Merge pull request #212 from achiya-automation/add-safari-harness

Add safari/agent-harness — Safari browser automation via safari-mcp
This commit is contained in:
Yuhao
2026-04-14 22:28:55 +08:00
committed by GitHub
26 changed files with 6494 additions and 0 deletions
+2
View File
@@ -219,6 +219,8 @@
!/exa/agent-harness/
!/n8n/agent-harness/
!/obsidian/agent-harness/
!/safari/
!/safari/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
+19
View File
@@ -844,6 +844,25 @@
"url": "https://github.com/dorukozgen"
}
]
},
{
"name": "safari",
"display_name": "Safari",
"version": "1.0.0",
"description": "Native macOS Safari browser automation via safari-mcp — 84 tools for navigation, DOM, forms, network capture, and screenshots",
"requires": "macOS, Safari, safari-mcp (npm install -g safari-mcp)",
"homepage": "https://github.com/achiya-automation/safari-mcp",
"source_url": null,
"install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=safari/agent-harness",
"entry_point": "cli-anything-safari",
"skill_md": "safari/agent-harness/cli_anything/safari/skills/SKILL.md",
"category": "web",
"contributors": [
{
"name": "achiya-automation",
"url": "https://github.com/achiya-automation"
}
]
}
]
}
+20
View File
@@ -0,0 +1,20 @@
# Python build artifacts
__pycache__/
*.py[cod]
*.egg-info/
*.egg
.eggs/
build/
dist/
.pytest_cache/
# Coverage / type-checker caches
.coverage
.mypy_cache/
.pyright/
.ruff_cache/
# Editor / OS
.DS_Store
.idea/
.vscode/
+357
View File
@@ -0,0 +1,357 @@
# Safari Harness: safari-mcp MCP Integration
> **Disclosure:** This harness was contributed by the maintainer of
> `safari-mcp`. It wraps the upstream MCP server as a CLI; no code
> from safari-mcp is vendored or modified.
## Purpose
This harness provides Safari browser automation on macOS by wrapping
[`safari-mcp`](https://github.com/achiya-automation/safari-mcp) — a
Node.js MCP server — in a Python Click CLI.
It follows the MCP backend pattern documented in
[`cli-anything-plugin/guides/mcp-backend.md`](../cli-anything-plugin/guides/mcp-backend.md).
## Architecture Overview
```
┌──────────────────────┐ ┌──────────────────────┐
│ Click command │────▶│ safari_backend.call │
│ (auto-generated │ │ (MCP stdio client) │
│ from tool schema) │ └───────────┬──────────┘
└──────────┬───────────┘ │
│ ▼
│ ┌──────────────────────┐
│ │ Spawn npx subprocess│
│ │ npx -y safari-mcp │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ safari-mcp server │
│ │ (Node.js, stdio) │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ Dual engine: │
│ │ 1. Safari Extension │
│ │ 2. AppleScript/Swift│
│ └──────────┬───────────┘
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────────┐
│ resources/ │ │ Safari (macOS) │
│ tools.json │ └──────────────────────┘
│ (84 schemas) │
└─────────────────┘
```
## Key Design Decision: Schema-Driven CLI
**Every Click command is auto-generated from the bundled MCP tool schema.**
Feature parity with safari-mcp is guaranteed because there is no manual
mapping between MCP tools and Click commands — the CLI reads the schema
at import time and registers one subcommand per tool, with option names,
types, descriptions, enum choices, and required flags pulled straight
from the source.
### Why not manual wrappers?
The DOMShell harness hand-wraps ~10 tools as explicit Python functions.
That works for a small tool surface but breaks down at scale:
- Safari MCP has **84 tools** — ~1,500 lines of manual boilerplate
- Argument names drift (`--source-selector` vs `sourceSelector`)
- Descriptions fall out of date with upstream
- New tools upstream require manual addition here
Instead, this harness ships a single dynamic command group and generates
every command at import time from `resources/tools.json`.
### How the schema is sourced
The schema is extracted **offline** from `safari-mcp`'s JavaScript source
by [`scripts/extract_tools.py`](scripts/extract_tools.py). The extractor
is a hand-written parser that walks the Zod schema definitions with
depth-aware modifier detection so nested schemas (`z.array(z.object({...}))
.describe("outer")`) don't confuse it.
Regenerate the schema whenever `safari-mcp` upgrades:
```bash
python scripts/extract_tools.py \
/path/to/safari-mcp/index.js \
cli_anything/safari/resources/tools.json
```
The parser produces JSON with a top-level `tool_count`, `source_version`,
`source_basename`, and a `tools` array. The `test_parity.py` suite pins
the expected tool count and locks the nested-schema shapes that were
previously miscounted.
## Parity Guarantee
Three layers enforce CLI ↔ MCP parity:
1. **Schema extraction**`extract_tools.py` parses every
`server.tool(...)` block in `safari-mcp`'s source and emits a JSON
Schema fragment per tool.
2. **Runtime generation**`safari_cli.py._register_all_tools()` loads
`tools.json` and calls `_build_tool_command(tool)` for every tool,
producing a Click command with options derived from the schema.
3. **Parity tests**`test_parity.py` holds the two halves accountable:
- Every tool in the registry must be reachable as a Click subcommand
- Every MCP parameter must have a matching Click option
- Required MCP params must be required in Click (covers all types,
including object/array)
- Enum choices must match exactly
- Plus regression locks for specific nested-schema bugs that the
parser previously got wrong
## Structure
```
safari/agent-harness/
├── HARNESS.md this file
├── setup.py find_namespace_packages + bundles tools.json
├── scripts/
│ └── extract_tools.py offline parser → tools.json
└── cli_anything/ PEP 420 namespace (NO __init__.py)
└── safari/
├── __init__.py
├── __main__.py python -m cli_anything.safari
├── README.md user-facing docs
├── safari_cli.py dynamic Click CLI
├── core/
│ └── session.py in-memory state (last URL, tab)
├── utils/
│ ├── safari_backend.py MCP stdio client (sync wrapper)
│ ├── security.py URL validation
│ ├── tool_registry.py loads tools.json, normalizes names
│ └── repl_skin.py (copied verbatim from plugin)
├── resources/
│ └── tools.json bundled MCP tool registry (84 tools)
├── skills/
│ └── SKILL.md agent-discovery manifest
└── tests/
├── test_core.py unit tests, no Safari required
├── test_security.py URL validation
├── test_parity.py CLI ↔ registry parity + regression locks
└── test_full_e2e.py CliRunner + subprocess E2E (gated by SAFARI_E2E)
```
## Command Structure
Five top-level commands:
| Command | Purpose |
|-----------|-----------------------------------------------------------------|
| `tool` | Call any safari-mcp tool by its short name |
| `tools` | Inspect the bundled registry (`list`, `describe`, `count`) |
| `raw` | Escape hatch — call a tool by full MCP name with JSON args |
| `session` | In-memory session state (last URL, current tab) |
| `repl` | Interactive REPL (default when run with no subcommand) |
The `tool` group contains exactly 84 subcommands, one per MCP tool, with
the `safari_` prefix stripped and underscores converted to hyphens:
```
tool navigate --url https://example.com
tool click --ref 0_5
tool scroll --direction down --amount 500
tool fill-form --fields '[{"selector":"#email","value":"a@b.com"}]'
```
## URL Validation
Navigation tools (anything with a `url` param whose name is literally
`"url"`) pass the URL through `utils/security.py` before calling MCP.
Blocked schemes include `file`, `javascript`, `data`, `vbscript`, `about`,
browser-internal schemes (`chrome:`, `safari:`, `webkit:`, `opera:`),
and `x-apple:`. Allowed schemes default to `http` and `https`.
The `raw` command **also** enforces this check for tools in the
navigation set. The set is computed dynamically at startup from the
registry so new URL-taking tools added upstream are automatically
protected.
### Configuration
- `CLI_ANYTHING_SAFARI_ALLOWED_SCHEMES` — comma-separated scheme list
- `CLI_ANYTHING_SAFARI_BLOCK_PRIVATE` — set to `1` to block private IPs
See [`cli_anything/safari/utils/security.py`](cli_anything/safari/utils/security.py)
for the full scheme and private-network lists.
## Error Handling
### Dependency Checks
```python
available, message = is_available()
if not available:
print(f"Error: {message}")
```
Error messages the CLI surfaces on startup:
- `Not macOS` → harness refuses; use DOMShell (Chrome) instead
- `npx not found` → install Node.js 18+
- `safari-mcp package not found on npm registry` → network/npm issue
### MCP Tool Failures
MCP tool failures raise `RuntimeError` with Safari-specific context
including the enable-Apple-Events reminder. Both the dynamic `tool`
commands and the `raw` command catch exceptions at the top of their
handlers and route them through `_handle_error`, which honors the global
`--json` flag and the REPL mode.
## Session State (Not Persistent)
`Session` keeps two in-memory fields for REPL display only:
- `last_url: str` — the last URL the CLI navigated to
- `current_tab_index: Optional[int]` — last known active tab index
There is no state persistence between CLI invocations and no daemon
mode. Daemon mode was considered and rejected because a sync Python
wrapper around an async MCP client cannot cleanly hold an `asyncio`
session across `asyncio.run()` calls without a background event-loop
thread — a v2 concern, not v1.
## Performance Characteristics
Each CLI invocation spawns a fresh `npx safari-mcp` subprocess. This
adds ~2.9s of overhead per call (npx resolution, Node startup, MCP
handshake). For latency-sensitive workflows, users should drive the
Python API directly (`from cli_anything.safari.utils.safari_backend
import call`) or use `safari-mcp` over MCP stdio.
The CLI targets use cases where MCP is not available: non-MCP agent
frameworks, bash pipelines, CI/cron, and terminal debugging.
## Testing Strategy
### Unit Tests (`tests/test_core.py`)
Unit tests for the backend helpers with mocked MCP calls. No Safari, no
network, no subprocess. Covers:
- Platform gating (Darwin-only)
- MCP result unwrapping (JSON vs raw text)
- Argument cleaning (None stripping)
- Session state
### Security Tests (`tests/test_security.py`)
Covers `validate_url` in isolation:
- Blocked schemes (`file`, `javascript`, `data`, `about`, `vbscript`,
`webkit`, `safari`)
- Malformed inputs (empty, whitespace, None, missing scheme/host)
- Enum-style scheme helpers
- Private-network env var behavior
### Parity Tests (`tests/test_parity.py`)
The linchpin for the "exactly like MCP" guarantee:
- Registry size pinned to 84
- Every tool reachable as a Click subcommand
- No unexpected Click subcommands not in the registry
- Every MCP param has a matching Click option
- Required MCP params are required in Click (**all** types, including
object/array — regression fix from early drafts that skipped those)
- Enum choices match exactly
- Introspection commands (`tools list/describe/count`) return the
expected shapes
- Regression locks for four specific nested-schema bugs that a past
version of the parser got wrong (see
`TestParityHighValueSchemas`)
### E2E Tests (`tests/test_full_e2e.py`)
Gated behind the `SAFARI_E2E` environment variable. The original
concern was that spawning `npx safari-mcp` would trigger the
singleton-killer branch (lines 22-49 of `~/safari-mcp/index.js`) and
terminate any active safari-mcp serving a concurrent Claude Code
session. In practice, **safari-mcp's proxy mode** (lines 479-526)
takes over before the killer fires when port 9224 is already bound,
and the primary survives — verified live during v1.0 testing. The
gate is kept as defensive cover and to avoid mutating Safari state
during a casual `pytest` run (some tests open or navigate tabs).
Five classes:
- `TestDependencyChecks``--help` works, all groups visible
- `TestSessionCommands` — session status via CliRunner
- `TestSecurityIntegration` — URL validation at the CLI boundary
- `TestRealSafariRoundTrip` — actually talks to Safari, mutates state
- `TestCLISubprocess` — invokes the installed `cli-anything-safari`
binary via `subprocess.run`, using `_resolve_cli()` to honor the
`CLI_ANYTHING_FORCE_INSTALLED` env var. `CLI_BASE` is a lazy class
property so collection does not fail when the command is missing
and E2E is disabled.
To run E2E locally (will kill any concurrent safari-mcp):
```bash
SAFARI_E2E=1 CLI_ANYTHING_FORCE_INSTALLED=1 \
python -m pytest cli_anything/safari/tests/test_full_e2e.py -v -s
```
## Performance
### Per-Command Overhead
Each command spawns a fresh `npx -y safari-mcp`:
- **Cold start**: 500ms2s on first run (npx resolution + package fetch)
- **Warm start**: ~200500ms (package cached)
There is no daemon mode. For latency-sensitive workflows, drive the
CLI from a long-lived Python script that imports
`cli_anything.safari.utils.safari_backend.call()` directly, which still
spawns per call but at least avoids the Python interpreter startup.
### Response Sizes
- `tool snapshot` → typically 550 KB of structured text
- `tool screenshot --full-page` → 100 KB several MB (image)
- `tool get-source` → up to 200 KB (configurable `--max-length`)
**Prefer `tool snapshot` over `tool screenshot`** — structured text is
orders of magnitude smaller and carries the ref IDs needed for
interaction.
## Future Enhancements
**Not in scope for v1:**
- Daemon mode (requires a background event-loop thread)
- Multi-browser coverage (Firefox via WebDriver BiDi)
- WebSocket transport (currently stdio)
- Headless Safari mode (doesn't exist on macOS)
- Recursive array/object item schema extraction in the parser (current
output carries the outer `.describe()` text but not the nested shape)
- Persistent state across CLI invocations
## Applying This Pattern
The MCP backend pattern with schema-driven Click generation can be
applied to any software that exposes an MCP server. Steps:
1. Identify the MCP server and count its tool surface
2. Write or adapt `extract_tools.py` for the server's source format
3. Generate a `tools.json` and bundle it as `package_data`
4. Create a `tool_registry.py` that loads and normalizes the schema
5. Register a dynamic Click group that walks the registry
6. Add URL / path / file validation hooks for state-changing tools
7. Write parity tests that compare the CLI surface against the registry
with regression locks for any nested-schema quirks
8. Add SKILL.md with examples drawn from the most common tools
## References
- [safari-mcp GitHub](https://github.com/achiya-automation/safari-mcp)
- [safari-mcp on npm](https://www.npmjs.com/package/safari-mcp)
- [CLI-Anything plugin HARNESS.md](../cli-anything-plugin/HARNESS.md)
- [MCP Backend Pattern Guide](../cli-anything-plugin/guides/mcp-backend.md)
- [Sibling: browser/agent-harness (DOMShell / Chrome)](../browser/agent-harness/)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
+256
View File
@@ -0,0 +1,256 @@
# SAFARI.md — Software-Specific Analysis
> **Disclosure:** This harness was contributed by the maintainer of
> `safari-mcp`.
Software: **[safari-mcp](https://github.com/achiya-automation/safari-mcp)** (npm: `safari-mcp`)
Target: **Safari on macOS** via safari-mcp's Node.js MCP server.
This document covers the Phase 1 codebase analysis, command map, and
rendering-gap assessment required by the CLI-Anything methodology
(see [`../cli-anything-plugin/HARNESS.md`](../cli-anything-plugin/HARNESS.md)).
---
## 1. Codebase Analysis
### Backend engine
Safari MCP is a Node.js MCP server that wraps Safari on macOS. It has
two execution engines:
1. **Safari Web Extension** — HTTP polling transport, loaded when the
extension is installed.
2. **AppleScript + Swift daemon** — always available, no extension
required. Default path for most users.
The server uses `extensionOrFallback()` internally to select the
engine per call — the CLI does not need to expose engine selection.
### Multi-instance behavior (proxy mode)
`safari-mcp` defends against duplicate instances in two complementary
ways (see `~/safari-mcp/index.js`):
1. **Singleton check** (lines 22-49) — at startup, `pgrep` for other
`node …/safari-mcp/index.js` processes; SIGTERM any older than 10s.
2. **Proxy mode** (lines 479-526) — when starting fresh and finding
port 9224 already bound (because the singleton check didn't fire,
typically because the primary is owned by another agent's process
tree), the new instance switches to proxy mode and forwards all
commands to the primary via HTTP. The primary keeps running.
In practice, when this CLI runs alongside an existing safari-mcp (e.g.
one serving Claude Code), proxy mode kicks in within ~2 seconds and
the primary survives. This was verified live during v1.0 testing —
all 19 E2E tests pass with the user's existing safari-mcp instance
still alive afterward.
### Transport
`safari-mcp` exposes its tools over **stdio** (`StdioServerTransport`),
which is the transport this harness uses. The server does not publish
an HTTP or WebSocket interface.
### Data model
Safari MCP is **stateless from a document perspective** — there are no
project files, no scenes, no timelines. Every tool call acts on the
**currently active Safari tab** (with per-session tab ownership
enforcement) and returns its result as JSON text wrapped in MCP's
standard `{content: [{type:"text", text: ...}]}` envelope.
The closest thing to "state" is:
- **Tab set** — which tabs exist, which is active
- **Snapshot refs** — ref IDs returned by `safari_snapshot` that expire
on the next snapshot (`0_xx → 1_xx → 2_xx...`)
- **Session tab ownership** — safari-mcp tracks which tabs the current
MCP session opened, and blocks modifications to tabs the session did
not open (prevents an agent from mangling the user's active work)
### GUI-to-API mapping
Safari MCP already IS the API. There is no GUI-to-API mapping phase for
this harness — the server's `server.tool()` calls are the API, and we
extract them directly with
[`scripts/extract_tools.py`](scripts/extract_tools.py) rather than
reverse-engineering GUI actions.
### Existing CLI tools
Safari MCP has **no native CLI**. It is a pure MCP stdio server — it
does not respond to `--version` or `--help` on the command line. Our
availability check uses `npm view safari-mcp version` instead of
`npx safari-mcp --version` because the latter would hang waiting for
stdin.
### Command/undo system
Safari MCP has no undo/redo. Browser actions are inherently
imperative and irreversible (clicks can't be un-clicked). The CLI
inherits this and does not attempt to fake history.
---
## 2. Command Map
Safari MCP exposes **84 tools**. The CLI generates one Click subcommand
per tool automatically from the bundled tool schema — there is **no
hand-written mapping**. Run `cli-anything-safari tools list` for the
complete set; the short table below groups them by purpose.
| Purpose | Representative tools |
|--------------------------------|--------------------------------------------------------------|
| Navigation | `navigate`, `go-back`, `go-forward`, `reload` |
| Page content | `read-page`, `snapshot`, `get-source`, `accessibility-snapshot`, `extract-tables`, `extract-meta`, `extract-links`, `extract-images`, `analyze-page`, `detect-forms` |
| Click | `click`, `click-and-read`, `click-and-wait`, `double-click`, `right-click`, `native-click` |
| Form input | `fill`, `clear-field`, `select-option`, `fill-form`, `fill-and-submit`, `press-key`, `type-text`, `replace-editor` |
| Screenshots + PDF | `screenshot`, `screenshot-element`, `save-pdf` |
| Scroll | `scroll`, `scroll-to`, `scroll-to-element` |
| Tab management | `list-tabs`, `new-tab`, `close-tab`, `switch-tab`, `wait-for-new-tab` |
| Waits | `wait`, `wait-for` |
| JavaScript | `evaluate`, `run-script` |
| Storage — cookies | `get-cookies`, `set-cookie`, `delete-cookies` |
| Storage — localStorage | `local-storage`, `set-local-storage`, `delete-local-storage` |
| Storage — sessionStorage | `session-storage`, `set-session-storage`, `delete-session-storage` |
| Storage — IndexedDB | `list-indexed-dbs`, `get-indexed-db` |
| Storage — import/export | `export-storage`, `import-storage` |
| Network monitoring | `network`, `start-network-capture`, `network-details`, `clear-network` |
| Network shaping | `mock-route`, `clear-mocks`, `throttle-network` |
| Performance | `performance-metrics`, `css-coverage` |
| Console | `start-console`, `get-console`, `console-filter`, `clear-console` |
| Mouse / drag | `hover`, `drag` |
| Files | `upload-file`, `paste-image` |
| Dialogs | `handle-dialog` |
| Clipboard | `clipboard-read`, `clipboard-write` |
| Device / viewport | `emulate`, `reset-emulation`, `resize`, `override-geolocation` |
| Computed style | `get-computed-style` |
| Single-element read | `get-element`, `query-all` |
Every one of these is reachable as `cli-anything-safari tool <short-name>`
with the full MCP schema driving the Click options (argument names,
types, enum choices, required/optional, and descriptions).
---
## 3. Rendering-Gap Assessment
**Status: N/A.**
The "rendering gap" pitfall in HARNESS.md applies to apps where the CLI
builds a project file (MLT XML, ODF, .blend, etc.) and then has to hand
it off to a renderer. Browser automation has no rendering step — every
tool call is synchronous and its output is the final answer.
Safari MCP IS the renderer. We call it, it runs the action against the
real Safari, and the result is final. There is no intermediate project
format for us to translate.
## 4. Filter Translation
**Status: N/A.**
No effect/filter system in browser automation. The
[`filter-translation.md`](../cli-anything-plugin/guides/filter-translation.md)
guide does not apply.
## 5. Timecode Precision
**Status: N/A.**
No video/audio in browser automation. The
[`timecode-precision.md`](../cli-anything-plugin/guides/timecode-precision.md)
guide does not apply.
## 6. Session Locking
**Status: N/A (no persistent session).**
Unlike document-based harnesses, this harness does not persist session
state to disk. The [`session-locking.md`](../cli-anything-plugin/guides/session-locking.md)
pattern (`_locked_save_json` with `fcntl.flock`) is not needed because
there are no session JSON saves. The in-memory `Session` object holds
only the last URL and current tab index for REPL display, both of
which are reset on every CLI invocation.
---
## 7. "Use the Real Software" Compliance
HARNESS.md's #1 rule: **Use the real software — don't reimplement it.**
This harness complies: every `cli-anything-safari tool <name>` call
spawns `npx -y safari-mcp` as a subprocess and routes the call through
the real safari-mcp server, which in turn drives the real Safari
application. There is no pure-Python fallback, no "mock Safari", and no
attempt to reimplement DOM interaction in Python.
- **Hard dependency:** Node.js 18+, macOS (Darwin), Safari. The
`is_available()` check refuses to run if any of these are missing and
prints install instructions.
- **No graceful degradation:** Tool calls raise `RuntimeError` with
clear install instructions if safari-mcp cannot be spawned or Safari
is not reachable.
- **No reimplementation:** Every tool call goes through the MCP stdio
client → safari-mcp → Safari. The CLI owns presentation (Click
commands, REPL, JSON output, URL validation) and nothing else.
---
## 8. Validator Checklist — Why Some Items Are N/A
The [`validate.md`](../cli-anything-plugin/commands/validate.md) checklist
lists a few "required files" that do not apply to this harness:
| Required by validate.md | Status | Reason |
|--------------------------|-----------|----------------------------------------------------|
| `core/project.py` | N/A | No project file format — browser is stateless |
| `core/export.py` | N/A | No rendering step — safari-mcp IS the backend |
| `--project` CLI flag | N/A | No project file to operate on |
| Session undo/redo | N/A | Browser actions are irreversible by design |
| Session snapshot | N/A | No document state to snapshot |
| Filter translation | N/A | No effect pipeline |
| Rendering verification | N/A | No rendered output to verify |
| Session locking | N/A | No persistent session state |
The sibling DOMShell harness (`browser/agent-harness/`) makes the same
choices — it has `core/fs.py` and `core/page.py` instead of
`core/project.py` and `core/export.py`, because the "project/export"
pattern is for document-based apps (LibreOffice, GIMP, Blender,
Shotcut), not for interactive browsers.
---
## 9. Architecture Decisions Unique to This Harness
Unlike every other CLI-Anything harness, this one is **schema-driven**:
1. **Schema extraction** (offline): [`scripts/extract_tools.py`](scripts/extract_tools.py)
parses safari-mcp's Zod source with a depth-aware scanner and
produces [`cli_anything/safari/resources/tools.json`](cli_anything/safari/resources/tools.json).
2. **Dynamic Click registration**: [`cli_anything/safari/safari_cli.py`](cli_anything/safari/safari_cli.py)
loads `tools.json` at import time and calls `_build_tool_command`
for every tool, producing a Click command with option names, types,
choices, and required/optional flags pulled from the schema.
3. **Parity test**: [`cli_anything/safari/tests/test_parity.py`](cli_anything/safari/tests/test_parity.py)
iterates the registry and verifies every tool is reachable via
Click with the correct shape. It pins the expected tool count (84)
so upstream drift fails loudly.
The alternative — hand-wrapping 84 tools in Python — would be ~1,500
lines of boilerplate that duplicates schema information already
present in safari-mcp's source. The schema-driven approach stays
correct without manual synchronization when safari-mcp adds tools.
---
## 10. References
- [safari-mcp GitHub](https://github.com/achiya-automation/safari-mcp)
- [safari-mcp on npm](https://www.npmjs.com/package/safari-mcp)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [CLI-Anything HARNESS.md](../cli-anything-plugin/HARNESS.md)
- [MCP Backend Pattern Guide](../cli-anything-plugin/guides/mcp-backend.md)
- [Sibling: browser/agent-harness (DOMShell/Chrome)](../browser/agent-harness/)
- [Local HARNESS.md](HARNESS.md) — harness-specific deep dive
@@ -0,0 +1,178 @@
# cli-anything-safari
A command-line interface for Safari browser automation on macOS via
[`safari-mcp`](https://github.com/achiya-automation/safari-mcp).
Every one of the 84 MCP tools is auto-generated as a Click command
from the bundled tool schema.
> **Note:** Each CLI invocation spawns a fresh subprocess. If your
> agent supports MCP natively, using `safari-mcp` directly will be
> faster. This CLI targets non-MCP agent frameworks, bash pipelines,
> CI/cron, and terminal debugging.
## Installation
### Prerequisites
1. **macOS** (Darwin) — Safari MCP is macOS-only
2. **Node.js 18+**`brew install node` or https://nodejs.org/
3. **Python 3.10+**
4. **Safari** with `Develop → Allow JavaScript from Apple Events` enabled
### Install
```bash
cd safari/agent-harness
pip install -e .
```
The first `tool` call downloads the `safari-mcp` npm package (a few MB).
## Quick Start
```bash
# Discover the tool surface
cli-anything-safari tools count
# → 84
cli-anything-safari tools list
cli-anything-safari tools describe safari_click
# Call any tool
cli-anything-safari tool navigate --url https://example.com
cli-anything-safari --json tool snapshot
cli-anything-safari tool click --ref 0_5
cli-anything-safari tool fill --selector "#email" --value "user@example.com"
cli-anything-safari --json tool screenshot --full-page \
| python3 -c "import sys,json,base64; d=json.load(sys.stdin); open('/tmp/shot.jpg','wb').write(base64.b64decode(d['data']))"
cli-anything-safari tool evaluate --script "document.title"
# Interactive REPL
cli-anything-safari
```
## Command Structure
| Command | Purpose |
|-----------|-------------------------------------------------------------------|
| `tool` | Call any of safari-mcp's 84 tools (dynamic, schema-driven) |
| `tools` | Inspect the bundled tool registry (`list`, `describe`, `count`) |
| `raw` | Escape hatch — call a tool by full name with raw JSON args |
| `session` | In-memory session state (last URL, current tab) |
| `repl` | Interactive REPL (default when no subcommand given) |
Run `cli-anything-safari <command> --help` for details.
## JSON Output
```bash
cli-anything-safari --json tool snapshot
cli-anything-safari --json tools list
```
## Environment Variables
Passed through to `safari-mcp`:
| Variable | Purpose |
|-------------------------|----------------------------------------------|
| `SAFARI_PROFILE` | Safari profile name (e.g. "Automation") |
| `MCP_MAX_TABS` | Max tabs per session (default 6) |
| `MCP_MEMORY_CHECK_MS` | Memory check interval (default 60000) |
| `MCP_WEBKIT_LIMIT_MB` | WebKit memory limit (default 3000) |
Consumed by the CLI itself:
| Variable | Purpose |
|---------------------------------------|-------------------------------------|
| `CLI_ANYTHING_SAFARI_BLOCK_PRIVATE` | Set to `1` to block private IPs |
| `CLI_ANYTHING_SAFARI_ALLOWED_SCHEMES` | Override allowed URL schemes |
| `CLI_ANYTHING_FORCE_INSTALLED` | Test mode: require installed CLI |
## Snapshot-Driven Workflow (Recommended)
Snapshots return structured text with **ref IDs** for every interactive
element. Clicking by ref is cheaper and more reliable than by CSS selector.
```bash
cli-anything-safari --json tool snapshot > /tmp/snap.json
# Agent reads /tmp/snap.json, finds "Submit" button with ref "3_12"
cli-anything-safari tool click --ref 3_12
```
**Refs expire** after each new snapshot (`5_xx → 6_xx`). Snapshot → click in
close succession.
## Troubleshooting
### "npx not found"
Install Node.js 18+: `brew install node`.
### "safari-mcp package not found on npm registry"
Check your internet connection, then try:
```bash
npm view safari-mcp version
```
### "AppleScript execution failed"
Enable `Safari → Develop → Allow JavaScript from Apple Events`.
### "Tool cannot operate on tab it did not open"
This is the tab ownership guard. Open a fresh tab first:
```bash
cli-anything-safari tool new-tab --url https://example.com
cli-anything-safari tool click --selector "#button"
```
## Security
- **Tab isolation** — upstream safari-mcp enforces per-session tab ownership
- **URL validation** — navigation tools validate URLs and block dangerous
schemes (`file`, `javascript`, `data`, `about`, browser-internal, etc.)
both through the `tool` group and via the `raw` escape hatch
- **Profile separation** — use `SAFARI_PROFILE` to keep automation data
separate from the user's main browsing
### ⚠️ Singleton-killer warning
Safari MCP enforces a single active instance by killing any other
`node …/safari-mcp/index.js` process older than 10 seconds at startup.
This means **running `cli-anything-safari` (or any other safari-mcp
client) will terminate any concurrent safari-mcp instance** — including
one serving Claude Code, Cursor, or another agent session on the same
machine. Plan your usage accordingly:
- Don't run two CLI invocations in parallel from different shells
- Don't run this CLI while another agent (Claude Code, etc.) is
actively using safari-mcp via MCP transport
- The E2E test suite is gated behind `SAFARI_E2E=1` precisely because
running it would kill any active safari-mcp instance
## Regenerating the tool registry
After upgrading `safari-mcp`, regenerate the bundled schema:
```bash
python scripts/extract_tools.py \
/path/to/safari-mcp/index.js \
cli_anything/safari/resources/tools.json
```
Then run `python -m pytest cli_anything/safari/tests/test_parity.py`
update the pinned tool count if safari-mcp changed.
## Links
- [safari-mcp GitHub](https://github.com/achiya-automation/safari-mcp)
- [safari-mcp on npm](https://www.npmjs.com/package/safari-mcp)
- [CLI-Anything](https://github.com/HKUDS/CLI-Anything)
- [Harness architecture deep-dive](https://github.com/HKUDS/CLI-Anything/blob/main/safari/agent-harness/HARNESS.md)
- [Safari-specific analysis](https://github.com/HKUDS/CLI-Anything/blob/main/safari/agent-harness/SAFARI.md)
- [Test plan & results](https://github.com/HKUDS/CLI-Anything/blob/main/safari/agent-harness/cli_anything/safari/tests/TEST.md)
@@ -0,0 +1,3 @@
"""cli-anything-safari — CLI harness for Safari browser automation."""
__version__ = "1.0.0"
@@ -0,0 +1,4 @@
from cli_anything.safari.safari_cli import main
if __name__ == "__main__":
main()
@@ -0,0 +1,33 @@
"""Session state for Safari CLI.
Safari MCP is stateless per call — each MCP invocation starts a fresh
process. The CLI keeps a tiny amount of in-memory state for REPL display
only:
- last_url: last URL the CLI navigated to (for the REPL prompt context)
- current_tab_index: last known active tab index (for the REPL prompt)
There is no filesystem-tree abstraction like DOMShell — Safari MCP works
with tabs and refs from snapshots.
"""
from dataclasses import dataclass
from typing import Optional
@dataclass
class Session:
current_tab_index: Optional[int] = None
last_url: str = ""
def set_url(self, url: str) -> None:
self.last_url = url
def set_tab(self, index: int) -> None:
self.current_tab_index = index
def status(self) -> dict:
return {
"last_url": self.last_url or "(no navigation yet)",
"current_tab_index": self.current_tab_index,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,626 @@
#!/usr/bin/env python3
"""Safari CLI — Command-line interface for Safari browser automation via safari-mcp.
Wraps the `safari-mcp` Node.js MCP server in a Python Click CLI so that any
agent framework (not just MCP clients) can drive Safari on macOS.
**Feature parity with the original MCP is guaranteed** by bundling the tool
schema (generated offline from safari-mcp's source) and building every Click
command dynamically from it. Every tool and every argument safari-mcp exposes
is reachable here with the same name and type.
Usage:
# One-shot commands (every tool exposed as 'tool <short-name>')
cli-anything-safari tool navigate --url https://example.com
cli-anything-safari tool snapshot
cli-anything-safari tool click --ref 0_5
cli-anything-safari tool scroll --direction down --amount 500
cli-anything-safari --json tool read-page
# Introspection
cli-anything-safari tools list
cli-anything-safari tools describe safari_click
# Interactive REPL
cli-anything-safari
# Raw escape hatch for anything the schema-driven path can't express
cli-anything-safari raw safari_evaluate --json-args '{"script":"document.title"}'
"""
from __future__ import annotations
import json
import shlex
import sys
from typing import Any, Optional
import click
from cli_anything.safari.core.session import Session
from cli_anything.safari.utils import safari_backend as backend
from cli_anything.safari.utils import security as security_mod
from cli_anything.safari.utils.tool_registry import (
ToolParam,
ToolSchema,
coerce_arg_value,
load_registry,
)
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
_availability_cached: Optional[tuple[bool, str]] = None
# Tools whose `url` argument is a navigation target and must be validated
# through the security layer. Populated by ``_register_all_tools()`` at
# import time from the bundled registry, so new URL-taking tools added
# upstream are picked up automatically.
#
# Frozenset after registration so accidental mutation downstream raises.
_URL_VALIDATED_TOOLS: frozenset[str] = frozenset()
def _compute_url_validated_tools(registry) -> frozenset[str]:
"""Find every tool with a `url` param that takes a navigation target.
Heuristic: a param whose MCP name is literally ``"url"`` (not
``urlPattern`` or similar) and type ``string`` is a navigation
target. ``mock_route``'s ``urlPattern`` is a regex/substring
pattern, not a target, and is correctly excluded.
"""
result: set[str] = set()
for tool in registry:
for p in tool.params:
if p.name == "url" and p.type == "string":
result.add(tool.name)
break
return frozenset(result)
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def output(data, message: str = ""):
if _json_output:
click.echo(json.dumps(data, indent=2, default=str, ensure_ascii=False))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
elif data is not None:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def _handle_error(e: Exception):
"""Uniform error reporting that respects --json and REPL mode."""
err_type = type(e).__name__
if _json_output:
click.echo(json.dumps({"error": str(e), "type": err_type}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
def handle_error(func):
"""Decorator that funnels exceptions through ``_handle_error``.
Applied to ``tools``, ``raw``, and ``session`` commands so that any
uncaught ``RuntimeError``, ``ValueError``, ``OSError``, or
``ClickException`` (the base class covering ``UsageError``,
``BadParameter``, ``BadOptionUsage``, ``MissingParameter``,
``FileError``, ``BadArgumentUsage``) is reported through the
uniform error path (respects ``--json`` and REPL mode).
The dynamically-built ``tool`` commands catch their own exceptions
inline because they need per-parameter JSON-decode handling that a
generic decorator cannot express.
"""
import functools
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except (RuntimeError, ValueError, IndexError,
OSError, json.JSONDecodeError,
click.exceptions.ClickException) as e:
_handle_error(e)
return wrapper
def _validate_url_or_exit(url: str) -> None:
"""Validate a URL and abort the current command if it's unsafe.
In non-REPL mode this calls ``_handle_error`` which ``sys.exit(1)``s.
In REPL mode it raises ``click.exceptions.UsageError`` so the REPL
loop can report the error once and continue. The caller should
propagate the raise — nothing downstream should run if the URL is
bad.
"""
ok, err = security_mod.validate_url(url)
if ok:
return
if _repl_mode:
raise click.exceptions.UsageError(err)
_handle_error(click.exceptions.UsageError(err))
return
# Subcommands that operate on bundled metadata and never touch safari-mcp.
_INTROSPECTION_SUBCOMMANDS = {"tools"}
# ── Main CLI group ─────────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.pass_context
def cli(ctx, use_json):
"""Safari CLI — Browser automation on macOS via safari-mcp.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output, _session, _availability_cached
_json_output = use_json
# Click's --help support short-circuits before the group body runs,
# so we only need to skip the availability probe for commands that
# work off the bundled registry (tools list|describe|count).
skip_probe = ctx.invoked_subcommand in _INTROSPECTION_SUBCOMMANDS
if not skip_probe:
if _availability_cached is None:
_availability_cached = backend.is_available()
available, msg = _availability_cached
if not available:
if _json_output:
click.echo(json.dumps({"error": msg, "type": "dependency_error"}))
else:
click.echo(f"Error: {msg}", err=True)
click.echo(
"\nDocs: https://github.com/achiya-automation/safari-mcp"
)
sys.exit(1)
_session = get_session()
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Dynamic tool group — registers one Click command per MCP tool ─
@cli.group("tool")
def tool_group():
"""Call any of safari-mcp's 84 tools.
Every MCP tool is exposed here with its full schema. Use
``cli-anything-safari tools list`` to see them, and
``cli-anything-safari tools describe <name>`` for full details.
"""
def _click_type_for_param(param: ToolParam):
"""Map a JSON Schema type to a Click ParamType."""
base = {
"string": click.STRING,
"integer": click.INT,
"number": click.FLOAT,
"boolean": click.BOOL,
}.get(param.type, click.STRING)
if param.choices and param.type in ("string", "integer"):
return click.Choice(param.choices, case_sensitive=False)
return base
def _build_tool_command(tool: ToolSchema):
"""Build a Click command for a single MCP tool from its schema."""
def run(**kwargs):
# Convert kebab-case kwargs back to camelCase MCP names and coerce types.
# Object/array params arrive as JSON strings and are decoded here;
# a decode error is reported to the user via _handle_error rather
# than bubbling up as an ugly traceback.
args: dict[str, Any] = {}
for param in tool.params:
value = kwargs.get(_click_param_name(param.cli_name))
if value is None:
# Click's `required=True` covers most types, but boolean
# flag pairs (--foo/--no-foo) cannot be marked required
# at the Click level. We enforce here.
if param.required and param.type == "boolean":
_handle_error(
click.exceptions.UsageError(
f"Missing required boolean flag: "
f"--{param.cli_name} or --no-{param.cli_name}"
)
)
return
continue
try:
args[param.name] = coerce_arg_value(param, value)
except json.JSONDecodeError as e:
_handle_error(
click.exceptions.UsageError(
f"Invalid JSON for --{param.cli_name}: {e}"
)
)
return
# URL safety for navigation tools. _validate_url_or_exit either
# exits (non-REPL) or raises UsageError (REPL). Either way we
# abort here before calling the MCP backend.
if tool.name in _URL_VALIDATED_TOOLS and args.get("url"):
_validate_url_or_exit(args["url"])
try:
result = backend.call(tool.name, **args)
except Exception as e:
_handle_error(e)
return
# Track URL for REPL context (only after a successful call).
if tool.name in _URL_VALIDATED_TOOLS and args.get("url"):
get_session().set_url(args["url"])
output(result)
# Apply Click options, in reverse so decorator order matches param order.
decorated = run
for param in reversed(tool.params):
help_text = param.description or ""
if param.default is not None:
help_text = f"{help_text} (default: {param.default})".strip()
if param.type == "boolean":
# Required booleans need an explicit default (Click can't enforce
# `required=True` on a boolean flag pair). For optional booleans
# we use `default=None` so the arg is omitted from the MCP call
# when the user doesn't pass --foo or --no-foo.
bool_default = None
if param.required:
# No safe default for a required boolean — force the user
# to pass --foo or --no-foo. Click doesn't have a "required
# boolean flag" concept, so we approximate by leaving
# default=None and validating in the runner below.
bool_default = None
decorated = click.option(
f"--{param.cli_name}/--no-{param.cli_name}",
default=bool_default,
help=help_text,
)(decorated)
elif param.type in ("object", "array"):
decorated = click.option(
f"--{param.cli_name}",
type=click.STRING,
required=param.required,
help=(
help_text + f" [JSON {param.type}]"
if help_text
else f"[JSON {param.type}]"
).strip(),
)(decorated)
else:
decorated = click.option(
f"--{param.cli_name}",
type=_click_type_for_param(param),
required=param.required,
help=help_text,
)(decorated)
decorated.__doc__ = tool.description or f"Call {tool.name}."
cmd = click.command(
name=tool.short_name,
help=tool.description or f"Call {tool.name}.",
)(decorated)
return cmd
def _click_param_name(cli_name: str) -> str:
"""Click normalizes option names to underscores for the handler kwarg."""
return cli_name.replace("-", "_")
def _register_all_tools():
"""Load the bundled registry and register every tool as a subcommand.
Populates ``_URL_VALIDATED_TOOLS`` BEFORE registering any commands so
no command can be invoked while the validation set is empty.
"""
global _URL_VALIDATED_TOOLS
try:
registry = load_registry()
except FileNotFoundError:
click.echo(
"Warning: bundled tool registry (resources/tools.json) is missing. "
"Run: python scripts/extract_tools.py <safari-mcp>/index.js "
"cli_anything/safari/resources/tools.json",
err=True,
)
return
# Compute the validation set first so any registered command sees it.
_URL_VALIDATED_TOOLS = _compute_url_validated_tools(registry)
for tool in registry:
cmd = _build_tool_command(tool)
tool_group.add_command(cmd)
_register_all_tools()
# ── tools group — introspection over the bundled registry ────────
@cli.group("tools")
def tools_group():
"""Inspect the bundled safari-mcp tool registry."""
@tools_group.command("list")
@click.option("--filter", "pattern", default="", help="Substring to filter tool names")
@handle_error
def tools_list(pattern):
"""List every safari-mcp tool available to the CLI."""
registry = load_registry()
if _json_output:
data = [
{
"name": t.name,
"short_name": t.short_name,
"description": t.description,
"param_count": len(t.params),
}
for t in registry
if pattern.lower() in t.name.lower()
]
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
return
count = 0
for t in registry:
if pattern.lower() not in t.name.lower():
continue
count += 1
desc = (t.description or "").split("\n", 1)[0]
if len(desc) > 80:
desc = desc[:77] + "..."
click.echo(f" {t.short_name:<30} {desc}")
click.echo()
click.echo(
f"{count} tool(s) shown (registry version: {registry.source_version})"
)
@tools_group.command("describe")
@click.argument("tool_name")
@handle_error
def tools_describe(tool_name):
"""Show the full schema for a single tool."""
registry = load_registry()
tool = registry.get(tool_name) or registry.get_short(tool_name)
if not tool:
_handle_error(
click.exceptions.UsageError(
f"Unknown tool: {tool_name}. "
f"Use 'tools list' to see available tools."
)
)
return
if _json_output:
click.echo(
json.dumps(
{
"name": tool.name,
"short_name": tool.short_name,
"description": tool.description,
"params": [
{
"name": p.name,
"cli_name": p.cli_name,
"type": p.type,
"description": p.description,
"required": p.required,
"default": p.default,
"choices": p.choices,
}
for p in tool.params
],
},
indent=2,
ensure_ascii=False,
)
)
return
click.echo(f"Name: {tool.name}")
click.echo(f"CLI command: tool {tool.short_name}")
click.echo(f"Description: {tool.description}")
if not tool.params:
click.echo("Parameters: (none)")
return
click.echo("Parameters:")
for p in tool.params:
req = "required" if p.required else "optional"
extra = f" [choices: {p.choices}]" if p.choices else ""
default = f" [default: {p.default}]" if p.default is not None else ""
click.echo(f" --{p.cli_name} ({p.type}, {req}){extra}{default}")
if p.description:
click.echo(f" {p.description}")
@tools_group.command("count")
@handle_error
def tools_count():
"""Print the number of tools in the bundled registry (for scripts)."""
registry = load_registry()
if _json_output:
click.echo(json.dumps({"tool_count": len(registry)}))
else:
click.echo(str(len(registry)))
# ── raw command — escape hatch for arbitrary tool calls ──────────
@cli.command()
@click.argument("tool_name")
@click.option(
"--json-args", default="{}",
help="JSON string of arguments to pass to the MCP tool",
)
@handle_error
def raw(tool_name, json_args):
"""Call any safari-mcp tool directly by name.
This bypasses the schema-driven 'tool' group — useful when you have
a pre-built JSON args blob or when testing new tools.
Example:
cli-anything-safari raw safari_evaluate \\
--json-args '{"script":"document.title"}'
"""
try:
args = json.loads(json_args)
except json.JSONDecodeError as e:
_handle_error(
click.exceptions.UsageError(f"Invalid JSON for --json-args: {e}")
)
return
if not isinstance(args, dict):
_handle_error(
click.exceptions.UsageError(
"--json-args must decode to a JSON object, "
f"got {type(args).__name__}"
)
)
return
# Even via raw, still run URL validation for navigation tools
if tool_name in _URL_VALIDATED_TOOLS and args.get("url"):
_validate_url_or_exit(args["url"])
try:
result = backend.call(tool_name, **args)
except Exception as e:
_handle_error(e)
return
output(result)
# ── session command ───────────────────────────────────────────────
@cli.group()
def session():
"""Session state (last URL, current tab)."""
@session.command("status")
@handle_error
def session_status():
"""Show current session state."""
output(get_session().status())
# ── REPL ──────────────────────────────────────────────────────────
@cli.command()
def repl():
"""Start interactive REPL session."""
from cli_anything.safari.utils.repl_skin import ReplSkin
global _repl_mode
_repl_mode = True
skin = ReplSkin("safari", version="1.0.0")
skin.print_banner()
pt_session = skin.create_prompt_session()
repl_commands = {
"tool <name>": "Call any safari-mcp tool (use 'tools list' for names)",
"tools list": "List all available tools",
"tools describe <name>": "Show full schema for a tool",
"raw <name>": "Call a tool via JSON args",
"session status": "Show current session state",
"help": "Show this help",
"quit": "Exit REPL",
}
while True:
try:
sess = get_session()
context = ""
if sess.last_url:
url_display = (
sess.last_url[:40] + "..."
if len(sess.last_url) > 40
else sess.last_url
)
context = url_display
if sess.current_tab_index is not None:
context = f"tab{sess.current_tab_index} {url_display}"
line = skin.get_input(pt_session, context=context)
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
skin.help(repl_commands)
continue
try:
args = shlex.split(line)
except ValueError:
args = line.split()
try:
cli.main(args, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.warning(f"Usage error: {e}")
except Exception as e:
skin.error(f"{e}")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
_repl_mode = False
# ── Entry point ───────────────────────────────────────────────────
def main():
cli()
if __name__ == "__main__":
main()
@@ -0,0 +1,395 @@
---
name: >-
cli-anything-safari
description: >-
Safari browser automation CLI on macOS via safari-mcp. Controls real Safari
(native, keeps logins) by wrapping the safari-mcp MCP server. Every one of
the 84 MCP tools is exposed 1:1 with schema-accurate arguments — guaranteed
parity, no manual drift.
---
# cli-anything-safari
A command-line interface for Safari browser automation on macOS. Wraps the
[`safari-mcp`](https://github.com/achiya-automation/safari-mcp) Node.js MCP
server in a Python Click CLI.
**Feature parity is guaranteed.** Every Click command is generated
automatically from `safari-mcp`'s tool schema (bundled as
`resources/tools.json`). All 84 tools are reachable with the exact
argument names and types the MCP server expects.
## When to use this CLI
Each CLI invocation spawns a fresh subprocess, so there is per-call
overhead. If your agent speaks MCP natively (Claude Code, Cursor, Cline,
etc.), using `safari-mcp` directly over MCP stdio will be faster.
**Use this CLI when:**
- Your agent framework does **not** speak MCP (Codex CLI, GitHub Copilot
CLI, custom scripts, older agent frameworks).
- You need to **script browser automation from bash**
`cli-anything-safari --json tool snapshot | jq '...'`.
- You run in **CI/CD** and want cron-able, subprocess-friendly output.
- You're **debugging interactively** from Terminal.
## Installation
### Prerequisites
1. **macOS** — Safari MCP is macOS-only.
2. **Safari** — already installed on macOS.
3. **Node.js 18+**`brew install node` or from https://nodejs.org/
4. **Python 3.10+**
5. **Enable Apple Events for Safari**: Safari → Develop → Allow JavaScript from Apple Events
### Install the CLI
```bash
cd safari/agent-harness
pip install -e .
```
The first `tool` call will download the `safari-mcp` npm package (one-time, a few MB).
## Command Structure
The CLI has 5 top-level commands:
| Command | Purpose |
|-----------|-------------------------------------------------------------------|
| `tool` | Call any of safari-mcp's **84 tools** (dynamic, schema-driven) |
| `tools` | Inspect the bundled tool registry (`list`, `describe`, `count`) |
| `raw` | Escape hatch — call a tool by full name with raw JSON args |
| `session` | In-memory session state (last URL, current tab) |
| `repl` | Interactive REPL (default when no subcommand given) |
## Usage Examples
### Discover the tool surface
```bash
# Count of tools (sanity check — must match safari-mcp's registered tools)
cli-anything-safari tools count
# → 84
# List every tool
cli-anything-safari tools list
cli-anything-safari tools list --filter click # filter by substring
# Full schema for one tool (JSON or human format)
cli-anything-safari tools describe safari_scroll
cli-anything-safari --json tools describe safari_click
```
### Call a tool (schema-driven)
```bash
# Navigate
cli-anything-safari tool navigate --url https://example.com
# Take a snapshot (preferred over screenshot — structured text with ref IDs)
cli-anything-safari --json tool snapshot
# Click by ref (refs come from snapshot; they expire on the next snapshot!)
cli-anything-safari tool click --ref 0_5
# Click by selector or visible text
cli-anything-safari tool click --selector "#submit"
cli-anything-safari tool click --text "Log in"
# Fill a field
cli-anything-safari tool fill --selector "#email" --value "user@example.com"
# Scroll by direction/amount (NOT x/y — note the schema!)
cli-anything-safari tool scroll --direction down --amount 500
# Drag one element onto another
cli-anything-safari tool drag \
--source-selector ".card" \
--target-selector ".trash"
# Screenshot — returns base64 JPEG in stdout. Decode with:
cli-anything-safari --json tool screenshot --full-page \
| python3 -c "import sys,json,base64; \
d=json.load(sys.stdin); \
open('/tmp/shot.jpg','wb').write(base64.b64decode(d['data']))"
# Save as PDF (this one writes to disk directly)
cli-anything-safari tool save-pdf --path /tmp/page.pdf
# Evaluate JavaScript (note: parameter is --script, not --code)
cli-anything-safari tool evaluate --script "document.title"
```
### Navigate and read in one round-trip
```bash
cli-anything-safari --json tool navigate-and-read --url https://example.com
```
### Form fill (bulk)
`safari_fill_form` takes an **array** of `{selector, value}` objects.
Pass it as a JSON string:
```bash
cli-anything-safari tool fill-form --fields '[
{"selector": "#email", "value": "user@example.com"},
{"selector": "#password", "value": "hunter2"}
]'
```
Run `cli-anything-safari tools describe safari_fill_form` to see the
exact schema, including any new fields safari-mcp adds upstream.
### Network monitoring
```bash
cli-anything-safari tool start-network-capture
cli-anything-safari tool navigate --url https://example.com
cli-anything-safari --json tool network
cli-anything-safari tool performance-metrics
```
### Storage
```bash
cli-anything-safari tool get-cookies
cli-anything-safari tool set-cookie --name session --value abc123 --domain example.com
cli-anything-safari tool local-storage --key theme
# export-storage returns JSON to stdout — no --path arg. Pipe to a file:
cli-anything-safari --json tool export-storage > /tmp/storage.json
```
### Raw JSON escape hatch
When you need to pass a complex nested object or want to drive the CLI from
a pre-built JSON blob:
```bash
cli-anything-safari raw safari_evaluate \
--json-args '{"code":"[...document.querySelectorAll(\"a\")].map(a => a.href)"}'
```
### Interactive REPL
```bash
cli-anything-safari
```
The REPL banner prints the absolute path to this SKILL.md so agents can
self-discover capabilities.
## JSON Output
All commands support `--json` as a global flag:
```bash
cli-anything-safari --json tool snapshot
cli-anything-safari --json tool list-tabs
cli-anything-safari --json tools list
```
## State Management
The CLI maintains a small amount of in-memory state for REPL display only:
- **`last_url`** — last URL the CLI navigated to (updated after every
successful `tool navigate`, `tool navigate-and-read`, or
`tool new-tab`)
- **`current_tab_index`** — last known active tab index
There is **no persistent session**, no undo/redo, no document model.
Every CLI invocation starts with fresh state. Safari MCP itself is
stateless per-call: each `tool` command spawns a fresh
`npx safari-mcp` subprocess, performs the action, and exits. This is a
deliberate design choice; see `HARNESS.md` and `TEST.md` for the
reasoning behind the deviation from the standard undo/redo pattern.
## Output Formats
All commands support dual output modes:
- **Human-readable** (default): indented key-value text for `dict`
results, bullet lists for arrays, plain text otherwise
- **Machine-readable** (`--json` flag): structured JSON for agent
consumption
```bash
# Human output
cli-anything-safari tool snapshot
# JSON output for agents
cli-anything-safari --json tool snapshot
cli-anything-safari --json tools list
cli-anything-safari --json tools describe safari_click
```
## For AI Agents
When using this CLI programmatically:
1. **Always use `--json` flag** for parseable output.
2. **Check return codes** — 0 for success, non-zero for errors (URL
validation failures, MCP call failures, invalid JSON args).
3. **Parse stderr** for error messages; use stdout for data.
4. **File-handling tools have inconsistent path arg names** — always
check `tools describe <name>` first:
- `tool save-pdf --path /tmp/x.pdf`
- `tool upload-file --selector ... --file-path /tmp/x.txt` (note: `--file-path`, not `--path`)
- `tool export-storage` — no path arg; pipe JSON output to a file
- `tool import-storage --path /tmp/x.json`
- `tool screenshot` / `screenshot-element` — return base64 in
the JSON response, no path arg (decode it yourself)
5. **Snapshot before click** — refs from `tool snapshot` expire on the
next snapshot. Always snapshot → find ref → click in close
succession.
6. **Discover tools via `tools list`** — the bundled registry is the
source of truth for what's available. Do not hard-code tool names
that may change upstream.
7. **Use `tools describe <name>`** to learn the exact schema (required
args, enum choices, JSON-typed args) before constructing a call.
**Never assume parameter names from the description** — for example,
`safari_evaluate` takes `--script` (not `--code`) even though the
description says "JavaScript code to execute".
## Agent-Specific Guidance
### Finding the right tool
Use the introspection commands. The CLI is **guaranteed** to reflect the
MCP server 1:1:
```bash
# Find all click-related tools
cli-anything-safari tools list --filter click
# Get the full schema (including every argument with type, description,
# required/optional, enum choices, defaults)
cli-anything-safari --json tools describe safari_click
```
### Tool selection strategy
1. **`tool snapshot`** over `tool screenshot` — structured text with ref IDs
is orders of magnitude cheaper and carries the refs needed for clicks.
2. **`tool click --ref`** over `tool click --selector` — refs are stable
within a single snapshot, selectors may be brittle.
3. **`tool navigate-and-read`** over `navigate` + `read-page` — saves one
round-trip.
4. **`tool click-and-read`** over `click` + `read-page` — saves one round-trip.
5. **`tool native-click`** only when regular click fails with 405/403 (WAF
blocks, G2, Cloudflare) — it physically moves the cursor.
### Refs Expire
Refs from `tool snapshot` expire when you take a new snapshot:
- First snapshot: refs `0_1`, `0_2`, `0_3`...
- Second snapshot: refs `1_1`, `1_2`, `1_3`...
Always snapshot → click in close succession. If in doubt, snapshot again.
### Tab Ownership Safety
Safari MCP tracks tab ownership per session. Tools that modify a tab
(navigate, click, fill) are **blocked** on tabs the session did not open.
To operate on a specific page, always start with `tool new-tab --url ...`.
### Error Handling
Common errors:
- `npx not found` → install Node.js 18+
- `safari-mcp package not found on npm registry` → check network
- `Not macOS` → harness is macOS-only
- `AppleScript denied` → enable "Allow JavaScript from Apple Events" in Safari → Develop
- `Blocked URL scheme: file` → URL validation rejected the input (by design)
### URL Validation
The CLI validates URLs before passing them to `safari_navigate`,
`safari_navigate_and_read`, and `safari_new_tab`. Blocked schemes:
`file`, `javascript`, `data`, `vbscript`, `about`, `chrome`, `safari`,
`webkit`, `x-apple`, and other browser-internal schemes. The `raw`
command **also** enforces this for navigation tools.
### Multi-Session Warning
Safari MCP enforces a single active session by killing stale Node.js
processes older than 10 seconds. If you run two CLI instances at once,
one will kill the other's backend. **There is currently no daemon
mode** — for latency-sensitive workflows, drive the CLI from a
long-lived Python script that imports
``cli_anything.safari.utils.safari_backend.call()`` directly to avoid
re-spawning the subprocess on every invocation.
## Links
- [Safari MCP GitHub](https://github.com/achiya-automation/safari-mcp)
- [Safari MCP on npm](https://www.npmjs.com/package/safari-mcp)
- [CLI-Anything](https://github.com/HKUDS/CLI-Anything)
- [MCP Backend Pattern Guide](https://github.com/HKUDS/CLI-Anything/blob/main/cli-anything-plugin/guides/mcp-backend.md)
## Security Considerations
### URL Validation
All navigation tools (`tool navigate`, `tool navigate-and-read`, `tool
new-tab`, and `raw safari_navigate*`) pass the `url` argument through
`utils/security.py` which blocks dangerous schemes and optionally blocks
private networks (set `CLI_ANYTHING_SAFARI_BLOCK_PRIVATE=1`).
### Tab Isolation
Safari MCP enforces per-session tab ownership upstream — tools cannot
operate on tabs the session did not open.
### Profile Isolation
Set `SAFARI_PROFILE` env var to use a separate Safari profile for
automation:
```bash
export SAFARI_PROFILE="Automation"
cli-anything-safari tool navigate --url https://example.com
```
This keeps cookies/logins/history separate from the user's main browsing.
### JavaScript Execution
`tool evaluate` and `tool run-script` run arbitrary JavaScript in the page
context. Treat untrusted input with the same care as any dynamic code
execution path.
### Clipboard
`tool clipboard-read` and `tool clipboard-write` touch the system
clipboard. Be careful when running inside a user's active session —
overwriting the clipboard mid-task is disruptive.
## Regenerating the tool registry
If you upgrade `safari-mcp`, regenerate the bundled schema:
```bash
python scripts/extract_tools.py \
"$(npm root -g)/safari-mcp/index.js" \
cli_anything/safari/resources/tools.json
```
The parity test (`test_parity.py`) pins the expected tool count; update
it when the upstream tool list changes.
## More Information
- **Full documentation:** `cli_anything/safari/README.md` in the package
- **Test coverage:** `cli_anything/safari/tests/TEST.md` in the package
- **Architecture analysis:** `safari/agent-harness/SAFARI.md`
- **Methodology:** `cli-anything-plugin/HARNESS.md`
- **MCP backend pattern:** `cli-anything-plugin/guides/mcp-backend.md`
## Version
1.0.0 — targets safari-mcp 2.7.8 (84 tools). Bundled tool registry is
regenerated via `scripts/extract_tools.py` when safari-mcp upgrades.
@@ -0,0 +1,428 @@
# TEST.md — Test Plan & Results
This file follows the two-part structure required by
[`cli-anything-plugin/HARNESS.md`](../../../../cli-anything-plugin/HARNESS.md):
**Part 1** (test plan, written before implementation) documents what is
tested and why. **Part 2** (test results, appended after a successful
run) pastes the pytest output for traceability.
---
## Part 1 — Test Plan
### Deliberate Deviations from `validate.md`
This harness deviates from the standard CLI-Anything test rules in
three documented ways. Each deviation is justified by the MCP-backend
architecture; the alternative would mean shipping fake compliance
that doesn't reflect reality.
**1. E2E tests are gated behind `SAFARI_E2E=1` (HARNESS.md L501 says
they MUST run by default).**
Why gated:
- E2E tests **mutate Safari state**`test_navigate_and_read_example_com`
navigates the active Safari tab to https://example.com/. A user
who runs `pytest` casually shouldn't lose whatever they were
looking at.
- Running these tests in a developer environment that has a
long-lived `safari-mcp` instance triggers the singleton-killer
branch in `~/safari-mcp/index.js` lines 22-49 — *technically*
Safari MCP enters proxy mode when port 9224 is already bound
(lines 479-526), so the primary survives in practice (verified
live during the v1.0 final test pass), but we keep the gate as
defensive cover for the case where proxy mode doesn't initialize
fast enough.
- CI environments and the package maintainer should set
`SAFARI_E2E=1` to run the full suite. Verified locally:
`SAFARI_E2E=1 CLI_ANYTHING_FORCE_INSTALLED=1 pytest …` runs all
19 E2E tests including the 3 that talk to real Safari, and they
all pass against the live MCP server in proxy mode.
**2. `core/project.py` and `core/export.py` do not exist.**
`validate.md` lines 37-39 list both as required. They are N/A for
browser automation:
- There is no project file format to manage. Every browser action
is imperative and immediate.
- There is no rendering pipeline. Safari MCP IS the renderer; the
CLI just forwards calls.
- The DOMShell harness (`browser/agent-harness/`) makes the same
decision and ships `core/fs.py` + `core/page.py` instead. Our
schema-driven design eliminates even those — `core/` only holds
`session.py` (a tiny in-memory state holder).
See `SAFARI.md` §8 for the full N/A breakdown.
**3. `Session` has no undo/redo/snapshot.**
`validate.md` line 53 requires "Session class with undo/redo/snapshot".
N/A because:
- Browser actions (clicks, navigations) are inherently irreversible.
There is no document to roll back to.
- Snapshot already exists at the safari-mcp level
(`safari_snapshot` / `safari_accessibility_snapshot`) and is
exposed via `cli-anything-safari tool snapshot`. The CLI does not
duplicate this state.
- DOMShell's `Session` also lacks undo/redo for the same reason.
If the upstream MCP server ever adds a stateful document model
(unlikely for safari-mcp), these features can be added back without
changing the rest of the harness.
### Test Inventory
| File | Test count | Category | Requires Safari? |
|-------------------------|-----------:|-------------------------------|------------------|
| `test_core.py` | 16 | unit, mocked backend | no |
| `test_security.py` | 36 | security / URL validation | no |
| `test_parity.py` | 24 | CLI ↔ MCP schema parity | no |
| `test_full_e2e.py` | 19 | E2E (CliRunner + subprocess) | yes (gated) |
| **Total** | **95** | | |
E2E tests are gated behind the `SAFARI_E2E=1` environment variable. Of
the 19 E2E tests, **16 can run without mutating Safari state** (all
help/metadata/security tests) and the remaining 3 (`TestRealSafariRoundTrip::*`,
`TestCLISubprocess::test_list_tabs_json_roundtrip`) actually exercise
real Safari and should be run manually.
### Unit Test Plan (`test_core.py`)
Covers the backend helpers in `utils/safari_backend.py` and the
`Session` dataclass in `core/session.py`. **No Safari, no network, no
subprocess.** Mocks `mcp.ClientSession` and `asyncio.run`.
Modules and functions under test:
- `safari_backend.is_available()` — platform gating + npm registry probe
- `safari_backend._unwrap()` — MCP `CallToolResult` → Python value
- **TextContent** items (most tools)
- **ImageContent** items (`safari_screenshot`, `safari_screenshot_element`)
- Empty content, missing mimeType, multiple content parts
- `safari_backend.call()` — argument forwarding, None stripping, result unwrapping
- `core.session.Session` — defaults, set_url, set_tab, status() (with
empty-url sentinel branch)
Edge cases:
- Non-Darwin platform must be rejected
- Empty MCP result content
- Multiple content parts (list vs single-value unwrap)
- JSON vs raw-text content handling
- ImageContent NOT silently dropped (regression lock)
- Missing optional fields on returned objects
Expected test count: **16** — matches delivered count.
### Security Test Plan (`test_security.py`)
Covers `utils/security.py` in isolation. **No Safari required.**
Classes:
- **TestURLValidation** — every blocked scheme, every malformed input
form, scheme helper accessors (22 tests)
- **TestDOMSanitization** — plain text, truncation, prompt-injection
pattern flagging (English/Chinese), HTML comment / script tag
detection, control-char stripping, newline preservation (11 tests)
- **TestPrivateNetworkConfig** — default behavior (allow localhost),
env-var override expectations (3 tests)
Expected test count: **36** — matches delivered count.
### Parity Test Plan (`test_parity.py`)
This is the central "exactly like MCP" guarantee. Each test verifies
one aspect of the CLI ↔ registry mapping. **No Safari required.**
Classes:
- **TestParityToolCoverage** (5 tests)
- Registry is non-empty
- Registry tool count is pinned to 84 (fails loudly on upstream drift)
- Every tool in the registry is reachable as `cli-anything-safari tool <short-name>`
- `tool` group has exactly `len(registry)` Click subcommands
- No Click subcommands exist that are not in the registry
- **TestParityParameters** (3 tests)
- Every MCP param has a matching Click option (kebab-case match)
- Every required MCP param is required in Click (all types, including
`object`/`array` which were skipped in an earlier draft and masked
a parser regression)
- Every enum MCP param exposes the same choices in Click's `Choice` type
- **TestParityIntrospection** (6 tests)
- `tools count` prints the right number
- `tools list` mentions every tool
- `tools list --json` returns the expected JSON shape
- `tools describe <full-name>` works
- `tools describe <short-name>` works
- `tools describe <unknown>` exits non-zero
- **TestParityHighValueSchemas** (8 tests) — regression locks for
specific parser bugs that earlier revisions got wrong:
- `safari_scroll` uses `direction` (enum) + `amount`, NOT `x`/`y`
- `safari_drag` uses `sourceSelector`/`targetSelector`
- `safari_mock_route` uses `urlPattern`
- `safari_throttle_network` has `profile`
- `safari_mock_route.response` is REQUIRED and has the outer
`"Mock response to return"` description (not the nested
`"HTTP status code"`)
- `safari_run_script.steps` is REQUIRED (no top-level `.optional()`)
and has the outer `"Array of steps to execute sequentially"`
description
- `safari_fill_form.fields` description comes from the outer
`.describe("Array of {selector, value} pairs")`, not from the
inner `selector: z.string().describe("CSS selector")`
- `safari_fill_and_submit.fields` same pattern
Expected test count: **24** — matches delivered count (includes
`test_evaluate_param_is_script_not_code` and `test_run_script_param_is_steps`
regression locks added in v1.0).
### E2E Test Plan (`test_full_e2e.py`)
Gated behind `SAFARI_E2E=1`. **Requires Safari + macOS** and will
trigger safari-mcp's singleton killer, which terminates any
concurrent `node .*/safari-mcp/index.js` process older than 10
seconds. **Do not run** while another Claude Code or agent session is
using safari-mcp concurrently.
Classes:
- **TestDependencyChecks** (2 tests, CliRunner)
- `--help` works
- Top-level groups all visible
- **TestSessionCommands** (1 test, CliRunner)
- `session status --json` returns expected keys
- **TestSecurityIntegration** (5 tests, CliRunner)
- `tool navigate --url file:///etc/passwd` is blocked
- `tool navigate --url javascript:alert(1)` is blocked
- `tool navigate --url about:blank` is blocked
- `tool navigate --url example.com` is rejected (no scheme)
- `raw safari_navigate` also enforces URL validation
- **TestRealSafariRoundTrip** (2 tests, CliRunner) — **mutate Safari
state**, run manually only:
- `tool list-tabs` returns valid JSON from the real server
- `tool navigate-and-read --url https://example.com` opens the page
and reads "Example Domain" back
- **TestCLISubprocess** (9 tests, `subprocess.run`) — exercises the
installed `cli-anything-safari` command, required by HARNESS.md
Phase 5. Uses a lazy `CLI_BASE` property with `_resolve_cli()` so
collection does not fail when the command is missing. Honors
`CLI_ANYTHING_FORCE_INSTALLED=1`:
- `--help` has "Safari CLI"
- `tool --help` mentions the expected short names
(navigate, snapshot, click, fill, screenshot, evaluate, list-tabs,
mock-route)
- `tools count` prints 84
- `tools describe safari_scroll` contains direction + amount
- `tool scroll --help` shows `--direction [up|down]` and `--amount`
- `raw --help` mentions `tool_name`
- `session status --json` has expected keys
- `tool navigate --url file:///etc/passwd` exits non-zero
- `tool list-tabs --json` round-trips to Safari (manual only)
Expected test count: **19** — matches delivered count.
### Realistic Workflow Scenarios
A schema-driven browser CLI does not have the multi-step project
workflows that document-based harnesses use as E2E anchors (video
editing, photo compositing, etc.). The workflows we can meaningfully
test are single-action sanity checks. More elaborate flows should be
exercised by agents driving the installed command in real use.
1. **URL validation** — block dangerous schemes at the Click boundary.
2. **Schema introspection** — the bundled registry is the source of
truth for CLI shape. Drift → parity test failure.
3. **Snapshot → click** — the idiomatic Safari-MCP interaction
pattern. Exercised manually because it mutates real Safari state.
4. **Help round-trip via subprocess** — the installed command works
from any cwd and produces the expected top-level command list.
---
## Part 2 — Test Results
Last run: 2026-04-10 (pytest -v, offline, mcp==1.27.0, Python 3.14.3)
```
============================= test session starts ==============================
platform darwin -- Python 3.14.3, pytest-9.0.3, pluggy-1.6.0 -- /tmp/safari-harness-venv/bin/python
cachedir: .pytest_cache
rootdir: /Users/am/CLI-Anything/safari/agent-harness
plugins: anyio-4.13.0
collecting ... collected 95 items
cli_anything/safari/tests/test_core.py::TestPlatformCheck::test_refuses_non_darwin PASSED
cli_anything/safari/tests/test_core.py::TestPlatformCheck::test_accepts_darwin_if_deps_present PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_json_text PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_plain_text PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_multiple_parts PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_empty PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_image_content PASSED
cli_anything/safari/tests/test_core.py::TestUnwrap::test_unwrap_image_content_default_mimetype PASSED
cli_anything/safari/tests/test_core.py::TestCallForwarding::test_strips_none_args_before_call PASSED
cli_anything/safari/tests/test_core.py::TestCallForwarding::test_passes_full_arg_set_when_none_omitted PASSED
cli_anything/safari/tests/test_core.py::TestCallForwarding::test_unwraps_plain_text_when_not_json PASSED
cli_anything/safari/tests/test_core.py::TestSessionState::test_session_defaults PASSED
cli_anything/safari/tests/test_core.py::TestSessionState::test_set_url_updates_last_url PASSED
cli_anything/safari/tests/test_core.py::TestSessionState::test_set_tab_updates_current_tab PASSED
cli_anything/safari/tests/test_core.py::TestSessionState::test_status_contains_expected_keys PASSED
cli_anything/safari/tests/test_core.py::TestSessionState::test_status_empty_url_returns_sentinel PASSED
cli_anything/safari/tests/test_full_e2e.py::TestDependencyChecks::test_cli_help_works SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestDependencyChecks::test_cli_shows_all_command_groups SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSessionCommands::test_session_status_json SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSecurityIntegration::test_file_url_blocked SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSecurityIntegration::test_javascript_url_blocked SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSecurityIntegration::test_about_url_blocked SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSecurityIntegration::test_missing_scheme_rejected SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestSecurityIntegration::test_raw_navigate_also_blocked SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestRealSafariRoundTrip::test_tab_list_returns_json SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestRealSafariRoundTrip::test_navigate_and_read_example_com SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_help SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_tool_group_help SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_tools_count_is_84 SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_tools_describe_scroll SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_tool_scroll_help_uses_schema SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_raw_help SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_session_status_json SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_blocked_scheme_exits_nonzero SKIPPED
cli_anything/safari/tests/test_full_e2e.py::TestCLISubprocess::test_list_tabs_json_roundtrip SKIPPED
cli_anything/safari/tests/test_parity.py::TestParityToolCoverage::test_registry_not_empty PASSED
cli_anything/safari/tests/test_parity.py::TestParityToolCoverage::test_registry_tool_count_matches_expected PASSED
cli_anything/safari/tests/test_parity.py::TestParityToolCoverage::test_every_tool_reachable_via_tool_group PASSED
cli_anything/safari/tests/test_parity.py::TestParityToolCoverage::test_tool_group_has_exactly_registry_count PASSED
cli_anything/safari/tests/test_parity.py::TestParityToolCoverage::test_no_unexpected_tools_in_cli PASSED
cli_anything/safari/tests/test_parity.py::TestParityParameters::test_every_param_has_cli_option PASSED
cli_anything/safari/tests/test_parity.py::TestParityParameters::test_required_params_are_required_in_click PASSED
cli_anything/safari/tests/test_parity.py::TestParityParameters::test_enum_choices_match PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_count_command PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_list_outputs_every_tool PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_list_json_shape PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_describe_known_tool PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_describe_by_short_name PASSED
cli_anything/safari/tests/test_parity.py::TestParityIntrospection::test_tools_describe_unknown_rejects PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_scroll_uses_direction_not_xy PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_drag_uses_source_and_target PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_mock_route_uses_url_pattern PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_throttle_network_has_profile PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_mock_route_response_is_required_not_status_description PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_run_script_steps_is_required_and_described_correctly PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_fill_form_fields_description_is_outer_not_inner PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_fill_and_submit_fields_description_is_outer PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_evaluate_param_is_script_not_code PASSED
cli_anything/safari/tests/test_parity.py::TestParityHighValueSchemas::test_run_script_param_is_steps PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_valid_http_url PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_valid_https_url PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_valid_https_with_path_and_query PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_valid_https_with_port PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_file_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_javascript_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_data_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_about_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_vbscript_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_webkit_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_blocked_safari_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_empty_string PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_whitespace_only PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_none_input PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_non_string_input PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_missing_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_missing_hostname PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_unknown_scheme PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_unknown_scheme_ws PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_get_allowed_schemes PASSED
cli_anything/safari/tests/test_security.py::TestURLValidation::test_get_blocked_schemes PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_plain_text_unchanged PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_empty_text_returns_empty PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_none_passes_through PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_truncation PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_default_max_length PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_prompt_injection_flagged PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_chinese_injection_flagged PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_html_comment_flagged PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_script_tag_flagged PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_control_chars_stripped PASSED
cli_anything/safari/tests/test_security.py::TestDOMSanitization::test_newlines_preserved PASSED
cli_anything/safari/tests/test_security.py::TestPrivateNetworkConfig::test_default_private_not_blocked PASSED
cli_anything/safari/tests/test_security.py::TestPrivateNetworkConfig::test_localhost_allowed_by_default PASSED
cli_anything/safari/tests/test_security.py::TestPrivateNetworkConfig::test_127_0_0_1_allowed_by_default PASSED
cli_anything/safari/tests/test_security.py::TestPrivateNetworkConfig::test_private_ip_allowed_by_default PASSED
======================== 76 passed, 19 skipped in 0.41s ========================
```
**Summary:** 76 passed, 19 skipped (all E2E, gated on `SAFARI_E2E=1`).
With `SAFARI_E2E=1 CLI_ANYTHING_FORCE_INSTALLED=1` the full suite is
**95 passed, 0 skipped** — including the 3 tests that exercise real
Safari (`test_tab_list_returns_json`, `test_navigate_and_read_example_com`,
`test_list_tabs_json_roundtrip`). Verified live against macOS Safari
and the bundled safari-mcp 2.7.8 in proxy mode (the primary safari-mcp
serving the user's Claude Code session was not disturbed).
### Live verification log (v1.0)
The following live operations were executed against real Safari to
verify the schema-driven CLI works end-to-end and the recent
ImageContent fix in `_unwrap` is correct:
```text
$ cli-anything-safari --json tool list-tabs
[
{"index": 1, "title": "WhatsApp bot for conference gamification - Claude",
"url": "https://claude.ai/chat/cc6353cc-..."}
]
$ cli-anything-safari --json tool evaluate --script "document.title"
"WhatsApp bot for conference gamification - Claude"
# (Regression test for the C1 bug where docs used --code by mistake.
# The CLI now correctly uses --script and the param is forwarded
# through MCP unchanged. Real Safari returned the real title.)
$ cli-anything-safari --json tool screenshot
{"type": "image", "data": "<65612 base64 chars>", "mimeType": "image/jpeg"}
# Decoded: 49,208 bytes, magic ff d8 ff e0 = valid JPEG.
# Saved to disk as a real image file. This proves the _unwrap
# ImageContent fix works against a real CallToolResult, not just
# MagicMock.
```
### Test Execution Time
- Offline (no Safari): ~0.8s
- With E2E gate on (excluding live-Safari mutations): ~3.5s
### Coverage Notes
- **Fully covered:** schema parsing (via parity tests), URL validation,
session state, CLI wiring, introspection
- **Covered via regression locks:** four specific nested-schema parser
bugs that earlier revisions got wrong (`mock_route.response`,
`run_script.steps`, `fill_form.fields`, `fill_and_submit.fields`)
- **Covered by manual E2E (gated):** actual Safari round-trips for
navigation and tab listing
- **Not covered:** every individual tool's actual MCP round-trip. We
rely on the schema parity to ensure the CLI passes the right args,
and on safari-mcp's own test suite to verify the tools themselves.
- **Not in scope:** real-world multi-step workflows (snapshot → find →
click → read). These are exercised by agents in actual use, not by
the test suite.
### How to Re-Run
```bash
# Offline suite (fast, no Safari required)
python -m pytest cli_anything/safari/tests/ -v --tb=no
# Full suite with E2E (requires Safari + macOS + Apple Events)
SAFARI_E2E=1 CLI_ANYTHING_FORCE_INSTALLED=1 \
python -m pytest cli_anything/safari/tests/ -v -s
# Just the parity check (core of the "exact parity" guarantee)
python -m pytest cli_anything/safari/tests/test_parity.py -v
```
@@ -0,0 +1,227 @@
"""Unit tests for cli-anything-safari — Core modules with mocked backend.
These tests use synthetic data and mock the MCP backend. No Safari, npx,
or network access required. Covers:
- Platform gating (Darwin-only)
- MCP result unwrapping (JSON parsing)
- Argument cleaning (strip None values)
- Session state management
Usage:
python -m pytest cli_anything/safari/tests/test_core.py -v
"""
import platform
from unittest.mock import patch, MagicMock
class TestPlatformCheck:
def test_refuses_non_darwin(self):
from cli_anything.safari.utils import safari_backend as backend
with patch.object(platform, "system", return_value="Linux"):
available, msg = backend.is_available()
assert available is False
assert "macOS" in msg
def test_accepts_darwin_if_deps_present(self):
from cli_anything.safari.utils import safari_backend as backend
with patch.object(platform, "system", return_value="Darwin"), \
patch.object(backend, "_check_npx", return_value=True), \
patch.object(backend, "_check_safari_mcp_package",
return_value=(True, "2.7.8")):
available, msg = backend.is_available()
assert available is True
assert "2.7.8" in msg
class TestUnwrap:
def test_unwrap_json_text(self):
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
item = MagicMock()
item.text = '{"ok": true, "url": "https://example.com"}'
result.content = [item]
parsed = _unwrap(result)
assert parsed == {"ok": True, "url": "https://example.com"}
def test_unwrap_plain_text(self):
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
item = MagicMock()
item.text = "not json, just a string"
result.content = [item]
assert _unwrap(result) == "not json, just a string"
def test_unwrap_multiple_parts(self):
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
a, b = MagicMock(), MagicMock()
a.text = '{"a": 1}'
b.text = '{"b": 2}'
result.content = [a, b]
parts = _unwrap(result)
assert parts == [{"a": 1}, {"b": 2}]
def test_unwrap_empty(self):
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
result.content = []
assert _unwrap(result) is None
def test_unwrap_image_content(self):
"""ImageContent items must NOT be silently dropped.
Regression test for the bug where _unwrap only checked for
``.text`` and returned None for screenshot tools, which
return ``{type:'image', data:<base64>, mimeType:...}``.
"""
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
item = MagicMock(spec=["data", "mimeType"])
item.data = "base64encodedimagedata=="
item.mimeType = "image/jpeg"
result.content = [item]
unwrapped = _unwrap(result)
assert unwrapped is not None
assert unwrapped["type"] == "image"
assert unwrapped["data"] == "base64encodedimagedata=="
assert unwrapped["mimeType"] == "image/jpeg"
def test_unwrap_image_content_default_mimetype(self):
from cli_anything.safari.utils.safari_backend import _unwrap
result = MagicMock()
item = MagicMock(spec=["data"])
item.data = "abc"
# No mimeType attribute
result.content = [item]
unwrapped = _unwrap(result)
assert unwrapped is not None
assert unwrapped["type"] == "image"
assert unwrapped["data"] == "abc"
assert unwrapped["mimeType"] == "application/octet-stream"
class TestCallForwarding:
"""Verify backend.call() forwards args, strips None, and unwraps results."""
def test_strips_none_args_before_call(self):
from cli_anything.safari.utils import safari_backend as backend
captured: dict = {}
async def fake_call_tool(tool_name, arguments):
captured["tool"] = tool_name
captured["args"] = arguments
result = MagicMock()
item = MagicMock()
item.text = '{"ok": true}'
result.content = [item]
return result
with patch.object(backend, "_call_tool", side_effect=fake_call_tool):
result = backend.call("safari_navigate", url="https://example.com",
selector=None, x=None, y=42)
assert captured["tool"] == "safari_navigate"
# None values must be stripped; non-None must be forwarded.
assert captured["args"] == {"url": "https://example.com", "y": 42}
# The MCP CallToolResult must be unwrapped to the inner JSON.
assert result == {"ok": True}
def test_passes_full_arg_set_when_none_omitted(self):
from cli_anything.safari.utils import safari_backend as backend
captured: dict = {}
async def fake_call_tool(tool_name, arguments):
captured["args"] = arguments
result = MagicMock()
result.content = []
return result
with patch.object(backend, "_call_tool", side_effect=fake_call_tool):
backend.call("safari_click", ref="0_5", selector="#submit")
assert captured["args"] == {"ref": "0_5", "selector": "#submit"}
def test_unwraps_plain_text_when_not_json(self):
from cli_anything.safari.utils import safari_backend as backend
captured: dict = {}
async def fake_call_tool(tool_name, arguments):
captured["tool"] = tool_name
captured["args"] = arguments
result = MagicMock()
item = MagicMock()
item.text = "not a json string"
result.content = [item]
return result
with patch.object(backend, "_call_tool", side_effect=fake_call_tool):
# Note: safari_evaluate's parameter is "script", not "code".
# This test doubles as a regression lock for the doc bug
# where examples used --code by mistake.
result = backend.call("safari_evaluate", script="document.title")
assert captured["tool"] == "safari_evaluate"
assert captured["args"] == {"script": "document.title"}
assert result == "not a json string"
class TestSessionState:
def test_session_defaults(self):
from cli_anything.safari.core.session import Session
s = Session()
assert s.current_tab_index is None
assert s.last_url == ""
def test_set_url_updates_last_url(self):
from cli_anything.safari.core.session import Session
s = Session()
s.set_url("https://example.com")
assert s.last_url == "https://example.com"
def test_set_tab_updates_current_tab(self):
from cli_anything.safari.core.session import Session
s = Session()
s.set_tab(3)
assert s.current_tab_index == 3
def test_status_contains_expected_keys(self):
from cli_anything.safari.core.session import Session
s = Session()
s.set_url("https://example.com")
s.set_tab(2)
status = s.status()
assert status["last_url"] == "https://example.com"
assert status["current_tab_index"] == 2
assert "daemon_mode" not in status # removed in v1
def test_status_empty_url_returns_sentinel(self):
from cli_anything.safari.core.session import Session
s = Session()
# last_url not set yet — status() should return the sentinel
# so REPL display has something readable.
status = s.status()
assert status["last_url"] == "(no navigation yet)"
assert status["current_tab_index"] is None
@@ -0,0 +1,258 @@
"""E2E tests for cli-anything-safari — Requires Safari + macOS.
These tests interact with real Safari via the safari-mcp MCP server. They are
SKIPPED by default because:
1. Safari MCP kills stale instances (>10s) which can disrupt other agents
using Safari MCP concurrently
2. E2E tests mutate browser state (open tabs, navigate, read cookies)
3. They require macOS + Safari with Apple Events enabled
To enable:
export SAFARI_E2E=1
python -m pytest cli_anything/safari/tests/test_full_e2e.py -v -s
To also enforce the installed command (not module fallback):
CLI_ANYTHING_FORCE_INSTALLED=1 SAFARI_E2E=1 \\
python -m pytest cli_anything/safari/tests/test_full_e2e.py -v -s
"""
import json
import os
import shutil
import subprocess
import sys
import pytest
from click.testing import CliRunner
from cli_anything.safari.utils.safari_backend import is_available
from cli_anything.safari.safari_cli import cli
# ── Feature flag and skip rule ───────────────────────────────────────
SAFARI_E2E_ENABLED = os.environ.get("SAFARI_E2E", "").lower() in {"1", "true", "yes"}
def _should_skip_e2e() -> bool:
"""Decide whether to skip the E2E file.
Evaluated lazily so that pytest --collect-only does not call
``is_available()`` (which would hit the npm registry with up to a
15-second timeout) when the feature flag is off. Only when
SAFARI_E2E=1 do we actually probe for safari-mcp availability.
"""
if not SAFARI_E2E_ENABLED:
return True
return not is_available()[0]
# Skip all tests when E2E is disabled or safari-mcp is unreachable.
pytestmark = pytest.mark.skipif(
_should_skip_e2e(),
reason=(
"Safari E2E tests are disabled or safari-mcp is not available. "
"Set SAFARI_E2E=1 and ensure Safari is installed with 'Allow "
"JavaScript from Apple Events' enabled (Safari → Develop menu)."
),
)
# A stable read-only target for navigation tests.
TEST_URL = "https://example.com"
# ── CLI resolver (mandatory per HARNESS.md) ──────────────────────────
def _resolve_cli(name: str):
"""Resolve installed CLI command; falls back to python -m for dev.
Set env CLI_ANYTHING_FORCE_INSTALLED=1 to require the installed command.
This matches the pattern from HARNESS.md Phase 5.
"""
force = os.environ.get("CLI_ANYTHING_FORCE_INSTALLED", "").strip() == "1"
path = shutil.which(name)
if path:
print(f"[_resolve_cli] Using installed command: {path}")
return [path]
if force:
raise RuntimeError(
f"{name} not found in PATH. Install with: pip install -e ."
)
# Fallback: run as module
module = "cli_anything.safari.safari_cli"
print(f"[_resolve_cli] Falling back to: {sys.executable} -m {module}")
return [sys.executable, "-m", module]
@pytest.fixture
def runner():
return CliRunner()
# ── CliRunner-based tests (fast in-process) ──────────────────────────
class TestDependencyChecks:
"""Verify dependency checking works with Safari MCP available."""
def test_cli_help_works(self, runner):
"""--help must succeed even when Safari MCP is reachable."""
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
assert "Safari CLI" in result.output
def test_cli_shows_all_command_groups(self, runner):
result = runner.invoke(cli, ["--help"])
assert result.exit_code == 0
for group in ("tool", "tools", "raw", "session", "repl"):
assert group in result.output
class TestSessionCommands:
"""Test session management via CliRunner."""
def test_session_status_json(self, runner):
result = runner.invoke(cli, ["--json", "session", "status"])
assert result.exit_code == 0
data = json.loads(result.output)
assert "last_url" in data
assert "current_tab_index" in data
class TestSecurityIntegration:
"""URL validation must block dangerous schemes at the CLI layer."""
def test_file_url_blocked(self, runner):
result = runner.invoke(cli, ["tool", "navigate", "--url", "file:///etc/passwd"])
assert result.exit_code != 0
assert "Blocked URL scheme" in result.output or "file" in result.output
def test_javascript_url_blocked(self, runner):
result = runner.invoke(cli, ["tool", "navigate", "--url", "javascript:alert(1)"])
assert result.exit_code != 0
assert "Blocked" in result.output or "javascript" in result.output
def test_about_url_blocked(self, runner):
result = runner.invoke(cli, ["tool", "navigate", "--url", "about:blank"])
assert result.exit_code != 0
def test_missing_scheme_rejected(self, runner):
result = runner.invoke(cli, ["tool", "navigate", "--url", "example.com"])
assert result.exit_code != 0
def test_raw_navigate_also_blocked(self, runner):
"""The raw escape hatch must also enforce URL validation."""
result = runner.invoke(
cli,
["raw", "safari_navigate", "--json-args", '{"url":"file:///etc/passwd"}']
)
assert result.exit_code != 0
# ── Real MCP round-trip (mutates Safari state) ───────────────────────
class TestRealSafariRoundTrip:
"""These tests actually talk to Safari. Only run when SAFARI_E2E=1."""
def test_tab_list_returns_json(self, runner):
"""list-tabs should round-trip through safari-mcp and return valid JSON."""
result = runner.invoke(cli, ["--json", "tool", "list-tabs"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data is not None
def test_navigate_and_read_example_com(self, runner):
"""End-to-end navigation: open example.com and read title."""
result = runner.invoke(
cli, ["--json", "tool", "navigate-and-read", "--url", TEST_URL]
)
assert result.exit_code == 0
assert "Example" in result.output or "example" in result.output
# ── Subprocess tests (HARNESS.md requirement) ────────────────────────
class TestCLISubprocess:
"""Invoke the installed CLI command as a real user/agent would.
This class is required by HARNESS.md Phase 5: tests must exercise the
actual installed `cli-anything-safari` command via subprocess, not just
source imports via CliRunner. ``CLI_BASE`` is a cached class property
rather than a class attribute so pytest collection does not call
``_resolve_cli`` (which can raise when ``CLI_ANYTHING_FORCE_INSTALLED=1``
is set but the command is not in PATH).
"""
_cli_base: "list[str] | None" = None
@property
def CLI_BASE(self) -> list[str]:
base = type(self)._cli_base
if base is None:
base = _resolve_cli("cli-anything-safari")
type(self)._cli_base = base
return base
def _run(self, args, check=True):
return subprocess.run(
self.CLI_BASE + args,
capture_output=True,
text=True,
check=check,
)
def test_help(self):
result = self._run(["--help"])
assert result.returncode == 0
assert "Safari CLI" in result.stdout
def test_tool_group_help(self):
result = self._run(["tool", "--help"])
assert result.returncode == 0
# Spot-check short names from each category — these are the MCP
# tool names with the safari_ prefix stripped, so they're stable.
for short in (
"navigate", "snapshot", "click", "fill",
"screenshot", "evaluate", "list-tabs", "mock-route",
):
assert short in result.stdout, f"missing '{short}' in tool --help"
def test_tools_count_is_84(self):
result = self._run(["tools", "count"])
assert result.returncode == 0
assert result.stdout.strip() == "84"
def test_tools_describe_scroll(self):
result = self._run(["tools", "describe", "safari_scroll"])
assert result.returncode == 0
assert "direction" in result.stdout
assert "amount" in result.stdout
def test_tool_scroll_help_uses_schema(self):
"""Verify the auto-generated command matches the MCP schema exactly."""
result = self._run(["tool", "scroll", "--help"])
assert result.returncode == 0
assert "--direction" in result.stdout
assert "--amount" in result.stdout
assert "up|down" in result.stdout # enum choices
def test_raw_help(self):
result = self._run(["raw", "--help"])
assert result.returncode == 0
assert "tool_name" in result.stdout.lower()
def test_session_status_json(self):
result = self._run(["--json", "session", "status"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert "last_url" in data
assert "current_tab_index" in data
def test_blocked_scheme_exits_nonzero(self):
# Note: check=False because we expect non-zero exit
result = self._run(
["tool", "navigate", "--url", "file:///etc/passwd"], check=False
)
assert result.returncode != 0
def test_list_tabs_json_roundtrip(self):
"""End-to-end: installed CLI → safari-mcp → Safari → back."""
result = self._run(["--json", "tool", "list-tabs"])
assert result.returncode == 0
data = json.loads(result.stdout)
assert data is not None
print(f"\n list-tabs: {len(data) if isinstance(data, list) else 'n/a'} tabs")
@@ -0,0 +1,356 @@
"""Parity tests — guarantee the CLI exposes every safari-mcp tool 1:1.
These tests verify that the Click CLI surface matches the bundled MCP tool
registry exactly. If these pass, you can trust the CLI to have the same
feature surface as the underlying safari-mcp server.
The tests iterate over every tool in ``resources/tools.json`` and check:
1. The tool is reachable as ``safari tool <short-name>``
2. Every parameter from the MCP schema has a matching Click option
3. Required parameters are marked required in Click
4. Boolean parameters are flag-style
5. Enum parameters expose the same choices
6. The number of CLI options matches the number of MCP parameters
Run:
python -m pytest cli_anything/safari/tests/test_parity.py -v
"""
from __future__ import annotations
from click.testing import CliRunner
from cli_anything.safari.safari_cli import cli, tool_group
from cli_anything.safari.utils.tool_registry import load_registry
def _cli_name_for(param_name: str) -> str:
"""Match the same normalization the registry applies."""
import re
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", param_name)
s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", s)
return s.replace("_", "-").lower()
class TestParityToolCoverage:
"""Every tool in the registry must be reachable as a Click subcommand."""
def setup_method(self):
self.registry = load_registry()
def test_registry_not_empty(self):
assert len(self.registry) > 0, "tools.json is empty"
def test_registry_tool_count_matches_expected(self):
# safari-mcp v2.7.8 exposes 84 tools. Update this when bumping upstream.
assert len(self.registry) == 84, (
f"Expected 84 tools from safari-mcp, got {len(self.registry)}. "
f"Re-run scripts/extract_tools.py if safari-mcp was upgraded."
)
def test_every_tool_reachable_via_tool_group(self):
"""For each tool in the registry, `safari tool <short-name>` exists."""
runner = CliRunner()
missing = []
for tool in self.registry:
result = runner.invoke(
cli, ["tool", tool.short_name, "--help"],
catch_exceptions=False,
)
if result.exit_code != 0:
missing.append(tool.short_name)
assert not missing, f"Tools missing from CLI: {missing}"
def test_tool_group_has_exactly_registry_count(self):
"""The number of Click subcommands must equal the registry size."""
ctx_commands = tool_group.commands
assert len(ctx_commands) == len(self.registry), (
f"tool group has {len(ctx_commands)} commands, "
f"registry has {len(self.registry)} tools"
)
def test_no_unexpected_tools_in_cli(self):
"""Every Click subcommand under `tool` must come from the registry."""
registry_short_names = {t.short_name for t in self.registry}
cli_names = set(tool_group.commands.keys())
extras = cli_names - registry_short_names
assert not extras, f"Unexpected tools in CLI (not in registry): {extras}"
class TestParityParameters:
"""Every MCP parameter must map to a Click option with the right shape."""
def setup_method(self):
self.registry = load_registry()
def test_every_param_has_cli_option(self):
"""For each MCP param, the Click command has a matching option."""
missing = []
for tool in self.registry:
cmd = tool_group.commands.get(tool.short_name)
assert cmd is not None, f"missing command for {tool.short_name}"
cli_opt_names = set()
for param in cmd.params:
if hasattr(param, "opts"):
for opt in param.opts:
# Strip leading dashes and normalize
clean = opt.lstrip("-")
# Boolean flags come as "--flag/--no-flag" so the raw
# opt may already be "flag" or "no-flag".
if clean.startswith("no-"):
clean = clean[3:]
cli_opt_names.add(clean)
for mcp_param in tool.params:
expected = _cli_name_for(mcp_param.name)
if expected not in cli_opt_names:
missing.append(f"{tool.name}.{mcp_param.name} → --{expected}")
assert not missing, f"Missing CLI options for params:\n" + "\n".join(missing)
def test_required_params_are_required_in_click(self):
"""Required MCP params must be required in Click — covers all types.
Previously this test skipped object/array params on the theory
that JSON-string inputs were always optional. That masked a real
parser regression (safari_mock_route.response / safari_run_script.steps
were wrongly marked optional). The fix in extract_tools.py is
locked in by this test now covering all types.
"""
drift = []
for tool in self.registry:
cmd = tool_group.commands.get(tool.short_name)
if cmd is None:
continue
click_required_by_name = {}
for cp in cmd.params:
if hasattr(cp, "opts"):
for opt in cp.opts:
clean = opt.lstrip("-")
if clean.startswith("no-"):
clean = clean[3:]
click_required_by_name[clean] = getattr(cp, "required", False)
for mp in tool.params:
if not mp.required:
continue
key = _cli_name_for(mp.name)
if not click_required_by_name.get(key, False):
drift.append(
f"{tool.name}.{mp.name} ({mp.type}) is required "
f"in MCP but not in Click"
)
assert not drift, "\n".join(drift)
def test_enum_choices_match(self):
"""Enum params must expose the same choices."""
drift = []
for tool in self.registry:
cmd = tool_group.commands.get(tool.short_name)
if cmd is None:
continue
for mp in tool.params:
if not mp.choices:
continue
target_name = _cli_name_for(mp.name)
for cp in cmd.params:
if not hasattr(cp, "opts"):
continue
opts_clean = [o.lstrip("-") for o in cp.opts]
if target_name not in opts_clean:
continue
click_type = getattr(cp, "type", None)
if not hasattr(click_type, "choices"):
drift.append(
f"{tool.name}.{mp.name} has choices "
f"{mp.choices} but Click option has no Choice type"
)
continue
if set(click_type.choices) != set(mp.choices):
drift.append(
f"{tool.name}.{mp.name} choices mismatch: "
f"registry={mp.choices} cli={click_type.choices}"
)
assert not drift, "\n".join(drift)
class TestParityIntrospection:
"""The introspection commands (tools list/describe) reflect the registry."""
def test_tools_count_command(self):
runner = CliRunner()
registry = load_registry()
result = runner.invoke(cli, ["tools", "count"])
assert result.exit_code == 0
assert result.output.strip() == str(len(registry))
def test_tools_list_outputs_every_tool(self):
runner = CliRunner()
registry = load_registry()
result = runner.invoke(cli, ["tools", "list"])
assert result.exit_code == 0
for tool in registry:
assert tool.short_name in result.output
def test_tools_list_json_shape(self):
import json
runner = CliRunner()
result = runner.invoke(cli, ["--json", "tools", "list"])
assert result.exit_code == 0
data = json.loads(result.output)
registry = load_registry()
assert len(data) == len(registry)
for item in data:
assert {"name", "short_name", "description", "param_count"} <= set(item)
def test_tools_describe_known_tool(self):
runner = CliRunner()
result = runner.invoke(cli, ["tools", "describe", "safari_scroll"])
assert result.exit_code == 0
assert "safari_scroll" in result.output
assert "direction" in result.output
assert "amount" in result.output
def test_tools_describe_by_short_name(self):
runner = CliRunner()
result = runner.invoke(cli, ["tools", "describe", "scroll"])
assert result.exit_code == 0
assert "safari_scroll" in result.output
def test_tools_describe_unknown_rejects(self):
runner = CliRunner()
result = runner.invoke(cli, ["tools", "describe", "does_not_exist"])
assert result.exit_code != 0
class TestParityHighValueSchemas:
"""Spot-check schemas that had known drift bugs in previous revisions.
Each test here is a regression lock: if the parser or upstream
safari-mcp changes these shapes, the test fails loud and the
contributor has to decide whether to update the pin or fix the parser.
"""
def setup_method(self):
self.registry = load_registry()
def test_scroll_uses_direction_not_xy(self):
tool = self.registry.get("safari_scroll")
assert tool is not None
names = {p.name for p in tool.params}
assert "direction" in names
assert "amount" in names
assert "x" not in names # previously (wrongly) wrapped as --x/--y
assert "y" not in names
def test_drag_uses_source_and_target(self):
tool = self.registry.get("safari_drag")
assert tool is not None
names = {p.name for p in tool.params}
assert "sourceSelector" in names
assert "targetSelector" in names
def test_mock_route_uses_url_pattern(self):
tool = self.registry.get("safari_mock_route")
assert tool is not None
names = {p.name for p in tool.params}
assert "urlPattern" in names
def test_throttle_network_has_profile(self):
tool = self.registry.get("safari_throttle_network")
assert tool is not None
names = {p.name for p in tool.params}
assert "profile" in names
# ── Nested-schema parser regression locks ────────────────────
# These test the bugs fixed in extract_tools.py's depth-aware
# modifier detection. If the parser regresses, `.describe("...")`
# from a nested schema would leak into the outer field description,
# and `.optional()` on nested fields would incorrectly mark the
# outer param as optional.
def test_mock_route_response_is_required_not_status_description(self):
"""safari_mock_route.response is required and takes a JSON object.
Regression target: the parser used to pick the nested
.describe("HTTP status code") from the inner `status` field
instead of the outer .describe("Mock response to return"),
and wrongly inferred optional from nested .optional() calls.
"""
tool = self.registry.get("safari_mock_route")
assert tool is not None
response = tool.get_param("response")
assert response is not None
assert response.required, "response must be required"
assert response.type == "object"
assert "status code" not in (response.description or "").lower(), (
"parser leaked nested .describe(); expected 'Mock response to return'"
)
assert "mock response" in (response.description or "").lower()
def test_run_script_steps_is_required_and_described_correctly(self):
"""safari_run_script.steps is required (no top-level .optional).
Regression target: the parser's old naive `.optional(` check
would find the nested `args: z.record(...).optional()` and
wrongly mark the outer `steps` as optional.
"""
tool = self.registry.get("safari_run_script")
assert tool is not None
steps = tool.get_param("steps")
assert steps is not None
assert steps.required, "steps must be required"
assert steps.type == "array"
assert "array of steps" in (steps.description or "").lower()
def test_fill_form_fields_description_is_outer_not_inner(self):
"""safari_fill_form.fields description must come from the
OUTER .describe(), not the nested selector's .describe("CSS selector").
"""
tool = self.registry.get("safari_fill_form")
assert tool is not None
fields = tool.get_param("fields")
assert fields is not None
assert fields.required
assert fields.type == "array"
assert fields.description != "CSS selector", (
"parser leaked the nested selector description; should be "
"the outer 'Array of {selector, value} pairs'"
)
assert "selector" in fields.description.lower()
assert "value" in fields.description.lower()
def test_fill_and_submit_fields_description_is_outer(self):
tool = self.registry.get("safari_fill_and_submit")
assert tool is not None
fields = tool.get_param("fields")
assert fields is not None
assert fields.required
assert fields.description != "CSS selector"
def test_evaluate_param_is_script_not_code(self):
"""safari_evaluate's parameter is named ``script`` upstream.
Regression test: every prior version of the docs and a
TestCallForwarding test exemplar called it ``code`` by mistake,
which would silently send the wrong arg through ``raw`` calls
and fail with ``--code`` is unknown option through ``tool``.
"""
tool = self.registry.get("safari_evaluate")
assert tool is not None
param_names = {p.name for p in tool.params}
assert "script" in param_names, (
f"safari_evaluate must take 'script', got params: {param_names}"
)
assert "code" not in param_names, (
"Doc/test bug regression: safari_evaluate uses 'script' upstream"
)
script_param = tool.get_param("script")
assert script_param is not None
assert script_param.required
assert script_param.type == "string"
def test_run_script_param_is_steps(self):
"""safari_run_script takes 'steps' (array). Locks the rename
regression alongside test_evaluate_param_is_script_not_code."""
tool = self.registry.get("safari_run_script")
assert tool is not None
param_names = {p.name for p in tool.params}
assert "steps" in param_names
@@ -0,0 +1,159 @@
"""Security module tests.
Tests URL validation. No Safari or npx required.
"""
import importlib
from cli_anything.safari.utils import security
def _reload_security_module():
"""Reload the security module to pick up env var changes."""
importlib.reload(security)
_reload_security_module()
from cli_anything.safari.utils.security import (
get_allowed_schemes,
get_blocked_schemes,
is_private_network_blocked,
validate_url,
)
class TestURLValidation:
"""URL validation security checks."""
# ── Allowed schemes ──────────────────────────────────────────
def test_valid_http_url(self):
ok, err = validate_url("http://example.com")
assert ok
assert err == ""
def test_valid_https_url(self):
ok, err = validate_url("https://example.com")
assert ok
assert err == ""
def test_valid_https_with_path_and_query(self):
ok, err = validate_url("https://example.com/path/page?q=value&b=1")
assert ok
assert err == ""
def test_valid_https_with_port(self):
ok, err = validate_url("https://example.com:8443/")
assert ok
assert err == ""
# ── Blocked schemes ──────────────────────────────────────────
def test_blocked_file_scheme(self):
ok, err = validate_url("file:///etc/passwd")
assert not ok
assert "Blocked URL scheme: file" in err
def test_blocked_javascript_scheme(self):
ok, err = validate_url("javascript:alert(1)")
assert not ok
assert "Blocked URL scheme: javascript" in err
def test_blocked_data_scheme(self):
ok, err = validate_url("data:text/html,<script>alert(1)</script>")
assert not ok
assert "Blocked URL scheme: data" in err
def test_blocked_about_scheme(self):
ok, err = validate_url("about:blank")
assert not ok
assert "Blocked URL scheme: about" in err
def test_blocked_vbscript_scheme(self):
ok, err = validate_url("vbscript:msgbox(1)")
assert not ok
assert "Blocked URL scheme: vbscript" in err
def test_blocked_webkit_scheme(self):
ok, err = validate_url("webkit:inspector")
assert not ok
assert "Blocked URL scheme: webkit" in err
def test_blocked_safari_scheme(self):
ok, err = validate_url("safari:history")
assert not ok
assert "Blocked URL scheme: safari" in err
# ── Malformed inputs ─────────────────────────────────────────
def test_empty_string(self):
ok, err = validate_url("")
assert not ok
assert "non-empty" in err.lower()
def test_whitespace_only(self):
ok, err = validate_url(" ")
assert not ok
assert "empty" in err.lower() or "whitespace" in err.lower()
def test_none_input(self):
ok, err = validate_url(None) # type: ignore
assert not ok
def test_non_string_input(self):
ok, err = validate_url(12345) # type: ignore
assert not ok
def test_missing_scheme(self):
ok, err = validate_url("example.com/path")
assert not ok
assert "scheme" in err.lower()
def test_missing_hostname(self):
ok, err = validate_url("https://")
assert not ok
assert "hostname" in err.lower()
def test_unknown_scheme(self):
ok, err = validate_url("ftp://example.com")
assert not ok
assert "Unsupported URL scheme: ftp" in err
def test_unknown_scheme_ws(self):
ok, err = validate_url("ws://example.com")
assert not ok
# ── Scheme helpers ───────────────────────────────────────────
def test_get_allowed_schemes(self):
allowed = get_allowed_schemes()
assert "http" in allowed
assert "https" in allowed
assert "file" not in allowed
def test_get_blocked_schemes(self):
blocked = get_blocked_schemes()
assert "file" in blocked
assert "javascript" in blocked
assert "data" in blocked
assert "safari" in blocked
assert "webkit" in blocked
assert "http" not in blocked
class TestPrivateNetworkConfig:
"""Test the env-var controlled private network blocking."""
def test_default_private_not_blocked(self):
"""By default, private networks are NOT blocked (dev-friendly)."""
assert is_private_network_blocked() is False
def test_localhost_allowed_by_default(self):
ok, _ = validate_url("http://localhost:3000")
assert ok
def test_127_0_0_1_allowed_by_default(self):
ok, _ = validate_url("http://127.0.0.1:8080/api")
assert ok
def test_private_ip_allowed_by_default(self):
ok, _ = validate_url("http://192.168.1.1/")
assert ok
@@ -0,0 +1,522 @@
"""cli-anything REPL Skin — Unified terminal interface for all CLI harnesses.
Copy this file into your CLI package at:
cli_anything/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.utils.repl_skin import ReplSkin
skin = ReplSkin("shotcut", version="1.0.0")
skin.print_banner() # auto-detects skills/SKILL.md inside the package
prompt_text = skin.prompt(project_name="my_video.mlt", modified=True)
skin.success("Project saved")
skin.error("File not found")
skin.warning("Unsaved changes")
skin.info("Processing 24 clips...")
skin.status("Track 1", "3 clips, 00:02:30")
skin.table(headers, rows)
skin.print_goodbye()
"""
import os
import sys
# ── ANSI color codes (no external deps for core styling) ──────────────
_RESET = "\033[0m"
_BOLD = "\033[1m"
_DIM = "\033[2m"
_ITALIC = "\033[3m"
_UNDERLINE = "\033[4m"
# Brand colors
_CYAN = "\033[38;5;80m" # cli-anything brand cyan
_CYAN_BG = "\033[48;5;80m"
_WHITE = "\033[97m"
_GRAY = "\033[38;5;245m"
_DARK_GRAY = "\033[38;5;240m"
_LIGHT_GRAY = "\033[38;5;250m"
# Software accent colors — each software gets a unique accent
_ACCENT_COLORS = {
"gimp": "\033[38;5;214m", # warm orange
"blender": "\033[38;5;208m", # deep orange
"inkscape": "\033[38;5;39m", # bright blue
"audacity": "\033[38;5;33m", # navy blue
"libreoffice": "\033[38;5;40m", # green
"obs_studio": "\033[38;5;55m", # purple
"kdenlive": "\033[38;5;69m", # slate blue
"shotcut": "\033[38;5;35m", # teal green
"safari": "\033[38;5;33m", # Safari blue
}
_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue
# Status colors
_GREEN = "\033[38;5;78m"
_YELLOW = "\033[38;5;220m"
_RED = "\033[38;5;196m"
_BLUE = "\033[38;5;75m"
_MAGENTA = "\033[38;5;176m"
# ── Brand icon ────────────────────────────────────────────────────────
# The cli-anything icon: a small colored diamond/chevron mark
_ICON = f"{_CYAN}{_BOLD}{_RESET}"
_ICON_SMALL = f"{_CYAN}{_RESET}"
# ── Box drawing characters ────────────────────────────────────────────
_H_LINE = ""
_V_LINE = ""
_TL = ""
_TR = ""
_BL = ""
_BR = ""
_T_DOWN = ""
_T_UP = ""
_T_RIGHT = ""
_T_LEFT = ""
_CROSS = ""
def _strip_ansi(text: str) -> str:
"""Remove ANSI escape codes for length calculation."""
import re
return re.sub(r"\033\[[^m]*m", "", text)
def _visible_len(text: str) -> int:
"""Get visible length of text (excluding ANSI codes)."""
return len(_strip_ansi(text))
class ReplSkin:
"""Unified REPL skin for cli-anything CLIs.
Provides consistent branding, prompts, and message formatting
across all CLI harnesses built with the cli-anything methodology.
"""
def __init__(self, software: str, version: str = "1.0.0",
history_file: str | None = None, skill_path: str | None = None):
"""Initialize the REPL skin.
Args:
software: Software name (e.g., "gimp", "shotcut", "blender").
version: CLI version string.
history_file: Path for persistent command history.
Defaults to ~/.cli-anything-<software>/history
skill_path: Path to the SKILL.md file for agent discovery.
Auto-detected from the package's skills/ directory if not provided.
Displayed in banner for AI agents to know where to read skill info.
"""
self.software = software.lower().replace("-", "_")
self.display_name = software.replace("_", " ").title()
self.version = version
# Auto-detect skill path from package layout:
# cli_anything/<software>/utils/repl_skin.py (this file)
# cli_anything/<software>/skills/SKILL.md (target)
if skill_path is None:
from pathlib import Path
_auto = Path(__file__).resolve().parent.parent / "skills" / "SKILL.md"
if _auto.is_file():
skill_path = str(_auto)
self.skill_path = skill_path
self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT)
# History file
if history_file is None:
from pathlib import Path
hist_dir = Path.home() / f".cli-anything-{self.software}"
hist_dir.mkdir(parents=True, exist_ok=True)
self.history_file = str(hist_dir / "history")
else:
self.history_file = history_file
# Detect terminal capabilities
self._color = self._detect_color_support()
def _detect_color_support(self) -> bool:
"""Check if terminal supports color."""
if os.environ.get("NO_COLOR"):
return False
if os.environ.get("CLI_ANYTHING_NO_COLOR"):
return False
if not hasattr(sys.stdout, "isatty"):
return False
return sys.stdout.isatty()
def _c(self, code: str, text: str) -> str:
"""Apply color code if colors are supported."""
if not self._color:
return text
return f"{code}{text}{_RESET}"
# ── Banner ────────────────────────────────────────────────────────
def print_banner(self):
"""Print the startup banner with branding."""
inner = 54
def _box_line(content: str) -> str:
"""Wrap content in box drawing, padding to inner width."""
pad = inner - _visible_len(content)
vl = self._c(_DARK_GRAY, _V_LINE)
return f"{vl}{content}{' ' * max(0, pad)}{vl}"
top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}")
bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}")
# Title: ◆ cli-anything · Shotcut
icon = self._c(_CYAN + _BOLD, "")
brand = self._c(_CYAN + _BOLD, "cli-anything")
dot = self._c(_DARK_GRAY, "·")
name = self._c(self.accent + _BOLD, self.display_name)
title = f" {icon} {brand} {dot} {name}"
ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}"
tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}"
empty = ""
# Skill path for agent discovery
skill_line = None
if self.skill_path:
skill_icon = self._c(_MAGENTA, "")
skill_label = self._c(_DARK_GRAY, " Skill:")
skill_path_display = self._c(_LIGHT_GRAY, self.skill_path)
skill_line = f" {skill_icon} {skill_label} {skill_path_display}"
print(top)
print(_box_line(title))
print(_box_line(ver))
if skill_line:
print(_box_line(skill_line))
print(_box_line(empty))
print(_box_line(tip))
print(bot)
print()
# ── Prompt ────────────────────────────────────────────────────────
def prompt(self, project_name: str = "", modified: bool = False,
context: str = "") -> str:
"""Build a styled prompt string for prompt_toolkit or input().
Args:
project_name: Current project name (empty if none open).
modified: Whether the project has unsaved changes.
context: Optional extra context to show in prompt.
Returns:
Formatted prompt string.
"""
parts = []
# Icon
if self._color:
parts.append(f"{_CYAN}{_RESET} ")
else:
parts.append("> ")
# Software name
parts.append(self._c(self.accent + _BOLD, self.software))
# Project context
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
parts.append(f" {self._c(_DARK_GRAY, '[')}")
parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}"))
parts.append(self._c(_DARK_GRAY, ']'))
parts.append(self._c(_GRAY, " "))
return "".join(parts)
def prompt_tokens(self, project_name: str = "", modified: bool = False,
context: str = ""):
"""Build prompt_toolkit formatted text tokens for the prompt.
Use with prompt_toolkit's FormattedText for proper ANSI handling.
Returns:
list of (style, text) tuples for prompt_toolkit.
"""
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
tokens = []
tokens.append(("class:icon", ""))
tokens.append(("class:software", self.software))
if project_name or context:
ctx = context or project_name
mod = "*" if modified else ""
tokens.append(("class:bracket", " ["))
tokens.append(("class:context", f"{ctx}{mod}"))
tokens.append(("class:bracket", "]"))
tokens.append(("class:arrow", " "))
return tokens
def get_prompt_style(self):
"""Get a prompt_toolkit Style object matching the skin.
Returns:
prompt_toolkit.styles.Style
"""
try:
from prompt_toolkit.styles import Style
except ImportError:
return None
accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff")
return Style.from_dict({
"icon": "#5fdfdf bold", # cyan brand color
"software": f"{accent_hex} bold",
"bracket": "#585858",
"context": "#bcbcbc",
"arrow": "#808080",
# Completion menu
"completion-menu.completion": "bg:#303030 #bcbcbc",
"completion-menu.completion.current": f"bg:{accent_hex} #000000",
"completion-menu.meta.completion": "bg:#303030 #808080",
"completion-menu.meta.completion.current": f"bg:{accent_hex} #000000",
# Auto-suggest
"auto-suggest": "#585858",
# Bottom toolbar
"bottom-toolbar": "bg:#1c1c1c #808080",
"bottom-toolbar.text": "#808080",
})
# ── Messages ──────────────────────────────────────────────────────
def success(self, message: str):
"""Print a success message with green checkmark."""
icon = self._c(_GREEN + _BOLD, "")
print(f" {icon} {self._c(_GREEN, message)}")
def error(self, message: str):
"""Print an error message with red cross."""
icon = self._c(_RED + _BOLD, "")
print(f" {icon} {self._c(_RED, message)}", file=sys.stderr)
def warning(self, message: str):
"""Print a warning message with yellow triangle."""
icon = self._c(_YELLOW + _BOLD, "")
print(f" {icon} {self._c(_YELLOW, message)}")
def info(self, message: str):
"""Print an info message with blue dot."""
icon = self._c(_BLUE, "")
print(f" {icon} {self._c(_LIGHT_GRAY, message)}")
def hint(self, message: str):
"""Print a subtle hint message."""
print(f" {self._c(_DARK_GRAY, message)}")
def section(self, title: str):
"""Print a section header."""
print()
print(f" {self._c(self.accent + _BOLD, title)}")
print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}")
# ── Status display ────────────────────────────────────────────────
def status(self, label: str, value: str):
"""Print a key-value status line."""
lbl = self._c(_GRAY, f" {label}:")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def status_block(self, items: dict[str, str], title: str = ""):
"""Print a block of status key-value pairs.
Args:
items: Dict of label -> value pairs.
title: Optional title for the block.
"""
if title:
self.section(title)
max_key = max(len(k) for k in items) if items else 0
for label, value in items.items():
lbl = self._c(_GRAY, f" {label:<{max_key}}")
val = self._c(_WHITE, f" {value}")
print(f"{lbl}{val}")
def progress(self, current: int, total: int, label: str = ""):
"""Print a simple progress indicator.
Args:
current: Current step number.
total: Total number of steps.
label: Optional label for the progress.
"""
pct = int(current / total * 100) if total > 0 else 0
bar_width = 20
filled = int(bar_width * current / total) if total > 0 else 0
bar = "" * filled + "" * (bar_width - filled)
text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}"
if label:
text += f" {self._c(_LIGHT_GRAY, label)}"
print(text)
# ── Table display ─────────────────────────────────────────────────
def table(self, headers: list[str], rows: list[list[str]],
max_col_width: int = 40):
"""Print a formatted table with box-drawing characters.
Args:
headers: Column header strings.
rows: List of rows, each a list of cell strings.
max_col_width: Maximum column width before truncation.
"""
if not headers:
return
# Calculate column widths
col_widths = [min(len(h), max_col_width) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = min(
max(col_widths[i], len(str(cell))), max_col_width
)
def pad(text: str, width: int) -> str:
t = str(text)[:width]
return t + " " * (width - len(t))
# Header
header_cells = [
self._c(_CYAN + _BOLD, pad(h, col_widths[i]))
for i, h in enumerate(headers)
]
sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
header_line = f" {sep.join(header_cells)}"
print(header_line)
# Separator
sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths]
sep_line = self._c(_DARK_GRAY, f" {'───'.join([_H_LINE * w for w in col_widths])}")
print(sep_line)
# Rows
for row in rows:
cells = []
for i, cell in enumerate(row):
if i < len(col_widths):
cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i])))
row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ")
print(f" {row_sep.join(cells)}")
# ── Help display ──────────────────────────────────────────────────
def help(self, commands: dict[str, str]):
"""Print a formatted help listing.
Args:
commands: Dict of command -> description pairs.
"""
self.section("Commands")
max_cmd = max(len(c) for c in commands) if commands else 0
for cmd, desc in commands.items():
cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}")
desc_styled = self._c(_GRAY, f" {desc}")
print(f"{cmd_styled}{desc_styled}")
print()
# ── Goodbye ───────────────────────────────────────────────────────
def print_goodbye(self):
"""Print a styled goodbye message."""
print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n")
# ── Prompt toolkit session factory ────────────────────────────────
def create_prompt_session(self):
"""Create a prompt_toolkit PromptSession with skin styling.
Returns:
A configured PromptSession, or None if prompt_toolkit unavailable.
"""
try:
from prompt_toolkit import PromptSession
from prompt_toolkit.history import FileHistory
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit.formatted_text import FormattedText
style = self.get_prompt_style()
session = PromptSession(
history=FileHistory(self.history_file),
auto_suggest=AutoSuggestFromHistory(),
style=style,
enable_history_search=True,
)
return session
except ImportError:
return None
def get_input(self, pt_session, project_name: str = "",
modified: bool = False, context: str = "") -> str:
"""Get input from user using prompt_toolkit or fallback.
Args:
pt_session: A prompt_toolkit PromptSession (or None).
project_name: Current project name.
modified: Whether project has unsaved changes.
context: Optional context string.
Returns:
User input string (stripped).
"""
if pt_session is not None:
from prompt_toolkit.formatted_text import FormattedText
tokens = self.prompt_tokens(project_name, modified, context)
return pt_session.prompt(FormattedText(tokens)).strip()
else:
raw_prompt = self.prompt(project_name, modified, context)
return input(raw_prompt).strip()
# ── Toolbar builder ───────────────────────────────────────────────
def bottom_toolbar(self, items: dict[str, str]):
"""Create a bottom toolbar callback for prompt_toolkit.
Args:
items: Dict of label -> value pairs to show in toolbar.
Returns:
A callable that returns FormattedText for the toolbar.
"""
def toolbar():
from prompt_toolkit.formatted_text import FormattedText
parts = []
for i, (k, v) in enumerate(items.items()):
if i > 0:
parts.append(("class:bottom-toolbar.text", ""))
parts.append(("class:bottom-toolbar.text", f" {k}: "))
parts.append(("class:bottom-toolbar", v))
return FormattedText(parts)
return toolbar
# ── ANSI 256-color to hex mapping (for prompt_toolkit styles) ─────────
_ANSI_256_TO_HEX = {
"\033[38;5;33m": "#0087ff", # audacity navy blue
"\033[38;5;35m": "#00af5f", # shotcut teal
"\033[38;5;39m": "#00afff", # inkscape bright blue
"\033[38;5;40m": "#00d700", # libreoffice green
"\033[38;5;55m": "#5f00af", # obs purple
"\033[38;5;69m": "#5f87ff", # kdenlive slate blue
"\033[38;5;75m": "#5fafff", # default sky blue
"\033[38;5;80m": "#5fd7d7", # brand cyan
"\033[38;5;208m": "#ff8700", # blender deep orange
"\033[38;5;214m": "#ffaf00", # gimp warm orange
}
@@ -0,0 +1,198 @@
"""Safari MCP client wrapper — communicates with safari-mcp server via stdio.
Provides a synchronous Python interface to safari-mcp's MCP server.
Every call spawns a fresh ``npx safari-mcp`` subprocess, performs one
tool call, and exits. This keeps the wrapper simple and avoids async
event-loop lifecycle issues.
Requires Node.js 18+ (for npx) and macOS.
"""
import asyncio
import os
import subprocess
import shutil
from typing import Any
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
DEFAULT_SERVER_CMD = "npx"
DEFAULT_SERVER_ARGS = ["-y", "safari-mcp"]
def _check_npx() -> bool:
return shutil.which("npx") is not None
def _check_platform() -> bool:
import platform
return platform.system() == "Darwin"
def _check_safari_mcp_package() -> tuple[bool, str]:
"""Check whether the safari-mcp package is resolvable.
Safari MCP is a pure MCP stdio server — it does not respond to
``--version`` or ``--help``. We instead query the npm registry via
``npm view`` which is fast and does not spawn the server.
Returns:
(found, version_or_error)
"""
try:
result = subprocess.run(
["npm", "view", "safari-mcp", "version"],
capture_output=True,
timeout=15,
text=True,
)
if result.returncode != 0:
return False, result.stderr.strip() or "npm view failed"
version = result.stdout.strip()
return bool(version), version or "(no version)"
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
return False, str(e)
def is_available() -> tuple[bool, str]:
"""Check if the safari-mcp MCP server is reachable.
Returns:
(available, message): tuple of availability and a descriptive message.
"""
if not _check_platform():
return (
False,
"Safari MCP only supports macOS. "
"Detected non-Darwin platform.",
)
if not _check_npx():
return (
False,
"npx not found. Install Node.js 18+ from https://nodejs.org/",
)
found, version_or_err = _check_safari_mcp_package()
if not found:
return (
False,
f"safari-mcp package not found on npm registry: {version_or_err}\n"
f"Check your network connection and npm access.",
)
return True, f"safari-mcp v{version_or_err} is available"
async def _call_tool(tool_name: str, arguments: dict) -> Any:
"""Spawn safari-mcp, call one tool, and return the result."""
server_params = StdioServerParameters(
command=DEFAULT_SERVER_CMD,
args=DEFAULT_SERVER_ARGS,
env=os.environ.copy(),
)
try:
async with stdio_client(server_params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
return await session.call_tool(tool_name, arguments)
except Exception as e:
raise RuntimeError(
f"safari-mcp tool call failed: {e}\n"
f"Ensure Safari is running and 'Allow JavaScript from Apple Events' "
f"is enabled (Safari → Develop menu). "
f"See https://github.com/achiya-automation/safari-mcp"
) from e
# ── Generic sync entry point ────────────────────────────────────────
def call(tool_name: str, **arguments) -> Any:
"""Call any safari-mcp tool synchronously.
This is the primary entry point for the CLI. All 84 Safari MCP tools
are reachable via this single function; the Click layer generates
ergonomic commands but ultimately funnels here.
Note:
Uses ``asyncio.run()`` internally. This will raise
``RuntimeError`` if called from an already-running event loop
(e.g. inside Jupyter or an async framework). Callers in async
contexts should use ``_call_tool()`` directly with ``await``.
Args:
tool_name: Full MCP tool name, e.g. "safari_navigate"
**arguments: Tool arguments (forwarded to the MCP server)
Returns:
Parsed tool result. Safari MCP returns text content — if the text
parses as JSON we return the decoded dict/list, otherwise the raw
string.
Example:
>>> call("safari_navigate", url="https://example.com")
{"ok": True, "url": "https://example.com"}
"""
# Drop None values so optional args don't confuse the schema
clean_args = {k: v for k, v in arguments.items() if v is not None}
result = asyncio.run(_call_tool(tool_name, clean_args))
return _unwrap(result)
def _unwrap(result: Any) -> Any:
"""Extract the payload from an MCP ``CallToolResult``.
Safari MCP wraps tool output in ``{content: [...]}`` where each item
is either:
- **TextContent**: ``{type: 'text', text: '...'}`` — returned by most
tools. The text is JSON-decoded when possible, otherwise returned
as a raw string.
- **ImageContent**: ``{type: 'image', data: '<base64>', mimeType: 'image/...'}``
— returned by ``safari_screenshot`` and ``safari_screenshot_element``.
We return ``{type: 'image', data: <base64>, mimeType: <str>}`` so
callers can decode the base64 (e.g. ``base64.b64decode(d['data'])``)
and write it to a file.
Multiple content items are returned as a list. A single item is
unwrapped.
"""
import json
try:
content = result.content
except AttributeError:
return result
if not content:
return None
parts = []
for item in content:
# TextContent — has .text
text = getattr(item, "text", None)
if text is not None:
try:
parts.append(json.loads(text))
except (json.JSONDecodeError, ValueError):
parts.append(text)
continue
# ImageContent — has .data and .mimeType
data = getattr(item, "data", None)
if data is not None:
mime_type = getattr(item, "mimeType", None) or "application/octet-stream"
parts.append({
"type": "image",
"data": data,
"mimeType": mime_type,
})
continue
# Unknown content type — preserve the raw object so the caller
# at least sees something rather than silently dropping it.
parts.append(item)
if len(parts) == 1:
return parts[0]
return parts
@@ -0,0 +1,162 @@
"""Security utilities for Safari browser automation.
This module provides security functions for the safari-mcp harness,
including URL validation and attack surface mitigation.
Threat Model:
- SSRF: Safari can access arbitrary URLs including localhost/private networks
- Scheme injection: javascript:, file:, data: URLs can execute code locally
- Tab ownership bypass: upstream safari-mcp enforces this; validated here too
"""
from __future__ import annotations
import os
import re
from urllib.parse import urlparse
# Environment variable to control private network blocking.
# Default: False (allow localhost/private networks for development —
# Safari MCP is often used to automate local dashboards and dev servers).
_BLOCK_PRIVATE_NETWORKS = os.environ.get(
"CLI_ANYTHING_SAFARI_BLOCK_PRIVATE", ""
).lower() in ("true", "1")
# Environment variable to define allowed URL schemes (comma-separated).
# Default: "http,https". Normalized to lowercase, empty entries filtered.
_ALLOWED_SCHEMES = set(
scheme
for scheme in (
s.strip().lower()
for s in os.environ.get(
"CLI_ANYTHING_SAFARI_ALLOWED_SCHEMES", "http,https"
).split(",")
)
if scheme
)
# Dangerous URI schemes that should NEVER be allowed.
_BLOCKED_SCHEMES = {
"file", # Local file access
"javascript", # Code execution via pseudo-protocol
"data", # Data URI attacks
"vbscript", # Legacy IE script injection
"about", # Browser-internal pages (about:blank, about:config)
"chrome", # Chrome internal pages
"chrome-extension", # Chrome extensions
"moz-extension", # Firefox extensions
"edge", # Edge internal pages
"safari", # Safari internal pages
"webkit", # WebKit internal pages
"opera", # Opera internal pages
"brave", # Brave internal pages
"x-apple", # Apple URL schemes (x-apple-helpbasic, etc.)
"feed", # RSS feed handler
}
# Private network patterns (RFC 1918 + loopback + link-local + IPv6 variants).
_PRIVATE_NETWORK_PATTERNS = [
r'^127\.\d+\.\d+\.\d+', # 127.0.0.0/8 (loopback)
r'^::1$', # IPv6 loopback
r'^localhost$', # localhost hostname
r'^localhost:', # localhost with port
r'^0\.0\.0\.0$', # 0.0.0.0 (all interfaces)
r'^10\.\d+\.\d+\.\d+', # 10.0.0.0/8
r'^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+', # 172.16.0.0/12
r'^192\.168\.\d+\.\d+', # 192.168.0.0/16
r'^169\.254\.\d+\.\d+', # 169.254.0.0/16 (link-local)
r'^fc00:', # IPv6 ULA
r'^fd[0-9a-f]{2}:', # IPv6 ULA prefix
r'^fe80:', # IPv6 link-local
r'^::', # IPv6 unspecified variants
r'^\[::1\]', # IPv6 loopback with brackets
r'^\[::\]', # IPv6 unspecified with brackets
r'^\[fe80:', # IPv6 link-local with brackets
r'^\[fd[0-9a-f]{2}:', # IPv6 ULA with brackets
]
def validate_url(url: str) -> tuple[bool, str]:
"""Validate a URL for security before handing it to Safari MCP.
Checks:
1. Dangerous URI schemes (file://, javascript:, data:, etc.)
2. Private network access (if enabled via env var)
3. Unsupported schemes (only http/https allowed by default)
Args:
url: URL to validate
Returns:
(is_valid, error_message). Returns (True, "") if URL is safe.
Examples:
>>> validate_url("https://example.com")
(True, "")
>>> validate_url("file:///etc/passwd")
(False, "Blocked URL scheme: file")
>>> validate_url("javascript:alert(1)")
(False, "Blocked URL scheme: javascript")
"""
if not url or not isinstance(url, str):
return False, "URL must be a non-empty string"
url = url.strip()
if not url:
return False, "URL cannot be empty or whitespace"
try:
parsed = urlparse(url)
except Exception as e:
return False, f"Invalid URL: {e}"
scheme = parsed.scheme.lower()
if scheme in _BLOCKED_SCHEMES:
return False, f"Blocked URL scheme: {scheme}"
if not scheme:
return False, (
f"URL must include an explicit scheme. "
f"Allowed: {', '.join(sorted(_ALLOWED_SCHEMES))}"
)
if scheme not in _ALLOWED_SCHEMES:
return False, (
f"Unsupported URL scheme: {scheme}. "
f"Allowed: {', '.join(sorted(_ALLOWED_SCHEMES))}"
)
hostname = parsed.hostname or ""
if not hostname:
return False, "URL must include a hostname"
if _BLOCK_PRIVATE_NETWORKS:
hostname_lower = hostname.lower()
for pattern in _PRIVATE_NETWORK_PATTERNS:
if re.match(pattern, hostname_lower):
return False, f"Private network access blocked: {hostname}"
netloc = parsed.netloc.lower()
for pattern in _PRIVATE_NETWORK_PATTERNS:
if re.match(pattern, netloc):
return False, f"Private network access blocked: {netloc}"
return True, ""
def is_private_network_blocked() -> bool:
"""Check if private network blocking is enabled."""
return _BLOCK_PRIVATE_NETWORKS
def get_allowed_schemes() -> set[str]:
"""Get the set of allowed URL schemes."""
return _ALLOWED_SCHEMES.copy()
def get_blocked_schemes() -> set[str]:
"""Get the set of blocked URL schemes."""
return _BLOCKED_SCHEMES.copy()
@@ -0,0 +1,191 @@
"""Tool registry — loads the bundled safari-mcp tool schema.
The registry is generated offline from safari-mcp's source code by
``scripts/extract_tools.py`` and bundled as ``resources/tools.json``. This
guarantees feature parity with safari-mcp without requiring the CLI to
spawn the MCP server just to learn its tool surface.
Usage:
from cli_anything.safari.utils.tool_registry import load_registry
registry = load_registry()
for tool in registry.tools:
print(tool.name, tool.description)
click_tool = registry.get("safari_click")
for param in click_tool.params:
print(param.cli_name, param.type, param.required)
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import Any, Optional
@dataclass(frozen=True)
class ToolParam:
"""A single parameter on an MCP tool, normalized for CLI generation."""
name: str # MCP name (camelCase as the server expects)
cli_name: str # kebab-case for CLI flag (e.g. "url-pattern")
type: str # JSON schema type: string|number|integer|boolean|array|object
description: str # from .describe("...") in Zod
required: bool
default: Optional[str] = None
choices: Optional[list[str]] = None
@classmethod
def from_json_schema(cls, name: str, schema: dict, required: bool) -> "ToolParam":
cli_name = _camel_to_kebab(name)
ptype = schema.get("type", "string")
if isinstance(ptype, list):
non_null = [t for t in ptype if t != "null"]
ptype = non_null[0] if non_null else "string"
return cls(
name=name,
cli_name=cli_name,
type=ptype,
description=schema.get("description", ""),
required=required,
default=schema.get("default"),
choices=schema.get("enum"),
)
@dataclass(frozen=True)
class ToolSchema:
"""A single MCP tool with its full schema."""
name: str # e.g. "safari_navigate"
short_name: str # without the "safari_" prefix, kebab-case: "navigate"
description: str
params: tuple[ToolParam, ...]
raw_schema: dict
@classmethod
def from_dict(cls, data: dict) -> "ToolSchema":
name = data["name"]
schema = data.get("inputSchema", {})
props = schema.get("properties", {}) or {}
required_set = set(schema.get("required", []) or [])
params = tuple(
ToolParam.from_json_schema(pname, pdef, pname in required_set)
for pname, pdef in props.items()
)
short = name
if short.startswith("safari_"):
short = short[len("safari_"):]
short = short.replace("_", "-")
return cls(
name=name,
short_name=short,
description=data.get("description", ""),
params=params,
raw_schema=schema,
)
def get_param(self, name: str) -> Optional[ToolParam]:
"""Look up a param by MCP name or CLI name."""
for p in self.params:
if p.name == name or p.cli_name == name:
return p
return None
@dataclass
class ToolRegistry:
"""The full set of MCP tools from a particular safari-mcp version."""
source_version: str
tools: list[ToolSchema] = field(default_factory=list)
_by_name: dict[str, ToolSchema] = field(default_factory=dict, repr=False)
_by_short_name: dict[str, ToolSchema] = field(default_factory=dict, repr=False)
def __post_init__(self):
self._by_name = {t.name: t for t in self.tools}
self._by_short_name = {t.short_name: t for t in self.tools}
def get(self, name: str) -> Optional[ToolSchema]:
"""Look up a tool by full MCP name (e.g. 'safari_navigate')."""
return self._by_name.get(name)
def get_short(self, short_name: str) -> Optional[ToolSchema]:
"""Look up a tool by short name (e.g. 'navigate')."""
return self._by_short_name.get(short_name)
def __iter__(self):
return iter(self.tools)
def __len__(self):
return len(self.tools)
def _camel_to_kebab(name: str) -> str:
"""Convert camelCase / snake_case to kebab-case.
Examples:
urlPattern -> url-pattern
sourceSelector -> source-selector
max_length -> max-length
x -> x
URLPattern -> url-pattern
"""
import re
# Handle ALLCAPS runs followed by lowercase (e.g. "URLPattern" -> "URL-Pattern")
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1-\2", name)
# Handle camelCase transitions (e.g. "fooBar" -> "foo-Bar")
s = re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", s)
# Convert underscores to hyphens and lowercase
return s.replace("_", "-").lower()
def _resources_path() -> Path:
return Path(__file__).resolve().parent.parent / "resources" / "tools.json"
@lru_cache(maxsize=1)
def load_registry(path: Optional[Path] = None) -> ToolRegistry:
"""Load the bundled tool registry (cached for the process lifetime).
Args:
path: Optional override path to a tools.json file (for tests).
Returns:
A populated ToolRegistry.
Raises:
FileNotFoundError: If the registry JSON is missing.
json.JSONDecodeError: If the registry JSON is malformed.
"""
if path is None:
path = _resources_path()
data = json.loads(Path(path).read_text(encoding="utf-8"))
tools = [ToolSchema.from_dict(t) for t in data.get("tools", [])]
return ToolRegistry(
source_version=data.get("source_version", "unknown"),
tools=tools,
)
def clear_cache() -> None:
"""Clear the registry cache — primarily for tests."""
load_registry.cache_clear()
def coerce_arg_value(param: ToolParam, raw: Any) -> Any:
"""Coerce a raw CLI value into the MCP-expected type.
Click already handles primitive conversion; this layer handles the
remaining cases (object/array come in as JSON strings).
"""
if raw is None:
return None
if param.type == "object" or param.type == "array":
if isinstance(raw, str):
return json.loads(raw)
return raw
return raw
@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""Extract MCP tool schemas from safari-mcp's index.js.
This script parses the JavaScript source of safari-mcp offline (no Node.js,
no subprocess, no MCP spawn) and produces a JSON tool registry that is
bundled with cli-anything-safari. Bundling ensures:
1. True feature parity — every tool exposed by safari-mcp is reachable
2. --help works without touching the network or spawning safari-mcp
3. The CLI doesn't disrupt concurrent safari-mcp instances (singleton killer)
Usage:
python scripts/extract_tools.py /path/to/safari-mcp/index.js \\
cli_anything/safari/resources/tools.json
Re-run this whenever safari-mcp upgrades to refresh the bundled schema.
The parser is hand-written (no external deps) and uses a depth-aware
scanner for Zod modifier chains so nested schemas don't confuse it.
It handles the specific Zod patterns safari-mcp uses:
- z.string() / z.number() / z.boolean() / z.array(...) / z.enum([...])
- z.coerce.number() (mapped to number)
- z.object({...}) / z.literal("...") / z.record(...)
- .optional() / .default(...) / .nullable() modifiers
- .describe("...") metadata
- nested z.array(z.object({...})).describe("outer") patterns
"""
import json
import re
import sys
from pathlib import Path
def extract_tools(source: str) -> list[dict]:
"""Scan the source for all server.tool(...) invocations and return their schemas."""
tools: list[dict] = []
idx = 0
while True:
start = source.find("server.tool(", idx)
if start == -1:
break
tool = _parse_tool_block(source, start)
if tool:
tools.append(tool)
idx = tool.pop("_end")
else:
idx = start + len("server.tool(")
return tools
def _parse_tool_block(source: str, start: int) -> dict | None:
pos = start + len("server.tool(")
pos = _skip_ws(source, pos)
# Name
if pos >= len(source) or source[pos] != '"':
return None
name_end = _find_string_end(source, pos)
if name_end == -1:
return None
name = _decode_js_string(source[pos + 1:name_end])
pos = _skip_ws_comma(source, name_end + 1)
# Description
if pos >= len(source) or source[pos] != '"':
return None
desc_end = _find_string_end(source, pos)
if desc_end == -1:
return None
description = _decode_js_string(source[pos + 1:desc_end])
pos = _skip_ws_comma(source, desc_end + 1)
# Schema object
if pos >= len(source) or source[pos] != "{":
return None
schema_end = _find_brace_end(source, pos)
if schema_end == -1:
return None
schema_src = source[pos:schema_end + 1]
params = _parse_schema_block(schema_src)
pos = schema_end + 1
properties: dict[str, dict] = {}
required: list[str] = []
for p in params:
properties[p["name"]] = _param_to_jsonschema(p)
if p["required"]:
required.append(p["name"])
return {
"name": name,
"description": description,
"inputSchema": {
"type": "object",
"properties": properties,
"required": required,
},
"_end": pos,
}
# ── String helpers (JS string decoding, not Python unicode_escape) ──
def _decode_js_string(inner: str) -> str:
"""Decode a JS string literal's escape sequences safely.
We cannot use ``bytes.decode('unicode_escape')`` because it assumes
latin-1 input, which corrupts multi-byte UTF-8 characters. Instead we
handle the JS escapes we actually care about and leave the rest alone.
"""
def _replace(m: re.Match) -> str:
esc = m.group(0)
if esc == '\\"':
return '"'
if esc == "\\'":
return "'"
if esc == "\\\\":
return "\\"
if esc == "\\n":
return "\n"
if esc == "\\r":
return "\r"
if esc == "\\t":
return "\t"
if esc == "\\b":
return "\b"
if esc == "\\f":
return "\f"
if esc == "\\/":
return "/"
if esc.startswith("\\u"):
try:
return chr(int(esc[2:], 16))
except ValueError:
return esc
if esc.startswith("\\x"):
try:
return chr(int(esc[2:], 16))
except ValueError:
return esc
return esc
return re.sub(
r'\\(?:["\'\\/bfnrt]|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2})',
_replace,
inner,
)
def _skip_ws(src: str, pos: int) -> int:
while pos < len(src) and src[pos] in " \t\r\n":
pos += 1
return pos
def _skip_ws_comma(src: str, pos: int) -> int:
pos = _skip_ws(src, pos)
if pos < len(src) and src[pos] == ",":
pos += 1
return _skip_ws(src, pos)
def _find_string_end(src: str, start: int) -> int:
"""Return index of the closing quote for a double-quoted string."""
if src[start] != '"':
return -1
i = start + 1
while i < len(src):
if src[i] == "\\":
i += 2
continue
if src[i] == '"':
return i
i += 1
return -1
def _find_matching_paren(src: str, open_pos: int) -> int:
"""Find the index of the ')' matching the '(' at open_pos."""
if open_pos >= len(src) or src[open_pos] != "(":
return -1
depth = 1
i = open_pos + 1
while i < len(src):
c = src[i]
if c == '"':
end = _find_string_end(src, i)
if end == -1:
return -1
i = end + 1
continue
if c == "'":
i += 1
while i < len(src) and src[i] != "'":
i += 2 if src[i] == "\\" else 1
i += 1
continue
if c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _find_brace_end(src: str, start: int) -> int:
"""Find the matching closing brace, respecting strings and nesting."""
if src[start] != "{":
return -1
depth = 1
i = start + 1
while i < len(src):
c = src[i]
if c == '"':
end = _find_string_end(src, i)
if end == -1:
return -1
i = end + 1
continue
if c == "'":
i += 1
while i < len(src) and src[i] != "'":
i += 2 if src[i] == "\\" else 1
i += 1
continue
if c == "/" and i + 1 < len(src) and src[i + 1] == "/":
while i < len(src) and src[i] != "\n":
i += 1
continue
if c == "/" and i + 1 < len(src) and src[i + 1] == "*":
i += 2
while i + 1 < len(src) and not (src[i] == "*" and src[i + 1] == "/"):
i += 1
i += 2
continue
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return i
i += 1
return -1
def _parse_schema_block(src: str) -> list[dict]:
"""Parse `{ foo: z.string()..., bar: z.number()..., }` into field dicts."""
inner = src[1:-1].strip()
if not inner:
return []
fields = _split_top_level(inner, ",")
params: list[dict] = []
for field in fields:
field = field.strip().rstrip(",").strip()
if not field or field.startswith("//"):
continue
p = _parse_field(field)
if p:
params.append(p)
return params
def _split_top_level(src: str, sep: str) -> list[str]:
"""Split on `sep` at depth 0 (outside brackets/parens/strings)."""
parts: list[str] = []
depth = 0
buf: list[str] = []
i = 0
while i < len(src):
c = src[i]
if c == '"':
end = _find_string_end(src, i)
if end == -1:
buf.append(src[i:])
break
buf.append(src[i:end + 1])
i = end + 1
continue
if c == "'":
start = i
i += 1
while i < len(src) and src[i] != "'":
i += 2 if src[i] == "\\" else 1
if i >= len(src):
# Unterminated single-quoted string; bail
buf.append(src[start:])
break
buf.append(src[start:i + 1])
i += 1
continue
if c in "([{":
depth += 1
elif c in ")]}":
depth -= 1
if c == sep and depth == 0:
parts.append("".join(buf))
buf = []
i += 1
continue
buf.append(c)
i += 1
if buf:
parts.append("".join(buf))
return parts
_TYPE_MAP = {
"string": "string",
"number": "number",
"boolean": "boolean",
"array": "array",
"object": "object",
"enum": "string", # enums become strings with choices
"literal": "string", # literals become strings with one choice
"any": "string",
"unknown": "string",
"null": "null",
"nullable": "string",
"record": "object",
}
def _parse_field(field: str) -> dict | None:
"""Parse one field: `name: z.TYPE(...).modifier().describe(...)`."""
m = re.match(r"(\w+)\s*:\s*(.*)", field, re.DOTALL)
if not m:
return None
name = m.group(1)
value = m.group(2).strip()
# Extract root Zod type
root_match = re.match(r"z\.(?:coerce\.)?(\w+)", value)
if not root_match:
return None
zod_type = root_match.group(1)
json_type = _TYPE_MAP.get(zod_type, "string")
# Skip past the root call's parens so we can look at modifiers alone.
root_args_text = ""
modifier_text = ""
after_root_start = root_match.end()
if after_root_start < len(value) and value[after_root_start] == "(":
close_pos = _find_matching_paren(value, after_root_start)
if close_pos == -1:
return None
root_args_text = value[after_root_start + 1:close_pos]
modifier_text = value[close_pos + 1:]
else:
# Root has no call (e.g. `z.string` without parens) — unusual but handled
modifier_text = value[after_root_start:]
# Parse modifier chain at top level only (nested modifiers inside
# root_args_text are ignored, which is the fix for the old nested-describe bug).
modifiers = _parse_modifier_chain(modifier_text)
optional = "optional" in modifiers
nullable = "nullable" in modifiers
default_val = modifiers.get("default")
description = modifiers.get("describe", "")
# Enum / literal choices
choices = None
if zod_type == "enum":
enum_match = re.match(r"\s*\[([^\]]*)\]", root_args_text, re.DOTALL)
if enum_match:
choices = []
for s in enum_match.group(1).split(","):
s = s.strip().strip('"').strip("'")
if s:
choices.append(s)
elif zod_type == "literal":
lit_match = re.match(r'\s*"((?:[^"\\]|\\.)*)"', root_args_text)
if lit_match:
choices = [_decode_js_string(lit_match.group(1))]
is_required = not (optional or nullable or default_val is not None)
return {
"name": name,
"type": json_type,
"description": description,
"required": is_required,
"default": default_val.strip() if default_val else None,
"choices": choices,
}
def _parse_modifier_chain(text: str) -> dict:
"""Parse `.foo(args).bar(args)...` returning {method: args_str}.
Walks the chain sequentially. For each `.method(...)` found, records
the argument text (inner of the parens). For `.describe(...)`, also
decodes the string literal if the arg looks like `"..."`.
"""
result: dict = {}
i = 0
while i < len(text):
c = text[i]
if c in " \t\r\n":
i += 1
continue
if c != ".":
break # end of modifier chain
m = re.match(r"\.(\w+)\s*\(", text[i:])
if not m:
break
method = m.group(1)
arg_open = i + m.end() - 1 # position of '('
arg_close = _find_matching_paren(text, arg_open)
if arg_close == -1:
break
arg_content = text[arg_open + 1:arg_close]
if method == "describe":
# Handle both double- and single-quoted string literals.
dq_match = re.match(
r'\s*"((?:[^"\\]|\\.)*)"\s*',
arg_content,
)
sq_match = re.match(
r"\s*'((?:[^'\\]|\\.)*)'\s*",
arg_content,
)
if dq_match:
arg_content = _decode_js_string(dq_match.group(1))
elif sq_match:
arg_content = _decode_js_string(sq_match.group(1))
result[method] = arg_content
i = arg_close + 1
return result
def _param_to_jsonschema(param: dict) -> dict:
schema: dict = {"type": param["type"]}
if param.get("description"):
schema["description"] = param["description"]
if param.get("choices"):
schema["enum"] = param["choices"]
default = param.get("default")
if default is not None:
schema["default"] = _coerce_default(default, param["type"])
return schema
def _coerce_default(raw: str, json_type: str):
"""Coerce a Zod ``.default(...)`` raw text into the JSON Schema type.
The parser captures defaults as raw JS text (e.g. ``"false"``,
``"42"``, ``"\"auto\""``). We convert to the matching Python/JSON
primitive so the bundled JSON Schema is type-correct.
"""
raw = raw.strip()
if json_type == "boolean":
if raw == "true":
return True
if raw == "false":
return False
return raw
if json_type in ("number", "integer"):
try:
if "." in raw:
return float(raw)
return int(raw)
except ValueError:
return raw
if json_type == "null" or raw == "null":
return None
# String / array / object — try to strip quotes for plain string defaults
if (raw.startswith('"') and raw.endswith('"')) or (
raw.startswith("'") and raw.endswith("'")
):
return raw[1:-1]
return raw
def _extract_pkg_version(index_js_path: Path) -> str:
"""Try to read version from sibling package.json."""
pkg = index_js_path.parent / "package.json"
if not pkg.is_file():
return "unknown"
try:
data = json.loads(pkg.read_text())
return data.get("version", "unknown")
except Exception:
return "unknown"
def main() -> int:
if len(sys.argv) < 2:
print(
"Usage: extract_tools.py <path/to/safari-mcp/index.js> [output.json]",
file=sys.stderr,
)
return 2
index_path = Path(sys.argv[1]).expanduser().resolve()
if not index_path.is_file():
print(f"Error: {index_path} not found", file=sys.stderr)
return 1
source = index_path.read_text()
tools = extract_tools(source)
out = {
"source_version": _extract_pkg_version(index_path),
"source_basename": index_path.name, # no absolute path — privacy
"tool_count": len(tools),
"tools": tools,
}
if len(sys.argv) >= 3:
out_path = Path(sys.argv[2]).expanduser().resolve()
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(out, indent=2, ensure_ascii=False))
print(
f"Extracted {len(tools)} tools from safari-mcp v{out['source_version']}",
file=sys.stderr,
)
print(f"Wrote {out_path}", file=sys.stderr)
else:
print(json.dumps(out, indent=2, ensure_ascii=False))
print(
f"Extracted {len(tools)} tools from safari-mcp v{out['source_version']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
from pathlib import Path
from setuptools import setup, find_namespace_packages
ROOT = Path(__file__).parent
README = ROOT / "cli_anything/safari/README.md"
def read_readme():
try:
return README.read_text(encoding="utf-8")
except FileNotFoundError:
return ""
setup(
name="cli-anything-safari",
version="1.0.0",
author="CLI Anything Contributors",
author_email="noreply@example.com",
description="CLI harness for Safari browser automation via safari-mcp MCP server",
long_description=read_readme(),
long_description_content_type="text/markdown",
url="https://github.com/HKUDS/CLI-Anything",
project_urls={
"Homepage": "https://github.com/HKUDS/CLI-Anything",
"Issues": "https://github.com/HKUDS/CLI-Anything/issues",
"Upstream": "https://github.com/achiya-automation/safari-mcp",
},
license="MIT",
packages=find_namespace_packages(include=["cli_anything.*"]),
python_requires=">=3.10",
install_requires=[
"click>=8.1,<9.0",
"prompt-toolkit>=3.0,<4.0",
"mcp>=1.0.0,<2.0.0",
],
extras_require={
"dev": [
"pytest>=7",
"pytest-cov>=4",
"build",
"twine",
],
},
entry_points={
"console_scripts": [
"cli-anything-safari=cli_anything.safari.safari_cli:main",
],
},
package_data={
"cli_anything.safari": [
"skills/*.md",
"resources/*.json",
"README.md",
],
},
include_package_data=True,
zip_safe=False,
keywords="cli browser automation mcp safari macos ai-agent",
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Topic :: Internet :: WWW/HTTP :: Browsers",
"Topic :: Software Development :: Testing",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Operating System :: MacOS :: MacOS X",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
)