diff --git a/README.md b/README.md index 53c480d..5dfd1b9 100644 --- a/README.md +++ b/README.md @@ -43,10 +43,11 @@ skore skills remove # remove installed skills On the first run, `skore agent` logs in when needed, lets you pick a workspace and harness, creates a workspace API key, writes the harness configuration and -launches the agent. Supported harnesses: **Claude**, **OpenCode**, **Pi**, -**GitHub Copilot** and **Codex** (must be on `PATH`). Later runs reuse `.skore` -in the project directory (gitignored). Use `SKORE_HUB_URI` (or `--hub-url`) to -point at a non-default hub. +launches the agent. Supported harnesses: **Bob Shell**, **Bob IDE**, **Claude**, +**Cursor**, **OpenCode**, **Pi**, **GitHub Copilot** and **Codex** (all must be on +`PATH`; on macOS, Bob IDE is found via its application bundle). Later runs reuse +`.skore` in the project directory (gitignored). Use `SKORE_HUB_URI` (or +`--hub-url`) to point at a non-default hub. ```bash skore agent diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index 05c8901..40d9d27 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -14,8 +14,12 @@ DEFAULT_AGENT = "agents" DEFAULT_MODEL_ID = "skore-agent" +# Name under which Skore registers in each harness config (model provider or +# MCP server). +SKORE_PROVIDER_KEY = "skore" + +# OpenCode OPENCODE_SCHEMA = "https://opencode.ai/config.json" -OPENCODE_PROVIDER_KEY = "skore" OPENCODE_SESSION_PLUGIN = ".opencode/plugins/skore-session.js" OPENCODE_SESSION_PLUGIN_SOURCE = """\ export const SkoreSessionPlugin = async () => ({ @@ -26,6 +30,26 @@ }, }); """ + +# Cursor +# Cursor concatenates the per-user and per-workspace allowlists, so this entry +# applies on top of whatever the user already allows. A convenience, not a +# security boundary. +CURSOR_ALLOWLIST_ENTRY = "skore:*" +CURSOR_AUTORUN_INSTRUCTIONS = ( + "curl fetching a Skore materialize URL under /v1/materialize/ to write a " + "template or script into the workspace", + "calls to the skore MCP server's skore_agent tool", +) + +# Bob +# On macOS, Bob IDE ships as an application bundle with no command on PATH. +# On Windows it installs a ``bob-ide`` command; on Linux the .deb/.rpm package +# does the same. The macOS bundle path is a module constant so tests can point +# it somewhere that does not exist. +BOB_IDE_APP_PATH = Path("/Applications/IBM Bob.app") + +# GitHub Copilot COPILOT_PROVIDER_NAME = "Skore Agent" COPILOT_PROJECT_CONFIG = ".vscode/chatLanguageModels.json" COPILOT_BINARIES = ("code", "code-insiders") @@ -49,6 +73,10 @@ def base_url(self) -> str: """Return the OpenAI-compatible API base URL.""" return f"{self.hub_url.rstrip('/')}/v1" + @property + def mcp_url(self) -> str: + return f"{self.hub_url.rstrip('/')}/mcp" + Configure = Callable[[HarnessContext], dict[str, Any]] Launch = Callable[[Path, str], None] @@ -85,9 +113,9 @@ def _configure_opencode(ctx: HarnessContext) -> dict[str, Any]: config_path = ctx.workspace / "opencode.json" config: dict[str, Any] = { "$schema": OPENCODE_SCHEMA, - "model": f"{OPENCODE_PROVIDER_KEY}/{ctx.model_id}", + "model": f"{SKORE_PROVIDER_KEY}/{ctx.model_id}", "provider": { - OPENCODE_PROVIDER_KEY: { + SKORE_PROVIDER_KEY: { "npm": "@ai-sdk/openai-compatible", "name": "Skore Hub", "options": { @@ -99,6 +127,7 @@ def _configure_opencode(ctx: HarnessContext) -> dict[str, Any]: }, } config_path.write_text(json.dumps(config, indent=2) + "\n") + ensure_gitignore_entry(ctx.workspace, "opencode.json") console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") plugin_path = ctx.workspace / OPENCODE_SESSION_PLUGIN @@ -112,6 +141,7 @@ def _configure_opencode(ctx: HarnessContext) -> dict[str, Any]: def _configure_claude(ctx: HarnessContext) -> dict[str, Any]: """Write ``.claude/settings.local.json`` for Claude.""" from skore_cli._style import console + from skore_cli.agent._skore_file import ensure_gitignore_entry config_dir = ctx.workspace / ".claude" config_dir.mkdir(parents=True, exist_ok=True) @@ -124,6 +154,7 @@ def _configure_claude(ctx: HarnessContext) -> dict[str, Any]: } } config_path.write_text(json.dumps(payload, indent=2) + "\n") + ensure_gitignore_entry(ctx.workspace, ".claude/settings.local.json") console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") return {"config_path": str(config_path)} @@ -131,13 +162,14 @@ def _configure_claude(ctx: HarnessContext) -> dict[str, Any]: def _configure_pi(ctx: HarnessContext) -> dict[str, Any]: """Write ``.pi/agent/models.json`` for Pi.""" from skore_cli._style import console + from skore_cli.agent._skore_file import ensure_gitignore_entry config_dir = ctx.workspace / ".pi" / "agent" config_dir.mkdir(parents=True, exist_ok=True) config_path = config_dir / "models.json" payload = { "providers": { - "skore": { + SKORE_PROVIDER_KEY: { "baseUrl": ctx.base_url, "api": "openai-completions", "apiKey": ctx.api_key, @@ -162,10 +194,149 @@ def _configure_pi(ctx: HarnessContext) -> dict[str, Any]: } } config_path.write_text(json.dumps(payload, indent=2) + "\n") + ensure_gitignore_entry(ctx.workspace, ".pi/agent/models.json") + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + return {"config_path": str(config_path)} + + +def _load_json_object(path: Path) -> dict[str, Any]: + """Return the JSON object stored at ``path``, or an empty one when absent. + + Refuses anything else: the caller writes the result back, so treating an + unreadable file as empty would drop the servers and rules it holds. Cursor + accepts comments in these files and Python's parser does not. + """ + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text() or "{}") + except json.JSONDecodeError as error: + raise RuntimeError( + f"{path} is not valid JSON (comments are not supported here); " + "fix or move it, then run `skore agent` again." + ) from error + if not isinstance(data, dict): + raise RuntimeError( + f"{path} does not hold a JSON object; fix or move it, then run " + "`skore agent` again." + ) + return data + + +def _configure_cursor(ctx: HarnessContext) -> dict[str, Any]: + """Point Cursor's MCP client at the hub and pre-approve the Skore tools. + + These two files belong to Cursor, not to Skore, so they are read-modify- + written: another MCP server or permission rule the user set up survives. + """ + from skore_cli._style import console + from skore_cli.agent._skore_file import ensure_gitignore_entry + + config_dir = ctx.workspace / ".cursor" + config_dir.mkdir(parents=True, exist_ok=True) + + config_path = config_dir / "mcp.json" + permissions_path = config_dir / "permissions.json" + # Both files are read up front so an unreadable second one cannot leave the + # workspace half-configured. + config = _load_json_object(config_path) + permissions = _load_json_object(permissions_path) + + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + servers[SKORE_PROVIDER_KEY] = { + "url": ctx.mcp_url, + # Written out rather than interpolated from the environment: Cursor + # resolves ${env:...} against its own process, which is whatever + # launched the app, not `skore agent`. + "headers": {"Authorization": f"Bearer {ctx.api_key}"}, + } + config["mcpServers"] = servers + config_path.write_text(json.dumps(config, indent=2) + "\n") + ensure_gitignore_entry(ctx.workspace, ".cursor/mcp.json") + + allowlist = permissions.get("mcpAllowlist") + if not isinstance(allowlist, list): + allowlist = [] + if CURSOR_ALLOWLIST_ENTRY not in allowlist: + allowlist.append(CURSOR_ALLOWLIST_ENTRY) + permissions["mcpAllowlist"] = allowlist + auto_run = permissions.get("autoRun") + if not isinstance(auto_run, dict): + auto_run = {} + instructions = auto_run.get("allow_instructions") + if not isinstance(instructions, list): + instructions = [] + for instruction in CURSOR_AUTORUN_INSTRUCTIONS: + if instruction not in instructions: + instructions.append(instruction) + auto_run["allow_instructions"] = instructions + permissions["autoRun"] = auto_run + permissions_path.write_text(json.dumps(permissions, indent=2) + "\n") + + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") + console.print(f"[skore.ok]+[/] wrote [skore.path]{permissions_path}[/]") + console.print( + f"[skore.muted] turn [skore.skill]{SKORE_PROVIDER_KEY}[/] on under " + f"Settings -> Tools & MCP; Cursor asks once per change to this file[/]" + ) + return {"config_path": str(config_path)} + + +def _configure_bob(ctx: HarnessContext, transport: dict[str, str]) -> dict[str, Any]: + """Register the hub's MCP server in ``.bob/mcp.json``. + + Both Bobs read this file but declare a streamable-HTTP server differently, + so the transport keys come from the caller. Unlike Cursor, Bob takes the + server's enabled state and the tool approval from the file, so there is no + manual step left to the user. + """ + from skore_cli._style import console + from skore_cli.agent._skore_file import ensure_gitignore_entry + + config_dir = ctx.workspace / ".bob" + config_dir.mkdir(parents=True, exist_ok=True) + config_path = config_dir / "mcp.json" + + config = _load_json_object(config_path) + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + servers[SKORE_PROVIDER_KEY] = { + **transport, + "headers": {"Authorization": f"Bearer {ctx.api_key}"}, + "alwaysAllow": ["skore_agent"], + "disabled": False, + } + config["mcpServers"] = servers + config_path.write_text(json.dumps(config, indent=2) + "\n") + ensure_gitignore_entry(ctx.workspace, ".bob/mcp.json") + console.print(f"[skore.ok]+[/] wrote [skore.path]{config_path}[/]") return {"config_path": str(config_path)} +def _configure_bob_shell(ctx: HarnessContext) -> dict[str, Any]: + """Configure Bob Shell, which reads a streamable-HTTP server from ``httpURL``.""" + # A plain `url` would be read as the legacy SSE endpoint, which the hub does + # not serve. + return _configure_bob(ctx, {"httpURL": ctx.mcp_url}) + + +def _configure_bob_ide(ctx: HarnessContext) -> dict[str, Any]: + """Configure Bob IDE, which reads a streamable-HTTP server from ``type``/``url``.""" + from skore_cli._style import console + + written = _configure_bob(ctx, {"type": "streamable-http", "url": ctx.mcp_url}) + console.print( + "[skore.muted] raise the network timeout for " + f"[skore.skill]{SKORE_PROVIDER_KEY}[/] to 5 minutes under Settings -> MCP; a " + "turn can outlast the 1 minute default[/]" + ) + return written + + def _copilot_provider(ctx: HarnessContext) -> dict[str, Any]: """Build the Custom Endpoint provider entry for VS Code Copilot Chat.""" return { @@ -317,7 +488,7 @@ def _configure_codex(ctx: HarnessContext) -> dict[str, Any]: def _launch_opencode(_workspace: Path, model_id: str) -> None: _exec_harness( "opencode", - ["opencode", "-m", f"{OPENCODE_PROVIDER_KEY}/{model_id}"], + ["opencode", "-m", f"{SKORE_PROVIDER_KEY}/{model_id}"], ) @@ -335,11 +506,26 @@ def _launch_pi(workspace: Path, model_id: str) -> None: env["PI_CODING_AGENT_DIR"] = str(workspace / ".pi" / "agent") _exec_harness( "pi", - ["pi", "--provider", OPENCODE_PROVIDER_KEY, "--model", model_id], + ["pi", "--provider", SKORE_PROVIDER_KEY, "--model", model_id], env=env, ) +def _launch_cursor(workspace: Path, _model_id: str) -> None: + _exec_harness("cursor", ["cursor", str(workspace)]) + + +def _launch_bob_shell(_workspace: Path, _model_id: str) -> None: + _exec_harness("bob", ["bob"]) + + +def _launch_bob_ide(workspace: Path, _model_id: str) -> None: + if sys.platform == "darwin": + _exec_harness("open", ["open", "-a", str(BOB_IDE_APP_PATH), str(workspace)]) + else: + _exec_harness("bob-ide", ["bob-ide", str(workspace)]) + + def _launch_copilot(workspace: Path, _model_id: str) -> None: from skore_cli._style import console @@ -447,6 +633,10 @@ def _exec_harness( detection_priority=1, user_skills_dir=".cursor/skills", project_skills_dir=".cursor/skills", + harness_name="cursor", + harness_label="Cursor", + configure=_configure_cursor, + launch=_launch_cursor, ), "codex": Agent( name="codex", @@ -496,6 +686,23 @@ def _exec_harness( configure=_configure_copilot, launch=_launch_copilot, ), + "bob": Agent( + name="bob", + label="Bob Shell", + harness_name="bob", + harness_label="Bob Shell", + configure=_configure_bob_shell, + launch=_launch_bob_shell, + ), + "bob-ide": Agent( + name="bob-ide", + label="Bob IDE", + harness_name="bob-ide", + harness_label="Bob IDE", + harness_binaries=("bob-ide",), + configure=_configure_bob_ide, + launch=_launch_bob_ide, + ), } SKILL_AGENT_NAMES = [ @@ -584,6 +791,8 @@ def normalize_harness_name(name: str | None) -> str | None: def is_harness_installed(agent: Agent) -> bool: """Return whether ``agent``'s harness executable is on ``PATH``.""" + if agent.harness_name == "bob-ide" and sys.platform == "darwin": + return BOB_IDE_APP_PATH.is_dir() binaries = agent.harness_binaries or ( (agent.harness_name,) if agent.harness_name else () ) diff --git a/src/skore_cli/agent/__init__.py b/src/skore_cli/agent/__init__.py index d5aa755..1817d34 100644 --- a/src/skore_cli/agent/__init__.py +++ b/src/skore_cli/agent/__init__.py @@ -1,8 +1,8 @@ """The ``skore agent`` command to connect a project to the Skore Hub agent. The command authenticates with the hub, stores workspace credentials in a local -``.skore`` file, writes the harness configuration, and launches Claude, -OpenCode, Pi or GitHub Copilot when installed. +``.skore`` file, writes the harness configuration, and launches Bob, Claude, +Cursor, OpenCode, Pi or GitHub Copilot when installed. Heavy ``skore`` (and ``textual``) imports are deferred into the command callback so building the CLI (and ``--help``) never imports them. diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index 76fcb79..de04243 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -190,8 +190,9 @@ def agent( harness config, and launches the agent. Later runs reuse ``.skore`` in the project directory. - Supported harnesses: Claude, OpenCode, Pi, GitHub Copilot and Codex - (must be on ``PATH``). + Supported harnesses: Bob Shell, Bob IDE, Claude, Cursor, OpenCode, Pi, + GitHub Copilot and Codex (all must be on ``PATH``; on macOS, Bob IDE is found + via its application bundle instead). """ workspace = workspace.resolve() if not workspace.is_dir(): diff --git a/src/skore_cli/agent/app/_picker.py b/src/skore_cli/agent/app/_picker.py index 4ee7567..6818319 100644 --- a/src/skore_cli/agent/app/_picker.py +++ b/src/skore_cli/agent/app/_picker.py @@ -21,7 +21,10 @@ Pick the local coding agent to configure and launch. Supported harnesses: + • Bob Shell — writes .bob/mcp.json + • Bob IDE — writes .bob/mcp.json • Claude — writes .claude/settings.local.json + • Cursor — writes .cursor/mcp.json • OpenCode — writes opencode.json • Pi — writes .pi/agent/models.json • Copilot — writes .vscode/chatLanguageModels.json diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index fa48fb4..8bca9e5 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -11,7 +11,7 @@ from click.testing import CliRunner from skore_cli import _agents -from skore_cli._agents import AGENTS, DEFAULT_MODEL_ID, HarnessContext +from skore_cli._agents import AGENTS, DEFAULT_MODEL_ID, HARNESS_NAMES, HarnessContext from skore_cli.agent import _client, _commands from skore_cli.agent import app as _agent_app from skore_cli.agent._commands import agent @@ -158,6 +158,16 @@ def test_agent_nonexistent_workspace_errors(tmp_path): assert "workspace does not exist" in result.output +def test_agent_invalid_harness_lists_all_supported_harnesses(tmp_path): + result = CliRunner().invoke( + agent, ["--workspace", str(tmp_path), "--harness", "does-not-exist"] + ) + assert result.exit_code != 0 + output = _plain_output(result.output) + for name in HARNESS_NAMES: + assert name in output + + def test_agent_uses_existing_skore_config(tmp_path, monkeypatch): _write_skore(tmp_path) _mock_harness_on_path(monkeypatch, "opencode") diff --git a/tests/test_agents.py b/tests/test_agents.py index 49ee95f..256477b 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -37,7 +37,16 @@ def test_agent_names_match_registry(): def test_harness_names_come_from_registry(): - assert HARNESS_NAMES == ["claude", "codex", "opencode", "pi", "copilot"] + assert HARNESS_NAMES == [ + "claude", + "cursor", + "codex", + "opencode", + "pi", + "copilot", + "bob", + "bob-ide", + ] def test_harness_rows_are_complete(): @@ -142,7 +151,8 @@ def test_harness_display_name_uses_label(): assert AGENTS["agents"].harness_display_name == "Agents" -def test_harness_registry_helpers(monkeypatch): +def test_harness_registry_helpers(monkeypatch, tmp_path): + monkeypatch.setattr(_agents, "BOB_IDE_APP_PATH", tmp_path / "absent.app") monkeypatch.setattr( _agents.shutil, "which", diff --git a/tests/test_cli.py b/tests/test_cli.py index 5cde61d..3fdf8ab 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -106,7 +106,8 @@ def test_cli_help_cursor_detected(monkeypatch): assert result.exit_code == 0 assert "Detected: Cursor" in result.output assert "Skills target: .cursor/skills" in result.output - assert "Harness:" not in result.output + assert "Harness: Cursor" in result.output + assert "Configure Cursor with the Skore Hub provider" in result.output def test_cli_help_opencode_detected(monkeypatch): diff --git a/tests/test_detect.py b/tests/test_detect.py index d8f75bb..d607b8e 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -116,7 +116,7 @@ def test_priority_pi_before_opencode(monkeypatch): "env_var,value,expected", [ ("CLAUDECODE", "1", "claude"), - ("CURSOR_AGENT", "1", None), + ("CURSOR_AGENT", "1", "cursor"), ("GEMINI_CLI", "1", None), ("CODEX_SANDBOX", "seatbelt", "codex"), ("PI_CODING_AGENT", "true", "pi"), diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index a78dd73..8ce9203 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -26,6 +26,21 @@ def _ctx(workspace, **kwargs): ) +@pytest.fixture(autouse=True) +def no_bob_ide_app(tmp_path, monkeypatch): + """Keep detection off the real machine: Bob IDE is found by its bundle on + macOS and by the ``bob-ide`` binary on other platforms.""" + monkeypatch.setattr(_agents, "BOB_IDE_APP_PATH", tmp_path / "absent.app") + _real_which = _agents.shutil.which + + def _which(name): + if name == "bob-ide": + return None + return _real_which(name) + + monkeypatch.setattr(_agents.shutil, "which", _which) + + def test_opencode_installed_by_binary(monkeypatch): monkeypatch.setattr( _agents.shutil, @@ -54,6 +69,49 @@ def test_pi_installed_by_binary(monkeypatch): assert [agent.harness_name for agent in installed_harnesses()] == ["pi"] +def test_cursor_installed_by_binary(monkeypatch): + monkeypatch.setattr( + _agents.shutil, + "which", + lambda name: "/usr/local/bin/cursor" if name == "cursor" else None, + ) + assert is_harness_installed(AGENTS["cursor"]) is True + assert [agent.harness_name for agent in installed_harnesses()] == ["cursor"] + + +def test_bob_shell_installed_by_binary(monkeypatch): + monkeypatch.setattr( + _agents.shutil, + "which", + lambda name: "/usr/local/bin/bob" if name == "bob" else None, + ) + assert is_harness_installed(AGENTS["bob"]) is True + assert [agent.harness_name for agent in installed_harnesses()] == ["bob"] + + +def test_bob_ide_installed_by_app_bundle(tmp_path, monkeypatch): + """On macOS the IDE installs no command, so detection uses the bundle.""" + monkeypatch.setattr(_agents.sys, "platform", "darwin") + bundle = tmp_path / "IBM Bob.app" + bundle.mkdir() + monkeypatch.setattr(_agents, "BOB_IDE_APP_PATH", bundle) + monkeypatch.setattr(_agents.shutil, "which", lambda name: None) + assert is_harness_installed(AGENTS["bob-ide"]) is True + assert [agent.harness_name for agent in installed_harnesses()] == ["bob-ide"] + + +def test_bob_ide_installed_by_binary_on_non_darwin(tmp_path, monkeypatch): + """On non-macOS the IDE installs a ``bob-ide`` command on PATH.""" + monkeypatch.setattr(_agents.sys, "platform", "linux") + monkeypatch.setattr( + _agents.shutil, + "which", + lambda name: "/usr/bin/bob-ide" if name == "bob-ide" else None, + ) + assert is_harness_installed(AGENTS["bob-ide"]) is True + assert [agent.harness_name for agent in installed_harnesses()] == ["bob-ide"] + + def test_installed_harnesses_excludes_missing(monkeypatch): monkeypatch.setattr(_agents.shutil, "which", lambda name: None) assert installed_harnesses() == [] @@ -90,6 +148,174 @@ def test_pi_config_matches_hub_ui(tmp_path): assert compat["sessionAffinityFormat"] == "openrouter" +def test_cursor_config_points_at_the_mcp_endpoint(tmp_path): + AGENTS["cursor"].configure(_ctx(tmp_path)) + + config = json.loads((tmp_path / ".cursor" / "mcp.json").read_text()) + assert config["mcpServers"]["skore"] == { + "url": "http://hub.test/mcp", + "headers": {"Authorization": "Bearer secret-key"}, + } + + permissions = json.loads((tmp_path / ".cursor" / "permissions.json").read_text()) + assert permissions["mcpAllowlist"] == ["skore:*"] + assert permissions["autoRun"]["allow_instructions"] == [ + "curl fetching a Skore materialize URL under /v1/materialize/ to write " + "a template or script into the workspace", + "calls to the skore MCP server's skore_agent tool", + ] + + +def test_bob_shell_config_declares_the_streamable_http_url(tmp_path): + """Bob Shell reads ``httpURL``; a plain ``url`` would mean legacy SSE.""" + AGENTS["bob"].configure(_ctx(tmp_path)) + + config = json.loads((tmp_path / ".bob" / "mcp.json").read_text()) + assert config["mcpServers"]["skore"] == { + "httpURL": "http://hub.test/mcp", + "headers": {"Authorization": "Bearer secret-key"}, + "alwaysAllow": ["skore_agent"], + "disabled": False, + } + + +def test_bob_ide_config_declares_the_transport_type(tmp_path): + """Bob IDE reads the same file but wants ``type`` alongside ``url``.""" + AGENTS["bob-ide"].configure(_ctx(tmp_path)) + + config = json.loads((tmp_path / ".bob" / "mcp.json").read_text()) + assert config["mcpServers"]["skore"] == { + "type": "streamable-http", + "url": "http://hub.test/mcp", + "headers": {"Authorization": "Bearer secret-key"}, + "alwaysAllow": ["skore_agent"], + "disabled": False, + } + + +def test_bob_config_preserves_what_the_user_already_had(tmp_path): + config_dir = tmp_path / ".bob" + config_dir.mkdir() + (config_dir / "mcp.json").write_text( + json.dumps({"mcpServers": {"other": {"command": "node"}}}) + ) + + AGENTS["bob"].configure(_ctx(tmp_path)) + + config = json.loads((config_dir / "mcp.json").read_text()) + assert set(config["mcpServers"]) == {"other", "skore"} + assert config["mcpServers"]["other"] == {"command": "node"} + + +def test_bob_config_is_idempotent(tmp_path): + AGENTS["bob"].configure(_ctx(tmp_path)) + AGENTS["bob"].configure(_ctx(tmp_path)) + + config = json.loads((tmp_path / ".bob" / "mcp.json").read_text()) + assert config["mcpServers"]["skore"]["alwaysAllow"] == ["skore_agent"] + assert (tmp_path / ".gitignore").read_text().count(".bob/mcp.json") == 1 + + +def test_bob_config_refuses_to_overwrite_what_it_cannot_read(tmp_path): + config_dir = tmp_path / ".bob" + config_dir.mkdir() + (config_dir / "mcp.json").write_text("{not json") + + with pytest.raises(RuntimeError, match="mcp.json"): + AGENTS["bob"].configure(_ctx(tmp_path)) + + assert (config_dir / "mcp.json").read_text() == "{not json" + + +@pytest.mark.parametrize( + "name, entry", + [ + ("opencode", "opencode.json"), + ("claude-code", ".claude/settings.local.json"), + ("pi", ".pi/agent/models.json"), + ("cursor", ".cursor/mcp.json"), + ("bob", ".bob/mcp.json"), + ("bob-ide", ".bob/mcp.json"), + ], +) +def test_config_embedding_the_api_key_is_gitignored(tmp_path, name, entry): + AGENTS[name].configure(_ctx(tmp_path)) + + assert "secret-key" in (tmp_path / entry).read_text() + assert entry in (tmp_path / ".gitignore").read_text().splitlines() + + +def test_cursor_permissions_are_not_gitignored(tmp_path): + """They hold no secret, and a team may well want them committed.""" + AGENTS["cursor"].configure(_ctx(tmp_path)) + gitignore = (tmp_path / ".gitignore").read_text() + assert "permissions.json" not in gitignore + + +def test_cursor_config_preserves_what_the_user_already_had(tmp_path): + config_dir = tmp_path / ".cursor" + config_dir.mkdir() + (config_dir / "mcp.json").write_text( + json.dumps({"mcpServers": {"other": {"url": "http://elsewhere"}}}) + ) + (config_dir / "permissions.json").write_text( + json.dumps( + { + "mcpAllowlist": ["other:*"], + "allow": ["Read(**)"], + "autoRun": {"allow_instructions": ["anything the user allowed"]}, + } + ) + ) + + AGENTS["cursor"].configure(_ctx(tmp_path)) + + config = json.loads((config_dir / "mcp.json").read_text()) + assert set(config["mcpServers"]) == {"other", "skore"} + assert config["mcpServers"]["other"] == {"url": "http://elsewhere"} + + permissions = json.loads((config_dir / "permissions.json").read_text()) + assert permissions["mcpAllowlist"] == ["other:*", "skore:*"] + assert permissions["allow"] == ["Read(**)"] + assert permissions["autoRun"]["allow_instructions"][0] == ( + "anything the user allowed" + ) + assert len(permissions["autoRun"]["allow_instructions"]) == 3 + + +def test_cursor_config_is_idempotent(tmp_path): + AGENTS["cursor"].configure(_ctx(tmp_path)) + AGENTS["cursor"].configure(_ctx(tmp_path)) + + permissions = json.loads((tmp_path / ".cursor" / "permissions.json").read_text()) + assert permissions["mcpAllowlist"] == ["skore:*"] + assert len(permissions["autoRun"]["allow_instructions"]) == 2 + + +@pytest.mark.parametrize("content", ["{not json", "// a comment\n{}", "[]"]) +def test_cursor_config_refuses_to_overwrite_what_it_cannot_read(tmp_path, content): + """Cursor accepts comments in these files; wiping one would lose real config.""" + config_dir = tmp_path / ".cursor" + config_dir.mkdir() + (config_dir / "mcp.json").write_text(content) + + with pytest.raises(RuntimeError, match="mcp.json"): + AGENTS["cursor"].configure(_ctx(tmp_path)) + + assert (config_dir / "mcp.json").read_text() == content + + +def test_cursor_config_writes_nothing_when_permissions_cannot_be_read(tmp_path): + config_dir = tmp_path / ".cursor" + config_dir.mkdir() + (config_dir / "permissions.json").write_text("{not json") + + with pytest.raises(RuntimeError, match="permissions.json"): + AGENTS["cursor"].configure(_ctx(tmp_path)) + + assert not (config_dir / "mcp.json").exists() + + def test_launch_opencode_passes_model_flag(tmp_path, monkeypatch): captured: dict[str, list[str]] = {} @@ -124,6 +350,74 @@ def fake_exec(name, argv, *, env=None): assert "PI_CODING_AGENT_DIR" in captured["env"] +def test_launch_cursor_opens_the_workspace(tmp_path, monkeypatch): + captured: dict[str, object] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + captured["env"] = env + + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/local/bin/cursor" if cmd == "cursor" else None, + ) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + _agents.launch_harness(AGENTS["cursor"], tmp_path) + assert captured["argv"] == ["cursor", str(tmp_path)] + # The key lives in mcp.json, so no environment has to reach the app and a + # window opened any other way works just as well. + assert captured["env"] is None + + +def test_launch_bob_shell_takes_the_workspace_from_the_cwd(tmp_path, monkeypatch): + captured: dict[str, object] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + + monkeypatch.setattr( + _agents.shutil, + "which", + lambda cmd: "/usr/local/bin/bob" if cmd == "bob" else None, + ) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + _agents.launch_harness(AGENTS["bob"], tmp_path) + assert captured["argv"] == ["bob"] + + +def test_launch_bob_ide_opens_the_app_bundle(tmp_path, monkeypatch): + monkeypatch.setattr(_agents.sys, "platform", "darwin") + captured: dict[str, object] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + + bundle = tmp_path / "IBM Bob.app" + bundle.mkdir() + monkeypatch.setattr(_agents, "BOB_IDE_APP_PATH", bundle) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + _agents.launch_harness(AGENTS["bob-ide"], tmp_path) + assert captured["argv"] == ["open", "-a", str(bundle), str(tmp_path)] + + +def test_launch_bob_ide_uses_binary_on_non_darwin(tmp_path, monkeypatch): + captured: dict[str, object] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + + monkeypatch.setattr(_agents.sys, "platform", "linux") + monkeypatch.setattr( + _agents.shutil, + "which", + lambda name: "/usr/bin/bob-ide" if name == "bob-ide" else None, + ) + monkeypatch.setattr(_agents, "_exec_harness", fake_exec) + _agents.launch_harness(AGENTS["bob-ide"], tmp_path) + assert captured["argv"] == ["bob-ide", str(tmp_path)] + + def test_launch_errors_when_binary_missing(tmp_path, monkeypatch): monkeypatch.setattr( _agents.shutil,