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' && ( -