mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-08-31 01:42:25 +08:00
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:
@@ -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__/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
@@ -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**: 500ms–2s on first run (npx resolution + package fetch)
|
||||
- **Warm start**: ~200–500ms (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 5–50 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)
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
],
|
||||
)
|
||||
Reference in New Issue
Block a user