From 4f55d6e648caf38ef2fe49a2f841b027605ca65d Mon Sep 17 00:00:00 2001 From: Guillaume Lemaitre Date: Fri, 7 Aug 2026 00:36:22 +0200 Subject: [PATCH 1/5] feat: Configure Skore Agent as an MCP client/server in Cursor and IBM Bob --- src/skore_cli/agent/_commands.py | 3 +- src/skore_cli/agent/_harnesses.py | 211 +++++++++++++++++++++++- tests/test_agent_commands.py | 2 +- tests/test_harnesses.py | 257 ++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 470 insertions(+), 5 deletions(-) diff --git a/src/skore_cli/agent/_commands.py b/src/skore_cli/agent/_commands.py index b1303db..a88ab1b 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -190,7 +190,8 @@ def agent( harness config, and launches the agent. Later runs reuse ``.skore`` in the project directory. - Supported harnesses: Claude, OpenCode and Pi (must be on ``PATH``). + Supported harnesses: Bob Shell, Bob IDE, Claude, Cursor, OpenCode and Pi + (all but Bob IDE must be on ``PATH``). """ workspace = workspace.resolve() if not workspace.is_dir(): diff --git a/src/skore_cli/agent/_harnesses.py b/src/skore_cli/agent/_harnesses.py index 69a940c..5c481da 100644 --- a/src/skore_cli/agent/_harnesses.py +++ b/src/skore_cli/agent/_harnesses.py @@ -1,7 +1,11 @@ """Harness registry, detection and per-harness configuration writers. -Supported harnesses: Claude, OpenCode and Pi. Each writer mirrors the -copy-pastable setup snippets from the Skore Hub agent-setup UI. +Supported harnesses: Bob Shell, Bob IDE, Claude, Cursor, OpenCode and Pi. Each +writer mirrors the copy-pastable setup snippets from the Skore Hub agent-setup UI. + +Cursor and the two Bobs are the odd ones out: they talk to the hub's MCP +front-end rather than treating it as an OpenAI-compatible model provider, so they +are configured with a server URL instead of a base URL and a model id. """ from __future__ import annotations @@ -16,9 +20,25 @@ from skore_cli._style import console +from ._skore_file import ensure_gitignore_entry + DEFAULT_MODEL_ID = "skore-agent" OPENCODE_SCHEMA = "https://opencode.ai/config.json" OPENCODE_PROVIDER_KEY = "skore" +CURSOR_SERVER_KEY = "skore" +# 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_SERVER_KEY = "skore" +# Bob IDE ships as an application, with no documented command it installs on +# PATH; a module constant so tests can point it somewhere that does not exist. +BOB_IDE_APP_PATH = Path("/Applications/IBM Bob.app") @dataclass(frozen=True) @@ -34,6 +54,10 @@ class HarnessContext: def base_url(self) -> str: return f"{self.hub_url.rstrip('/')}/v1" + @property + def mcp_url(self) -> str: + return f"{self.hub_url.rstrip('/')}/mcp" + def _configure_opencode(ctx: HarnessContext) -> dict[str, Any]: """Write ``opencode.json`` with the Skore Hub provider.""" @@ -54,6 +78,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}[/]") return {"config_path": str(config_path)} @@ -71,6 +96,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)} @@ -105,10 +131,141 @@ 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. + """ + 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[CURSOR_SERVER_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]{CURSOR_SERVER_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. + """ + 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[BOB_SERVER_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``.""" + written = _configure_bob(ctx, {"type": "streamable-http", "url": ctx.mcp_url}) + console.print( + "[skore.muted] raise the network timeout for " + f"[skore.skill]{BOB_SERVER_KEY}[/] to 5 minutes under Settings -> MCP; a " + "turn can outlast the 1 minute default[/]" + ) + return written + + def _detect_opencode(_workspace: Path) -> bool: return shutil.which("opencode") is not None @@ -121,6 +278,22 @@ def _detect_pi(_workspace: Path) -> bool: return shutil.which("pi") is not None +def _detect_cursor(_workspace: Path) -> bool: + # The macOS app bundle is not enough: `_launch_cursor` needs the CLI, which + # only exists once the user runs "Shell Command: Install 'cursor' command". + return shutil.which("cursor") is not None + + +def _detect_bob_shell(_workspace: Path) -> bool: + return shutil.which("bob") is not None + + +def _detect_bob_ide(_workspace: Path) -> bool: + # `_launch_bob_ide` opens the bundle rather than a command, so the bundle is + # all that has to exist. + return BOB_IDE_APP_PATH.is_dir() + + def launch_harness( name: str, workspace: Path, *, model_id: str = DEFAULT_MODEL_ID ) -> None: @@ -158,6 +331,19 @@ def _launch_pi(workspace: Path, *, model_id: str) -> None: ) +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: + # Bob Shell takes the workspace from the working directory, like opencode. + _exec_harness("bob", ["bob"]) + + +def _launch_bob_ide(workspace: Path, *, model_id: str) -> None: + _exec_harness("open", ["open", "-a", str(BOB_IDE_APP_PATH), str(workspace)]) + + def _exec_harness( name: str, argv: list[str], *, env: dict[str, str] | None = None ) -> None: @@ -168,7 +354,10 @@ def _exec_harness( _LAUNCHERS = { + "bob": _launch_bob_shell, + "bob-ide": _launch_bob_ide, "claude": _launch_claude, + "cursor": _launch_cursor, "opencode": _launch_opencode, "pi": _launch_pi, } @@ -186,12 +375,30 @@ class Harness: HARNESSES: dict[str, Harness] = { + "bob": Harness( + "bob", + "Bob Shell", + _detect_bob_shell, + _configure_bob_shell, + ), + "bob-ide": Harness( + "bob-ide", + "Bob IDE", + _detect_bob_ide, + _configure_bob_ide, + ), "claude": Harness( "claude", "Claude", _detect_claude, _configure_claude, ), + "cursor": Harness( + "cursor", + "Cursor", + _detect_cursor, + _configure_cursor, + ), "opencode": Harness( "opencode", "OpenCode", diff --git a/tests/test_agent_commands.py b/tests/test_agent_commands.py index bb48e4d..1184455 100644 --- a/tests/test_agent_commands.py +++ b/tests/test_agent_commands.py @@ -216,7 +216,7 @@ def test_agent_creates_skore_on_first_run(tmp_path, monkeypatch): saved = json.loads((tmp_path / SKORE_FILENAME).read_text()) assert saved["api_key"] == "new-secret" assert saved["workspace"] == "ws-1" - assert (tmp_path / ".gitignore").read_text().strip().endswith(".skore") + assert ".skore" in (tmp_path / ".gitignore").read_text().splitlines() def test_agent_non_interactive_without_harness_errors(tmp_path, monkeypatch): diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 48ff7a4..80c28df 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -23,6 +23,12 @@ 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.""" + monkeypatch.setattr(_harnesses, "BOB_IDE_APP_PATH", tmp_path / "absent.app") + + def test_detect_opencode_by_binary(tmp_path, monkeypatch): monkeypatch.setattr( _harnesses.shutil, @@ -51,6 +57,39 @@ def test_detect_pi_by_binary(tmp_path, monkeypatch): assert detect_harnesses(tmp_path) == ["pi"] +def test_detect_cursor_by_binary(tmp_path, monkeypatch): + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda name: "/usr/local/bin/cursor" if name == "cursor" else None, + ) + assert detect_harnesses(tmp_path) == ["cursor"] + + +def test_detect_cursor_ignores_an_install_without_the_cli(tmp_path, monkeypatch): + """Offering a harness `_exec_harness` cannot start only fails later, louder.""" + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) + assert _harnesses._detect_cursor(tmp_path) is False + + +def test_detect_bob_shell_by_binary(tmp_path, monkeypatch): + monkeypatch.setattr( + _harnesses.shutil, + "which", + lambda name: "/usr/local/bin/bob" if name == "bob" else None, + ) + assert detect_harnesses(tmp_path) == ["bob"] + + +def test_detect_bob_ide_by_app_bundle(tmp_path, monkeypatch): + """The IDE installs no command, so `_launch_bob_ide` opens the bundle.""" + bundle = tmp_path / "IBM Bob.app" + bundle.mkdir() + monkeypatch.setattr(_harnesses, "BOB_IDE_APP_PATH", bundle) + monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) + assert detect_harnesses(tmp_path) == ["bob-ide"] + + def test_detect_harnesses_excludes_missing(tmp_path, monkeypatch): monkeypatch.setattr(_harnesses.shutil, "which", lambda name: None) assert detect_harnesses(tmp_path) == [] @@ -73,6 +112,174 @@ def test_pi_config_matches_hub_ui(tmp_path): assert model["contextWindow"] == 200000 +def test_cursor_config_points_at_the_mcp_endpoint(tmp_path): + HARNESSES["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.""" + HARNESSES["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`.""" + HARNESSES["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"}}}) + ) + + HARNESSES["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): + HARNESSES["bob"].configure(_ctx(tmp_path)) + HARNESSES["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"): + HARNESSES["bob"].configure(_ctx(tmp_path)) + + assert (config_dir / "mcp.json").read_text() == "{not json" + + +@pytest.mark.parametrize( + "name, entry", + [ + ("opencode", "opencode.json"), + ("claude", ".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): + HARNESSES[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.""" + HARNESSES["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"]}, + } + ) + ) + + HARNESSES["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): + HARNESSES["cursor"].configure(_ctx(tmp_path)) + HARNESSES["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"): + HARNESSES["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"): + HARNESSES["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]] = {} @@ -107,6 +314,56 @@ 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( + _harnesses.shutil, + "which", + lambda cmd: "/usr/local/bin/cursor" if cmd == "cursor" else None, + ) + monkeypatch.setattr(_harnesses, "_exec_harness", fake_exec) + _harnesses.launch_harness("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( + _harnesses.shutil, + "which", + lambda cmd: "/usr/local/bin/bob" if cmd == "bob" else None, + ) + monkeypatch.setattr(_harnesses, "_exec_harness", fake_exec) + _harnesses.launch_harness("bob", tmp_path) + assert captured["argv"] == ["bob"] + + +def test_launch_bob_ide_opens_the_app_bundle(tmp_path, monkeypatch): + captured: dict[str, object] = {} + + def fake_exec(name, argv, *, env=None): + captured["argv"] = argv + + bundle = tmp_path / "IBM Bob.app" + bundle.mkdir() + monkeypatch.setattr(_harnesses, "BOB_IDE_APP_PATH", bundle) + monkeypatch.setattr(_harnesses, "_exec_harness", fake_exec) + _harnesses.launch_harness("bob-ide", tmp_path) + assert captured["argv"] == ["open", "-a", str(bundle), str(tmp_path)] + + def test_launch_errors_when_binary_missing(tmp_path, monkeypatch): monkeypatch.setattr( _harnesses.shutil, diff --git a/uv.lock b/uv.lock index f8b3a4c..f110c76 100644 --- a/uv.lock +++ b/uv.lock @@ -2036,7 +2036,7 @@ hub = [ [[package]] name = "skore-cli" -version = "0.1.0" +version = "0.1.1" source = { editable = "." } dependencies = [ { name = "click" }, From 9dd64999ea71fb9a7b97c2f99994f7a402e8df81 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Tue, 25 Aug 2026 14:27:44 +0200 Subject: [PATCH 2/5] pr feedbacks --- tests/test_agent_commands.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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") From ce9e79dabbee395dc2476d8d3bb1d5a8ef6b30e6 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Tue, 25 Aug 2026 15:09:38 +0200 Subject: [PATCH 3/5] pr feedbacks --- README.md | 4 +-- src/skore_cli/_agents.py | 46 +++++++++++++++++++++----------- src/skore_cli/agent/_commands.py | 3 ++- tests/test_harnesses.py | 42 +++++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 2417e02..5aaea15 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ 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: **Bob Shell**, **Bob IDE**, **Claude**, -**Cursor**, **OpenCode**, **Pi** and **GitHub Copilot** (all but Bob IDE must be -on `PATH`). Later runs reuse `.skore` in the project directory (gitignored). Use +**Cursor**, **OpenCode**, **Pi** and **GitHub Copilot** (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 diff --git a/src/skore_cli/_agents.py b/src/skore_cli/_agents.py index 8a0f161..1eff40e 100644 --- a/src/skore_cli/_agents.py +++ b/src/skore_cli/_agents.py @@ -13,8 +13,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 () => ({ @@ -25,7 +29,8 @@ }, }); """ -CURSOR_SERVER_KEY = "skore" + +# 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. @@ -35,10 +40,15 @@ "template or script into the workspace", "calls to the skore MCP server's skore_agent tool", ) -BOB_SERVER_KEY = "skore" -# Bob IDE ships as an application, with no documented command it installs on -# PATH; a module constant so tests can point it somewhere that does not exist. + +# 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") @@ -98,9 +108,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": { @@ -154,7 +164,7 @@ def _configure_pi(ctx: HarnessContext) -> dict[str, Any]: config_path = config_dir / "models.json" payload = { "providers": { - "skore": { + SKORE_PROVIDER_KEY: { "baseUrl": ctx.base_url, "api": "openai-completions", "apiKey": ctx.api_key, @@ -230,7 +240,7 @@ def _configure_cursor(ctx: HarnessContext) -> dict[str, Any]: servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} - servers[CURSOR_SERVER_KEY] = { + 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 @@ -263,7 +273,7 @@ def _configure_cursor(ctx: HarnessContext) -> dict[str, Any]: 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]{CURSOR_SERVER_KEY}[/] on under " + 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)} @@ -288,7 +298,7 @@ def _configure_bob(ctx: HarnessContext, transport: dict[str, str]) -> dict[str, servers = config.get("mcpServers") if not isinstance(servers, dict): servers = {} - servers[BOB_SERVER_KEY] = { + servers[SKORE_PROVIDER_KEY] = { **transport, "headers": {"Authorization": f"Bearer {ctx.api_key}"}, "alwaysAllow": ["skore_agent"], @@ -316,7 +326,7 @@ def _configure_bob_ide(ctx: HarnessContext) -> dict[str, Any]: written = _configure_bob(ctx, {"type": "streamable-http", "url": ctx.mcp_url}) console.print( "[skore.muted] raise the network timeout for " - f"[skore.skill]{BOB_SERVER_KEY}[/] to 5 minutes under Settings -> MCP; a " + f"[skore.skill]{SKORE_PROVIDER_KEY}[/] to 5 minutes under Settings -> MCP; a " "turn can outlast the 1 minute default[/]" ) return written @@ -413,7 +423,7 @@ def _upsert_copilot_provider(user_config_path: Path, provider: 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}"], ) @@ -431,7 +441,7 @@ 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, ) @@ -445,7 +455,10 @@ def _launch_bob_shell(_workspace: Path, _model_id: str) -> None: def _launch_bob_ide(workspace: Path, _model_id: str) -> None: - _exec_harness("open", ["open", "-a", str(BOB_IDE_APP_PATH), str(workspace)]) + 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: @@ -563,6 +576,7 @@ def _exec_harness( label="Bob IDE", harness_name="bob-ide", harness_label="Bob IDE", + harness_binaries=("bob-ide",), configure=_configure_bob_ide, launch=_launch_bob_ide, ), @@ -654,7 +668,7 @@ 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": + 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/_commands.py b/src/skore_cli/agent/_commands.py index 1bfbdb1..33d3743 100644 --- a/src/skore_cli/agent/_commands.py +++ b/src/skore_cli/agent/_commands.py @@ -191,7 +191,8 @@ def agent( project directory. Supported harnesses: Bob Shell, Bob IDE, Claude, Cursor, OpenCode, Pi and - GitHub Copilot (all but Bob IDE must be on ``PATH``). + GitHub Copilot (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/tests/test_harnesses.py b/tests/test_harnesses.py index 15179b3..a32e58b 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -26,8 +26,17 @@ 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.""" + """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): @@ -79,7 +88,7 @@ def test_bob_shell_installed_by_binary(monkeypatch): def test_bob_ide_installed_by_app_bundle(tmp_path, monkeypatch): - """The IDE installs no command, so ``_launch_bob_ide`` opens the bundle.""" + """On macOS the IDE installs no command, so detection uses the bundle.""" bundle = tmp_path / "IBM Bob.app" bundle.mkdir() monkeypatch.setattr(_agents, "BOB_IDE_APP_PATH", bundle) @@ -88,6 +97,18 @@ def test_bob_ide_installed_by_app_bundle(tmp_path, monkeypatch): 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() == [] @@ -376,6 +397,23 @@ def fake_exec(name, argv, *, env=None): 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, From 62665e80465b03fb8cf1754720f5d2a8228e5204 Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Tue, 25 Aug 2026 15:20:00 +0200 Subject: [PATCH 4/5] fix: align HARNESS_NAMES test order with dict iteration --- tests/test_agents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_agents.py b/tests/test_agents.py index b097e2c..256477b 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -39,8 +39,8 @@ def test_agent_names_match_registry(): def test_harness_names_come_from_registry(): assert HARNESS_NAMES == [ "claude", - "codex", "cursor", + "codex", "opencode", "pi", "copilot", From 25012a6561825eec2727e5eb957a6e235908b9cd Mon Sep 17 00:00:00 2001 From: "Matt J." Date: Tue, 25 Aug 2026 16:30:31 +0200 Subject: [PATCH 5/5] fix tests --- tests/test_harnesses.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index c979f7f..8ce9203 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -91,6 +91,7 @@ def test_bob_shell_installed_by_binary(monkeypatch): 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) @@ -386,6 +387,7 @@ def fake_exec(name, argv, *, env=None): 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):