diff --git a/CHANGELOG.md b/CHANGELOG.md index 536d2af..d932044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to Code Context Control (C3) are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.149.0] - 2026-09-24 + +### Added — the hub launches YOUR agent, not a command named `claude` + +"Claude Code CLI" was hard-coded to the binary `claude`, so a box that starts +its agent through a wrapper (`yep`, a project-specific launcher) could only +reach it via the Custom slot — which was never stored, so the project card's +launch button kept running the stock command. + +- **`ide_cmds` in `~/.c3/hub_config.json`**: per-IDE command overrides, e.g. + `{"claude-code": "yep"}`. Editable from Hub Settings → Agent command. +- **`ide_cmd` per project** (`projects.json`, set from Open in IDE): overrides + the hub-wide value for that project and is what the card's launch button + uses, so one-click launch and the picker agree. +- **Resolution order** in `POST /api/projects/launch-ide`: the request's + `custom_cmd` → the project's `ide_cmd` → `ide_cmds[ide]` → the stock + command. An override replaces the binary only; a terminal CLI is still + launched in a terminal and a GUI editor still receives the path argument. +- `POST /api/projects/update` accepts `ide_cmd`; blank clears the override. + ## [2.148.0] - 2026-09-23 ### Added — loosen a built-in guard for a while, or for one session @@ -306,7 +326,6 @@ same asymmetry. does not read protects nothing while claiming to. - Every real write lands in the target's activity log and edit ledger (`via: hub`); global writes land in `~/.c3`. - ## [2.141.0] - 2026-09-18 ### Added — inherit again, and every project's approval policy in one read diff --git a/cli/c3.py b/cli/c3.py index 4977b97..30889be 100644 --- a/cli/c3.py +++ b/cli/c3.py @@ -91,7 +91,7 @@ # Config CONFIG_DIR = ".c3" CONFIG_FILE = ".c3/config.json" -__version__ = "2.148.0" +__version__ = "2.149.0" # The PreToolUse matcher for native subagent calls (installer and hub migration). AGENT_MATCHER = "Agent|Task" diff --git a/cli/hub_server.py b/cli/hub_server.py index b978b87..e699ced 100644 --- a/cli/hub_server.py +++ b/cli/hub_server.py @@ -72,6 +72,10 @@ def _hub_allowed_hosts(): "projects_view": "list", "main_view": "projects", "oracle_url": "", + # Per-IDE launch command overrides, e.g. {"claude-code": "yep"} when the + # agent is started through a wrapper script instead of the stock binary. + # A project's own `ide_cmd` (projects.json) wins over this. + "ide_cmds": {}, # Minutes of project silence, with no session heartbeat left, before a UI # server that an IDE session launched is stopped. 0 = never reap. "ui_reap_minutes": 30, @@ -81,6 +85,48 @@ def _hub_allowed_hosts(): _MAIN_VIEWS = {"projects", "board", "ci", "creds", "tokens", "locks", "access", "enforce", "sessions"} +# Stock command per IDE id, and whether it takes the project path as an +# argument (GUI) instead of being run inside a terminal at that path. +_IDE_DEFAULT_CMDS = { + "claude-code": ("claude", False), + "claude-app": ("claude-app", True), + "codex": ("codex", False), + "grok": ("grok", False), + "gemini": ("gemini", False), + "antigravity": ("antigravity", False), + "vscode": ("code", True), + "cursor": ("cursor", True), +} + + +def _resolve_ide_cmd(ide: str, path: str, custom_cmd: str = "") -> tuple: + """Resolve the command to launch for `ide` in `path`. + + Precedence: explicit custom_cmd (this launch) → the project's stored + `ide_cmd` → hub config `ide_cmds[ide]` → the stock command. + Returns (cmd, is_gui, error) — error is a message when nothing resolves. + """ + default_cmd, is_gui = _IDE_DEFAULT_CMDS.get(ide, ("", False)) + if ide != "custom" and not default_cmd: + return "", False, f"Unknown IDE: {ide}" + cmd = (custom_cmd or "").strip() + if not cmd: + try: + cmd = _pm().get_ide_cmd(path) + except Exception: + cmd = "" + if not cmd: + overrides = _read_hub_config().get("ide_cmds") or {} + if isinstance(overrides, dict): + cmd = str(overrides.get(ide) or "").strip() + if not cmd: + cmd = default_cmd + if not cmd: + return "", False, "custom_cmd is required for custom IDE" + # An overridden command replaces the binary, not the launch style: a + # wrapper for a terminal CLI is still a terminal CLI. + return cmd, is_gui, "" + # How often the hub reaps orphaned UI servers and chases autostart projects. _UI_SWEEP_INTERVAL_S = 60 @@ -460,6 +506,15 @@ def api_hub_config_set(): cfg["main_view"] = main_view if "oracle_url" in data: cfg["oracle_url"] = str(data["oracle_url"]).strip() + if "ide_cmds" in data: + raw = data["ide_cmds"] + if not isinstance(raw, dict): + return jsonify({"error": "ide_cmds must be an object"}), 400 + # Empty string = "back to the stock command", so the key is dropped + # rather than stored blank. + cfg["ide_cmds"] = { + str(k): str(v).strip() for k, v in raw.items() if str(v or "").strip() + } if "sidebar_group" in data: cfg["sidebar_group"] = str(data["sidebar_group"]).strip() if "sidebar_collapsed" in data: @@ -718,17 +773,11 @@ def api_projects_open(): @app.route("/api/projects/launch-ide", methods=["POST"]) def api_launch_ide(): - """Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?}""" - _IDE_CMDS = { - "claude-code": ("claude", False), - "claude-app": ("claude-app", True), - "codex": ("codex", False), - "grok": ("grok", False), - "gemini": ("gemini", False), - "antigravity": ("antigravity", False), - "vscode": ("code", True), - "cursor": ("cursor", True), - } + """Launch an IDE or CLI tool in the project directory. Body: {path, ide, custom_cmd?} + + The command itself is resolved by _resolve_ide_cmd, so a project or the + hub can point `claude-code` at a wrapper such as `yep`. + """ try: data = request.get_json(force=True) or {} path_str = (data.get("path") or "").strip() @@ -744,14 +793,9 @@ def api_launch_ide(): if not path.exists(): return jsonify({"error": f"Path does not exist: {path_str}"}), 404 - if ide == "custom": - if not custom_cmd: - return jsonify({"error": "custom_cmd is required for custom IDE"}), 400 - cmd, is_gui = custom_cmd, False - elif ide in _IDE_CMDS: - cmd, is_gui = _IDE_CMDS[ide] - else: - return jsonify({"error": f"Unknown IDE: {ide}"}), 400 + cmd, is_gui, err = _resolve_ide_cmd(ide, str(path), custom_cmd) + if err: + return jsonify({"error": err}), 400 if is_gui: # GUI IDEs (VS Code, Cursor) accept a path argument directly @@ -795,7 +839,7 @@ def api_launch_ide(): @app.route("/api/projects/update", methods=["POST"]) def api_projects_update(): - """Update editable project fields (name, tags, notes). Body: {path, name?, tags?, notes?}""" + """Update editable project fields. Body: {path, name?, tags?, notes?, ide_cmd?}""" data = request.get_json(force=True) or {} path = (data.get("path") or "").strip() if not path: @@ -812,6 +856,9 @@ def api_projects_update(): fields["tags"] = [t.strip() for t in str(raw).split(",") if t.strip()] if "notes" in data: fields["notes"] = str(data["notes"]) + if "ide_cmd" in data: + # Blank clears the override and falls back to the stock command. + fields["ide_cmd"] = str(data["ide_cmd"] or "").strip() try: ok = _pm().update_project(path, **fields) return jsonify({"updated": ok}) diff --git a/cli/hub_ui/components/modals.js b/cli/hub_ui/components/modals.js index a2fe958..2bd63b4 100644 --- a/cli/hub_ui/components/modals.js +++ b/cli/hub_ui/components/modals.js @@ -528,24 +528,41 @@ function ReparentModal({ project, projects, onClose, onChanged }) { // ── IDE picker ───────────────────────────────────────────────── // POST /api/projects/launch-ide {path, ide, custom_cmd?} +// The command is editable per project (stored as ide_cmd via +// /api/projects/update) so an agent started through a wrapper — `yep` rather +// than `claude` — launches from the card too, not just from here. function IdePickerModal({ project, onClose, onChanged }) { const known = IDE_OPTIONS.some(o => o.id === (project && project.ide)); const [selected, setSelected] = useState(known ? project.ide : 'claude-code'); - const [customCmd, setCustomCmd] = useState(''); + const savedCmd = ((project && project.ide_cmd) || '').trim(); + const [customCmd, setCustomCmd] = useState(savedCmd); + const [remember, setRemember] = useState(!!savedCmd); const [busy, setBusy] = useState(false); + const stockCmd = (id) => { + const opt = IDE_OPTIONS.find(o => o.id === id); + return opt && opt.cmd !== '...' ? opt.cmd : ''; + }; + const effectiveCmd = customCmd.trim() || stockCmd(selected); + const launch = async () => { - if (selected === 'custom' && !customCmd.trim()) { + const cmd = customCmd.trim(); + if (selected === 'custom' && !cmd) { notify('Enter a custom command first.', 'err'); return; } setBusy(true); try { + // Persist first: the project card launches with no command of its own. + const wanted = remember ? cmd : ''; + if (wanted !== savedCmd) { + await api.post('/api/projects/update', { path: project.path, ide_cmd: wanted }); + } await api.post('/api/projects/launch-ide', { - path: project.path, ide: selected, - custom_cmd: selected === 'custom' ? customCmd.trim() : '', + path: project.path, ide: selected, custom_cmd: cmd, }); - notify(`Launched ${ideLabel(selected)} in ${project.name || project.path}`); + notify(`Launched ${ideLabel(selected)} (${effectiveCmd}) in ${project.name || project.path}`); + if (onChanged) onChanged(); onClose(); } catch (e) { notify(`Launch failed: ${e.message}`, 'err'); @@ -575,13 +592,18 @@ function IdePickerModal({ project, onClose, onChanged }) { ); })} - {selected === 'custom' && ( -
- Custom command - setCustomCmd(e.target.value)} - placeholder="e.g. nvim ." className="mono" style={mdlInputStyle()} /> -
- )} +
+ Command{selected === 'custom' ? '' : ' (leave blank for the default)'} + setCustomCmd(e.target.value)} + placeholder={selected === 'custom' ? 'e.g. nvim .' : `${stockCmd(selected)} — override with e.g. yep`} + className="mono" style={mdlInputStyle()} /> + +
Cancel diff --git a/cli/hub_ui/components/settings_modal.js b/cli/hub_ui/components/settings_modal.js index b1c5676..a326a9d 100644 --- a/cli/hub_ui/components/settings_modal.js +++ b/cli/hub_ui/components/settings_modal.js @@ -14,6 +14,7 @@ function SettingsModal({ onClose, onChanged }) { const [port, setPort] = useState(''); const [autoBrowser, setAutoBrowser] = useState(true); const [oracleUrl, setOracleUrl] = useState(''); + const [agentCmd, setAgentCmd] = useState(''); // ide_cmds['claude-code'] const [cacheSize, setCacheSize] = useState(8); const [saving, setSaving] = useState(false); const [svc, setSvc] = useState(null); // hub: {installed, running, method, port, log_path} | {error} @@ -51,6 +52,7 @@ function SettingsModal({ onClose, onChanged }) { setPort(d.port || 3330); setAutoBrowser(!!d.auto_open_browser); setOracleUrl(d.oracle_url || ''); + setAgentCmd(((d.ide_cmds || {})['claude-code']) || ''); setCacheSize(d.runtime_cache_size || 8); } catch { notify('Could not load hub config', 'err'); @@ -70,9 +72,14 @@ function SettingsModal({ onClose, onChanged }) { const cache = Math.max(1, parseInt(cacheSize, 10) || 8); setSaving(true); try { + // Blank restores the stock `claude`; other IDE overrides are left alone. + const ideCmds = { ...((cfg && cfg.ide_cmds) || {}) }; + if (agentCmd.trim()) ideCmds['claude-code'] = agentCmd.trim(); + else delete ideCmds['claude-code']; await api.post('/api/hub/config', { port: portVal, auto_open_browser: autoBrowser, oracle_url: oracleUrl.trim(), runtime_cache_size: cache, + ide_cmds: ideCmds, }); const portChanged = cfg && portVal !== cfg.port; notify(portChanged @@ -222,6 +229,10 @@ function SettingsModal({ onClose, onChanged }) { setCacheSize(e.target.value)} min={1} className="mono" style={inputStyle(110)} /> ), 'Max project runtimes kept warm for drill-in and global search.')} + {fieldRow('Agent command', ( + setAgentCmd(e.target.value)} + placeholder="claude" className="mono" style={inputStyle(220)} /> + ), 'Default command for "Claude Code CLI" launches — e.g. yep for a wrapper script. A project can override it in Open in IDE.')} {fieldRow('Oracle URL', ( setOracleUrl(e.target.value)} placeholder="http://localhost:3331" className="mono" style={inputStyle(220)} /> diff --git a/pyproject.toml b/pyproject.toml index fe4e5cf..55848f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "code-context-control" -version = "2.148.0" +version = "2.149.0" description = "Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub." readme = "README.md" requires-python = ">=3.10" diff --git a/services/project_manager.py b/services/project_manager.py index 0c7196a..3503805 100644 --- a/services/project_manager.py +++ b/services/project_manager.py @@ -794,11 +794,19 @@ def get_project_details(self, path: str) -> dict: "initialized": bool(cfg), } + def get_ide_cmd(self, path: str) -> str: + """Per-project launch command override (e.g. `yep` instead of `claude`).""" + path = str(Path(path).resolve()) + for p in self._read_projects(): + if p["path"] == path: + return str(p.get("ide_cmd") or "").strip() + return "" + def update_project(self, path: str, **fields) -> bool: - """Update editable project fields: name, tags, notes.""" + """Update editable project fields: name, tags, notes, ide_cmd.""" path = str(Path(path).resolve()) projects = self._read_projects() - allowed = {"name", "tags", "notes", "autostart_ui"} + allowed = {"name", "tags", "notes", "autostart_ui", "ide_cmd"} for p in projects: if p["path"] == path: for k, v in fields.items(): diff --git a/tests/test_hub_ide_cmd.py b/tests/test_hub_ide_cmd.py new file mode 100644 index 0000000..5018ebb --- /dev/null +++ b/tests/test_hub_ide_cmd.py @@ -0,0 +1,125 @@ +"""Launch-command overrides: the hub can start an agent through a wrapper. + +`claude-code` means "the Claude Code CLI", not literally the binary named +`claude` — a box that launches it as `yep` must be able to say so once, per +project or hub-wide, and have every launch surface honour it. +""" +import sys +import unittest +import unittest.mock as mock +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + + +class TestResolveIdeCmd(unittest.TestCase): + @classmethod + def setUpClass(cls): + from cli import hub_server + cls.mod = hub_server + + def _resolve(self, ide, project_cmd="", hub_cmds=None, custom_cmd=""): + pm = mock.Mock() + pm.get_ide_cmd.return_value = project_cmd + cfg = dict(self.mod._HUB_CONFIG_DEFAULTS) + cfg["ide_cmds"] = hub_cmds or {} + with mock.patch.object(self.mod, "_pm", return_value=pm), \ + mock.patch.object(self.mod, "_read_hub_config", return_value=cfg): + return self.mod._resolve_ide_cmd(ide, "C:/proj", custom_cmd) + + def test_default_is_the_stock_command(self): + self.assertEqual(self._resolve("claude-code"), ("claude", False, "")) + + def test_hub_override_replaces_the_binary(self): + self.assertEqual( + self._resolve("claude-code", hub_cmds={"claude-code": "yep"}), + ("yep", False, "")) + + def test_project_override_beats_hub_override(self): + cmd, _, _ = self._resolve("claude-code", project_cmd="yep --resume", + hub_cmds={"claude-code": "yep"}) + self.assertEqual(cmd, "yep --resume") + + def test_explicit_custom_cmd_beats_everything(self): + cmd, _, _ = self._resolve("claude-code", project_cmd="yep", + hub_cmds={"claude-code": "nope"}, + custom_cmd="claude --continue") + self.assertEqual(cmd, "claude --continue") + + def test_override_keeps_the_launch_style(self): + # A wrapper for a GUI editor is still launched with the path argument. + self.assertEqual(self._resolve("vscode", project_cmd="code-insiders"), + ("code-insiders", True, "")) + self.assertEqual(self._resolve("codex", project_cmd="my-codex")[1], False) + + def test_unknown_ide_is_an_error(self): + _, _, err = self._resolve("emacs-but-no") + self.assertIn("Unknown IDE", err) + + def test_custom_without_a_command_is_an_error(self): + _, _, err = self._resolve("custom") + self.assertIn("custom_cmd is required", err) + + def test_custom_uses_the_stored_project_command(self): + self.assertEqual(self._resolve("custom", project_cmd="nvim .")[0], "nvim .") + + +class TestIdeCmdsConfig(unittest.TestCase): + @classmethod + def setUpClass(cls): + from cli import hub_server + cls.mod = hub_server + cls.client = hub_server.app.test_client() + + def test_ide_cmds_must_be_an_object(self): + resp = self.client.post("/api/hub/config", json={"ide_cmds": "yep"}) + self.assertEqual(resp.status_code, 400) + + def test_blank_command_clears_the_override(self): + written = {} + with mock.patch.object(self.mod, "_write_hub_config", written.update): + resp = self.client.post("/api/hub/config", + json={"ide_cmds": {"claude-code": "yep", + "codex": " "}}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(written.get("ide_cmds"), {"claude-code": "yep"}) + + +class TestProjectIdeCmdField(unittest.TestCase): + """The per-project override must survive a write/read round trip.""" + + def test_update_and_read_back(self): + import tempfile + + from services import project_manager as pmod + + with tempfile.TemporaryDirectory() as tmp: + proj = Path(tmp) / "proj" + proj.mkdir() + with mock.patch.object(pmod, "_PROJECTS_FILE", Path(tmp) / "projects.json"): + pm = pmod.ProjectManager() + pm.add_project(str(proj), name="proj") + self.assertEqual(pm.get_ide_cmd(str(proj)), "") + self.assertTrue(pm.update_project(str(proj), ide_cmd="yep")) + self.assertEqual(pm.get_ide_cmd(str(proj)), "yep") + pm.update_project(str(proj), ide_cmd="") + self.assertEqual(pm.get_ide_cmd(str(proj)), "") + + def test_unknown_fields_are_still_rejected(self): + import tempfile + + from services import project_manager as pmod + + with tempfile.TemporaryDirectory() as tmp: + proj = Path(tmp) / "proj" + proj.mkdir() + with mock.patch.object(pmod, "_PROJECTS_FILE", Path(tmp) / "projects.json"): + pm = pmod.ProjectManager() + pm.add_project(str(proj), name="proj") + pm.update_project(str(proj), evil="rm -rf /") + self.assertNotIn("evil", pm._read_projects()[0]) + + +if __name__ == "__main__": + unittest.main()