agent-shell-py 0.2.4__tar.gz → 0.2.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/AGENTS.md +11 -1
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/PKG-INFO +12 -4
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/README.md +11 -3
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/agent_parameter_comparison.md +7 -7
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/invoking-cli-agents/SKILL.md +2 -1
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/_version.py +2 -2
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/copilot_cli_adapter.py +1 -1
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/cursor_adapter.py +168 -7
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_integration.py +13 -0
- agent_shell_py-0.2.5/tests/integration/test_cursor_mcp_integration.py +367 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_process_lifecycle.py +9 -5
- agent_shell_py-0.2.4/tests/integration/test_cursor_mcp_integration.py +0 -36
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/build.yml +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/ci.yml +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.github/workflows/publish.yml +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.gitignore +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/.python-version +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/LICENSE +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/assets/skill_banner.png +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/disabled_tools.md +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/info.md +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/total_token_count.md +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/pyproject.toml +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/delegating-code-review/SKILL.md +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/skills/invoking-cli-agents/api-reference.md +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/agent_adapter_protocol.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/claude_code_adapter.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/codex_adapter.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/grok_adapter.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/health.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/model_discovery.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/opencode_adapter.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/outcome.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/pi_adapter.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/response.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/stderr_format.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/tool_denial.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/models/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/models/agent.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/process_cleanup.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/shell.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/conftest.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_claude_code_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_codex_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_copilot_cli_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_cursor_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_grok_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_health_check_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_model_discovery_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_opencode_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/e2e/test_pi_e2e.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_codex_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_codex_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_cursor_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_grok_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_grok_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_health_check_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_model_discovery_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_pi_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_pi_mcp_integration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/__init__.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/adapter_matrix.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/codex_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/copilot_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/cursor_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/grok_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/opencode_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/pi_fixtures.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_adapter_transport.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_codex_warnings.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_copilot_cli_stream.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_cursor_warnings.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_execute_outcome.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_grok_warnings.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_health_probe.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_mcp_server_spec.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_model_discovery.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_models.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_spawn.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_opencode_stream.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_cancel.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_execute.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_parse_event.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_pi_warnings.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_process_cleanup.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_process_group_registration.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_response_aggregation.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell_cancellation.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_shell_mcp.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_stderr_format.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_stream.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/unit/test_tool_denial.py +0 -0
- {agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/uv.lock +0 -0
|
@@ -171,9 +171,19 @@ All adapters write to user-scope configuration:
|
|
|
171
171
|
| OpenCode | direct JSON file write | `~/.config/opencode/opencode.json` |
|
|
172
172
|
| Copilot CLI | direct JSON file write | `~/.copilot/mcp-config.json` |
|
|
173
173
|
| Codex | `codex mcp add` subprocess | Codex config |
|
|
174
|
+
| Cursor | direct JSON file write | `~/.cursor/mcp.json` |
|
|
174
175
|
| Grok | `grok mcp add --scope user` subprocess | `~/.grok/config.toml` (managed by CLI) |
|
|
175
176
|
|
|
176
|
-
Adds are idempotent (
|
|
177
|
+
Adds are idempotent (update existing entries with the same name). Cursor preserves native fields
|
|
178
|
+
that `MCPServerSpec` cannot represent when an update keeps the same transport, and writes its file
|
|
179
|
+
atomically with user-only permissions. Removes warn rather than raise when the named server is not
|
|
180
|
+
found. Claude Code listing reads the user-scope `mcpServers`
|
|
181
|
+
entries from `~/.claude.json` directly, avoiding the health checks and human-readable output of
|
|
182
|
+
`claude mcp list`. Cursor manages user-scope MCP entries directly in `~/.cursor/mcp.json` because
|
|
183
|
+
its `mcp` subcommands have no add/remove commands. Grok listing reads user-scope `mcp_servers`
|
|
184
|
+
entries from `~/.grok/config.toml` directly for the same reason. Pi's MCP add/remove/list methods
|
|
185
|
+
raise `NotImplementedError`. Pi manages capability via `pi install` extensions, which needs
|
|
186
|
+
investigation before wiring up.
|
|
177
187
|
|
|
178
188
|
## Test Philosophy
|
|
179
189
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-shell-py
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: A lightweight abstraction for executing CLI coding agents headlessly
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -276,8 +276,9 @@ print(f"Session: {response.session_id}")
|
|
|
276
276
|
> tools are auto-*rejected* but the run still completes. `allowed_tools`, `effort`, and
|
|
277
277
|
> `disallowed_tools` are **ignored** — Cursor exposes no per-call tool policy or effort flag
|
|
278
278
|
> (tool policy lives in `.cursor/cli.json`), so each emits a `UserWarning`. On a Free plan
|
|
279
|
-
> only `model=None`/`"auto"` works. MCP
|
|
280
|
-
> `
|
|
279
|
+
> only `model=None`/`"auto"` works. MCP add/remove/list are supported by directly managing
|
|
280
|
+
> the user-scope `~/.cursor/mcp.json` file because `cursor-agent mcp` has no add/remove
|
|
281
|
+
> subcommands.
|
|
281
282
|
|
|
282
283
|
### Grok
|
|
283
284
|
|
|
@@ -348,7 +349,14 @@ await shell.add_mcp_server(MCPServerSpec(
|
|
|
348
349
|
))
|
|
349
350
|
```
|
|
350
351
|
|
|
351
|
-
`add_mcp_server`
|
|
352
|
+
`add_mcp_server` adds or updates a server with the same name. Cursor preserves native fields
|
|
353
|
+
that `MCPServerSpec` cannot represent when an update keeps the same transport. The configuration
|
|
354
|
+
is written atomically with user-only permissions. `remove_mcp_server` warns rather than raises
|
|
355
|
+
when the named server is not found. `list_mcp_servers()` works for
|
|
356
|
+
Claude Code, OpenCode, Copilot CLI, Codex, Cursor, and Grok. Claude Code reads user-scope
|
|
357
|
+
entries directly from `~/.claude.json`, Cursor from `~/.cursor/mcp.json`, and Grok from
|
|
358
|
+
`~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP
|
|
359
|
+
is not supported for Pi; all three MCP methods raise `NotImplementedError`.
|
|
352
360
|
|
|
353
361
|
## Logging
|
|
354
362
|
|
|
@@ -267,8 +267,9 @@ print(f"Session: {response.session_id}")
|
|
|
267
267
|
> tools are auto-*rejected* but the run still completes. `allowed_tools`, `effort`, and
|
|
268
268
|
> `disallowed_tools` are **ignored** — Cursor exposes no per-call tool policy or effort flag
|
|
269
269
|
> (tool policy lives in `.cursor/cli.json`), so each emits a `UserWarning`. On a Free plan
|
|
270
|
-
> only `model=None`/`"auto"` works. MCP
|
|
271
|
-
> `
|
|
270
|
+
> only `model=None`/`"auto"` works. MCP add/remove/list are supported by directly managing
|
|
271
|
+
> the user-scope `~/.cursor/mcp.json` file because `cursor-agent mcp` has no add/remove
|
|
272
|
+
> subcommands.
|
|
272
273
|
|
|
273
274
|
### Grok
|
|
274
275
|
|
|
@@ -339,7 +340,14 @@ await shell.add_mcp_server(MCPServerSpec(
|
|
|
339
340
|
))
|
|
340
341
|
```
|
|
341
342
|
|
|
342
|
-
`add_mcp_server`
|
|
343
|
+
`add_mcp_server` adds or updates a server with the same name. Cursor preserves native fields
|
|
344
|
+
that `MCPServerSpec` cannot represent when an update keeps the same transport. The configuration
|
|
345
|
+
is written atomically with user-only permissions. `remove_mcp_server` warns rather than raises
|
|
346
|
+
when the named server is not found. `list_mcp_servers()` works for
|
|
347
|
+
Claude Code, OpenCode, Copilot CLI, Codex, Cursor, and Grok. Claude Code reads user-scope
|
|
348
|
+
entries directly from `~/.claude.json`, Cursor from `~/.cursor/mcp.json`, and Grok from
|
|
349
|
+
`~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP
|
|
350
|
+
is not supported for Pi; all three MCP methods raise `NotImplementedError`.
|
|
343
351
|
|
|
344
352
|
## Logging
|
|
345
353
|
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/docs/development/agent_parameter_comparison.md
RENAMED
|
@@ -128,9 +128,9 @@ test checks all seven real discovery commands.
|
|
|
128
128
|
- **Headless mode**: `-p` / `--prompt` for one-shot; `--acp` for Agent Client Protocol
|
|
129
129
|
- **Model**: `--model <model>`; use `auto` to let Copilot choose
|
|
130
130
|
- **Effort**: `--effort` / `--reasoning-effort` with choices
|
|
131
|
-
`none|minimal|low|medium|high|xhigh|max`. AgentShell
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
`none|minimal|low|medium|high|xhigh|max`. AgentShell treats `None` and `""` as omitted, so
|
|
132
|
+
Copilot uses its default. Explicit values, including `"none"`, are accepted case-insensitively,
|
|
133
|
+
validated against these choices, and passed to Copilot via `--effort` in lowercase.
|
|
134
134
|
- **Allowed tools**: `--allow-tool`, `--deny-tool`, `--available-tools`,
|
|
135
135
|
`--excluded-tools`, `--allow-all-tools`
|
|
136
136
|
- **Auto-approve**: `--allow-all-tools`; `--allow-all` / `--yolo` also grant path and URL
|
|
@@ -193,10 +193,10 @@ test checks all seven real discovery commands.
|
|
|
193
193
|
cursor-agent ACCEPTS an unknown id — `--resume=<never-seen-uuid>` starts a session under that
|
|
194
194
|
id and echoes it back rather than failing, so id identity proves the flag was passed through
|
|
195
195
|
and honoured, not that a prior transcript was replayed (measured 2026-07-26)
|
|
196
|
-
- **MCP**: `cursor-agent mcp` = login/list/list-tools/enable/disable only (no add/remove)
|
|
197
|
-
servers
|
|
198
|
-
|
|
199
|
-
|
|
196
|
+
- **MCP**: `cursor-agent mcp` = login/list/list-tools/enable/disable only (no add/remove).
|
|
197
|
+
The adapter manages user-scope servers directly in `~/.cursor/mcp.json`, which provides the
|
|
198
|
+
transport details that `mcp list` omits. Writes are atomic and user-only; same-transport
|
|
199
|
+
updates preserve Cursor-native fields that `MCPServerSpec` cannot represent.
|
|
200
200
|
- **Usage**: the terminal `result` event carries `usage.outputTokens` (undocumented but real)
|
|
201
201
|
and `duration_ms`; there is no cost field, so `cost` is `0.0`
|
|
202
202
|
|
|
@@ -316,7 +316,8 @@ succeeded = saw_ok and error is None # absent result => succeeded stays
|
|
|
316
316
|
**not** run your prompt, so use the stream-based check above when you care about a specific
|
|
317
317
|
call's outcome.
|
|
318
318
|
- **MCP server management** — `add_mcp_server`, `remove_mcp_server`, `list_mcp_servers` manage
|
|
319
|
-
|
|
319
|
+
user-scope MCP configuration. Pi raises `NotImplementedError`; Cursor is managed directly
|
|
320
|
+
through `~/.cursor/mcp.json` because `cursor-agent mcp` has no add/remove subcommands.
|
|
320
321
|
|
|
321
322
|
See [api-reference.md](api-reference.md) for their full signatures and the `MCPServerSpec` model.
|
|
322
323
|
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.2.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
21
|
+
__version__ = version = '0.2.5'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 5)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/copilot_cli_adapter.py
RENAMED
|
@@ -48,7 +48,7 @@ _COPILOT_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def _normalize_effort(effort: str | None) -> str | None:
|
|
51
|
-
if effort is None:
|
|
51
|
+
if effort is None or effort == "":
|
|
52
52
|
return None
|
|
53
53
|
|
|
54
54
|
normalized = effort.lower() if isinstance(effort, str) else None
|
|
@@ -4,12 +4,15 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
import os
|
|
6
6
|
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from tempfile import NamedTemporaryFile
|
|
7
9
|
from typing import AsyncIterator
|
|
8
10
|
|
|
9
11
|
from agent_shell.models.agent import (
|
|
10
12
|
AgentResponse,
|
|
11
13
|
HealthCheckResult,
|
|
12
14
|
MCPServerSpec,
|
|
15
|
+
MCPServerType,
|
|
13
16
|
StreamEvent,
|
|
14
17
|
)
|
|
15
18
|
from agent_shell.process_cleanup import (
|
|
@@ -324,15 +327,173 @@ class CursorAdapter:
|
|
|
324
327
|
models.append(model.strip())
|
|
325
328
|
return models
|
|
326
329
|
|
|
330
|
+
def _mcp_config_path(self) -> Path:
|
|
331
|
+
return Path(os.path.expanduser("~/.cursor/mcp.json"))
|
|
332
|
+
|
|
333
|
+
def _read_mcp_config(self) -> dict:
|
|
334
|
+
path = self._mcp_config_path()
|
|
335
|
+
if not path.exists():
|
|
336
|
+
return {}
|
|
337
|
+
return json.loads(path.read_text())
|
|
338
|
+
|
|
339
|
+
def _write_mcp_config(self, config: dict) -> None:
|
|
340
|
+
path = self._mcp_config_path()
|
|
341
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
342
|
+
temporary_path: Path | None = None
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
with NamedTemporaryFile(
|
|
346
|
+
mode="w",
|
|
347
|
+
encoding="utf-8",
|
|
348
|
+
dir=path.parent,
|
|
349
|
+
prefix=f".{path.name}.",
|
|
350
|
+
delete=False,
|
|
351
|
+
) as temporary_file:
|
|
352
|
+
temporary_path = Path(temporary_file.name)
|
|
353
|
+
temporary_file.write(json.dumps(config, indent=2) + "\n")
|
|
354
|
+
temporary_file.flush()
|
|
355
|
+
os.fsync(temporary_file.fileno())
|
|
356
|
+
|
|
357
|
+
os.replace(temporary_path, path)
|
|
358
|
+
temporary_path = None
|
|
359
|
+
finally:
|
|
360
|
+
if temporary_path is not None:
|
|
361
|
+
temporary_path.unlink(missing_ok=True)
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _mcp_servers(config: dict) -> dict:
|
|
365
|
+
if not isinstance(config, dict):
|
|
366
|
+
raise ValueError("Cursor mcp.json root must be a JSON object")
|
|
367
|
+
|
|
368
|
+
servers = config.get("mcpServers", {})
|
|
369
|
+
if not isinstance(servers, dict):
|
|
370
|
+
raise ValueError("Cursor 'mcpServers' must be a JSON object")
|
|
371
|
+
|
|
372
|
+
config["mcpServers"] = servers
|
|
373
|
+
return servers
|
|
374
|
+
|
|
375
|
+
@staticmethod
|
|
376
|
+
def _mcp_entry_type(entry: dict) -> MCPServerType | None:
|
|
377
|
+
entry_type = entry.get("type")
|
|
378
|
+
if entry_type == "stdio":
|
|
379
|
+
return MCPServerType.STDIO
|
|
380
|
+
if entry_type in {"http", "sse"}:
|
|
381
|
+
return MCPServerType.HTTP
|
|
382
|
+
if entry_type is not None:
|
|
383
|
+
return None
|
|
384
|
+
if entry.get("command"):
|
|
385
|
+
return MCPServerType.STDIO
|
|
386
|
+
if entry.get("url"):
|
|
387
|
+
return MCPServerType.HTTP
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _string_list(value: object, field_name: str) -> list[str]:
|
|
392
|
+
if value is None:
|
|
393
|
+
return []
|
|
394
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
395
|
+
raise TypeError(f"{field_name} must be a list of strings")
|
|
396
|
+
return list(value)
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _string_mapping(value: object, field_name: str) -> dict[str, str]:
|
|
400
|
+
if value is None:
|
|
401
|
+
return {}
|
|
402
|
+
if not isinstance(value, dict) or not all(
|
|
403
|
+
isinstance(key, str) and isinstance(item, str)
|
|
404
|
+
for key, item in value.items()
|
|
405
|
+
):
|
|
406
|
+
raise TypeError(f"{field_name} must be an object with string values")
|
|
407
|
+
return dict(value)
|
|
408
|
+
|
|
327
409
|
async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None:
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
410
|
+
config = self._read_mcp_config()
|
|
411
|
+
servers = self._mcp_servers(config)
|
|
412
|
+
|
|
413
|
+
if mcp_server.type == MCPServerType.STDIO:
|
|
414
|
+
entry = {
|
|
415
|
+
"command": mcp_server.command,
|
|
416
|
+
"args": list(mcp_server.args),
|
|
417
|
+
"env": dict(mcp_server.env),
|
|
418
|
+
}
|
|
419
|
+
else:
|
|
420
|
+
entry = {
|
|
421
|
+
"url": mcp_server.url,
|
|
422
|
+
"headers": dict(mcp_server.headers),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
existing = servers.get(mcp_server.name)
|
|
426
|
+
if (
|
|
427
|
+
isinstance(existing, dict)
|
|
428
|
+
and self._mcp_entry_type(existing) == mcp_server.type
|
|
429
|
+
):
|
|
430
|
+
entry = {**existing, **entry}
|
|
431
|
+
|
|
432
|
+
servers[mcp_server.name] = entry
|
|
433
|
+
self._write_mcp_config(config)
|
|
333
434
|
|
|
334
435
|
async def remove_mcp_server(self, mcp_server_name: str) -> None:
|
|
335
|
-
|
|
436
|
+
config = self._read_mcp_config()
|
|
437
|
+
servers = self._mcp_servers(config)
|
|
438
|
+
if mcp_server_name not in servers:
|
|
439
|
+
warnings.warn(
|
|
440
|
+
f"MCP server '{mcp_server_name}' not found in Cursor config",
|
|
441
|
+
UserWarning,
|
|
442
|
+
stacklevel=2,
|
|
443
|
+
)
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
del servers[mcp_server_name]
|
|
447
|
+
self._write_mcp_config(config)
|
|
336
448
|
|
|
337
449
|
async def list_mcp_servers(self) -> list[MCPServerSpec]:
|
|
338
|
-
|
|
450
|
+
config = self._read_mcp_config()
|
|
451
|
+
try:
|
|
452
|
+
servers = self._mcp_servers(config)
|
|
453
|
+
except ValueError as error:
|
|
454
|
+
warnings.warn(str(error), UserWarning, stacklevel=2)
|
|
455
|
+
return []
|
|
456
|
+
|
|
457
|
+
result: list[MCPServerSpec] = []
|
|
458
|
+
for name, entry in servers.items():
|
|
459
|
+
if not isinstance(entry, dict):
|
|
460
|
+
warnings.warn(
|
|
461
|
+
f"Skipping malformed MCP entry '{name}': expected object, "
|
|
462
|
+
f"got {type(entry).__name__}",
|
|
463
|
+
UserWarning,
|
|
464
|
+
stacklevel=2,
|
|
465
|
+
)
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
try:
|
|
469
|
+
entry_type = self._mcp_entry_type(entry)
|
|
470
|
+
if entry_type == MCPServerType.STDIO:
|
|
471
|
+
result.append(MCPServerSpec(
|
|
472
|
+
name=name,
|
|
473
|
+
type=MCPServerType.STDIO,
|
|
474
|
+
command=entry.get("command"),
|
|
475
|
+
args=self._string_list(entry.get("args"), "args"),
|
|
476
|
+
env=self._string_mapping(entry.get("env"), "env"),
|
|
477
|
+
))
|
|
478
|
+
elif entry_type == MCPServerType.HTTP:
|
|
479
|
+
result.append(MCPServerSpec(
|
|
480
|
+
name=name,
|
|
481
|
+
type=MCPServerType.HTTP,
|
|
482
|
+
url=entry.get("url"),
|
|
483
|
+
headers=self._string_mapping(entry.get("headers"), "headers"),
|
|
484
|
+
))
|
|
485
|
+
else:
|
|
486
|
+
warnings.warn(
|
|
487
|
+
f"Skipping malformed MCP entry '{name}': "
|
|
488
|
+
"unsupported or missing transport",
|
|
489
|
+
UserWarning,
|
|
490
|
+
stacklevel=2,
|
|
491
|
+
)
|
|
492
|
+
except (TypeError, ValueError) as error:
|
|
493
|
+
warnings.warn(
|
|
494
|
+
f"Skipping malformed MCP entry '{name}': {error}",
|
|
495
|
+
UserWarning,
|
|
496
|
+
stacklevel=2,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
return result
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_integration.py
RENAMED
|
@@ -224,6 +224,19 @@ class TestCommandConstructionIntegration:
|
|
|
224
224
|
assert "--effort" in cmd_args
|
|
225
225
|
assert cmd_args[cmd_args.index("--effort") + 1] == "high"
|
|
226
226
|
|
|
227
|
+
async def test_execute_omits_effort_flag_for_empty_string(self):
|
|
228
|
+
# Arrange
|
|
229
|
+
shell = AgentShell(agent_type=AgentType.COPILOT_CLI)
|
|
230
|
+
ndjson = [MESSAGE_DELTA_EVENT, RESULT_EVENT_SUCCESS]
|
|
231
|
+
mock_process = _make_mock_process(ndjson)
|
|
232
|
+
|
|
233
|
+
# Act
|
|
234
|
+
with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec:
|
|
235
|
+
await shell.execute(cwd="/tmp", prompt="test", effort="")
|
|
236
|
+
|
|
237
|
+
# Assert
|
|
238
|
+
assert "--effort" not in mock_exec.call_args[0]
|
|
239
|
+
|
|
227
240
|
async def test_prompt_is_second_argument(self):
|
|
228
241
|
# Arrange
|
|
229
242
|
shell = AgentShell(agent_type=AgentType.COPILOT_CLI)
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from agent_shell.shell import AgentShell
|
|
8
|
+
from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def isolated_home(tmp_path, monkeypatch):
|
|
13
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
14
|
+
return tmp_path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_config(home: Path) -> dict:
|
|
18
|
+
return json.loads((home / ".cursor" / "mcp.json").read_text())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestCursorMcp:
|
|
22
|
+
async def test_adds_stdio_server_to_user_config(self, isolated_home):
|
|
23
|
+
# Arrange
|
|
24
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
25
|
+
spec = MCPServerSpec(
|
|
26
|
+
name="forgetful",
|
|
27
|
+
type=MCPServerType.STDIO,
|
|
28
|
+
command="uvx",
|
|
29
|
+
args=["forgetful-ai"],
|
|
30
|
+
env={"FORGETFUL_API_KEY": "secret"},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Act
|
|
34
|
+
await shell.add_mcp_server(spec)
|
|
35
|
+
|
|
36
|
+
# Assert
|
|
37
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
38
|
+
assert _read_config(isolated_home) == {
|
|
39
|
+
"mcpServers": {
|
|
40
|
+
"forgetful": {
|
|
41
|
+
"command": "uvx",
|
|
42
|
+
"args": ["forgetful-ai"],
|
|
43
|
+
"env": {"FORGETFUL_API_KEY": "secret"},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
assert stat.S_IMODE(config_path.stat().st_mode) == 0o600
|
|
48
|
+
|
|
49
|
+
async def test_failed_atomic_replace_preserves_existing_config(
|
|
50
|
+
self,
|
|
51
|
+
isolated_home,
|
|
52
|
+
monkeypatch,
|
|
53
|
+
):
|
|
54
|
+
# Arrange
|
|
55
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
56
|
+
config_path.parent.mkdir(parents=True)
|
|
57
|
+
original = {"mcpServers": {"existing": {"command": "existing-command"}}}
|
|
58
|
+
config_path.write_text(json.dumps(original))
|
|
59
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
60
|
+
|
|
61
|
+
def fail_replace(source, destination):
|
|
62
|
+
raise OSError("simulated replace failure")
|
|
63
|
+
|
|
64
|
+
monkeypatch.setattr("agent_shell.adapters.cursor_adapter.os.replace", fail_replace)
|
|
65
|
+
|
|
66
|
+
# Act / Assert
|
|
67
|
+
with pytest.raises(OSError, match="simulated replace failure"):
|
|
68
|
+
await shell.add_mcp_server(MCPServerSpec(
|
|
69
|
+
name="new",
|
|
70
|
+
type=MCPServerType.STDIO,
|
|
71
|
+
command="new-command",
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
assert _read_config(isolated_home) == original
|
|
75
|
+
assert list(config_path.parent.iterdir()) == [config_path]
|
|
76
|
+
|
|
77
|
+
async def test_add_rejects_malformed_server_collection_without_writing(
|
|
78
|
+
self,
|
|
79
|
+
isolated_home,
|
|
80
|
+
):
|
|
81
|
+
# Arrange
|
|
82
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
83
|
+
config_path.parent.mkdir(parents=True)
|
|
84
|
+
original = {"mcpServers": ["broken"]}
|
|
85
|
+
config_path.write_text(json.dumps(original))
|
|
86
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
87
|
+
|
|
88
|
+
# Act / Assert
|
|
89
|
+
with pytest.raises(ValueError, match="mcpServers"):
|
|
90
|
+
await shell.add_mcp_server(MCPServerSpec(
|
|
91
|
+
name="new",
|
|
92
|
+
type=MCPServerType.STDIO,
|
|
93
|
+
command="new-command",
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
assert _read_config(isolated_home) == original
|
|
97
|
+
|
|
98
|
+
async def test_same_transport_update_preserves_cursor_only_fields(self, isolated_home):
|
|
99
|
+
# Arrange
|
|
100
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
101
|
+
config_path.parent.mkdir(parents=True)
|
|
102
|
+
config_path.write_text(json.dumps({
|
|
103
|
+
"mcpServers": {
|
|
104
|
+
"local": {
|
|
105
|
+
"type": "stdio",
|
|
106
|
+
"command": "old-command",
|
|
107
|
+
"args": ["old-arg"],
|
|
108
|
+
"env": {"OLD": "value"},
|
|
109
|
+
"envFile": "~/.cursor/mcp.env",
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}))
|
|
113
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
114
|
+
replacement = MCPServerSpec(
|
|
115
|
+
name="local",
|
|
116
|
+
type=MCPServerType.STDIO,
|
|
117
|
+
command="new-command",
|
|
118
|
+
args=["new-arg"],
|
|
119
|
+
env={"NEW": "value"},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Act
|
|
123
|
+
await shell.add_mcp_server(replacement)
|
|
124
|
+
|
|
125
|
+
# Assert
|
|
126
|
+
assert _read_config(isolated_home)["mcpServers"]["local"] == {
|
|
127
|
+
"type": "stdio",
|
|
128
|
+
"command": "new-command",
|
|
129
|
+
"args": ["new-arg"],
|
|
130
|
+
"env": {"NEW": "value"},
|
|
131
|
+
"envFile": "~/.cursor/mcp.env",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async def test_remote_update_preserves_auth_and_sse_type(self, isolated_home):
|
|
135
|
+
# Arrange
|
|
136
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
137
|
+
config_path.parent.mkdir(parents=True)
|
|
138
|
+
config_path.write_text(json.dumps({
|
|
139
|
+
"mcpServers": {
|
|
140
|
+
"remote": {
|
|
141
|
+
"type": "sse",
|
|
142
|
+
"url": "https://old.example.com/sse",
|
|
143
|
+
"headers": {"OLD": "value"},
|
|
144
|
+
"auth": {"CLIENT_ID": "client-id"},
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}))
|
|
148
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
149
|
+
replacement = MCPServerSpec(
|
|
150
|
+
name="remote",
|
|
151
|
+
type=MCPServerType.HTTP,
|
|
152
|
+
url="https://new.example.com/sse",
|
|
153
|
+
headers={"NEW": "value"},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Act
|
|
157
|
+
await shell.add_mcp_server(replacement)
|
|
158
|
+
|
|
159
|
+
# Assert
|
|
160
|
+
assert _read_config(isolated_home)["mcpServers"]["remote"] == {
|
|
161
|
+
"type": "sse",
|
|
162
|
+
"url": "https://new.example.com/sse",
|
|
163
|
+
"headers": {"NEW": "value"},
|
|
164
|
+
"auth": {"CLIENT_ID": "client-id"},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async def test_transport_change_drops_old_transport_fields(self, isolated_home):
|
|
168
|
+
# Arrange
|
|
169
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
170
|
+
config_path.parent.mkdir(parents=True)
|
|
171
|
+
config_path.write_text(json.dumps({
|
|
172
|
+
"mcpServers": {
|
|
173
|
+
"changing": {
|
|
174
|
+
"type": "stdio",
|
|
175
|
+
"command": "old-command",
|
|
176
|
+
"envFile": "~/.cursor/mcp.env",
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}))
|
|
180
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
181
|
+
|
|
182
|
+
# Act
|
|
183
|
+
await shell.add_mcp_server(MCPServerSpec(
|
|
184
|
+
name="changing",
|
|
185
|
+
type=MCPServerType.HTTP,
|
|
186
|
+
url="https://example.com/mcp",
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
# Assert
|
|
190
|
+
assert _read_config(isolated_home)["mcpServers"]["changing"] == {
|
|
191
|
+
"url": "https://example.com/mcp",
|
|
192
|
+
"headers": {},
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async def test_adds_http_server_without_replacing_existing_config(self, isolated_home):
|
|
196
|
+
# Arrange
|
|
197
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
198
|
+
config_path.parent.mkdir(parents=True)
|
|
199
|
+
config_path.write_text(json.dumps({
|
|
200
|
+
"mcpServers": {"existing": {"command": "existing-command"}},
|
|
201
|
+
"unrelated": {"preserve": True},
|
|
202
|
+
}))
|
|
203
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
204
|
+
spec = MCPServerSpec(
|
|
205
|
+
name="remote",
|
|
206
|
+
type=MCPServerType.HTTP,
|
|
207
|
+
url="https://example.com/mcp",
|
|
208
|
+
headers={"Authorization": "Bearer secret"},
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Act
|
|
212
|
+
await shell.add_mcp_server(spec)
|
|
213
|
+
|
|
214
|
+
# Assert
|
|
215
|
+
assert _read_config(isolated_home) == {
|
|
216
|
+
"mcpServers": {
|
|
217
|
+
"existing": {"command": "existing-command"},
|
|
218
|
+
"remote": {
|
|
219
|
+
"url": "https://example.com/mcp",
|
|
220
|
+
"headers": {"Authorization": "Bearer secret"},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
"unrelated": {"preserve": True},
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async def test_removes_server_without_replacing_existing_config(self, isolated_home):
|
|
227
|
+
# Arrange
|
|
228
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
229
|
+
config_path.parent.mkdir(parents=True)
|
|
230
|
+
config_path.write_text(json.dumps({
|
|
231
|
+
"mcpServers": {
|
|
232
|
+
"forgetful": {"command": "uvx"},
|
|
233
|
+
"existing": {"command": "existing-command"},
|
|
234
|
+
},
|
|
235
|
+
"unrelated": {"preserve": True},
|
|
236
|
+
}))
|
|
237
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
238
|
+
|
|
239
|
+
# Act
|
|
240
|
+
await shell.remove_mcp_server("forgetful")
|
|
241
|
+
|
|
242
|
+
# Assert
|
|
243
|
+
assert _read_config(isolated_home) == {
|
|
244
|
+
"mcpServers": {"existing": {"command": "existing-command"}},
|
|
245
|
+
"unrelated": {"preserve": True},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async def test_remove_warns_when_server_is_missing(self, isolated_home):
|
|
249
|
+
# Arrange
|
|
250
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
251
|
+
|
|
252
|
+
# Act / Assert
|
|
253
|
+
with pytest.warns(UserWarning, match="not found"):
|
|
254
|
+
await shell.remove_mcp_server("missing")
|
|
255
|
+
|
|
256
|
+
assert not (isolated_home / ".cursor" / "mcp.json").exists()
|
|
257
|
+
|
|
258
|
+
async def test_lists_stdio_and_http_servers_from_user_config(self, isolated_home):
|
|
259
|
+
# Arrange
|
|
260
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
261
|
+
config_path.parent.mkdir(parents=True)
|
|
262
|
+
config_path.write_text(json.dumps({
|
|
263
|
+
"mcpServers": {
|
|
264
|
+
"forgetful": {
|
|
265
|
+
"command": "uvx",
|
|
266
|
+
"args": ["forgetful-ai"],
|
|
267
|
+
"env": {"K": "V"},
|
|
268
|
+
},
|
|
269
|
+
"remote": {
|
|
270
|
+
"url": "https://example.com/mcp",
|
|
271
|
+
"headers": {"Authorization": "Bearer secret"},
|
|
272
|
+
},
|
|
273
|
+
}
|
|
274
|
+
}))
|
|
275
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
276
|
+
|
|
277
|
+
# Act
|
|
278
|
+
servers = await shell.list_mcp_servers()
|
|
279
|
+
|
|
280
|
+
# Assert
|
|
281
|
+
assert servers == [
|
|
282
|
+
MCPServerSpec(
|
|
283
|
+
name="forgetful",
|
|
284
|
+
type=MCPServerType.STDIO,
|
|
285
|
+
command="uvx",
|
|
286
|
+
args=["forgetful-ai"],
|
|
287
|
+
env={"K": "V"},
|
|
288
|
+
),
|
|
289
|
+
MCPServerSpec(
|
|
290
|
+
name="remote",
|
|
291
|
+
type=MCPServerType.HTTP,
|
|
292
|
+
url="https://example.com/mcp",
|
|
293
|
+
headers={"Authorization": "Bearer secret"},
|
|
294
|
+
),
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
async def test_list_honours_explicit_transport_type(self, isolated_home):
|
|
298
|
+
# Arrange
|
|
299
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
300
|
+
config_path.parent.mkdir(parents=True)
|
|
301
|
+
config_path.write_text(json.dumps({
|
|
302
|
+
"mcpServers": {
|
|
303
|
+
"legacy-sse": {
|
|
304
|
+
"type": "sse",
|
|
305
|
+
"url": "https://example.com/sse",
|
|
306
|
+
"command": "must-not-win",
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}))
|
|
310
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
311
|
+
|
|
312
|
+
# Act
|
|
313
|
+
servers = await shell.list_mcp_servers()
|
|
314
|
+
|
|
315
|
+
# Assert
|
|
316
|
+
assert servers == [MCPServerSpec(
|
|
317
|
+
name="legacy-sse",
|
|
318
|
+
type=MCPServerType.HTTP,
|
|
319
|
+
url="https://example.com/sse",
|
|
320
|
+
)]
|
|
321
|
+
|
|
322
|
+
async def test_list_skips_malformed_entries_with_warnings(self, isolated_home):
|
|
323
|
+
# Arrange
|
|
324
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
325
|
+
config_path.parent.mkdir(parents=True)
|
|
326
|
+
config_path.write_text(json.dumps({
|
|
327
|
+
"mcpServers": {
|
|
328
|
+
"not-an-object": "broken",
|
|
329
|
+
"missing-transport": {"args": ["orphaned"]},
|
|
330
|
+
"invalid-args": {"command": "uvx", "args": 42},
|
|
331
|
+
"string-args": {"command": "uvx", "args": "--port"},
|
|
332
|
+
"invalid-env": {"command": "uvx", "env": {"PORT": 42}},
|
|
333
|
+
"good": {"command": "good-command"},
|
|
334
|
+
}
|
|
335
|
+
}))
|
|
336
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
337
|
+
|
|
338
|
+
# Act
|
|
339
|
+
with pytest.warns(UserWarning) as captured:
|
|
340
|
+
servers = await shell.list_mcp_servers()
|
|
341
|
+
|
|
342
|
+
# Assert
|
|
343
|
+
messages = [str(warning.message) for warning in captured]
|
|
344
|
+
assert any("not-an-object" in message for message in messages)
|
|
345
|
+
assert any("missing-transport" in message for message in messages)
|
|
346
|
+
assert any("invalid-args" in message for message in messages)
|
|
347
|
+
assert any("string-args" in message for message in messages)
|
|
348
|
+
assert any("invalid-env" in message for message in messages)
|
|
349
|
+
assert servers == [MCPServerSpec(
|
|
350
|
+
name="good",
|
|
351
|
+
type=MCPServerType.STDIO,
|
|
352
|
+
command="good-command",
|
|
353
|
+
)]
|
|
354
|
+
|
|
355
|
+
async def test_list_warns_when_mcp_servers_is_not_an_object(self, isolated_home):
|
|
356
|
+
# Arrange
|
|
357
|
+
config_path = isolated_home / ".cursor" / "mcp.json"
|
|
358
|
+
config_path.parent.mkdir(parents=True)
|
|
359
|
+
config_path.write_text(json.dumps({"mcpServers": ["broken"]}))
|
|
360
|
+
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
361
|
+
|
|
362
|
+
# Act
|
|
363
|
+
with pytest.warns(UserWarning, match="mcpServers"):
|
|
364
|
+
servers = await shell.list_mcp_servers()
|
|
365
|
+
|
|
366
|
+
# Assert
|
|
367
|
+
assert servers == []
|
|
@@ -244,11 +244,15 @@ async def test_guardian_does_not_inherit_the_owners_file_descriptors(tmp_path):
|
|
|
244
244
|
guardian = process_cleanup._guardians[process]
|
|
245
245
|
|
|
246
246
|
try:
|
|
247
|
-
# Act
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
247
|
+
# Act — /proc is a live view: an fd can close after listdir() but before readlink().
|
|
248
|
+
# That normal Linux race made the tag build flaky with FileNotFoundError. Ignore only
|
|
249
|
+
# entries that vanished; any descriptor still open is captured and asserted below.
|
|
250
|
+
guardian_fds: dict[str, str] = {}
|
|
251
|
+
for fd in os.listdir(f"/proc/{guardian.pid}/fd"):
|
|
252
|
+
try:
|
|
253
|
+
guardian_fds[fd] = os.readlink(f"/proc/{guardian.pid}/fd/{fd}")
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
continue
|
|
252
256
|
|
|
253
257
|
# Assert — a guardian must not keep an owner's output pipe, log, or native FD open.
|
|
254
258
|
assert guardian_fds["1"] == "/dev/null"
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
|
|
3
|
-
from agent_shell.shell import AgentShell
|
|
4
|
-
from agent_shell.models.agent import AgentType, MCPServerSpec, MCPServerType
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class TestCursorMcpNotImplemented:
|
|
8
|
-
# Cursor's `mcp` subcommands are login/list/list-tools/enable/disable ONLY — no add/remove.
|
|
9
|
-
# Servers are declared in .cursor/mcp.json, and `mcp list` reports only `name: status`
|
|
10
|
-
# (not the transport/command/url MCPServerSpec needs), so no method can be faithfully
|
|
11
|
-
# supported. They must fail loud, not silently no-op.
|
|
12
|
-
async def test_add_mcp_server_raises(self):
|
|
13
|
-
# Arrange
|
|
14
|
-
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
15
|
-
spec = MCPServerSpec(name="forgetful", type=MCPServerType.STDIO, command="uvx",
|
|
16
|
-
args=["forgetful-ai"])
|
|
17
|
-
|
|
18
|
-
# Act / Assert
|
|
19
|
-
with pytest.raises(NotImplementedError):
|
|
20
|
-
await shell.add_mcp_server(spec)
|
|
21
|
-
|
|
22
|
-
async def test_remove_mcp_server_raises(self):
|
|
23
|
-
# Arrange
|
|
24
|
-
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
25
|
-
|
|
26
|
-
# Act / Assert
|
|
27
|
-
with pytest.raises(NotImplementedError):
|
|
28
|
-
await shell.remove_mcp_server("forgetful")
|
|
29
|
-
|
|
30
|
-
async def test_list_mcp_servers_raises(self):
|
|
31
|
-
# Arrange
|
|
32
|
-
shell = AgentShell(agent_type=AgentType.CURSOR)
|
|
33
|
-
|
|
34
|
-
# Act / Assert
|
|
35
|
-
with pytest.raises(NotImplementedError):
|
|
36
|
-
await shell.list_mcp_servers()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/agent_adapter_protocol.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/src/agent_shell/adapters/claude_code_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_claude_code_mcp_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_codex_mcp_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_copilot_cli_mcp_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_grok_mcp_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_health_check_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_model_discovery_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_integration.py
RENAMED
|
File without changes
|
{agent_shell_py-0.2.4 → agent_shell_py-0.2.5}/tests/integration/test_opencode_mcp_integration.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|