diff --git a/Makefile b/Makefile index 7568581..e6fa6e9 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,11 @@ -.PHONY: install-commands install-commands-force test sync help +.PHONY: install-commands install-commands-force install-systemd-user setup-provider-presets test sync help help: @echo "Available targets:" @echo " install-commands Install slash commands to ~/.claude/commands/" @echo " install-commands-force Install slash commands (overwrite existing)" + @echo " install-systemd-user Install/update the user-level systemd service" + @echo " setup-provider-presets Install wrapper and create provider config templates" @echo " test Run pytest" @echo " sync Sync dependencies with uv" @@ -13,6 +15,12 @@ install-commands: install-commands-force: uv run scripts/install-commands.py --force +install-systemd-user: + bash scripts/install-systemd-user.sh + +setup-provider-presets: + bash scripts/setup-provider-presets.sh + test: uv run --group dev pytest diff --git a/README.md b/README.md index 748883a..019f19b 100644 --- a/README.md +++ b/README.md @@ -62,16 +62,88 @@ Workers can run either Claude Code or OpenAI Codex. Set `agent_type: "codex"` in - **HTTP Mode**: Run as a persistent service with streamable-http transport - **Config File**: Centralized configuration at `~/.maniple/config.json` +### HTTP Mode + +Minimal service command: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --allow-host 100.64.0.45:8766 +``` + +Use `--host` to change the bind address for streamable HTTP mode: + +```bash +uv run python -m maniple_mcp --http --host 0.0.0.0 --port 8766 +``` + +If you want DNS rebinding protection enabled for non-localhost clients, add +repeatable `--allow-host` entries and optional `--allow-origin` entries: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --allow-host 100.64.0.45:8766 \ + --allow-host my-tailnet-host.ts.net:8766 +``` + +For temporary experiments on a trusted network, you can disable the protection: + +```bash +uv run python -m maniple_mcp \ + --http \ + --host 100.64.0.45 \ + --port 8766 \ + --disable-dns-rebinding-protection +``` + ## Requirements - Python 3.11+ - `uv` package manager - **tmux backend**: tmux installed (macOS or Linux) - **iTerm2 backend**: macOS with iTerm2 and Python API enabled (Preferences > General > Magic > Enable Python API) +- **Claude workers** (optional): Claude Code CLI installed - **Codex workers** (optional): OpenAI Codex CLI installed +- **Linux service mode** (optional): `systemd --user` available +- **Provider wrapper mode** (optional): a local `claude-switch`-style wrapper installation if you want multiple Claude-compatible providers ## Installation +### Linux Quick Start + +For a Debian/Ubuntu-style source checkout with `systemd --user` and provider +presets, the fastest path is: + +```bash +git clone https://github.com/Martian-Engineering/maniple.git +cd maniple +uv sync --group dev + +# Install/update ~/bin/claude-maniple-switch and create starter files: +bash scripts/setup-provider-presets.sh + +# Install/update ~/.config/systemd/user/maniple.service for this checkout: +bash scripts/install-systemd-user.sh +``` + +Then edit: +- `~/.maniple/config.json` +- `~/.maniple/.env` + +After changing provider settings: + +```bash +systemctl --user restart maniple +``` + +For the full Debian/Linux walkthrough, see [Running as a Service](#running-as-a-service). + ### As Claude Code Plugin (recommended) ```bash @@ -148,6 +220,164 @@ For project-scoped `.mcp.json` files, use `MANIPLE_PROJECT_DIR` so workers inher After adding the configuration, restart Claude Code for it to take effect. +## Running as a Service + +For a persistent Linux deployment, run `maniple` under `systemd --user` and keep +logs under `~/.maniple/logs/`. + +### Debian/Ubuntu From-Source Setup + +This is the recommended path when you want a user-level `systemd` service that +runs directly from your local checkout and automatically picks up source +changes after a service restart. + +1. Install system packages: + +```bash +sudo apt update +sudo apt install -y git tmux curl +``` + +2. Install `uv` if needed: + +```bash +curl -LsSf https://astral.sh/uv/install.sh | sh +``` + +3. Clone and sync the project: + +```bash +git clone https://github.com/Martian-Engineering/maniple.git +cd maniple +uv sync --group dev +``` + +4. Verify the CLI works from the checkout: + +```bash +uv run maniple --help +``` + +Quick-start helpers from this repo: + +```bash +# Install/update ~/bin/claude-maniple-switch and create starter files: +bash scripts/setup-provider-presets.sh + +# Install/update ~/.config/systemd/user/maniple.service for this checkout: +bash scripts/install-systemd-user.sh +``` + +These scripts create the files for you and keep existing user config files in +place. After they run, you still need to edit `~/.maniple/config.json` and +`~/.maniple/.env` with your actual provider choices and credentials. + +5. Create the user service directories: + +```bash +mkdir -p ~/.maniple/logs ~/.config/systemd/user +``` + +6. Create `~/.config/systemd/user/maniple.service`: + +```ini +[Unit] +Description=Maniple MCP Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/you/path/to/maniple +Environment=MANIPLE_TERMINAL_BACKEND=tmux +ExecStart=/home/you/.local/bin/uv run --project /home/you/path/to/maniple maniple --http --host 100.64.0.45 --port 8766 --allow-host 100.64.0.45:8766 +Restart=always +RestartSec=3 +StandardOutput=append:%h/.maniple/logs/maniple.out.log +StandardError=append:%h/.maniple/logs/maniple.err.log + +[Install] +WantedBy=default.target +``` + +7. Load and start the user service: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now maniple +systemctl --user status maniple +``` + +8. Enable lingering so the service survives reboot without an active desktop login: + +```bash +loginctl enable-linger "$USER" +``` + +9. After editing Python source code in the checkout, restart the service: + +```bash +systemctl --user restart maniple +``` + +10. After editing the unit file itself, reload systemd and restart: + +```bash +systemctl --user daemon-reload +systemctl --user restart maniple +``` + +11. Tail logs while debugging: + +```bash +journalctl --user -u maniple -n 100 --no-pager +tail -f ~/.maniple/logs/maniple.out.log ~/.maniple/logs/maniple.err.log +``` + +Example unit file at `~/.config/systemd/user/maniple.service`: + +```ini +[Unit] +Description=Maniple MCP Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/path/to/maniple +Environment=MANIPLE_TERMINAL_BACKEND=tmux +ExecStart=/path/to/uv run --project /path/to/maniple maniple --http --host 100.64.0.45 --port 8766 --allow-host 100.64.0.45:8766 +Restart=always +RestartSec=3 +StandardOutput=append:%h/.maniple/logs/maniple.out.log +StandardError=append:%h/.maniple/logs/maniple.err.log + +[Install] +WantedBy=default.target +``` + +Enable and start it: + +```bash +mkdir -p ~/.maniple/logs ~/.config/systemd/user +systemctl --user daemon-reload +systemctl --user enable --now maniple +systemctl --user status maniple +``` + +To keep the service running after reboot without an active graphical login: + +```bash +loginctl enable-linger "$USER" +``` + +Useful log commands: + +```bash +journalctl --user -u maniple -n 100 --no-pager +tail -f ~/.maniple/logs/maniple.out.log ~/.maniple/logs/maniple.err.log +``` + ## Config File `maniple` reads configuration from `~/.maniple/config.json`. Manage it with the CLI: @@ -176,12 +406,14 @@ maniple config set # Set and persist a value }, "defaults": { "agent_type": "claude", + "provider": null, "skip_permissions": false, "use_worktree": true, "layout": "auto" }, "terminal": { - "backend": null + "backend": null, + "auto_accept_startup_prompts": false }, "events": { "max_size_mb": 1, @@ -198,10 +430,12 @@ maniple config set # Set and persist a value | `commands.claude` | string | Override Claude CLI command (e.g. `"happy"`) | | `commands.codex` | string | Override Codex CLI command (e.g. `"happy codex"`) | | `defaults.agent_type` | `"claude"` or `"codex"` | Default agent type for new workers | -| `defaults.skip_permissions` | bool | Default `--dangerously-skip-permissions` flag | +| `defaults.provider` | string or null | Default named provider preset for `spawn_workers` when a worker omits `provider` | +| `defaults.skip_permissions` | bool | Default Claude permission mode (`--permission-mode bypassPermissions`) | | `defaults.use_worktree` | bool | Create git worktrees by default | | `defaults.layout` | `"auto"` or `"new"` | Default layout mode for spawn_workers | | `terminal.backend` | `"tmux"` or `"iterm"` | Terminal backend override (null = auto-detect) | +| `terminal.auto_accept_startup_prompts` | bool | Auto-confirm known Claude startup prompts during worker launch | | `events.max_size_mb` | int | Max event log file size before rotation | | `events.recent_hours` | int | Hours of events to retain | | `issue_tracker.override` | `"beads"` or `"pebbles"` | Force a specific issue tracker | @@ -213,8 +447,332 @@ maniple config set # Set and persist a value | `MANIPLE_TERMINAL_BACKEND` | (auto-detect) | Force terminal backend: `tmux` or `iterm`. Highest precedence. | | `MANIPLE_PROJECT_DIR` | (none) | Enables `"project_path": "auto"` in worker configs. | | `MANIPLE_COMMAND` | `claude` | Override the CLI command for Claude Code workers. | +| `MANIPLE_CLAUDE_SUPPORTS_SETTINGS` | auto | Force custom Claude wrappers to receive `--settings` so stop hooks still work. | | `MANIPLE_CODEX_COMMAND` | `codex` | Override the CLI command for Codex workers. | +### Provider Presets + +For multiple Claude-compatible providers, keep the files separated like this: + +- Main config: `~/.maniple/config.json` +- Provider credentials: `~/.maniple/.env` +- Wrapper executable: `~/bin/claude-maniple-switch` + +The wrapper can source an existing `claude-switch` installation, but it reads +provider credentials from `~/.maniple/.env` so users do not need to depend on +`~/.claude-switch/.env` or the repository checkout for secrets. + +Minimal setup: + +1. Put provider credentials in `~/.maniple/.env` +2. Install `~/bin/claude-maniple-switch` +3. Add named presets under `providers` in `~/.maniple/config.json` +4. Call `spawn_workers` with `provider: "minimax"` or `provider: "kimi"` or `provider: "local"` + +Bootstrap these files automatically: + +```bash +bash scripts/setup-provider-presets.sh +``` + +That script will: +- install `~/bin/claude-maniple-switch` +- create `~/.maniple/config.json` if it does not exist +- create `~/.maniple/.env` if it does not exist +- leave existing files untouched and print the next manual steps + +Define named presets in `~/.maniple/config.json` and reference them per worker: + +```json +{ + "providers": { + "minimax": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "minimax" + } + }, + "kimi": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "kimi" + } + }, + "local": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local" + } + } + } +} +``` + +Example `~/.maniple/.env`: + +```bash +export MINIMAX_API_KEY="replace-me" +export MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MINIMAX_MODEL="MiniMax-M2.1" + +export KIMI_API_KEY="replace-me" +export KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export KIMI_MODEL="kimi-k2-thinking-turbo" + +export LOCAL_API_KEY="replace-me" +export LOCAL_BASE_URL="http://127.0.0.1:4000" +export LOCAL_MODEL="claude-3-5-sonnet" +``` + +Recommended convention for `~/.maniple/.env`: + +```bash +export MANIPLE_PROVIDER_MINIMAX_API_KEY="replace-me" +export MANIPLE_PROVIDER_MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MANIPLE_PROVIDER_MINIMAX_MODEL="MiniMax-M2.1" + +export MANIPLE_PROVIDER_KIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export MANIPLE_PROVIDER_KIMI_MODEL="kimi-k2-thinking-turbo" + +export MANIPLE_PROVIDER_LOCAL_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL_BASE_URL="http://127.0.0.1:4000" +export MANIPLE_PROVIDER_LOCAL_MODEL="claude-3-5-sonnet" +``` + +The wrapper maps these namespaced variables automatically based on the selected +provider name. The convention is: + +```text +provider name in config: local +CLAUDE_SWITCH_PROVIDER: local +~/.maniple/.env prefix: MANIPLE_PROVIDER_LOCAL_ +provider env aliases loaded: LOCAL_API_KEY / LOCAL_BASE_URL / LOCAL_MODEL +``` + +Examples: +- `provider: "kimi"` -> reads `MANIPLE_PROVIDER_KIMI_*` +- `provider: "minimax"` -> reads `MANIPLE_PROVIDER_MINIMAX_*` +- `provider: "local2"` -> reads `MANIPLE_PROVIDER_LOCAL2_*` + +This keeps provider selection in `~/.maniple/config.json` and provider secrets +in `~/.maniple/.env`, with a predictable naming rule between them. + +### Adding or Removing Providers + +To add a provider: + +1. Ensure your wrapper supports it in the `case "$provider" in ... esac` block. +2. Add a preset under `providers` in `~/.maniple/config.json`. +3. Add matching `MANIPLE_PROVIDER__*` values to `~/.maniple/.env`. +4. Restart the MCP service if it is running under `systemd --user`. + +Example: add `local2` + +`~/.maniple/config.json` + +```json +{ + "providers": { + "local2": { + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local2" + } + } + } +} +``` + +`~/.maniple/.env` + +```bash +export MANIPLE_PROVIDER_LOCAL2_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL2_BASE_URL="http://127.0.0.1:4001" +export MANIPLE_PROVIDER_LOCAL2_MODEL="claude-3-5-sonnet" +``` + +To remove a provider: + +1. Delete its preset from `~/.maniple/config.json` +2. Optionally delete its `MANIPLE_PROVIDER__*` lines from `~/.maniple/.env` +3. Restart the MCP service if needed + +Service reload after provider changes: + +```bash +systemctl --user restart maniple +``` + +Set a default provider for workers that do not specify one explicitly: + +```bash +maniple config set defaults.provider local +``` + +Clear the default provider: + +```bash +maniple config set defaults.provider null +``` + +Example wrapper at `~/bin/claude-maniple-switch`: + +```bash +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_SWITCH_DIR="${CLAUDE_SWITCH_DIR:-$HOME/.local/bin/claude-switch}" +CLAUDE_SWITCH_SCRIPT="${CLAUDE_SWITCH_SCRIPT:-$CLAUDE_SWITCH_DIR/claude-providers-v3.sh}" +MANIPLE_PROVIDER_ENV_FILE="${MANIPLE_PROVIDER_ENV_FILE:-$HOME/.maniple/.env}" +provider="${CLAUDE_SWITCH_PROVIDER:-official}" + +source "$CLAUDE_SWITCH_SCRIPT" >/dev/null 2>&1 + +if [[ -f "$MANIPLE_PROVIDER_ENV_FILE" ]]; then + set -a + source "$MANIPLE_PROVIDER_ENV_FILE" >/dev/null 2>&1 + set +a +fi + +case "$provider" in + minimax) claude-minimax "$@" ;; + kimi) claude-kimi "$@" ;; + local) claude-local "$@" ;; + official) claude-official "$@" ;; + *) echo "Unknown CLAUDE_SWITCH_PROVIDER: $provider" >&2; exit 1 ;; +esac +``` + +Then use either a named `provider` preset or direct `command` / `env` overrides: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi" + }, + { + "project_path": "/repo", + "command": "/home/you/bin/claude-maniple-switch", + "env": { + "CLAUDE_SWITCH_PROVIDER": "local" + } + } + ] +} +``` + +Minimal `spawn_workers` example: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi", + "skip_permissions": true + } + ], + "layout": "new" +} +``` + +### MCP Client Usage + +Recommended client flow: + +1. Call `list_providers` to discover available named provider presets. +2. Call `spawn_workers` with `provider: ""`. +3. Put the actual task in the worker `prompt`. + +Structured example: + +```json +{ + "workers": [ + { + "project_path": "/repo", + "provider": "kimi", + "prompt": "Inspect the training pipeline and fix the failing evaluation step.", + "skip_permissions": true + } + ], + "layout": "new" +} +``` + +Minimal natural-language prompt examples for MCP-capable clients: + +```text +先调用 list_providers,告诉我当前可用的 provider 名字。 +``` + +```text +调用 spawn_workers,在 /repo 启动 1 个 Claude worker,provider 用 kimi,skip_permissions=true,prompt 用“检查训练脚本报错并修复”。 +``` + +```text +调用 spawn_workers,在 /repo 启动 1 个 Claude worker,provider 用 local2,layout 用 new,prompt 用“检查 API 服务启动失败的原因并修复”。 +``` + +Prompt-writing guidance for reliable tool invocation: +- Name the MCP tool explicitly: `list_providers` or `spawn_workers` +- Specify the provider name exactly as returned by `list_providers` +- Put the worker's assignment into the `prompt` field +- Include `project_path` explicitly +- Use `skip_permissions: true` when the worker needs to edit files or run commands + +`spawn_work` is not a valid tool name; use `spawn_workers`. + +### First-Run Notes for Claude Workers + +When testing autonomous Claude workers, the MCP service itself may be healthy but +Claude Code can still pause on local interactive confirmations. Common examples: + +- trusting a newly opened project folder +- approving local `.mcp.json` servers discovered in the target repo +- confirming Claude permission-mode / bypass mode warnings when the CLI requires it + +If this happens, the worker will appear to time out during startup even though +the tmux pane is alive. Check the pane directly and clear the confirmation once. +After that, service-mode orchestration is much smoother. + +If you want unattended worker startup in a trusted local environment, set +`terminal.auto_accept_startup_prompts` to `true`. This only auto-confirms +known startup blockers during launch, currently: + +- the project `.mcp.json` trust prompt +- the Claude bypass-permissions startup confirmation + +This is intentionally opt-in because it accepts security prompts on your behalf. + +For the same project, MCP trust is usually a one-time confirmation. After you +accept the `.mcp.json` server for that project, future workers in the same +project normally stop hitting that prompt. It can reappear if you reset Claude +state, change the project's MCP config, or open a different project. + +The bypass-permissions warning is also a Claude-side interactive confirmation. +After you accept it once, repeated prompts are less likely in the same local +setup, but you should not assume that it is permanently suppressed across CLI +upgrades, config resets, or entirely new environments. + +For tmux-backed workers, you can jump straight to the blocked worker pane and +clear the prompt manually: + +```bash +# Open the smoke-test worker that was blocked on the .mcp.json trust prompt +tmux attach-session -t maniple-maniple-smoke \; select-window -t 2 + +# Open the MolGen worker that was blocked on the bypass-permissions prompt +tmux attach-session -t maniple-MolGen \; select-window -t 35 +``` + +Once attached: +- For the `.mcp.json` prompt, choose `Use this and all future MCP servers in this project` if you trust that repo's MCP config. +- For the bypass-permissions prompt, move to `Yes, I accept` and press Enter if you intentionally launched that worker with `skip_permissions: true`. + ## MCP Tools ### Worker Management @@ -224,9 +782,10 @@ maniple config set # Set and persist a value | `spawn_workers` | Create workers with multi-pane layouts. Supports Claude Code and Codex agents. | | `list_workers` | List all managed workers with status. Filter by status or project. | | `examine_worker` | Get detailed worker status including conversation stats and last response preview. | -| `close_workers` | Gracefully terminate one or more workers. Worktree branches are preserved. | +| `close_workers` | Gracefully terminate one or more workers. Worktree branches are preserved by default, or deleted with `delete_branch: true`. | | `discover_workers` | Find existing Claude Code/Codex sessions running in tmux or iTerm2. | | `adopt_worker` | Import a discovered session into the managed registry. | +| `list_providers` | List configured Claude-compatible provider presets from `~/.maniple/config.json`. | ### Communication @@ -272,7 +831,7 @@ WorkerConfig fields: badge: str - Task description (shown in badge, used in branch names) issue_id: str - Issue tracker ID (for badge, branch naming, and workflow instructions) prompt: str - Additional instructions (combined with standard worker prompt) - skip_permissions: bool - Start with --dangerously-skip-permissions + skip_permissions: bool - Start Claude with `--permission-mode bypassPermissions` use_worktree: bool - Create isolated git worktree (default: true) worktree: WorktreeConfig - Optional worktree settings: branch: Explicit branch name (auto-generated if omitted) @@ -307,6 +866,32 @@ Returns: success, session_ids, results, [idle_session_ids, all_idle, timed_out] ``` +#### close_workers + +``` +Arguments: + session_ids: list[str] - Worker IDs to close (accepts any identifier format) + force: bool - Force close even if a worker is busy + delete_branch: bool - Also delete the worker branch after removing its + worktree (default: false) + +Returns: + session_ids, results, success_count, failure_count +``` + +Worktree cleanup behavior: +- By default, `close_workers` removes the worker worktree directory and keeps the branch. +- Set `delete_branch: true` to remove both the worktree and its branch in one call. + +Example: + +```json +{ + "session_ids": ["Hypatia"], + "delete_branch": true +} +``` + #### wait_idle_workers ``` @@ -320,6 +905,16 @@ Returns: session_ids, idle_session_ids, all_idle, waiting_on, mode, waited_seconds, timed_out ``` +#### list_providers + +``` +Returns: + config_path, count, provider_names, providers, usage_tip +``` + +Use this when an MCP client wants to discover which named provider presets are +available before calling `spawn_workers` with `provider: ""`. + #### poll_worker_changes ``` @@ -512,6 +1107,27 @@ make install-commands ### General +**Worker startup timed out, but the pane is still alive** +- Claude may be blocked on a first-run confirmation rather than actually failing to start +- Common blockers are: + - the project `.mcp.json` trust prompt +- the Claude bypass-permissions confirmation +- For tmux sessions, attach directly to the blocked pane: + +```bash +tmux attach-session -t \; select-window -t +``` + +- Example sessions from local debugging: + +```bash +tmux attach-session -t maniple-maniple-smoke \; select-window -t 2 +tmux attach-session -t maniple-MolGen \; select-window -t 35 +``` + +- After you clear the confirmation once, retry `spawn_workers` +- For the same project, `.mcp.json` trust is usually a one-time confirmation unless Claude state or project MCP config changes + **"Session not found"** - The worker may have been closed externally - Use `list_workers` to see active workers diff --git a/scripts/claude-maniple-switch b/scripts/claude-maniple-switch new file mode 100644 index 0000000..0d7b592 --- /dev/null +++ b/scripts/claude-maniple-switch @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +CLAUDE_SWITCH_DIR="${CLAUDE_SWITCH_DIR:-$HOME/.local/bin/claude-switch}" +CLAUDE_SWITCH_SCRIPT="${CLAUDE_SWITCH_SCRIPT:-$CLAUDE_SWITCH_DIR/claude-providers-v3.sh}" +MANIPLE_PROVIDER_ENV_FILE="${MANIPLE_PROVIDER_ENV_FILE:-$HOME/.maniple/.env}" +provider="${CLAUDE_SWITCH_PROVIDER:-official}" + +if [[ ! -f "$CLAUDE_SWITCH_SCRIPT" ]]; then + echo "claude-maniple-switch: missing claude-switch script: $CLAUDE_SWITCH_SCRIPT" >&2 + exit 1 +fi + +# Import function definitions from claude-switch, but skip its top-level side +# effects (dependency checks, tips, and automatic env loading). +# shellcheck disable=SC1090 +source <( + sed \ + -e '/^_claude_check_dependencies$/d' \ + -e '/^_claude_init_config$/d' \ + -e '/^_claude_ccmanager_tips$/d' \ + -e '/^_claude_check_ralph_setup$/d' \ + -e '/^_claude_load_env$/d' \ + "$CLAUDE_SWITCH_SCRIPT" +) >/dev/null 2>&1 + +# Load maniple-specific provider credentials after claude-switch so these +# values take precedence without requiring changes to the user's shell setup. +if [[ -f "$MANIPLE_PROVIDER_ENV_FILE" ]]; then + set -a + # shellcheck disable=SC1090 + source "$MANIPLE_PROVIDER_ENV_FILE" >/dev/null 2>&1 + set +a +fi + +_provider_env_prefix() { + printf '%s' "$1" | tr '[:lower:]-' '[:upper:]_' +} + +_apply_provider_env_convention() { + local provider_prefix shell_prefix suffix source_var target_var value + + provider_prefix="$(_provider_env_prefix "$provider")" + + for suffix in API_KEY AUTH_TOKEN BASE_URL MODEL; do + source_var="MANIPLE_PROVIDER_${provider_prefix}_${suffix}" + target_var="${provider_prefix}_${suffix}" + value="${!source_var:-}" + if [[ -n "$value" && -z "${!target_var:-}" ]]; then + export "${target_var}=${value}" + fi + done +} + +_apply_provider_env_convention + +case "$provider" in + official) claude-official "$@" ;; + local) claude-local "$@" ;; + local2) claude-local2 "$@" ;; + local3) claude-local3 "$@" ;; + kimi) claude-kimi "$@" ;; + ckimi) claude-ckimi "$@" ;; + glm) claude-glm "$@" ;; + minimax) claude-minimax "$@" ;; + aliyun) claude-aliyun "$@" ;; + kat) claude-kat "$@" ;; + greverse) claude-greverse "$@" ;; + gcs) claude-gcs "$@" ;; + *) + echo "claude-maniple-switch: unknown CLAUDE_SWITCH_PROVIDER: $provider" >&2 + exit 1 + ;; +esac diff --git a/scripts/install-systemd-user.sh b/scripts/install-systemd-user.sh new file mode 100644 index 0000000..bd25321 --- /dev/null +++ b/scripts/install-systemd-user.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Install a user-level systemd service for running Maniple HTTP mode from this +# source checkout. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "${script_dir}/.." && pwd -P)" + +uv_bin="${MANIPLE_UV_BIN:-$(command -v uv)}" +if [[ -z "${uv_bin}" ]]; then + echo "uv not found in PATH. Install uv first." >&2 + exit 1 +fi + +host="${MANIPLE_HTTP_HOST:-127.0.0.1}" +port="${MANIPLE_HTTP_PORT:-8766}" +backend="${MANIPLE_TERMINAL_BACKEND:-tmux}" +allow_host="${MANIPLE_ALLOW_HOST:-${host}:${port}}" + +config_dir="${HOME}/.config/systemd/user" +service_path="${config_dir}/maniple.service" +logs_dir="${HOME}/.maniple/logs" + +mkdir -p "${config_dir}" "${logs_dir}" + +cat > "${service_path}" < "${config_path}" < "${env_path}" <<'EOF' +# Fill in only the providers you actually use. +# +# Naming convention: +# provider name "local2" -> MANIPLE_PROVIDER_LOCAL2_* +# provider name "kimi" -> MANIPLE_PROVIDER_KIMI_* + +export MANIPLE_PROVIDER_LOCAL_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL_BASE_URL="https://example.local" +export MANIPLE_PROVIDER_LOCAL_MODEL="replace-me" + +export MANIPLE_PROVIDER_LOCAL2_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL2_BASE_URL="https://example.local2" +export MANIPLE_PROVIDER_LOCAL2_MODEL="replace-me" + +export MANIPLE_PROVIDER_LOCAL3_API_KEY="replace-me" +export MANIPLE_PROVIDER_LOCAL3_BASE_URL="https://example.local3" +export MANIPLE_PROVIDER_LOCAL3_MODEL="replace-me" + +export MANIPLE_PROVIDER_KIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_KIMI_BASE_URL="https://api.moonshot.cn/anthropic" +export MANIPLE_PROVIDER_KIMI_MODEL="replace-me" + +export MANIPLE_PROVIDER_CKIMI_API_KEY="replace-me" +export MANIPLE_PROVIDER_CKIMI_BASE_URL="https://example.ckimi" +export MANIPLE_PROVIDER_CKIMI_MODEL="replace-me" + +export MANIPLE_PROVIDER_MINIMAX_API_KEY="replace-me" +export MANIPLE_PROVIDER_MINIMAX_BASE_URL="https://api.minimaxi.com/anthropic" +export MANIPLE_PROVIDER_MINIMAX_MODEL="replace-me" + +export MANIPLE_PROVIDER_ALIYUN_API_KEY="replace-me" +export MANIPLE_PROVIDER_ALIYUN_BASE_URL="https://example.aliyun" +export MANIPLE_PROVIDER_ALIYUN_MODEL="replace-me" + +export MANIPLE_PROVIDER_GLM_API_KEY="replace-me" +export MANIPLE_PROVIDER_GLM_BASE_URL="https://example.glm" +export MANIPLE_PROVIDER_GLM_MODEL="replace-me" + +export MANIPLE_PROVIDER_KAT_API_KEY="replace-me" +export MANIPLE_PROVIDER_KAT_BASE_URL="https://example.kat" +export MANIPLE_PROVIDER_KAT_MODEL="replace-me" + +export MANIPLE_PROVIDER_GREVERSE_API_KEY="replace-me" +export MANIPLE_PROVIDER_GREVERSE_BASE_URL="https://example.greverse" +export MANIPLE_PROVIDER_GREVERSE_MODEL="replace-me" + +export MANIPLE_PROVIDER_GCS_API_KEY="replace-me" +export MANIPLE_PROVIDER_GCS_BASE_URL="https://example.gcs" +export MANIPLE_PROVIDER_GCS_MODEL="replace-me" +EOF + chmod 0600 "${env_path}" + echo "Created ${env_path}" +else + echo "Keeping existing ${env_path}" +fi + +echo +echo "Installed wrapper:" +echo " ${wrapper_dst}" +echo +echo "Next steps:" +echo " 1. Edit ${config_path} and keep only the providers you want." +echo " 2. Edit ${env_path} and replace the placeholder values." +echo " 3. Optional: set a default provider:" +echo " uv run maniple config set defaults.provider local" +echo " 4. If running under systemd:" +echo " systemctl --user restart maniple" diff --git a/src/maniple_mcp/cli_backends/base.py b/src/maniple_mcp/cli_backends/base.py index 9389be3..c122f50 100644 --- a/src/maniple_mcp/cli_backends/base.py +++ b/src/maniple_mcp/cli_backends/base.py @@ -47,6 +47,7 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build the argument list for the CLI command. @@ -89,7 +90,7 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: ... @abstractmethod - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Return whether this CLI supports --settings flag for hook injection. @@ -104,6 +105,7 @@ def build_full_command( settings_file: str | None = None, plugin_dir: str | list[str] | None = None, env_vars: dict[str, str] | None = None, + command_override: str | None = None, ) -> str: """ Build the complete command string including env vars. @@ -120,11 +122,16 @@ def build_full_command( Returns: Complete command string ready for shell execution """ - cmd = self.command() + cmd = command_override or self.command() args = self.build_args( dangerously_skip_permissions=dangerously_skip_permissions, - settings_file=settings_file if self.supports_settings_file() else None, + settings_file=( + settings_file + if self.supports_settings_file(command_override=command_override) + else None + ), plugin_dir=plugin_dir, + command_override=command_override, ) if args: diff --git a/src/maniple_mcp/cli_backends/claude.py b/src/maniple_mcp/cli_backends/claude.py index a3d63fe..12d309e 100644 --- a/src/maniple_mcp/cli_backends/claude.py +++ b/src/maniple_mcp/cli_backends/claude.py @@ -6,6 +6,7 @@ """ from typing import Literal +from pathlib import Path from .base import AgentCLI from ..utils.env_vars import get_env_with_fallback @@ -16,6 +17,8 @@ # Environment variables for command override (takes highest precedence). _ENV_VAR = "MANIPLE_COMMAND" _ENV_VAR_FALLBACK = "CLAUDE_TEAM_COMMAND" +_SETTINGS_ENV_VAR = "MANIPLE_CLAUDE_SUPPORTS_SETTINGS" +_SETTINGS_ENV_VAR_FALLBACK = "CLAUDE_TEAM_CLAUDE_SUPPORTS_SETTINGS" def get_claude_command() -> str: @@ -51,12 +54,30 @@ def get_claude_command() -> str: return _DEFAULT_COMMAND +def _command_supports_settings(command: str) -> bool: + """ + Decide whether a Claude command wrapper should receive --settings. + + Default behavior stays conservative for custom commands, but wrappers can opt in + explicitly via env when they simply inject provider env vars and then exec Claude. + """ + if command == _DEFAULT_COMMAND: + return True + + env_val = get_env_with_fallback(_SETTINGS_ENV_VAR, _SETTINGS_ENV_VAR_FALLBACK) + if env_val: + return env_val.strip().lower() in {"1", "true", "yes", "on"} + + command_name = Path(command.split()[0]).name + return command_name.startswith("claude-") + + class ClaudeCLI(AgentCLI): """ Claude Code CLI implementation. Supports: - - --dangerously-skip-permissions flag + - --permission-mode bypassPermissions - --settings flag for Stop hook injection - Ready detection via TUI patterns (robot banner, '>' prompt, 'tokens' status) - Idle detection via Stop hook markers in JSONL @@ -84,12 +105,13 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build Claude CLI arguments. Args: - dangerously_skip_permissions: Add --dangerously-skip-permissions + dangerously_skip_permissions: Add --permission-mode bypassPermissions settings_file: Path to settings JSON for Stop hook injection plugin_dir: Path(s) to plugin directory for --plugin-dir (single string or list) @@ -99,7 +121,8 @@ def build_args( args: list[str] = [] if dangerously_skip_permissions: - args.append("--dangerously-skip-permissions") + args.append("--permission-mode") + args.append("bypassPermissions") if plugin_dir: # Support both single string and list of strings @@ -108,10 +131,9 @@ def build_args( args.append("--plugin-dir") args.append(dir_path) - # Only add --settings for the default 'claude' command. - # Custom commands like 'happy' have their own session tracking mechanisms. - # See HAPPY_INTEGRATION_RESEARCH.md for full analysis. - if settings_file and self._is_default_command(): + # Only add --settings when the command is known to pass through to Claude. + # Wrapper commands can opt in explicitly via MANIPLE_CLAUDE_SUPPORTS_SETTINGS. + if settings_file and self.supports_settings_file(command_override=command_override): args.append("--settings") args.append(settings_file) @@ -142,14 +164,14 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: """ return "stop_hook" - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Claude supports --settings for hook injection. Only returns True for the default 'claude' command. Custom wrappers may have their own settings mechanisms. """ - return self._is_default_command() + return _command_supports_settings(command_override or get_claude_command()) def _is_default_command(self) -> bool: """Check if using the default 'claude' command (not a custom wrapper).""" diff --git a/src/maniple_mcp/cli_backends/codex.py b/src/maniple_mcp/cli_backends/codex.py index 8df3221..c03dab4 100644 --- a/src/maniple_mcp/cli_backends/codex.py +++ b/src/maniple_mcp/cli_backends/codex.py @@ -88,6 +88,7 @@ def build_args( dangerously_skip_permissions: bool = False, settings_file: str | None = None, plugin_dir: str | list[str] | None = None, + command_override: str | None = None, ) -> list[str]: """ Build Codex CLI arguments for interactive mode. @@ -140,7 +141,7 @@ def idle_detection_method(self) -> Literal["stop_hook", "jsonl_stream", "none"]: """ return "jsonl_stream" - def supports_settings_file(self) -> bool: + def supports_settings_file(self, command_override: str | None = None) -> bool: """ Codex doesn't support --settings for hook injection. diff --git a/src/maniple_mcp/config.py b/src/maniple_mcp/config.py index df43b57..d8da25f 100644 --- a/src/maniple_mcp/config.py +++ b/src/maniple_mcp/config.py @@ -43,6 +43,7 @@ class DefaultsConfig: """Default values applied when spawn_workers fields are omitted.""" agent_type: AgentType = "claude" + provider: str | None = None skip_permissions: bool = False use_worktree: bool = True layout: LayoutMode = "auto" @@ -53,6 +54,7 @@ class TerminalConfig: """Terminal backend configuration.""" backend: TerminalBackend | None = None # None = auto-detect + auto_accept_startup_prompts: bool = False @dataclass @@ -71,6 +73,14 @@ class IssueTrackerConfig: override: IssueTrackerName | None = None +@dataclass +class ProviderConfig: + """Named command/env preset for worker launches.""" + + command: str | None = None + env: dict[str, str] = field(default_factory=dict) + + @dataclass class ClaudeTeamConfig: """Top-level configuration container for claude-team.""" @@ -81,6 +91,7 @@ class ClaudeTeamConfig: terminal: TerminalConfig = field(default_factory=TerminalConfig) events: EventsConfig = field(default_factory=EventsConfig) issue_tracker: IssueTrackerConfig = field(default_factory=IssueTrackerConfig) + providers: dict[str, ProviderConfig] = field(default_factory=dict) def default_config() -> ClaudeTeamConfig: @@ -159,7 +170,15 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: # Validate expected top-level keys before parsing sections. _validate_keys( data, - {"version", "commands", "defaults", "terminal", "events", "issue_tracker"}, + { + "version", + "commands", + "defaults", + "terminal", + "events", + "issue_tracker", + "providers", + }, "config", ) version = _read_version(data.get("version")) @@ -168,6 +187,7 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: terminal = _parse_terminal(data.get("terminal")) events = _parse_events(data.get("events")) issue_tracker = _parse_issue_tracker(data.get("issue_tracker")) + providers = _parse_providers(data.get("providers")) return ClaudeTeamConfig( version=version, commands=commands, @@ -175,6 +195,7 @@ def _parse_config(data: dict) -> ClaudeTeamConfig: terminal=terminal, events=events, issue_tracker=issue_tracker, + providers=providers, ) @@ -206,7 +227,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: data = _ensure_dict(value, "defaults") _validate_keys( data, - {"agent_type", "skip_permissions", "use_worktree", "layout"}, + {"agent_type", "provider", "skip_permissions", "use_worktree", "layout"}, "defaults", ) return DefaultsConfig( @@ -216,6 +237,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: "defaults.agent_type", DefaultsConfig.agent_type, ), + provider=_optional_str(data.get("provider"), "defaults.provider"), skip_permissions=_optional_bool( data.get("skip_permissions"), "defaults.skip_permissions", @@ -238,7 +260,7 @@ def _parse_defaults(value: object) -> DefaultsConfig: def _parse_terminal(value: object) -> TerminalConfig: # Parse terminal backend configuration. data = _ensure_dict(value, "terminal") - _validate_keys(data, {"backend"}, "terminal") + _validate_keys(data, {"backend", "auto_accept_startup_prompts"}, "terminal") return TerminalConfig( backend=_optional_literal( data.get("backend"), @@ -246,6 +268,11 @@ def _parse_terminal(value: object) -> TerminalConfig: "terminal.backend", None, ), + auto_accept_startup_prompts=_optional_bool( + data.get("auto_accept_startup_prompts"), + "terminal.auto_accept_startup_prompts", + TerminalConfig.auto_accept_startup_prompts, + ), ) @@ -291,6 +318,25 @@ def _parse_issue_tracker(value: object) -> IssueTrackerConfig: ) +def _parse_providers(value: object) -> dict[str, ProviderConfig]: + # Parse named worker launch provider presets. + data = _ensure_dict(value, "providers") + providers: dict[str, ProviderConfig] = {} + for provider_name, provider_value in data.items(): + if not isinstance(provider_name, str) or not provider_name.strip(): + raise ConfigError("providers keys must be non-empty strings") + provider_data = _ensure_dict(provider_value, f"providers.{provider_name}") + _validate_keys(provider_data, {"command", "env"}, f"providers.{provider_name}") + providers[provider_name] = ProviderConfig( + command=_optional_str( + provider_data.get("command"), + f"providers.{provider_name}.command", + ), + env=_optional_str_map(provider_data.get("env"), f"providers.{provider_name}.env"), + ) + return providers + + def _ensure_dict(value: object, path: str) -> dict: # Ensure sections are JSON objects, defaulting to empty dicts. if value is None: @@ -356,6 +402,23 @@ def _optional_literal( return value +def _optional_str_map(value: object, path: str) -> dict[str, str]: + # Validate optional string-to-string maps. + if value is None: + return {} + if not isinstance(value, dict): + raise ConfigError(f"{path} must be a JSON object") + + result: dict[str, str] = {} + for key, item in value.items(): + if not isinstance(key, str) or not key.strip(): + raise ConfigError(f"{path} keys must be non-empty strings") + if not isinstance(item, str): + raise ConfigError(f"{path}.{key} must be a string") + result[key] = item + return result + + __all__ = [ "AgentType", "ClaudeTeamConfig", @@ -365,6 +428,7 @@ def _optional_literal( "EventsConfig", "IssueTrackerConfig", "LayoutMode", + "ProviderConfig", "TerminalBackend", "TerminalConfig", "IssueTrackerName", diff --git a/src/maniple_mcp/config_cli.py b/src/maniple_mcp/config_cli.py index 1b971fe..d118749 100644 --- a/src/maniple_mcp/config_cli.py +++ b/src/maniple_mcp/config_cli.py @@ -274,6 +274,7 @@ def _set_nested_value(data: dict, key: str, value: object) -> None: field, _ALLOWED_AGENT_TYPES, ), + "defaults.provider": _parse_optional_string, "defaults.skip_permissions": _parse_bool, "defaults.use_worktree": _parse_bool, "defaults.layout": lambda value, field: _parse_literal( diff --git a/src/maniple_mcp/iterm_utils.py b/src/maniple_mcp/iterm_utils.py index 3a0f1c7..1622fff 100644 --- a/src/maniple_mcp/iterm_utils.py +++ b/src/maniple_mcp/iterm_utils.py @@ -20,6 +20,7 @@ from .cli_backends import AgentCLI from .subprocess_cache import cached_system_profiler +from .launch_blockers import AgentLaunchBlocked, detect_launch_blocker logger = logging.getLogger("maniple.iterm_utils") @@ -481,6 +482,9 @@ async def wait_for_claude_ready( try: content = await read_screen_text(session) lines = content.split('\n') + blocker = detect_launch_blocker(content, "claude") + if blocker is not None: + raise AgentLaunchBlocked(blocker) # Check if content is stable (same as last read) if content == last_content: @@ -502,6 +506,8 @@ async def wait_for_claude_ready( logger.debug("Claude ready: found status bar with 'tokens'") return True + except AgentLaunchBlocked: + raise except Exception as e: # Screen read failed, retry logger.debug(f"Screen read failed during Claude ready check: {e}") @@ -518,6 +524,7 @@ async def wait_for_agent_ready( timeout_seconds: float = 15.0, poll_interval: float = 0.2, stable_count: int = 2, + auto_accept_startup_prompts: bool = False, ) -> bool: """ Wait for an agent CLI to be ready to accept input. @@ -542,11 +549,32 @@ async def wait_for_agent_ready( start_time = time.monotonic() last_content = None stable_reads = 0 + auto_accepted_blockers: set[str] = set() while (time.monotonic() - start_time) < timeout_seconds: try: content = await read_screen_text(session) lines = content.split('\n') + blocker = detect_launch_blocker(content, cli.engine_id) + if blocker is not None: + if ( + auto_accept_startup_prompts + and blocker.auto_accept_choice + and blocker.slug not in auto_accepted_blockers + ): + logger.info( + "Auto-accepting Claude startup blocker in iTerm: %s", + blocker.slug, + ) + await send_text(session, blocker.auto_accept_choice) + await asyncio.sleep(0.2) + await send_key(session, "enter") + auto_accepted_blockers.add(blocker.slug) + last_content = None + stable_reads = 0 + await asyncio.sleep(0.5) + continue + raise AgentLaunchBlocked(blocker) # Check if content is stable (same as last read) if content == last_content: @@ -566,6 +594,8 @@ async def wait_for_agent_ready( ) return True + except AgentLaunchBlocked: + raise except Exception as e: # Screen read failed, retry logger.debug(f"Screen read failed during agent ready check: {e}") @@ -638,6 +668,8 @@ async def start_agent_in_session( stop_hook_marker_id: Optional[str] = None, output_capture_path: Optional[str] = None, plugin_dir: Optional[str | list[str]] = None, + command_override: Optional[str] = None, + auto_accept_startup_prompts: bool = False, ) -> None: """ Start an agent CLI in an existing iTerm2 session. @@ -673,7 +705,7 @@ async def start_agent_in_session( # Build settings file for Stop hook injection if supported settings_file = None - if stop_hook_marker_id and cli.supports_settings_file(): + if stop_hook_marker_id and cli.supports_settings_file(command_override=command_override): settings_file = build_stop_hook_settings_file(stop_hook_marker_id) # Build the full command using the AgentCLI abstraction @@ -682,6 +714,7 @@ async def start_agent_in_session( settings_file=settings_file, plugin_dir=plugin_dir, env_vars=env, + command_override=command_override, ) # Add output capture via tee if requested @@ -698,12 +731,16 @@ async def start_agent_in_session( await send_prompt(session, cmd) # Wait for agent to actually start (detect ready patterns, not blind sleep) + effective_command = command_override or cli.command() if not await wait_for_agent_ready( - session, cli, timeout_seconds=agent_ready_timeout + session, + cli, + timeout_seconds=agent_ready_timeout, + auto_accept_startup_prompts=auto_accept_startup_prompts, ): raise RuntimeError( f"{cli.engine_id} failed to start in {project_path} within " - f"{agent_ready_timeout}s. Check that '{cli.command()}' command is " + f"{agent_ready_timeout}s. Check that '{effective_command}' command is " "available and authentication is configured." ) @@ -880,7 +917,7 @@ async def create_multi_claude_layout( the expected pane names for the layout (e.g., for 'quad': 'top_left', 'top_right', 'bottom_left', 'bottom_right') layout: Layout type (single, vertical, horizontal, quad, triple_vertical) - skip_permissions: If True, start Claude with --dangerously-skip-permissions + skip_permissions: If True, start Claude with --permission-mode bypassPermissions project_envs: Optional dict mapping pane names to env var dicts. Each pane can have its own environment variables set before starting Claude. profile: Optional profile name to use for all panes diff --git a/src/maniple_mcp/launch_blockers.py b/src/maniple_mcp/launch_blockers.py new file mode 100644 index 0000000..a3e3f9a --- /dev/null +++ b/src/maniple_mcp/launch_blockers.py @@ -0,0 +1,71 @@ +""" +Detect interactive launch blockers shown before an agent reaches its ready UI. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LaunchBlocker: + """Structured description of a launch blocker visible on screen.""" + + slug: str + summary: str + hint: str + auto_accept_choice: str | None = None + + +class AgentLaunchBlocked(RuntimeError): + """Raised when an agent launch is blocked on an interactive confirmation UI.""" + + def __init__(self, blocker: LaunchBlocker): + self.blocker = blocker + self.hint = blocker.hint + super().__init__(blocker.summary) + + +_CLAUDE_BLOCKERS: tuple[tuple[tuple[str, ...], LaunchBlocker], ...] = ( + ( + ("New MCP server found in .mcp.json", "Use this and all future MCP servers"), + LaunchBlocker( + slug="mcp_trust_confirmation", + summary=( + "Claude launch is blocked on the project MCP trust prompt. " + "Approve the MCP server in the worker terminal, then retry." + ), + hint=( + "Open the worker pane and accept the .mcp.json server prompt " + "once for this project, or remove/disable that MCP entry before spawning." + ), + auto_accept_choice="1", + ), + ), + ( + ("WARNING: Claude Code running in Bypass Permissions mode", "Yes, I accept"), + LaunchBlocker( + slug="bypass_permissions_confirmation", + summary=( + "Claude launch is blocked on the Bypass Permissions confirmation. " + "Accept it in the worker terminal, or disable skip_permissions for that worker." + ), + hint=( + "Open the worker pane and confirm Bypass Permissions once, or set " + "skip_permissions=False if you do not want that startup prompt." + ), + auto_accept_choice="2", + ), + ), +) + + +def detect_launch_blocker(content: str, engine_id: str) -> LaunchBlocker | None: + """Return a known blocker if the current screen content matches one.""" + if engine_id != "claude": + return None + + for needles, blocker in _CLAUDE_BLOCKERS: + if all(needle in content for needle in needles): + return blocker + return None diff --git a/src/maniple_mcp/server.py b/src/maniple_mcp/server.py index 76a0ca9..17a949d 100644 --- a/src/maniple_mcp/server.py +++ b/src/maniple_mcp/server.py @@ -15,6 +15,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.server.session import ServerSession +from mcp.server.transport_security import TransportSecuritySettings from maniple.events import get_latest_snapshot, prune_event_backups, read_events_since from maniple.poller import WorkerPoller @@ -28,6 +29,9 @@ logger = logging.getLogger("maniple") EVENT_BACKUP_CAP_MB = 200 +LOCAL_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1") +LOCAL_ALLOWED_HOSTS = ["127.0.0.1:*", "localhost:*", "[::1]:*"] +LOCAL_ALLOWED_ORIGINS = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"] # ============================================================================= @@ -338,6 +342,7 @@ def create_mcp_server( host: str = "127.0.0.1", port: int = 8766, enable_poller: bool = False, + transport_security: TransportSecuritySettings | None = None, ) -> FastMCP: """Create and configure the FastMCP server instance.""" server = FastMCP( @@ -345,6 +350,7 @@ def create_mcp_server( lifespan=functools.partial(app_lifespan, enable_poller=enable_poller), host=host, port=port, + transport_security=transport_security, ) # Register all tools from the tools package register_all_tools(server, ensure_connection) @@ -474,19 +480,79 @@ async def resource_session_screen( # ============================================================================= -def run_server(transport: str = "stdio", port: int = 8766): +def _dedupe_preserving_order(values: list[str]) -> list[str]: + """Return values with duplicates removed while keeping the first occurrence.""" + return list(dict.fromkeys(values)) + + +def build_transport_security_settings( + *, + host: str, + allowed_hosts: list[str] | None = None, + allowed_origins: list[str] | None = None, + disable_dns_rebinding_protection: bool = False, +) -> TransportSecuritySettings | None: + """ + Build explicit transport security settings for HTTP mode. + + Returns None when no explicit overrides are requested so FastMCP can keep + its built-in localhost defaults unchanged. + """ + if not allowed_hosts and not allowed_origins and not disable_dns_rebinding_protection: + return None + + merged_hosts = list(allowed_hosts or []) + merged_origins = list(allowed_origins or []) + + if not disable_dns_rebinding_protection and host in LOCAL_HTTP_HOSTS: + merged_hosts = [*LOCAL_ALLOWED_HOSTS, *merged_hosts] + merged_origins = [*LOCAL_ALLOWED_ORIGINS, *merged_origins] + + return TransportSecuritySettings( + enable_dns_rebinding_protection=not disable_dns_rebinding_protection, + allowed_hosts=_dedupe_preserving_order(merged_hosts), + allowed_origins=_dedupe_preserving_order(merged_origins), + ) + + +def run_server( + transport: str = "stdio", + *, + host: str = "127.0.0.1", + port: int = 8766, + allowed_hosts: list[str] | None = None, + allowed_origins: list[str] | None = None, + disable_dns_rebinding_protection: bool = False, +): """ Run the MCP server. Args: transport: Transport mode - "stdio" or "streamable-http" + host: Bind address for HTTP transport port: Port for HTTP transport (default 8766) """ log_path = configure_logging() if transport == "streamable-http": - logger.info("Starting Maniple MCP Server (HTTP on port %s). Logs: %s", port, log_path) + logger.info( + "Starting Maniple MCP Server (HTTP on %s:%s). Logs: %s", + host, + port, + log_path, + ) + transport_security = build_transport_security_settings( + host=host, + allowed_hosts=allowed_hosts, + allowed_origins=allowed_origins, + disable_dns_rebinding_protection=disable_dns_rebinding_protection, + ) # Create server with configured port for HTTP mode - server = create_mcp_server(host="127.0.0.1", port=port, enable_poller=True) + server = create_mcp_server( + host=host, + port=port, + enable_poller=True, + transport_security=transport_security, + ) server.run(transport="streamable-http") else: logger.info("Starting Maniple MCP Server (stdio). Logs: %s", log_path) @@ -505,12 +571,34 @@ def main(): action="store_true", help="Run in HTTP mode (streamable-http) instead of stdio", ) + parser.add_argument( + "--host", + default="127.0.0.1", + help="Bind address for HTTP mode (default: 127.0.0.1)", + ) parser.add_argument( "--port", type=int, default=8766, help="Port for HTTP mode (default: 8766)", ) + parser.add_argument( + "--allow-host", + action="append", + default=None, + help="Allow this HTTP Host header when DNS rebinding protection is enabled. Repeatable.", + ) + parser.add_argument( + "--allow-origin", + action="append", + default=None, + help="Allow this HTTP Origin header when DNS rebinding protection is enabled. Repeatable.", + ) + parser.add_argument( + "--disable-dns-rebinding-protection", + action="store_true", + help="Disable HTTP DNS rebinding protection for streamable-http mode.", + ) # Config subcommands for reading/writing ~/.maniple/config.json. subparsers = parser.add_subparsers(dest="command") @@ -627,7 +715,14 @@ def main(): # Default behavior: run the MCP server. if args.http: - run_server(transport="streamable-http", port=args.port) + run_server( + transport="streamable-http", + host=args.host, + port=args.port, + allowed_hosts=args.allow_host, + allowed_origins=args.allow_origin, + disable_dns_rebinding_protection=args.disable_dns_rebinding_protection, + ) else: run_server(transport="stdio") diff --git a/src/maniple_mcp/terminal_backends/iterm.py b/src/maniple_mcp/terminal_backends/iterm.py index b3ccafd..4e3140d 100644 --- a/src/maniple_mcp/terminal_backends/iterm.py +++ b/src/maniple_mcp/terminal_backends/iterm.py @@ -197,6 +197,8 @@ async def start_agent_in_session( stop_hook_marker_id: Optional[str] = None, output_capture_path: Optional[str] = None, plugin_dir: Optional[str] = None, + command_override: Optional[str] = None, + auto_accept_startup_prompts: bool = False, ) -> None: """Start a CLI agent in an existing terminal session.""" await iterm_utils.start_agent_in_session( @@ -210,6 +212,8 @@ async def start_agent_in_session( stop_hook_marker_id=stop_hook_marker_id, output_capture_path=output_capture_path, plugin_dir=plugin_dir, + command_override=command_override, + auto_accept_startup_prompts=auto_accept_startup_prompts, ) async def find_available_window( diff --git a/src/maniple_mcp/terminal_backends/tmux.py b/src/maniple_mcp/terminal_backends/tmux.py index 92f2bd8..d5505e7 100644 --- a/src/maniple_mcp/terminal_backends/tmux.py +++ b/src/maniple_mcp/terminal_backends/tmux.py @@ -14,6 +14,7 @@ from typing import Any, TYPE_CHECKING from .base import TerminalBackend, TerminalSession +from ..launch_blockers import AgentLaunchBlocked, detect_launch_blocker if TYPE_CHECKING: from ..cli_backends import AgentCLI @@ -468,6 +469,8 @@ async def start_agent_in_session( stop_hook_marker_id: str | None = None, output_capture_path: str | None = None, plugin_dir: str | None = None, + command_override: str | None = None, + auto_accept_startup_prompts: bool = False, ) -> None: """Start a CLI agent in an existing tmux pane.""" # Ensure the shell is responsive before we send the launch command. @@ -482,7 +485,7 @@ async def start_agent_in_session( # Optionally inject a Stop hook using a settings file (Claude only). settings_file = None - if stop_hook_marker_id and cli.supports_settings_file(): + if stop_hook_marker_id and cli.supports_settings_file(command_override=command_override): from ..iterm_utils import build_stop_hook_settings_file settings_file = build_stop_hook_settings_file(stop_hook_marker_id) @@ -493,6 +496,7 @@ async def start_agent_in_session( settings_file=settings_file, plugin_dir=plugin_dir, env_vars=env, + command_override=command_override, ) # Capture stdout/stderr if requested (useful for JSONL idle detection). @@ -508,11 +512,13 @@ async def start_agent_in_session( handle, cli, timeout_seconds=agent_ready_timeout, + auto_accept_startup_prompts=auto_accept_startup_prompts, ) + effective_command = command_override or cli.command() if not agent_ready: raise RuntimeError( f"{cli.engine_id} failed to start in {project_path} within " - f"{agent_ready_timeout}s. Check that '{cli.command()}' is " + f"{agent_ready_timeout}s. Check that '{effective_command}' is " "available and authentication is configured." ) @@ -599,6 +605,7 @@ async def _wait_for_agent_ready( timeout_seconds: float = 15.0, poll_interval: float = 0.2, stable_count: int = 2, + auto_accept_startup_prompts: bool = False, ) -> bool: """Wait for an agent CLI to show its ready patterns.""" import time @@ -607,10 +614,27 @@ async def _wait_for_agent_ready( start_time = time.monotonic() last_content = None stable_reads = 0 + auto_accepted_blockers: set[str] = set() while (time.monotonic() - start_time) < timeout_seconds: # Read the pane content and only check once output stabilizes. content = await self.read_screen_text(session) + blocker = detect_launch_blocker(content, cli.engine_id) + if blocker is not None: + if ( + auto_accept_startup_prompts + and blocker.auto_accept_choice + and blocker.slug not in auto_accepted_blockers + ): + await self.send_text(session, blocker.auto_accept_choice) + await asyncio.sleep(0.2) + await self.send_key(session, "enter") + auto_accepted_blockers.add(blocker.slug) + last_content = None + stable_reads = 0 + await asyncio.sleep(0.5) + continue + raise AgentLaunchBlocked(blocker) if content == last_content: stable_reads += 1 else: diff --git a/src/maniple_mcp/tools/__init__.py b/src/maniple_mcp/tools/__init__.py index f398182..977f9a2 100644 --- a/src/maniple_mcp/tools/__init__.py +++ b/src/maniple_mcp/tools/__init__.py @@ -14,6 +14,7 @@ from . import discover_workers from . import examine_worker from . import list_workers +from . import list_providers from . import list_worktrees from . import message_workers from . import poll_worker_changes @@ -38,6 +39,7 @@ def register_all_tools(mcp: FastMCP, ensure_connection) -> None: check_idle_workers.register_tools(mcp) close_workers.register_tools(mcp) examine_worker.register_tools(mcp) + list_providers.register_tools(mcp) list_workers.register_tools(mcp) list_worktrees.register_tools(mcp) message_workers.register_tools(mcp) diff --git a/src/maniple_mcp/tools/close_workers.py b/src/maniple_mcp/tools/close_workers.py index 0331a9d..b0ba7e5 100644 --- a/src/maniple_mcp/tools/close_workers.py +++ b/src/maniple_mcp/tools/close_workers.py @@ -16,7 +16,12 @@ from ..iterm_utils import CODEX_PRE_ENTER_DELAY from ..registry import SessionRegistry, SessionStatus -from ..worktree import WorktreeError, remove_worktree +from ..worktree import ( + WorktreeError, + delete_worktree_branch, + get_worktree_branch, + remove_worktree, +) from ..utils import error_response, HINTS logger = logging.getLogger("maniple") @@ -48,6 +53,7 @@ async def _close_single_worker( session_id: str, registry: "SessionRegistry", force: bool = False, + delete_branch: bool = False, ) -> dict: """ Close a single worker session. @@ -63,7 +69,7 @@ async def _close_single_worker( force: If True, force close even if session is busy Returns: - Dict with success status and worktree_cleaned flag + Dict with success status and cleanup flags """ # Check if busy if session.status == SessionStatus.BUSY and not force: @@ -72,6 +78,7 @@ async def _close_single_worker( "error": "Session is busy", "hint": HINTS["session_busy"], "worktree_cleaned": False, + "branch_deleted": False, } try: @@ -91,10 +98,17 @@ async def _close_single_worker( # TODO(rabsef-bicrym): Programmatically time these actions await asyncio.sleep(1.0) - # Clean up worktree if exists (keeps branch alive for cherry-picking) + # Capture the branch before removing the worktree so optional branch + # cleanup can still happen after git unregisters the worktree. worktree_cleaned = False + branch_deleted = False if session.worktree_path and session.main_repo_path: + worktree_branch = None try: + worktree_branch = get_worktree_branch( + repo_path=session.main_repo_path, + worktree_path=session.worktree_path, + ) remove_worktree( repo_path=session.main_repo_path, worktree_path=session.worktree_path, @@ -104,6 +118,16 @@ async def _close_single_worker( # Log but don't fail the close logger.warning(f"Failed to clean up worktree for {session_id}: {e}") + if delete_branch and worktree_cleaned and worktree_branch: + try: + delete_worktree_branch( + repo_path=session.main_repo_path, + branch_name=worktree_branch, + ) + branch_deleted = True + except WorktreeError as e: + logger.warning(f"Failed to delete branch for {session_id}: {e}") + # Close the terminal pane/window await backend.close_session(session.terminal_session, force=force) @@ -113,6 +137,7 @@ async def _close_single_worker( return { "success": True, "worktree_cleaned": worktree_cleaned, + "branch_deleted": branch_deleted, } except Exception as e: @@ -123,6 +148,7 @@ async def _close_single_worker( "success": True, "warning": f"Session removed but cleanup may be incomplete: {e}", "worktree_cleaned": False, + "branch_deleted": False, } @@ -134,6 +160,7 @@ async def close_workers( ctx: Context[ServerSession, "AppContext"], session_ids: list[str], force: bool | None = False, + delete_branch: bool | None = False, ) -> dict: """ Close one or more managed Claude Code sessions. @@ -144,7 +171,8 @@ async def close_workers( ⚠️ **NOTE: WORKTREE CLEANUP** Workers with worktrees commit to ephemeral branches. When closed: - The worktree directory is removed - - The branch is KEPT for cherry-picking/merging + - The branch is kept by default for cherry-picking/merging + - Set delete_branch=True to also delete the worker branch immediately **AFTER closing workers with worktrees:** 1. Review commits on the worker's branch @@ -155,6 +183,8 @@ async def close_workers( session_ids: List of session IDs to close (1 or more required). Accepts internal IDs, terminal IDs, or worker names. force: If True, force close even if sessions are busy + delete_branch: If True, also delete the worker branch after the + worktree is removed Returns: Dict with: @@ -165,6 +195,7 @@ async def close_workers( """ # Handle None values from MCP clients that send explicit null for omitted params force = force if force is not None else False + delete_branch = delete_branch if delete_branch is not None else False app_ctx = ctx.request_context.lifespan_context registry = app_ctx.registry @@ -198,7 +229,14 @@ async def close_workers( # Close all sessions in parallel async def close_one(sid: str, session) -> tuple[str, dict]: - result = await _close_single_worker(backend, session, sid, registry, force) + result = await _close_single_worker( + backend, + session, + sid, + registry, + force, + delete_branch, + ) return (sid, result) tasks = [close_one(sid, session) for sid, session in sessions_to_close] diff --git a/src/maniple_mcp/tools/list_providers.py b/src/maniple_mcp/tools/list_providers.py new file mode 100644 index 0000000..dcb141b --- /dev/null +++ b/src/maniple_mcp/tools/list_providers.py @@ -0,0 +1,56 @@ +""" +Provider listing tool. + +Provides list_providers for discovering configured worker launch providers. +""" + +from mcp.server.fastmcp import FastMCP + +from ..config import ConfigError, load_config, resolve_config_path +from ..utils import error_response + + +def register_tools(mcp: FastMCP) -> None: + """Register list_providers tool on the MCP server.""" + + @mcp.tool() + async def list_providers() -> dict: + """ + List configured worker launch providers from ~/.maniple/config.json. + + Useful for clients that want to discover which Claude-compatible + provider presets are available before calling spawn_workers. + + Returns: + Dict with provider names, command/env metadata, and config path + """ + config_path = resolve_config_path() + + try: + config = load_config() + except ConfigError as exc: + return error_response( + f"Invalid config: {exc}", + hint="Fix ~/.maniple/config.json and try again.", + config_path=str(config_path), + ) + + providers = [] + for name in sorted(config.providers): + provider = config.providers[name] + providers.append({ + "name": name, + "command": provider.command, + "env": dict(sorted(provider.env.items())), + }) + + return { + "config_path": str(config_path), + "count": len(providers), + "provider_names": [item["name"] for item in providers], + "providers": providers, + "usage_tip": ( + "Pass provider='' in spawn_workers to launch a worker " + "with one of these presets." + ), + } diff --git a/src/maniple_mcp/tools/spawn_workers.py b/src/maniple_mcp/tools/spawn_workers.py index 3b875e5..e656444 100644 --- a/src/maniple_mcp/tools/spawn_workers.py +++ b/src/maniple_mcp/tools/spawn_workers.py @@ -20,6 +20,7 @@ from ..config import ConfigError, default_config, load_config from ..colors import generate_tab_color from ..formatting import format_badge_text, format_session_title +from ..launch_blockers import AgentLaunchBlocked from ..names import pick_names_for_count from ..profile import apply_appearance_colors from ..registry import SessionStatus @@ -51,6 +52,9 @@ class WorkerConfig(TypedDict, total=False): use_worktree: bool # Optional: Create isolated worktree (default True) worktree: WorktreeConfig # Optional: Worktree settings (branch/base) plugin_dir: str | list[str] # Optional: Path(s) to plugin directory for --plugin-dir + provider: str # Optional: Named provider preset from config.providers + command: str # Optional: Per-worker command override + env: dict[str, str] # Optional: Per-worker environment variable overrides def register_tools(mcp: FastMCP, ensure_connection) -> None: @@ -151,9 +155,15 @@ async def spawn_workers( commit with issue reference). Used for badge first line and branch naming. prompt: Optional additional instructions. Combined with standard worker prompt, not a replacement. Use for extra context beyond what the issue describes. - skip_permissions: Whether to start Claude with --dangerously-skip-permissions. + skip_permissions: Whether to start Claude with --permission-mode bypassPermissions. Default False. Without this, workers can only read local files and will struggle with most commands (writes, shell, etc.). + provider: Optional named launch preset from config.providers. Cannot be + combined with command/env on the same worker. + command: Optional per-worker command override. Use this for wrapper + scripts like /path/to/maniple-claude-local. + env: Optional per-worker environment variables merged into the worker + launch environment. Values must be strings. **Worker Assignment (how workers know what to do):** @@ -233,6 +243,7 @@ async def spawn_workers( logger.warning("Invalid config file; using defaults: %s", exc) config = default_config() defaults = config.defaults + providers = config.providers # Resolve layout from config if not explicitly provided if layout is None: @@ -387,6 +398,61 @@ async def spawn_workers( agent_type = defaults.agent_type agent_types.append(agent_type) + resolved_commands: list[str | None] = [] + resolved_env_overrides: list[dict[str, str]] = [] + for i, w in enumerate(workers): + provider_name = w.get("provider") + if provider_name is None and agent_types[i] != "codex": + provider_name = defaults.provider + command_override = w.get("command") + env_override = w.get("env") + + if provider_name is not None and (command_override is not None or env_override is not None): + return error_response( + f"Worker {i} cannot combine 'provider' with 'command' or 'env'", + hint="Use either a named provider preset or direct command/env overrides.", + ) + + if provider_name is not None: + if not isinstance(provider_name, str) or not provider_name.strip(): + return error_response(f"Worker {i} has invalid 'provider'") + provider = providers.get(provider_name) + if provider is None: + return error_response( + f"Unknown provider for worker {i}: {provider_name}", + hint="Add it under config.providers or use command/env directly.", + ) + resolved_commands.append(provider.command) + resolved_env_overrides.append(dict(provider.env)) + continue + + if command_override is not None: + if not isinstance(command_override, str) or not command_override.strip(): + return error_response(f"Worker {i} has invalid 'command'") + resolved_commands.append(command_override) + else: + resolved_commands.append(None) + + if env_override is None: + resolved_env_overrides.append({}) + elif not isinstance(env_override, dict): + return error_response(f"Worker {i} has invalid 'env'") + else: + normalized_env: dict[str, str] = {} + for key, value in env_override.items(): + if not isinstance(key, str) or not key.strip(): + return error_response( + f"Worker {i} has invalid env key", + hint="Environment variable names must be non-empty strings.", + ) + if not isinstance(value, str): + return error_response( + f"Worker {i} has invalid env value for {key}", + hint="Environment variable values must be strings.", + ) + normalized_env[key] = value + resolved_env_overrides.append(normalized_env) + # Build profile customizations for each worker (iTerm-only) profile_customizations: list[object | None] = [None] * worker_count if isinstance(backend, ItermBackend): @@ -671,6 +737,10 @@ async def start_agent_for_worker(index: int) -> None: else: env = None + extra_env = resolved_env_overrides[index] + if extra_env: + env = (env or {}) | extra_env + # Codex can prompt interactively to install updates, which blocks # unattended remote worker launches. Mark worker sessions as CI to # suppress interactive upgrade prompts. @@ -684,6 +754,7 @@ async def start_agent_for_worker(index: int) -> None: if skip_permissions is None: skip_permissions = defaults.skip_permissions plugin_dir = worker_config.get("plugin_dir") + command_override = resolved_commands[index] await backend.start_agent_in_session( handle=session, cli=cli, @@ -692,6 +763,8 @@ async def start_agent_for_worker(index: int) -> None: env=env, stop_hook_marker_id=stop_hook_marker_id, plugin_dir=plugin_dir, + command_override=command_override, + auto_accept_startup_prompts=config.terminal.auto_accept_startup_prompts, ) await asyncio.gather(*[start_agent_for_worker(i) for i in range(worker_count)]) @@ -865,9 +938,18 @@ async def start_agent_for_worker(index: int) -> None: except ValueError as e: logger.error(f"Validation error in spawn_workers: {e}") return error_response(str(e)) + except AgentLaunchBlocked as e: + logger.error(f"Worker launch blocked: {e}") + return error_response( + str(e), + hint=getattr(e, "hint", HINTS["launch_blocked"]), + ) except Exception as e: logger.error(f"Failed to spawn workers: {e}") + hint = getattr(e, "hint", None) + if hint is None and backend.backend_id == "iterm": + hint = HINTS["iterm_connection"] return error_response( str(e), - hint=HINTS["iterm_connection"], + hint=hint, ) diff --git a/src/maniple_mcp/utils/errors.py b/src/maniple_mcp/utils/errors.py index f4534be..c67166d 100644 --- a/src/maniple_mcp/utils/errors.py +++ b/src/maniple_mcp/utils/errors.py @@ -4,7 +4,12 @@ Provides standardized error formatting and common hints for recovery. """ -from ..registry import SessionRegistry, ManagedSession +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ..registry import ManagedSession, SessionRegistry def error_response( @@ -66,6 +71,10 @@ def error_response( "The session is currently processing. Wait for it to finish, or use " "force=True to close it anyway (may lose work)" ), + "launch_blocked": ( + "Open the worker terminal and complete any Claude startup confirmation " + "prompt, then retry spawning the worker" + ), } diff --git a/src/maniple_mcp/worktree.py b/src/maniple_mcp/worktree.py index 8eb5b45..def84d5 100644 --- a/src/maniple_mcp/worktree.py +++ b/src/maniple_mcp/worktree.py @@ -457,6 +457,64 @@ def remove_worktree( return True +def get_worktree_branch(repo_path: Path, worktree_path: Path) -> Optional[str]: + """ + Return the branch currently associated with a registered worktree path. + + Args: + repo_path: Path to the main repository + worktree_path: Full path to the worktree + + Returns: + Branch name if the worktree is registered and not detached, otherwise None + """ + repo_path = Path(repo_path).resolve() + worktree_path = Path(worktree_path).resolve() + + for worktree in list_git_worktrees(repo_path): + if Path(worktree["path"]).resolve() == worktree_path: + return worktree.get("branch") + + return None + + +def delete_worktree_branch( + repo_path: Path, + branch_name: str, + force: bool = True, +) -> bool: + """ + Delete a worktree branch after its worktree has been removed. + + Args: + repo_path: Path to the main repository + branch_name: Branch to delete + force: If True, use `git branch -D`; otherwise use `-d` + + Returns: + True if the branch was deleted or already absent + + Raises: + WorktreeError: If the branch deletion fails + """ + repo_path = Path(repo_path).resolve() + + branch_flag = "-D" if force else "-d" + result = subprocess.run( + ["git", "-C", str(repo_path), "branch", branch_flag, branch_name], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + stderr = result.stderr.strip() + if "not found" in stderr or "branch '" in stderr and "not found" in stderr: + return True + raise WorktreeError(f"Failed to delete branch: {stderr}") + + return True + + def list_git_worktrees(repo_path: Path) -> list[dict]: """ List all worktrees registered with git for a repository. diff --git a/tests/test_claude_maniple_switch.py b/tests/test_claude_maniple_switch.py new file mode 100644 index 0000000..486dd9f --- /dev/null +++ b/tests/test_claude_maniple_switch.py @@ -0,0 +1,107 @@ +"""Tests for the claude-maniple-switch wrapper script.""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + + +def _write_executable(path: Path, contents: str) -> None: + path.write_text(contents) + path.chmod(path.stat().st_mode | stat.S_IXUSR) + + +def test_wrapper_loads_maniple_env_and_dispatches_provider(tmp_path: Path) -> None: + """The wrapper should dispatch to the requested provider with ~/.maniple env overrides.""" + fake_switch = tmp_path / "fake-claude-switch.sh" + _write_executable( + fake_switch, + """#!/usr/bin/env bash +claude-minimax() { + echo "provider=minimax" + echo "base_url=${ANTHROPIC_BASE_URL:-}" + echo "auth=${ANTHROPIC_AUTH_TOKEN:-}" + echo "arg1=${1:-}" +} +claude-kimi() { echo "provider=kimi"; } +claude-local() { echo "provider=local"; } +claude-official() { echo "provider=official"; } +""", + ) + + provider_env = tmp_path / ".env" + provider_env.write_text( + "export ANTHROPIC_BASE_URL=https://minimax.example.test/anthropic\n" + "export ANTHROPIC_AUTH_TOKEN=test-token\n" + ) + + wrapper = Path(__file__).resolve().parent.parent / "scripts" / "claude-maniple-switch" + env = os.environ.copy() + env.update({ + "CLAUDE_SWITCH_SCRIPT": str(fake_switch), + "CLAUDE_SWITCH_PROVIDER": "minimax", + "MANIPLE_PROVIDER_ENV_FILE": str(provider_env), + }) + + result = subprocess.run( + ["bash", str(wrapper), "--help"], + capture_output=True, + text=True, + check=True, + env=env, + ) + + assert "provider=minimax" in result.stdout + assert "base_url=https://minimax.example.test/anthropic" in result.stdout + assert "auth=test-token" in result.stdout + assert "arg1=--help" in result.stdout + + +def test_wrapper_applies_namespaced_provider_env_convention(tmp_path: Path) -> None: + """Namespaced MANIPLE_PROVIDER_* vars should map to provider-specific aliases.""" + fake_switch = tmp_path / "fake-claude-switch.sh" + _write_executable( + fake_switch, + """#!/usr/bin/env bash +claude-local() { + echo "provider=local" + echo "local_base_url=${LOCAL_BASE_URL:-}" + echo "local_api_key=${LOCAL_API_KEY:-}" + echo "local_model=${LOCAL_MODEL:-}" +} +claude-official() { echo "provider=official"; } +""", + ) + + provider_env = tmp_path / ".env" + provider_env.write_text( + "export MANIPLE_PROVIDER_LOCAL_BASE_URL=http://127.0.0.1:4000\n" + "export MANIPLE_PROVIDER_LOCAL_API_KEY=local-token\n" + "export MANIPLE_PROVIDER_LOCAL_MODEL=claude-local-model\n" + ) + + wrapper = Path(__file__).resolve().parent.parent / "scripts" / "claude-maniple-switch" + env = os.environ.copy() + env.pop("LOCAL_BASE_URL", None) + env.pop("LOCAL_API_KEY", None) + env.pop("LOCAL_MODEL", None) + env.update({ + "CLAUDE_SWITCH_SCRIPT": str(fake_switch), + "CLAUDE_SWITCH_PROVIDER": "local", + "MANIPLE_PROVIDER_ENV_FILE": str(provider_env), + }) + + result = subprocess.run( + ["bash", str(wrapper)], + capture_output=True, + text=True, + check=True, + env=env, + ) + + assert "provider=local" in result.stdout + assert "local_base_url=http://127.0.0.1:4000" in result.stdout + assert "local_api_key=local-token" in result.stdout + assert "local_model=claude-local-model" in result.stdout diff --git a/tests/test_cli_backends.py b/tests/test_cli_backends.py index e315ba9..7427749 100644 --- a/tests/test_cli_backends.py +++ b/tests/test_cli_backends.py @@ -107,10 +107,10 @@ def test_build_args_empty_default(self): assert args == [] def test_build_args_skip_permissions(self): - """Should add --dangerously-skip-permissions flag.""" + """Should add permission-mode bypassPermissions.""" cli = ClaudeCLI() args = cli.build_args(dangerously_skip_permissions=True) - assert "--dangerously-skip-permissions" in args + assert args[:2] == ["--permission-mode", "bypassPermissions"] def test_build_args_settings_file_default_command(self): """Should add --settings flag for default claude command.""" @@ -160,6 +160,24 @@ def test_supports_settings_file_custom_command(self): cli = ClaudeCLI() assert cli.supports_settings_file() is False + def test_supports_settings_file_claude_wrapper_command(self): + """Wrapper commands named like claude-* should keep --settings support.""" + with patch.dict(os.environ, {"MANIPLE_COMMAND": "/usr/local/bin/claude-local"}): + cli = ClaudeCLI() + assert cli.supports_settings_file() is True + + def test_supports_settings_file_env_opt_in_for_wrapper(self): + """Custom wrappers can opt in explicitly to preserve stop-hook injection.""" + with patch.dict( + os.environ, + { + "MANIPLE_COMMAND": "/opt/bin/provider-wrapper", + "MANIPLE_CLAUDE_SUPPORTS_SETTINGS": "true", + }, + ): + cli = ClaudeCLI() + assert cli.supports_settings_file() is True + def test_build_full_command_simple(self): """build_full_command should combine command and args.""" with patch.dict(os.environ, {}, clear=True): @@ -167,7 +185,7 @@ def test_build_full_command_simple(self): os.environ.pop("CLAUDE_TEAM_COMMAND", None) cli = ClaudeCLI() cmd = cli.build_full_command(dangerously_skip_permissions=True) - assert cmd == "claude --dangerously-skip-permissions" + assert cmd == "claude --permission-mode bypassPermissions" def test_build_full_command_with_env_vars(self): """build_full_command should prepend env vars.""" @@ -180,6 +198,14 @@ def test_build_full_command_with_env_vars(self): assert "BAZ=qux" in cmd assert cmd.endswith("claude") + def test_build_args_settings_file_supported_for_wrapper(self): + """Wrapper commands that pass through to Claude should still get --settings.""" + with patch.dict(os.environ, {"MANIPLE_COMMAND": "/usr/local/bin/claude-local"}): + cli = ClaudeCLI() + args = cli.build_args(settings_file="/path/to/settings.json") + assert "--settings" in args + assert "/path/to/settings.json" in args + class TestCodexCLI: """Tests for Codex CLI backend.""" diff --git a/tests/test_close_workers.py b/tests/test_close_workers.py new file mode 100644 index 0000000..586b9e0 --- /dev/null +++ b/tests/test_close_workers.py @@ -0,0 +1,187 @@ +"""Tests for the close_workers tool.""" + +import importlib +from pathlib import Path +from types import SimpleNamespace + +import pytest +from mcp.server.fastmcp import FastMCP + +close_workers_module = importlib.import_module("maniple_mcp.tools.close_workers") + + +class FakeBackend: + """Minimal backend for close_workers tests.""" + + def __init__(self) -> None: + self.sent_keys: list[tuple[str, str]] = [] + self.sent_texts: list[tuple[str, str]] = [] + self.closed: list[tuple[str, bool]] = [] + + async def send_key(self, session, key: str) -> None: + self.sent_keys.append((session.native_id, key)) + + async def send_text(self, session, text: str) -> None: + self.sent_texts.append((session.native_id, text)) + + async def close_session(self, session, force: bool = False) -> None: + self.closed.append((session.native_id, force)) + + +async def _no_sleep(_: float) -> None: + """Skip timing delays in tests.""" + + +class FakeRegistry: + """Minimal registry for close_workers tests.""" + + def __init__(self, session) -> None: + self.session = session + self.removed: list[str] = [] + + def resolve(self, session_id: str): + if self.session and session_id == self.session.session_id: + return self.session + return None + + def remove(self, session_id: str): + self.removed.append(session_id) + if self.session and session_id == self.session.session_id: + session = self.session + self.session = None + return session + return None + + +def _make_session() -> SimpleNamespace: + return SimpleNamespace( + session_id="worker-1", + status=close_workers_module.SessionStatus.READY, + agent_type="claude", + terminal_session=SimpleNamespace(native_id="%1"), + main_repo_path=Path("/repo"), + worktree_path=Path("/repo/.worktrees/worker-branch"), + ) + + +def _build_tool(backend: FakeBackend, registry: FakeRegistry): + app_ctx = SimpleNamespace(registry=registry, terminal_backend=backend) + mcp = FastMCP("test") + close_workers_module.register_tools(mcp) + tool = mcp._tool_manager.get_tool("close_workers") + assert tool is not None + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + return tool, ctx + + +@pytest.mark.asyncio +async def test_close_workers_keeps_branch_by_default(monkeypatch): + """close_workers should remove the worktree but keep the branch by default.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + removed: list[tuple[Path, Path]] = [] + deleted_branches: list[tuple[Path, str]] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + monkeypatch.setattr( + close_workers_module, + "remove_worktree", + lambda repo_path, worktree_path: removed.append((repo_path, worktree_path)) or True, + ) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: deleted_branches.append((repo_path, branch_name)) or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run({"session_ids": ["worker-1"]}, context=ctx) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is True + assert result["results"]["worker-1"]["branch_deleted"] is False + assert removed == [(Path("/repo"), Path("/repo/.worktrees/worker-branch"))] + assert deleted_branches == [] + + +@pytest.mark.asyncio +async def test_close_workers_can_delete_branch(monkeypatch): + """close_workers should optionally delete the branch after removing the worktree.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + call_order: list[str] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + monkeypatch.setattr( + close_workers_module, + "remove_worktree", + lambda repo_path, worktree_path: call_order.append("remove_worktree") or True, + ) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: call_order.append(f"delete_branch:{branch_name}") or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run( + {"session_ids": ["worker-1"], "delete_branch": True}, + context=ctx, + ) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is True + assert result["results"]["worker-1"]["branch_deleted"] is True + assert call_order == ["remove_worktree", "delete_branch:worker-branch"] + + +@pytest.mark.asyncio +async def test_close_workers_skips_branch_delete_when_worktree_cleanup_fails(monkeypatch): + """Branch deletion should not run if worktree removal fails.""" + monkeypatch.setattr(close_workers_module.asyncio, "sleep", _no_sleep) + + deleted_branches: list[str] = [] + + monkeypatch.setattr( + close_workers_module, + "get_worktree_branch", + lambda repo_path, worktree_path: "worker-branch", + ) + + def fail_remove_worktree(repo_path, worktree_path): + raise close_workers_module.WorktreeError("boom") + + monkeypatch.setattr(close_workers_module, "remove_worktree", fail_remove_worktree) + monkeypatch.setattr( + close_workers_module, + "delete_worktree_branch", + lambda repo_path, branch_name: deleted_branches.append(branch_name) or True, + ) + + backend = FakeBackend() + registry = FakeRegistry(_make_session()) + + tool, ctx = _build_tool(backend, registry) + result = await tool.run( + {"session_ids": ["worker-1"], "delete_branch": True}, + context=ctx, + ) + + assert result["success_count"] == 1 + assert result["results"]["worker-1"]["worktree_cleaned"] is False + assert result["results"]["worker-1"]["branch_deleted"] is False + assert deleted_branches == [] diff --git a/tests/test_config.py b/tests/test_config.py index 24dcba3..ac23b37 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -12,6 +12,7 @@ DefaultsConfig, EventsConfig, IssueTrackerConfig, + ProviderConfig, TerminalConfig, default_config, load_config, @@ -42,6 +43,7 @@ def test_default_defaults(self): """Default spawn_workers defaults.""" config = default_config() assert config.defaults.agent_type == "claude" + assert config.defaults.provider is None assert config.defaults.skip_permissions is False assert config.defaults.use_worktree is True assert config.defaults.layout == "auto" @@ -50,6 +52,7 @@ def test_default_terminal(self): """Default terminal backend is None (auto-detect).""" config = default_config() assert config.terminal.backend is None + assert config.terminal.auto_accept_startup_prompts is False def test_default_events(self): """Default events config values.""" @@ -63,6 +66,11 @@ def test_default_issue_tracker(self): config = default_config() assert config.issue_tracker.override is None + def test_default_providers(self): + """Default provider presets should be empty.""" + config = default_config() + assert config.providers == {} + class TestSaveConfig: """Tests for save_config function.""" @@ -97,13 +105,23 @@ def test_saves_all_fields(self, tmp_path: Path): commands=CommandsConfig(claude="/custom/claude", codex="/custom/codex"), defaults=DefaultsConfig( agent_type="codex", + provider="local", skip_permissions=True, use_worktree=False, layout="new", ), - terminal=TerminalConfig(backend="tmux"), + terminal=TerminalConfig( + backend="tmux", + auto_accept_startup_prompts=True, + ), events=EventsConfig(max_size_mb=5, recent_hours=48, stale_threshold_minutes=15), issue_tracker=IssueTrackerConfig(override="beads"), + providers={ + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + }, ) save_config(config, config_path) data = json.loads(config_path.read_text()) @@ -111,14 +129,18 @@ def test_saves_all_fields(self, tmp_path: Path): assert data["commands"]["claude"] == "/custom/claude" assert data["commands"]["codex"] == "/custom/codex" assert data["defaults"]["agent_type"] == "codex" + assert data["defaults"]["provider"] == "local" assert data["defaults"]["skip_permissions"] is True assert data["defaults"]["use_worktree"] is False assert data["defaults"]["layout"] == "new" assert data["terminal"]["backend"] == "tmux" + assert data["terminal"]["auto_accept_startup_prompts"] is True assert data["events"]["max_size_mb"] == 5 assert data["events"]["recent_hours"] == 48 assert data["events"]["stale_threshold_minutes"] == 15 assert data["issue_tracker"]["override"] == "beads" + assert data["providers"]["local"]["command"] == "/usr/local/bin/claude-local" + assert data["providers"]["local"]["env"] == {"CLAUDE_PROVIDER": "local"} def test_json_is_formatted(self, tmp_path: Path): """Saved JSON is indented for readability.""" @@ -157,16 +179,29 @@ def test_loads_existing_config(self, tmp_path: Path): "version": 1, "commands": {"claude": "/my/claude"}, "defaults": {"agent_type": "codex"}, - "terminal": {"backend": "iterm"}, + "terminal": { + "backend": "iterm", + "auto_accept_startup_prompts": True, + }, "events": {"max_size_mb": 10}, "issue_tracker": {"override": "pebbles"}, + "providers": { + "local": { + "command": "/usr/local/bin/claude-local", + "env": {"CLAUDE_PROVIDER": "local"}, + } + }, })) config = load_config(config_path) assert config.commands.claude == "/my/claude" assert config.defaults.agent_type == "codex" + assert config.defaults.provider is None assert config.terminal.backend == "iterm" + assert config.terminal.auto_accept_startup_prompts is True assert config.events.max_size_mb == 10 assert config.issue_tracker.override == "pebbles" + assert config.providers["local"].command == "/usr/local/bin/claude-local" + assert config.providers["local"].env == {"CLAUDE_PROVIDER": "local"} def test_partial_config_uses_defaults(self, tmp_path: Path): """Missing sections use default values.""" @@ -176,7 +211,9 @@ def test_partial_config_uses_defaults(self, tmp_path: Path): # All other fields should have defaults assert config.commands.claude is None assert config.defaults.agent_type == "claude" + assert config.defaults.provider is None assert config.terminal.backend is None + assert config.terminal.auto_accept_startup_prompts is False assert config.events.max_size_mb == 1 assert config.issue_tracker.override is None @@ -203,6 +240,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): commands=CommandsConfig(claude="/bin/claude", codex="/bin/codex"), defaults=DefaultsConfig( agent_type="codex", + provider="local", skip_permissions=True, use_worktree=False, layout="new", @@ -210,6 +248,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): terminal=TerminalConfig(backend="tmux"), events=EventsConfig(max_size_mb=2, recent_hours=12, stale_threshold_minutes=30), issue_tracker=IssueTrackerConfig(override="beads"), + providers={"local": ProviderConfig(env={"CLAUDE_PROVIDER": "local"})}, ) save_config(original, config_path) loaded = load_config(config_path) @@ -217,6 +256,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): assert loaded.commands.claude == original.commands.claude assert loaded.commands.codex == original.commands.codex assert loaded.defaults.agent_type == original.defaults.agent_type + assert loaded.defaults.provider == original.defaults.provider assert loaded.defaults.skip_permissions == original.defaults.skip_permissions assert loaded.defaults.use_worktree == original.defaults.use_worktree assert loaded.defaults.layout == original.defaults.layout @@ -225,6 +265,7 @@ def test_roundtrip_preserves_values(self, tmp_path: Path): assert loaded.events.recent_hours == original.events.recent_hours assert loaded.events.stale_threshold_minutes == original.events.stale_threshold_minutes assert loaded.issue_tracker.override == original.issue_tracker.override + assert loaded.providers == original.providers class TestJsonValidationErrors: @@ -444,6 +485,26 @@ def test_defaults_skip_permissions_not_bool(self, tmp_path: Path): with pytest.raises(ConfigError, match="defaults.skip_permissions must be a boolean"): load_config(config_path) + def test_defaults_provider_not_string(self, tmp_path: Path): + """Non-string provider raises ConfigError.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "version": 1, + "defaults": {"provider": True}, + })) + with pytest.raises(ConfigError, match="defaults.provider must be a string"): + load_config(config_path) + + def test_defaults_provider_empty_string_raises(self, tmp_path: Path): + """Empty default provider raises ConfigError.""" + config_path = tmp_path / "config.json" + config_path.write_text(json.dumps({ + "version": 1, + "defaults": {"provider": ""}, + })) + with pytest.raises(ConfigError, match="defaults.provider cannot be empty"): + load_config(config_path) + def test_defaults_use_worktree_not_bool(self, tmp_path: Path): """Non-boolean use_worktree raises ConfigError.""" config_path = tmp_path / "config.json" diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index c134b00..1f9b383 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -109,6 +109,12 @@ def test_set_stale_threshold_minutes(self, config_path: Path): config = load_config() assert config.events.stale_threshold_minutes == 30 + def test_set_default_provider(self, config_path: Path): + """set_config_value persists defaults.provider.""" + set_config_value("defaults.provider", "local") + config = load_config() + assert config.defaults.provider == "local" + class TestStaleThresholdEnvOverride: """Tests for stale_threshold_minutes env override.""" diff --git a/tests/test_iterm_utils.py b/tests/test_iterm_utils.py index 3e9c1fe..3bb5b9f 100644 --- a/tests/test_iterm_utils.py +++ b/tests/test_iterm_utils.py @@ -5,7 +5,81 @@ import pytest -from maniple_mcp.iterm_utils import build_stop_hook_settings_file +from maniple_mcp.iterm_utils import build_stop_hook_settings_file, wait_for_agent_ready +from maniple_mcp.launch_blockers import AgentLaunchBlocked + + +class _FakeClaudeCLI: + engine_id = "claude" + + def ready_patterns(self): + return [">", "tokens"] + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_mcp_trust_prompt(monkeypatch): + async def fake_read_screen_text(_session): + return ( + "New MCP server found in .mcp.json\n" + "1. Use this and all future MCP servers in this project\n" + ) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await wait_for_agent_ready(object(), _FakeClaudeCLI(), timeout_seconds=1.0) + + assert "MCP trust prompt" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_bypass_permissions_prompt(monkeypatch): + async def fake_read_screen_text(_session): + return ( + "WARNING: Claude Code running in Bypass Permissions mode\n" + "2. Yes, I accept\n" + ) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await wait_for_agent_ready(object(), _FakeClaudeCLI(), timeout_seconds=1.0) + + assert "Bypass Permissions confirmation" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_auto_accepts_bypass_permissions(monkeypatch): + responses = iter([ + "WARNING: Claude Code running in Bypass Permissions mode\n2. Yes, I accept\n", + "> ", + "> ", + "> ", + ]) + sent = [] + + async def fake_read_screen_text(_session): + return next(responses) + + async def fake_send_text(_session, text): + sent.append(("text", text)) + + async def fake_send_key(_session, key): + sent.append(("key", key)) + + monkeypatch.setattr("maniple_mcp.iterm_utils.read_screen_text", fake_read_screen_text) + monkeypatch.setattr("maniple_mcp.iterm_utils.send_text", fake_send_text) + monkeypatch.setattr("maniple_mcp.iterm_utils.send_key", fake_send_key) + + ready = await wait_for_agent_ready( + object(), + _FakeClaudeCLI(), + timeout_seconds=2.0, + auto_accept_startup_prompts=True, + ) + + assert ready is True + assert sent == [("text", "2"), ("key", "enter")] class TestClaudeCommandBuilding: @@ -75,8 +149,8 @@ def test_custom_command_with_path_skips_settings(self): assert "--settings" not in claude_cmd - def test_dangerously_skip_permissions_still_added(self): - """--dangerously-skip-permissions should be added regardless of command.""" + def test_bypass_permissions_mode_still_added(self): + """Bypass permission mode should be added regardless of command.""" # Test with default claude with patch.dict(os.environ, {}, clear=False): os.environ.pop("MANIPLE_COMMAND", None) @@ -84,9 +158,9 @@ def test_dangerously_skip_permissions_still_added(self): dangerously_skip_permissions = True if dangerously_skip_permissions: - claude_cmd += " --dangerously-skip-permissions" + claude_cmd += " --permission-mode bypassPermissions" - assert "--dangerously-skip-permissions" in claude_cmd + assert "--permission-mode bypassPermissions" in claude_cmd # Test with custom command with patch.dict(os.environ, {"MANIPLE_COMMAND": "happy"}): @@ -94,10 +168,10 @@ def test_dangerously_skip_permissions_still_added(self): dangerously_skip_permissions = True if dangerously_skip_permissions: - claude_cmd += " --dangerously-skip-permissions" + claude_cmd += " --permission-mode bypassPermissions" - assert "--dangerously-skip-permissions" in claude_cmd - assert claude_cmd == "happy --dangerously-skip-permissions" + assert "--permission-mode bypassPermissions" in claude_cmd + assert claude_cmd == "happy --permission-mode bypassPermissions" class TestBuildStopHookSettingsFile: diff --git a/tests/test_list_providers.py b/tests/test_list_providers.py new file mode 100644 index 0000000..062ad94 --- /dev/null +++ b/tests/test_list_providers.py @@ -0,0 +1,73 @@ +"""Tests for the list_providers tool.""" + +from types import SimpleNamespace + +from mcp.server.fastmcp import FastMCP + +from maniple_mcp.config import ConfigError, ProviderConfig, default_config +from maniple_mcp.tools import list_providers as list_providers_module + + +async def _run_tool(payload: dict | None = None): + mcp = FastMCP("test") + list_providers_module.register_tools(mcp) + tool = mcp._tool_manager.get_tool("list_providers") + assert tool is not None + return await tool.run(payload or {}, context=SimpleNamespace()) + + +async def test_list_providers_returns_sorted_provider_metadata(monkeypatch): + """Configured providers should be returned in stable sorted order.""" + config = default_config() + config.providers = { + "kimi": ProviderConfig( + command="/home/test/bin/claude-maniple-switch", + env={"CLAUDE_SWITCH_PROVIDER": "kimi"}, + ), + "aliyun": ProviderConfig( + command="/home/test/bin/claude-maniple-switch", + env={"CLAUDE_SWITCH_PROVIDER": "aliyun"}, + ), + } + monkeypatch.setattr(list_providers_module, "load_config", lambda: config) + monkeypatch.setattr( + list_providers_module, + "resolve_config_path", + lambda: "/home/test/.maniple/config.json", + ) + + result = await _run_tool() + + assert result["count"] == 2 + assert result["provider_names"] == ["aliyun", "kimi"] + assert result["providers"] == [ + { + "name": "aliyun", + "command": "/home/test/bin/claude-maniple-switch", + "env": {"CLAUDE_SWITCH_PROVIDER": "aliyun"}, + }, + { + "name": "kimi", + "command": "/home/test/bin/claude-maniple-switch", + "env": {"CLAUDE_SWITCH_PROVIDER": "kimi"}, + }, + ] + + +async def test_list_providers_returns_error_for_invalid_config(monkeypatch): + """Config validation failures should surface as tool errors.""" + monkeypatch.setattr( + list_providers_module, + "load_config", + lambda: (_ for _ in ()).throw(ConfigError("bad config")), + ) + monkeypatch.setattr( + list_providers_module, + "resolve_config_path", + lambda: "/home/test/.maniple/config.json", + ) + + result = await _run_tool() + + assert result["error"] == "Invalid config: bad config" + assert result["config_path"] == "/home/test/.maniple/config.json" diff --git a/tests/test_server_http_cli.py b/tests/test_server_http_cli.py new file mode 100644 index 0000000..001da96 --- /dev/null +++ b/tests/test_server_http_cli.py @@ -0,0 +1,106 @@ +"""Tests for HTTP CLI options and transport security configuration.""" + +import sys + +import maniple_mcp.server as server_module + + +def test_build_transport_security_settings_defaults_to_fastmcp_behavior(): + """No explicit overrides should preserve FastMCP's built-in defaults.""" + settings = server_module.build_transport_security_settings(host="127.0.0.1") + assert settings is None + + +def test_build_transport_security_settings_merges_localhost_defaults(): + """Explicit allow-lists on localhost should keep local defaults available.""" + settings = server_module.build_transport_security_settings( + host="127.0.0.1", + allowed_hosts=["100.64.0.45:8766"], + allowed_origins=["https://manager.example.com"], + ) + + assert settings is not None + assert settings.enable_dns_rebinding_protection is True + assert "127.0.0.1:*" in settings.allowed_hosts + assert "localhost:*" in settings.allowed_hosts + assert "100.64.0.45:8766" in settings.allowed_hosts + assert "http://127.0.0.1:*" in settings.allowed_origins + assert "https://manager.example.com" in settings.allowed_origins + + +def test_build_transport_security_settings_can_disable_rebinding_protection(): + """The explicit disable flag should override localhost auto-protection.""" + settings = server_module.build_transport_security_settings( + host="127.0.0.1", + disable_dns_rebinding_protection=True, + ) + + assert settings is not None + assert settings.enable_dns_rebinding_protection is False + assert settings.allowed_hosts == [] + assert settings.allowed_origins == [] + + +def test_run_server_http_uses_custom_host_and_security(monkeypatch): + """HTTP mode should forward host and transport security to FastMCP.""" + captured: dict[str, object] = {} + + class DummyServer: + def run(self, *, transport: str) -> None: + captured["transport"] = transport + + def fake_create_mcp_server(**kwargs): + captured.update(kwargs) + return DummyServer() + + monkeypatch.setattr(server_module, "create_mcp_server", fake_create_mcp_server) + monkeypatch.setattr(server_module, "configure_logging", lambda: "/tmp/maniple.log") + + server_module.run_server( + transport="streamable-http", + host="100.64.0.45", + port=8766, + allowed_hosts=["100.64.0.45:8766"], + ) + + assert captured["host"] == "100.64.0.45" + assert captured["port"] == 8766 + assert captured["enable_poller"] is True + assert captured["transport"] == "streamable-http" + settings = captured["transport_security"] + assert settings is not None + assert settings.allowed_hosts == ["100.64.0.45:8766"] + + +def test_main_parses_http_host_security_flags(monkeypatch): + """CLI parsing should pass host and allow-lists through to run_server.""" + captured: dict[str, object] = {} + monkeypatch.setattr( + sys, + "argv", + [ + "maniple", + "--http", + "--host", + "100.64.0.45", + "--port", + "8766", + "--allow-host", + "100.64.0.45:8766", + "--allow-origin", + "https://manager.example.com", + "--disable-dns-rebinding-protection", + ], + ) + monkeypatch.setattr(server_module, "run_server", lambda **kwargs: captured.update(kwargs)) + + server_module.main() + + assert captured == { + "transport": "streamable-http", + "host": "100.64.0.45", + "port": 8766, + "allowed_hosts": ["100.64.0.45:8766"], + "allowed_origins": ["https://manager.example.com"], + "disable_dns_rebinding_protection": True, + } diff --git a/tests/test_spawn_workers_defaults.py b/tests/test_spawn_workers_defaults.py index b7bce9b..c3c6690 100644 --- a/tests/test_spawn_workers_defaults.py +++ b/tests/test_spawn_workers_defaults.py @@ -6,7 +6,8 @@ from mcp.server.fastmcp import FastMCP import maniple_mcp.session_state as session_state -from maniple_mcp.config import ConfigError, DefaultsConfig, default_config +from maniple_mcp.config import ConfigError, DefaultsConfig, ProviderConfig, default_config +from maniple_mcp.launch_blockers import AgentLaunchBlocked, LaunchBlocker from maniple_mcp.registry import SessionRegistry from maniple_mcp.terminal_backends.base import TerminalSession from maniple_mcp.tools import spawn_workers as spawn_workers_module @@ -22,6 +23,7 @@ def __init__(self) -> None: self.prompts = [] self.sessions = [] self.create_calls = [] + self.raise_on_start = None async def create_session( self, @@ -57,6 +59,8 @@ async def start_agent_in_session( stop_hook_marker_id: str | None = None, **kwargs, ) -> None: + if self.raise_on_start is not None: + raise self.raise_on_start self.started.append({ "handle": handle, "cli": cli, @@ -64,6 +68,7 @@ async def start_agent_in_session( "dangerously_skip_permissions": dangerously_skip_permissions, "env": env, "stop_hook_marker_id": stop_hook_marker_id, + **kwargs, }) async def send_prompt_for_agent( @@ -81,6 +86,47 @@ async def send_prompt_for_agent( }) +async def _run_spawn_workers_tool(tmp_path, monkeypatch, backend, workers): + config = default_config() + config.defaults = DefaultsConfig(use_worktree=False, layout="new") + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda agent_type: f"cli:{agent_type}") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "await_codex_marker_in_jsonl", fake_await_codex_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + return await tool.run({"workers": workers}, context=ctx) + + @pytest.mark.asyncio async def test_spawn_workers_uses_config_defaults(tmp_path, monkeypatch): """spawn_workers should apply config defaults when fields are omitted.""" @@ -131,7 +177,15 @@ def fake_generate_worker_prompt(*args, **kwargs): async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -162,6 +216,85 @@ async def ensure_connection(app_context): assert prompt_calls == [False] +@pytest.mark.asyncio +async def test_spawn_workers_returns_launch_blocked_hint(tmp_path, monkeypatch): + """spawn_workers should surface launch blocker hints instead of iTerm advice.""" + repo_path = tmp_path / "repo" + repo_path.mkdir() + + backend = FakeBackend() + backend.raise_on_start = AgentLaunchBlocked( + LaunchBlocker( + slug="mcp_trust_confirmation", + summary="Claude launch is blocked on the project MCP trust prompt.", + hint="Open the worker pane and accept the .mcp.json server prompt once.", + ) + ) + + result = await _run_spawn_workers_tool( + tmp_path, + monkeypatch, + backend, + [{"project_path": str(repo_path), "name": "Worker1"}], + ) + + assert result["error"] == "Claude launch is blocked on the project MCP trust prompt." + assert ".mcp.json server prompt" in result["hint"] + + +@pytest.mark.asyncio +async def test_spawn_workers_passes_auto_accept_startup_prompts(tmp_path, monkeypatch): + """spawn_workers should pass terminal auto-accept config to the backend.""" + config = default_config() + config.defaults = DefaultsConfig(use_worktree=False, layout="new") + config.terminal.auto_accept_startup_prompts = True + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda agent_type: f"cli:{agent_type}") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "await_codex_marker_in_jsonl", fake_await_codex_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{"project_path": str(repo_path), "name": "Worker1"}], + }, context=ctx) + + assert backend.started[0]["auto_accept_startup_prompts"] is True + + @pytest.mark.asyncio async def test_spawn_workers_invalid_config_falls_back(tmp_path, monkeypatch): """spawn_workers should fall back to defaults if config is invalid.""" @@ -208,7 +341,15 @@ def fake_generate_worker_prompt(*args, **kwargs): async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -270,7 +411,15 @@ async def test_spawn_workers_merges_codex_ci_with_worktree_tracker_env(tmp_path, async def fake_await_marker_in_jsonl(*args, **kwargs): return None + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") backend = FakeBackend() @@ -356,3 +505,311 @@ async def ensure_connection(app_context): session = result["sessions"]["Worker1"] assert session["coordinator_badge"] == "Preferred badge" assert backend.create_calls[0]["coordinator_badge"] == "Preferred badge" + + +@pytest.mark.asyncio +async def test_spawn_workers_uses_named_provider_preset(tmp_path, monkeypatch): + """Named providers should supply per-worker command and env overrides.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local", "ANTHROPIC_BASE_URL": "http://provider"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + "provider": "local", + }], + }, context=ctx) + + assert backend.started[0]["env"] == { + "CLAUDE_PROVIDER": "local", + "ANTHROPIC_BASE_URL": "http://provider", + } + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" + + +@pytest.mark.asyncio +async def test_spawn_workers_uses_default_provider_when_not_specified(tmp_path, monkeypatch): + """defaults.provider should apply when a worker omits provider/command/env.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + provider="local", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + }], + }, context=ctx) + + assert backend.started[0]["env"] == {"CLAUDE_PROVIDER": "local"} + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-local" + + +@pytest.mark.asyncio +async def test_spawn_workers_codex_ignores_default_provider(tmp_path, monkeypatch): + """Codex workers should not inherit defaults.provider Claude wrappers.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="codex", + skip_permissions=False, + use_worktree=False, + layout="new", + provider="local", + ) + config.providers = { + "local": ProviderConfig( + command="/usr/local/bin/claude-local", + env={"CLAUDE_PROVIDER": "local"}, + ) + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:codex") + monkeypatch.setattr(spawn_workers_module, "get_worktree_tracker_dir", lambda *_: None) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_codex_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr( + session_state, + "await_codex_marker_in_jsonl", + fake_await_codex_marker_in_jsonl, + ) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + }], + }, context=ctx) + + assert backend.started[0]["env"] == {"CI": "1"} + assert backend.started[0]["command_override"] is None + + +@pytest.mark.asyncio +async def test_spawn_workers_uses_direct_command_and_env_override(tmp_path, monkeypatch): + """Workers should support direct command/env overrides without providers.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + monkeypatch.setattr(spawn_workers_module, "get_cli_backend", lambda *_: "cli:claude") + monkeypatch.setattr( + spawn_workers_module, + "get_worktree_tracker_dir", + lambda *_: ("MANIPLE_WORKTREE_TRACKER_DIR", "/tmp/tracker"), + ) + monkeypatch.setattr( + spawn_workers_module, + "generate_worker_prompt", + lambda *args, **kwargs: "PROMPT", + ) + monkeypatch.setattr( + spawn_workers_module, + "get_coordinator_guidance", + lambda *args, **kwargs: {"summary": "ok"}, + ) + + async def fake_await_marker_in_jsonl(*args, **kwargs): + return None + + monkeypatch.setattr(session_state, "await_marker_in_jsonl", fake_await_marker_in_jsonl) + monkeypatch.setattr(session_state, "generate_marker_message", lambda *args, **kwargs: "MARKER") + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "name": "Worker1", + "command": "/usr/local/bin/claude-ckimi", + "env": {"CLAUDE_PROVIDER": "ckimi"}, + }], + }, context=ctx) + + assert backend.started[0]["env"] == { + "MANIPLE_WORKTREE_TRACKER_DIR": "/tmp/tracker", + "CLAUDE_PROVIDER": "ckimi", + } + assert backend.started[0]["command_override"] == "/usr/local/bin/claude-ckimi" + + +@pytest.mark.asyncio +async def test_spawn_workers_rejects_provider_and_command_mix(tmp_path, monkeypatch): + """provider cannot be combined with direct command/env overrides.""" + config = default_config() + config.defaults = DefaultsConfig( + agent_type="claude", + skip_permissions=False, + use_worktree=False, + layout="new", + ) + config.providers = { + "local": ProviderConfig(command="/bin/claude-local") + } + monkeypatch.setattr(spawn_workers_module, "load_config", lambda: config) + + backend = FakeBackend() + registry = SessionRegistry() + app_ctx = SimpleNamespace(registry=registry, backend=backend) + + async def ensure_connection(app_context): + return app_context.backend + + mcp = FastMCP("test") + spawn_workers_module.register_tools(mcp, ensure_connection) + tool = mcp._tool_manager.get_tool("spawn_workers") + assert tool is not None + + repo_path = tmp_path / "repo" + repo_path.mkdir() + + ctx = SimpleNamespace(request_context=SimpleNamespace(lifespan_context=app_ctx)) + result = await tool.run({ + "workers": [{ + "project_path": str(repo_path), + "provider": "local", + "command": "/bin/other", + }], + }, context=ctx) + + assert "error" in result + assert "cannot combine 'provider' with 'command' or 'env'" in result["error"] diff --git a/tests/test_tmux_backend.py b/tests/test_tmux_backend.py index 21c5bff..83a8ace 100644 --- a/tests/test_tmux_backend.py +++ b/tests/test_tmux_backend.py @@ -4,6 +4,8 @@ import pytest +from maniple_mcp.cli_backends import claude_cli +from maniple_mcp.launch_blockers import AgentLaunchBlocked from maniple_mcp.terminal_backends.base import TerminalSession from maniple_mcp.terminal_backends.tmux import TmuxBackend, tmux_session_name_for_project @@ -257,3 +259,77 @@ def test_tmux_session_name_fallback_for_none(): """Test that None project path produces fallback session name.""" session = tmux_session_name_for_project(None) assert session == "maniple-project" + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_mcp_trust_prompt(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + + async def fake_read_screen_text(_session): + return ( + "New MCP server found in .mcp.json\n" + "1. Use this and all future MCP servers in this project\n" + ) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await backend._wait_for_agent_ready(session, claude_cli, timeout_seconds=1.0) + + assert "MCP trust prompt" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_raises_on_bypass_permissions_prompt(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + + async def fake_read_screen_text(_session): + return ( + "WARNING: Claude Code running in Bypass Permissions mode\n" + "2. Yes, I accept\n" + ) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + + with pytest.raises(AgentLaunchBlocked) as excinfo: + await backend._wait_for_agent_ready(session, claude_cli, timeout_seconds=1.0) + + assert "Bypass Permissions confirmation" in str(excinfo.value) + + +@pytest.mark.asyncio +async def test_wait_for_agent_ready_auto_accepts_bypass_permissions(monkeypatch): + backend = TmuxBackend() + session = TerminalSession("tmux", "%1", "%1") + responses = iter([ + "WARNING: Claude Code running in Bypass Permissions mode\n2. Yes, I accept\n", + "> ", + "> ", + "> ", + ]) + sent = [] + + async def fake_read_screen_text(_session): + return next(responses) + + async def fake_send_text(_session, text): + sent.append(("text", text)) + + async def fake_send_key(_session, key): + sent.append(("key", key)) + + monkeypatch.setattr(backend, "read_screen_text", fake_read_screen_text) + monkeypatch.setattr(backend, "send_text", fake_send_text) + monkeypatch.setattr(backend, "send_key", fake_send_key) + + ready = await backend._wait_for_agent_ready( + session, + claude_cli, + timeout_seconds=2.0, + auto_accept_startup_prompts=True, + ) + + assert ready is True + assert sent == [("text", "2"), ("key", "enter")] diff --git a/uv.lock b/uv.lock index 297ee1f..af94b72 100644 --- a/uv.lock +++ b/uv.lock @@ -301,7 +301,7 @@ wheels = [ [[package]] name = "maniple-mcp" -version = "0.12.0" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "iterm2" },