Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/c3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
87 changes: 67 additions & 20 deletions cli/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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})
Expand Down
46 changes: 34 additions & 12 deletions cli/hub_ui/components/modals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -575,13 +592,18 @@ function IdePickerModal({ project, onClose, onChanged }) {
);
})}
</div>
{selected === 'custom' && (
<div>
<MdlLabel>Custom command</MdlLabel>
<input value={customCmd} onChange={e => setCustomCmd(e.target.value)}
placeholder="e.g. nvim ." className="mono" style={mdlInputStyle()} />
</div>
)}
<div>
<MdlLabel>Command{selected === 'custom' ? '' : ' (leave blank for the default)'}</MdlLabel>
<input value={customCmd} onChange={e => setCustomCmd(e.target.value)}
placeholder={selected === 'custom' ? 'e.g. nvim .' : `${stockCmd(selected)} — override with e.g. yep`}
className="mono" style={mdlInputStyle()} />
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 6,
fontSize: 11, color: T.textMuted, cursor: 'pointer' }}>
<input type="checkbox" checked={remember}
onChange={e => setRemember(e.target.checked)} />
Remember for this project (used by the card's launch button too)
</label>
</div>
<MdlFooter>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn onClick={launch} disabled={busy}>
Expand Down
11 changes: 11 additions & 0 deletions cli/hub_ui/components/settings_modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -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');
Expand All @@ -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
Expand Down Expand Up @@ -222,6 +229,10 @@ function SettingsModal({ onClose, onChanged }) {
<input type="number" value={cacheSize} onChange={e => 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', (
<input value={agentCmd} onChange={e => 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', (
<input value={oracleUrl} onChange={e => setOracleUrl(e.target.value)}
placeholder="http://localhost:3331" className="mono" style={inputStyle(220)} />
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 10 additions & 2 deletions services/project_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading
Loading