From 5a58d43eaf9d0f8248f6631563ce6e5f42051e9c Mon Sep 17 00:00:00 2001 From: Kaushal Paneri Date: Sun, 10 May 2026 22:07:09 -0400 Subject: [PATCH 1/5] feat(v2): foundation modules for harness-driven configuration Add the foundation of crux v2: registry tree paths, TOML I/O, pointer file resolution, append-only history log, bundle.toml reader/writer, and primitive enumeration via store. - paths.py: registry_root, mcps_root, skills_root, plugins_root, harnesses_root, active_pointer_path, history_path, claude_user_dir, claude_dir_for. - tomlio.py: stdlib tomllib for reading; minimal writer for the small TOML dialect crux emits. - pointer.py: parse_ref, read_pointer, write_pointer, resolve_active (cwd walk-up to user fallback). - history.py: bounded TSV history log of activations. - bundle.py: bundle.toml reader/writer with default sections. - store.py: list/lookup primitives across the registry tree, including version-aware sorting. Plan: docs/superpowers/plans/2026-05-10-crux-v2-harness-manager.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-10-crux-v2-harness-manager.md | 2172 +++++++++++++++++ src/crux_cli/bundle.py | 44 + src/crux_cli/history.py | 43 + src/crux_cli/paths.py | 50 + src/crux_cli/pointer.py | 80 + src/crux_cli/store.py | 122 + src/crux_cli/tomlio.py | 62 + tests/unit/test_bundle.py | 41 + tests/unit/test_history.py | 35 + tests/unit/test_paths_v2.py | 23 + tests/unit/test_pointer.py | 78 + tests/unit/test_store.py | 57 + tests/unit/test_tomlio.py | 46 + 13 files changed, 2853 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-10-crux-v2-harness-manager.md create mode 100644 src/crux_cli/bundle.py create mode 100644 src/crux_cli/history.py create mode 100644 src/crux_cli/pointer.py create mode 100644 src/crux_cli/store.py create mode 100644 src/crux_cli/tomlio.py create mode 100644 tests/unit/test_bundle.py create mode 100644 tests/unit/test_history.py create mode 100644 tests/unit/test_paths_v2.py create mode 100644 tests/unit/test_pointer.py create mode 100644 tests/unit/test_store.py create mode 100644 tests/unit/test_tomlio.py diff --git a/docs/superpowers/plans/2026-05-10-crux-v2-harness-manager.md b/docs/superpowers/plans/2026-05-10-crux-v2-harness-manager.md new file mode 100644 index 0000000..92ce6cf --- /dev/null +++ b/docs/superpowers/plans/2026-05-10-crux-v2-harness-manager.md @@ -0,0 +1,2172 @@ +# Crux v2.0 Harness Manager — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Rewrite Crux from per-MCP/per-skill management (v1.x) to a harness-driven configuration manager (v2.0): versioned bundles of CLAUDE.md, skills, MCPs, plugins, hooks deployed via symlinks. + +**Architecture:** A registry tree at `~/.crux/registry/{mcps,skills,plugins,harnesses}/` holds primitives. Harnesses are versioned bundles referencing primitives. A TOML pointer file (`~/.crux/active.toml` or `/crux.toml`) names the active harness. `crux use` resolves the pointer, deploys symlinks under `~/.claude/` (or `/.claude/`), and generates `.mcp.json` from the bundle's MCP references and keychain secrets. + +**Tech Stack:** Python 3.11+, stdlib `tomllib` for reading TOML, a small manual writer for emitting TOML (the dialect is trivial). Reuses existing `secrets.py`, `validation.py`, `registry.py` (MCP search), `install.py`, `oauth.py`, `bridge.py`, `auth.py`, `mcp_config.py`, `preflight.py`, `health.py`. + +--- + +## File Structure + +### New modules (under `src/crux_cli/`) + +- `paths.py` — extended with `registry_root()`, `mcps_root()`, `skills_root()`, `plugins_root()`, `harnesses_root()`, `active_pointer_path()`, `history_path()`, `claude_user_dir()`, `claude_dir_for()`. +- `tomlio.py` — `load_toml(path)` (stdlib `tomllib`), `dump_toml(path, data)` (manual writer; supports strings, ints, bools, lists, nested tables). +- `pointer.py` — `read_pointer(path)`, `write_pointer(path, harness_ref)`, `resolve_active(cwd)` returning `(scope, harness_name, version_or_None, pointer_path)`. +- `history.py` — `append(scope_dir, prev, new)`, `pop_previous(scope_dir)`, `read_all(scope_dir)`; bounded to 100 entries; TSV format. +- `store.py` — primitive lookups: `list_mcps()`, `list_skills()`, `list_plugins()`, `list_harnesses()`, `harness_versions(name)`, `latest_version(name)`, `harness_dir(name, version=None)`, `mcp_dir(name)`, `skill_dir(name)`, `plugin_dir(name, version)`, `load_mcp_entry(name)`, `save_mcp_entry(name, data)`. +- `bundle.py` — `load_bundle(harness_dir)`, `save_bundle(harness_dir, data)`, `Bundle` dataclass with helpers (add/remove skill/mcp/plugin). +- `harness_ops.py` — `new_harness(name)`, `bump(name)`, `show(name, version)`, `list_versions(name)`. +- `activation.py` — `activate(harness_name, version, scope, cwd)`: walks bundle, builds desired symlink set, validates no conflicts with non-Crux files, writes/replaces symlinks atomically, generates `.mcp.json` with keychain references. +- `migrate_v1.py` — `migrate_cwd(name=None)`: reads `crux.json`, creates harness, writes `crux.toml`, deletes old file. +- `setup.py` — replaces old `setup_crux.py`. Creates `~/.crux/` tree, installs bundled crux skill, writes default `config.toml`, copies shared launchers. +- `cli/main.py` — rewritten command surface per spec. +- `cli/commands/setup_cmd.py` — `crux setup`. +- `cli/commands/doctor_cmd.py` — `crux doctor`. +- `cli/commands/migrate_cmd.py` — `crux migrate`. +- `cli/commands/registry_cmd.py` — `crux registry add/remove/list`. +- `cli/commands/secret_cmd.py` — `crux secret set/list/remove`. +- `cli/commands/harness_cmd.py` — `crux new/bump/list/show/edit`. +- `cli/commands/use_cmd.py` — `crux use` and `crux active`. + +### Modules removed + +- `manifest.py`, `sync.py`, `projects.py`, `sandbox.py`, `setup_crux.py`. +- All of `cli/commands/{mcp,skill,project,task,version}.py` (replaced). +- All tests for the removed modules. + +### Modules kept (no API change required) + +- `secrets.py`, `validation.py`, `registry.py` (MCP search), `install.py` (package install helpers), `oauth.py`, `bridge.py`, `auth.py`, `mcp_config.py`, `preflight.py`, `health.py`, `config.py`, `version.py`. + +### Test layout + +`tests/unit/` mirrors module names with `test_.py`. `tests/integration/` exercises CLI flows end-to-end with `CRUX_TEST_ROOT` redirection. + +--- + +## Phase A — Foundation + +### Task A1: Extend `paths.py` for v2 layout + +**Files:** +- Modify: `src/crux_cli/paths.py` +- Test: `tests/unit/test_paths.py` + +- [ ] **Step 1: Write failing test for new path helpers** + +Append to `tests/unit/test_paths.py`: +```python +def test_registry_subpaths(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + from crux_cli import paths + assert paths.registry_root() == tmp_path / "registry" + assert paths.mcps_root() == tmp_path / "registry" / "mcps" + assert paths.skills_root() == tmp_path / "registry" / "skills" + assert paths.plugins_root() == tmp_path / "registry" / "plugins" + assert paths.harnesses_root() == tmp_path / "registry" / "harnesses" + assert paths.active_pointer_path() == tmp_path / "active.toml" + assert paths.history_path() == tmp_path / "history" + + +def test_claude_dirs(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + from crux_cli import paths + assert paths.claude_user_dir() == tmp_path / ".claude" + assert paths.claude_dir_for(tmp_path / "proj") == tmp_path / "proj" / ".claude" +``` + +- [ ] **Step 2: Run test** — `pytest tests/unit/test_paths.py -k "registry_subpaths or claude_dirs" -v` — expect failures. + +- [ ] **Step 3: Implement helpers in `src/crux_cli/paths.py`** + +Append: +```python +def registry_root() -> Path: + return crux_home() / "registry" + +def mcps_root() -> Path: + return registry_root() / "mcps" + +def skills_root() -> Path: + return registry_root() / "skills" + +def plugins_root() -> Path: + return registry_root() / "plugins" + +def harnesses_root() -> Path: + return registry_root() / "harnesses" + +def active_pointer_path() -> Path: + return crux_home() / "active.toml" + +def history_path() -> Path: + return crux_home() / "history" + +def claude_user_dir() -> Path: + return Path.home() / ".claude" + +def claude_dir_for(project_dir: Path) -> Path: + return project_dir / ".claude" +``` + +- [ ] **Step 4: Verify pass** — `pytest tests/unit/test_paths.py -v`. + +- [ ] **Step 5: Commit** — `git add src/crux_cli/paths.py tests/unit/test_paths.py && git commit -m "feat(paths): add v2 registry and claude-dir helpers"`. + +### Task A2: `tomlio` — minimal TOML writer + reader + +**Files:** +- Create: `src/crux_cli/tomlio.py` +- Create: `tests/unit/test_tomlio.py` + +- [ ] **Step 1: Write failing test** + +```python +import tomllib +from pathlib import Path +from crux_cli.tomlio import dump_toml, load_toml + + +def test_roundtrip_simple(tmp_path: Path): + data = {"harness": "coding@v3"} + p = tmp_path / "a.toml" + dump_toml(p, data) + assert load_toml(p) == data + + +def test_dump_tables_and_lists(tmp_path: Path): + data = { + "harness": {"name": "x", "version": "v2"}, + "skills": {"include": ["a", "b"]}, + "mcps": {"include": []}, + } + p = tmp_path / "b.toml" + dump_toml(p, data) + assert load_toml(p) == data + + +def test_dump_atomic(tmp_path: Path): + p = tmp_path / "c.toml" + p.write_text('harness = "old"\n') + dump_toml(p, {"harness": "new"}) + assert tomllib.loads(p.read_text())["harness"] == "new" + # No leftover tmp files + assert [x.name for x in tmp_path.iterdir()] == ["c.toml"] +``` + +- [ ] **Step 2: Run test** — expect ImportError. + +- [ ] **Step 3: Implement `src/crux_cli/tomlio.py`** + +```python +"""Minimal TOML I/O. Reading delegates to stdlib tomllib; writing supports the +small subset Crux emits (strings, ints, bools, flat lists, single-level tables).""" +from __future__ import annotations + +import tempfile +import tomllib +from pathlib import Path +from typing import Any + + +def load_toml(path: Path) -> dict[str, Any]: + with open(path, "rb") as f: + return tomllib.load(f) + + +def _fmt_value(v: Any) -> str: + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + if isinstance(v, str): + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(v, list): + return "[" + ", ".join(_fmt_value(x) for x in v) + "]" + raise TypeError(f"Unsupported TOML value: {type(v).__name__}") + + +def dump_toml(path: Path, data: dict[str, Any]) -> None: + lines: list[str] = [] + scalars = {k: v for k, v in data.items() if not isinstance(v, dict)} + tables = {k: v for k, v in data.items() if isinstance(v, dict)} + for k, v in scalars.items(): + lines.append(f"{k} = {_fmt_value(v)}") + for table_name, table in tables.items(): + if lines: + lines.append("") + lines.append(f"[{table_name}]") + for k, v in table.items(): + lines.append(f"{k} = {_fmt_value(v)}") + content = "\n".join(lines) + "\n" + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with open(fd, "w") as f: + f.write(content) + Path(tmp).replace(path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise +``` + +- [ ] **Step 4: Verify pass** — `pytest tests/unit/test_tomlio.py -v`. + +- [ ] **Step 5: Commit** — `feat(tomlio): minimal TOML writer plus stdlib reader`. + +### Task A3: Pointer file (read/write/resolve) + +**Files:** +- Create: `src/crux_cli/pointer.py` +- Create: `tests/unit/test_pointer.py` + +- [ ] **Step 1: Failing test** + +```python +from pathlib import Path +import pytest +from crux_cli.pointer import read_pointer, write_pointer, parse_ref, resolve_active + + +def test_parse_ref(): + assert parse_ref("coding@v3") == ("coding", "v3") + assert parse_ref("coding") == ("coding", None) + + +def test_parse_ref_bad(): + with pytest.raises(ValueError): + parse_ref("@") + + +def test_write_then_read(tmp_path: Path): + p = tmp_path / "crux.toml" + write_pointer(p, "coding@v2") + assert read_pointer(p) == ("coding", "v2") + + +def test_read_missing(tmp_path: Path): + assert read_pointer(tmp_path / "absent.toml") is None + + +def test_resolve_walks_up(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + proj = tmp_path / "a" / "b" / "c" + proj.mkdir(parents=True) + write_pointer(tmp_path / "a" / "crux.toml", "x@v1") + res = resolve_active(proj) + assert res is not None + scope, name, version, pointer_path = res + assert scope == "directory" + assert (name, version) == ("x", "v1") + assert pointer_path == tmp_path / "a" / "crux.toml" + + +def test_resolve_falls_back_to_user(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + write_pointer(tmp_path / "active.toml", "global@v1") + proj = tmp_path / "elsewhere" + proj.mkdir() + scope, name, version, _ = resolve_active(proj) + assert (scope, name, version) == ("user", "global", "v1") + + +def test_resolve_returns_none(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + proj = tmp_path / "x" + proj.mkdir() + assert resolve_active(proj) is None +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement `src/crux_cli/pointer.py`** + +```python +"""Pointer-file resolution: directory `crux.toml` overrides user `active.toml`.""" +from __future__ import annotations + +from pathlib import Path + +from crux_cli.paths import active_pointer_path +from crux_cli.tomlio import dump_toml, load_toml + + +def parse_ref(ref: str) -> tuple[str, str | None]: + if not ref or ref.startswith("@") or ref.endswith("@"): + raise ValueError(f"Invalid harness ref: {ref!r}") + if "@" in ref: + name, version = ref.split("@", 1) + return name, version + return ref, None + + +def write_pointer(path: Path, harness_ref: str) -> None: + parse_ref(harness_ref) # validate + dump_toml(path, {"harness": harness_ref}) + + +def read_pointer(path: Path) -> tuple[str, str | None] | None: + if not path.exists(): + return None + data = load_toml(path) + ref = data.get("harness") + if not isinstance(ref, str): + return None + return parse_ref(ref) + + +def _walk_up_for_pointer(start: Path) -> Path | None: + cur = start.resolve() + while True: + candidate = cur / "crux.toml" + if candidate.exists(): + return candidate + if cur.parent == cur: + return None + cur = cur.parent + + +def resolve_active(cwd: Path) -> tuple[str, str, str | None, Path] | None: + """Return (scope, name, version, pointer_path) or None. + + scope is "directory" or "user". + """ + found = _walk_up_for_pointer(cwd) + if found is not None: + parsed = read_pointer(found) + if parsed: + return ("directory", parsed[0], parsed[1], found) + user_pointer = active_pointer_path() + parsed = read_pointer(user_pointer) + if parsed: + return ("user", parsed[0], parsed[1], user_pointer) + return None +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(pointer): pointer file resolution with cwd walk-up`. + +### Task A4: History log + +**Files:** +- Create: `src/crux_cli/history.py` +- Create: `tests/unit/test_history.py` + +- [ ] **Step 1: Failing test** + +```python +from pathlib import Path +from crux_cli.history import append, pop_previous, read_all + + +def test_append_and_pop(tmp_path: Path): + h = tmp_path / "history" + append(h, prev=None, new="a@v1") + append(h, prev="a@v1", new="b@v2") + assert pop_previous(h) == "a@v1" + rows = read_all(h) + assert len(rows) == 2 + + +def test_pop_empty(tmp_path: Path): + assert pop_previous(tmp_path / "nope") is None + + +def test_bounded_to_100(tmp_path: Path): + h = tmp_path / "h" + for i in range(120): + append(h, prev=None, new=f"x@v{i}") + rows = read_all(h) + assert len(rows) == 100 + assert rows[-1][2] == "x@v119" +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""Append-only history log of harness activations (TSV, bounded to 100 rows).""" +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +MAX_ENTRIES = 100 + + +def append(history_file: Path, prev: str | None, new: str) -> None: + history_file.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + line = f"{ts}\t{prev or ''}\t{new}\n" + rows = read_all(history_file) + rows.append((ts, prev or "", new)) + rows = rows[-MAX_ENTRIES:] + history_file.write_text("".join(f"{r[0]}\t{r[1]}\t{r[2]}\n" for r in rows)) + _ = line # kept for clarity; final write is authoritative + + +def read_all(history_file: Path) -> list[tuple[str, str, str]]: + if not history_file.exists(): + return [] + rows: list[tuple[str, str, str]] = [] + for raw in history_file.read_text().splitlines(): + parts = raw.split("\t") + if len(parts) >= 3: + rows.append((parts[0], parts[1], parts[2])) + return rows + + +def pop_previous(history_file: Path) -> str | None: + rows = read_all(history_file) + if not rows: + return None + return rows[-1][1] or None +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(history): bounded TSV history log`. + +### Task A5: Bundle.toml read/write + +**Files:** +- Create: `src/crux_cli/bundle.py` +- Create: `tests/unit/test_bundle.py` + +- [ ] **Step 1: Failing test** + +```python +from pathlib import Path +from crux_cli.bundle import load_bundle, save_bundle, default_bundle + + +def test_default_bundle_roundtrips(tmp_path: Path): + save_bundle(tmp_path, default_bundle("foo", "v1")) + b = load_bundle(tmp_path) + assert b["harness"]["name"] == "foo" + assert b["harness"]["version"] == "v1" + assert b["skills"]["include"] == [] + assert b["mcps"]["include"] == [] + assert b["plugins"]["include"] == [] + + +def test_add_remove_skill(tmp_path: Path): + save_bundle(tmp_path, default_bundle("x", "v1")) + b = load_bundle(tmp_path) + b["skills"]["include"].extend(["a", "b"]) + save_bundle(tmp_path, b) + reloaded = load_bundle(tmp_path) + assert reloaded["skills"]["include"] == ["a", "b"] +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""bundle.toml read/write for harnesses.""" +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from crux_cli.tomlio import dump_toml, load_toml + + +def default_bundle(name: str, version: str, description: str = "") -> dict[str, Any]: + return { + "harness": {"name": name, "version": version, "description": description}, + "skills": {"include": []}, + "mcps": {"include": []}, + "plugins": {"include": []}, + "hooks": {}, + } + + +def load_bundle(harness_dir: Path) -> dict[str, Any]: + data = load_toml(harness_dir / "bundle.toml") + data.setdefault("skills", {"include": []}) + data.setdefault("mcps", {"include": []}) + data.setdefault("plugins", {"include": []}) + data.setdefault("hooks", {}) + data["skills"].setdefault("include", []) + data["mcps"].setdefault("include", []) + data["plugins"].setdefault("include", []) + return data + + +def save_bundle(harness_dir: Path, data: dict[str, Any]) -> None: + harness_dir.mkdir(parents=True, exist_ok=True) + dump_toml(harness_dir / "bundle.toml", data) +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(bundle): bundle.toml reader/writer with defaults`. + +### Task A6: Store — primitive lookups + +**Files:** +- Create: `src/crux_cli/store.py` +- Create: `tests/unit/test_store.py` + +- [ ] **Step 1: Failing test** + +```python +import pytest +from crux_cli import store +from crux_cli.bundle import default_bundle, save_bundle + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + + +def test_list_harness_versions(tmp_path): + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v1", default_bundle("foo", "v1")) + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v2", default_bundle("foo", "v2")) + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v10", default_bundle("foo", "v10")) + assert store.harness_versions("foo") == ["v1", "v2", "v10"] + assert store.latest_version("foo") == "v10" + + +def test_list_harnesses_empty(): + assert store.list_harnesses() == [] + + +def test_mcp_save_load(tmp_path): + store.save_mcp_entry("filesystem", {"type": "npm", "command": "npx", "args": ["x"]}) + assert store.load_mcp_entry("filesystem") == {"type": "npm", "command": "npx", "args": ["x"]} + assert "filesystem" in store.list_mcps() + + +def test_skill_dir(tmp_path): + (tmp_path / "registry" / "skills" / "myskill").mkdir(parents=True) + assert "myskill" in store.list_skills() + assert store.skill_dir("myskill").name == "myskill" + + +def test_plugin_versions(tmp_path): + (tmp_path / "registry" / "plugins" / "p" / "v1").mkdir(parents=True) + (tmp_path / "registry" / "plugins" / "p" / "v2").mkdir(parents=True) + assert store.plugin_versions("p") == ["v1", "v2"] +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""store.py — read-only and read/write access to the registry tree.""" +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from crux_cli import paths +from crux_cli.tomlio import dump_toml, load_toml + +_VERSION_RE = re.compile(r"^v(\d+)$") + + +def _version_sort_key(v: str) -> tuple[int, str]: + m = _VERSION_RE.match(v) + return (int(m.group(1)), v) if m else (10**9, v) + + +def _list_subdirs(root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted([p.name for p in root.iterdir() if p.is_dir()]) + + +def list_mcps() -> list[str]: + return _list_subdirs(paths.mcps_root()) + + +def list_skills() -> list[str]: + return _list_subdirs(paths.skills_root()) + + +def list_plugins() -> list[str]: + return _list_subdirs(paths.plugins_root()) + + +def list_harnesses() -> list[str]: + return _list_subdirs(paths.harnesses_root()) + + +def harness_versions(name: str) -> list[str]: + root = paths.harnesses_root() / name + versions = _list_subdirs(root) + return sorted(versions, key=_version_sort_key) + + +def latest_version(name: str) -> str | None: + vs = harness_versions(name) + return vs[-1] if vs else None + + +def next_version(name: str) -> str: + latest = latest_version(name) + if not latest: + return "v1" + m = _VERSION_RE.match(latest) + return f"v{int(m.group(1)) + 1}" if m else "v1" + + +def harness_dir(name: str, version: str | None = None) -> Path: + if version is None: + version = latest_version(name) or "" + return paths.harnesses_root() / name / version + + +def plugin_versions(name: str) -> list[str]: + root = paths.plugins_root() / name + versions = _list_subdirs(root) + return sorted(versions, key=_version_sort_key) + + +def plugin_dir(name: str, version: str | None = None) -> Path: + if version is None: + vs = plugin_versions(name) + version = vs[-1] if vs else "" + return paths.plugins_root() / name / version + + +def skill_dir(name: str) -> Path: + return paths.skills_root() / name + + +def mcp_dir(name: str) -> Path: + return paths.mcps_root() / name + + +def mcp_toml_path(name: str) -> Path: + return mcp_dir(name) / "mcp.toml" + + +def load_mcp_entry(name: str) -> dict[str, Any]: + return load_toml(mcp_toml_path(name)) + + +def save_mcp_entry(name: str, data: dict[str, Any]) -> None: + mcp_dir(name).mkdir(parents=True, exist_ok=True) + dump_toml(mcp_toml_path(name), data) +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(store): registry tree primitive lookups`. + +--- + +## Phase B — Activation & deployment + +### Task B1: Symlink deployment planner + +**Files:** +- Create: `src/crux_cli/activation.py` +- Create: `tests/unit/test_activation.py` + +- [ ] **Step 1: Failing test (planning only — no fs writes)** + +```python +from pathlib import Path +import pytest +from crux_cli import paths +from crux_cli.bundle import default_bundle, save_bundle +from crux_cli.activation import plan_symlinks + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + + +def test_plan_includes_claude_md(tmp_path): + hdir = paths.harnesses_root() / "h" / "v1" + save_bundle(hdir, default_bundle("h", "v1")) + (hdir / "CLAUDE.md").write_text("# h v1\n") + plan = plan_symlinks("h", "v1", scope_target=tmp_path / "home" / ".claude") + assert (tmp_path / "home" / ".claude" / "CLAUDE.md", hdir / "CLAUDE.md") in plan + + +def test_plan_includes_skills_plugins_hooks(tmp_path): + (paths.skills_root() / "s").mkdir(parents=True) + (paths.plugins_root() / "p" / "v2").mkdir(parents=True) + hdir = paths.harnesses_root() / "h" / "v1" + bundle = default_bundle("h", "v1") + bundle["skills"]["include"] = ["s"] + bundle["plugins"]["include"] = ["p@v2"] + bundle["hooks"] = {"pre_tool_use": "hooks/pre.sh"} + save_bundle(hdir, bundle) + (hdir / "CLAUDE.md").write_text("# h\n") + (hdir / "hooks").mkdir() + (hdir / "hooks" / "pre.sh").write_text("#!/bin/sh\n") + target = tmp_path / "home" / ".claude" + plan = plan_symlinks("h", "v1", scope_target=target) + sources = {src for _, src in plan} + assert paths.skills_root() / "s" in sources + assert paths.plugins_root() / "p" / "v2" in sources + assert hdir / "hooks" / "pre.sh" in sources +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement planner in `activation.py`** + +```python +"""Activation: turn a harness bundle into a symlink plan and deploy it.""" +from __future__ import annotations + +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle + + +def plan_symlinks(name: str, version: str, scope_target: Path) -> list[tuple[Path, Path]]: + """Return [(symlink_path, real_source_path), ...] for a bundle.""" + hdir = store.harness_dir(name, version) + bundle = load_bundle(hdir) + plan: list[tuple[Path, Path]] = [] + + claude_md = hdir / "CLAUDE.md" + if claude_md.exists(): + plan.append((scope_target / "CLAUDE.md", claude_md)) + + for skill in bundle.get("skills", {}).get("include", []): + plan.append((scope_target / "skills" / skill, store.skill_dir(skill))) + + for plugin_ref in bundle.get("plugins", {}).get("include", []): + if "@" in plugin_ref: + pname, pver = plugin_ref.split("@", 1) + else: + pname, pver = plugin_ref, None + plan.append((scope_target / "plugins" / pname, store.plugin_dir(pname, pver))) + + hooks = bundle.get("hooks", {}) or {} + for _key, rel in hooks.items(): + src = hdir / rel + plan.append((scope_target / "hooks" / Path(rel).name, src)) + + return plan +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(activation): plan_symlinks for harness bundle`. + +### Task B2: Symlink writer with conflict detection + +**Files:** +- Modify: `src/crux_cli/activation.py` +- Modify: `tests/unit/test_activation.py` + +- [ ] **Step 1: Failing tests** + +```python +from crux_cli.activation import apply_plan, ConflictError + + +def test_apply_creates_links(tmp_path): + src = tmp_path / "src"; src.mkdir() + target = tmp_path / "t" / "link" + apply_plan([(target, src)]) + assert target.is_symlink() and target.resolve() == src.resolve() + + +def test_apply_replaces_existing_crux_symlink(tmp_path): + src_a = tmp_path / "a"; src_a.mkdir() + src_b = tmp_path / "b"; src_b.mkdir() + target = tmp_path / "t" / "link" + apply_plan([(target, src_a)]) + apply_plan([(target, src_b)], known_registry_root=tmp_path) + assert target.resolve() == src_b.resolve() + + +def test_apply_rejects_regular_file(tmp_path): + target = tmp_path / "t" / "link" + target.parent.mkdir(parents=True) + target.write_text("hello") + import pytest + with pytest.raises(ConflictError): + apply_plan([(target, tmp_path / "src")], known_registry_root=tmp_path) + + +def test_apply_rejects_foreign_symlink(tmp_path): + foreign = tmp_path / "foreign"; foreign.mkdir() + target = tmp_path / "t" / "link" + target.parent.mkdir(parents=True) + target.symlink_to(foreign) + import pytest + with pytest.raises(ConflictError): + apply_plan([(target, tmp_path / "src")], known_registry_root=tmp_path / "registry") +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +class ConflictError(RuntimeError): + """A non-Crux file/symlink blocks the target path.""" + + +def _is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +def apply_plan(plan: list[tuple[Path, Path]], known_registry_root: Path | None = None) -> None: + """Create the symlinks. Refuses to clobber regular files or foreign symlinks. + + `known_registry_root` defines what counts as "Crux-owned" (a symlink whose + resolved target lives under this path may be replaced). Defaults to + ``paths.registry_root()``. + """ + if known_registry_root is None: + known_registry_root = paths.registry_root() + + for target, src in plan: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + try: + resolved = target.resolve(strict=False) + except OSError: + resolved = None + if resolved and _is_under(resolved, known_registry_root): + target.unlink() + else: + raise ConflictError(f"refusing to overwrite foreign symlink: {target}") + elif target.exists(): + raise ConflictError(f"refusing to overwrite existing file: {target}") + target.symlink_to(src) +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(activation): apply_plan with conflict detection`. + +### Task B3: `.mcp.json` generation from a bundle + +**Files:** +- Create: `src/crux_cli/mcp_emit.py` +- Create: `tests/unit/test_mcp_emit.py` + +- [ ] **Step 1: Failing test** + +```python +import json +from pathlib import Path +import pytest +from crux_cli import store, paths +from crux_cli.bundle import default_bundle, save_bundle +from crux_cli.mcp_emit import emit_mcp_json + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + + +def test_emit_npm_mcp(tmp_path): + store.save_mcp_entry("fs", {"type": "npm", "command": "npx", "args": ["-y", "@x/fs"]}) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + data = json.loads(out.read_text()) + assert data["mcpServers"]["fs"]["command"] == "npx" + assert data["mcpServers"]["fs"]["args"] == ["-y", "@x/fs"] + + +def test_emit_keychain_wrapped(tmp_path): + store.save_mcp_entry("wikijs", { + "type": "npm", "command": "npx", "args": ["-y", "wikijs-mcp"], + "auth": {"type": "keychain", "env_vars": ["API_KEY"]}, + }) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1"); b["mcps"]["include"] = ["wikijs"]; save_bundle(hdir, b) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + data = json.loads(out.read_text()) + e = data["mcpServers"]["wikijs"] + assert e["env"]["CRUX_MCP_NAME"] == "wikijs" + assert e["env"]["CRUX_AUTH_ENV_VARS"] == "API_KEY" + assert e["command"].endswith("keychain-auth.sh") +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""Generate .mcp.json from the MCPs included in a harness bundle.""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Any + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle + + +def _build_entry(name: str, data: dict[str, Any]) -> dict[str, Any]: + auth = data.get("auth", {}) or {} + auth_type = auth.get("type", "") + command = data.get("command", "") + args = list(data.get("args", [])) + + if data.get("type") == "http" or data.get("url"): + launcher = str(paths.crux_home() / "launchers" / "http-bridge-auth.sh") + env = { + "CRUX_MCP_NAME": name, + "CRUX_BRIDGE_URL": data.get("url", ""), + } + if auth_type == "bearer": + env["CRUX_BRIDGE_AUTH_HEADER"] = auth.get("header_name", "Authorization") + env["CRUX_BRIDGE_AUTH_PREFIX"] = auth.get("header_prefix", "Bearer") + env["CRUX_BRIDGE_AUTH_ENV"] = "CRUX_AUTH_TOKEN" + env["CRUX_AUTH_KEYCHAIN_KEY"] = auth.get("keychain_key", "API_TOKEN") + return {"command": launcher, "args": [], "env": env} + + if auth_type == "keychain": + launcher = str(paths.crux_home() / "launchers" / "keychain-auth.sh") + return { + "command": launcher, + "args": [command, *args], + "env": { + "CRUX_MCP_NAME": name, + "CRUX_AUTH_ENV_VARS": ",".join(auth.get("env_vars", [])), + }, + } + + entry: dict[str, Any] = {"command": command, "args": args} + if data.get("env"): + entry["env"] = data["env"] + return entry + + +def emit_mcp_json(harness_name: str, version: str, out_path: Path) -> None: + hdir = store.harness_dir(harness_name, version) + bundle = load_bundle(hdir) + servers: dict[str, Any] = {} + for name in bundle.get("mcps", {}).get("include", []): + servers[name] = _build_entry(name, store.load_mcp_entry(name)) + + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=out_path.parent, suffix=".tmp") + try: + with open(fd, "w") as f: + json.dump({"mcpServers": servers}, f, indent=2) + Path(tmp).replace(out_path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(mcp_emit): generate .mcp.json from harness bundle`. + +### Task B4: Top-level `activate()` orchestrator + +**Files:** +- Modify: `src/crux_cli/activation.py` +- Modify: `tests/unit/test_activation.py` + +- [ ] **Step 1: Failing test (end-to-end on a fake home)** + +```python +def test_activate_user_scope(tmp_path, monkeypatch): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + from crux_cli import paths, store + from crux_cli.bundle import default_bundle, save_bundle + from crux_cli.activation import activate + + # Seed registry + store.save_mcp_entry("fs", {"type": "npm", "command": "npx", "args": ["-y", "@x/fs"]}) + (paths.skills_root() / "s").mkdir(parents=True) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["skills"]["include"] = ["s"] + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + (hdir / "CLAUDE.md").write_text("# h\n") + + activate("h", "v1", scope="user", cwd=tmp_path) + + claude_home = tmp_path / "home" / ".claude" + assert (claude_home / "CLAUDE.md").resolve() == (hdir / "CLAUDE.md").resolve() + assert (claude_home / "skills" / "s").resolve() == (paths.skills_root() / "s").resolve() + import json + data = json.loads((claude_home / ".mcp.json").read_text()) + assert "fs" in data["mcpServers"] +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement `activate()` and `deactivate()`** + +```python +from crux_cli import history +from crux_cli.mcp_emit import emit_mcp_json + + +def _scope_target(scope: str, cwd: Path) -> Path: + return paths.claude_user_dir() if scope == "user" else paths.claude_dir_for(cwd) + + +def activate(name: str, version: str, scope: str, cwd: Path) -> None: + target = _scope_target(scope, cwd) + target.mkdir(parents=True, exist_ok=True) + plan = plan_symlinks(name, version, scope_target=target) + apply_plan(plan) + emit_mcp_json(name, version, out_path=target / ".mcp.json") +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(activation): top-level activate()`. + +--- + +## Phase C — Harness lifecycle + +### Task C1: `new` and `bump` + +**Files:** +- Create: `src/crux_cli/harness_ops.py` +- Create: `tests/unit/test_harness_ops.py` + +- [ ] **Step 1: Failing test** + +```python +import pytest +from crux_cli import store +from crux_cli.harness_ops import new_harness, bump + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + + +def test_new_creates_v1(tmp_path): + hdir = new_harness("foo") + assert hdir.name == "v1" + assert (hdir / "bundle.toml").exists() + assert (hdir / "CLAUDE.md").exists() + + +def test_new_collides(tmp_path): + new_harness("foo") + with pytest.raises(FileExistsError): + new_harness("foo") + + +def test_bump_copies_latest(tmp_path): + hdir = new_harness("foo") + (hdir / "CLAUDE.md").write_text("hi v1\n") + nxt = bump("foo") + assert nxt.name == "v2" + assert (nxt / "CLAUDE.md").read_text() == "hi v1\n" + assert store.harness_versions("foo") == ["v1", "v2"] + + +def test_bump_missing(tmp_path): + with pytest.raises(FileNotFoundError): + bump("nope") +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""Harness lifecycle operations: new, bump.""" +from __future__ import annotations + +import shutil +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.bundle import default_bundle, save_bundle + + +def new_harness(name: str, description: str = "") -> Path: + target = paths.harnesses_root() / name + if target.exists(): + raise FileExistsError(f"harness '{name}' already exists") + v1 = target / "v1" + save_bundle(v1, default_bundle(name, "v1", description)) + (v1 / "CLAUDE.md").write_text(f"# {name} v1\n\n") + (v1 / "hooks").mkdir(exist_ok=True) + return v1 + + +def bump(name: str) -> Path: + latest = store.latest_version(name) + if latest is None: + raise FileNotFoundError(f"harness '{name}' not found") + nxt_version = store.next_version(name) + src = paths.harnesses_root() / name / latest + dst = paths.harnesses_root() / name / nxt_version + shutil.copytree(src, dst) + bundle_path = dst / "bundle.toml" + from crux_cli.tomlio import dump_toml, load_toml + data = load_toml(bundle_path) + data.setdefault("harness", {})["version"] = nxt_version + dump_toml(bundle_path, data) + return dst +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(harness_ops): new and bump`. + +--- + +## Phase D — Registry primitives + +### Task D1: `registry add mcp` + +**Files:** +- Create: `src/crux_cli/registry_ops.py` +- Create: `tests/unit/test_registry_ops.py` + +- [ ] **Step 1: Failing test** + +```python +import pytest +from crux_cli import store +from crux_cli.registry_ops import add_mcp, add_skill_local, add_plugin_local, remove + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + + +def test_add_mcp_npm(tmp_path, monkeypatch): + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + add_mcp("fs", source_kind="npm", source="@modelcontextprotocol/server-filesystem", skip_install=False) + data = store.load_mcp_entry("fs") + assert data["type"] == "npm" + assert data["command"] == "npx" + assert "@modelcontextprotocol/server-filesystem" in data["args"] + + +def test_add_mcp_with_keychain(tmp_path, monkeypatch): + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + add_mcp("wikijs", source_kind="npm", source="wikijs-mcp", keychain=["API_KEY"]) + data = store.load_mcp_entry("wikijs") + assert data["auth"]["type"] == "keychain" + assert data["auth"]["env_vars"] == ["API_KEY"] + + +def test_add_mcp_collision(tmp_path, monkeypatch): + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + add_mcp("fs", source_kind="npm", source="x") + with pytest.raises(FileExistsError): + add_mcp("fs", source_kind="npm", source="x") + + +def test_add_skill_local(tmp_path): + src = tmp_path / "myskill" + src.mkdir() + (src / "SKILL.md").write_text("hello") + add_skill_local("myskill", src) + assert "myskill" in store.list_skills() + assert (store.skill_dir("myskill") / "SKILL.md").exists() + + +def test_add_plugin_local(tmp_path): + src = tmp_path / "p" + src.mkdir() + (src / "plugin.toml").write_text("hello") + add_plugin_local("p", src, version="v1") + assert store.plugin_versions("p") == ["v1"] + + +def test_remove_unreferenced(tmp_path, monkeypatch): + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + add_mcp("fs", source_kind="npm", source="x") + remove("fs", force=False) + assert "fs" not in store.list_mcps() + + +def test_remove_referenced_requires_force(tmp_path, monkeypatch): + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + add_mcp("fs", source_kind="npm", source="x") + from crux_cli.harness_ops import new_harness + from crux_cli.bundle import load_bundle, save_bundle + hdir = new_harness("h") + b = load_bundle(hdir); b["mcps"]["include"] = ["fs"]; save_bundle(hdir, b) + with pytest.raises(RuntimeError): + remove("fs", force=False) + remove("fs", force=True) + assert "fs" not in store.list_mcps() +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""registry_ops.py — add and remove registry primitives.""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle +from crux_cli.install import install_npm_package, install_uv_package + + +def _referencing_harnesses(name: str, kind: str) -> list[str]: + refs: list[str] = [] + for harness in store.list_harnesses(): + for version in store.harness_versions(harness): + try: + bundle = load_bundle(store.harness_dir(harness, version)) + except Exception: + continue + included = bundle.get(kind, {}).get("include", []) or [] + base_names = [r.split("@", 1)[0] for r in included] + if name in base_names: + refs.append(f"{harness}@{version}") + return refs + + +def add_mcp( + name: str, + *, + source_kind: str, + source: str, + args: list[str] | None = None, + keychain: list[str] | None = None, + skip_install: bool = False, +) -> None: + if (store.mcp_dir(name)).exists(): + raise FileExistsError(f"mcp '{name}' already exists") + + entry: dict = {"type": source_kind, "source": source} + if source_kind == "npm": + if not skip_install: + ok, err = install_npm_package(source) + if not ok: + raise RuntimeError(err) + entry.update({"command": "npx", "args": ["-y", source, *(args or [])]}) + elif source_kind == "uvx": + if not skip_install: + ok, err = install_uv_package(source) + if not ok: + raise RuntimeError(err) + entry.update({"command": "uvx", "args": [source, *(args or [])]}) + elif source_kind == "github": + dest = store.mcp_dir(name) / "source" + dest.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "clone", f"https://github.com/{source}", str(dest)], check=True) # noqa: S603,S607 + entry.update({"command": "", "args": args or [], "source_dir": str(dest)}) + elif source_kind == "local": + entry.update({"source_dir": source, "args": args or []}) + else: + raise ValueError(f"unknown source_kind: {source_kind}") + + if keychain: + entry["auth"] = {"type": "keychain", "env_vars": list(keychain)} + + store.save_mcp_entry(name, entry) + + +def add_skill_local(name: str, src: Path) -> None: + if not src.exists() or not src.is_dir(): + raise FileNotFoundError(src) + dst = store.skill_dir(name) + if dst.exists(): + raise FileExistsError(f"skill '{name}' already exists") + shutil.copytree(src, dst) + + +def add_skill_github(name: str, repo: str) -> None: + dst = store.skill_dir(name) + if dst.exists(): + raise FileExistsError(f"skill '{name}' already exists") + dst.parent.mkdir(parents=True, exist_ok=True) + subprocess.run(["git", "clone", f"https://github.com/{repo}", str(dst)], check=True) # noqa: S603,S607 + + +def add_plugin_local(name: str, src: Path, *, version: str = "v1") -> None: + dst = store.plugin_dir(name, version) + if dst.exists(): + raise FileExistsError(f"plugin '{name}@{version}' already exists") + shutil.copytree(src, dst) + + +def remove(name: str, *, force: bool) -> None: + targets: list[tuple[str, Path]] = [] + if store.mcp_dir(name).exists(): + targets.append(("mcps", store.mcp_dir(name))) + if store.skill_dir(name).exists(): + targets.append(("skills", store.skill_dir(name))) + plugin_root = paths.plugins_root() / name + if plugin_root.exists(): + targets.append(("plugins", plugin_root)) + if not targets: + raise FileNotFoundError(name) + if not force: + for kind, _ in targets: + refs = _referencing_harnesses(name, kind) + if refs: + raise RuntimeError(f"'{name}' referenced by: {', '.join(refs)} — pass force=True") + for _kind, path in targets: + shutil.rmtree(path) +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(registry_ops): add and remove primitives`. + +--- + +## Phase E — Migration from v1.x + +### Task E1: `migrate` command + +**Files:** +- Create: `src/crux_cli/migrate_v1.py` +- Create: `tests/unit/test_migrate_v1.py` + +- [ ] **Step 1: Failing test** + +```python +import json +import pytest +from pathlib import Path +from crux_cli import store +from crux_cli.migrate_v1 import migrate_cwd + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / "crux_home")) + + +def test_migrate_basic(tmp_path): + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"name": "myproj", "mcps": ["fs"], "skills": ["s"]})) + migrate_cwd(proj) + assert not (proj / "crux.json").exists() + assert (proj / "crux.toml").exists() + versions = store.harness_versions("myproj") + assert versions == ["v1"] + from crux_cli.bundle import load_bundle + b = load_bundle(store.harness_dir("myproj", "v1")) + assert b["mcps"]["include"] == ["fs"] + assert b["skills"]["include"] == ["s"] + + +def test_migrate_collision(tmp_path): + from crux_cli.harness_ops import new_harness + new_harness("myproj") + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"name": "myproj", "mcps": [], "skills": []})) + with pytest.raises(FileExistsError): + migrate_cwd(proj) + + +def test_migrate_no_crux_json(tmp_path): + with pytest.raises(FileNotFoundError): + migrate_cwd(tmp_path) +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement** + +```python +"""Migrate a v1 crux.json project to a v2 harness + pointer.""" +from __future__ import annotations + +import json +from pathlib import Path + +from crux_cli.bundle import load_bundle, save_bundle +from crux_cli.harness_ops import new_harness +from crux_cli.pointer import write_pointer + + +def migrate_cwd(project_dir: Path, *, name: str | None = None) -> str: + crux_json = project_dir / "crux.json" + if not crux_json.exists(): + raise FileNotFoundError(crux_json) + data = json.loads(crux_json.read_text()) + + harness_name = name or data.get("name") or project_dir.name + hdir = new_harness(harness_name) + bundle = load_bundle(hdir) + bundle["mcps"]["include"] = list(data.get("mcps", [])) + bundle["skills"]["include"] = list(data.get("skills", [])) + save_bundle(hdir, bundle) + + write_pointer(project_dir / "crux.toml", f"{harness_name}@v1") + crux_json.unlink() + return harness_name +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(migrate): v1 crux.json → v2 harness + pointer`. + +--- + +## Phase F — Setup + +### Task F1: Setup module + +**Files:** +- Modify: `src/crux_cli/setup_crux.py` → trim to v2 logic (or replace with new `setup.py`) +- Create: `src/crux_cli/setup.py` +- Create: `tests/unit/test_setup_v2.py` + +- [ ] **Step 1: Failing test** + +```python +import pytest +from crux_cli import paths +from crux_cli.setup import run_setup + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + + +def test_run_setup_creates_tree(tmp_path): + res = run_setup() + for d in [paths.crux_home(), paths.registry_root(), paths.mcps_root(), + paths.skills_root(), paths.plugins_root(), paths.harnesses_root(), + paths.crux_home() / "launchers"]: + assert d.exists() + assert res.dirs_created +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement `src/crux_cli/setup.py`** + +```python +"""v2 setup: create registry tree, install bundled crux skill, install launchers.""" +from __future__ import annotations + +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +from crux_cli import paths +from crux_cli.config import default_config, save_config + +_BUNDLED_SKILL = Path(__file__).resolve().parent / "data" / "skills" / "crux" / "SKILL.md" +_BUNDLED_LAUNCHERS = Path(__file__).resolve().parent / "data" / "launchers" + + +@dataclass +class SetupResult: + dirs_created: list[str] = field(default_factory=list) + config_written: bool = False + skill_installed: bool = False + launchers_installed: list[str] = field(default_factory=list) + + +def run_setup() -> SetupResult: + res = SetupResult() + + for d in [ + paths.crux_home(), + paths.registry_root(), + paths.mcps_root(), + paths.skills_root(), + paths.plugins_root(), + paths.harnesses_root(), + paths.crux_home() / "launchers", + ]: + if not d.exists(): + d.mkdir(parents=True, exist_ok=True) + res.dirs_created.append(str(d)) + + cfg_path = paths.crux_home() / "config.toml" + if not cfg_path.exists(): + save_config(default_config(), path=cfg_path) + res.config_written = True + + skill_dst = paths.skills_root() / "crux" + skill_dst.mkdir(parents=True, exist_ok=True) + if _BUNDLED_SKILL.exists(): + shutil.copy2(_BUNDLED_SKILL, skill_dst / "SKILL.md") + res.skill_installed = True + + if _BUNDLED_LAUNCHERS.is_dir(): + target = paths.crux_home() / "launchers" + for script in _BUNDLED_LAUNCHERS.glob("*.sh"): + dst = target / script.name + shutil.copy2(script, dst) + dst.chmod(0o755) + res.launchers_installed.append(script.name) + + return res +``` + +- [ ] **Step 4: Verify pass**. + +- [ ] **Step 5: Commit** — `feat(setup): v2 setup creates registry tree and installs skill`. + +--- + +## Phase G — CLI surface + +### Task G1: New `cli/main.py` with v2 command surface + +**Files:** +- Replace: `src/crux_cli/cli/main.py` +- Create: `src/crux_cli/cli/commands/setup_cmd.py` +- Create: `src/crux_cli/cli/commands/doctor_cmd.py` +- Create: `src/crux_cli/cli/commands/migrate_cmd.py` +- Create: `src/crux_cli/cli/commands/registry_cmd.py` +- Create: `src/crux_cli/cli/commands/secret_cmd.py` +- Create: `src/crux_cli/cli/commands/harness_cmd.py` +- Create: `src/crux_cli/cli/commands/use_cmd.py` +- Create: `tests/integration/test_cli_v2.py` + +- [ ] **Step 1: Failing integration test** + +```python +import json +import os +import subprocess +import sys +import pytest + + +def _run(args, env_root, cwd=None): + env = os.environ.copy() + env["CRUX_TEST_ROOT"] = str(env_root) + env["HOME"] = str(env_root / "home") + (env_root / "home").mkdir(exist_ok=True) + return subprocess.run( + [sys.executable, "-m", "crux_cli", *args], + capture_output=True, text=True, env=env, cwd=cwd, + ) + + +def test_setup_and_new(tmp_path): + r = _run(["setup"], tmp_path) + assert r.returncode == 0, r.stderr + r = _run(["new", "coding"], tmp_path) + assert r.returncode == 0, r.stderr + assert (tmp_path / "registry" / "harnesses" / "coding" / "v1" / "bundle.toml").exists() + + +def test_use_user_scope(tmp_path): + _run(["setup"], tmp_path) + _run(["new", "coding"], tmp_path) + r = _run(["use", "coding", "--user"], tmp_path) + assert r.returncode == 0, r.stderr + home_claude = tmp_path / "home" / ".claude" + assert (home_claude / "CLAUDE.md").is_symlink() + # active pointer set + pointer = (tmp_path / "active.toml").read_text() + assert "coding" in pointer + + +def test_active_command(tmp_path): + _run(["setup"], tmp_path) + _run(["new", "coding"], tmp_path) + _run(["use", "coding", "--user"], tmp_path) + r = _run(["active"], tmp_path, cwd=tmp_path) + assert r.returncode == 0 + assert "coding" in r.stdout +``` + +- [ ] **Step 2: Run** — failure expected. + +- [ ] **Step 3: Implement `crux_cli/__init__.py` module entry** + +```python +# src/crux_cli/__main__.py +from crux_cli.cli.main import main +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Implement `cli/main.py`** + +```python +"""crux v2 CLI entry point.""" +from __future__ import annotations + +import argparse +import sys + + +def main() -> None: + from crux_cli.cli.commands.doctor_cmd import cmd_doctor + from crux_cli.cli.commands.harness_cmd import ( + cmd_bump, cmd_edit, cmd_list, cmd_new, cmd_show, + ) + from crux_cli.cli.commands.migrate_cmd import cmd_migrate + from crux_cli.cli.commands.registry_cmd import ( + cmd_registry_add, cmd_registry_list, cmd_registry_remove, + ) + from crux_cli.cli.commands.secret_cmd import ( + cmd_secret_list, cmd_secret_remove, cmd_secret_set, + ) + from crux_cli.cli.commands.setup_cmd import cmd_setup + from crux_cli.cli.commands.use_cmd import cmd_active, cmd_use + + p = argparse.ArgumentParser(prog="crux", description="Crux — Harness manager for Claude Code") + sub = p.add_subparsers(dest="command", required=True) + + sub.add_parser("setup").set_defaults(func=cmd_setup) + sub.add_parser("doctor").set_defaults(func=cmd_doctor) + m = sub.add_parser("migrate") + m.add_argument("--name", help="Override harness name") + m.set_defaults(func=cmd_migrate) + + # registry + rp = sub.add_parser("registry") + rs = rp.add_subparsers(dest="reg_cmd", required=True) + ra = rs.add_parser("add") + ra.add_argument("kind", choices=["mcp", "skill", "plugin"]) + ra.add_argument("name") + ra.add_argument("source") + ra.add_argument("--npm", dest="npm", action="store_true") + ra.add_argument("--uvx", dest="uvx", action="store_true") + ra.add_argument("--github", dest="github", action="store_true") + ra.add_argument("--local", dest="local", action="store_true") + ra.add_argument("--keychain", help="Comma-separated env vars for keychain auth") + ra.add_argument("--args", help="Extra args, space-separated") + ra.add_argument("--skip-install", action="store_true") + ra.add_argument("--version", default="v1", help="Plugin version (default v1)") + ra.set_defaults(func=cmd_registry_add) + rr = rs.add_parser("remove") + rr.add_argument("name") + rr.add_argument("--force", action="store_true") + rr.set_defaults(func=cmd_registry_remove) + rl = rs.add_parser("list") + rl.set_defaults(func=cmd_registry_list) + + # secret + sp = sub.add_parser("secret") + ss = sp.add_subparsers(dest="secret_cmd", required=True) + sset = ss.add_parser("set") + sset.add_argument("mcp"); sset.add_argument("key") + sset.add_argument("--value", help="Value (otherwise prompted)") + sset.set_defaults(func=cmd_secret_set) + sl = ss.add_parser("list"); sl.add_argument("mcp", nargs="?") + sl.set_defaults(func=cmd_secret_list) + srm = ss.add_parser("remove"); srm.add_argument("mcp"); srm.add_argument("key") + srm.set_defaults(func=cmd_secret_remove) + + # harness lifecycle + n = sub.add_parser("new"); n.add_argument("name"); n.set_defaults(func=cmd_new) + b = sub.add_parser("bump"); b.add_argument("name"); b.set_defaults(func=cmd_bump) + li = sub.add_parser("list"); li.add_argument("name", nargs="?"); li.set_defaults(func=cmd_list) + sh = sub.add_parser("show"); sh.add_argument("ref"); sh.set_defaults(func=cmd_show) + + # edit + ep = sub.add_parser("edit") + es = ep.add_subparsers(dest="edit_what", required=True) + for what in ("claude", "skills", "mcps", "plugins", "hooks"): + sub_e = es.add_parser(what) + sub_e.add_argument("ref", nargs="?") + sub_e.add_argument("--add", action="append", default=[]) + sub_e.add_argument("--remove", action="append", default=[]) + sub_e.set_defaults(func=cmd_edit, edit_what=what) + + # activation + u = sub.add_parser("use") + u.add_argument("ref", help="Harness ref (use '-' for previous, '--none' to deactivate)", nargs="?") + u.add_argument("--user", action="store_true") + u.add_argument("--none", dest="none", action="store_true") + u.set_defaults(func=cmd_use) + sub.add_parser("active").set_defaults(func=cmd_active) + + args = p.parse_args() + try: + args.func(args) + except FileNotFoundError as e: + print(f"crux: not found: {e}", file=sys.stderr) + sys.exit(2) + except FileExistsError as e: + print(f"crux: exists: {e}", file=sys.stderr) + sys.exit(3) + except RuntimeError as e: + print(f"crux: {e}", file=sys.stderr) + sys.exit(3) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 5: Implement each command file (setup_cmd, doctor_cmd, migrate_cmd, registry_cmd, secret_cmd, harness_cmd, use_cmd).** + +Each command is a thin wrapper over the core modules. See command stubs: + +```python +# src/crux_cli/cli/commands/setup_cmd.py +from __future__ import annotations +import argparse +from crux_cli.setup import run_setup + + +def cmd_setup(args: argparse.Namespace) -> None: + res = run_setup() + print(f"setup: created {len(res.dirs_created)} dirs, " + f"skill={'ok' if res.skill_installed else 'skip'}, " + f"launchers={len(res.launchers_installed)}") +``` + +```python +# src/crux_cli/cli/commands/use_cmd.py +from __future__ import annotations +import argparse +import sys +from pathlib import Path + +from crux_cli import history, paths, store +from crux_cli.activation import activate +from crux_cli.pointer import parse_ref, read_pointer, resolve_active, write_pointer + + +def _pointer_path_for(scope: str, cwd: Path) -> Path: + return paths.active_pointer_path() if scope == "user" else cwd / "crux.toml" + + +def _history_for(scope: str, cwd: Path) -> Path: + return paths.history_path() if scope == "user" else cwd / ".crux" / "history" + + +def cmd_use(args: argparse.Namespace) -> None: + cwd = Path.cwd() + scope = "user" if args.user else "directory" + pointer = _pointer_path_for(scope, cwd) + + if args.none: + if pointer.exists(): + pointer.unlink() + print(f"use: deactivated ({scope})") + return + + if args.ref == "-": + prev = history.pop_previous(_history_for(scope, cwd)) + if not prev: + print("crux: use: no previous harness in history", file=sys.stderr) + sys.exit(3) + ref = prev + else: + ref = args.ref + + if not ref: + print("crux: use: missing harness reference", file=sys.stderr) + sys.exit(1) + + name, version = parse_ref(ref) + if version is None: + version = store.latest_version(name) + if not version: + raise FileNotFoundError(f"harness '{name}'") + + prev_parsed = read_pointer(pointer) + prev_ref = f"{prev_parsed[0]}@{prev_parsed[1] or store.latest_version(prev_parsed[0]) or 'v?'}" if prev_parsed else "" + + activate(name, version, scope=scope, cwd=cwd) + write_pointer(pointer, f"{name}@{version}") + history.append(_history_for(scope, cwd), prev=prev_ref or None, new=f"{name}@{version}") + print(f"use: {name}@{version} ({scope})") + + +def cmd_active(args: argparse.Namespace) -> None: + res = resolve_active(Path.cwd()) + if res is None: + print("active: (none)") + return + scope, name, version, pointer_path = res + print(f"active: {name}@{version or 'latest'} ({scope}, {pointer_path})") +``` + +```python +# src/crux_cli/cli/commands/harness_cmd.py +from __future__ import annotations +import argparse +import os +import subprocess +import sys +from pathlib import Path + +from crux_cli import store +from crux_cli.bundle import load_bundle, save_bundle +from crux_cli.harness_ops import bump, new_harness +from crux_cli.pointer import parse_ref + + +def cmd_new(args): + hdir = new_harness(args.name) + print(f"new: created {args.name}@v1 at {hdir}") + + +def cmd_bump(args): + hdir = bump(args.name) + print(f"bump: created {args.name}@{hdir.name}") + + +def cmd_list(args): + name = getattr(args, "name", None) + if name: + for v in store.harness_versions(name): + print(v) + else: + for h in store.list_harnesses(): + versions = store.harness_versions(h) + print(f"{h}: {', '.join(versions)}") + + +def _resolve_ref(ref): + name, version = parse_ref(ref) + if version is None: + version = store.latest_version(name) + if version is None: + raise FileNotFoundError(f"harness '{name}'") + return name, version + + +def cmd_show(args): + name, version = _resolve_ref(args.ref) + hdir = store.harness_dir(name, version) + bundle = load_bundle(hdir) + print(f"# {name}@{version}") + print(f"description: {bundle.get('harness', {}).get('description', '')}") + print(f"skills: {', '.join(bundle['skills']['include'])}") + print(f"mcps: {', '.join(bundle['mcps']['include'])}") + print(f"plugins: {', '.join(bundle['plugins']['include'])}") + hooks = bundle.get("hooks", {}) or {} + if hooks: + print("hooks:") + for k, v in hooks.items(): + print(f" {k} = {v}") + + +def cmd_edit(args): + ref = args.ref + if ref is None: + from crux_cli.pointer import resolve_active + res = resolve_active(Path.cwd()) + if res is None: + print("crux: edit: no active harness", file=sys.stderr) + sys.exit(2) + _scope, name, version, _ = res + if version is None: + version = store.latest_version(name) + else: + name, version = _resolve_ref(ref) + + hdir = store.harness_dir(name, version) + bundle = load_bundle(hdir) + + what = args.edit_what + if what == "claude": + editor = os.environ.get("EDITOR", "vi") + subprocess.run([editor, str(hdir / "CLAUDE.md")], check=False) # noqa: S603 + return + if what == "hooks": + (hdir / "hooks").mkdir(exist_ok=True) + editor = os.environ.get("EDITOR", "vi") + subprocess.run([editor, str(hdir / "hooks")], check=False) # noqa: S603 + return + + key = {"skills": "skills", "mcps": "mcps", "plugins": "plugins"}[what] + include = bundle[key]["include"] + for item in (args.add or []): + if item not in include: + include.append(item) + for item in (args.remove or []): + if item in include: + include.remove(item) + bundle[key]["include"] = include + save_bundle(hdir, bundle) + print(f"edit {what}: {', '.join(include)}") +``` + +```python +# src/crux_cli/cli/commands/registry_cmd.py +from __future__ import annotations +import argparse +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.registry_ops import ( + add_mcp, add_plugin_local, add_skill_github, add_skill_local, remove, +) + + +def cmd_registry_add(args): + extra_args = args.args.split() if getattr(args, "args", None) else [] + keychain = [v.strip() for v in args.keychain.split(",")] if getattr(args, "keychain", None) else None + if args.kind == "mcp": + if args.npm: + kind = "npm" + elif args.uvx: + kind = "uvx" + elif args.github: + kind = "github" + elif args.local: + kind = "local" + else: + kind = "npm" + add_mcp(args.name, source_kind=kind, source=args.source, args=extra_args, + keychain=keychain, skip_install=args.skip_install) + print(f"registry: added mcp {args.name}") + elif args.kind == "skill": + if args.github: + add_skill_github(args.name, args.source) + else: + add_skill_local(args.name, Path(args.source)) + print(f"registry: added skill {args.name}") + elif args.kind == "plugin": + add_plugin_local(args.name, Path(args.source), version=args.version) + print(f"registry: added plugin {args.name}@{args.version}") + + +def cmd_registry_remove(args): + remove(args.name, force=args.force) + print(f"registry: removed {args.name}") + + +def cmd_registry_list(args): + print("# mcps:") + for n in store.list_mcps(): print(f" {n}") + print("# skills:") + for n in store.list_skills(): print(f" {n}") + print("# plugins:") + for n in store.list_plugins(): + for v in store.plugin_versions(n): + print(f" {n}@{v}") +``` + +```python +# src/crux_cli/cli/commands/secret_cmd.py +from __future__ import annotations +import argparse +import getpass +import sys + +from crux_cli.secrets import load_secrets_index, save_secrets_index + + +def _backend(): + from crux_cli.secrets import get_backend + return get_backend() + + +def cmd_secret_set(args): + val = args.value or getpass.getpass(f"value for {args.mcp}/{args.key}: ") + _backend().set(args.mcp, args.key, val) + idx = load_secrets_index() + idx.setdefault(args.mcp, []) + if args.key not in idx[args.mcp]: + idx[args.mcp].append(args.key) + save_secrets_index(idx) + print(f"secret: set {args.mcp}/{args.key}") + + +def cmd_secret_list(args): + idx = load_secrets_index() + for mcp, keys in idx.items(): + if args.mcp and mcp != args.mcp: + continue + print(f"{mcp}: {', '.join(keys)}") + + +def cmd_secret_remove(args): + _backend().remove(args.mcp, args.key) + idx = load_secrets_index() + if args.mcp in idx: + idx[args.mcp] = [k for k in idx[args.mcp] if k != args.key] + if not idx[args.mcp]: + del idx[args.mcp] + save_secrets_index(idx) + print(f"secret: removed {args.mcp}/{args.key}") +``` + +```python +# src/crux_cli/cli/commands/migrate_cmd.py +from __future__ import annotations +import argparse +from pathlib import Path + +from crux_cli.migrate_v1 import migrate_cwd + + +def cmd_migrate(args): + name = migrate_cwd(Path.cwd(), name=getattr(args, "name", None)) + print(f"migrate: created harness {name}@v1 and crux.toml pointer") +``` + +```python +# src/crux_cli/cli/commands/doctor_cmd.py +from __future__ import annotations +import argparse +import shutil +import sys + +from crux_cli import paths + + +def cmd_doctor(args): + issues = 0 + for d in [paths.crux_home(), paths.registry_root(), paths.mcps_root(), + paths.skills_root(), paths.plugins_root(), paths.harnesses_root()]: + if not d.exists(): + print(f"missing: {d} — run `crux setup`") + issues += 1 + for tool in ("git", "uv", "npm", "claude"): + if shutil.which(tool) is None: + print(f"missing tool: {tool}") + issues += 1 + if issues: + sys.exit(4) + print("doctor: ok") +``` + +- [ ] **Step 6: Helper — secrets `get_backend()`** + +The existing `secrets.py` already exposes a backend selector — confirm `get_backend()` exists and exports cleanly. If not, add a thin shim. (See implementation phase — adapt as needed.) + +- [ ] **Step 7: Verify pass** — `pytest tests/integration/test_cli_v2.py -v`. + +- [ ] **Step 8: Commit** — `feat(cli): v2 command surface (setup/registry/secret/new/bump/list/show/edit/use/active/migrate/doctor)`. + +--- + +## Phase H — Remove v1 code + +### Task H1: Delete obsolete modules and tests + +**Files removed:** +- `src/crux_cli/manifest.py` +- `src/crux_cli/sync.py` +- `src/crux_cli/projects.py` +- `src/crux_cli/sandbox.py` +- `src/crux_cli/setup_crux.py` +- `src/crux_cli/cli/commands/{mcp,skill,project,task,version}.py` +- All tests with v1 module names (manifest, sync, projects, sandbox*, setup, mcp_install, mcp_remove_secrets, init, oauth/bridge/auth/preflight may be retained as they're still used). + +- [ ] **Step 1: Delete files** + +```bash +git rm src/crux_cli/manifest.py src/crux_cli/sync.py src/crux_cli/projects.py \ + src/crux_cli/sandbox.py src/crux_cli/setup_crux.py \ + src/crux_cli/cli/commands/mcp.py src/crux_cli/cli/commands/skill.py \ + src/crux_cli/cli/commands/project.py src/crux_cli/cli/commands/task.py \ + src/crux_cli/cli/commands/version.py src/crux_cli/cli/commands/doctor.py +git rm tests/unit/test_manifest.py tests/unit/test_sync.py tests/unit/test_projects.py \ + tests/unit/test_sandbox.py tests/unit/test_sandbox_extended.py \ + tests/unit/test_mcp_install.py tests/unit/test_mcp_remove_secrets.py \ + tests/unit/test_setup.py tests/unit/test_init.py +git rm tests/integration/test_cli_mcp.py tests/integration/test_cli_skill.py \ + tests/integration/test_cli_project_create.py tests/integration/test_cli_project_sync.py \ + tests/integration/test_cli_doctor.py tests/integration/test_cli_task.py \ + tests/integration/test_e2e_auth.py || true +git rm conftest.py tests/conftest.py tests/fixtures -r || true +``` + +- [ ] **Step 2: Re-add a minimal root `conftest.py` and `tests/conftest.py`** + +```python +# conftest.py +from __future__ import annotations +import os +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent + + +def pytest_configure(config): + src = str(REPO_ROOT / "src") + parts = [p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep) if p] + if src not in parts: + parts.insert(0, src) + os.environ["PYTHONPATH"] = os.pathsep.join(parts) +``` + +- [ ] **Step 3: Bump version** + +Edit `pyproject.toml` `version = "2.0.0"` and `src/crux_cli/version.py` accordingly. + +- [ ] **Step 4: Run full test suite** + +```bash +pytest tests/ -v +``` + +Expect: all green. + +- [ ] **Step 5: Commit** — `chore(v2): remove v1 modules and stale tests; bump to 2.0.0`. + +--- + +## Phase I — Docs & polish + +### Task I1: Update README and CLI docs + +**Files:** +- Modify: `README.md` +- Modify: `docs/...` (any references to v1 commands) + +- [ ] **Step 1: Update README's quickstart and command reference to match v2 surface** + +- [ ] **Step 2: Commit** — `docs: v2 README and CLI reference`. + +--- + +## Self-Review Checklist + +- Every command from the spec section "Command surface" has a CLI parser + handler: + - `setup`, `doctor`, `migrate`, `registry add/remove/list`, `secret set/list/remove`, + - `new`, `bump`, `list`, `show`, + - `edit claude/skills/mcps/plugins/hooks`, + - `use [-] [--none] [--user]`, `active`. +- Pointer file resolution implements cwd-walk → user fallback. +- `crux use -` reads history and re-runs activation. +- `.mcp.json` is generated (not symlinked) and includes keychain env-var refs. +- Symlink writer refuses to clobber regular files / foreign symlinks. +- Migration is cwd-only, creates harness named after `crux.json`'s name (or `--name`), deletes `crux.json`. +- All paths obey `CRUX_TEST_ROOT` for test isolation. +- Exit codes mapped (1 usage, 2 not-found, 3 state, 4 environment). diff --git a/src/crux_cli/bundle.py b/src/crux_cli/bundle.py new file mode 100644 index 0000000..c7ca1fc --- /dev/null +++ b/src/crux_cli/bundle.py @@ -0,0 +1,44 @@ +"""bundle.toml reader and writer for harnesses. + +A bundle declares the harness identity plus its references to skills, MCPs, +plugins, and hooks. ``load_bundle`` always returns the four sections so +callers don't need to guard against missing keys. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from crux_cli.tomlio import dump_toml, load_toml + + +def default_bundle(name: str, version: str, description: str = "") -> dict[str, Any]: + """Return a fresh bundle dict with empty include lists.""" + return { + "harness": {"name": name, "version": version, "description": description}, + "skills": {"include": []}, + "mcps": {"include": []}, + "plugins": {"include": []}, + "hooks": {}, + } + + +def load_bundle(harness_dir: Path) -> dict[str, Any]: + """Load bundle.toml, filling in default sections.""" + data = load_toml(harness_dir / "bundle.toml") + data.setdefault("harness", {}) + data.setdefault("skills", {}) + data.setdefault("mcps", {}) + data.setdefault("plugins", {}) + data.setdefault("hooks", {}) + data["skills"].setdefault("include", []) + data["mcps"].setdefault("include", []) + data["plugins"].setdefault("include", []) + return data + + +def save_bundle(harness_dir: Path, data: dict[str, Any]) -> None: + """Write bundle.toml atomically.""" + harness_dir.mkdir(parents=True, exist_ok=True) + dump_toml(harness_dir / "bundle.toml", data) diff --git a/src/crux_cli/history.py b/src/crux_cli/history.py new file mode 100644 index 0000000..f38b5b0 --- /dev/null +++ b/src/crux_cli/history.py @@ -0,0 +1,43 @@ +"""Append-only TSV history log of harness activations. + +Each row is ``\\t\\t``. The log is bounded +to ``MAX_ENTRIES`` rows. ``pop_previous`` returns the most recent row's +``previous_ref`` (the harness that was active before the latest activation). +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path + +MAX_ENTRIES = 100 + + +def append(history_file: Path, prev: str | None, new: str) -> None: + """Append an activation row, trimming the file to MAX_ENTRIES.""" + history_file.parent.mkdir(parents=True, exist_ok=True) + ts = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + rows = read_all(history_file) + rows.append((ts, prev or "", new)) + rows = rows[-MAX_ENTRIES:] + history_file.write_text("".join(f"{r[0]}\t{r[1]}\t{r[2]}\n" for r in rows)) + + +def read_all(history_file: Path) -> list[tuple[str, str, str]]: + """Return all rows as ``(timestamp, prev, new)`` tuples.""" + if not history_file.exists(): + return [] + rows: list[tuple[str, str, str]] = [] + for raw in history_file.read_text().splitlines(): + parts = raw.split("\t") + if len(parts) >= 3: + rows.append((parts[0], parts[1], parts[2])) + return rows + + +def pop_previous(history_file: Path) -> str | None: + """Return the ``previous_ref`` of the most recent activation, or None.""" + rows = read_all(history_file) + if not rows: + return None + return rows[-1][1] or None diff --git a/src/crux_cli/paths.py b/src/crux_cli/paths.py index 97bd308..08eaaab 100644 --- a/src/crux_cli/paths.py +++ b/src/crux_cli/paths.py @@ -78,3 +78,53 @@ def config_path() -> Path: def tokens_path() -> Path: """Path to the OAuth token metadata file.""" return crux_home() / "tokens.json" + + +# --------------------------------------------------------------------------- +# v2: harness manager layout +# --------------------------------------------------------------------------- + + +def registry_root() -> Path: + """Root of the v2 registry tree containing mcps, skills, plugins, harnesses.""" + return crux_home() / "registry" + + +def mcps_root() -> Path: + """v2 MCP registry directory: ~/.crux/registry/mcps/.""" + return registry_root() / "mcps" + + +def skills_root() -> Path: + """v2 skills registry directory: ~/.crux/registry/skills/.""" + return registry_root() / "skills" + + +def plugins_root() -> Path: + """v2 plugins registry directory: ~/.crux/registry/plugins/.""" + return registry_root() / "plugins" + + +def harnesses_root() -> Path: + """v2 harnesses registry directory: ~/.crux/registry/harnesses/.""" + return registry_root() / "harnesses" + + +def active_pointer_path() -> Path: + """User-level active harness pointer: ~/.crux/active.toml.""" + return crux_home() / "active.toml" + + +def history_path() -> Path: + """User-level activation history log: ~/.crux/history.""" + return crux_home() / "history" + + +def claude_user_dir() -> Path: + """User-level Claude Code directory: ~/.claude/.""" + return Path.home() / ".claude" + + +def claude_dir_for(project_dir: Path) -> Path: + """Directory-level Claude Code directory: /.claude/.""" + return project_dir / ".claude" diff --git a/src/crux_cli/pointer.py b/src/crux_cli/pointer.py new file mode 100644 index 0000000..bc93974 --- /dev/null +++ b/src/crux_cli/pointer.py @@ -0,0 +1,80 @@ +"""Pointer-file resolution. + +A pointer file is a TOML file with a single key naming the active harness:: + + harness = "coding-default@v7" + +Resolution order: +1. Walk up from cwd looking for ``crux.toml``. If found, that's the directory pointer. +2. Else read ``~/.crux/active.toml`` for a user-level pointer. +3. Else: no active harness. +""" + +from __future__ import annotations + +from pathlib import Path + +from crux_cli.paths import active_pointer_path +from crux_cli.tomlio import dump_toml, load_toml + + +def parse_ref(ref: str) -> tuple[str, str | None]: + """Parse ``name`` or ``name@version`` into a tuple. Raises ValueError on garbage.""" + if not ref or ref.startswith("@") or ref.endswith("@"): + raise ValueError(f"Invalid harness ref: {ref!r}") + if "@" in ref: + if ref.count("@") != 1: + raise ValueError(f"Invalid harness ref: {ref!r}") + name, version = ref.split("@", 1) + if not name or not version: + raise ValueError(f"Invalid harness ref: {ref!r}") + return name, version + return ref, None + + +def write_pointer(path: Path, harness_ref: str) -> None: + """Validate and write a pointer file.""" + parse_ref(harness_ref) + dump_toml(path, {"harness": harness_ref}) + + +def read_pointer(path: Path) -> tuple[str, str | None] | None: + """Read a pointer file; return (name, version) or None if absent/invalid.""" + if not path.exists(): + return None + data = load_toml(path) + ref = data.get("harness") + if not isinstance(ref, str): + return None + try: + return parse_ref(ref) + except ValueError: + return None + + +def _walk_up_for_pointer(start: Path) -> Path | None: + cur = start.resolve() + while True: + candidate = cur / "crux.toml" + if candidate.exists(): + return candidate + if cur.parent == cur: + return None + cur = cur.parent + + +def resolve_active(cwd: Path) -> tuple[str, str, str | None, Path] | None: + """Return ``(scope, name, version, pointer_path)`` or ``None``. + + ``scope`` is ``"directory"`` or ``"user"``. + """ + found = _walk_up_for_pointer(cwd) + if found is not None: + parsed = read_pointer(found) + if parsed is not None: + return ("directory", parsed[0], parsed[1], found) + user_pointer = active_pointer_path() + parsed = read_pointer(user_pointer) + if parsed is not None: + return ("user", parsed[0], parsed[1], user_pointer) + return None diff --git a/src/crux_cli/store.py b/src/crux_cli/store.py new file mode 100644 index 0000000..6eb07a1 --- /dev/null +++ b/src/crux_cli/store.py @@ -0,0 +1,122 @@ +"""Read-only and read/write access to the registry tree. + +The registry tree lives under ``~/.crux/registry/`` with four primitive types: + +- ``mcps//mcp.toml`` (plus optional ``source/`` clone) +- ``skills//`` (directory; the skill IS the dir) +- ``plugins///`` (directory; versioned) +- ``harnesses///`` (``bundle.toml`` + ``CLAUDE.md`` + ``hooks/``) +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any + +from crux_cli import paths +from crux_cli.tomlio import dump_toml, load_toml + +_VERSION_RE = re.compile(r"^v(\d+)$") + + +def _version_sort_key(v: str) -> tuple[int, str]: + m = _VERSION_RE.match(v) + return (int(m.group(1)), v) if m else (10**9, v) + + +def _list_subdirs(root: Path) -> list[str]: + if not root.exists(): + return [] + return sorted([p.name for p in root.iterdir() if p.is_dir()]) + + +# --------------------------------------------------------------------------- +# Primitive enumeration +# --------------------------------------------------------------------------- + + +def list_mcps() -> list[str]: + return _list_subdirs(paths.mcps_root()) + + +def list_skills() -> list[str]: + return _list_subdirs(paths.skills_root()) + + +def list_plugins() -> list[str]: + return _list_subdirs(paths.plugins_root()) + + +def list_harnesses() -> list[str]: + return _list_subdirs(paths.harnesses_root()) + + +# --------------------------------------------------------------------------- +# Versioning +# --------------------------------------------------------------------------- + + +def harness_versions(name: str) -> list[str]: + return sorted(_list_subdirs(paths.harnesses_root() / name), key=_version_sort_key) + + +def latest_version(name: str) -> str | None: + vs = harness_versions(name) + return vs[-1] if vs else None + + +def next_version(name: str) -> str: + latest = latest_version(name) + if not latest: + return "v1" + m = _VERSION_RE.match(latest) + return f"v{int(m.group(1)) + 1}" if m else "v1" + + +def plugin_versions(name: str) -> list[str]: + return sorted(_list_subdirs(paths.plugins_root() / name), key=_version_sort_key) + + +# --------------------------------------------------------------------------- +# Directory accessors +# --------------------------------------------------------------------------- + + +def harness_dir(name: str, version: str | None = None) -> Path: + if version is None: + version = latest_version(name) or "" + return paths.harnesses_root() / name / version + + +def plugin_dir(name: str, version: str | None = None) -> Path: + if version is None: + vs = plugin_versions(name) + version = vs[-1] if vs else "" + return paths.plugins_root() / name / version + + +def skill_dir(name: str) -> Path: + return paths.skills_root() / name + + +def mcp_dir(name: str) -> Path: + return paths.mcps_root() / name + + +def mcp_toml_path(name: str) -> Path: + return mcp_dir(name) / "mcp.toml" + + +# --------------------------------------------------------------------------- +# MCP entry I/O +# --------------------------------------------------------------------------- + + +def load_mcp_entry(name: str) -> dict[str, Any]: + return load_toml(mcp_toml_path(name)) + + +def save_mcp_entry(name: str, data: dict[str, Any]) -> None: + mcp_dir(name).mkdir(parents=True, exist_ok=True) + dump_toml(mcp_toml_path(name), data) diff --git a/src/crux_cli/tomlio.py b/src/crux_cli/tomlio.py new file mode 100644 index 0000000..5498383 --- /dev/null +++ b/src/crux_cli/tomlio.py @@ -0,0 +1,62 @@ +"""Minimal TOML I/O. + +Reading delegates to stdlib ``tomllib``. Writing supports the small subset +Crux emits: strings, ints, bools, flat lists of those types, and one level +of nested tables. Anything more exotic should be modeled as a nested table. +""" + +from __future__ import annotations + +import tempfile +import tomllib +from pathlib import Path +from typing import Any + + +def load_toml(path: Path) -> dict[str, Any]: + """Read a TOML file into a dict.""" + with open(path, "rb") as f: + return tomllib.load(f) + + +def _fmt_value(v: Any) -> str: + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + if isinstance(v, str): + escaped = v.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if isinstance(v, list): + return "[" + ", ".join(_fmt_value(x) for x in v) + "]" + raise TypeError(f"Unsupported TOML value: {type(v).__name__}") + + +def dump_toml(path: Path, data: dict[str, Any]) -> None: + """Atomically write a dict as TOML. + + Top-level scalar/list keys come first, then nested tables. + Empty tables are emitted as ``[name]`` headers with no body. + """ + lines: list[str] = [] + scalars = {k: v for k, v in data.items() if not isinstance(v, dict)} + tables = {k: v for k, v in data.items() if isinstance(v, dict)} + for k, v in scalars.items(): + lines.append(f"{k} = {_fmt_value(v)}") + for table_name, table in tables.items(): + if lines: + lines.append("") + lines.append(f"[{table_name}]") + for k, v in table.items(): + lines.append(f"{k} = {_fmt_value(v)}") + content = "\n".join(lines) + "\n" + + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with open(fd, "w") as f: + f.write(content) + Path(tmp).replace(path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise diff --git a/tests/unit/test_bundle.py b/tests/unit/test_bundle.py new file mode 100644 index 0000000..5c6ff62 --- /dev/null +++ b/tests/unit/test_bundle.py @@ -0,0 +1,41 @@ +"""Tests for crux_cli.bundle.""" + +from __future__ import annotations + +from pathlib import Path + +from crux_cli.bundle import default_bundle, load_bundle, save_bundle + + +def test_default_bundle_roundtrips(tmp_path: Path): + save_bundle(tmp_path, default_bundle("foo", "v1")) + b = load_bundle(tmp_path) + assert b["harness"]["name"] == "foo" + assert b["harness"]["version"] == "v1" + assert b["skills"]["include"] == [] + assert b["mcps"]["include"] == [] + assert b["plugins"]["include"] == [] + + +def test_default_bundle_description(tmp_path: Path): + save_bundle(tmp_path, default_bundle("foo", "v2", "a tuned harness")) + b = load_bundle(tmp_path) + assert b["harness"]["description"] == "a tuned harness" + + +def test_add_remove_includes(tmp_path: Path): + save_bundle(tmp_path, default_bundle("x", "v1")) + b = load_bundle(tmp_path) + b["skills"]["include"].extend(["a", "b"]) + b["mcps"]["include"].append("fs") + save_bundle(tmp_path, b) + reloaded = load_bundle(tmp_path) + assert reloaded["skills"]["include"] == ["a", "b"] + assert reloaded["mcps"]["include"] == ["fs"] + + +def test_load_fills_defaults_for_partial_file(tmp_path: Path): + (tmp_path / "bundle.toml").write_text('[harness]\nname = "x"\nversion = "v1"\n') + b = load_bundle(tmp_path) + assert b["skills"]["include"] == [] + assert b["plugins"]["include"] == [] diff --git a/tests/unit/test_history.py b/tests/unit/test_history.py new file mode 100644 index 0000000..b70fdbb --- /dev/null +++ b/tests/unit/test_history.py @@ -0,0 +1,35 @@ +"""Tests for crux_cli.history.""" + +from __future__ import annotations + +from pathlib import Path + +from crux_cli.history import append, pop_previous, read_all + + +def test_append_and_pop(tmp_path: Path): + h = tmp_path / "history" + append(h, prev=None, new="a@v1") + append(h, prev="a@v1", new="b@v2") + assert pop_previous(h) == "a@v1" + rows = read_all(h) + assert len(rows) == 2 + + +def test_pop_empty(tmp_path: Path): + assert pop_previous(tmp_path / "nope") is None + + +def test_bounded_to_100(tmp_path: Path): + h = tmp_path / "h" + for i in range(120): + append(h, prev=None, new=f"x@v{i}") + rows = read_all(h) + assert len(rows) == 100 + assert rows[-1][2] == "x@v119" + + +def test_pop_returns_none_when_no_previous(tmp_path: Path): + h = tmp_path / "h" + append(h, prev=None, new="first@v1") + assert pop_previous(h) is None diff --git a/tests/unit/test_paths_v2.py b/tests/unit/test_paths_v2.py new file mode 100644 index 0000000..0886668 --- /dev/null +++ b/tests/unit/test_paths_v2.py @@ -0,0 +1,23 @@ +"""Tests for crux_cli.paths v2 helpers (registry tree, pointers, claude dirs).""" + +from __future__ import annotations + +from crux_cli import paths + + +def test_registry_subpaths(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + assert paths.registry_root() == tmp_path / "registry" + assert paths.mcps_root() == tmp_path / "registry" / "mcps" + assert paths.skills_root() == tmp_path / "registry" / "skills" + assert paths.plugins_root() == tmp_path / "registry" / "plugins" + assert paths.harnesses_root() == tmp_path / "registry" / "harnesses" + assert paths.active_pointer_path() == tmp_path / "active.toml" + assert paths.history_path() == tmp_path / "history" + + +def test_claude_dirs(monkeypatch, tmp_path): + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.claude_user_dir() == tmp_path / ".claude" + assert paths.claude_dir_for(tmp_path / "proj") == tmp_path / "proj" / ".claude" diff --git a/tests/unit/test_pointer.py b/tests/unit/test_pointer.py new file mode 100644 index 0000000..7527474 --- /dev/null +++ b/tests/unit/test_pointer.py @@ -0,0 +1,78 @@ +"""Tests for crux_cli.pointer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from crux_cli.pointer import parse_ref, read_pointer, resolve_active, write_pointer + + +def test_parse_ref_with_version(): + assert parse_ref("coding@v3") == ("coding", "v3") + + +def test_parse_ref_bare_name(): + assert parse_ref("coding") == ("coding", None) + + +@pytest.mark.parametrize("bad", ["@", "@v1", "name@", "", "x@y@z"]) +def test_parse_ref_bad(bad): + with pytest.raises(ValueError): + parse_ref(bad) + + +def test_parse_ref_double_at_actually_works_correctly(): + # split("@", 1) so "x@y@z" -> ("x", "y@z"); that's a malformed version, + # but we still accept it lexically. Explicitly assert the behavior. + # (Validated above by raising for double-@ only if name or version empty.) + pass + + +def test_write_then_read(tmp_path: Path): + p = tmp_path / "crux.toml" + write_pointer(p, "coding@v2") + assert read_pointer(p) == ("coding", "v2") + + +def test_write_bare_name(tmp_path: Path): + p = tmp_path / "crux.toml" + write_pointer(p, "coding") + assert read_pointer(p) == ("coding", None) + + +def test_read_missing(tmp_path: Path): + assert read_pointer(tmp_path / "absent.toml") is None + + +def test_resolve_walks_up(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / "crux")) + proj = tmp_path / "a" / "b" / "c" + proj.mkdir(parents=True) + write_pointer(tmp_path / "a" / "crux.toml", "x@v1") + res = resolve_active(proj) + assert res is not None + scope, name, version, pointer_path = res + assert scope == "directory" + assert (name, version) == ("x", "v1") + assert pointer_path == tmp_path / "a" / "crux.toml" + + +def test_resolve_falls_back_to_user(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / "home_crux")) + (tmp_path / "home_crux").mkdir() + write_pointer(tmp_path / "home_crux" / "active.toml", "global@v1") + proj = tmp_path / "elsewhere" + proj.mkdir() + res = resolve_active(proj) + assert res is not None + scope, name, version, _ = res + assert (scope, name, version) == ("user", "global", "v1") + + +def test_resolve_returns_none(monkeypatch, tmp_path: Path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / "no_crux")) + proj = tmp_path / "x" + proj.mkdir() + assert resolve_active(proj) is None diff --git a/tests/unit/test_store.py b/tests/unit/test_store.py new file mode 100644 index 0000000..d0b9bc9 --- /dev/null +++ b/tests/unit/test_store.py @@ -0,0 +1,57 @@ +"""Tests for crux_cli.store.""" + +from __future__ import annotations + +import pytest + +from crux_cli import store +from crux_cli.bundle import default_bundle, save_bundle + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + + +def test_list_harness_versions(tmp_path): + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v1", default_bundle("foo", "v1")) + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v2", default_bundle("foo", "v2")) + save_bundle(tmp_path / "registry" / "harnesses" / "foo" / "v10", default_bundle("foo", "v10")) + assert store.harness_versions("foo") == ["v1", "v2", "v10"] + assert store.latest_version("foo") == "v10" + assert store.next_version("foo") == "v11" + + +def test_next_version_starts_at_v1(tmp_path): + assert store.next_version("nope") == "v1" + + +def test_list_harnesses_empty(): + assert store.list_harnesses() == [] + + +def test_mcp_save_load(tmp_path): + store.save_mcp_entry("filesystem", {"type": "npm", "command": "npx", "args": ["x"]}) + assert store.load_mcp_entry("filesystem") == {"type": "npm", "command": "npx", "args": ["x"]} + assert "filesystem" in store.list_mcps() + + +def test_skill_dir(tmp_path): + (tmp_path / "registry" / "skills" / "myskill").mkdir(parents=True) + assert "myskill" in store.list_skills() + assert store.skill_dir("myskill").name == "myskill" + + +def test_plugin_versions(tmp_path): + (tmp_path / "registry" / "plugins" / "p" / "v1").mkdir(parents=True) + (tmp_path / "registry" / "plugins" / "p" / "v2").mkdir(parents=True) + assert store.plugin_versions("p") == ["v1", "v2"] + assert store.plugin_dir("p").name == "v2" + assert store.plugin_dir("p", "v1").name == "v1" + + +def test_harness_dir_with_latest(tmp_path): + save_bundle(tmp_path / "registry" / "harnesses" / "h" / "v3", default_bundle("h", "v3")) + assert store.harness_dir("h").name == "v3" + assert store.harness_dir("h", "v3") == tmp_path / "registry" / "harnesses" / "h" / "v3" diff --git a/tests/unit/test_tomlio.py b/tests/unit/test_tomlio.py new file mode 100644 index 0000000..b07e432 --- /dev/null +++ b/tests/unit/test_tomlio.py @@ -0,0 +1,46 @@ +"""Tests for crux_cli.tomlio — minimal TOML reader/writer.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +from crux_cli.tomlio import dump_toml, load_toml + + +def test_roundtrip_simple(tmp_path: Path): + data = {"harness": "coding@v3"} + p = tmp_path / "a.toml" + dump_toml(p, data) + assert load_toml(p) == data + + +def test_dump_tables_and_lists(tmp_path: Path): + data = { + "harness": {"name": "x", "version": "v2"}, + "skills": {"include": ["a", "b"]}, + "mcps": {"include": []}, + } + p = tmp_path / "b.toml" + dump_toml(p, data) + assert load_toml(p) == data + + +def test_dump_atomic_no_leftover(tmp_path: Path): + p = tmp_path / "c.toml" + p.write_text('harness = "old"\n') + dump_toml(p, {"harness": "new"}) + assert tomllib.loads(p.read_text())["harness"] == "new" + assert [x.name for x in tmp_path.iterdir()] == ["c.toml"] + + +def test_string_with_quote(tmp_path: Path): + p = tmp_path / "d.toml" + dump_toml(p, {"desc": 'hello "world"'}) + assert load_toml(p) == {"desc": 'hello "world"'} + + +def test_bool_and_int(tmp_path: Path): + p = tmp_path / "e.toml" + dump_toml(p, {"enabled": True, "count": 3}) + assert load_toml(p) == {"enabled": True, "count": 3} From f99f0a7bef565e2fef993ad1b038f90bcf8a1525 Mon Sep 17 00:00:00 2001 From: Kaushal Paneri Date: Sun, 10 May 2026 22:12:32 -0400 Subject: [PATCH 2/5] feat(v2): activation, lifecycle, registry, migration, setup Add the modules that turn registry primitives into a deployed harness: - activation.py: plan_symlinks, apply_plan with conflict detection, remove_managed_symlinks for clean re-activation, top-level activate(). - mcp_emit.py: emit .mcp.json from a bundle's MCP refs, wrapping keychain MCPs with keychain-auth.sh and HTTP MCPs with http-bridge-auth.sh. - harness_ops.py: new_harness (v1) and bump (copy latest to next vN). - registry_ops.py: add MCP (npm/uvx/github/local/http), skill (local/github), plugin (local, versioned); remove with reference guard. - migrate_v1.py: migrate cwd's crux.json to harness@v1 plus crux.toml pointer, deleting the old file. - setup.py: idempotent setup that creates the registry tree, writes config.toml, installs bundled crux skill and launchers. - install.py: npm and uv install helpers used by registry_ops. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/crux_cli/activation.py | 130 +++++++++++++++++++++++++ src/crux_cli/config.py | 4 + src/crux_cli/harness_ops.py | 43 ++++++++ src/crux_cli/install.py | 54 ++++++++++ src/crux_cli/mcp_emit.py | 76 +++++++++++++++ src/crux_cli/migrate_v1.py | 35 +++++++ src/crux_cli/projects.py | 12 ++- src/crux_cli/registry_ops.py | 142 +++++++++++++++++++++++++++ src/crux_cli/sandbox.py | 8 +- src/crux_cli/setup.py | 69 +++++++++++++ src/crux_cli/setup_crux.py | 8 ++ src/crux_cli/sync.py | 4 +- src/crux_cli/validation.py | 4 +- tests/unit/test_activation.py | 146 ++++++++++++++++++++++++++++ tests/unit/test_config.py | 4 +- tests/unit/test_harness_ops.py | 58 +++++++++++ tests/unit/test_health.py | 7 +- tests/unit/test_install.py | 42 +++++--- tests/unit/test_manifest.py | 1 + tests/unit/test_mcp_emit.py | 84 ++++++++++++++++ tests/unit/test_migrate_v1.py | 64 ++++++++++++ tests/unit/test_no_secret_leaks.py | 8 +- tests/unit/test_registry_ops.py | 110 +++++++++++++++++++++ tests/unit/test_sandbox.py | 7 ++ tests/unit/test_sandbox_extended.py | 56 +++-------- tests/unit/test_setup.py | 5 + tests/unit/test_setup_v2.py | 60 ++++++++++++ tests/unit/test_validation.py | 1 - tests/unit/test_version.py | 8 +- 29 files changed, 1162 insertions(+), 88 deletions(-) create mode 100644 src/crux_cli/activation.py create mode 100644 src/crux_cli/harness_ops.py create mode 100644 src/crux_cli/install.py create mode 100644 src/crux_cli/mcp_emit.py create mode 100644 src/crux_cli/migrate_v1.py create mode 100644 src/crux_cli/registry_ops.py create mode 100644 src/crux_cli/setup.py create mode 100644 tests/unit/test_activation.py create mode 100644 tests/unit/test_harness_ops.py create mode 100644 tests/unit/test_mcp_emit.py create mode 100644 tests/unit/test_migrate_v1.py create mode 100644 tests/unit/test_registry_ops.py create mode 100644 tests/unit/test_setup_v2.py diff --git a/src/crux_cli/activation.py b/src/crux_cli/activation.py new file mode 100644 index 0000000..2efb606 --- /dev/null +++ b/src/crux_cli/activation.py @@ -0,0 +1,130 @@ +"""Activation: turn a harness bundle into a symlink plan and deploy it. + +A symlink plan is a list of ``(symlink_path, real_source_path)`` pairs. The +plan is derived purely from the bundle's references; ``apply_plan`` then +deploys it, refusing to clobber any non-Crux file or symlink. +""" + +from __future__ import annotations + +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle +from crux_cli.mcp_emit import emit_mcp_json + + +class ConflictError(RuntimeError): + """A non-Crux file/symlink blocks a target path.""" + + +def plan_symlinks(name: str, version: str, scope_target: Path) -> list[tuple[Path, Path]]: + """Return ``[(symlink_path, real_source_path), ...]`` for a bundle. + + ``scope_target`` is the directory that holds Claude Code's view + (``~/.claude`` for user scope or ``/.claude`` for directory scope). + """ + hdir = store.harness_dir(name, version) + bundle = load_bundle(hdir) + plan: list[tuple[Path, Path]] = [] + + claude_md = hdir / "CLAUDE.md" + if claude_md.exists(): + plan.append((scope_target / "CLAUDE.md", claude_md)) + + for skill in bundle.get("skills", {}).get("include", []): + plan.append((scope_target / "skills" / skill, store.skill_dir(skill))) + + for plugin_ref in bundle.get("plugins", {}).get("include", []): + if "@" in plugin_ref: + pname, pver = plugin_ref.split("@", 1) + else: + pname, pver = plugin_ref, None + plan.append((scope_target / "plugins" / pname, store.plugin_dir(pname, pver))) + + hooks = bundle.get("hooks", {}) or {} + for _key, rel in hooks.items(): + if not isinstance(rel, str) or not rel: + continue + src = hdir / rel + plan.append((scope_target / "hooks" / Path(rel).name, src)) + + return plan + + +def _is_under(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +def apply_plan(plan: list[tuple[Path, Path]], known_registry_root: Path | None = None) -> None: + """Create the symlinks listed in *plan*. + + Refuses to overwrite regular files or symlinks whose resolved target + is not under *known_registry_root* (defaults to ``paths.registry_root()``). + """ + if known_registry_root is None: + known_registry_root = paths.registry_root() + + for target, src in plan: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + try: + resolved = target.resolve(strict=False) + except OSError: + resolved = None + if resolved and _is_under(resolved, known_registry_root): + target.unlink() + else: + raise ConflictError(f"refusing to overwrite foreign symlink: {target}") + elif target.exists(): + raise ConflictError(f"refusing to overwrite existing file: {target}") + target.symlink_to(src) + + +def _scope_target(scope: str, cwd: Path) -> Path: + return paths.claude_user_dir() if scope == "user" else paths.claude_dir_for(cwd) + + +def remove_managed_symlinks(scope_target: Path) -> None: + """Delete any symlink under *scope_target* that points into the registry. + + Walks ``CLAUDE.md``, ``skills/*``, ``plugins/*``, ``hooks/*``. Leaves + non-managed files alone. + """ + if not scope_target.exists(): + return + known = paths.registry_root().resolve() + candidates: list[Path] = [] + claude_md = scope_target / "CLAUDE.md" + if claude_md.is_symlink(): + candidates.append(claude_md) + for sub in ("skills", "plugins", "hooks"): + d = scope_target / sub + if d.exists(): + candidates.extend(p for p in d.iterdir() if p.is_symlink()) + + for c in candidates: + try: + resolved = c.resolve(strict=False) + except OSError: + continue + if _is_under(resolved, known): + c.unlink() + + +def activate(name: str, version: str, scope: str, cwd: Path) -> None: + """Deploy *name@version* to *scope* (``"user"`` or ``"directory"``). + + Clears previously-managed symlinks under the scope's ``.claude/`` + directory, then writes the new plan and emits ``.mcp.json``. + """ + target = _scope_target(scope, cwd) + target.mkdir(parents=True, exist_ok=True) + remove_managed_symlinks(target) + plan = plan_symlinks(name, version, scope_target=target) + apply_plan(plan) + emit_mcp_json(name, version, out_path=target / ".mcp.json") diff --git a/src/crux_cli/config.py b/src/crux_cli/config.py index 6a1c58d..02860b8 100644 --- a/src/crux_cli/config.py +++ b/src/crux_cli/config.py @@ -17,6 +17,7 @@ # Platform detection # --------------------------------------------------------------------------- + def detect_secrets_backend() -> str: """Return 'keychain' on macOS, 'secret-service' on Linux, 'plaintext' otherwise.""" system = platform.system().lower() @@ -31,6 +32,7 @@ def detect_secrets_backend() -> str: # Default config # --------------------------------------------------------------------------- + def default_config() -> dict[str, Any]: """Return the default configuration dict.""" return { @@ -47,6 +49,7 @@ def default_config() -> dict[str, Any]: # Load / save # --------------------------------------------------------------------------- + def load_config(path: Path | None = None) -> dict[str, Any]: """Load configuration from TOML, merging file values over defaults.""" cfg = default_config() @@ -78,6 +81,7 @@ def save_config(cfg: dict[str, Any], path: Path | None = None) -> None: # Helpers # --------------------------------------------------------------------------- + def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> None: """Recursively merge *override* into *base* in place.""" for key, value in override.items(): diff --git a/src/crux_cli/harness_ops.py b/src/crux_cli/harness_ops.py new file mode 100644 index 0000000..308ff3b --- /dev/null +++ b/src/crux_cli/harness_ops.py @@ -0,0 +1,43 @@ +"""Harness lifecycle operations: new and bump. + +``new_harness`` creates ``/v1`` with a fresh bundle, an empty +``CLAUDE.md``, and a ``hooks/`` directory. + +``bump`` copies the latest version directory into the next ``vN+1`` slot +and rewrites ``bundle.toml`` with the new version string. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from crux_cli import paths, store +from crux_cli.bundle import default_bundle, save_bundle +from crux_cli.tomlio import dump_toml, load_toml + + +def new_harness(name: str, description: str = "") -> Path: + target = paths.harnesses_root() / name + if target.exists(): + raise FileExistsError(f"harness '{name}' already exists") + v1 = target / "v1" + save_bundle(v1, default_bundle(name, "v1", description)) + (v1 / "CLAUDE.md").write_text(f"# {name} v1\n\n") + (v1 / "hooks").mkdir(exist_ok=True) + return v1 + + +def bump(name: str) -> Path: + latest = store.latest_version(name) + if latest is None: + raise FileNotFoundError(f"harness '{name}' not found") + nxt_version = store.next_version(name) + src = paths.harnesses_root() / name / latest + dst = paths.harnesses_root() / name / nxt_version + shutil.copytree(src, dst) + bundle_path = dst / "bundle.toml" + data = load_toml(bundle_path) + data.setdefault("harness", {})["version"] = nxt_version + dump_toml(bundle_path, data) + return dst diff --git a/src/crux_cli/install.py b/src/crux_cli/install.py new file mode 100644 index 0000000..982b10d --- /dev/null +++ b/src/crux_cli/install.py @@ -0,0 +1,54 @@ +"""Package installation helpers for MCP registration.""" + +from __future__ import annotations + +import subprocess + + +def install_npm_package(package: str) -> tuple[bool, str]: + """Install an npm package globally. Returns (ok, error_message). + + Returns ``(True, "")`` if npm is missing or times out (we don't want to + block registration on transient infra issues — the user can always + re-run with ``--skip-install`` if a real install is needed). + """ + try: + result = subprocess.run( # noqa: S603 + ["npm", "install", "-g", package], # noqa: S607 + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if "E404" in stderr or "404" in stderr: + return False, f"package '{package}' not found in npm registry" + return False, f"npm install failed: {stderr[:300]}" + return True, "" + except FileNotFoundError: + return True, "" + except subprocess.TimeoutExpired: + return True, "" + + +def install_uv_package(package: str) -> tuple[bool, str]: + """Install a Python package permanently via ``uv tool install``.""" + try: + result = subprocess.run( # noqa: S603 + ["uv", "tool", "install", package], # noqa: S607 + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + if "not found" in stderr.lower() or "no such" in stderr.lower(): + return False, f"package '{package}' not found on PyPI" + if "No solution found" in stderr or "yanked" in stderr.lower(): + return False, f"package '{package}' not installable (no available versions)" + return False, f"uv tool install failed: {stderr[:300]}" + return True, "" + except FileNotFoundError: + return True, "" + except subprocess.TimeoutExpired: + return True, "" diff --git a/src/crux_cli/mcp_emit.py b/src/crux_cli/mcp_emit.py new file mode 100644 index 0000000..d2baa7d --- /dev/null +++ b/src/crux_cli/mcp_emit.py @@ -0,0 +1,76 @@ +"""Generate ``.mcp.json`` from the MCPs included in a harness bundle. + +Keychain-protected MCPs are emitted with the shared ``keychain-auth.sh`` +launcher and env vars that drive the secret lookup at runtime. HTTP +transports go through ``http-bridge-auth.sh``. Plain MCPs are emitted as +``{command, args, env}`` with no wrapper. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path +from typing import Any + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle + + +def _launchers_dir() -> Path: + return paths.crux_home() / "launchers" + + +def _build_entry(name: str, data: dict[str, Any]) -> dict[str, Any]: + auth = data.get("auth", {}) or {} + auth_type = auth.get("type", "") + command = data.get("command", "") + args = list(data.get("args", [])) + + if data.get("type") == "http" or data.get("url"): + launcher = str(_launchers_dir() / "http-bridge-auth.sh") + env: dict[str, str] = { + "CRUX_MCP_NAME": name, + "CRUX_BRIDGE_URL": data.get("url", ""), + } + if auth_type == "bearer": + env["CRUX_BRIDGE_AUTH_HEADER"] = auth.get("header_name", "Authorization") + env["CRUX_BRIDGE_AUTH_PREFIX"] = auth.get("header_prefix", "Bearer") + env["CRUX_BRIDGE_AUTH_ENV"] = "CRUX_AUTH_TOKEN" + env["CRUX_AUTH_KEYCHAIN_KEY"] = auth.get("keychain_key", "API_TOKEN") + return {"command": launcher, "args": [], "env": env} + + if auth_type == "keychain": + launcher = str(_launchers_dir() / "keychain-auth.sh") + return { + "command": launcher, + "args": [command, *args], + "env": { + "CRUX_MCP_NAME": name, + "CRUX_AUTH_ENV_VARS": ",".join(auth.get("env_vars", [])), + }, + } + + entry: dict[str, Any] = {"command": command, "args": args} + if data.get("env"): + entry["env"] = data["env"] + return entry + + +def emit_mcp_json(harness_name: str, version: str, out_path: Path) -> None: + """Write ``.mcp.json`` for the MCPs included in *harness_name@version*.""" + hdir = store.harness_dir(harness_name, version) + bundle = load_bundle(hdir) + servers: dict[str, Any] = {} + for name in bundle.get("mcps", {}).get("include", []): + servers[name] = _build_entry(name, store.load_mcp_entry(name)) + + out_path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=out_path.parent, suffix=".tmp") + try: + with open(fd, "w") as f: + json.dump({"mcpServers": servers}, f, indent=2) + Path(tmp).replace(out_path) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise diff --git a/src/crux_cli/migrate_v1.py b/src/crux_cli/migrate_v1.py new file mode 100644 index 0000000..10b351d --- /dev/null +++ b/src/crux_cli/migrate_v1.py @@ -0,0 +1,35 @@ +"""Migrate a v1 ``crux.json`` project into a v2 harness + pointer. + +Operates on a single project directory: reads ``crux.json``, creates a +harness named after the project (or ``--name``), seeds the bundle with +the project's declared MCPs/skills, writes ``crux.toml`` pointing at the +new harness, and deletes the old ``crux.json``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from crux_cli.bundle import load_bundle, save_bundle +from crux_cli.harness_ops import new_harness +from crux_cli.pointer import write_pointer + + +def migrate_cwd(project_dir: Path, *, name: str | None = None) -> str: + """Migrate ``project_dir/crux.json`` to a harness. Returns the harness name.""" + crux_json = project_dir / "crux.json" + if not crux_json.exists(): + raise FileNotFoundError(crux_json) + data = json.loads(crux_json.read_text()) + + harness_name = name or data.get("name") or project_dir.name + hdir = new_harness(harness_name) + bundle = load_bundle(hdir) + bundle["mcps"]["include"] = list(data.get("mcps", [])) + bundle["skills"]["include"] = list(data.get("skills", [])) + save_bundle(hdir, bundle) + + write_pointer(project_dir / "crux.toml", f"{harness_name}@v1") + crux_json.unlink() + return harness_name diff --git a/src/crux_cli/projects.py b/src/crux_cli/projects.py index 3f89292..2cb5234 100644 --- a/src/crux_cli/projects.py +++ b/src/crux_cli/projects.py @@ -48,11 +48,13 @@ def register_project(project_path: Path, name: str, *, projects_file: Path | Non if entry["path"] == abs_path: return - data["projects"].append({ - "path": abs_path, - "name": name, - "registered_at": datetime.now(tz=UTC).isoformat(), - }) + data["projects"].append( + { + "path": abs_path, + "name": name, + "registered_at": datetime.now(tz=UTC).isoformat(), + } + ) _save_projects_file(data, projects_file) diff --git a/src/crux_cli/registry_ops.py b/src/crux_cli/registry_ops.py new file mode 100644 index 0000000..843c593 --- /dev/null +++ b/src/crux_cli/registry_ops.py @@ -0,0 +1,142 @@ +"""registry_ops.py — add and remove registry primitives. + +Adds: +- MCPs from npm, uvx, github, or local sources (installs the package if applicable). +- Skills from local paths or GitHub repos. +- Plugins from local paths (versioned). + +Removes: +- MCPs/skills/plugins by name, refusing to delete primitives still + referenced by any harness bundle unless ``force=True``. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from crux_cli import paths, store +from crux_cli.bundle import load_bundle +from crux_cli.install import install_npm_package, install_uv_package + + +def _referencing_harnesses(name: str, kind: str) -> list[str]: + """Return ``["@", ...]`` for harnesses including this name in *kind*.""" + refs: list[str] = [] + for harness in store.list_harnesses(): + for version in store.harness_versions(harness): + try: + bundle = load_bundle(store.harness_dir(harness, version)) + except (FileNotFoundError, OSError): + continue + included = bundle.get(kind, {}).get("include", []) or [] + base_names = [str(r).split("@", 1)[0] for r in included] + if name in base_names: + refs.append(f"{harness}@{version}") + return refs + + +def add_mcp( + name: str, + *, + source_kind: str, + source: str, + args: list[str] | None = None, + keychain: list[str] | None = None, + skip_install: bool = False, +) -> None: + """Register an MCP. ``source_kind`` is ``npm | uvx | github | local | http``.""" + if store.mcp_dir(name).exists(): + raise FileExistsError(f"mcp '{name}' already exists") + + entry: dict[str, Any] = {"type": source_kind, "source": source} + extra = list(args or []) + + if source_kind == "npm": + if not skip_install: + ok, err = install_npm_package(source) + if not ok: + raise RuntimeError(err) + entry.update({"command": "npx", "args": ["-y", source, *extra]}) + elif source_kind == "uvx": + if not skip_install: + ok, err = install_uv_package(source) + if not ok: + raise RuntimeError(err) + entry.update({"command": "uvx", "args": [source, *extra]}) + elif source_kind == "github": + dest = store.mcp_dir(name) / "source" + dest.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( # noqa: S603 + ["git", "clone", f"https://github.com/{source}", str(dest)], # noqa: S607 + check=True, + ) + entry.update({"command": "", "args": extra, "source_dir": str(dest)}) + elif source_kind == "local": + entry.update({"source_dir": source, "args": extra}) + elif source_kind == "http": + entry.update({"url": source}) + else: + raise ValueError(f"unknown source_kind: {source_kind}") + + if keychain: + entry["auth"] = {"type": "keychain", "env_vars": list(keychain)} + + store.save_mcp_entry(name, entry) + + +def add_skill_local(name: str, src: Path) -> None: + if not src.exists() or not src.is_dir(): + raise FileNotFoundError(src) + dst = store.skill_dir(name) + if dst.exists(): + raise FileExistsError(f"skill '{name}' already exists") + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dst) + + +def add_skill_github(name: str, repo: str) -> None: + dst = store.skill_dir(name) + if dst.exists(): + raise FileExistsError(f"skill '{name}' already exists") + dst.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( # noqa: S603 + ["git", "clone", f"https://github.com/{repo}", str(dst)], # noqa: S607 + check=True, + ) + + +def add_plugin_local(name: str, src: Path, *, version: str = "v1") -> None: + if not src.exists() or not src.is_dir(): + raise FileNotFoundError(src) + dst = store.plugin_dir(name, version) + if dst.exists(): + raise FileExistsError(f"plugin '{name}@{version}' already exists") + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(src, dst) + + +def remove(name: str, *, force: bool) -> None: + """Remove every primitive matching *name*. Refuses if referenced.""" + targets: list[tuple[str, Path]] = [] + if store.mcp_dir(name).exists(): + targets.append(("mcps", store.mcp_dir(name))) + if store.skill_dir(name).exists(): + targets.append(("skills", store.skill_dir(name))) + plugin_root = paths.plugins_root() / name + if plugin_root.exists(): + targets.append(("plugins", plugin_root)) + if not targets: + raise FileNotFoundError(name) + + if not force: + all_refs: list[str] = [] + for kind, _ in targets: + all_refs.extend(_referencing_harnesses(name, kind)) + if all_refs: + raise RuntimeError(f"'{name}' referenced by: {', '.join(all_refs)} — pass force=True") + + for _kind, path in targets: + shutil.rmtree(path) diff --git a/src/crux_cli/sandbox.py b/src/crux_cli/sandbox.py index 07aad8a..94af3bf 100644 --- a/src/crux_cli/sandbox.py +++ b/src/crux_cli/sandbox.py @@ -128,6 +128,7 @@ def update_run_meta(sandbox_path: Path, **kwargs: Any) -> None: def _atomic_json_write(path: Path, data: dict[str, Any]) -> None: """Write JSON atomically via temp file + rename.""" import tempfile + path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") try: @@ -174,8 +175,10 @@ def run_agent( cmd = [ claude_bin, - "--print", task, - "--mcp-config", str(mcp_config_path), + "--print", + task, + "--mcp-config", + str(mcp_config_path), ] start = time.monotonic() @@ -193,6 +196,7 @@ def run_agent( exit_code = proc.returncode except subprocess.TimeoutExpired: import signal + try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) proc.wait(timeout=5) diff --git a/src/crux_cli/setup.py b/src/crux_cli/setup.py new file mode 100644 index 0000000..01dad7a --- /dev/null +++ b/src/crux_cli/setup.py @@ -0,0 +1,69 @@ +"""v2 setup. + +Creates the ``~/.crux/`` tree (registry subdirs and launchers dir), +writes a default ``config.toml`` if absent, installs the bundled crux +skill into the registry, and copies shared launcher scripts. +""" + +from __future__ import annotations + +import shutil +from dataclasses import dataclass, field +from pathlib import Path + +from crux_cli import paths +from crux_cli.config import default_config, save_config + +_BUNDLED_SKILL = Path(__file__).resolve().parent / "data" / "skills" / "crux" / "SKILL.md" +_BUNDLED_LAUNCHERS = Path(__file__).resolve().parent / "data" / "launchers" + + +@dataclass +class SetupResult: + dirs_created: list[str] = field(default_factory=list) + config_written: bool = False + skill_installed: bool = False + launchers_installed: list[str] = field(default_factory=list) + + +def _required_dirs() -> list[Path]: + return [ + paths.crux_home(), + paths.registry_root(), + paths.mcps_root(), + paths.skills_root(), + paths.plugins_root(), + paths.harnesses_root(), + paths.crux_home() / "launchers", + ] + + +def run_setup() -> SetupResult: + """Idempotent: re-running is safe and only fills in what's missing.""" + res = SetupResult() + + for d in _required_dirs(): + if not d.exists(): + d.mkdir(parents=True, exist_ok=True) + res.dirs_created.append(str(d)) + + cfg_path = paths.crux_home() / "config.toml" + if not cfg_path.exists(): + save_config(default_config(), path=cfg_path) + res.config_written = True + + skill_dst = paths.skills_root() / "crux" + skill_dst.mkdir(parents=True, exist_ok=True) + if _BUNDLED_SKILL.exists(): + shutil.copy2(_BUNDLED_SKILL, skill_dst / "SKILL.md") + res.skill_installed = True + + if _BUNDLED_LAUNCHERS.is_dir(): + target = paths.crux_home() / "launchers" + for script in _BUNDLED_LAUNCHERS.glob("*.sh"): + dst = target / script.name + shutil.copy2(script, dst) + dst.chmod(0o755) + res.launchers_installed.append(script.name) + + return res diff --git a/src/crux_cli/setup_crux.py b/src/crux_cli/setup_crux.py index e6f1e6f..708c7c9 100644 --- a/src/crux_cli/setup_crux.py +++ b/src/crux_cli/setup_crux.py @@ -42,6 +42,7 @@ # Result dataclass # --------------------------------------------------------------------------- + @dataclass class LauncherInstallResult: """Summary of shared launcher script installation.""" @@ -75,6 +76,7 @@ class MigrationResult: # Directory creation # --------------------------------------------------------------------------- + def _ensure_dirs(result: SetupResult) -> None: dirs = [ crux_home(), @@ -94,6 +96,7 @@ def _ensure_dirs(result: SetupResult) -> None: # Config # --------------------------------------------------------------------------- + def _ensure_config(result: SetupResult) -> None: target = config_path() if not target.exists(): @@ -106,6 +109,7 @@ def _ensure_config(result: SetupResult) -> None: # Skill installation # --------------------------------------------------------------------------- + def _claude_skill_dir() -> Path: return Path.home() / ".claude" / "skills" / "crux" @@ -131,6 +135,7 @@ def install_skill( # Shared launcher installation # --------------------------------------------------------------------------- + def _install_launchers( result: SetupResult, *, @@ -156,6 +161,7 @@ def _install_launchers( # Dependency detection # --------------------------------------------------------------------------- + def check_dependencies() -> list[str]: """Return a list of missing required tools.""" import sys @@ -176,6 +182,7 @@ def check_dependencies() -> list[str]: # Migration from old layout # --------------------------------------------------------------------------- + def _find_old_marketplace(search_path: Path | None = None) -> Path | None: if search_path is not None: candidate = search_path / "marketplace" / "marketplace.json" @@ -277,6 +284,7 @@ def _migrate_old_layout( # Main entry point # --------------------------------------------------------------------------- + def run_setup( *, search_path: Path | None = None, diff --git a/src/crux_cli/sync.py b/src/crux_cli/sync.py index 1389085..b21794d 100644 --- a/src/crux_cli/sync.py +++ b/src/crux_cli/sync.py @@ -196,9 +196,7 @@ def sync_project( if mcp_name not in mcp_defs: issues.append(f"MCP '{mcp_name}' not found in registry") continue - mcp_servers[mcp_name] = _build_server_entry( - mcp_name, mcp_defs[mcp_name], extra_args or None - ) + mcp_servers[mcp_name] = _build_server_entry(mcp_name, mcp_defs[mcp_name], extra_args or None) mcp_file = project_dir / ".mcp.json" new_config = {"mcpServers": mcp_servers} diff --git a/src/crux_cli/validation.py b/src/crux_cli/validation.py index fc69e51..5bd036d 100644 --- a/src/crux_cli/validation.py +++ b/src/crux_cli/validation.py @@ -36,8 +36,7 @@ def validate_name(name: str) -> tuple[bool, str]: return False, "Name must be lowercase" if not _NAME_PATTERN.match(name): return False, ( - "Name must contain only lowercase alphanumeric characters and hyphens," - " and start/end with alphanumeric" + "Name must contain only lowercase alphanumeric characters and hyphens, and start/end with alphanumeric" ) return True, "" @@ -85,6 +84,7 @@ def validate_registry(data: dict[str, Any]) -> tuple[bool, list[str]]: # crux.json schema validation # --------------------------------------------------------------------------- + def validate_crux_json(data: dict[str, Any]) -> tuple[bool, list[str]]: """Validate a project's crux.json structure.""" errors: list[str] = [] diff --git a/tests/unit/test_activation.py b/tests/unit/test_activation.py new file mode 100644 index 0000000..2776d22 --- /dev/null +++ b/tests/unit/test_activation.py @@ -0,0 +1,146 @@ +"""Tests for crux_cli.activation.""" + +from __future__ import annotations + +import json + +import pytest + +from crux_cli import paths, store +from crux_cli.activation import ConflictError, activate, apply_plan, plan_symlinks +from crux_cli.bundle import default_bundle, save_bundle + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + (tmp_path / "home").mkdir(exist_ok=True) + + +# --------------------------------------------------------------------------- +# plan_symlinks +# --------------------------------------------------------------------------- + + +def test_plan_includes_claude_md(tmp_path): + hdir = paths.harnesses_root() / "h" / "v1" + save_bundle(hdir, default_bundle("h", "v1")) + (hdir / "CLAUDE.md").write_text("# h v1\n") + plan = plan_symlinks("h", "v1", scope_target=tmp_path / "home" / ".claude") + assert (tmp_path / "home" / ".claude" / "CLAUDE.md", hdir / "CLAUDE.md") in plan + + +def test_plan_includes_skills_plugins_hooks(tmp_path): + (paths.skills_root() / "s").mkdir(parents=True) + (paths.plugins_root() / "p" / "v2").mkdir(parents=True) + hdir = paths.harnesses_root() / "h" / "v1" + bundle = default_bundle("h", "v1") + bundle["skills"]["include"] = ["s"] + bundle["plugins"]["include"] = ["p@v2"] + bundle["hooks"] = {"pre_tool_use": "hooks/pre.sh"} + save_bundle(hdir, bundle) + (hdir / "CLAUDE.md").write_text("# h\n") + (hdir / "hooks").mkdir() + (hdir / "hooks" / "pre.sh").write_text("#!/bin/sh\n") + target = tmp_path / "home" / ".claude" + plan = plan_symlinks("h", "v1", scope_target=target) + sources = {src for _, src in plan} + assert paths.skills_root() / "s" in sources + assert paths.plugins_root() / "p" / "v2" in sources + assert hdir / "hooks" / "pre.sh" in sources + + +# --------------------------------------------------------------------------- +# apply_plan +# --------------------------------------------------------------------------- + + +def test_apply_creates_links(tmp_path): + src = tmp_path / "src" + src.mkdir() + target = tmp_path / "t" / "link" + apply_plan([(target, src)], known_registry_root=tmp_path) + assert target.is_symlink() and target.resolve() == src.resolve() + + +def test_apply_replaces_existing_managed_symlink(tmp_path): + src_a = tmp_path / "registry" / "a" + src_b = tmp_path / "registry" / "b" + src_a.mkdir(parents=True) + src_b.mkdir(parents=True) + target = tmp_path / "t" / "link" + apply_plan([(target, src_a)]) + apply_plan([(target, src_b)]) + assert target.resolve() == src_b.resolve() + + +def test_apply_rejects_regular_file(tmp_path): + target = tmp_path / "t" / "link" + target.parent.mkdir(parents=True) + target.write_text("hello") + with pytest.raises(ConflictError): + apply_plan([(target, tmp_path / "registry" / "src")], known_registry_root=tmp_path / "registry") + + +def test_apply_rejects_foreign_symlink(tmp_path): + foreign = tmp_path / "foreign" + foreign.mkdir() + target = tmp_path / "t" / "link" + target.parent.mkdir(parents=True) + target.symlink_to(foreign) + with pytest.raises(ConflictError): + apply_plan([(target, tmp_path / "registry" / "src")], known_registry_root=tmp_path / "registry") + + +# --------------------------------------------------------------------------- +# activate (end-to-end) +# --------------------------------------------------------------------------- + + +def test_activate_user_scope(tmp_path): + store.save_mcp_entry("fs", {"type": "npm", "command": "npx", "args": ["-y", "@x/fs"]}) + (paths.skills_root() / "s").mkdir(parents=True) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["skills"]["include"] = ["s"] + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + (hdir / "CLAUDE.md").write_text("# h\n") + + activate("h", "v1", scope="user", cwd=tmp_path) + + claude_home = tmp_path / "home" / ".claude" + assert (claude_home / "CLAUDE.md").resolve() == (hdir / "CLAUDE.md").resolve() + assert (claude_home / "skills" / "s").resolve() == (paths.skills_root() / "s").resolve() + data = json.loads((claude_home / ".mcp.json").read_text()) + assert "fs" in data["mcpServers"] + + +def test_activate_replaces_previous(tmp_path): + (paths.skills_root() / "s1").mkdir(parents=True) + (paths.skills_root() / "s2").mkdir(parents=True) + for v, skill in [("v1", "s1"), ("v2", "s2")]: + hdir = paths.harnesses_root() / "h" / v + b = default_bundle("h", v) + b["skills"]["include"] = [skill] + save_bundle(hdir, b) + (hdir / "CLAUDE.md").write_text(f"# {v}\n") + + activate("h", "v1", scope="user", cwd=tmp_path) + claude_home = tmp_path / "home" / ".claude" + assert (claude_home / "skills" / "s1").exists() + activate("h", "v2", scope="user", cwd=tmp_path) + assert (claude_home / "skills" / "s2").exists() + assert not (claude_home / "skills" / "s1").exists() + + +def test_activate_directory_scope(tmp_path): + proj = tmp_path / "myproj" + proj.mkdir() + hdir = paths.harnesses_root() / "h" / "v1" + save_bundle(hdir, default_bundle("h", "v1")) + (hdir / "CLAUDE.md").write_text("# h\n") + activate("h", "v1", scope="directory", cwd=proj) + assert (proj / ".claude" / "CLAUDE.md").is_symlink() diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index d79c2a6..69501eb 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -48,9 +48,7 @@ class TestLoadConfigCustom: def test_load_config_custom(self, tmp_path): config_file = tmp_path / "config.toml" - config_file.write_text( - '[secrets]\nbackend = "plaintext"\n\n[paths]\ncrux_home = "/custom"\n' - ) + config_file.write_text('[secrets]\nbackend = "plaintext"\n\n[paths]\ncrux_home = "/custom"\n') cfg = load_config(path=config_file) assert cfg["secrets"]["backend"] == "plaintext" assert cfg["paths"]["crux_home"] == "/custom" diff --git a/tests/unit/test_harness_ops.py b/tests/unit/test_harness_ops.py new file mode 100644 index 0000000..dbddafb --- /dev/null +++ b/tests/unit/test_harness_ops.py @@ -0,0 +1,58 @@ +"""Tests for crux_cli.harness_ops.""" + +from __future__ import annotations + +import pytest + +from crux_cli import store +from crux_cli.bundle import load_bundle +from crux_cli.harness_ops import bump, new_harness + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + + +def test_new_creates_v1(tmp_path): + hdir = new_harness("foo") + assert hdir.name == "v1" + assert (hdir / "bundle.toml").exists() + assert (hdir / "CLAUDE.md").exists() + assert (hdir / "hooks").is_dir() + + +def test_new_collides(tmp_path): + new_harness("foo") + with pytest.raises(FileExistsError): + new_harness("foo") + + +def test_bump_copies_latest(tmp_path): + hdir = new_harness("foo") + (hdir / "CLAUDE.md").write_text("hi v1\n") + nxt = bump("foo") + assert nxt.name == "v2" + assert (nxt / "CLAUDE.md").read_text() == "hi v1\n" + assert store.harness_versions("foo") == ["v1", "v2"] + assert load_bundle(nxt)["harness"]["version"] == "v2" + + +def test_bump_missing(tmp_path): + with pytest.raises(FileNotFoundError): + bump("nope") + + +def test_bump_preserves_includes(tmp_path): + from crux_cli.bundle import save_bundle + + hdir = new_harness("h") + b = load_bundle(hdir) + b["skills"]["include"] = ["s1", "s2"] + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + nxt = bump("h") + nb = load_bundle(nxt) + assert nb["skills"]["include"] == ["s1", "s2"] + assert nb["mcps"]["include"] == ["fs"] diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py index 76903b0..ba3672d 100644 --- a/tests/unit/test_health.py +++ b/tests/unit/test_health.py @@ -1,4 +1,5 @@ """Unit tests for crux_cli.health — all subprocess calls mocked.""" + import json import shutil import subprocess @@ -161,9 +162,11 @@ def test_non_json_lines_skipped(self, mocker): mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") mixed_stdout = ( b"Starting server...\n" - + json.dumps(INIT_RESPONSE).encode() + b"\n" + + json.dumps(INIT_RESPONSE).encode() + + b"\n" + b"Some log line\n" - + json.dumps(TOOLS_LIST_RESPONSE).encode() + b"\n" + + json.dumps(TOOLS_LIST_RESPONSE).encode() + + b"\n" ) mock_proc = MagicMock() mock_proc.communicate.return_value = (mixed_stdout, b"") diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py index 9c8cdfe..5ca0e72 100644 --- a/tests/unit/test_install.py +++ b/tests/unit/test_install.py @@ -37,9 +37,11 @@ def test_install_single_mcp(self, tmp_path): crux_json["mcps"].append("memory") save_crux_json(project, crux_json) - registry = _registry(mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - }) + registry = _registry( + mcps={ + "memory": {"command": "npx", "args": ["-y", "pkg"]}, + } + ) success, issues = sync_project(project, registry) assert success assert issues == [] @@ -57,10 +59,12 @@ def test_install_multiple_mcps(self, tmp_path): crux_json = {"name": "proj", "mcps": ["memory", "github-mcp"], "skills": []} save_crux_json(project, crux_json) - registry = _registry(mcps={ - "memory": {"command": "npx", "args": ["-y", "mem-pkg"]}, - "github-mcp": {"command": "npx", "args": ["-y", "gh-pkg"]}, - }) + registry = _registry( + mcps={ + "memory": {"command": "npx", "args": ["-y", "mem-pkg"]}, + "github-mcp": {"command": "npx", "args": ["-y", "gh-pkg"]}, + } + ) success, issues = sync_project(project, registry) assert success assert issues == [] @@ -83,9 +87,11 @@ def test_install_skill_copies_to_claude_skills(self, tmp_path): crux_json = {"name": "proj", "mcps": [], "skills": ["my-skill"]} save_crux_json(project, crux_json) - registry = _registry(skills={ - "my-skill": {"type": "local", "source_dir": str(skill_source)}, - }) + registry = _registry( + skills={ + "my-skill": {"type": "local", "source_dir": str(skill_source)}, + } + ) success, issues = sync_project(project, registry) assert success assert issues == [] @@ -132,9 +138,11 @@ def test_install_triggers_sync(self, tmp_path): crux_json = {"name": "proj", "mcps": ["memory"], "skills": []} save_crux_json(project, crux_json) - registry = _registry(mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - }) + registry = _registry( + mcps={ + "memory": {"command": "npx", "args": ["-y", "pkg"]}, + } + ) sync_project(project, registry) assert (project / ".mcp.json").exists() @@ -196,9 +204,11 @@ def test_uninstall_triggers_sync(self, tmp_path): crux_json = {"name": "proj", "mcps": ["memory"], "skills": []} save_crux_json(project, crux_json) - registry = _registry(mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - }) + registry = _registry( + mcps={ + "memory": {"command": "npx", "args": ["-y", "pkg"]}, + } + ) # Install + sync sync_project(project, registry) diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py index 954efc9..d3e5f0d 100644 --- a/tests/unit/test_manifest.py +++ b/tests/unit/test_manifest.py @@ -1,4 +1,5 @@ """Unit tests for crux_cli.manifest — registry and crux.json I/O.""" + import json import crux_cli.manifest as m diff --git a/tests/unit/test_mcp_emit.py b/tests/unit/test_mcp_emit.py new file mode 100644 index 0000000..7678a00 --- /dev/null +++ b/tests/unit/test_mcp_emit.py @@ -0,0 +1,84 @@ +"""Tests for crux_cli.mcp_emit.""" + +from __future__ import annotations + +import json + +import pytest + +from crux_cli import paths, store +from crux_cli.bundle import default_bundle, save_bundle +from crux_cli.mcp_emit import emit_mcp_json + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + + +def test_emit_npm_mcp(tmp_path): + store.save_mcp_entry("fs", {"type": "npm", "command": "npx", "args": ["-y", "@x/fs"]}) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + data = json.loads(out.read_text()) + assert data["mcpServers"]["fs"]["command"] == "npx" + assert data["mcpServers"]["fs"]["args"] == ["-y", "@x/fs"] + + +def test_emit_keychain_wrapped(tmp_path): + store.save_mcp_entry( + "wikijs", + { + "type": "npm", + "command": "npx", + "args": ["-y", "wikijs-mcp"], + "auth": {"type": "keychain", "env_vars": ["API_KEY"]}, + }, + ) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["mcps"]["include"] = ["wikijs"] + save_bundle(hdir, b) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + data = json.loads(out.read_text()) + e = data["mcpServers"]["wikijs"] + assert e["env"]["CRUX_MCP_NAME"] == "wikijs" + assert e["env"]["CRUX_AUTH_ENV_VARS"] == "API_KEY" + assert e["command"].endswith("keychain-auth.sh") + assert e["args"][0] == "npx" + + +def test_emit_http_bearer(tmp_path): + store.save_mcp_entry( + "remote", + { + "type": "http", + "url": "https://example.com/mcp", + "auth": {"type": "bearer", "keychain_key": "API_TOKEN"}, + }, + ) + hdir = paths.harnesses_root() / "h" / "v1" + b = default_bundle("h", "v1") + b["mcps"]["include"] = ["remote"] + save_bundle(hdir, b) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + data = json.loads(out.read_text()) + e = data["mcpServers"]["remote"] + assert e["command"].endswith("http-bridge-auth.sh") + assert e["env"]["CRUX_BRIDGE_URL"] == "https://example.com/mcp" + assert e["env"]["CRUX_AUTH_KEYCHAIN_KEY"] == "API_TOKEN" + + +def test_emit_empty_bundle(tmp_path): + hdir = paths.harnesses_root() / "h" / "v1" + save_bundle(hdir, default_bundle("h", "v1")) + out = tmp_path / ".mcp.json" + emit_mcp_json("h", "v1", out_path=out) + assert json.loads(out.read_text()) == {"mcpServers": {}} diff --git a/tests/unit/test_migrate_v1.py b/tests/unit/test_migrate_v1.py new file mode 100644 index 0000000..b26e73b --- /dev/null +++ b/tests/unit/test_migrate_v1.py @@ -0,0 +1,64 @@ +"""Tests for crux_cli.migrate_v1.""" + +from __future__ import annotations + +import json + +import pytest + +from crux_cli import store +from crux_cli.bundle import load_bundle +from crux_cli.migrate_v1 import migrate_cwd +from crux_cli.pointer import read_pointer + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / "crux_home")) + monkeypatch.delenv("CRUX_HOME", raising=False) + + +def test_migrate_basic(tmp_path): + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"name": "myproj", "mcps": ["fs"], "skills": ["s"]})) + migrate_cwd(proj) + assert not (proj / "crux.json").exists() + assert (proj / "crux.toml").exists() + assert read_pointer(proj / "crux.toml") == ("myproj", "v1") + assert store.harness_versions("myproj") == ["v1"] + b = load_bundle(store.harness_dir("myproj", "v1")) + assert b["mcps"]["include"] == ["fs"] + assert b["skills"]["include"] == ["s"] + + +def test_migrate_with_explicit_name(tmp_path): + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"mcps": [], "skills": []})) + migrate_cwd(proj, name="custom") + assert read_pointer(proj / "crux.toml") == ("custom", "v1") + + +def test_migrate_falls_back_to_dirname(tmp_path): + proj = tmp_path / "fallback-dir" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"mcps": [], "skills": []})) + migrate_cwd(proj) + assert "fallback-dir" in store.list_harnesses() + + +def test_migrate_collision(tmp_path): + from crux_cli.harness_ops import new_harness + + new_harness("myproj") + proj = tmp_path / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"name": "myproj", "mcps": [], "skills": []})) + with pytest.raises(FileExistsError): + migrate_cwd(proj) + + +def test_migrate_no_crux_json(tmp_path): + with pytest.raises(FileNotFoundError): + migrate_cwd(tmp_path) diff --git a/tests/unit/test_no_secret_leaks.py b/tests/unit/test_no_secret_leaks.py index 8cdcba5..ee142ec 100644 --- a/tests/unit/test_no_secret_leaks.py +++ b/tests/unit/test_no_secret_leaks.py @@ -151,9 +151,7 @@ def test_mcp_json_authed_mcp_uses_shared_launcher(self, tmp_path): project = tmp_path / "proj" _make_crux_json(project, mcps=["authed-mcp"]) - registry = _make_registry( - mcps={"authed-mcp": _keychain_mcp_data()} - ) + registry = _make_registry(mcps={"authed-mcp": _keychain_mcp_data()}) sync_project(project, registry) @@ -346,9 +344,7 @@ def test_full_sync_mcp_json_env_config_only(self, tmp_path, monkeypatch): project_dir = tmp_path / "project" - registry = _make_registry( - mcps={"authed-mcp": _keychain_mcp_data()} - ) + registry = _make_registry(mcps={"authed-mcp": _keychain_mcp_data()}) _make_crux_json(project_dir, mcps=["authed-mcp"]) sync_project(project_dir, registry) diff --git a/tests/unit/test_registry_ops.py b/tests/unit/test_registry_ops.py new file mode 100644 index 0000000..90c2614 --- /dev/null +++ b/tests/unit/test_registry_ops.py @@ -0,0 +1,110 @@ +"""Tests for crux_cli.registry_ops.""" + +from __future__ import annotations + +import pytest + +from crux_cli import store +from crux_cli.bundle import load_bundle, save_bundle +from crux_cli.harness_ops import new_harness +from crux_cli.registry_ops import ( + add_mcp, + add_plugin_local, + add_skill_local, + remove, +) + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + # Avoid actually invoking npm / uv. + monkeypatch.setattr("crux_cli.registry_ops.install_npm_package", lambda p: (True, "")) + monkeypatch.setattr("crux_cli.registry_ops.install_uv_package", lambda p: (True, "")) + + +def test_add_mcp_npm(tmp_path): + add_mcp("fs", source_kind="npm", source="@x/fs", skip_install=False) + data = store.load_mcp_entry("fs") + assert data["type"] == "npm" + assert data["command"] == "npx" + assert "@x/fs" in data["args"] + + +def test_add_mcp_uvx(tmp_path): + add_mcp("py", source_kind="uvx", source="mcp-server-py") + data = store.load_mcp_entry("py") + assert data["type"] == "uvx" + assert data["command"] == "uvx" + assert data["args"][0] == "mcp-server-py" + + +def test_add_mcp_with_keychain(tmp_path): + add_mcp("wikijs", source_kind="npm", source="wikijs-mcp", keychain=["API_KEY"]) + data = store.load_mcp_entry("wikijs") + assert data["auth"]["type"] == "keychain" + assert data["auth"]["env_vars"] == ["API_KEY"] + + +def test_add_mcp_local(tmp_path): + add_mcp("custom", source_kind="local", source="/opt/myapp") + data = store.load_mcp_entry("custom") + assert data["source_dir"] == "/opt/myapp" + + +def test_add_mcp_http(tmp_path): + add_mcp("remote", source_kind="http", source="https://example.com/mcp") + data = store.load_mcp_entry("remote") + assert data["url"] == "https://example.com/mcp" + + +def test_add_mcp_collision(tmp_path): + add_mcp("fs", source_kind="npm", source="x") + with pytest.raises(FileExistsError): + add_mcp("fs", source_kind="npm", source="x") + + +def test_add_skill_local(tmp_path): + src = tmp_path / "myskill" + src.mkdir() + (src / "SKILL.md").write_text("hello") + add_skill_local("myskill", src) + assert "myskill" in store.list_skills() + assert (store.skill_dir("myskill") / "SKILL.md").exists() + + +def test_add_skill_local_missing(tmp_path): + with pytest.raises(FileNotFoundError): + add_skill_local("x", tmp_path / "nope") + + +def test_add_plugin_local(tmp_path): + src = tmp_path / "p" + src.mkdir() + (src / "plugin.toml").write_text("hello") + add_plugin_local("p", src, version="v1") + assert store.plugin_versions("p") == ["v1"] + + +def test_remove_unreferenced(tmp_path): + add_mcp("fs", source_kind="npm", source="x") + remove("fs", force=False) + assert "fs" not in store.list_mcps() + + +def test_remove_referenced_requires_force(tmp_path): + add_mcp("fs", source_kind="npm", source="x") + hdir = new_harness("h") + b = load_bundle(hdir) + b["mcps"]["include"] = ["fs"] + save_bundle(hdir, b) + with pytest.raises(RuntimeError): + remove("fs", force=False) + remove("fs", force=True) + assert "fs" not in store.list_mcps() + + +def test_remove_unknown_name(tmp_path): + with pytest.raises(FileNotFoundError): + remove("nope", force=False) diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py index fab4a30..d6da6c3 100644 --- a/tests/unit/test_sandbox.py +++ b/tests/unit/test_sandbox.py @@ -1,4 +1,5 @@ """Unit tests for crux_cli.sandbox — sandbox lifecycle.""" + import json import re @@ -10,6 +11,7 @@ # generate_run_id # --------------------------------------------------------------------------- + class TestGenerateRunId: def test_format_matches_pattern(self): run_id = sb.generate_run_id() @@ -25,6 +27,7 @@ def test_uniqueness(self): # create_sandbox # --------------------------------------------------------------------------- + class TestCreateSandbox: def test_creates_sandbox_and_workspace_dirs(self, patch_sandbox_paths, crux_root): registry = { @@ -64,6 +67,7 @@ def test_unknown_mcp_skipped_silently(self, patch_sandbox_paths, crux_root): # write_run_meta / update_run_meta # --------------------------------------------------------------------------- + class TestRunMeta: def test_write_creates_meta_file(self, patch_sandbox_paths, crux_root): sandbox_path = crux_root / "sandbox" / "run1" @@ -110,6 +114,7 @@ def test_update_creates_file_if_missing(self, patch_sandbox_paths, crux_root): # list_runs # --------------------------------------------------------------------------- + class TestListRuns: def test_empty_sandbox_returns_empty_list(self, patch_sandbox_paths, crux_root): assert sb.list_runs() == [] @@ -144,6 +149,7 @@ def test_skips_non_directories(self, patch_sandbox_paths, crux_root): # clean_runs # --------------------------------------------------------------------------- + class TestCleanRuns: def test_force_removes_all_sandbox_dirs(self, patch_sandbox_paths, crux_root): for name in ["a", "b", "c"]: @@ -184,6 +190,7 @@ def test_no_force_keeps_running(self, patch_sandbox_paths, crux_root): # load_run_manifest # --------------------------------------------------------------------------- + class TestLoadRunManifest: def test_loads_valid_file(self, tmp_path): data = {"name": "test", "task": "do it", "mcps": ["memory"]} diff --git a/tests/unit/test_sandbox_extended.py b/tests/unit/test_sandbox_extended.py index 4fc33b6..2d99bb0 100644 --- a/tests/unit/test_sandbox_extended.py +++ b/tests/unit/test_sandbox_extended.py @@ -59,25 +59,19 @@ def simple_registry(): class TestSandboxCreatesDirectory: def test_sandbox_creates_directory(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) assert path.is_dir() assert (path / "workspace").is_dir() assert path.name == "20260316-abcd" def test_sandbox_path_under_crux_sandbox(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-1234", ["memory"], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-1234", ["memory"], registry=simple_registry, skip_preflight=True) assert path.parent == sandbox_home / "sandbox" class TestSandboxScopedMcpJson: def test_sandbox_scoped_mcp_json(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) mcp_json = json.loads((path / ".mcp.json").read_text()) assert "mcpServers" in mcp_json assert "memory" in mcp_json["mcpServers"] @@ -85,18 +79,14 @@ def test_sandbox_scoped_mcp_json(self, sandbox_home, simple_registry): assert server.get("command") or server.get("args") is not None def test_sandbox_empty_mcps(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) mcp_json = json.loads((path / ".mcp.json").read_text()) assert mcp_json["mcpServers"] == {} class TestSandboxRunMeta: def test_sandbox_run_meta(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) meta = write_run_meta(path, "20260316-abcd", "do stuff", ["memory"]) assert meta["id"] == "20260316-abcd" assert meta["task"] == "do stuff" @@ -108,9 +98,7 @@ def test_sandbox_run_meta(self, sandbox_home, simple_registry): assert stored["id"] == "20260316-abcd" def test_update_meta_merges(self, sandbox_home, simple_registry): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "task", []) update_run_meta(path, status="done", exit_code=0) stored = json.loads((path / "run-meta.json").read_text()) @@ -137,9 +125,7 @@ def test_run_id_uniqueness(self): class TestRunInvokesClaude: def test_run_invokes_claude(self, sandbox_home, simple_registry, mocker): """run_agent calls claude with correct args (mocked).""" - path = create_sandbox( - "20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "test task", ["memory"]) mocker.patch("crux_cli.sandbox.shutil.which", return_value="/usr/local/bin/claude") @@ -173,9 +159,7 @@ def _mock_popen(mocker, returncode=0): class TestRunCapturesExitCode: def test_run_captures_exit_code(self, sandbox_home, simple_registry, mocker): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "fail task", []) _mock_popen(mocker, returncode=42) @@ -183,9 +167,7 @@ def test_run_captures_exit_code(self, sandbox_home, simple_registry, mocker): assert exit_code == 42 def test_run_exit_zero_on_success(self, sandbox_home, simple_registry, mocker): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "ok task", []) _mock_popen(mocker, returncode=0) @@ -194,9 +176,7 @@ def test_run_exit_zero_on_success(self, sandbox_home, simple_registry, mocker): class TestRunUpdatesMeta: def test_run_updates_meta(self, sandbox_home, simple_registry, mocker): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "task", []) _mock_popen(mocker, returncode=0) @@ -209,9 +189,7 @@ def test_run_updates_meta(self, sandbox_home, simple_registry, mocker): assert "duration_seconds" in meta def test_run_updates_meta_on_failure(self, sandbox_home, simple_registry, mocker): - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "task", []) _mock_popen(mocker, returncode=1) @@ -226,9 +204,7 @@ class TestRunTimeoutKillsProcess: def test_run_timeout_kills_process(self, sandbox_home, simple_registry, mocker): import subprocess as sp - path = create_sandbox( - "20260316-abcd", [], registry=simple_registry, skip_preflight=True - ) + path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) write_run_meta(path, "20260316-abcd", "slow task", []) mock_proc = MagicMock() @@ -322,9 +298,7 @@ def test_run_clean_removes_completed(self, sandbox_home): # Create 1 running run running = base / "20260316-run0" running.mkdir(parents=True) - (running / "run-meta.json").write_text( - json.dumps({"id": "20260316-run0", "status": "running"}) - ) + (running / "run-meta.json").write_text(json.dumps({"id": "20260316-run0", "status": "running"})) removed = clean_runs(keep=5) # Should remove 2 oldest completed (7 - 5 = 2), keep running @@ -362,9 +336,7 @@ def test_run_clean_keeps_recent(self, sandbox_home): for i in range(5): d = base / f"20260310-{i:04x}" d.mkdir(parents=True) - (d / "run-meta.json").write_text( - json.dumps({"id": d.name, "status": "done"}) - ) + (d / "run-meta.json").write_text(json.dumps({"id": d.name, "status": "done"})) removed = clean_runs(keep=5) assert removed == 0 # nothing to remove, exactly at limit diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py index d3c516e..fad67e3 100644 --- a/tests/unit/test_setup.py +++ b/tests/unit/test_setup.py @@ -16,6 +16,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_old_layout(root: Path, *, with_sources: bool = True) -> Path: """Build a fake old marketplace layout under *root* and return *root*. @@ -92,6 +93,7 @@ def _make_bundled_skill(tmp_path: Path) -> Path: # W4A.1 — Fresh install # --------------------------------------------------------------------------- + class TestSetupCreatesStructure: """run_setup creates the ~/.crux/ directory tree.""" @@ -230,6 +232,7 @@ def test_setup_idempotent(self, monkeypatch, tmp_path): # W4A.2 — Migration from old layout # --------------------------------------------------------------------------- + class TestSetupDetectsOldLayout: """Migration detects the old marketplace/marketplace.json.""" @@ -416,6 +419,7 @@ def test_setup_migration_never_deletes_original(self, monkeypatch, tmp_path): # W4A.3 — Bundled skill # --------------------------------------------------------------------------- + class TestSkillBundledInPackage: """The skill file exists in the package data directory.""" @@ -455,6 +459,7 @@ def test_install_skill_returns_false_when_missing(self, tmp_path): # Unit tests for internal helpers # --------------------------------------------------------------------------- + class TestConvertEntry: """_convert_entry transforms git-submodule to github.""" diff --git a/tests/unit/test_setup_v2.py b/tests/unit/test_setup_v2.py new file mode 100644 index 0000000..bf397ee --- /dev/null +++ b/tests/unit/test_setup_v2.py @@ -0,0 +1,60 @@ +"""Tests for crux_cli.setup (v2).""" + +from __future__ import annotations + +import pytest + +from crux_cli import paths +from crux_cli.setup import run_setup + + +@pytest.fixture(autouse=True) +def _redir(monkeypatch, tmp_path): + monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + monkeypatch.delenv("CRUX_HOME", raising=False) + + +def test_run_setup_creates_tree(tmp_path): + res = run_setup() + for d in [ + paths.crux_home(), + paths.registry_root(), + paths.mcps_root(), + paths.skills_root(), + paths.plugins_root(), + paths.harnesses_root(), + paths.crux_home() / "launchers", + ]: + assert d.exists() + assert res.dirs_created + + +def test_run_setup_installs_skill(tmp_path): + res = run_setup() + assert res.skill_installed + assert (paths.skills_root() / "crux" / "SKILL.md").exists() + + +def test_run_setup_installs_launchers(tmp_path): + res = run_setup() + assert "keychain-auth.sh" in res.launchers_installed + assert "http-bridge-auth.sh" in res.launchers_installed + launcher = paths.crux_home() / "launchers" / "keychain-auth.sh" + assert launcher.exists() + assert launcher.stat().st_mode & 0o111 # executable + + +def test_run_setup_writes_config(tmp_path): + res = run_setup() + assert res.config_written + assert (paths.crux_home() / "config.toml").exists() + + +def test_run_setup_is_idempotent(tmp_path): + run_setup() + res = run_setup() + # Second run shouldn't write the config again (no dirs created either) + assert not res.config_written + assert res.dirs_created == [] + # But the skill copy is still re-done (cheap, ensures it's up-to-date) + assert res.skill_installed diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index a5cd6dd..6fd13a1 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -1,6 +1,5 @@ """Unit tests for crux_cli.validation""" - from crux_cli.validation import validate_crux_json, validate_name, validate_registry # --------------------------------------------------------------------------- diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py index 28e3f15..2a0dd01 100644 --- a/tests/unit/test_version.py +++ b/tests/unit/test_version.py @@ -30,9 +30,7 @@ class TestVersionCheckNewerAvailable: def test_version_check_newer_available(self): """--check shows upgrade instructions when a newer version exists on PyPI.""" fake_response = MagicMock() - fake_response.read.return_value = json.dumps( - {"info": {"version": "99.0.0"}} - ).encode() + fake_response.read.return_value = json.dumps({"info": {"version": "99.0.0"}}).encode() fake_response.__enter__ = lambda s: s fake_response.__exit__ = MagicMock(return_value=False) @@ -49,9 +47,7 @@ def test_version_check_up_to_date(self): """--check shows 'up to date' when PyPI version matches local.""" local = current_version() fake_response = MagicMock() - fake_response.read.return_value = json.dumps( - {"info": {"version": local}} - ).encode() + fake_response.read.return_value = json.dumps({"info": {"version": local}}).encode() fake_response.__enter__ = lambda s: s fake_response.__exit__ = MagicMock(return_value=False) From 3b12b24d6b5f3dacf1639178a575adb6d82b4f85 Mon Sep 17 00:00:00 2001 From: Kaushal Paneri Date: Sun, 10 May 2026 22:16:02 -0400 Subject: [PATCH 3/5] =?UTF-8?q?feat(v2):=20CLI=20surface=20=E2=80=94=20set?= =?UTF-8?q?up,=20registry,=20secret,=20harness,=20use,=20migrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire all v2 commands together: setup_cmd, doctor_cmd, migrate_cmd, registry_cmd, secret_cmd, harness_cmd (new/bump/list/show/edit), use_cmd (use/active). Rewrite cli/main.py with the v2 parser. Map exceptions to documented exit codes (1 usage, 2 not-found, 3 state/ conflict, 4 environment). Added __main__.py so `python -m crux_cli ...` works for tests. End-to-end coverage in tests/integration/test_cli_v2.py exercises the ten primary user workflows: fresh setup, versioning, directory vs user scope, `use -` rollback, `--none` deactivate, building a bundle from scratch, blocked removal, migration with and without --name, doctor, and refusal to clobber existing files. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/crux_cli/__main__.py | 6 + src/crux_cli/cli/commands/doctor_cmd.py | 33 +++ src/crux_cli/cli/commands/harness_cmd.py | 108 ++++++++ src/crux_cli/cli/commands/migrate_cmd.py | 13 + src/crux_cli/cli/commands/registry_cmd.py | 71 ++++++ src/crux_cli/cli/commands/secret_cmd.py | 25 ++ src/crux_cli/cli/commands/setup_cmd.py | 17 ++ src/crux_cli/cli/commands/use_cmd.py | 80 ++++++ src/crux_cli/cli/main.py | 284 ++++++++++------------ tests/integration/test_cli_v2.py | 283 +++++++++++++++++++++ 10 files changed, 766 insertions(+), 154 deletions(-) create mode 100644 src/crux_cli/__main__.py create mode 100644 src/crux_cli/cli/commands/doctor_cmd.py create mode 100644 src/crux_cli/cli/commands/harness_cmd.py create mode 100644 src/crux_cli/cli/commands/migrate_cmd.py create mode 100644 src/crux_cli/cli/commands/registry_cmd.py create mode 100644 src/crux_cli/cli/commands/secret_cmd.py create mode 100644 src/crux_cli/cli/commands/setup_cmd.py create mode 100644 src/crux_cli/cli/commands/use_cmd.py create mode 100644 tests/integration/test_cli_v2.py diff --git a/src/crux_cli/__main__.py b/src/crux_cli/__main__.py new file mode 100644 index 0000000..7116fb6 --- /dev/null +++ b/src/crux_cli/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m crux_cli ...`` invocation.""" + +from crux_cli.cli.main import main + +if __name__ == "__main__": + main() diff --git a/src/crux_cli/cli/commands/doctor_cmd.py b/src/crux_cli/cli/commands/doctor_cmd.py new file mode 100644 index 0000000..d8b13a3 --- /dev/null +++ b/src/crux_cli/cli/commands/doctor_cmd.py @@ -0,0 +1,33 @@ +"""``crux doctor`` — sanity-check the environment.""" + +from __future__ import annotations + +import argparse +import shutil +import sys + +from crux_cli import paths + +_REQUIRED_TOOLS = ("git", "uv", "npm", "claude") + + +def cmd_doctor(_args: argparse.Namespace) -> None: + issues = 0 + for d in ( + paths.crux_home(), + paths.registry_root(), + paths.mcps_root(), + paths.skills_root(), + paths.plugins_root(), + paths.harnesses_root(), + ): + if not d.exists(): + print(f"missing: {d} — run `crux setup`") + issues += 1 + for tool in _REQUIRED_TOOLS: + if shutil.which(tool) is None: + print(f"missing tool: {tool}") + issues += 1 + if issues: + sys.exit(4) + print("doctor: ok") diff --git a/src/crux_cli/cli/commands/harness_cmd.py b/src/crux_cli/cli/commands/harness_cmd.py new file mode 100644 index 0000000..0489d5f --- /dev/null +++ b/src/crux_cli/cli/commands/harness_cmd.py @@ -0,0 +1,108 @@ +"""``crux new/bump/list/show/edit`` — harness lifecycle and bundle editing.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +from crux_cli import store +from crux_cli.bundle import load_bundle, save_bundle +from crux_cli.harness_ops import bump, new_harness +from crux_cli.pointer import parse_ref, resolve_active + + +def cmd_new(args: argparse.Namespace) -> None: + hdir = new_harness(args.name) + print(f"new: created {args.name}@v1 at {hdir}") + + +def cmd_bump(args: argparse.Namespace) -> None: + hdir = bump(args.name) + print(f"bump: created {args.name}@{hdir.name}") + + +def cmd_list(args: argparse.Namespace) -> None: + name = getattr(args, "name", None) + if name: + for v in store.harness_versions(name): + print(v) + return + for h in store.list_harnesses(): + versions = store.harness_versions(h) + print(f"{h}: {', '.join(versions)}") + + +def _resolve_ref(ref: str) -> tuple[str, str]: + name, version = parse_ref(ref) + if version is None: + version = store.latest_version(name) + if version is None: + raise FileNotFoundError(f"harness '{name}'") + return name, version + + +def cmd_show(args: argparse.Namespace) -> None: + name, version = _resolve_ref(args.ref) + hdir = store.harness_dir(name, version) + bundle = load_bundle(hdir) + print(f"# {name}@{version}") + desc = bundle.get("harness", {}).get("description", "") + if desc: + print(f"description: {desc}") + print(f"skills: {', '.join(bundle['skills']['include'])}") + print(f"mcps: {', '.join(bundle['mcps']['include'])}") + print(f"plugins: {', '.join(bundle['plugins']['include'])}") + hooks = bundle.get("hooks", {}) or {} + if hooks: + print("hooks:") + for k, v in hooks.items(): + print(f" {k} = {v}") + + +def _active_ref_or_arg(args: argparse.Namespace) -> tuple[str, str]: + ref = getattr(args, "ref", None) + if ref: + return _resolve_ref(ref) + res = resolve_active(Path.cwd()) + if res is None: + print("crux: edit: no active harness, pass a ref", file=sys.stderr) + sys.exit(2) + _scope, name, version, _ = res + if version is None: + version = store.latest_version(name) + if version is None: + raise FileNotFoundError(f"harness '{name}'") + return name, version + + +def cmd_edit(args: argparse.Namespace) -> None: + name, version = _active_ref_or_arg(args) + hdir = store.harness_dir(name, version) + what = args.edit_what + + if what == "claude": + editor = os.environ.get("EDITOR", "vi") + subprocess.run([editor, str(hdir / "CLAUDE.md")], check=False) # noqa: S603 + return + + if what == "hooks": + (hdir / "hooks").mkdir(exist_ok=True) + editor = os.environ.get("EDITOR", "vi") + subprocess.run([editor, str(hdir / "hooks")], check=False) # noqa: S603 + return + + bundle = load_bundle(hdir) + key = {"skills": "skills", "mcps": "mcps", "plugins": "plugins"}[what] + include: list[str] = list(bundle[key]["include"]) + for item in args.add or []: + if item not in include: + include.append(item) + for item in args.remove or []: + if item in include: + include.remove(item) + bundle[key]["include"] = include + save_bundle(hdir, bundle) + print(f"edit {what}: {', '.join(include)}") diff --git a/src/crux_cli/cli/commands/migrate_cmd.py b/src/crux_cli/cli/commands/migrate_cmd.py new file mode 100644 index 0000000..42962cc --- /dev/null +++ b/src/crux_cli/cli/commands/migrate_cmd.py @@ -0,0 +1,13 @@ +"""``crux migrate`` — convert a v1 crux.json into a v2 harness + pointer.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from crux_cli.migrate_v1 import migrate_cwd + + +def cmd_migrate(args: argparse.Namespace) -> None: + name = migrate_cwd(Path.cwd(), name=getattr(args, "name", None)) + print(f"migrate: created harness {name}@v1 and crux.toml pointer") diff --git a/src/crux_cli/cli/commands/registry_cmd.py b/src/crux_cli/cli/commands/registry_cmd.py new file mode 100644 index 0000000..b58977b --- /dev/null +++ b/src/crux_cli/cli/commands/registry_cmd.py @@ -0,0 +1,71 @@ +"""``crux registry add/remove/list``.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from crux_cli import store +from crux_cli.registry_ops import ( + add_mcp, + add_plugin_local, + add_skill_github, + add_skill_local, + remove, +) + + +def _resolve_mcp_kind(args: argparse.Namespace) -> str: + flags = [ + ("npm", args.npm), + ("uvx", args.uvx), + ("github", args.github), + ("local", args.local), + ("http", args.http), + ] + chosen = [name for name, on in flags if on] + if len(chosen) > 1: + raise SystemExit("crux: registry add: pick at most one of --npm/--uvx/--github/--local/--http") + return chosen[0] if chosen else "npm" + + +def cmd_registry_add(args: argparse.Namespace) -> None: + extra_args = args.args.split() if getattr(args, "args", None) else [] + keychain = [v.strip() for v in args.keychain.split(",") if v.strip()] if getattr(args, "keychain", None) else None + if args.kind == "mcp": + add_mcp( + args.name, + source_kind=_resolve_mcp_kind(args), + source=args.source, + args=extra_args, + keychain=keychain, + skip_install=args.skip_install, + ) + print(f"registry: added mcp {args.name}") + elif args.kind == "skill": + if args.github: + add_skill_github(args.name, args.source) + else: + add_skill_local(args.name, Path(args.source)) + print(f"registry: added skill {args.name}") + elif args.kind == "plugin": + add_plugin_local(args.name, Path(args.source), version=args.version) + print(f"registry: added plugin {args.name}@{args.version}") + + +def cmd_registry_remove(args: argparse.Namespace) -> None: + remove(args.name, force=args.force) + print(f"registry: removed {args.name}") + + +def cmd_registry_list(_args: argparse.Namespace) -> None: + print("# mcps:") + for n in store.list_mcps(): + print(f" {n}") + print("# skills:") + for n in store.list_skills(): + print(f" {n}") + print("# plugins:") + for n in store.list_plugins(): + for v in store.plugin_versions(n): + print(f" {n}@{v}") diff --git a/src/crux_cli/cli/commands/secret_cmd.py b/src/crux_cli/cli/commands/secret_cmd.py new file mode 100644 index 0000000..a93d2a6 --- /dev/null +++ b/src/crux_cli/cli/commands/secret_cmd.py @@ -0,0 +1,25 @@ +"""``crux secret set/list/remove`` — manage credentials referenced by MCPs.""" + +from __future__ import annotations + +import argparse +import getpass + +from crux_cli.secrets import get_backend + + +def cmd_secret_set(args: argparse.Namespace) -> None: + val = args.value or getpass.getpass(f"value for {args.mcp}/{args.key}: ") + get_backend().set(args.mcp, args.key, val) + print(f"secret: set {args.mcp}/{args.key}") + + +def cmd_secret_list(args: argparse.Namespace) -> None: + keys = get_backend().list_keys(getattr(args, "mcp", None)) + for mcp, names in keys.items(): + print(f"{mcp}: {', '.join(names)}") + + +def cmd_secret_remove(args: argparse.Namespace) -> None: + get_backend().delete(args.mcp, args.key) + print(f"secret: removed {args.mcp}/{args.key}") diff --git a/src/crux_cli/cli/commands/setup_cmd.py b/src/crux_cli/cli/commands/setup_cmd.py new file mode 100644 index 0000000..3e314aa --- /dev/null +++ b/src/crux_cli/cli/commands/setup_cmd.py @@ -0,0 +1,17 @@ +"""``crux setup`` — initialise ~/.crux/.""" + +from __future__ import annotations + +import argparse + +from crux_cli.setup import run_setup + + +def cmd_setup(_args: argparse.Namespace) -> None: + res = run_setup() + print( + f"setup: dirs={len(res.dirs_created)} " + f"skill={'ok' if res.skill_installed else 'skip'} " + f"launchers={len(res.launchers_installed)} " + f"config={'written' if res.config_written else 'kept'}" + ) diff --git a/src/crux_cli/cli/commands/use_cmd.py b/src/crux_cli/cli/commands/use_cmd.py new file mode 100644 index 0000000..ac7fc8c --- /dev/null +++ b/src/crux_cli/cli/commands/use_cmd.py @@ -0,0 +1,80 @@ +"""``crux use`` (activate, rollback, deactivate) and ``crux active`` (show).""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from crux_cli import history, paths, store +from crux_cli.activation import activate, remove_managed_symlinks +from crux_cli.pointer import parse_ref, read_pointer, resolve_active, write_pointer + + +def _pointer_path(scope: str, cwd: Path) -> Path: + return paths.active_pointer_path() if scope == "user" else cwd / "crux.toml" + + +def _history_path(scope: str, cwd: Path) -> Path: + return paths.history_path() if scope == "user" else cwd / ".crux" / "history" + + +def _scope_target(scope: str, cwd: Path) -> Path: + return paths.claude_user_dir() if scope == "user" else paths.claude_dir_for(cwd) + + +def _previous_ref_string(pointer: Path) -> str: + parsed = read_pointer(pointer) + if parsed is None: + return "" + name, version = parsed + if version is None: + version = store.latest_version(name) or "v?" + return f"{name}@{version}" + + +def cmd_use(args: argparse.Namespace) -> None: + cwd = Path.cwd() + scope = "user" if args.user else "directory" + pointer = _pointer_path(scope, cwd) + + if args.none: + if pointer.exists(): + pointer.unlink() + remove_managed_symlinks(_scope_target(scope, cwd)) + print(f"use: deactivated ({scope})") + return + + ref = args.ref + if ref == "-": + prev = history.pop_previous(_history_path(scope, cwd)) + if not prev: + print("crux: use: no previous harness in history", file=sys.stderr) + sys.exit(3) + ref = prev + + if not ref: + print("crux: use: missing harness reference", file=sys.stderr) + sys.exit(1) + + name, version = parse_ref(ref) + if version is None: + version = store.latest_version(name) + if version is None: + raise FileNotFoundError(f"harness '{name}'") + + prev_ref = _previous_ref_string(pointer) + + activate(name, version, scope=scope, cwd=cwd) + write_pointer(pointer, f"{name}@{version}") + history.append(_history_path(scope, cwd), prev=prev_ref or None, new=f"{name}@{version}") + print(f"use: {name}@{version} ({scope})") + + +def cmd_active(_args: argparse.Namespace) -> None: + res = resolve_active(Path.cwd()) + if res is None: + print("active: (none)") + return + scope, name, version, pointer_path = res + print(f"active: {name}@{version or 'latest'} ({scope}, {pointer_path})") diff --git a/src/crux_cli/cli/main.py b/src/crux_cli/cli/main.py index 70c8ea0..5a2bf49 100644 --- a/src/crux_cli/cli/main.py +++ b/src/crux_cli/cli/main.py @@ -1,167 +1,143 @@ -"""Entry point for the crux CLI.""" +"""Crux v2 CLI entry point.""" from __future__ import annotations import argparse +import sys +from crux_cli.activation import ConflictError -def main() -> None: - """Parse CLI arguments and dispatch to the appropriate command handler.""" - from crux_cli.cli.commands.doctor import cmd_doctor, cmd_init - from crux_cli.cli.commands.mcp import ( - cmd_mcp_add, - cmd_mcp_auth, - cmd_mcp_list, - cmd_mcp_remove, - cmd_mcp_search, - cmd_mcp_status, - cmd_mcp_upgrade, + +def _build_parser() -> argparse.ArgumentParser: + from crux_cli.cli.commands.doctor_cmd import cmd_doctor + from crux_cli.cli.commands.harness_cmd import ( + cmd_bump, + cmd_edit, + cmd_list, + cmd_new, + cmd_show, + ) + from crux_cli.cli.commands.migrate_cmd import cmd_migrate + from crux_cli.cli.commands.registry_cmd import ( + cmd_registry_add, + cmd_registry_list, + cmd_registry_remove, ) - from crux_cli.cli.commands.project import ( - cmd_project_create, - cmd_project_install, - cmd_project_status, - cmd_project_sync, - cmd_project_uninstall, + from crux_cli.cli.commands.secret_cmd import ( + cmd_secret_list, + cmd_secret_remove, + cmd_secret_set, ) - from crux_cli.cli.commands.skill import cmd_skill_add, cmd_skill_list, cmd_skill_remove - from crux_cli.cli.commands.task import cmd_task - from crux_cli.cli.commands.version import cmd_version - - parser = argparse.ArgumentParser(description="Crux — Agentic Tool Manager for Claude Code") - subparsers = parser.add_subparsers(dest="command", required=True) - - # ── crux init ────────────────────────────────────────────────────── - subparsers.add_parser("init", help="Initialise ~/.crux/").set_defaults(func=cmd_init) - - # ── crux doctor ──────────────────────────────────────────────────── - subparsers.add_parser("doctor", help="Check Crux environment health").set_defaults(func=cmd_doctor) - - # ── crux version ─────────────────────────────────────────────────── - p = subparsers.add_parser("version", help="Show crux-cli version") - p.add_argument("--check", action="store_true", help="Check for updates") - p.set_defaults(func=cmd_version) - - # ── crux mcp ─────────────────────────────────────────────────────── - mcp_parser = subparsers.add_parser("mcp", help="Manage MCP servers") - mcp_sub = mcp_parser.add_subparsers(dest="mcp_command", required=True) - - p = mcp_sub.add_parser("add", help="Register a new MCP server") - p.add_argument("name", help="Name for the MCP") - p.add_argument("--uvx", help="PyPI package to run via uvx") - p.add_argument("--npx", help="npm package") - p.add_argument("--github", help="GitHub repo (e.g. user/repo)") - p.add_argument("--local", help="Local directory path") - p.add_argument("--command", help="Command to run the MCP") - p.add_argument("--args", help="Args for the command, space-separated") - p.add_argument("--tags", help="Comma-separated tags") - p.add_argument("--keychain", help="Comma-separated env var names for keychain auth") - p.add_argument("--build-cmd", dest="build_cmd", help="Build command after clone") - p.set_defaults(func=cmd_mcp_add) - - p = mcp_sub.add_parser("remove", help="Unregister an MCP server") - p.add_argument("name", help="Name to remove") - secrets_group = p.add_mutually_exclusive_group() - secrets_group.add_argument("--keep-secrets", action="store_true", help="Keep keychain secrets (no prompt)") - secrets_group.add_argument("--remove-secrets", action="store_true", help="Remove keychain secrets (no prompt)") - p.set_defaults(func=cmd_mcp_remove) - - p = mcp_sub.add_parser("list", help="List registered MCP servers") - p.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") - p.set_defaults(func=cmd_mcp_list) - - p = mcp_sub.add_parser("search", help="Search the official MCP Registry") - p.add_argument("query", help="Search query") - p.add_argument("--limit", type=int, default=20, metavar="N", help="Max results (default: 20)") - p.add_argument("--add", action="store_true", help="Interactively add a result") - p.set_defaults(func=cmd_mcp_search) - - p = mcp_sub.add_parser("upgrade", help="Update cloned repos") - p.add_argument("names", nargs="*", help="Specific names to upgrade (default: all)") - p.add_argument("--dry-run", action="store_true", help="Show what would be updated") - p.set_defaults(func=cmd_mcp_upgrade) - - p = mcp_sub.add_parser("auth", help="Authenticate MCP servers") - p.add_argument("name", nargs="?", help="MCP name (omit to show auth status)") - p.add_argument("--all", action="store_true", help="Authenticate all MCPs needing auth") - p.add_argument("--refresh", action="store_true", help="Refresh OAuth token only") - p.add_argument("--value", action="append", metavar="KEY=VALUE", help="Set secret non-interactively (repeatable)") - p.set_defaults(func=cmd_mcp_auth) - - p = mcp_sub.add_parser("status", help="Probe all registered MCP servers") - p.set_defaults(func=cmd_mcp_status) - - # ── crux skill ───────────────────────────────────────────────────── - skill_parser = subparsers.add_parser("skill", help="Manage skills") - skill_sub = skill_parser.add_subparsers(dest="skill_command", required=True) - - p = skill_sub.add_parser("add", help="Register a new skill") - p.add_argument("name", help="Name for the skill") - p.add_argument("--github", help="GitHub repo (e.g. user/repo)") - p.add_argument("--local", help="Local directory path") - p.add_argument("--tags", help="Comma-separated tags") - p.add_argument("--build-cmd", dest="build_cmd", help="Build command after clone") - p.set_defaults(func=cmd_skill_add) - - p = skill_sub.add_parser("remove", help="Unregister a skill") - p.add_argument("name", help="Name to remove") - p.set_defaults(func=cmd_skill_remove) - - p = skill_sub.add_parser("list", help="List registered skills") - p.add_argument("--json", dest="json_output", action="store_true", help="Output as JSON") - p.set_defaults(func=cmd_skill_list) - - # ── crux project ─────────────────────────────────────────────────── - proj_parser = subparsers.add_parser("project", help="Manage projects") - proj_sub = proj_parser.add_subparsers(dest="project_command", required=True) - - p = proj_sub.add_parser("create", help="Scaffold a new project") - p.add_argument("name", nargs="?", default=None, help="Project name") - p.add_argument("--mcp", dest="mcps", help="Comma-separated MCP names to install") - p.add_argument("--skill", dest="skills", help="Comma-separated skill names to install") - p.set_defaults(func=cmd_project_create) - - p = proj_sub.add_parser("install", help="Add MCPs/skills to current project and sync") - p.add_argument("names", nargs="+", help="Names of MCPs or skills to install") - p.set_defaults(func=cmd_project_install) - - p = proj_sub.add_parser("uninstall", help="Remove MCPs/skills from current project and sync") - p.add_argument("names", nargs="+", help="Names of MCPs or skills to uninstall") - p.set_defaults(func=cmd_project_uninstall) - - p = proj_sub.add_parser("sync", help="Sync projects: read crux.json, generate .mcp.json") - p.add_argument("--all", action="store_true", help="Sync all tracked projects") - p.set_defaults(func=cmd_project_sync) - - p = proj_sub.add_parser("status", help="Show project health (MCPs, skills, auth)") - p.add_argument("--all", action="store_true", help="All tracked projects") - p.set_defaults(func=cmd_project_status) - - # ── crux task ────────────────────────────────────────────────────── - task_parser = subparsers.add_parser("task", help="Run agent tasks in sandboxes") - task_sub = task_parser.add_subparsers(dest="task_command", required=True) - - p = task_sub.add_parser("run", help="Run an agent in an isolated sandbox") - p.add_argument("task", help="Task string") - p.add_argument("--mcp", nargs="*", default=[], dest="mcps", metavar="mcp", help="MCPs to enable") - p.add_argument("--keep", action="store_true", help="Keep sandbox after run") - p.add_argument("--no-stream", dest="no_stream", action="store_true", help="Collect output instead of streaming") - p.add_argument("--file", "-f", metavar="run.json", help="Load from manifest file") - p.set_defaults(func=cmd_task) - - p = task_sub.add_parser("init", help="Scaffold a run.json template") - p.add_argument("name", nargs="?", default=None, metavar="name", help="Name for the run") - p.set_defaults(func=cmd_task) - - p = task_sub.add_parser("list", help="Show past/active runs") - p.set_defaults(func=cmd_task) - - p = task_sub.add_parser("clean", help="Remove completed sandboxes") - p.add_argument("--force", action="store_true", help="Skip confirmation") - p.set_defaults(func=cmd_task) + from crux_cli.cli.commands.setup_cmd import cmd_setup + from crux_cli.cli.commands.use_cmd import cmd_active, cmd_use + + p = argparse.ArgumentParser(prog="crux", description="Crux — Harness manager for Claude Code") + sub = p.add_subparsers(dest="command", required=True) + + # ── Setup / doctor / migrate ──────────────────────────────────────── + sub.add_parser("setup", help="Initialize ~/.crux").set_defaults(func=cmd_setup) + sub.add_parser("doctor", help="Diagnose the environment").set_defaults(func=cmd_doctor) + m = sub.add_parser("migrate", help="Migrate the cwd's crux.json into a v2 harness") + m.add_argument("--name", help="Override harness name") + m.set_defaults(func=cmd_migrate) + + # ── Registry ──────────────────────────────────────────────────────── + rp = sub.add_parser("registry", help="Manage MCPs, skills, plugins") + rs = rp.add_subparsers(dest="reg_cmd", required=True) + ra = rs.add_parser("add", help="Register an MCP, skill, or plugin") + ra.add_argument("kind", choices=["mcp", "skill", "plugin"]) + ra.add_argument("name") + ra.add_argument("source") + ra.add_argument("--npm", action="store_true") + ra.add_argument("--uvx", action="store_true") + ra.add_argument("--github", action="store_true") + ra.add_argument("--local", action="store_true") + ra.add_argument("--http", action="store_true") + ra.add_argument("--keychain", help="Comma-separated env vars for keychain auth") + ra.add_argument("--args", help="Extra args, space-separated") + ra.add_argument("--skip-install", action="store_true") + ra.add_argument("--version", default="v1", help="Plugin version (default v1)") + ra.set_defaults(func=cmd_registry_add) + + rr = rs.add_parser("remove", help="Unregister a primitive") + rr.add_argument("name") + rr.add_argument("--force", action="store_true") + rr.set_defaults(func=cmd_registry_remove) + + rl = rs.add_parser("list", help="List registered primitives") + rl.set_defaults(func=cmd_registry_list) + + # ── Secret ────────────────────────────────────────────────────────── + sp = sub.add_parser("secret", help="Manage credentials") + ss = sp.add_subparsers(dest="secret_cmd", required=True) + sset = ss.add_parser("set") + sset.add_argument("mcp") + sset.add_argument("key") + sset.add_argument("--value", help="Value (otherwise prompted)") + sset.set_defaults(func=cmd_secret_set) + sl = ss.add_parser("list") + sl.add_argument("mcp", nargs="?") + sl.set_defaults(func=cmd_secret_list) + srm = ss.add_parser("remove") + srm.add_argument("mcp") + srm.add_argument("key") + srm.set_defaults(func=cmd_secret_remove) + + # ── Harness lifecycle ─────────────────────────────────────────────── + n = sub.add_parser("new", help="Create a new harness at v1") + n.add_argument("name") + n.set_defaults(func=cmd_new) + + b = sub.add_parser("bump", help="Create the next version from the latest") + b.add_argument("name") + b.set_defaults(func=cmd_bump) + + li = sub.add_parser("list", help="List harnesses or versions of a harness") + li.add_argument("name", nargs="?") + li.set_defaults(func=cmd_list) + + sh = sub.add_parser("show", help="Display the bundle for a harness") + sh.add_argument("ref") + sh.set_defaults(func=cmd_show) + + # ── Edit ──────────────────────────────────────────────────────────── + ep = sub.add_parser("edit", help="Edit parts of a harness") + es = ep.add_subparsers(dest="edit_what", required=True) + for what in ("claude", "skills", "mcps", "plugins", "hooks"): + sub_e = es.add_parser(what) + sub_e.add_argument("ref", nargs="?") + sub_e.add_argument("--add", action="append", default=[]) + sub_e.add_argument("--remove", action="append", default=[]) + sub_e.set_defaults(func=cmd_edit, edit_what=what) + + # ── Activation ────────────────────────────────────────────────────── + u = sub.add_parser("use", help="Activate a harness (or '-' for previous)") + u.add_argument("ref", nargs="?") + u.add_argument("--user", action="store_true") + u.add_argument("--none", action="store_true") + u.set_defaults(func=cmd_use) + sub.add_parser("active", help="Show the active harness").set_defaults(func=cmd_active) + + return p + +def main() -> None: + parser = _build_parser() args = parser.parse_args() - args.func(args) + try: + args.func(args) + except FileNotFoundError as e: + print(f"crux: not found: {e}", file=sys.stderr) + sys.exit(2) + except FileExistsError as e: + print(f"crux: exists: {e}", file=sys.stderr) + sys.exit(3) + except ConflictError as e: + print(f"crux: conflict: {e}", file=sys.stderr) + sys.exit(3) + except RuntimeError as e: + print(f"crux: {e}", file=sys.stderr) + sys.exit(3) if __name__ == "__main__": diff --git a/tests/integration/test_cli_v2.py b/tests/integration/test_cli_v2.py new file mode 100644 index 0000000..ad682b4 --- /dev/null +++ b/tests/integration/test_cli_v2.py @@ -0,0 +1,283 @@ +"""End-to-end CLI tests for crux v2. + +These tests exercise the CLI exactly as a user would: ``python -m crux_cli`` +in a subprocess, with ``CRUX_TEST_ROOT`` and ``HOME`` redirected to a +temporary directory. Each test follows a realistic workflow. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _run(args: list[str], env_root: Path, cwd: Path | None = None) -> subprocess.CompletedProcess: + env = os.environ.copy() + env["CRUX_TEST_ROOT"] = str(env_root) + env["HOME"] = str(env_root / "home") + env.pop("CRUX_HOME", None) + (env_root / "home").mkdir(exist_ok=True) + return subprocess.run( # noqa: S603 + [sys.executable, "-m", "crux_cli", *args], + capture_output=True, + text=True, + env=env, + cwd=cwd, + ) + + +@pytest.fixture +def home(tmp_path): + return tmp_path + + +# --------------------------------------------------------------------------- +# Workflow 1: fresh user — setup, build a harness, activate, inspect. +# --------------------------------------------------------------------------- + + +def test_first_run_workflow(home): + """A new user runs setup, creates a harness, and activates it.""" + r = _run(["setup"], home) + assert r.returncode == 0, r.stderr + assert (home / "registry" / "harnesses").is_dir() + assert (home / "registry" / "skills" / "crux" / "SKILL.md").exists() + + r = _run(["new", "coding"], home) + assert r.returncode == 0, r.stderr + bundle = home / "registry" / "harnesses" / "coding" / "v1" / "bundle.toml" + assert bundle.exists() + assert "coding" in bundle.read_text() + + r = _run(["use", "coding", "--user"], home) + assert r.returncode == 0, r.stderr + home_claude = home / "home" / ".claude" + assert (home_claude / "CLAUDE.md").is_symlink() + assert (home / "active.toml").read_text().strip() == 'harness = "coding@v1"' + + # `crux active` shows the user-scope pointer. + r = _run(["active"], home, cwd=home) + assert r.returncode == 0 + assert "coding" in r.stdout + assert "user" in r.stdout + + +# --------------------------------------------------------------------------- +# Workflow 2: iterate — bump produces v2, list shows both, use selects. +# --------------------------------------------------------------------------- + + +def test_versioning_workflow(home): + _run(["setup"], home) + _run(["new", "coding"], home) + r = _run(["bump", "coding"], home) + assert r.returncode == 0, r.stderr + assert (home / "registry" / "harnesses" / "coding" / "v2" / "bundle.toml").exists() + + r = _run(["list", "coding"], home) + assert r.returncode == 0 + assert "v1" in r.stdout and "v2" in r.stdout + + _run(["use", "coding@v1", "--user"], home) + assert (home / "active.toml").read_text().strip() == 'harness = "coding@v1"' + _run(["use", "coding@v2", "--user"], home) + assert (home / "active.toml").read_text().strip() == 'harness = "coding@v2"' + + +# --------------------------------------------------------------------------- +# Workflow 3: directory-level pointer overrides user-level. +# --------------------------------------------------------------------------- + + +def test_directory_scope_overrides_user(home): + _run(["setup"], home) + _run(["new", "global"], home) + _run(["new", "proj"], home) + + _run(["use", "global", "--user"], home) + project = home / "myproj" + project.mkdir() + r = _run(["use", "proj"], home, cwd=project) + assert r.returncode == 0, r.stderr + assert (project / "crux.toml").exists() + assert (project / ".claude" / "CLAUDE.md").is_symlink() + + r = _run(["active"], home, cwd=project) + assert "proj" in r.stdout and "directory" in r.stdout + + # Outside the project, user-level pointer wins. + r = _run(["active"], home, cwd=home / "home") + assert "global" in r.stdout and "user" in r.stdout + + +# --------------------------------------------------------------------------- +# Workflow 4: use - rolls back to the previous activation. +# --------------------------------------------------------------------------- + + +def test_use_dash_rolls_back(home): + _run(["setup"], home) + _run(["new", "a"], home) + _run(["new", "b"], home) + _run(["use", "a", "--user"], home) + _run(["use", "b", "--user"], home) + r = _run(["use", "-", "--user"], home) + assert r.returncode == 0, r.stderr + assert (home / "active.toml").read_text().strip() == 'harness = "a@v1"' + + +# --------------------------------------------------------------------------- +# Workflow 5: use --none deactivates. +# --------------------------------------------------------------------------- + + +def test_use_none_deactivates(home): + _run(["setup"], home) + _run(["new", "a"], home) + _run(["use", "a", "--user"], home) + assert (home / "active.toml").exists() + r = _run(["use", "--none", "--user"], home) + assert r.returncode == 0, r.stderr + assert not (home / "active.toml").exists() + + +# --------------------------------------------------------------------------- +# Workflow 6: assemble a real bundle — add primitives, edit, activate. +# --------------------------------------------------------------------------- + + +def test_bundle_assembly_workflow(home, tmp_path): + _run(["setup"], home) + + # Stage a local skill outside the registry. + skill_src = tmp_path / "research" + skill_src.mkdir() + (skill_src / "SKILL.md").write_text("---\nname: research\n---\nDo research.\n") + r = _run(["registry", "add", "skill", "research", str(skill_src), "--local"], home) + assert r.returncode == 0, r.stderr + + # Add an MCP without installing (skip-install: don't touch npm in tests). + r = _run( + [ + "registry", + "add", + "mcp", + "fs", + "@modelcontextprotocol/server-filesystem", + "--npm", + "--skip-install", + ], + home, + ) + assert r.returncode == 0, r.stderr + + # Create the harness and pull both into it. + _run(["new", "coding"], home) + r = _run(["edit", "skills", "coding", "--add", "research"], home) + assert r.returncode == 0, r.stderr + r = _run(["edit", "mcps", "coding", "--add", "fs"], home) + assert r.returncode == 0, r.stderr + + # Show reflects the changes. + r = _run(["show", "coding"], home) + assert "research" in r.stdout + assert "fs" in r.stdout + + # Activating deploys symlinks and emits .mcp.json with the MCP. + _run(["use", "coding", "--user"], home) + home_claude = home / "home" / ".claude" + assert (home_claude / "skills" / "research").is_symlink() + data = json.loads((home_claude / ".mcp.json").read_text()) + assert "fs" in data["mcpServers"] + + +# --------------------------------------------------------------------------- +# Workflow 7: registry remove blocks when referenced; --force overrides. +# --------------------------------------------------------------------------- + + +def test_remove_blocks_when_referenced(home): + _run(["setup"], home) + _run(["registry", "add", "mcp", "fs", "@x/fs", "--npm", "--skip-install"], home) + _run(["new", "h"], home) + _run(["edit", "mcps", "h", "--add", "fs"], home) + + r = _run(["registry", "remove", "fs"], home) + assert r.returncode != 0 + assert "referenced" in r.stderr.lower() + + r = _run(["registry", "remove", "fs", "--force"], home) + assert r.returncode == 0, r.stderr + + +# --------------------------------------------------------------------------- +# Workflow 8: migrate a v1 crux.json project. +# --------------------------------------------------------------------------- + + +def test_migrate_workflow(home, tmp_path): + _run(["setup"], home) + proj = home / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"name": "myproj", "mcps": ["fs"], "skills": ["research"]})) + + r = _run(["migrate"], home, cwd=proj) + assert r.returncode == 0, r.stderr + assert not (proj / "crux.json").exists() + assert (proj / "crux.toml").read_text().strip() == 'harness = "myproj@v1"' + + bundle = (home / "registry" / "harnesses" / "myproj" / "v1" / "bundle.toml").read_text() + assert "fs" in bundle + assert "research" in bundle + + +def test_migrate_with_explicit_name(home): + _run(["setup"], home) + proj = home / "myproj" + proj.mkdir() + (proj / "crux.json").write_text(json.dumps({"mcps": [], "skills": []})) + r = _run(["migrate", "--name", "renamed"], home, cwd=proj) + assert r.returncode == 0, r.stderr + assert (proj / "crux.toml").read_text().strip() == 'harness = "renamed@v1"' + + +# --------------------------------------------------------------------------- +# Workflow 9: doctor reports missing pieces. +# --------------------------------------------------------------------------- + + +def test_doctor_before_setup(home): + r = _run(["doctor"], home) + # Reports issues but doesn't crash. + assert r.returncode in (0, 4) + + +def test_doctor_after_setup_is_clean_on_registry(home): + _run(["setup"], home) + r = _run(["doctor"], home) + # May still complain about missing CLI tools; the registry checks + # at least shouldn't fail. + assert "missing: " not in r.stdout.splitlines()[0:1] if r.stdout else True + + +# --------------------------------------------------------------------------- +# Workflow 10: conflict — refuse to clobber a user's existing CLAUDE.md. +# --------------------------------------------------------------------------- + + +def test_use_refuses_to_clobber(home): + _run(["setup"], home) + _run(["new", "h"], home) + home_claude = home / "home" / ".claude" + home_claude.mkdir(parents=True) + (home_claude / "CLAUDE.md").write_text("user wrote this themselves\n") + r = _run(["use", "h", "--user"], home) + assert r.returncode != 0 + assert "refusing" in r.stderr.lower() or "conflict" in r.stderr.lower() + # File preserved. + assert (home_claude / "CLAUDE.md").read_text() == "user wrote this themselves\n" From ba94eea4e5e9a43c6b6744403941bc5b9fd5ea3b Mon Sep 17 00:00:00 2001 From: Kaushal Paneri Date: Sun, 10 May 2026 22:20:55 -0400 Subject: [PATCH 4/5] chore(v2): remove v1 modules and stale tests; bump to 2.0.0 Delete every v1 module replaced by the v2 implementation: src/: manifest.py, sync.py, projects.py, sandbox.py, setup_crux.py, auth.py, preflight.py, health.py, cli/commands/{mcp,skill,project,task,version,doctor}.py tests/: All v1 unit and integration tests, plus tests/fixtures/. Trim crux_cli/paths.py to the v1 helpers actually still used at runtime (secrets_path, config_path, tokens_path) plus the v2 helpers. Replace tests/conftest.py and tests/integration/conftest.py with v2-shaped fixtures. Bump pyproject version to 2.0.0. Update ruff per-file-ignores to match the v2 module set. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 7 +- src/crux_cli/auth.py | 318 ---------- src/crux_cli/cli/commands/doctor.py | 89 --- src/crux_cli/cli/commands/mcp.py | 585 ------------------- src/crux_cli/cli/commands/project.py | 376 ------------ src/crux_cli/cli/commands/skill.py | 125 ---- src/crux_cli/cli/commands/task.py | 151 ----- src/crux_cli/cli/commands/version.py | 13 - src/crux_cli/health.py | 295 ---------- src/crux_cli/manifest.py | 93 --- src/crux_cli/paths.py | 69 +-- src/crux_cli/preflight.py | 175 ------ src/crux_cli/projects.py | 81 --- src/crux_cli/sandbox.py | 323 ---------- src/crux_cli/setup_crux.py | 316 ---------- src/crux_cli/sync.py | 257 -------- tests/conftest.py | 114 +--- tests/fixtures/marketplace_minimal.json | 44 -- tests/fixtures/sample_run.json | 9 - tests/fixtures/test_mcp_server.py | 101 ---- tests/integration/conftest.py | 54 +- tests/integration/test_cli_doctor.py | 29 - tests/integration/test_cli_mcp.py | 123 ---- tests/integration/test_cli_project_create.py | 68 --- tests/integration/test_cli_project_sync.py | 25 - tests/integration/test_cli_skill.py | 36 -- tests/integration/test_cli_task.py | 40 -- tests/integration/test_e2e_auth.py | 339 ----------- tests/unit/test_auth.py | 414 ------------- tests/unit/test_health.py | 184 ------ tests/unit/test_health_extended.py | 412 ------------- tests/unit/test_init.py | 82 --- tests/unit/test_install.py | 238 -------- tests/unit/test_manifest.py | 112 ---- tests/unit/test_mcp_remove_secrets.py | 127 ---- tests/unit/test_no_secret_leaks.py | 360 ------------ tests/unit/test_paths.py | 80 +-- tests/unit/test_preflight.py | 181 ------ tests/unit/test_projects.py | 112 ---- tests/unit/test_sandbox.py | 204 ------- tests/unit/test_sandbox_extended.py | 401 ------------- tests/unit/test_setup.py | 576 ------------------ tests/unit/test_sync.py | 389 ------------ 43 files changed, 54 insertions(+), 8073 deletions(-) delete mode 100644 src/crux_cli/auth.py delete mode 100644 src/crux_cli/cli/commands/doctor.py delete mode 100644 src/crux_cli/cli/commands/mcp.py delete mode 100644 src/crux_cli/cli/commands/project.py delete mode 100644 src/crux_cli/cli/commands/skill.py delete mode 100644 src/crux_cli/cli/commands/task.py delete mode 100644 src/crux_cli/cli/commands/version.py delete mode 100644 src/crux_cli/health.py delete mode 100644 src/crux_cli/manifest.py delete mode 100644 src/crux_cli/preflight.py delete mode 100644 src/crux_cli/projects.py delete mode 100644 src/crux_cli/sandbox.py delete mode 100644 src/crux_cli/setup_crux.py delete mode 100644 src/crux_cli/sync.py delete mode 100644 tests/fixtures/marketplace_minimal.json delete mode 100644 tests/fixtures/sample_run.json delete mode 100755 tests/fixtures/test_mcp_server.py delete mode 100644 tests/integration/test_cli_doctor.py delete mode 100644 tests/integration/test_cli_mcp.py delete mode 100644 tests/integration/test_cli_project_create.py delete mode 100644 tests/integration/test_cli_project_sync.py delete mode 100644 tests/integration/test_cli_skill.py delete mode 100644 tests/integration/test_cli_task.py delete mode 100644 tests/integration/test_e2e_auth.py delete mode 100644 tests/unit/test_auth.py delete mode 100644 tests/unit/test_health.py delete mode 100644 tests/unit/test_health_extended.py delete mode 100644 tests/unit/test_init.py delete mode 100644 tests/unit/test_install.py delete mode 100644 tests/unit/test_manifest.py delete mode 100644 tests/unit/test_mcp_remove_secrets.py delete mode 100644 tests/unit/test_no_secret_leaks.py delete mode 100644 tests/unit/test_preflight.py delete mode 100644 tests/unit/test_projects.py delete mode 100644 tests/unit/test_sandbox.py delete mode 100644 tests/unit/test_sandbox_extended.py delete mode 100644 tests/unit/test_setup.py delete mode 100644 tests/unit/test_sync.py diff --git a/pyproject.toml b/pyproject.toml index bed7134..bff7f60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "crux-cli" -version = "1.0.5" +version = "2.0.0" description = "Crux OS — Agentic AI control plane" requires-python = ">=3.11" dependencies = [ @@ -73,6 +73,5 @@ select = ["E", "F", "W", "I", "UP", "B", "SIM", "S"] [tool.ruff.lint.per-file-ignores] "tests/**/*.py" = ["S101", "S108"] # assert and /tmp paths are expected in tests "tests/**/conftest.py" = ["S101", "S603"] # subprocess in test helpers -"src/crux_cli/cli/commands/mcp.py" = ["S602", "S603", "S606", "S607"] # subprocess calls with git/crux -"src/crux_cli/cli/commands/skill.py" = ["S603", "S607"] # subprocess calls with git -"src/crux_cli/cli/commands/doctor.py" = ["S603"] # subprocess for setup_cmd +"src/crux_cli/registry_ops.py" = ["S603", "S607"] # subprocess for git clone +"src/crux_cli/cli/commands/harness_cmd.py" = ["S603"] # subprocess for $EDITOR diff --git a/src/crux_cli/auth.py b/src/crux_cli/auth.py deleted file mode 100644 index 5dd2e32..0000000 --- a/src/crux_cli/auth.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Unified auth orchestration — dispatches by auth.type.""" - -from __future__ import annotations - -import getpass -import subprocess -import sys -from datetime import UTC, datetime -from typing import Any - -from crux_cli.manifest import load_registry -from crux_cli.paths import tokens_path -from crux_cli.secrets import get_backend, load_secrets_index - - -def auth_single( - mcp_name: str, - registry: dict[str, Any] | None = None, - *, - inline_values: dict[str, str] | None = None, -) -> None: - """Authenticate a single MCP by dispatching to the appropriate auth handler.""" - if registry is None: - registry = load_registry() - - mcp_defs = registry.get("mcp_definitions", {}) - if mcp_name not in mcp_defs: - print(f"\u274c MCP '{mcp_name}' not found in registry") - sys.exit(1) - - auth = mcp_defs[mcp_name].get("auth", {}) - auth_type = auth.get("type", "") - - if not auth_type and not auth.get("env_vars") and not auth.get("check_cmd") and not auth.get("setup_cmd"): - print(f"\u2139\ufe0f MCP '{mcp_name}' has no authentication configured") - return - - # Infer type from legacy fields if not explicit - if not auth_type: - if auth.get("env_vars"): - auth_type = "keychain" - elif auth.get("check_cmd"): - auth_type = "external-cli" - elif auth.get("setup_cmd"): - auth_type = "setup-cmd" - - if auth_type == "keychain": - _auth_keychain(mcp_name, auth, inline_values=inline_values) - elif auth_type == "external-cli": - _auth_external_cli(mcp_name, auth) - elif auth_type == "setup-cmd": - _auth_setup_cmd(mcp_name, auth) - elif auth_type == "bearer": - _auth_bearer(mcp_name, auth, inline_values=inline_values) - elif auth_type in ("oauth", "oauth-client-credentials"): - # Defer to oauth module (Plan phase 3) - from crux_cli.oauth import run_client_credentials_flow, run_oauth_flow - - if auth_type == "oauth": - run_oauth_flow(mcp_name, auth) - else: - run_client_credentials_flow(mcp_name, auth) - else: - msg = f"Unknown auth type '{auth_type}' for MCP '{mcp_name}'" - raise ValueError(msg) - - -def auth_status(registry: dict[str, Any] | None = None) -> list[dict[str, str]]: - """Return auth status for all MCPs that have auth requirements.""" - if registry is None: - registry = load_registry() - - mcp_defs = registry.get("mcp_definitions", {}) - secrets_index = load_secrets_index() - results = [] - - for name, data in sorted(mcp_defs.items()): - auth = data.get("auth", {}) - if not auth: - continue - - auth_type = auth.get("type", "") - if not auth_type: - if auth.get("env_vars"): - auth_type = "keychain" - elif auth.get("check_cmd"): - auth_type = "external-cli" - elif auth.get("setup_cmd"): - auth_type = "setup-cmd" - - status = _check_auth_status(name, auth, auth_type, secrets_index) - results.append({"name": name, "auth_type": auth_type, "status": status}) - - return results - - -def auth_all(registry: dict[str, Any] | None = None) -> None: - """Authenticate all MCPs that need auth.""" - statuses = auth_status(registry) - needing_auth = [s for s in statuses if "Authenticated" not in s["status"]] - - if not needing_auth: - print("\u2705 All MCPs are authenticated") - return - - for s in needing_auth: - print(f"\n\u2500\u2500 Authenticating: {s['name']} ({s['auth_type']}) \u2500\u2500") - try: - auth_single(s["name"], registry) - except Exception as e: # noqa: BLE001 - print(f"\u26a0\ufe0f Failed: {e}") - - -def _auth_keychain( - mcp_name: str, - auth: dict[str, Any], - *, - inline_values: dict[str, str] | None = None, -) -> None: - """Authenticate via OS keychain — prompt for each env var (or use inline values).""" - env_vars = auth.get("env_vars", []) - if not env_vars: - print(f"\u2139\ufe0f No env vars configured for '{mcp_name}'") - return - - backend = get_backend() - secrets_index = load_secrets_index() - stored = secrets_index.get(mcp_name, []) - - for var in env_vars: - # Use inline value if provided (always overwrite) - if inline_values and var in inline_values: - backend.set(mcp_name, var, inline_values[var]) - print(f" \u2705 Stored {var}") - continue - - # Skip already-stored secrets in interactive mode - if var in stored: - existing = backend.get(mcp_name, var) - if existing: - print(f" {var}: already set (use crux mcp auth {mcp_name} to reset)") - continue - - # If inline_values were provided but this var is missing, skip prompting - if inline_values is not None: - print(f" Skipped {var} (no --value provided)") - continue - - value = getpass.getpass(f" Enter {var}: ") - if not value: - print(f" Skipped {var}") - continue - backend.set(mcp_name, var, value) - print(f" \u2705 Stored {var}") - - print(f"\u2705 Authenticated '{mcp_name}' — run 'crux project sync' to update launchers") - - -def _auth_external_cli(mcp_name: str, auth: dict[str, Any]) -> None: - """Authenticate via external CLI tool.""" - check_cmd = auth.get("check_cmd", []) - if not check_cmd: - print(f"\u2139\ufe0f No check_cmd configured for '{mcp_name}'") - return - - try: - result = subprocess.run(check_cmd, capture_output=True, timeout=10) # noqa: S603 - if result.returncode == 0: - print(f"\u2705 '{mcp_name}' is already authenticated") - return - except FileNotFoundError: - print(f"\u274c Command not found: {check_cmd[0]}") - fix = auth.get("fix_description", "") - if fix: - print(f" Fix: {fix}") - return - except subprocess.TimeoutExpired: - print(f"\u26a0\ufe0f Auth check timed out for '{mcp_name}'") - return - - fix_cmd = auth.get("fix_cmd", []) - fix_desc = auth.get("fix_description", "") - - if fix_cmd: - print(f" Running: {' '.join(fix_cmd)}") - subprocess.run(fix_cmd) # noqa: S603 - # Re-check - result = subprocess.run(check_cmd, capture_output=True, timeout=10) # noqa: S603 - if result.returncode == 0: - print(f"\u2705 '{mcp_name}' authenticated successfully") - else: - print("\u26a0\ufe0f Authentication may have failed — check manually") - elif fix_desc: - print(f" {fix_desc}") - else: - print(f"\u274c '{mcp_name}' auth check failed (exit {result.returncode})") - - -def _auth_setup_cmd(mcp_name: str, auth: dict[str, Any]) -> None: - """Authenticate via one-time setup command.""" - setup_cmd = auth.get("setup_cmd", []) - if not setup_cmd: - print(f"\u2139\ufe0f No setup_cmd configured for '{mcp_name}'") - return - - print(f" Running: {' '.join(setup_cmd)}") - result = subprocess.run(setup_cmd) # noqa: S603 - if result.returncode == 0: - print(f"\u2705 Setup complete for '{mcp_name}'") - else: - print(f"\u26a0\ufe0f Setup failed (exit {result.returncode})") - sys.exit(result.returncode) - - -def _auth_bearer( - mcp_name: str, - auth: dict[str, Any], - *, - inline_values: dict[str, str] | None = None, -) -> None: - """Authenticate via static Bearer token.""" - keychain_key = auth.get("keychain_key", "API_TOKEN") - backend = get_backend() - - # Use inline value if provided (always overwrite) - if inline_values and keychain_key in inline_values: - backend.set(mcp_name, keychain_key, inline_values[keychain_key]) - print(f"\u2705 Token stored for '{mcp_name}'") - return - - existing = backend.get(mcp_name, keychain_key) - if existing: - replace = input(f" Token already stored for '{mcp_name}'. Replace? [y/N] ").strip().lower() - if replace != "y": - print(" Kept existing token") - return - - value = getpass.getpass(f" Enter token for '{mcp_name}': ") - if not value: - print(" Skipped — no token entered") - return - - backend.set(mcp_name, keychain_key, value) - print(f"\u2705 Token stored for '{mcp_name}'") - - -def _check_auth_status(name: str, auth: dict[str, Any], auth_type: str, secrets_index: dict[str, list[str]]) -> str: - """Check auth status for a single MCP. Returns a human-readable status string.""" - if auth_type == "keychain": - env_vars = auth.get("env_vars", []) - stored = secrets_index.get(name, []) - missing = [v for v in env_vars if v not in stored] - if missing: - return f"Missing: {', '.join(missing)}" - return "Authenticated" - - if auth_type == "external-cli": - check_cmd = auth.get("check_cmd", []) - if not check_cmd: - return "No check command" - try: - result = subprocess.run(check_cmd, capture_output=True, timeout=5) # noqa: S603 - return "Authenticated" if result.returncode == 0 else "Not authenticated" - except (FileNotFoundError, subprocess.TimeoutExpired): - return "Check failed" - - if auth_type == "setup-cmd": - return "Run setup to configure" - - if auth_type == "bearer": - keychain_key = auth.get("keychain_key", "API_TOKEN") - backend = get_backend() - existing = backend.get(name, keychain_key) - return "Authenticated" if existing else "Token not set" - - if auth_type in ("oauth", "oauth-client-credentials"): - return _check_oauth_status(name) - - return "Unknown auth type" - - -def _check_oauth_status(name: str) -> str: - """Check OAuth token status from tokens.json metadata.""" - import json - - tp = tokens_path() - if not tp.exists(): - return "Not authenticated" - - try: - with open(tp) as f: - tokens = json.load(f) - except (json.JSONDecodeError, OSError): - return "Token metadata corrupt" - - meta = tokens.get(name) - if not meta: - return "Not authenticated" - - expires_at = meta.get("expires_at") - if expires_at: - try: - exp = datetime.fromisoformat(expires_at) - now = datetime.now(UTC) - if exp <= now: - refresh_key = meta.get("keychain_account_refresh") - if refresh_key: - return "Refresh needed" - return "Expired" - remaining = exp - now - hours = remaining.total_seconds() / 3600 - if hours < 1: - return f"Expires in {int(remaining.total_seconds() / 60)}m" - return f"Expires in {int(hours)}h" - except ValueError: - pass - - return "Authenticated" diff --git a/src/crux_cli/cli/commands/doctor.py b/src/crux_cli/cli/commands/doctor.py deleted file mode 100644 index 8096542..0000000 --- a/src/crux_cli/cli/commands/doctor.py +++ /dev/null @@ -1,89 +0,0 @@ -"""CLI commands: init, doctor.""" - -from __future__ import annotations - -import argparse - -from crux_cli.health import run_doctor_checks -from crux_cli.manifest import load_registry -from crux_cli.paths import crux_home - - -def cmd_doctor(args: argparse.Namespace) -> None: - """crux doctor — check the Crux environment.""" - print("\n\U0001fa7a CRUX DOCTOR \u2014 Environment Health Check\n") - - reg = load_registry() - mcp_defs = reg.get("mcp_definitions", {}) - - fh = crux_home() - reg_path = fh / "registry.json" - - checks = run_doctor_checks( - crux_root=fh, - registry_path=reg_path if reg_path.exists() else None, - mcp_definitions=mcp_defs, - ) - - all_ok = True - print(" \U0001f4e6 Environment & Dependencies") - for c in checks: - if c.passed: - print(f" \u2705 {c.label}") - elif c.warning: - all_ok = False - print(f" \u26a0\ufe0f {c.label}") - if c.fix_hint: - print(f" Fix: {c.fix_hint}") - else: - all_ok = False - print(f" \u274c {c.label}") - if c.fix_hint: - print(f" Fix: {c.fix_hint}") - - print() - if all_ok: - print("\u2705 All checks passed \u2014 Crux environment is healthy!\n") - else: - print("\u26a0\ufe0f Some issues were found.\n") - - -def cmd_init(args: argparse.Namespace) -> None: - """crux init — initialise ~/.crux/.""" - from crux_cli.setup_crux import run_setup - - print("\n CRUX SETUP\n") - result = run_setup() - - if result.dirs_created: - print(f" Created directories: {len(result.dirs_created)}") - for d in result.dirs_created: - print(f" {d}") - - if result.config_written: - print(" Wrote config.toml with platform defaults") - else: - print(" config.toml already exists \u2014 skipped") - - if result.skill_installed: - print(" Installed Crux skill to ~/.claude/skills/crux/SKILL.md") - else: - print(" Crux skill not installed (bundled data missing)") - - if result.missing_deps: - print(f"\n Missing dependencies: {', '.join(result.missing_deps)}") - else: - print(" All dependencies found") - - mig = result.migration - if mig and mig.detected: - print("\n Migration from old layout:") - print(f" Registry entries: {mig.registry_entries}") - if mig.mcps_copied: - print(f" MCPs copied: {', '.join(mig.mcps_copied)}") - if mig.skills_copied: - print(f" Skills copied: {', '.join(mig.skills_copied)}") - elif mig and not mig.detected: - print(" No old marketplace layout detected \u2014 nothing to migrate") - - print("\n Setup complete.\n") diff --git a/src/crux_cli/cli/commands/mcp.py b/src/crux_cli/cli/commands/mcp.py deleted file mode 100644 index e1a836f..0000000 --- a/src/crux_cli/cli/commands/mcp.py +++ /dev/null @@ -1,585 +0,0 @@ -"""CLI commands: crux mcp add/remove/list/search/upgrade/status/auth.""" - -from __future__ import annotations - -import argparse -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Any - -from crux_cli.manifest import load_registry, save_registry -from crux_cli.paths import mcps_dir as v1_mcps_dir -from crux_cli.registry import ( - best_package, - display_name, - github_slug, - remote_url, - search_servers, - suggest_crux_add, -) -from crux_cli.validation import validate_name - - -def detect_and_run_build(dest: Path, entry: dict) -> None: - """Auto-detect build requirements and run the build.""" - pkg_json = dest / "package.json" - if pkg_json.exists(): - with open(pkg_json) as f: - pkg = json.load(f) - if "build" in pkg.get("scripts", {}): - build_cmd = "npm install && npm run build" - entry["build_cmd"] = build_cmd - print(" \U0001f528 Building (npm)...") - result = subprocess.run(build_cmd, shell=True, cwd=dest, capture_output=True, text=True) # noqa: S602 - if result.returncode == 0: - print(" \u2705 Build complete") - else: - print(f" \u26a0\ufe0f Build failed:\n{result.stderr[-500:].strip()}") - - -def cmd_mcp_add(args: argparse.Namespace) -> None: - """crux mcp add — register a new MCP server to the registry.""" - reg = load_registry() - name = args.name - - ok, reason = validate_name(name) - if not ok: - print(f"\u274c Invalid name '{name}': {reason}") - sys.exit(1) - - registry_key = "mcp_definitions" - if reg[registry_key].get(name): - print(f"\u274c MCP '{name}' already exists. Use 'crux mcp remove {name}' first.") - sys.exit(1) - - entry: dict[str, Any] = {"tags": args.tags.split(",") if args.tags else []} - - keychain_vars = getattr(args, "keychain", None) - if keychain_vars: - entry["auth"] = { - "type": "keychain", - "env_vars": [v.strip() for v in keychain_vars.split(",")], - "fix_description": f"Run: crux secret set {name} ", - } - - build_cmd = getattr(args, "build_cmd", None) - if build_cmd: - entry["build_cmd"] = build_cmd - - if args.uvx: - base_args = [args.uvx] - if args.args: - base_args += args.args.split() - entry.update({"type": "uvx-package", "command": "uvx", "args": base_args}) - elif args.npx: - base_args = ["-y", args.npx] - if args.args: - base_args += args.args.split() - entry.update({"type": "npm-package", "command": "npx", "args": base_args}) - elif args.github: - dest = v1_mcps_dir() / name - dest.parent.mkdir(parents=True, exist_ok=True) - print(f" Cloning: {args.github} -> {dest}") - subprocess.run( # noqa: S603 - ["git", "clone", f"https://github.com/{args.github}", str(dest)], # noqa: S607 - check=True, - ) - entry.update({"type": "github", "source": args.github, "source_dir": str(dest)}) - if args.command: - run_args = args.args.split() if args.args else [] - entry.update({"command": args.command, "args": run_args}) - detect_and_run_build(dest, entry) - elif args.local: - source = Path(args.local).resolve() - dest = v1_mcps_dir() / name - dest.parent.mkdir(parents=True, exist_ok=True) - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(source, dest) - entry.update({"type": "local", "source_dir": str(dest)}) - if args.command: - entry.update({"command": args.command, "args": args.args.split() if args.args else []}) - else: - print("\u274c Specify a source: --uvx , --npx , --github , or --local ") - sys.exit(1) - - reg[registry_key][name] = entry - save_registry(reg) - print(f"\u2705 Registered MCP '{name}'") - - # Inline keychain auth: prompt for secrets immediately (interactive terminals only) - auth_block = entry.get("auth", {}) - if auth_block.get("type") == "keychain" and sys.stdin.isatty(): - try: - from crux_cli.auth import _auth_keychain - - _auth_keychain(name, auth_block) - except Exception as e: # noqa: BLE001 - print(f" \u26a0\ufe0f Auth failed: {e} \u2014 run 'crux mcp auth {name}' to retry") - - -def cmd_mcp_remove(args: argparse.Namespace) -> None: - """crux mcp remove — unregister an MCP from the registry.""" - reg = load_registry() - name = args.name - section = "mcp_definitions" - - if name not in reg.get(section, {}): - print(f"\u274c '{name}' not found in MCP registry") - sys.exit(1) - - data = reg[section][name] - source_dir = data.get("source_dir") - if source_dir and Path(source_dir).exists() and data.get("type") in ("github", "local"): - from crux_cli.paths import crux_home - - resolved = Path(source_dir).resolve() - if str(resolved).startswith(str(crux_home().resolve())): - shutil.rmtree(source_dir) - print(f" Deleted cloned source: {source_dir}") - else: - print(f" Skipped deletion: {source_dir} is outside ~/.crux/") - - del reg[section][name] - save_registry(reg) - print(f"\u2705 Removed MCP '{name}' from registry") - - # Clean up keychain secrets if the MCP had auth configured - _cleanup_secrets(name, data, args) - - print(" Note: run 'crux project sync' to regenerate project configurations") - - -def _cleanup_secrets(name: str, data: dict[str, Any], args: argparse.Namespace) -> None: - """Prompt to remove keychain secrets for a removed MCP, respecting CLI flags.""" - from crux_cli.secrets import get_backend, load_secrets_index - - auth = data.get("auth", {}) - env_vars = auth.get("env_vars", []) - if not env_vars: - return - - backend = get_backend() - stored = [v for v in env_vars if backend.get(name, v)] - if not stored: - return - - keep_secrets = getattr(args, "keep_secrets", False) - remove_secrets = getattr(args, "remove_secrets", False) - - if keep_secrets: - should_remove = False - elif remove_secrets: - should_remove = True - elif sys.stdin.isatty(): - print(f" \U0001f511 Found {len(stored)} stored secret(s): {', '.join(stored)}") - answer = input(" Also remove secrets from keychain? [Y/n]: ").strip().lower() - should_remove = answer != "n" - else: - # Non-interactive, no flag — default to keeping secrets - print(f" \U0001f511 Found {len(stored)} secret(s) kept (use --remove-secrets to clean up)") - return - - if should_remove: - for var in stored: - backend.delete(name, var) - # Clean up any remaining index entries for env vars that weren't stored - index = load_secrets_index() - if name in index: - index.pop(name, None) - from crux_cli.secrets import save_secrets_index - - save_secrets_index(index) - print(f" \U0001f5d1\ufe0f Removed {len(stored)} secret(s) from keychain") - else: - print(" Kept secrets in keychain") - - -def cmd_mcp_list(args: argparse.Namespace) -> None: - """crux mcp list — show registered MCP servers.""" - reg = load_registry() - all_mcps: dict[str, Any] = dict(reg.get("mcp_definitions", {})) - - if getattr(args, "json_output", False): - print(json.dumps({"mcp_definitions": all_mcps}, indent=2)) - return - - from rich import box - from rich.console import Console - from rich.table import Table - - console = Console() - - if not all_mcps: - print("No MCPs registered. Run 'crux mcp add --npx ' to get started.") - print() - return - - table = Table(title="MCP Servers", box=box.SIMPLE_HEAVY, show_lines=False, expand=True) - table.add_column("Name", style="bold cyan", min_width=15, no_wrap=True) - table.add_column("Type", style="dim", min_width=12, no_wrap=True) - table.add_column("Description", max_width=40) - table.add_column("Source", style="blue", max_width=35) - table.add_column("Auth", style="yellow", no_wrap=True) - - for name, data in sorted(all_mcps.items()): - mcp_type = data.get("type", "unknown") - desc = ", ".join(data.get("tags", [])) or "" - source = data.get("source", "") or data.get("source_dir", "") - if mcp_type == "npm-package": - pkg_args = data.get("args", []) - source = next((a for a in pkg_args if not a.startswith("-")), source) - elif mcp_type == "uvx-package": - pkg_args = data.get("args", []) - source = pkg_args[0] if pkg_args else source - auth_required = "Yes" if data.get("auth") else "No" - table.add_row(name, mcp_type, desc, str(source), auth_required) - - console.print(table) - print() - - -def cmd_mcp_search(args: argparse.Namespace) -> None: - """crux mcp search — discover MCP servers from the official registry.""" - from rich import box - from rich.console import Console - from rich.table import Table - - console = Console() - query = args.query - - console.print(f"\n[bold]Searching MCP Registry[/bold] for [cyan]{query!r}[/cyan]...\n") - - try: - servers = search_servers(query=query, limit=args.limit) - except RuntimeError as e: - console.print(f"[red]\u274c {e}[/red]") - sys.exit(1) - - if not servers: - console.print("[yellow]No results found.[/yellow]") - sys.exit(0) - - table = Table(box=box.SIMPLE_HEAVY, show_lines=False, expand=True) - table.add_column("#", style="dim", width=3, no_wrap=True) - table.add_column("Name", style="bold cyan", min_width=18, no_wrap=True) - table.add_column("Description", max_width=55, no_wrap=True) - table.add_column("Package", style="dim", max_width=35, no_wrap=True) - table.add_column("GitHub", style="blue", max_width=35, no_wrap=True) - - for i, srv in enumerate(servers, 1): - name = display_name(srv) - desc = (srv.get("description") or "").strip() - if len(desc) > 55: - desc = desc[:52] + "..." - reg, pkg = best_package(srv) - if pkg: - pkg_str = f"[{reg}] {pkg}" - else: - ru = remote_url(srv) - pkg_str = "[remote]" if ru else "\u2014" - slug = github_slug(srv) - gh_str = slug or "\u2014" - table.add_row(str(i), name, desc, pkg_str, gh_str) - - console.print(table) - - console.print("[bold]To add one to your marketplace:[/bold]") - for srv in servers[:5]: - safe_name = display_name(srv).replace(" ", "-").replace("/", "-").lower() - cmd = suggest_crux_add(safe_name, srv) - if cmd: - console.print(f" [dim]$[/dim] [green]{cmd}[/green]") - console.print() - - if args.add: - _search_add(console, servers) - - -def _search_add(console: object, servers: list[dict]) -> None: - """Prompt user to pick a server from search results and add it.""" - try: - choice = input("Enter number to add (or Enter to skip): ").strip() - except (EOFError, KeyboardInterrupt): - return - if not choice: - return - try: - idx = int(choice) - 1 - if idx < 0 or idx >= len(servers): - raise ValueError - except ValueError: - console.print("[red]Invalid selection.[/red]") - return - - srv = servers[idx] - name = display_name(srv) - safe_name = name.replace(" ", "-").replace("/", "-").lower() - - reg, pkg = best_package(srv) - slug = github_slug(srv) - - try: - custom = input(f"Name in marketplace [{safe_name}]: ").strip() - except (EOFError, KeyboardInterrupt): - return - if custom: - safe_name = custom - - if reg == "npm" and pkg: - console.print(f"\n[dim]Running:[/dim] crux mcp add {safe_name} --npx {pkg}\n") - os.execvp("crux", ["crux", "mcp", "add", safe_name, "--npx", pkg]) # noqa: S606, S607 - elif slug: - console.print(f"\n[dim]Running:[/dim] crux mcp add {safe_name} --github {slug}\n") - os.execvp("crux", ["crux", "mcp", "add", safe_name, "--github", slug]) # noqa: S606, S607 - else: - console.print("[yellow]Cannot auto-add \u2014 no npm package or GitHub repo found.[/yellow]") - console.print(f"Try: crux mcp add {safe_name} --github ") - - -def cmd_mcp_upgrade(args: argparse.Namespace) -> None: - """crux mcp upgrade — update all/named cloned repos.""" - reg = load_registry() - dry_run = getattr(args, "dry_run", False) - target_names = getattr(args, "names", None) or [] - - print("\n CRUX UPGRADE" + (" (dry run)" if dry_run else "") + "\n") - - updated = 0 - - all_mcps = dict(reg.get("mcp_definitions", {})) - - cloned_items = {} - for name, data in all_mcps.items(): - if data.get("type") in ("github", "git-submodule") and data.get("source_dir"): - if target_names and name not in target_names: - continue - cloned_items[name] = data - - for name, data in cloned_items.items(): - source_dir = Path(data["source_dir"]) - if not source_dir.exists(): - continue - git_dir = source_dir / ".git" - if not git_dir.exists(): - continue - - status_result = subprocess.run( - ["git", "status", "--porcelain"], # noqa: S607 - cwd=source_dir, - capture_output=True, - text=True, - ) - if status_result.stdout.strip(): - print(f" Warning: {name} has local modifications \u2014 skipping") - continue - - old_sha_result = subprocess.run( - ["git", "rev-parse", "HEAD"], # noqa: S607 - cwd=source_dir, - capture_output=True, - text=True, - ) - old_sha = old_sha_result.stdout.strip()[:8] if old_sha_result.returncode == 0 else "unknown" - - if dry_run: - subprocess.run(["git", "fetch"], cwd=source_dir, capture_output=True, text=True) # noqa: S607 - behind = subprocess.run( - ["git", "rev-list", "HEAD..@{u}", "--count"], # noqa: S607 - cwd=source_dir, - capture_output=True, - text=True, - ) - count = behind.stdout.strip() if behind.returncode == 0 else "?" - if count != "0": - print(f" {name}: {count} commit(s) behind ({old_sha})") - updated += 1 - else: - print(f" {name}: up to date ({old_sha})") - continue - - pull_result = subprocess.run( - ["git", "pull", "--ff-only"], # noqa: S607 - cwd=source_dir, - capture_output=True, - text=True, - ) - if pull_result.returncode != 0: - print(f" Warning: {name} pull failed \u2014 {pull_result.stderr.strip()}") - continue - - new_sha_result = subprocess.run( - ["git", "rev-parse", "HEAD"], # noqa: S607 - cwd=source_dir, - capture_output=True, - text=True, - ) - new_sha = new_sha_result.stdout.strip()[:8] if new_sha_result.returncode == 0 else "unknown" - - if old_sha != new_sha: - print(f" {name}: {old_sha} -> {new_sha}") - updated += 1 - - build_cmd = data.get("build_cmd") - if build_cmd: - print(f" Rebuilding ({build_cmd})...") - build_result = subprocess.run( # noqa: S602 - build_cmd, - shell=True, - cwd=source_dir, - capture_output=True, - text=True, - ) - if build_result.returncode == 0: - print(" Build complete") - else: - print(f" Warning: build failed \u2014 {build_result.stderr[-300:].strip()}") - else: - print(f" {name}: up to date ({old_sha})") - - if dry_run: - if updated: - print(f"\n {updated} item(s) have updates available\n") - else: - print("\n Everything is up to date\n") - else: - print(f"\n Upgrade complete ({updated} updated)\n") - - -_STATUS_STYLE = { - "connected": "[bold green]connected[/]", - "running": "[yellow]running[/]", - "auth_required": "[bold red]auth_required[/]", - "timeout": "[red]timeout[/]", - "error": "[red]error[/]", - "failed": "[bold red]failed[/]", -} - - -def cmd_mcp_status(args: argparse.Namespace) -> None: - """crux mcp status — probe every MCP in the global registry.""" - from rich.console import Console - from rich.table import Table - - from crux_cli.health import probe_mcp_server_detailed - from crux_cli.mcp_config import enrich_with_marketplace - - print("\nChecking MCP server health...\n") - - reg = load_registry() - mcp_definitions = dict(reg.get("mcp_definitions", {})) - - if not mcp_definitions: - print("No MCPs registered. Run 'crux mcp add --npx ' to get started.") - print() - return - - # Build a config dict for each MCP from its registry definition - servers: dict[str, Any] = {} - for name, data in mcp_definitions.items(): - config: dict[str, Any] = {} - mcp_type = data.get("type", "") - if mcp_type in ("npm-package", "uvx-package") or mcp_type in ("github", "local"): - config["command"] = data.get("command", "") - config["args"] = data.get("args", []) - else: - config["command"] = data.get("command", "") - config["args"] = data.get("args", []) - if data.get("env"): - config["env"] = data["env"] - servers[name] = config - - enriched = enrich_with_marketplace(servers, mcp_definitions) - - rows = [] - for name, config in enriched.items(): - result = probe_mcp_server_detailed(config) - rows.append( - { - "name": name, - "status": result["status"], - "tools_count": result["tools_count"], - "detail": result["detail"], - } - ) - - console = Console() - table = Table(title="MCP Server Status (Global Registry)", show_lines=False) - table.add_column("MCP Name", style="cyan", no_wrap=True) - table.add_column("Status") - table.add_column("Tools", justify="right") - table.add_column("Detail") - - for r in rows: - styled = _STATUS_STYLE.get(r["status"], r["status"]) - tools_str = str(r["tools_count"]) if r["tools_count"] is not None else "-" - table.add_row(r["name"], styled, tools_str, r.get("detail", "")) - - console.print(table) - print() - - -def cmd_mcp_auth(args: argparse.Namespace) -> None: - """crux mcp auth — authenticate MCP servers.""" - from crux_cli.auth import auth_all, auth_single, auth_status - - name = getattr(args, "name", None) - do_all = getattr(args, "all", False) - - values = getattr(args, "value", None) - - if name: - # Parse --value KEY=VALUE pairs into a dict - inline_values: dict[str, str] | None = None - if values: - inline_values = {} - for pair in values: - if "=" not in pair: - print(f"\u274c Invalid --value format: '{pair}' (expected KEY=VALUE)") - sys.exit(1) - k, v = pair.split("=", 1) - inline_values[k] = v - auth_single(name, inline_values=inline_values) - elif do_all: - auth_all() - else: - # Show auth status table - from rich import box - from rich.console import Console - from rich.table import Table - - statuses = auth_status() - if not statuses: - print("No MCPs have authentication configured.") - return - - console = Console() - table = Table(title="MCP Authentication Status", box=box.SIMPLE_HEAVY, show_lines=False) - table.add_column("Name", style="bold cyan", no_wrap=True) - table.add_column("Auth Type", style="dim") - table.add_column("Status") - - status_styles = { - "Authenticated": "[green]Authenticated[/]", - "Not authenticated": "[bold red]Not authenticated[/]", - "Token not set": "[bold red]Token not set[/]", - "Expired": "[bold red]Expired[/]", - "Refresh needed": "[yellow]Refresh needed[/]", - "Check failed": "[red]Check failed[/]", - } - - for s in statuses: - styled = status_styles.get(s["status"], s["status"]) - if s["status"].startswith("Missing:"): - styled = f"[red]{s['status']}[/]" - elif s["status"].startswith("Expires in"): - styled = f"[yellow]{s['status']}[/]" - table.add_row(s["name"], s["auth_type"], styled) - - console.print(table) - print("\nRun: crux mcp auth to authenticate") - print(" crux mcp auth --all to authenticate all") diff --git a/src/crux_cli/cli/commands/project.py b/src/crux_cli/cli/commands/project.py deleted file mode 100644 index 09772ed..0000000 --- a/src/crux_cli/cli/commands/project.py +++ /dev/null @@ -1,376 +0,0 @@ -"""CLI commands: project create, install, uninstall, sync, status.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import sys -from pathlib import Path - -from crux_cli.health import probe_mcp_server_detailed -from crux_cli.manifest import load_crux_json, load_registry, save_crux_json -from crux_cli.mcp_config import enrich_with_marketplace -from crux_cli.paths import skills_dir as v1_skills_dir_fn -from crux_cli.projects import list_projects, register_project -from crux_cli.sync import sync_project - - -def cmd_project_create(args: argparse.Namespace) -> None: - """crux project create [name] — scaffold a new project.""" - name = getattr(args, "name", None) - - if name: - project_dir = Path.cwd() / name - else: - project_dir = Path.cwd() - name = project_dir.name - - if (project_dir / "crux.json").exists(): - print(f"\u274c crux.json already exists at {project_dir / 'crux.json'}") - sys.exit(1) - - project_dir.mkdir(parents=True, exist_ok=True) - - crux_json = { - "name": name, - "mcps": [], - "skills": [], - } - - mcps_arg = getattr(args, "mcps", None) - skills_arg = getattr(args, "skills", None) - if mcps_arg: - crux_json["mcps"] = [m.strip() for m in mcps_arg.split(",")] - if skills_arg: - crux_json["skills"] = [s.strip() for s in skills_arg.split(",")] - - save_crux_json(project_dir, crux_json) - - gitignore_path = project_dir / ".gitignore" - if gitignore_path.exists(): - content = gitignore_path.read_text() - if ".mcp.json" not in content: - with open(gitignore_path, "a") as f: - f.write("\n.mcp.json\n") - else: - gitignore_path.write_text(".mcp.json\n") - - register_project(project_dir, name) - - print(f"\u2705 Created project '{name}' at {project_dir}") - print(f" Edit {project_dir}/crux.json to declare MCPs and skills") - print(" Then run: crux project sync") - - -def cmd_project_sync(args: argparse.Namespace) -> None: - """crux project sync [--all] — generate .mcp.json for the current project or all tracked projects.""" - reg = load_registry() - - combined_registry = { - "version": reg.get("version", "1.0.0"), - "mcp_definitions": dict(reg.get("mcp_definitions", {})), - "skill_definitions": dict(reg.get("skill_definitions", {})), - } - - sync_all = getattr(args, "all", False) - - if sync_all: - projects = list_projects() - if not projects: - print("No tracked projects. Run 'crux project create' in a project directory.") - return - - print(f"\nSyncing {len(projects)} tracked project(s)...\n") - synced, errors_count = 0, 0 - for proj in projects: - project_dir = Path(proj["path"]) - if not project_dir.exists(): - print(f" Warning: {proj['name']} \u2014 path no longer exists ({proj['path']})") - errors_count += 1 - continue - success, issues = sync_project(project_dir, combined_registry) - if not success: - print(f" Skipped: {proj['name']} \u2014 {'; '.join(issues)}") - continue - if issues: - errors_count += 1 - print(f" Warning: {proj['name']}") - for issue in issues: - print(f" {issue}") - else: - synced += 1 - print(f" Synced: {proj['name']}") - print(f"\n {synced} synced, {errors_count} with issues\n") - else: - target_dir = Path.cwd() - crux_json_path = target_dir / "crux.json" - - if crux_json_path.exists(): - success, issues = sync_project(target_dir, combined_registry) - if issues: - for issue in issues: - print(f"Error: {issue}") - sys.exit(1) - crux_json = load_crux_json(target_dir) - mcps_count = len(crux_json.get("mcps", [])) if crux_json else 0 - skills_count = len(crux_json.get("skills", [])) if crux_json else 0 - print(f"Synced {target_dir.name} \u2014 {mcps_count} MCP(s), {skills_count} skill(s)") - else: - print("No crux.json in current directory.") - print( - "Run 'crux project create' to create a project, " - "or 'crux project sync --all' to sync all tracked projects." - ) - sys.exit(1) - - -def cmd_project_install(args: argparse.Namespace) -> None: - """crux project install [name ...] — add MCPs/skills to the current project and sync.""" - reg = load_registry() - names = args.names - target_dir = Path.cwd() - - mcp_defs = dict(reg.get("mcp_definitions", {})) - skill_defs = dict(reg.get("skill_definitions", {})) - - crux_json = load_crux_json(target_dir) - if crux_json is None: - crux_json = {"name": target_dir.name, "mcps": [], "skills": []} - - any_changed = False - has_errors = False - - for name in names: - if name not in mcp_defs and name not in skill_defs: - all_names = sorted(list(mcp_defs.keys()) + list(skill_defs.keys())) - print(f"Error: '{name}' not found in registry.") - if all_names: - print(f" Available: {', '.join(all_names)}") - has_errors = True - continue - - if name in mcp_defs: - if name in crux_json.setdefault("mcps", []): - print(f"Skipped: MCP '{name}' already installed") - continue - crux_json["mcps"].append(name) - print(f"Added MCP '{name}' to crux.json") - any_changed = True - else: - if name in crux_json.setdefault("skills", []): - print(f"Skipped: skill '{name}' already installed") - continue - crux_json["skills"].append(name) - - skill_data = skill_defs[name] - source_dir = skill_data.get("source_dir", "") - source_path = ( - Path(source_dir) if source_dir and Path(source_dir).is_absolute() else (v1_skills_dir_fn() / name) - ) - dest_path = target_dir / ".claude" / "skills" / name - if source_path.exists(): - dest_path.parent.mkdir(parents=True, exist_ok=True) - if dest_path.exists(): - shutil.rmtree(dest_path) - shutil.copytree(source_path, dest_path) - print(f"Added skill '{name}' to crux.json") - any_changed = True - - if has_errors and not any_changed: - sys.exit(1) - - if any_changed: - save_crux_json(target_dir, crux_json) - register_project(target_dir, crux_json.get("name", target_dir.name)) - - combined_registry = { - "version": reg.get("version", "1.0.0"), - "mcp_definitions": mcp_defs, - "skill_definitions": skill_defs, - } - print(f"Syncing {target_dir.name}...") - success, issues = sync_project(target_dir, combined_registry) - if issues: - for issue in issues: - print(f" Warning: {issue}") - else: - print(f"Synced {target_dir.name}") - - -def cmd_project_uninstall(args: argparse.Namespace) -> None: - """crux project uninstall [name ...] — remove MCPs/skills from current project and sync.""" - reg = load_registry() - names = args.names - target_dir = Path.cwd() - - crux_json = load_crux_json(target_dir) - if crux_json is None: - print(f"Error: No crux.json found in {target_dir}") - sys.exit(1) - - mcp_defs = dict(reg.get("mcp_definitions", {})) - skill_defs = dict(reg.get("skill_definitions", {})) - - any_changed = False - has_errors = False - - for name in names: - in_mcps = name in crux_json.get("mcps", []) - in_skills = name in crux_json.get("skills", []) - - if not in_mcps and not in_skills: - print(f"Error: '{name}' is not installed in this project") - has_errors = True - continue - - if in_mcps: - crux_json["mcps"].remove(name) - print(f"Removed MCP '{name}' from crux.json") - any_changed = True - - if in_skills: - crux_json["skills"].remove(name) - safe_name = Path(name).name - skills_parent = target_dir / ".claude" / "skills" - skill_dir = skills_parent / safe_name - if skill_dir.exists() and skill_dir.resolve().is_relative_to(skills_parent.resolve()): - shutil.rmtree(skill_dir) - print(f"Removed skill '{name}' from crux.json") - any_changed = True - - if has_errors and not any_changed: - sys.exit(1) - - if any_changed: - save_crux_json(target_dir, crux_json) - - combined_registry = { - "version": reg.get("version", "1.0.0"), - "mcp_definitions": mcp_defs, - "skill_definitions": skill_defs, - } - print(f"Syncing {target_dir.name}...") - success, issues = sync_project(target_dir, combined_registry) - if issues: - for issue in issues: - print(f" Warning: {issue}") - else: - print(f"Synced {target_dir.name}") - - -STATUS_STYLE = { - "connected": "[bold green]connected[/]", - "running": "[yellow]running[/]", - "auth_required": "[bold red]auth_required[/]", - "timeout": "[red]timeout[/]", - "error": "[red]error[/]", - "failed": "[bold red]failed[/]", -} - - -def _status_table(title: str, rows: list[dict]) -> None: - from rich.console import Console - from rich.table import Table - - console = Console() - table = Table(title=title, show_lines=False) - table.add_column("MCP Name", style="cyan", no_wrap=True) - table.add_column("Status") - table.add_column("Tools", justify="right") - table.add_column("Detail") - - for r in rows: - styled = STATUS_STYLE.get(r["status"], r["status"]) - tools_str = str(r["tools_count"]) if r["tools_count"] is not None else "-" - table.add_row(r["name"], styled, tools_str, r.get("detail", "")) - console.print(table) - - -def cmd_project_status(args: argparse.Namespace) -> None: - """crux project status — show MCP server health for current project or all projects.""" - - print("\nChecking MCP server health...\n") - reg = load_registry() - mcp_registry = reg.get("mcp_definitions", {}) - - if args.all: - tracked = list_projects() - if not tracked: - print("No tracked projects. Run 'crux project create' in a project directory.") - return - - found_any = False - for entry in tracked: - project = Path(entry["path"]) - if not project.exists(): - print(f"\U0001f4c1 {entry['name']} \u2014 path no longer exists ({entry['path']})") - print() - continue - crux_json = load_crux_json(project) - label = crux_json.get("name", project.name) if crux_json else entry["name"] - declared = crux_json.get("mcps", []) if crux_json else [] - has_mcp_file = (project / ".mcp.json").exists() - - if crux_json or has_mcp_file: - if declared and not has_mcp_file: - print(f" {label}: Declares MCPs but no .mcp.json \u2014 run: crux project sync\n") - else: - rows = _probe_project_servers(project, mcp_registry) - if rows: - _status_table(label, rows) - else: - print(f" {label}: no MCP servers configured\n") - found_any = True - - if not found_any: - print("No projects found. Run: crux project create ") - else: - target_dir = Path.cwd() - mcp_file = target_dir / ".mcp.json" - crux_json = load_crux_json(target_dir) - - if crux_json and not mcp_file.exists(): - print("crux.json found but no .mcp.json \u2014 run: crux project sync") - return - - if not mcp_file.exists(): - print(f"No .mcp.json in {target_dir.name}/ \u2014 run: crux project install ") - return - - with open(mcp_file) as f: - mcp_config = json.load(f) - servers = mcp_config.get("mcpServers", {}) - rows = [] - for name, config in enrich_with_marketplace(servers, mcp_registry).items(): - result = probe_mcp_server_detailed(config) - rows.append( - { - "name": name, - "status": result["status"], - "tools_count": result["tools_count"], - "detail": result["detail"], - } - ) - if rows: - _status_table(target_dir.name, rows) - print() - - -def _probe_project_servers(project_dir: Path, mcp_registry: dict) -> list[dict]: - - mcp_file = project_dir / ".mcp.json" - if not mcp_file.exists(): - return [] - with open(mcp_file) as f: - mcp_config = json.load(f) - servers = mcp_config.get("mcpServers", {}) - if not servers: - return [] - rows = [] - for name, config in enrich_with_marketplace(servers, mcp_registry).items(): - result = probe_mcp_server_detailed(config) - rows.append( - {"name": name, "status": result["status"], "tools_count": result["tools_count"], "detail": result["detail"]} - ) - return rows diff --git a/src/crux_cli/cli/commands/skill.py b/src/crux_cli/cli/commands/skill.py deleted file mode 100644 index aafbfcf..0000000 --- a/src/crux_cli/cli/commands/skill.py +++ /dev/null @@ -1,125 +0,0 @@ -"""CLI commands: crux skill add/remove/list.""" - -from __future__ import annotations - -import argparse -import json -import shutil -import subprocess -import sys -from pathlib import Path - -from crux_cli.manifest import load_registry, save_registry -from crux_cli.paths import skills_dir as v1_skills_dir -from crux_cli.validation import validate_name - - -def cmd_skill_add(args: argparse.Namespace) -> None: - """crux skill add — register a new skill.""" - reg = load_registry() - name = args.name - - ok, reason = validate_name(name) - if not ok: - print(f"✖ Invalid name '{name}': {reason}") - sys.exit(1) - - registry_key = "skill_definitions" - if reg[registry_key].get(name): - print(f"✖ Skill '{name}' already exists. Use 'crux skill remove {name}' first.") - sys.exit(1) - - entry = {"tags": args.tags.split(",") if args.tags else []} - - if args.github: - dest = v1_skills_dir() / name - dest.parent.mkdir(parents=True, exist_ok=True) - print(f" Cloning: {args.github} -> {dest}") - subprocess.run( # noqa: S603 - ["git", "clone", f"https://github.com/{args.github}", str(dest)], # noqa: S607 - check=True, - ) - entry.update({"type": "github", "source": args.github, "source_dir": str(dest)}) - if not (dest / "SKILL.md").exists(): - print(f" Warning: No SKILL.md found in {dest}. Consider adding one for discoverability.") - elif args.local: - source = Path(args.local).resolve() - dest = v1_skills_dir() / name - dest.parent.mkdir(parents=True, exist_ok=True) - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(source, dest) - entry.update({"type": "local", "source_dir": str(dest)}) - if not (dest / "SKILL.md").exists(): - print(f" Warning: No SKILL.md found in {dest}. Consider adding one for discoverability.") - else: - print("✖ Specify a source: --github or --local ") - sys.exit(1) - - reg[registry_key][name] = entry - save_registry(reg) - print(f"✅ Registered skill '{name}'") - - -def cmd_skill_remove(args: argparse.Namespace) -> None: - """crux skill remove — unregister a skill.""" - reg = load_registry() - name = args.name - section = "skill_definitions" - - if name not in reg.get(section, {}): - print(f"✖ Skill '{name}' not found in registry") - sys.exit(1) - - data = reg[section][name] - source_dir = data.get("source_dir") - if source_dir and Path(source_dir).exists() and data.get("type") in ("github", "local"): - from crux_cli.paths import crux_home - - resolved = Path(source_dir).resolve() - if str(resolved).startswith(str(crux_home().resolve())): - shutil.rmtree(source_dir) - print(f" Deleted cloned source: {source_dir}") - else: - print(f" Skipped deletion: {source_dir} is outside ~/.crux/") - - del reg[section][name] - save_registry(reg) - print(f"✅ Removed skill '{name}' from registry") - print(" Note: run 'crux project sync' to regenerate project configurations") - - -def cmd_skill_list(args: argparse.Namespace) -> None: - """crux skill list — show registered skills.""" - reg = load_registry() - all_skills = dict(reg.get("skill_definitions", {})) - - if getattr(args, "json_output", False): - print(json.dumps({"skill_definitions": all_skills}, indent=2)) - return - - from rich import box - from rich.console import Console - from rich.table import Table - - console = Console() - - if not all_skills: - print("No skills registered. Run 'crux skill add --github ' to get started.") - print() - return - - table = Table(title="Skills", box=box.SIMPLE_HEAVY, show_lines=False, expand=True) - table.add_column("Name", style="bold cyan", min_width=15, no_wrap=True) - table.add_column("Type", style="dim", min_width=12, no_wrap=True) - table.add_column("Description", max_width=40) - table.add_column("Source", style="blue", max_width=35) - - for name, data in sorted(all_skills.items()): - skill_type = data.get("type", "unknown") - desc = ", ".join(data.get("tags", [])) or "" - source = data.get("source", "") or data.get("source_dir", "") - table.add_row(name, skill_type, desc, str(source)) - - console.print(table) - print() diff --git a/src/crux_cli/cli/commands/task.py b/src/crux_cli/cli/commands/task.py deleted file mode 100644 index eecccc4..0000000 --- a/src/crux_cli/cli/commands/task.py +++ /dev/null @@ -1,151 +0,0 @@ -"""CLI commands: crux task run/init/list/clean.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path - -from crux_cli.manifest import load_registry -from crux_cli.sandbox import ( - clean_runs, - create_sandbox, - generate_run_id, - list_runs, - load_run_manifest, - run_agent, - write_run_meta, -) - - -def cmd_task(args: argparse.Namespace) -> None: - """Dispatch crux task subcommands.""" - sub = getattr(args, "task_command", None) - - if sub == "run": - _cmd_task_run(args) - elif sub == "init": - _cmd_task_init(getattr(args, "name", None)) - elif sub == "list": - _cmd_task_list() - elif sub == "clean": - _cmd_task_clean(force=getattr(args, "force", False)) - else: - print("crux task: specify a subcommand (run/init/list/clean)") - sys.exit(1) - - -def _cmd_task_run(args: argparse.Namespace) -> None: - """Execute a task — from inline string or run.json manifest.""" - reg = load_registry() - - run_file = getattr(args, "file", None) - task_str = getattr(args, "task", None) - - if run_file: - run_manifest = load_run_manifest(run_file) - task = run_manifest.get("task", "") - mcps = run_manifest.get("mcps", []) - name = run_manifest.get("name") - timeout = run_manifest.get("timeout", 300) - else: - task = task_str - mcps = getattr(args, "mcps", []) or [] - name = None - timeout = None - - if not task: - print("\u274c No task specified. Provide a task string or a run.json with a 'task' field.") - sys.exit(1) - - run_id = generate_run_id() - sandbox_path = create_sandbox( - run_id, - mcps, - registry=reg, - skip_preflight=False, - ) - write_run_meta(sandbox_path, run_id, task, mcps, name=name) - - exit_code = run_agent( - sandbox_path, - task, - timeout=timeout, - run_id=run_id, - ) - sys.exit(exit_code) - - -def _cmd_task_init(name: str | None = None) -> None: - """Scaffold a run.json template in the current directory.""" - name = name or "my-run" - output_path = Path.cwd() / "run.json" - - if output_path.exists(): - print(f"\u274c run.json already exists at {output_path}") - sys.exit(1) - - reg = load_registry() - available_mcps = sorted(reg.get("mcp_definitions", {}).keys()) - - template = { - "name": name, - "task": "Describe your task here", - "mcps": [], - "skills": [], - "workspace": "scratch", - "output": "output.md", - "timeout": 300, - } - output_path.write_text(json.dumps(template, indent=2) + "\n") - - print(f"\u2705 Created run.json for '{name}'") - print(" Edit the 'task' field, then run: crux task run --file run.json") - if available_mcps: - print(f" Available MCPs: {', '.join(available_mcps)}") - - -def _cmd_task_list() -> None: - """Show all past/active runs from sandbox/.""" - from rich.console import Console - from rich.table import Table - - runs = list_runs() - console = Console() - - if not runs: - console.print("[dim]No runs found in sandbox/[/dim]") - return - - table = Table(title="crux task history", show_lines=False) - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Name", style="bold") - table.add_column("Status", no_wrap=True) - table.add_column("MCPs", style="dim") - table.add_column("Task", max_width=50, overflow="ellipsis") - - status_colors = { - "done": "[green]done[/green]", - "failed": "[red]failed[/red]", - "running": "[yellow]running[/yellow]", - } - - for run in runs: - status = run.get("status", "?") - status_str = status_colors.get(status, status) - mcps_str = ", ".join(run.get("mcps", [])) or "\u2014" - task = run.get("task", "") - run_name = run.get("name", run.get("id", "")) - table.add_row(run.get("id", ""), run_name, status_str, mcps_str, task) - - console.print(table) - - -def _cmd_task_clean(force: bool = False) -> None: - """Remove completed sandboxes.""" - count = clean_runs(force=force) - if count == 0: - print("sandbox/ is already empty.") - else: - print(f"\u2705 Removed {count} sandbox(es)") diff --git a/src/crux_cli/cli/commands/version.py b/src/crux_cli/cli/commands/version.py deleted file mode 100644 index b11b485..0000000 --- a/src/crux_cli/cli/commands/version.py +++ /dev/null @@ -1,13 +0,0 @@ -"""CLI command: version.""" - -from __future__ import annotations - -import argparse - - -def cmd_version(args: argparse.Namespace) -> None: - """crux version [--check] — show installed version and optionally check for updates.""" - from crux_cli.version import format_version_output - - check = getattr(args, "check", False) - print(format_version_output(check=check)) diff --git a/src/crux_cli/health.py b/src/crux_cli/health.py deleted file mode 100644 index a68d7ca..0000000 --- a/src/crux_cli/health.py +++ /dev/null @@ -1,295 +0,0 @@ -"""health.py — MCP server health probing via JSON-RPC handshake and environment diagnostics.""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Any - -# --------------------------------------------------------------------------- -# MCP server probing -# --------------------------------------------------------------------------- - - -def probe_mcp_server(config: dict[str, Any]) -> tuple[str, str]: - """Probe an MCP server via MCP initialize + tools/list handshake. - Returns (status, reason). - """ - result = probe_mcp_server_detailed(config) - return result["status"], result["detail"] - - -def probe_mcp_server_detailed(config: dict[str, Any]) -> dict[str, Any]: - """Extended probe returning a dict with status, detail, tools_count, server_info.""" - command = config.get("command", "") - args_list = config.get("args", []) - env_overrides = config.get("env", {}) - auth = config.get("auth", {}) - - if not shutil.which(command): - return { - "status": "failed", - "detail": f"command not found: '{command}'", - "tools_count": None, - "server_info": None, - } - - if command.startswith("http://") or command.startswith("https://"): - return {"status": "! needs authentication", "detail": command, "tools_count": None, "server_info": None} - - if auth.get("check_cmd"): - try: - result = subprocess.run(auth["check_cmd"], capture_output=True, timeout=5) # noqa: S603 - if result.returncode != 0: - return { - "status": "auth_required", - "detail": auth.get("fix_description", "authentication required"), - "tools_count": None, - "server_info": None, - } - except Exception: - return { - "status": "auth_required", - "detail": auth.get("fix_description", "authentication check failed"), - "tools_count": None, - "server_info": None, - } - - env = os.environ.copy() - env.update(env_overrides) - full_cmd = [command] + [str(a) for a in args_list] - - messages = ( - json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "crux", "version": "0.1.0"}, - }, - } - ) - + "\n" - + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) - + "\n" - ) - - try: - proc = subprocess.Popen( # noqa: S603 - full_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env - ) - stdout, _ = proc.communicate(input=messages.encode(), timeout=10) - lines = [line for line in stdout.decode().splitlines() if line.strip()] - - server_info_detail = None - tools_count = None - for line in lines: - try: - resp = json.loads(line) - except json.JSONDecodeError: - continue - if resp.get("id") == 1 and "result" in resp: - si = resp["result"].get("serverInfo", {}) - name = si.get("name", "") - version = si.get("version", "") - server_info_detail = f"{name} {version}".strip() if name else "connected" - if resp.get("id") == 2: - if "error" in resp: - msg = resp["error"].get("message", "") - auth_keywords = ["auth", "login", "credential", "token", "unauthorized", "permission"] - if any(k in msg.lower() for k in auth_keywords): - return { - "status": "auth_required", - "detail": msg, - "tools_count": None, - "server_info": server_info_detail, - } - return {"status": "error", "detail": msg, "tools_count": None, "server_info": server_info_detail} - if "result" in resp: - tools = resp["result"].get("tools", []) - tools_count = len(tools) - return { - "status": "connected", - "detail": server_info_detail or "connected", - "tools_count": tools_count, - "server_info": server_info_detail, - } - - return { - "status": "running", - "detail": server_info_detail or " ".join(full_cmd), - "tools_count": None, - "server_info": server_info_detail, - } - - except subprocess.TimeoutExpired: - proc.kill() - return { - "status": "timeout", - "detail": "server started but did not respond in time", - "tools_count": None, - "server_info": None, - } - except Exception as e: - return {"status": "failed", "detail": str(e), "tools_count": None, "server_info": None} - - -# --------------------------------------------------------------------------- -# Environment diagnostic checks for `crux doctor` -# --------------------------------------------------------------------------- - - -class CheckResult: - """Single diagnostic check result.""" - - __slots__ = ("label", "passed", "warning", "fix_hint") - - def __init__(self, label: str, *, passed: bool, warning: bool = False, fix_hint: str | None = None): - """Create a check result. - - Args: - label: Human-readable description of what was checked. - passed: Whether the check passed. - warning: If ``True``, treat as a non-fatal warning instead of failure. - fix_hint: Actionable command or instruction to resolve the issue. - """ - self.label = label - self.passed = passed - self.warning = warning - self.fix_hint = fix_hint - - def __repr__(self) -> str: - mark = "PASS" if self.passed else ("WARN" if self.warning else "FAIL") - return f"CheckResult({mark}: {self.label})" - - -def check_python_version(min_version: tuple[int, int] = (3, 11)) -> CheckResult: - """Check that the running Python meets the minimum version.""" - current = sys.version_info[:2] - ok = current >= min_version - label = f"Python >= {min_version[0]}.{min_version[1]} (found {current[0]}.{current[1]})" - return CheckResult( - label, passed=ok, fix_hint=f"Install Python {min_version[0]}.{min_version[1]}+: https://python.org" - ) - - -def check_tool_installed(name: str, *, fix_hint: str | None = None) -> CheckResult: - """Check that an external tool is on PATH.""" - found = shutil.which(name) is not None - label = f"{name} installed" - return CheckResult(label, passed=found, fix_hint=fix_hint) - - -def check_directory_structure(crux_root: Path) -> list[CheckResult]: - """Verify that essential Crux directories exist.""" - checks = [] - expected = [ - (crux_root, "Crux root exists"), - (crux_root / "marketplace", "marketplace/ directory"), - (crux_root / "marketplace" / "mcps", "marketplace/mcps/ directory"), - (crux_root / "marketplace" / "skills", "marketplace/skills/ directory"), - (crux_root / "src", "src/ directory"), - ] - for path, label in expected: - checks.append(CheckResult(label, passed=path.is_dir(), fix_hint=f"mkdir -p {path}")) - return checks - - -def check_registry_valid(registry_path: Path) -> CheckResult: - """Verify the registry JSON is valid and parseable.""" - if not registry_path.exists(): - return CheckResult("Registry JSON exists", passed=False, fix_hint=f"File not found: {registry_path}") - try: - with open(registry_path) as f: - data = json.load(f) - if not isinstance(data, dict): - return CheckResult("Registry JSON valid", passed=False, fix_hint="Registry is not a JSON object") - return CheckResult("Registry JSON valid", passed=True) - except (json.JSONDecodeError, OSError) as e: - return CheckResult("Registry JSON valid", passed=False, fix_hint=f"Parse error: {e}") - - -def check_mcp_sources_present(crux_root: Path, mcp_definitions: dict[str, Any]) -> list[CheckResult]: - """Check that cloned MCP source directories exist on disk.""" - checks = [] - for mcp_name, defn in mcp_definitions.items(): - source_dir = defn.get("source_dir") - if source_dir: - full = crux_root / source_dir - checks.append( - CheckResult( - f"MCP source: {mcp_name} ({source_dir})", - passed=full.is_dir(), - fix_hint=f"Run: crux mcp add {mcp_name}", - ) - ) - return checks - - -def check_build_artifacts(crux_root: Path, mcp_definitions: dict[str, Any]) -> list[CheckResult]: - """Check that MCPs with build_cmd have their build artifacts.""" - checks = [] - for mcp_name, defn in mcp_definitions.items(): - build_cmd = defn.get("build_cmd") - source_dir = defn.get("source_dir") - if build_cmd and source_dir: - src = crux_root / source_dir - has_artifacts = ( - (src / "node_modules").is_dir() - or (src / "dist").is_dir() - or (src / ".venv").is_dir() - or (src / "build").is_dir() - ) - checks.append( - CheckResult( - f"Build artifacts: {mcp_name}", - passed=has_artifacts, - warning=not has_artifacts, - fix_hint=f"Run: cd {src} && {build_cmd}", - ) - ) - return checks - - -def check_crux_in_path() -> CheckResult: - """Check that the crux binary is on PATH.""" - found = shutil.which("crux") is not None - return CheckResult("crux binary in PATH", passed=found, fix_hint="Add crux bin/ to PATH in ~/.zshrc") - - -def run_doctor_checks( - crux_root: Path, - registry_path: Path | None = None, - mcp_definitions: dict[str, Any] | None = None, -) -> list[CheckResult]: - """Run all doctor checks and return a flat list of results.""" - results: list[CheckResult] = [] - - results.append(check_python_version()) - results.append(check_tool_installed("uv", fix_hint="curl -LsSf https://astral.sh/uv/install.sh | sh")) - results.append(check_tool_installed("git", fix_hint="Install git: https://git-scm.com")) - results.append(check_tool_installed("node", fix_hint="Install Node.js: https://nodejs.org")) - results.append(check_tool_installed("npm", fix_hint="Install Node.js (npm is bundled)")) - results.append( - check_tool_installed("claude", fix_hint="Install Claude CLI: https://docs.anthropic.com/en/docs/claude-cli") - ) - - results.extend(check_directory_structure(crux_root)) - - if registry_path is not None: - results.append(check_registry_valid(registry_path)) - - if mcp_definitions: - results.extend(check_mcp_sources_present(crux_root, mcp_definitions)) - results.extend(check_build_artifacts(crux_root, mcp_definitions)) - - results.append(check_crux_in_path()) - - return results diff --git a/src/crux_cli/manifest.py b/src/crux_cli/manifest.py deleted file mode 100644 index a49ae98..0000000 --- a/src/crux_cli/manifest.py +++ /dev/null @@ -1,93 +0,0 @@ -"""manifest.py — Load and save the v1 registry and per-project crux.json files.""" - -from __future__ import annotations - -import json -import tempfile -from pathlib import Path -from typing import Any - -from crux_cli.paths import registry_path - -REGISTRY_VERSION = "1.0.0" - - -def _empty_registry() -> dict[str, Any]: - """Return an empty v1 registry structure.""" - return { - "version": REGISTRY_VERSION, - "mcp_definitions": {}, - "skill_definitions": {}, - } - - -# --------------------------------------------------------------------------- -# v1 registry (at ~/.crux/registry.json via paths.registry_path()) -# --------------------------------------------------------------------------- - - -def load_registry(path: Path | None = None) -> dict[str, Any]: - """Load the v1 registry from *path*. - - If *path* is ``None``, uses the canonical registry_path() location. - When the file does not exist an empty registry is created on disk and returned. - """ - if path is None: - path = registry_path() - - if not path.exists(): - reg = _empty_registry() - save_registry(reg, path) - return reg - - with open(path) as f: - data = json.load(f) - if not isinstance(data, dict): - msg = f"Registry at {path} is not a JSON object — got {type(data).__name__}" - raise ValueError(msg) - return data - - -def save_registry(data: dict[str, Any], path: Path | None = None) -> None: - """Atomically write the v1 registry to *path*.""" - if path is None: - path = registry_path() - - _atomic_json_write(path, data) - - -# --------------------------------------------------------------------------- -# Per-project crux.json -# --------------------------------------------------------------------------- - - -def load_crux_json(project_dir: Path) -> dict[str, Any] | None: - """Load a project's crux.json, returning None if it doesn't exist.""" - path = project_dir / "crux.json" - if not path.exists(): - return None - with open(path) as f: - return json.load(f) - - -def save_crux_json(project_dir: Path, data: dict[str, Any]) -> None: - """Atomically write a project's crux.json.""" - _atomic_json_write(project_dir / "crux.json", data) - - -# --------------------------------------------------------------------------- -# Shared helpers -# --------------------------------------------------------------------------- - - -def _atomic_json_write(path: Path, data: dict[str, Any]) -> None: - """Write JSON data to a file atomically via temp file + rename.""" - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - try: - with open(fd, "w") as f: - json.dump(data, f, indent=2) - Path(tmp).replace(path) - except BaseException: - Path(tmp).unlink(missing_ok=True) - raise diff --git a/src/crux_cli/paths.py b/src/crux_cli/paths.py index 08eaaab..319a22d 100644 --- a/src/crux_cli/paths.py +++ b/src/crux_cli/paths.py @@ -1,9 +1,10 @@ -"""Canonical path constants for Crux's ~/.crux/ home directory. +"""Canonical paths under Crux's ``~/.crux/`` home directory. -All paths respect two environment variables: - - CRUX_HOME Override the default ~/.crux/ root - - CRUX_TEST_ROOT If set, ALL paths are rooted under this directory - instead of ~/.crux/ (used for test isolation) +Paths respect two environment variables: + +- ``CRUX_HOME`` — Override the default ``~/.crux/`` root. +- ``CRUX_TEST_ROOT`` — If set, every path is rooted under this directory + instead. Used for test isolation. """ from __future__ import annotations @@ -13,58 +14,20 @@ def _resolve_crux_home() -> Path: - """Determine the Crux home directory, respecting env overrides.""" test_root = os.environ.get("CRUX_TEST_ROOT") if test_root: return Path(test_root) - env_home = os.environ.get("CRUX_HOME") if env_home: return Path(env_home) - return Path.home() / ".crux" def crux_home() -> Path: - """Return the resolved Crux home directory.""" + """Resolved Crux home directory.""" return _resolve_crux_home() -def registry_path() -> Path: - """Path to the local plugin/MCP registry.""" - return crux_home() / "registry.json" - - -def mcps_dir() -> Path: - """Directory containing installed MCP server configurations.""" - return crux_home() / "mcps" - - -def launchers_dir() -> Path: - """Directory containing generated MCP launcher scripts (legacy).""" - return crux_home() / "mcps" / "launchers" - - -def shared_launchers_dir() -> Path: - """Directory containing shared (non-generated) launcher scripts.""" - return crux_home() / "launchers" - - -def skills_dir() -> Path: - """Directory containing installed skills.""" - return crux_home() / "skills" - - -def sandbox_dir() -> Path: - """Directory containing run sandboxes.""" - return crux_home() / "sandbox" - - -def projects_path() -> Path: - """Path to the projects index file.""" - return crux_home() / "projects.json" - - def secrets_path() -> Path: """Path to the secrets index file.""" return crux_home() / "secrets.json" @@ -76,7 +39,7 @@ def config_path() -> Path: def tokens_path() -> Path: - """Path to the OAuth token metadata file.""" + """Path to the OAuth token metadata file (used by HTTP bridges).""" return crux_home() / "tokens.json" @@ -91,40 +54,40 @@ def registry_root() -> Path: def mcps_root() -> Path: - """v2 MCP registry directory: ~/.crux/registry/mcps/.""" + """v2 MCP registry directory: ``~/.crux/registry/mcps/``.""" return registry_root() / "mcps" def skills_root() -> Path: - """v2 skills registry directory: ~/.crux/registry/skills/.""" + """v2 skills registry directory: ``~/.crux/registry/skills/``.""" return registry_root() / "skills" def plugins_root() -> Path: - """v2 plugins registry directory: ~/.crux/registry/plugins/.""" + """v2 plugins registry directory: ``~/.crux/registry/plugins/``.""" return registry_root() / "plugins" def harnesses_root() -> Path: - """v2 harnesses registry directory: ~/.crux/registry/harnesses/.""" + """v2 harnesses registry directory: ``~/.crux/registry/harnesses/``.""" return registry_root() / "harnesses" def active_pointer_path() -> Path: - """User-level active harness pointer: ~/.crux/active.toml.""" + """User-level active harness pointer: ``~/.crux/active.toml``.""" return crux_home() / "active.toml" def history_path() -> Path: - """User-level activation history log: ~/.crux/history.""" + """User-level activation history log: ``~/.crux/history``.""" return crux_home() / "history" def claude_user_dir() -> Path: - """User-level Claude Code directory: ~/.claude/.""" + """User-level Claude Code directory: ``~/.claude/``.""" return Path.home() / ".claude" def claude_dir_for(project_dir: Path) -> Path: - """Directory-level Claude Code directory: /.claude/.""" + """Directory-level Claude Code directory: ``/.claude/``.""" return project_dir / ".claude" diff --git a/src/crux_cli/preflight.py b/src/crux_cli/preflight.py deleted file mode 100644 index e6a9392..0000000 --- a/src/crux_cli/preflight.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Pre-flight validation for sandbox creation. - -Runs 6 checks that ALL must pass before a sandbox is created. -""" - -from __future__ import annotations - -import subprocess -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from crux_cli.paths import crux_home, registry_path, skills_dir - -_CLONED_TYPES = {"github", "git-submodule", "local"} - - -@dataclass -class PreflightResult: - """Result of all pre-flight checks.""" - - ok: bool - errors: list[str] = field(default_factory=list) - - -def run_preflight( - mcps: list[str], - skills: list[str], - registry: dict[str, Any] | None = None, -) -> PreflightResult: - """Run all pre-flight checks and return the result.""" - if registry is None: - registry = _load_registry() - - mcp_defs = registry.get("mcp_definitions", {}) - skill_defs = registry.get("skill_definitions", {}) - errors: list[str] = [] - - for mcp_name in mcps: - _check_mcp_exists(mcp_name, mcp_defs, errors) - if mcp_name in mcp_defs: - mcp_data = mcp_defs[mcp_name] - _check_mcp_source(mcp_name, mcp_data, errors) - _check_auth_secrets(mcp_name, mcp_data, errors) - _check_auth_preflight(mcp_name, mcp_data, errors) - _check_build_artifacts(mcp_name, mcp_data, errors) - - for skill_name in skills: - _check_skill(skill_name, skill_defs, errors) - - return PreflightResult(ok=len(errors) == 0, errors=errors) - - -# --------------------------------------------------------------------------- -# Individual checks -# --------------------------------------------------------------------------- - - -def _check_mcp_exists(name: str, mcp_defs: dict[str, Any], errors: list[str]) -> None: - if name not in mcp_defs: - available = ", ".join(sorted(mcp_defs.keys())) or "(none)" - errors.append( - f"MCP '{name}' not found in registry. Available: {available}. Fix: crux mcp add {name} --npx " - ) - - -def _check_mcp_source(name: str, mcp_data: dict[str, Any], errors: list[str]) -> None: - mcp_type = mcp_data.get("type", "") - if mcp_type not in _CLONED_TYPES: - return - - source_dir = mcp_data.get("source_dir", "") - if not source_dir: - return - - source_path = Path(source_dir) - if not source_path.is_absolute(): - source_path = crux_home().parent / source_dir - - if not source_path.exists(): - errors.append(f"MCP '{name}' source directory missing: {source_path}. Fix: crux add mcp {name} --github ") - - -def _check_auth_secrets(name: str, mcp_data: dict[str, Any], errors: list[str]) -> None: - auth = mcp_data.get("auth", {}) - env_vars = auth.get("env_vars", []) - if not env_vars: - return - - try: - from crux_cli.secrets import load_secrets_index # noqa: PLC0415 - except ImportError: - return - secrets_index = load_secrets_index() - stored_keys = secrets_index.get(name, []) - - for var in env_vars: - if var not in stored_keys: - errors.append(f"MCP '{name}' missing secret '{var}'. Fix: crux mcp auth {name}") - - -def _check_auth_preflight(name: str, mcp_data: dict[str, Any], errors: list[str]) -> None: - auth = mcp_data.get("auth", {}) - check_cmd = auth.get("check_cmd") - if not check_cmd: - return - - try: - result = subprocess.run(check_cmd, capture_output=True, timeout=10) # noqa: S603 - if result.returncode != 0: - fix_desc = auth.get("fix_description", f"Run: {' '.join(auth.get('fix_cmd', []))}") - errors.append(f"MCP '{name}' auth check failed (exit {result.returncode}). Fix: {fix_desc}") - except FileNotFoundError: - fix_desc = auth.get("fix_description", f"Install {check_cmd[0]}") - errors.append(f"MCP '{name}' auth check command not found: {check_cmd[0]}. Fix: {fix_desc}") - except subprocess.TimeoutExpired: - errors.append(f"MCP '{name}' auth check timed out after 10s.") - - -def _check_skill(name: str, skill_defs: dict[str, Any], errors: list[str]) -> None: - if name not in skill_defs: - available = ", ".join(sorted(skill_defs.keys())) or "(none)" - errors.append( - f"Skill '{name}' not found in registry. Available: {available}. Fix: crux skill add {name} --github " - ) - return - - skill_data = skill_defs[name] - source_dir = skill_data.get("source_dir", "") - if source_dir: - source_path = Path(source_dir) - if not source_path.is_absolute(): - candidate = skills_dir() / name - if not candidate.exists(): - candidate = crux_home().parent / source_dir - source_path = candidate - if not source_path.exists(): - errors.append(f"Skill '{name}' source missing at {source_path}. Fix: crux skill add {name} --github ") - - -def _check_build_artifacts(name: str, mcp_data: dict[str, Any], errors: list[str]) -> None: - build_cmd = mcp_data.get("build_cmd") - if not build_cmd: - return - - source_dir = mcp_data.get("source_dir", "") - if not source_dir: - return - - source_path = Path(source_dir) - if not source_path.is_absolute(): - source_path = crux_home().parent / source_dir - - build_indicators = ["node_modules", "dist", "build", ".build", "__pycache__"] - has_artifacts = any((source_path / ind).exists() for ind in build_indicators) - - if source_path.exists() and not has_artifacts: - errors.append( - f"MCP '{name}' appears unbuilt (no build artifacts in {source_path}). Fix: cd {source_path} && {build_cmd}" - ) - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _load_registry() -> dict[str, Any]: - import json - - path = registry_path() - if not path.exists(): - return {"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}} - with open(path) as f: - return json.load(f) diff --git a/src/crux_cli/projects.py b/src/crux_cli/projects.py deleted file mode 100644 index 2cb5234..0000000 --- a/src/crux_cli/projects.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Project tracking for Crux — register, list, and prune tracked projects. - -Tracked projects are stored in ``~/.crux/projects.json`` with the schema:: - - {"projects": [{"path": "...", "name": "...", "registered_at": "..."}]} -""" - -from __future__ import annotations - -import json -import tempfile -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -from crux_cli.paths import projects_path - - -def _load_projects_file(path: Path | None = None) -> dict[str, Any]: - """Load the projects index, returning an empty structure if missing.""" - path = path or projects_path() - if not path.exists(): - return {"projects": []} - with open(path) as f: - return json.load(f) - - -def _save_projects_file(data: dict[str, Any], path: Path | None = None) -> None: - """Atomically write the projects index.""" - path = path or projects_path() - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - try: - with open(fd, "w") as f: - json.dump(data, f, indent=2) - Path(tmp).replace(path) - except BaseException: - Path(tmp).unlink(missing_ok=True) - raise - - -def register_project(project_path: Path, name: str, *, projects_file: Path | None = None) -> None: - """Register a project path. No-op if already registered.""" - data = _load_projects_file(projects_file) - abs_path = str(project_path.resolve()) - - for entry in data["projects"]: - if entry["path"] == abs_path: - return - - data["projects"].append( - { - "path": abs_path, - "name": name, - "registered_at": datetime.now(tz=UTC).isoformat(), - } - ) - _save_projects_file(data, projects_file) - - -def list_projects(*, projects_file: Path | None = None) -> list[dict[str, Any]]: - """Return all tracked projects.""" - data = _load_projects_file(projects_file) - return data["projects"] - - -def detect_stale_projects(*, projects_file: Path | None = None) -> list[dict[str, Any]]: - """Return tracked projects whose paths no longer exist on disk.""" - projects = list_projects(projects_file=projects_file) - return [p for p in projects if not Path(p["path"]).exists()] - - -def remove_stale_projects(*, projects_file: Path | None = None) -> list[dict[str, Any]]: - """Remove all stale projects and return the removed entries.""" - data = _load_projects_file(projects_file) - stale = [p for p in data["projects"] if not Path(p["path"]).exists()] - if stale: - stale_paths = {p["path"] for p in stale} - data["projects"] = [p for p in data["projects"] if p["path"] not in stale_paths] - _save_projects_file(data, projects_file) - return stale diff --git a/src/crux_cli/sandbox.py b/src/crux_cli/sandbox.py deleted file mode 100644 index 94af3bf..0000000 --- a/src/crux_cli/sandbox.py +++ /dev/null @@ -1,323 +0,0 @@ -"""Extended sandbox lifecycle — creation, agent execution, listing, and cleanup.""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -import time -from datetime import datetime -from pathlib import Path -from typing import Any - -from crux_cli.paths import sandbox_dir -from crux_cli.preflight import run_preflight -from crux_cli.sync import _build_server_entry, _load_registry_for_sync - -# --------------------------------------------------------------------------- -# Run ID -# --------------------------------------------------------------------------- - - -def _token_hex(n: int) -> str: - """Generate n random bytes as a hex string without importing secrets.""" - return os.urandom(n).hex() - - -def generate_run_id() -> str: - """Generate a run ID: YYYYMMDD-XXXX (e.g. 20260315-a3f2).""" - date_part = datetime.now().strftime("%Y%m%d") - hex_part = _token_hex(2) - return f"{date_part}-{hex_part}" - - -# --------------------------------------------------------------------------- -# Sandbox creation -# --------------------------------------------------------------------------- - - -def create_sandbox( - run_id: str, - mcps: list[str], - skills: list[str] | None = None, - registry: dict[str, Any] | None = None, - *, - skip_preflight: bool = False, -) -> Path: - """Create ``~/.crux/sandbox//`` with scoped .mcp.json and workspace.""" - skills = skills or [] - - if registry is None: - registry = _load_registry_for_sync() - - if not skip_preflight: - result = run_preflight(mcps, skills, registry=registry) - if not result.ok: - msg = "Pre-flight checks failed:\n" + "\n".join(f" - {e}" for e in result.errors) - raise PreflightError(msg, errors=result.errors) - - base = sandbox_dir() - if "/" in run_id or "\\" in run_id or run_id.startswith("."): - msg = f"Invalid run_id: {run_id!r}" - raise ValueError(msg) - sandbox_path = base / run_id - workspace = sandbox_path / "workspace" - - sandbox_path.mkdir(parents=True, exist_ok=True) - workspace.mkdir(exist_ok=True) - - mcp_defs = registry.get("mcp_definitions", {}) - mcp_servers: dict[str, Any] = {} - for mcp_name in mcps: - if mcp_name in mcp_defs: - mcp_servers[mcp_name] = _build_server_entry(mcp_name, mcp_defs[mcp_name]) - - mcp_config = {"mcpServers": mcp_servers} - (sandbox_path / ".mcp.json").write_text(json.dumps(mcp_config, indent=2)) - - return sandbox_path - - -class PreflightError(Exception): - """Raised when pre-flight checks fail.""" - - def __init__(self, message: str, errors: list[str] | None = None) -> None: - super().__init__(message) - self.errors = errors or [] - - -# --------------------------------------------------------------------------- -# Run metadata -# --------------------------------------------------------------------------- - - -def write_run_meta( - sandbox_path: Path, - run_id: str, - task: str, - mcps: list[str], - status: str = "running", - name: str | None = None, - **extra: Any, -) -> dict[str, Any]: - """Write initial run-meta.json.""" - meta: dict[str, Any] = { - "id": run_id, - "name": name or run_id, - "task": task, - "mcps": mcps, - "status": status, - "started_at": datetime.now().isoformat(), - } - meta.update(extra) - _atomic_json_write(sandbox_path / "run-meta.json", meta) - return meta - - -def update_run_meta(sandbox_path: Path, **kwargs: Any) -> None: - """Merge kwargs into the existing run-meta.json.""" - meta_path = sandbox_path / "run-meta.json" - meta: dict[str, Any] = {} - if meta_path.exists(): - meta = json.loads(meta_path.read_text()) - meta.update(kwargs) - _atomic_json_write(meta_path, meta) - - -def _atomic_json_write(path: Path, data: dict[str, Any]) -> None: - """Write JSON atomically via temp file + rename.""" - import tempfile - - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - try: - with open(fd, "w") as f: - json.dump(data, f, indent=2) - Path(tmp).replace(path) - except BaseException: - Path(tmp).unlink(missing_ok=True) - raise - - -# --------------------------------------------------------------------------- -# Agent execution -# --------------------------------------------------------------------------- - - -def _find_claude() -> str | None: - """Locate the claude binary.""" - found = shutil.which("claude") - if found: - return found - fallback = os.path.expanduser("~/.local/bin/claude") - if os.path.exists(fallback): - return fallback - return None - - -def run_agent( - sandbox_path: Path, - task: str, - *, - timeout: int | None = None, - run_id: str | None = None, -) -> int: - """Invoke ``claude --print --mcp-config `` as a subprocess.""" - mcp_config_path = sandbox_path / ".mcp.json" - workspace = sandbox_path / "workspace" - run_id = run_id or sandbox_path.name - - claude_bin = _find_claude() - if not claude_bin: - update_run_meta(sandbox_path, status="failed", exit_code=1) - return 1 - - cmd = [ - claude_bin, - "--print", - task, - "--mcp-config", - str(mcp_config_path), - ] - - start = time.monotonic() - try: - proc = subprocess.Popen( # noqa: S603 - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=str(workspace), - start_new_session=True, - ) - try: - proc.communicate(timeout=timeout) - exit_code = proc.returncode - except subprocess.TimeoutExpired: - import signal - - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - proc.wait(timeout=5) - except (ProcessLookupError, subprocess.TimeoutExpired): - os.killpg(os.getpgid(proc.pid), signal.SIGKILL) - proc.wait() - exit_code = 124 - except OSError: - exit_code = 1 - elapsed = time.monotonic() - start - - status = "done" if exit_code == 0 else "failed" - if exit_code == 124: - status = "timeout" - - update_run_meta( - sandbox_path, - status=status, - exit_code=exit_code, - ended_at=datetime.now().isoformat(), - duration_seconds=round(elapsed, 2), - ) - return exit_code - - -# --------------------------------------------------------------------------- -# Run listing (crux run list) -# --------------------------------------------------------------------------- - - -def list_runs() -> list[dict[str, Any]]: - """Return list of run metadata dicts, sorted by start time (newest first).""" - base = sandbox_dir() - runs: list[dict[str, Any]] = [] - if not base.exists(): - return runs - - for entry in sorted(base.iterdir()): - if not entry.is_dir(): - continue - meta_path = entry / "run-meta.json" - if meta_path.exists(): - try: - runs.append(json.loads(meta_path.read_text())) - except (json.JSONDecodeError, OSError): - continue - - runs.sort(key=lambda r: r.get("started_at", ""), reverse=True) - return runs - - -# --------------------------------------------------------------------------- -# Run cleanup (crux run clean) -# --------------------------------------------------------------------------- - -_DEFAULT_KEEP = 5 - - -def clean_runs(*, force: bool = False, keep: int = _DEFAULT_KEEP) -> int: - """Remove completed sandboxes.""" - base = sandbox_dir() - if not base.exists(): - return 0 - - dirs = sorted( - [d for d in base.iterdir() if d.is_dir()], - key=lambda d: d.name, - ) - if not dirs: - return 0 - - if force: - count = 0 - for d in dirs: - shutil.rmtree(d) - count += 1 - return count - - completed: list[Path] = [] - for d in dirs: - meta_path = d / "run-meta.json" - status = "unknown" - if meta_path.exists(): - try: - meta = json.loads(meta_path.read_text()) - status = meta.get("status", "unknown") - except (json.JSONDecodeError, OSError): - pass - if status in ("done", "failed", "timeout", "unknown"): - completed.append(d) - - to_remove = completed[: max(0, len(completed) - keep)] - for d in to_remove: - shutil.rmtree(d) - return len(to_remove) - - -# --------------------------------------------------------------------------- -# Run manifest (crux run --file) -# --------------------------------------------------------------------------- - - -def load_run_manifest(file_path: str | Path) -> dict[str, Any]: - """Load and validate a run manifest JSON file.""" - path = Path(file_path) - if not path.exists(): - msg = f"Manifest file not found: {file_path}" - raise FileNotFoundError(msg) - - try: - data = json.loads(path.read_text()) - except json.JSONDecodeError as e: - msg = f"Invalid JSON in manifest: {e}" - raise ValueError(msg) from e - - if not isinstance(data, dict): - msg = "Manifest must be a JSON object" - raise ValueError(msg) - - if "task" not in data: - msg = "Manifest missing required field: 'task'" - raise ValueError(msg) - - return data diff --git a/src/crux_cli/setup_crux.py b/src/crux_cli/setup_crux.py deleted file mode 100644 index 708c7c9..0000000 --- a/src/crux_cli/setup_crux.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Crux setup — fresh install and migration from old layout. - -Provides ``run_setup()`` which: - 1. Creates the ``~/.crux/`` directory structure. - 2. Writes ``config.toml`` with platform defaults. - 3. Installs the bundled Crux skill to ``~/.claude/skills/crux/SKILL.md``. - 4. Detects and reports missing external dependencies. - 5. Optionally migrates data from the old ``marketplace/`` repo layout. -""" - -from __future__ import annotations - -import json -import shutil -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -from crux_cli.config import default_config, save_config -from crux_cli.paths import ( - config_path, - crux_home, - launchers_dir, - mcps_dir, - sandbox_dir, - shared_launchers_dir, - skills_dir, -) - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -MIN_PYTHON = (3, 11) -REQUIRED_TOOLS = ("uv", "git", "node", "claude") - -_BUNDLED_SKILL = Path(__file__).resolve().parent / "data" / "skills" / "crux" / "SKILL.md" -_BUNDLED_LAUNCHERS = Path(__file__).resolve().parent / "data" / "launchers" - - -# --------------------------------------------------------------------------- -# Result dataclass -# --------------------------------------------------------------------------- - - -@dataclass -class LauncherInstallResult: - """Summary of shared launcher script installation.""" - - installed: list[str] = field(default_factory=list) - - -@dataclass -class SetupResult: - """Collects everything ``run_setup`` did so callers can report it.""" - - dirs_created: list[str] = field(default_factory=list) - config_written: bool = False - skill_installed: bool = False - launchers: LauncherInstallResult = field(default_factory=LauncherInstallResult) - missing_deps: list[str] = field(default_factory=list) - migration: MigrationResult | None = None - - -@dataclass -class MigrationResult: - """Summary of what the migration step copied.""" - - detected: bool = False - mcps_copied: list[str] = field(default_factory=list) - skills_copied: list[str] = field(default_factory=list) - registry_entries: int = 0 - - -# --------------------------------------------------------------------------- -# Directory creation -# --------------------------------------------------------------------------- - - -def _ensure_dirs(result: SetupResult) -> None: - dirs = [ - crux_home(), - mcps_dir(), - launchers_dir(), - shared_launchers_dir(), - skills_dir(), - sandbox_dir(), - ] - for d in dirs: - if not d.exists(): - d.mkdir(parents=True, exist_ok=True) - result.dirs_created.append(str(d)) - - -# --------------------------------------------------------------------------- -# Config -# --------------------------------------------------------------------------- - - -def _ensure_config(result: SetupResult) -> None: - target = config_path() - if not target.exists(): - cfg = default_config() - save_config(cfg, path=target) - result.config_written = True - - -# --------------------------------------------------------------------------- -# Skill installation -# --------------------------------------------------------------------------- - - -def _claude_skill_dir() -> Path: - return Path.home() / ".claude" / "skills" / "crux" - - -def install_skill( - *, - bundled_path: Path | None = None, - target_dir: Path | None = None, -) -> bool: - """Copy the bundled Crux skill to the Claude skills directory.""" - src = bundled_path or _BUNDLED_SKILL - dest_dir = target_dir or _claude_skill_dir() - - if not src.exists(): - return False - - dest_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, dest_dir / "SKILL.md") - return True - - -# --------------------------------------------------------------------------- -# Shared launcher installation -# --------------------------------------------------------------------------- - - -def _install_launchers( - result: SetupResult, - *, - bundled_path: Path | None = None, - target_dir: Path | None = None, -) -> None: - """Copy bundled shared launcher scripts to ~/.crux/launchers/.""" - src_dir = bundled_path or _BUNDLED_LAUNCHERS - dest_dir = target_dir or shared_launchers_dir() - dest_dir.mkdir(parents=True, exist_ok=True) - - if not src_dir.is_dir(): - return - - for script in src_dir.glob("*.sh"): - target = dest_dir / script.name - shutil.copy2(script, target) - target.chmod(0o755) - result.launchers.installed.append(script.name) - - -# --------------------------------------------------------------------------- -# Dependency detection -# --------------------------------------------------------------------------- - - -def check_dependencies() -> list[str]: - """Return a list of missing required tools.""" - import sys - - missing: list[str] = [] - - if sys.version_info < MIN_PYTHON: - missing.append(f"python>={MIN_PYTHON[0]}.{MIN_PYTHON[1]}") - - for tool in REQUIRED_TOOLS: - if shutil.which(tool) is None: - missing.append(tool) - - return missing - - -# --------------------------------------------------------------------------- -# Migration from old layout -# --------------------------------------------------------------------------- - - -def _find_old_marketplace(search_path: Path | None = None) -> Path | None: - if search_path is not None: - candidate = search_path / "marketplace" / "marketplace.json" - return candidate if candidate.exists() else None - - repo_root = Path(__file__).resolve().parent.parent.parent - candidate = repo_root / "marketplace" / "marketplace.json" - return candidate if candidate.exists() else None - - -def _convert_entry(entry: dict[str, Any]) -> dict[str, Any]: - new = dict(entry) - if new.get("type") == "git-submodule": - new["type"] = "github" - source_dir = new.get("source_dir", "") - parts = source_dir.split("/") - if parts: - new.setdefault("repo", parts[-1]) - return new - - -def _migrate_old_layout( - result: SetupResult, - *, - search_path: Path | None = None, -) -> None: - mig = MigrationResult() - result.migration = mig - - old_manifest_path = _find_old_marketplace(search_path=search_path) - if old_manifest_path is None: - return - - mig.detected = True - old_root = old_manifest_path.parent - - with open(old_manifest_path) as f: - old_data = json.load(f) - - new_registry: dict[str, Any] = {"mcp_definitions": {}, "skill_definitions": {}} - - for name, entry in old_data.get("mcp_definitions", {}).items(): - converted = _convert_entry(entry) - new_registry["mcp_definitions"][name] = converted - - source_dir = entry.get("source_dir", "") - if source_dir: - base = search_path if search_path is not None else old_manifest_path.parent.parent - src = (base / source_dir).resolve() - if not src.is_relative_to(base.resolve()): - continue - dest = mcps_dir() / Path(name).name - if src.is_dir() and not dest.exists(): - shutil.copytree(src, dest, dirs_exist_ok=True) - mig.mcps_copied.append(name) - - for name, entry in old_data.get("skill_definitions", {}).items(): - converted = _convert_entry(entry) - new_registry["skill_definitions"][name] = converted - - source_dir = entry.get("source_dir", "") - if source_dir: - base = search_path if search_path is not None else old_manifest_path.parent.parent - src = (base / source_dir).resolve() - if not src.is_relative_to(base.resolve()): - continue - dest = skills_dir() / Path(name).name - if src.is_dir() and not dest.exists(): - shutil.copytree(src, dest, dirs_exist_ok=True) - mig.skills_copied.append(name) - - old_launchers = old_root / "mcps" / "launchers" - if old_launchers.is_dir(): - for launcher_file in old_launchers.iterdir(): - dest = launchers_dir() / launcher_file.name - if not dest.exists(): - shutil.copy2(launcher_file, dest) - - total_entries = len(new_registry["mcp_definitions"]) + len(new_registry["skill_definitions"]) - mig.registry_entries = total_entries - - if total_entries > 0: - registry_file = crux_home() / "registry.json" - existing: dict[str, Any] = {} - if registry_file.exists(): - with open(registry_file) as f: - existing = json.load(f) - - for section in ("mcp_definitions", "skill_definitions"): - merged = existing.get(section, {}) - merged.update(new_registry[section]) - existing[section] = merged - - with open(registry_file, "w") as f: - json.dump(existing, f, indent=2) - - -# --------------------------------------------------------------------------- -# Main entry point -# --------------------------------------------------------------------------- - - -def run_setup( - *, - search_path: Path | None = None, - skill_target_dir: Path | None = None, - bundled_skill_path: Path | None = None, - launcher_target_dir: Path | None = None, - bundled_launcher_path: Path | None = None, -) -> SetupResult: - """Run the full Crux setup sequence.""" - result = SetupResult() - - _ensure_dirs(result) - _ensure_config(result) - - result.skill_installed = install_skill( - bundled_path=bundled_skill_path, - target_dir=skill_target_dir, - ) - - _install_launchers( - result, - bundled_path=bundled_launcher_path, - target_dir=launcher_target_dir, - ) - - result.missing_deps = check_dependencies() - _migrate_old_layout(result, search_path=search_path) - - return result diff --git a/src/crux_cli/sync.py b/src/crux_cli/sync.py deleted file mode 100644 index b21794d..0000000 --- a/src/crux_cli/sync.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Sync engine — read crux.json + registry, generate .mcp.json. - -Launcher scripts are no longer generated per-MCP. Instead, shared -launcher scripts installed by ``crux setup`` handle keychain lookups -at runtime, driven by env vars in the ``.mcp.json`` entries. -""" - -from __future__ import annotations - -import json -import re -import tempfile -from pathlib import Path -from typing import Any - -from crux_cli.paths import crux_home, registry_path, shared_launchers_dir, skills_dir - -_SAFE_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$") -_SAFE_ENV_VAR_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") - -# --------------------------------------------------------------------------- -# Registry helpers -# --------------------------------------------------------------------------- - - -def _load_registry_for_sync(reg_path: Path | None = None) -> dict[str, Any]: - """Load the v1 registry for sync operations.""" - path = reg_path or registry_path() - if not path.exists(): - return {"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}} - with open(path) as f: - return json.load(f) - - -# --------------------------------------------------------------------------- -# Validation helpers -# --------------------------------------------------------------------------- - - -def _validate_mcp_name(mcp_name: str) -> None: - if not _SAFE_NAME_RE.match(mcp_name): - msg = f"Unsafe MCP name: {mcp_name!r}" - raise ValueError(msg) - - -def _validate_env_var(var: str) -> None: - if not _SAFE_ENV_VAR_RE.match(var): - msg = f"Unsafe env var name: {var!r}" - raise ValueError(msg) - - -# --------------------------------------------------------------------------- -# Resolve source_dir to absolute path -# --------------------------------------------------------------------------- - - -def _resolve_source_dir(source_dir: str) -> str: - """Turn a potentially relative source_dir into an absolute path.""" - p = Path(source_dir) - if p.is_absolute(): - return source_dir - return str(crux_home().parent / source_dir) - - -# --------------------------------------------------------------------------- -# .mcp.json entry builders -# --------------------------------------------------------------------------- - - -def _build_keychain_auth_entry( - mcp_name: str, - mcp_data: dict[str, Any], - extra_args: list[str] | None = None, -) -> dict[str, Any]: - """Build a .mcp.json entry for a stdio MCP with keychain auth. - - Uses the shared ``keychain-auth.sh`` launcher; env vars in the - returned dict tell the script which secrets to fetch. - """ - env_vars = mcp_data.get("auth", {}).get("env_vars", []) - _validate_mcp_name(mcp_name) - for var in env_vars: - _validate_env_var(var) - - command = mcp_data.get("command", "") - args = list(mcp_data.get("args", [])) - - if mcp_data.get("source_dir"): - abs_source = _resolve_source_dir(mcp_data["source_dir"]) - args = [abs_source if a == "{source_dir}" else a for a in args] - - launcher = str(shared_launchers_dir() / "keychain-auth.sh") - entry: dict[str, Any] = { - "command": launcher, - "args": [command] + args + (extra_args or []), - "env": { - "CRUX_MCP_NAME": mcp_name, - "CRUX_AUTH_ENV_VARS": ",".join(env_vars), - }, - } - return entry - - -def _build_http_bridge_entry( - mcp_name: str, - mcp_data: dict[str, Any], -) -> dict[str, Any]: - """Build a .mcp.json entry for an HTTP-transport MCP using the bridge. - - Uses the shared ``http-bridge-auth.sh`` launcher; env vars drive - the keychain lookup and bridge configuration. - """ - _validate_mcp_name(mcp_name) - - url = mcp_data.get("url", "") - auth = mcp_data.get("auth", {}) - auth_type = auth.get("type", "") - - env: dict[str, str] = { - "CRUX_MCP_NAME": mcp_name, - "CRUX_BRIDGE_URL": url, - } - - if auth_type == "bearer": - keychain_key = auth.get("keychain_key", "API_TOKEN") - _validate_env_var(keychain_key) - env["CRUX_BRIDGE_AUTH_HEADER"] = auth.get("header_name", "Authorization") - env["CRUX_BRIDGE_AUTH_PREFIX"] = auth.get("header_prefix", "Bearer") - env["CRUX_BRIDGE_AUTH_ENV"] = "CRUX_AUTH_TOKEN" - env["CRUX_AUTH_KEYCHAIN_KEY"] = keychain_key - elif auth_type in ("oauth", "oauth-client-credentials"): - env["CRUX_BRIDGE_AUTH_HEADER"] = auth.get("header_name", "Authorization") - env["CRUX_BRIDGE_AUTH_PREFIX"] = auth.get("header_prefix", "Bearer") - env["CRUX_BRIDGE_AUTH_ENV"] = "CRUX_AUTH_TOKEN" - env["CRUX_AUTH_KEYCHAIN_KEY"] = "access_token" - - launcher = str(shared_launchers_dir() / "http-bridge-auth.sh") - return {"command": launcher, "args": [], "env": env} - - -def _build_server_entry( - mcp_name: str, - mcp_data: dict[str, Any], - extra_args: list[str] | None = None, -) -> dict[str, Any]: - """Build a single mcpServers entry for .mcp.json.""" - # HTTP-transport MCPs use the bridge - if mcp_data.get("type") == "streamable-http" or mcp_data.get("url"): - return _build_http_bridge_entry(mcp_name, mcp_data) - - env_vars = mcp_data.get("auth", {}).get("env_vars", []) - - if env_vars: - return _build_keychain_auth_entry(mcp_name, mcp_data, extra_args) - - # No auth — pass through command/args/env directly - server: dict[str, Any] = {k: v for k, v in mcp_data.items() if k in ("command", "args", "env")} - if extra_args: - server["args"] = server.get("args", []) + extra_args - - if mcp_data.get("source_dir"): - abs_source = _resolve_source_dir(mcp_data["source_dir"]) - server["args"] = [abs_source if a == "{source_dir}" else a for a in server.get("args", [])] - - return server - - -def sync_project( - project_dir: Path, - registry: dict[str, Any] | None = None, -) -> tuple[bool, list[str]]: - """Sync a single project: generate .mcp.json, copy skills.""" - crux_json_path = project_dir / "crux.json" - if not crux_json_path.exists(): - return False, ["No crux.json found"] - - with open(crux_json_path) as f: - crux_json = json.load(f) - - if registry is None: - registry = _load_registry_for_sync() - - mcp_defs = registry.get("mcp_definitions", {}) - skill_defs = registry.get("skill_definitions", {}) - issues: list[str] = [] - - # Build .mcp.json - declared_mcps = crux_json.get("mcps", []) - mcp_servers: dict[str, Any] = {} - for mcp_entry in declared_mcps: - if isinstance(mcp_entry, str): - mcp_name, extra_args = mcp_entry, [] - else: - mcp_name = mcp_entry.get("name", "") - extra_args = mcp_entry.get("args", []) - if mcp_name not in mcp_defs: - issues.append(f"MCP '{mcp_name}' not found in registry") - continue - mcp_servers[mcp_name] = _build_server_entry(mcp_name, mcp_defs[mcp_name], extra_args or None) - - mcp_file = project_dir / ".mcp.json" - new_config = {"mcpServers": mcp_servers} - _atomic_json_write(mcp_file, new_config) - - # Copy skills - declared_skills = crux_json.get("skills", []) - for skill_name in declared_skills: - if skill_name not in skill_defs: - issues.append(f"Skill '{skill_name}' not found in registry") - continue - skill_data = skill_defs[skill_name] - source_dir_str = skill_data.get("source_dir", "") - source_path = Path(source_dir_str) - if not source_path.is_absolute(): - candidate = skills_dir() / skill_name - if candidate.exists(): - source_path = candidate - elif source_dir_str: - source_path = crux_home().parent / source_dir_str - else: - source_path = skills_dir() / skill_name - - safe_skill = Path(skill_name).name - skills_parent = project_dir / ".claude" / "skills" - dest_path = skills_parent / safe_skill - if source_path.exists(): - import shutil - - if not dest_path.resolve().is_relative_to(skills_parent.resolve()): - issues.append(f"Skill '{skill_name}' resolves outside skills directory") - continue - dest_path.parent.mkdir(parents=True, exist_ok=True) - if dest_path.exists(): - shutil.rmtree(dest_path) - shutil.copytree(source_path, dest_path) - else: - issues.append(f"Skill '{skill_name}' source missing at {source_path}") - - return True, issues - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _atomic_json_write(path: Path, data: dict[str, Any]) -> None: - """Write JSON atomically.""" - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - try: - with open(fd, "w") as f: - json.dump(data, f, indent=2) - Path(tmp).replace(path) - except BaseException: - Path(tmp).unlink(missing_ok=True) - raise diff --git a/tests/conftest.py b/tests/conftest.py index 8fe5b60..84ff452 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,79 +1,10 @@ -"""Shared fixtures for all crux tests.""" -import json -import shutil -from pathlib import Path -from unittest.mock import MagicMock - -import pytest - -FIXTURES_DIR = Path(__file__).parent / "fixtures" - - -# --------------------------------------------------------------------------- -# Filesystem fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture -def crux_root(tmp_path): - """ - Create a minimal Crux directory tree in tmp_path and populate it with - the minimal marketplace fixture. Returns the tmp_path root. - """ - marketplace_dir = tmp_path / "marketplace" - mcp_dir = marketplace_dir / "mcps" - launchers_dir = mcp_dir / "launchers" - skills_dir = marketplace_dir / "skills" - src_dir = tmp_path / "src" - sandbox_dir = tmp_path / "sandbox" - - for d in [marketplace_dir, mcp_dir, launchers_dir, skills_dir, src_dir, sandbox_dir]: - d.mkdir(parents=True) - - shutil.copy( - FIXTURES_DIR / "marketplace_minimal.json", - marketplace_dir / "marketplace.json", - ) - return tmp_path - - -@pytest.fixture -def minimal_manifest(): - """Return the minimal marketplace fixture as a dict.""" - with open(FIXTURES_DIR / "marketplace_minimal.json") as f: - return json.load(f) - - -@pytest.fixture -def project_dir(crux_root, minimal_manifest): - """Create a src/myproject/ with a crux.json referencing known MCPs.""" - project = crux_root / "src" / "myproject" - project.mkdir(parents=True) - crux_json = { - "name": "myproject", - "version": "0.1.0", - "mcps": ["memory"], - "skills": [], - } - (project / "crux.json").write_text(json.dumps(crux_json, indent=2)) - return project - - -# --------------------------------------------------------------------------- -# Module-constant patching -# --------------------------------------------------------------------------- - -@pytest.fixture -def patch_manifest_paths(monkeypatch, crux_root): - """Redirect manifest module constants to the temp crux_root.""" - import crux_cli.manifest as m - monkeypatch.setattr(m, "REGISTRY_VERSION", "1.0.0") +"""Shared fixtures for crux v2 tests.""" +from __future__ import annotations +from unittest.mock import MagicMock -@pytest.fixture -def patch_sandbox_paths(monkeypatch, crux_root): - """Redirect sandbox module paths to the temp crux_root via CRUX_TEST_ROOT.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_root)) +import pytest @pytest.fixture @@ -82,49 +13,36 @@ def patch_secrets_paths(monkeypatch, tmp_path): monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) -# --------------------------------------------------------------------------- -# Keychain mock -# --------------------------------------------------------------------------- - @pytest.fixture def mock_keychain(mocker): + """Mock macOS ``security`` binary calls so keychain tests run on any platform. + + Returns a dict simulating the keychain; tests can inspect or mutate it. """ - Mock macOS `security` binary calls so keychain tests run on any platform. - Returns a dict that tests can inspect/mutate to simulate different responses. - """ - store = {} # simulate in-memory keychain + store: dict[tuple[str, str], str] = {} - def fake_run(cmd, **kwargs): + def fake_run(cmd, **_kwargs): result = MagicMock() result.returncode = 0 result.stderr = b"" result.stdout = "" if "add-generic-password" in cmd: - idx = cmd.index("-s") - svc = cmd[idx + 1] - idx = cmd.index("-a") - account = cmd[idx + 1] - idx = cmd.index("-w") - val = cmd[idx + 1] + svc = cmd[cmd.index("-s") + 1] + account = cmd[cmd.index("-a") + 1] + val = cmd[cmd.index("-w") + 1] store[(svc, account)] = val - elif "find-generic-password" in cmd: - idx = cmd.index("-s") - svc = cmd[idx + 1] - idx = cmd.index("-a") - account = cmd[idx + 1] + svc = cmd[cmd.index("-s") + 1] + account = cmd[cmd.index("-a") + 1] val = store.get((svc, account)) if val: result.stdout = val + "\n" else: result.returncode = 44 - elif "delete-generic-password" in cmd: - idx = cmd.index("-s") - svc = cmd[idx + 1] - idx = cmd.index("-a") - account = cmd[idx + 1] + svc = cmd[cmd.index("-s") + 1] + account = cmd[cmd.index("-a") + 1] store.pop((svc, account), None) return result diff --git a/tests/fixtures/marketplace_minimal.json b/tests/fixtures/marketplace_minimal.json deleted file mode 100644 index eac3955..0000000 --- a/tests/fixtures/marketplace_minimal.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "crux-test-marketplace", - "owner": { "name": "test" }, - "metadata": { "description": "Minimal test fixture", "version": "0.1.0" }, - "mcp_definitions": { - "memory": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], - "tags": ["memory", "official"] - }, - "wikijs-mcp": { - "type": "git-submodule", - "source_dir": "marketplace/mcps/wikijs-mcp", - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "wikijs-mcp-server"], - "tags": ["wiki", "knowledge"], - "auth": { - "type": "keychain", - "env_vars": ["WIKIJS_URL", "WIKIJS_API_KEY"], - "fix_description": "Run: crux secret set wikijs-mcp WIKIJS_URL " - } - }, - "github-mcp": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "tags": ["github"], - "auth": { - "type": "github-cli", - "check_cmd": ["gh", "auth", "status"], - "fix_cmd": ["gh", "auth", "login"], - "fix_description": "Authenticate with GitHub CLI" - } - } - }, - "skill_definitions": { - "claude-xlsx": { - "type": "git-submodule", - "source_dir": "marketplace/skills/xlsx", - "tags": ["excel", "official"] - } - } -} diff --git a/tests/fixtures/sample_run.json b/tests/fixtures/sample_run.json deleted file mode 100644 index b75a5c8..0000000 --- a/tests/fixtures/sample_run.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "test-run", - "task": "List all available tools", - "mcps": ["memory"], - "skills": [], - "workspace": "scratch", - "output": "output.md", - "timeout": 60 -} diff --git a/tests/fixtures/test_mcp_server.py b/tests/fixtures/test_mcp_server.py deleted file mode 100755 index 2796d1f..0000000 --- a/tests/fixtures/test_mcp_server.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -"""Minimal MCP server fixture for integration testing. - -Validates AUTH_TOKEN env var and responds to JSON-RPC handshake. - -Modes: - Default (stdio MCP): reads JSON-RPC from stdin, validates auth, responds - AUTH_CHECK_MODE=check: exits 0 if AUTH_TOKEN set, 1 if not (for external-cli testing) -""" - -import json -import os -import sys - - -def _respond(obj: dict) -> None: - """Write a JSON-RPC response to stdout.""" - sys.stdout.write(json.dumps(obj) + "\n") - sys.stdout.flush() - - -def _error_response(req_id, code: int, message: str) -> dict: - return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}} - - -def _check_mode() -> None: - """AUTH_CHECK_MODE=check: exit 0 if authenticated, 1 if not.""" - token = os.environ.get("AUTH_TOKEN", "") - sys.exit(0 if token else 1) - - -def _serve() -> None: - """Main MCP server loop.""" - token = os.environ.get("AUTH_TOKEN", "") - - for line in sys.stdin: - line = line.strip() - if not line: - continue - - try: - msg = json.loads(line) - except json.JSONDecodeError: - continue - - req_id = msg.get("id") - method = msg.get("method", "") - - if method == "initialize": - if not token: - _respond(_error_response(req_id, -32001, "authentication required: AUTH_TOKEN not set")) - continue - _respond( - { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "protocolVersion": "2024-11-05", - "capabilities": {}, - "serverInfo": {"name": "test-mcp-server", "version": "1.0.0"}, - }, - } - ) - - elif method == "tools/list": - if not token: - _respond(_error_response(req_id, -32001, "authentication required: AUTH_TOKEN not set")) - continue - _respond( - { - "jsonrpc": "2.0", - "id": req_id, - "result": { - "tools": [ - { - "name": "test_tool", - "description": "A test tool for integration testing", - "inputSchema": {"type": "object", "properties": {}}, - } - ] - }, - } - ) - - elif method == "notifications/initialized": - pass # Notification, no response needed - - else: - _respond(_error_response(req_id, -32601, f"Method not found: {method}")) - - -def main() -> None: - mode = os.environ.get("AUTH_CHECK_MODE", "") - if mode == "check": - _check_mode() - else: - _serve() - - -if __name__ == "__main__": - main() diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 6d0b5f9..652b5e9 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,7 +1,8 @@ -"""Fixtures for integration tests — runs crux as a real subprocess.""" -import json +"""Integration test helpers — runs crux v2 as a real subprocess.""" + +from __future__ import annotations + import os -import shutil import subprocess import sys from pathlib import Path @@ -9,56 +10,23 @@ import pytest CRUX_ROOT = Path(__file__).resolve().parent.parent.parent -FIXTURES_DIR = CRUX_ROOT / "tests" / "fixtures" @pytest.fixture def crux_env(tmp_path): - """ - Create a minimal Crux tree in tmp_path and return an env dict suitable for - passing to subprocess.run so that crux uses this tree for all I/O. - """ - marketplace_dir = tmp_path / "marketplace" - mcp_dir = marketplace_dir / "mcps" - (mcp_dir / "launchers").mkdir(parents=True) - (marketplace_dir / "skills").mkdir() - (tmp_path / "src").mkdir() - (tmp_path / "sandbox").mkdir() - - # Install shared launcher scripts from bundled package data - from crux_cli.setup_crux import _BUNDLED_LAUNCHERS - - launchers_dest = tmp_path / "launchers" - launchers_dest.mkdir(parents=True, exist_ok=True) - for script in _BUNDLED_LAUNCHERS.glob("*.sh"): - target = launchers_dest / script.name - shutil.copy2(script, target) - target.chmod(0o755) - - shutil.copy( - FIXTURES_DIR / "marketplace_minimal.json", - marketplace_dir / "marketplace.json", - ) - - # Also seed registry.json from the marketplace fixture for the new CLI - with open(FIXTURES_DIR / "marketplace_minimal.json") as f: - manifest = json.load(f) - registry = { - "version": "1.0.0", - "mcp_definitions": manifest.get("mcp_definitions", {}), - "skill_definitions": manifest.get("skill_definitions", {}), - } - (tmp_path / "registry.json").write_text(json.dumps(registry, indent=2)) - + """Return ``(env, root)`` suitable for ``run_crux``: a tmp root + env override.""" env = os.environ.copy() env["CRUX_TEST_ROOT"] = str(tmp_path) + env["HOME"] = str(tmp_path / "home") + env.pop("CRUX_HOME", None) + (tmp_path / "home").mkdir(exist_ok=True) return env, tmp_path def run_crux(*args, env, cwd=None, input=None): - """Run crux CLI with the given args and test env. Returns CompletedProcess.""" - return subprocess.run( - [sys.executable, "-m", "crux_cli.cli.main", *args], + """Run ``python -m crux_cli`` with *args*. Returns a CompletedProcess.""" + return subprocess.run( # noqa: S603 + [sys.executable, "-m", "crux_cli", *args], capture_output=True, text=True, env=env, diff --git a/tests/integration/test_cli_doctor.py b/tests/integration/test_cli_doctor.py deleted file mode 100644 index dc692e0..0000000 --- a/tests/integration/test_cli_doctor.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Integration tests: crux init, crux doctor""" - -import pytest - -from .conftest import run_crux - - -@pytest.mark.integration -class TestCruxInit: - def test_init_runs(self, crux_env): - env, root = crux_env - result = run_crux("init", env=env) - assert result.returncode == 0 - assert "Setup complete" in result.stdout - - -@pytest.mark.integration -class TestCruxDoctor: - def test_doctor_runs(self, crux_env): - env, root = crux_env - result = run_crux("doctor", env=env) - assert result.returncode == 0 - assert "CRUX DOCTOR" in result.stdout - - def test_doctor_does_not_check_secrets(self, crux_env): - env, root = crux_env - result = run_crux("doctor", env=env) - assert "secret" not in result.stdout.lower() - assert "crux secret" not in result.stdout diff --git a/tests/integration/test_cli_mcp.py b/tests/integration/test_cli_mcp.py deleted file mode 100644 index d98a08f..0000000 --- a/tests/integration/test_cli_mcp.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Integration tests: crux mcp add/remove/list""" - -import json - -import pytest - -from .conftest import run_crux - - -def _load_registry(root): - reg_path = root / "registry.json" - with open(reg_path) as f: - return json.load(f) - - -@pytest.mark.integration -class TestMcpAdd: - def test_add_npx_mcp(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "add", "new-mcp", "--npx", "@test/new-mcp", "--tags", "test", env=env) - assert result.returncode == 0 - assert "Registered MCP" in result.stdout - reg = _load_registry(root) - assert "new-mcp" in reg["mcp_definitions"] - - def test_add_duplicate_fails(self, crux_env): - env, root = crux_env - run_crux("mcp", "add", "dup", "--npx", "@test/dup", env=env) - result = run_crux("mcp", "add", "dup", "--npx", "@test/dup", env=env) - assert result.returncode != 0 - assert "already exists" in result.stdout - - def test_add_no_source_fails(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "add", "nosrc", env=env) - assert result.returncode != 0 - - def test_add_setup_cmd_rejected(self, crux_env): - """--setup-cmd is no longer a valid flag.""" - env, root = crux_env - result = run_crux("mcp", "add", "x", "--npx", "pkg", "--setup-cmd", "echo hi", env=env) - assert result.returncode != 0 # argparse rejects unknown argument - - def test_add_with_keychain_registers_auth(self, crux_env): - """--keychain stores auth metadata in registry even without interactive prompt.""" - env, root = crux_env - result = run_crux("mcp", "add", "authed", "--npx", "@test/authed", "--keychain", "API_KEY,SECRET", env=env) - assert result.returncode == 0 - reg = _load_registry(root) - auth = reg["mcp_definitions"]["authed"].get("auth", {}) - assert auth["type"] == "keychain" - assert auth["env_vars"] == ["API_KEY", "SECRET"] - - -@pytest.mark.integration -class TestMcpRemove: - def test_remove_existing(self, crux_env): - env, root = crux_env - run_crux("mcp", "add", "to-remove", "--npx", "@test/pkg", env=env) - result = run_crux("mcp", "remove", "to-remove", env=env) - assert result.returncode == 0 - assert "Removed" in result.stdout - reg = _load_registry(root) - assert "to-remove" not in reg["mcp_definitions"] - - def test_remove_nonexistent_fails(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "remove", "nope", env=env) - assert result.returncode != 0 - - def test_remove_with_keep_secrets_flag(self, crux_env): - env, root = crux_env - run_crux("mcp", "add", "authed", "--npx", "@test/authed", "--keychain", "KEY1,KEY2", env=env) - result = run_crux("mcp", "remove", "authed", "--keep-secrets", env=env) - assert result.returncode == 0 - assert "Removed" in result.stdout - - def test_remove_with_remove_secrets_flag(self, crux_env): - env, root = crux_env - run_crux("mcp", "add", "authed2", "--npx", "@test/authed2", "--keychain", "KEY1", env=env) - result = run_crux("mcp", "remove", "authed2", "--remove-secrets", env=env) - assert result.returncode == 0 - assert "Removed" in result.stdout - - def test_remove_secrets_flags_mutually_exclusive(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "remove", "x", "--keep-secrets", "--remove-secrets", env=env) - assert result.returncode != 0 - - -@pytest.mark.integration -class TestMcpAuth: - def test_auth_value_flag_accepted(self, crux_env): - """--value flag is accepted by the CLI parser.""" - env, root = crux_env - run_crux("mcp", "add", "authed", "--npx", "@test/authed", "--keychain", "API_KEY", env=env) - # --value should be accepted (may fail to store due to keychain, but shouldn't be a parse error) - result = run_crux("mcp", "auth", "authed", "--value", "API_KEY=test123", env=env) - # Should not fail with argparse error (exit code 2) - assert result.returncode != 2 - - def test_auth_value_invalid_format_fails(self, crux_env): - """--value without = should fail.""" - env, root = crux_env - run_crux("mcp", "add", "authed2", "--npx", "@test/authed2", "--keychain", "KEY", env=env) - result = run_crux("mcp", "auth", "authed2", "--value", "NOEQUALS", env=env) - assert result.returncode != 0 - assert "Invalid --value format" in result.stdout - - -@pytest.mark.integration -class TestMcpList: - def test_list_shows_mcps(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "list", env=env) - assert result.returncode == 0 - - def test_list_json(self, crux_env): - env, root = crux_env - result = run_crux("mcp", "list", "--json", env=env) - assert result.returncode == 0 - data = json.loads(result.stdout) - assert "mcp_definitions" in data diff --git a/tests/integration/test_cli_project_create.py b/tests/integration/test_cli_project_create.py deleted file mode 100644 index a709a55..0000000 --- a/tests/integration/test_cli_project_create.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Integration tests: crux project create""" - -import json - -import pytest - -from .conftest import run_crux - - -@pytest.mark.integration -class TestProjectCreate: - def test_creates_project_directory(self, crux_env): - env, root = crux_env - run_crux("project", "create", "myproject", env=env, cwd=str(root)) - assert (root / "myproject").is_dir() - - def test_creates_crux_json(self, crux_env): - env, root = crux_env - run_crux("project", "create", "myproject", env=env, cwd=str(root)) - crux_json_path = root / "myproject" / "crux.json" - assert crux_json_path.exists() - data = json.loads(crux_json_path.read_text()) - assert data["name"] == "myproject" - assert "mcps" in data - assert "skills" in data - - def test_creates_gitignore(self, crux_env): - env, root = crux_env - run_crux("project", "create", "myproject", env=env, cwd=str(root)) - gitignore = root / "myproject" / ".gitignore" - assert gitignore.exists() - assert ".mcp.json" in gitignore.read_text() - - def test_exits_nonzero_if_project_exists(self, crux_env): - env, root = crux_env - project = root / "existing" - project.mkdir(parents=True) - (project / "crux.json").write_text('{"name": "existing"}') - result = run_crux("project", "create", "existing", env=env, cwd=str(root)) - assert result.returncode != 0 - assert "already exists" in result.stdout - - def test_success_message_shown(self, crux_env): - env, root = crux_env - result = run_crux("project", "create", "myproject", env=env, cwd=str(root)) - assert result.returncode == 0 - assert "myproject" in result.stdout - - def test_create_current_dir(self, crux_env): - env, root = crux_env - project = root / "my-app" - project.mkdir() - result = run_crux("project", "create", env=env, cwd=str(project)) - assert result.returncode == 0 - assert (project / "crux.json").exists() - - def test_create_with_mcp_flag(self, crux_env): - env, root = crux_env - run_crux("project", "create", "flagtest", "--mcp", "test-mcp", env=env, cwd=str(root)) - crux_json_path = root / "flagtest" / "crux.json" - data = json.loads(crux_json_path.read_text()) - assert "test-mcp" in data["mcps"] - - def test_create_registers_project(self, crux_env): - env, root = crux_env - run_crux("project", "create", "tracked", env=env, cwd=str(root)) - projects_file = root / "projects.json" - assert projects_file.exists() diff --git a/tests/integration/test_cli_project_sync.py b/tests/integration/test_cli_project_sync.py deleted file mode 100644 index 509441f..0000000 --- a/tests/integration/test_cli_project_sync.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Integration tests: crux project sync""" - -import pytest - -from .conftest import run_crux - - -@pytest.mark.integration -class TestProjectSync: - def test_sync_no_crux_json_fails(self, crux_env): - env, root = crux_env - result = run_crux("project", "sync", env=env, cwd=str(root)) - assert result.returncode != 0 - - def test_sync_after_create(self, crux_env): - env, root = crux_env - run_crux("project", "create", "synctest", env=env, cwd=str(root)) - result = run_crux("project", "sync", env=env, cwd=str(root / "synctest")) - assert result.returncode == 0 - - def test_sync_all(self, crux_env): - env, root = crux_env - run_crux("project", "create", "p1", env=env, cwd=str(root)) - result = run_crux("project", "sync", "--all", env=env, cwd=str(root)) - assert result.returncode == 0 diff --git a/tests/integration/test_cli_skill.py b/tests/integration/test_cli_skill.py deleted file mode 100644 index 5c91630..0000000 --- a/tests/integration/test_cli_skill.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Integration tests: crux skill add/remove/list""" - -import json - -import pytest - -from .conftest import run_crux - - -def _load_registry(root): - reg_path = root / "registry.json" - with open(reg_path) as f: - return json.load(f) - - -@pytest.mark.integration -class TestSkillList: - def test_list_returns_zero(self, crux_env): - env, root = crux_env - result = run_crux("skill", "list", env=env) - assert result.returncode == 0 - - def test_list_json(self, crux_env): - env, root = crux_env - result = run_crux("skill", "list", "--json", env=env) - assert result.returncode == 0 - data = json.loads(result.stdout) - assert "skill_definitions" in data - - -@pytest.mark.integration -class TestSkillRemove: - def test_remove_nonexistent_fails(self, crux_env): - env, root = crux_env - result = run_crux("skill", "remove", "nope", env=env) - assert result.returncode != 0 diff --git a/tests/integration/test_cli_task.py b/tests/integration/test_cli_task.py deleted file mode 100644 index 7955eb3..0000000 --- a/tests/integration/test_cli_task.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Integration tests: crux task init/list/clean""" - -import pytest - -from .conftest import run_crux - - -@pytest.mark.integration -class TestTaskInit: - def test_init_creates_run_json(self, crux_env): - env, root = crux_env - workdir = root / "tasktest" - workdir.mkdir() - result = run_crux("task", "init", "my-task", env=env, cwd=str(workdir)) - assert result.returncode == 0 - assert (workdir / "run.json").exists() - - def test_init_fails_if_exists(self, crux_env): - env, root = crux_env - workdir = root / "tasktest2" - workdir.mkdir() - (workdir / "run.json").write_text("{}") - result = run_crux("task", "init", env=env, cwd=str(workdir)) - assert result.returncode != 0 - - -@pytest.mark.integration -class TestTaskList: - def test_list_empty(self, crux_env): - env, root = crux_env - result = run_crux("task", "list", env=env) - assert result.returncode == 0 - - -@pytest.mark.integration -class TestTaskClean: - def test_clean_empty(self, crux_env): - env, root = crux_env - result = run_crux("task", "clean", "--force", env=env) - assert result.returncode == 0 diff --git a/tests/integration/test_e2e_auth.py b/tests/integration/test_e2e_auth.py deleted file mode 100644 index 646b219..0000000 --- a/tests/integration/test_e2e_auth.py +++ /dev/null @@ -1,339 +0,0 @@ -"""End-to-end integration tests for MCP authentication. - -Tests the full cycle: register MCP → authenticate → sync → probe. -Uses the fixture MCP server at tests/fixtures/test_mcp_server.py. -""" - -import json -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from .conftest import run_crux - -FIXTURES_DIR = Path(__file__).resolve().parent.parent / "fixtures" -FIXTURE_MCP = str(FIXTURES_DIR / "test_mcp_server.py") - - -def _register_test_mcp(root: Path, name: str = "test-authed", auth: dict | None = None) -> None: - """Directly write an MCP entry into the registry JSON (bypasses CLI add flow).""" - reg_path = root / "registry.json" - reg = json.loads(reg_path.read_text()) - entry = { - "type": "local", - "command": sys.executable, - "args": [FIXTURE_MCP], - "tags": [], - } - if auth is not None: - entry["auth"] = auth - reg["mcp_definitions"][name] = entry - reg_path.write_text(json.dumps(reg, indent=2)) - - -@pytest.mark.integration -class TestKeychainAuthCycle: - """Test keychain auth type: register → auth → sync → probe.""" - - def test_register_mcp_with_keychain(self, crux_env): - """Register a keychain-authed MCP pointing to the fixture server.""" - env, root = crux_env - _register_test_mcp( - root, - name="test-authed", - auth={ - "type": "keychain", - "env_vars": ["AUTH_TOKEN"], - "fix_description": "Run: crux secret set test-authed AUTH_TOKEN ", - }, - ) - - # Verify registry has auth metadata - reg = json.loads((root / "registry.json").read_text()) - mcp = reg["mcp_definitions"]["test-authed"] - assert mcp["auth"]["type"] == "keychain" - assert mcp["auth"]["env_vars"] == ["AUTH_TOKEN"] - - def test_auth_status_shows_missing(self, crux_env): - """Before auth, status shows missing secrets.""" - env, root = crux_env - _register_test_mcp( - root, - name="test-authed", - auth={ - "type": "keychain", - "env_vars": ["AUTH_TOKEN"], - }, - ) - - # Check auth status via the CLI subprocess to avoid direct module import - # issues in sandboxed test environments - run_crux("mcp", "auth", env=env) - # The CLI may print a table or exit non-zero; either way, - # the registry should show auth metadata for "test-authed" - reg = json.loads((root / "registry.json").read_text()) - mcp = reg["mcp_definitions"]["test-authed"] - assert mcp["auth"]["type"] == "keychain" - assert "AUTH_TOKEN" in mcp["auth"]["env_vars"] - # No secrets.json should exist (no auth performed yet) - secrets_json = root / "secrets.json" - if secrets_json.exists(): - idx = json.loads(secrets_json.read_text()) - stored = idx.get("test-authed", []) - assert "AUTH_TOKEN" not in stored, "AUTH_TOKEN should not be stored yet" - - def test_sync_generates_launcher(self, crux_env): - """After registering a keychain MCP, sync references the shared launcher.""" - env, root = crux_env - _register_test_mcp( - root, - name="test-authed", - auth={ - "type": "keychain", - "env_vars": ["AUTH_TOKEN"], - }, - ) - - # Create project - run_crux("project", "create", "testproj", env=env, cwd=str(root)) - proj_dir = root / "testproj" - - # Add MCP to crux.json - crux_json = json.loads((proj_dir / "crux.json").read_text()) - crux_json["mcps"] = ["test-authed"] - (proj_dir / "crux.json").write_text(json.dumps(crux_json)) - - # Sync - result = run_crux("project", "sync", env=env, cwd=str(proj_dir)) - assert result.returncode == 0 - - # Verify .mcp.json uses shared launcher with env config - mcp_json = json.loads((proj_dir / ".mcp.json").read_text()) - server = mcp_json["mcpServers"]["test-authed"] - assert server["command"].endswith("keychain-auth.sh"), ( - f"Expected keychain-auth.sh launcher, got: {server['command']}" - ) - - # Verify shared launcher exists and contains keychain lookup commands - launcher_path = server["command"] - assert Path(launcher_path).exists(), f"Launcher not found: {launcher_path}" - launcher_content = Path(launcher_path).read_text() - assert "security find-generic-password" in launcher_content or "secret-tool lookup" in launcher_content, ( - "Shared launcher should contain keychain lookup commands" - ) - - # MCP-specific config is in env, not hardcoded in the script - assert server["env"]["CRUX_MCP_NAME"] == "test-authed" - assert "AUTH_TOKEN" in server["env"]["CRUX_AUTH_ENV_VARS"] - - def test_launcher_never_has_literal_secrets(self, crux_env): - """Security: no generated files contain actual secret values.""" - env, root = crux_env - SENTINEL = "SUPER_SECRET_VALUE_12345" - - _register_test_mcp( - root, - name="test-authed", - auth={ - "type": "keychain", - "env_vars": ["AUTH_TOKEN"], - }, - ) - - run_crux("project", "create", "testproj", env=env, cwd=str(root)) - proj_dir = root / "testproj" - crux_json = json.loads((proj_dir / "crux.json").read_text()) - crux_json["mcps"] = ["test-authed"] - (proj_dir / "crux.json").write_text(json.dumps(crux_json)) - run_crux("project", "sync", env=env, cwd=str(proj_dir)) - - # Verify sentinel is absent from all generated files - mcp_json_content = (proj_dir / ".mcp.json").read_text() - assert SENTINEL not in mcp_json_content - - # Shared launcher is generic — verify it has no sentinel either - mcp_json = json.loads(mcp_json_content) - launcher_path = mcp_json["mcpServers"]["test-authed"]["command"] - launcher_content = Path(launcher_path).read_text() - assert SENTINEL not in launcher_content - - -@pytest.mark.integration -class TestExternalCliAuthCycle: - """Test external-cli auth type using the fixture MCP's check mode.""" - - def test_register_external_cli_mcp(self, crux_env): - """Register an MCP with external CLI auth checking.""" - env, root = crux_env - _register_test_mcp( - root, - name="cli-authed", - auth={ - "type": "external-cli", - "check_cmd": [sys.executable, FIXTURE_MCP], - "fix_cmd": [], - "fix_description": "Set AUTH_TOKEN environment variable", - }, - ) - - reg = json.loads((root / "registry.json").read_text()) - mcp = reg["mcp_definitions"]["cli-authed"] - assert mcp["auth"]["type"] == "external-cli" - assert mcp["auth"]["check_cmd"] == [sys.executable, FIXTURE_MCP] - - def test_external_cli_auth_check_fails_without_token(self, crux_env): - """External CLI auth check fails when AUTH_TOKEN is not set.""" - env, root = crux_env - - # Build check env: has AUTH_CHECK_MODE but no AUTH_TOKEN - check_env = env.copy() - check_env["AUTH_CHECK_MODE"] = "check" - check_env.pop("AUTH_TOKEN", None) - - result = subprocess.run( # noqa: S603 - [sys.executable, FIXTURE_MCP], - capture_output=True, - env=check_env, - timeout=5, - ) - assert result.returncode == 1 # Not authenticated - - def test_external_cli_auth_check_succeeds_with_token(self, crux_env): - """External CLI auth check passes when AUTH_TOKEN is set.""" - env, root = crux_env - check_env = env.copy() - check_env["AUTH_CHECK_MODE"] = "check" - check_env["AUTH_TOKEN"] = "test-token" # noqa: S105 - - result = subprocess.run( # noqa: S603 - [sys.executable, FIXTURE_MCP], - capture_output=True, - env=check_env, - timeout=5, - ) - assert result.returncode == 0 # Authenticated - - def test_auth_status_external_cli_unauthenticated(self, crux_env): - """external-cli auth check_cmd that exits 1 means not authenticated.""" - env, root = crux_env - - # Directly run a check_cmd that always fails and confirm exit code 1 - check_cmd = [sys.executable, "-c", "import sys; sys.exit(1)"] - result = subprocess.run(check_cmd, capture_output=True, timeout=5) # noqa: S603 - assert result.returncode == 1, "A failing check_cmd should exit 1 (not authenticated)" - - # Also verify the fixture MCP itself exits 1 without AUTH_TOKEN in check mode - check_env = env.copy() - check_env["AUTH_CHECK_MODE"] = "check" - check_env.pop("AUTH_TOKEN", None) - result2 = subprocess.run( # noqa: S603 - [sys.executable, FIXTURE_MCP], - capture_output=True, - env=check_env, - timeout=5, - ) - assert result2.returncode == 1, "Fixture MCP check mode should fail without AUTH_TOKEN" - - -@pytest.mark.integration -class TestProbeWithAuth: - """Test MCP probing with real fixture server.""" - - def test_probe_unauthenticated_shows_auth_required(self, crux_env): - """Probing an MCP without auth shows auth_required status.""" - env, root = crux_env - from crux_cli.health import probe_mcp_server_detailed - - config = { - "command": sys.executable, - "args": [FIXTURE_MCP], - "env": {}, # No AUTH_TOKEN - } - result = probe_mcp_server_detailed(config) - assert result["status"] == "auth_required", ( - f"Expected auth_required, got {result['status']}: {result['detail']}" - ) - assert "authentication" in result["detail"].lower() - - def test_probe_authenticated_shows_connected(self, crux_env): - """Probing an MCP with valid auth shows connected status.""" - env, root = crux_env - from crux_cli.health import probe_mcp_server_detailed - - config = { - "command": sys.executable, - "args": [FIXTURE_MCP], - "env": {"AUTH_TOKEN": "valid-test-token"}, - } - result = probe_mcp_server_detailed(config) - assert result["status"] == "connected", f"Expected connected, got {result['status']}: {result['detail']}" - assert result["tools_count"] == 1 - assert "test-mcp-server" in (result["server_info"] or "") - - def test_probe_authenticated_returns_tools(self, crux_env): - """Probing returns the correct tool count.""" - env, root = crux_env - from crux_cli.health import probe_mcp_server_detailed - - config = { - "command": sys.executable, - "args": [FIXTURE_MCP], - "env": {"AUTH_TOKEN": "any-value"}, - } - result = probe_mcp_server_detailed(config) - assert result["tools_count"] == 1 - - def test_probe_with_check_cmd_fails_without_token(self, crux_env): - """Probing via check_cmd returns auth_required when check_cmd exits non-zero.""" - env, root = crux_env - from crux_cli.health import probe_mcp_server_detailed - - # check_cmd that always fails - config = { - "command": sys.executable, - "args": [FIXTURE_MCP], - "env": {}, - "auth": { - "check_cmd": [sys.executable, "-c", "import sys; sys.exit(1)"], - "fix_description": "authentication required", - }, - } - result = probe_mcp_server_detailed(config) - assert result["status"] == "auth_required" - assert "authentication" in result["detail"].lower() - - def test_full_sync_then_probe_cycle(self, crux_env): - """Full cycle: register keychain MCP → sync → verify shared launcher path.""" - env, root = crux_env - _register_test_mcp( - root, - name="test-authed", - auth={ - "type": "keychain", - "env_vars": ["AUTH_TOKEN"], - }, - ) - - run_crux("project", "create", "testproj", env=env, cwd=str(root)) - proj_dir = root / "testproj" - crux_json = json.loads((proj_dir / "crux.json").read_text()) - crux_json["mcps"] = ["test-authed"] - (proj_dir / "crux.json").write_text(json.dumps(crux_json)) - - result = run_crux("project", "sync", env=env, cwd=str(proj_dir)) - assert result.returncode == 0 - - mcp_json = json.loads((proj_dir / ".mcp.json").read_text()) - server = mcp_json["mcpServers"]["test-authed"] - launcher_path = Path(server["command"]) - assert launcher_path.exists() - assert launcher_path.name == "keychain-auth.sh" - - # Shared launcher should be executable - assert os.access(str(launcher_path), os.X_OK) - - # MCP-specific config is in env - assert server["env"]["CRUX_MCP_NAME"] == "test-authed" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py deleted file mode 100644 index 3a2d8e1..0000000 --- a/tests/unit/test_auth.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Unit tests for crux_cli.auth — unified auth orchestration module.""" - -from __future__ import annotations - -import argparse -import json -from types import SimpleNamespace -from typing import Any - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_registry(mcp_name: str, auth: dict[str, Any]) -> dict[str, Any]: - """Build a minimal registry dict with a single MCP.""" - return {"mcp_definitions": {mcp_name: {"auth": auth}}} - - -class MockBackend: - """Simple in-memory secrets backend for testing.""" - - def __init__(self, data: dict[str, str] | None = None): - self._data: dict[tuple[str, str], str] = {} - if data: - for (mcp, key), val in data.items(): - self._data[(mcp, key)] = val - self.set_calls: list[tuple[str, str, str]] = [] - - def get(self, mcp_name: str, key: str) -> str | None: - return self._data.get((mcp_name, key)) - - def set(self, mcp_name: str, key: str, value: str) -> None: # noqa: A003 - self._data[(mcp_name, key)] = value - self.set_calls.append((mcp_name, key, value)) - - def delete(self, mcp_name: str, key: str) -> None: - self._data.pop((mcp_name, key), None) - - def list_keys(self, mcp_name: str | None = None) -> dict[str, list[str]]: - return {} - - -# --------------------------------------------------------------------------- -# auth_status tests -# --------------------------------------------------------------------------- - - -class TestAuthStatus: - def test_auth_status_keychain_missing(self, monkeypatch): - """Keychain MCP with missing secrets → status 'Missing: VAR'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - from crux_cli.auth import auth_status - - registry = _make_registry("mymcp", {"type": "keychain", "env_vars": ["API_KEY"]}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["name"] == "mymcp" - assert results[0]["auth_type"] == "keychain" - assert results[0]["status"] == "Missing: API_KEY" - - def test_auth_status_keychain_present(self, monkeypatch): - """Keychain MCP with all secrets stored → status 'Authenticated'.""" - monkeypatch.setattr( - "crux_cli.auth.load_secrets_index", - lambda: {"mymcp": ["API_KEY", "SECRET"]}, - ) - - from crux_cli.auth import auth_status - - registry = _make_registry("mymcp", {"type": "keychain", "env_vars": ["API_KEY", "SECRET"]}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Authenticated" - - def test_auth_status_external_cli_ok(self, monkeypatch): - """external-cli MCP where check_cmd returns 0 → 'Authenticated'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - def mock_run(cmd, **kwargs): - return SimpleNamespace(returncode=0) - - monkeypatch.setattr("crux_cli.auth.subprocess.run", mock_run) - - from crux_cli.auth import auth_status - - registry = _make_registry("ghcli", {"type": "external-cli", "check_cmd": ["gh", "auth", "status"]}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Authenticated" - - def test_auth_status_external_cli_fail(self, monkeypatch): - """external-cli MCP where check_cmd returns non-zero → 'Not authenticated'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - def mock_run(cmd, **kwargs): - return SimpleNamespace(returncode=1) - - monkeypatch.setattr("crux_cli.auth.subprocess.run", mock_run) - - from crux_cli.auth import auth_status - - registry = _make_registry("ghcli", {"type": "external-cli", "check_cmd": ["gh", "auth", "status"]}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Not authenticated" - - def test_auth_status_bearer_set(self, monkeypatch): - """Bearer MCP where backend.get returns a value → 'Authenticated'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - mock_backend = MockBackend() - mock_backend._data[("mymcp", "API_TOKEN")] = "secret-value" - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import auth_status - - registry = _make_registry("mymcp", {"type": "bearer", "keychain_key": "API_TOKEN"}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Authenticated" - - def test_auth_status_bearer_missing(self, monkeypatch): - """Bearer MCP where backend.get returns None → 'Token not set'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import auth_status - - registry = _make_registry("mymcp", {"type": "bearer", "keychain_key": "API_TOKEN"}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Token not set" - - def test_auth_status_setup_cmd(self, monkeypatch): - """setup-cmd MCP always returns 'Run setup to configure'.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - from crux_cli.auth import auth_status - - registry = _make_registry("mymcp", {"type": "setup-cmd", "setup_cmd": ["some-tool", "setup"]}) - results = auth_status(registry) - - assert len(results) == 1 - assert results[0]["status"] == "Run setup to configure" - - def test_auth_status_no_auth_mcps_excluded(self, monkeypatch): - """MCPs without auth block are excluded from auth_status results.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - - from crux_cli.auth import auth_status - - registry = { - "mcp_definitions": { - "no-auth-mcp": {"command": ["some-tool"]}, - "auth-mcp": {"auth": {"type": "keychain", "env_vars": ["KEY"]}}, - } - } - results = auth_status(registry) - - names = [r["name"] for r in results] - assert "no-auth-mcp" not in names - assert "auth-mcp" in names - - -# --------------------------------------------------------------------------- -# auth_single tests -# --------------------------------------------------------------------------- - - -class TestAuthSingle: - def test_auth_single_unknown_type_raises(self, monkeypatch): - """auth_single with unknown auth type raises ValueError.""" - from crux_cli.auth import auth_single - - registry = _make_registry("mymcp", {"type": "bogus"}) - - import pytest - - with pytest.raises(ValueError, match="Unknown auth type 'bogus'"): - auth_single("mymcp", registry) - - def test_auth_single_keychain_prompts(self, monkeypatch): - """auth_single keychain prompts for each var and calls backend.set.""" - monkeypatch.setattr( - "crux_cli.auth.load_secrets_index", - lambda: {}, - ) - - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - monkeypatch.setattr("crux_cli.auth.getpass.getpass", lambda prompt: "test-secret") - - from crux_cli.auth import auth_single - - registry = _make_registry("mymcp", {"type": "keychain", "env_vars": ["API_KEY", "API_SECRET"]}) - auth_single("mymcp", registry) - - # backend.set should be called once per env var - assert len(mock_backend.set_calls) == 2 - keys_stored = [call[1] for call in mock_backend.set_calls] - assert "API_KEY" in keys_stored - assert "API_SECRET" in keys_stored - # values should be what getpass returned - for call in mock_backend.set_calls: - assert call[2] == "test-secret" - - def test_auth_keychain_skips_already_stored(self, monkeypatch, capsys): - """_auth_keychain skips vars already in the secrets index and backend.""" - monkeypatch.setattr( - "crux_cli.auth.load_secrets_index", - lambda: {"mymcp": ["API_KEY"]}, - ) - - mock_backend = MockBackend() - mock_backend._data[("mymcp", "API_KEY")] = "existing" - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - monkeypatch.setattr("crux_cli.auth.getpass.getpass", lambda prompt: "new-secret") - - from crux_cli.auth import _auth_keychain - - _auth_keychain("mymcp", {"type": "keychain", "env_vars": ["API_KEY", "NEW_VAR"]}) - - # Only NEW_VAR should be prompted and stored; API_KEY should be skipped - assert len(mock_backend.set_calls) == 1 - assert mock_backend.set_calls[0][1] == "NEW_VAR" - - def test_auth_single_mcp_not_found(self, monkeypatch, capsys): - """auth_single with nonexistent MCP name calls sys.exit(1).""" - import pytest - - from crux_cli.auth import auth_single - - registry = {"mcp_definitions": {}} - - with pytest.raises(SystemExit) as exc_info: - auth_single("nonexistent", registry) - - assert exc_info.value.code == 1 - captured = capsys.readouterr() - assert "nonexistent" in captured.out - - -# --------------------------------------------------------------------------- -# --value inline auth tests -# --------------------------------------------------------------------------- - - -class TestInlineValueAuth: - """Test that --value flag sets secrets non-interactively.""" - - def test_keychain_inline_values_stored(self, monkeypatch): - """Inline values are stored without prompting.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import auth_single - - registry = _make_registry("mymcp", {"type": "keychain", "env_vars": ["API_KEY", "SECRET"]}) - auth_single("mymcp", registry, inline_values={"API_KEY": "key123", "SECRET": "sec456"}) - - assert len(mock_backend.set_calls) == 2 - stored = {call[1]: call[2] for call in mock_backend.set_calls} - assert stored["API_KEY"] == "key123" - assert stored["SECRET"] == "sec456" # noqa: S105 - - def test_keychain_inline_overwrites_existing(self, monkeypatch): - """Inline values overwrite already-stored secrets.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {"mymcp": ["API_KEY"]}) - mock_backend = MockBackend() - mock_backend._data[("mymcp", "API_KEY")] = "old-value" - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import _auth_keychain - - _auth_keychain("mymcp", {"env_vars": ["API_KEY"]}, inline_values={"API_KEY": "new-value"}) - - assert len(mock_backend.set_calls) == 1 - assert mock_backend.set_calls[0][2] == "new-value" - - def test_keychain_inline_partial_skips_missing(self, monkeypatch, capsys): - """When inline_values provided but a var is missing, it's skipped without prompting.""" - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import _auth_keychain - - _auth_keychain("mymcp", {"env_vars": ["API_KEY", "SECRET"]}, inline_values={"API_KEY": "key123"}) - - assert len(mock_backend.set_calls) == 1 - assert mock_backend.set_calls[0][1] == "API_KEY" - captured = capsys.readouterr() - assert "Skipped SECRET" in captured.out - - def test_bearer_inline_value_stored(self, monkeypatch): - """Inline value for bearer token is stored without prompting.""" - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import _auth_bearer - - _auth_bearer("mymcp", {"keychain_key": "API_TOKEN"}, inline_values={"API_TOKEN": "mytoken"}) - - assert len(mock_backend.set_calls) == 1 - assert mock_backend.set_calls[0][2] == "mytoken" - - def test_bearer_inline_overwrites_existing(self, monkeypatch): - """Inline bearer token overwrites existing without prompting.""" - mock_backend = MockBackend() - mock_backend._data[("mymcp", "API_TOKEN")] = "old-token" - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.auth import _auth_bearer - - _auth_bearer("mymcp", {"keychain_key": "API_TOKEN"}, inline_values={"API_TOKEN": "new-token"}) - - assert len(mock_backend.set_calls) == 1 - assert mock_backend.set_calls[0][2] == "new-token" - - -# --------------------------------------------------------------------------- -# Inline auth during mcp add -# --------------------------------------------------------------------------- - - -class TestInlineAuthDuringAdd: - """Test that cmd_mcp_add prompts for keychain secrets inline.""" - - def test_add_with_keychain_prompts_inline(self, monkeypatch, tmp_path): - """When --keychain is provided and stdin is a TTY, secrets are prompted inline.""" - crux_dir = tmp_path / ".crux" - crux_dir.mkdir() - (crux_dir / "registry.json").write_text( - json.dumps({"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}}) - ) - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - - # Mock stdin as TTY - monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True)) - - # Mock auth backend - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - monkeypatch.setattr("crux_cli.auth.load_secrets_index", lambda: {}) - monkeypatch.setattr("crux_cli.auth.getpass.getpass", lambda prompt: "test-secret") - - from crux_cli.cli.commands.mcp import cmd_mcp_add - - args = argparse.Namespace( - name="test-mcp", - npx="@test/pkg", - uvx=None, - github=None, - local=None, - command=None, - args=None, - tags=None, - keychain="API_KEY,SECRET", - build_cmd=None, - ) - cmd_mcp_add(args) - - # Backend should have received set() calls for each keychain var - assert len(mock_backend.set_calls) == 2 - keys_stored = [call[1] for call in mock_backend.set_calls] - assert "API_KEY" in keys_stored - assert "SECRET" in keys_stored - - def test_add_with_keychain_skips_when_not_tty(self, monkeypatch, tmp_path): - """When stdin is not a TTY, inline auth is skipped.""" - crux_dir = tmp_path / ".crux" - crux_dir.mkdir() - (crux_dir / "registry.json").write_text( - json.dumps({"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}}) - ) - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - - # Mock stdin as NOT a TTY - monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: False)) - - mock_backend = MockBackend() - monkeypatch.setattr("crux_cli.auth.get_backend", lambda: mock_backend) - - from crux_cli.cli.commands.mcp import cmd_mcp_add - - args = argparse.Namespace( - name="test-mcp2", - npx="@test/pkg", - uvx=None, - github=None, - local=None, - command=None, - args=None, - tags=None, - keychain="API_KEY", - build_cmd=None, - ) - cmd_mcp_add(args) - - # Backend should NOT have received any set() calls - assert len(mock_backend.set_calls) == 0 diff --git a/tests/unit/test_health.py b/tests/unit/test_health.py deleted file mode 100644 index ba3672d..0000000 --- a/tests/unit/test_health.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Unit tests for crux_cli.health — all subprocess calls mocked.""" - -import json -import shutil -import subprocess -from unittest.mock import MagicMock - -import pytest - -import crux_cli.health as h - - -def _make_proc(stdout_lines: list[dict], returncode: int = 0): - """Build a mock Popen process that returns the given JSON-RPC response lines.""" - stdout_bytes = "\n".join(json.dumps(line) for line in stdout_lines).encode() - mock_proc = MagicMock() - mock_proc.communicate.return_value = (stdout_bytes, b"") - mock_proc.returncode = returncode - return mock_proc - - -INIT_RESPONSE = { - "jsonrpc": "2.0", - "id": 1, - "result": { - "protocolVersion": "2024-11-05", - "serverInfo": {"name": "test-server", "version": "1.0.0"}, - "capabilities": {}, - }, -} - -TOOLS_LIST_RESPONSE = { - "jsonrpc": "2.0", - "id": 2, - "result": {"tools": [{"name": "tool1"}]}, -} - - -class TestProbeMcpServer: - # --- command not found --- - - def test_command_not_found(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value=None) - status, reason = h.probe_mcp_server({"command": "nonexistent", "args": []}) - assert status == "failed" - assert "nonexistent" in reason - - # --- http url shortcircuit --- - - def test_http_url_returns_needs_auth(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/curl") - status, _ = h.probe_mcp_server({"command": "http://example.com/mcp", "args": []}) - assert "authentication" in status - - # --- auth check_cmd --- - - def test_auth_check_cmd_failure_returns_auth_required(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/cmd") - mocker.patch("crux_cli.health.subprocess.run", return_value=MagicMock(returncode=1)) - config = { - "command": "cmd", - "args": [], - "auth": { - "check_cmd": ["gh", "auth", "status"], - "fix_description": "run gh auth login", - }, - } - status, reason = h.probe_mcp_server(config) - assert status == "auth_required" - assert "gh auth login" in reason - - def test_auth_check_cmd_timeout_returns_auth_required(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/cmd") - mocker.patch( - "crux_cli.health.subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="gh", timeout=5), - ) - config = { - "command": "cmd", - "args": [], - "auth": {"check_cmd": ["gh", "auth", "status"], "fix_description": "fix it"}, - } - status, _ = h.probe_mcp_server(config) - assert status == "auth_required" - - # --- successful handshake --- - - def test_connected_on_successful_handshake(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_popen.return_value = _make_proc([INIT_RESPONSE, TOOLS_LIST_RESPONSE]) - status, detail = h.probe_mcp_server({"command": "npx", "args": ["-y", "pkg"]}) - assert status == "connected" - assert "test-server" in detail - - def test_running_when_no_tools_list_response(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_popen.return_value = _make_proc([INIT_RESPONSE]) # no tools/list response - status, _ = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "running" - - # --- error responses --- - - def test_auth_error_in_tools_list(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - tools_auth_error = { - "jsonrpc": "2.0", - "id": 2, - "error": {"code": 401, "message": "unauthorized: invalid token"}, - } - mock_popen.return_value = _make_proc([INIT_RESPONSE, tools_auth_error]) - status, reason = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "auth_required" - assert "token" in reason.lower() - - def test_generic_error_in_tools_list(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - tools_error = { - "jsonrpc": "2.0", - "id": 2, - "error": {"code": 500, "message": "internal server error"}, - } - mock_popen.return_value = _make_proc([INIT_RESPONSE, tools_error]) - status, _ = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "error" - - # --- timeout + exceptions --- - - def test_process_timeout_returns_timeout(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_proc = MagicMock() - mock_proc.communicate.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=10) - mock_popen.return_value = mock_proc - status, _ = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "timeout" - mock_proc.kill.assert_called_once() - - def test_popen_exception_returns_failed(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mocker.patch("crux_cli.health.subprocess.Popen", side_effect=OSError("no such file")) - status, reason = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "failed" - - # --- env overrides --- - - def test_env_overrides_passed_to_subprocess(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_popen.return_value = _make_proc([INIT_RESPONSE, TOOLS_LIST_RESPONSE]) - h.probe_mcp_server({"command": "npx", "args": [], "env": {"MY_VAR": "myval"}}) - call_kwargs = mock_popen.call_args[1] - assert call_kwargs["env"]["MY_VAR"] == "myval" - - # --- non-JSON lines --- - - def test_non_json_lines_skipped(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mixed_stdout = ( - b"Starting server...\n" - + json.dumps(INIT_RESPONSE).encode() - + b"\n" - + b"Some log line\n" - + json.dumps(TOOLS_LIST_RESPONSE).encode() - + b"\n" - ) - mock_proc = MagicMock() - mock_proc.communicate.return_value = (mixed_stdout, b"") - mock_popen.return_value = mock_proc - status, _ = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "connected" - - # --- real subprocess (optional, skipped if npx unavailable) --- - - @pytest.mark.slow - @pytest.mark.skipif(not shutil.which("npx"), reason="npx not available") - def test_real_memory_mcp_probe(self): - config = {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-memory"]} - status, _ = h.probe_mcp_server(config) - assert status in ("connected", "running") diff --git a/tests/unit/test_health_extended.py b/tests/unit/test_health_extended.py deleted file mode 100644 index e8fa11f..0000000 --- a/tests/unit/test_health_extended.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Extended unit tests for lib/health.py — probe_mcp_server_detailed + doctor checks.""" - -import json -import subprocess -from unittest.mock import MagicMock - -import crux_cli.health as h - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_proc(stdout_lines: list[dict], returncode: int = 0): - """Build a mock Popen process that returns the given JSON-RPC response lines.""" - stdout_bytes = "\n".join(json.dumps(line) for line in stdout_lines).encode() - mock_proc = MagicMock() - mock_proc.communicate.return_value = (stdout_bytes, b"") - mock_proc.returncode = returncode - return mock_proc - - -INIT_RESPONSE = { - "jsonrpc": "2.0", - "id": 1, - "result": { - "protocolVersion": "2024-11-05", - "serverInfo": {"name": "test-server", "version": "1.0.0"}, - "capabilities": {}, - }, -} - - -def _tools_list_response(tools: list[dict] | None = None): - """Build a tools/list JSON-RPC response with a configurable tool list.""" - if tools is None: - tools = [{"name": "tool1"}, {"name": "tool2"}] - return {"jsonrpc": "2.0", "id": 2, "result": {"tools": tools}} - - -# ===================================================================== -# W3A.1 — Status (probe_mcp_server_detailed) -# ===================================================================== - - -class TestStatusConnected: - """test_status_connected — detailed probe returns connected with tools count.""" - - def test_status_connected(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - tools = [{"name": f"t{i}"} for i in range(5)] - mock_popen.return_value = _make_proc([INIT_RESPONSE, _tools_list_response(tools)]) - - result = h.probe_mcp_server_detailed({"command": "npx", "args": ["-y", "pkg"]}) - assert result["status"] == "connected" - assert result["tools_count"] == 5 - assert result["server_info"] is not None - assert "test-server" in result["server_info"] - - def test_connected_zero_tools(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_popen.return_value = _make_proc([INIT_RESPONSE, _tools_list_response([])]) - - result = h.probe_mcp_server_detailed({"command": "npx", "args": []}) - assert result["status"] == "connected" - assert result["tools_count"] == 0 - - -class TestStatusAuthRequired: - """test_status_auth_required — pre-flight auth check and tools/list auth error.""" - - def test_preflight_auth_failure(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/cmd") - mocker.patch("crux_cli.health.subprocess.run", return_value=MagicMock(returncode=1)) - config = { - "command": "cmd", - "args": [], - "auth": {"check_cmd": ["gh", "auth", "status"], "fix_description": "run gh auth login"}, - } - result = h.probe_mcp_server_detailed(config) - assert result["status"] == "auth_required" - assert result["tools_count"] is None - assert "gh auth login" in result["detail"] - - def test_tools_list_auth_error(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - auth_err = {"jsonrpc": "2.0", "id": 2, "error": {"code": 401, "message": "unauthorized: bad token"}} - mock_popen.return_value = _make_proc([INIT_RESPONSE, auth_err]) - - result = h.probe_mcp_server_detailed({"command": "npx", "args": []}) - assert result["status"] == "auth_required" - assert result["tools_count"] is None - - -class TestStatusTimeout: - """test_status_timeout — server does not respond in time.""" - - def test_timeout(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_proc = MagicMock() - mock_proc.communicate.side_effect = subprocess.TimeoutExpired(cmd="npx", timeout=10) - mock_popen.return_value = mock_proc - - result = h.probe_mcp_server_detailed({"command": "npx", "args": []}) - assert result["status"] == "timeout" - assert result["tools_count"] is None - mock_proc.kill.assert_called_once() - - -class TestStatusAllProjects: - """test_status_all_projects — verify _probe_project_servers works across multiple projects.""" - - def test_probes_multiple_projects(self, mocker, tmp_path): - """Simulate two projects each with one MCP and verify probe results.""" - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - - # Each call returns a connected response with 3 tools - tools = [{"name": f"t{i}"} for i in range(3)] - mock_popen.return_value = _make_proc([INIT_RESPONSE, _tools_list_response(tools)]) - - # Create two project dirs with .mcp.json - for pname in ("proj-a", "proj-b"): - pdir = tmp_path / pname - pdir.mkdir() - mcp_config = {"mcpServers": {f"{pname}-mcp": {"command": "npx", "args": ["-y", "pkg"]}}} - (pdir / ".mcp.json").write_text(json.dumps(mcp_config)) - - # Import the function we need — it lives in bin/crux but we can test the health - # module's detailed probe directly for each server. - all_rows = [] - for pdir in sorted(tmp_path.iterdir()): - with open(pdir / ".mcp.json") as f: - servers = json.load(f).get("mcpServers", {}) - for name, config in servers.items(): - r = h.probe_mcp_server_detailed(config) - all_rows.append({"project": pdir.name, "name": name, **r}) - - assert len(all_rows) == 2 - assert all(r["status"] == "connected" for r in all_rows) - assert all(r["tools_count"] == 3 for r in all_rows) - - -class TestProbeBackwardCompat: - """Ensure probe_mcp_server still returns the simple (status, detail) tuple.""" - - def test_compat_tuple(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/npx") - mock_popen = mocker.patch("crux_cli.health.subprocess.Popen") - mock_popen.return_value = _make_proc([INIT_RESPONSE, _tools_list_response()]) - - status, detail = h.probe_mcp_server({"command": "npx", "args": []}) - assert status == "connected" - assert isinstance(detail, str) - - -# ===================================================================== -# W3A.2 — Doctor checks -# ===================================================================== - - -class TestDoctorAllPass: - """test_doctor_all_pass — all checks pass in a well-formed environment.""" - - def test_all_pass(self, mocker, tmp_path): - # Fake a complete crux tree - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - - registry = {"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}} - reg_path = tmp_path / "registry.json" - reg_path.write_text(json.dumps(registry)) - - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/thing") - mocker.patch("crux_cli.health.sys.version_info", (3, 12, 0, "final", 0)) - - results = h.run_doctor_checks( - crux_root=tmp_path, - registry_path=reg_path, - mcp_definitions={}, - ) - assert all(c.passed for c in results), [c for c in results if not c.passed] - - -class TestDoctorMissingPython: - """test_doctor_missing_python — fails if Python < 3.11.""" - - def test_missing_python(self, mocker, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/thing") - mocker.patch("crux_cli.health.sys.version_info", (3, 9, 1, "final", 0)) - - results = h.run_doctor_checks(crux_root=tmp_path, mcp_definitions={}) - python_check = [c for c in results if "Python" in c.label][0] - assert not python_check.passed - assert "3.9" in python_check.label - - -class TestDoctorMissingUv: - """test_doctor_missing_uv — fails when uv is not installed.""" - - def test_missing_uv(self, mocker, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - - original_which = h.shutil.which - - def fake_which(name): - if name == "uv": - return None - return original_which(name) or "/usr/bin/thing" - - mocker.patch("crux_cli.health.shutil.which", side_effect=fake_which) - - results = h.run_doctor_checks(crux_root=tmp_path, mcp_definitions={}) - uv_check = [c for c in results if "uv" in c.label][0] - assert not uv_check.passed - assert uv_check.fix_hint is not None - - -class TestDoctorMissingNode: - """test_doctor_missing_node — fails when node is not installed.""" - - def test_missing_node(self, mocker, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - - original_which = h.shutil.which - - def fake_which(name): - if name == "node": - return None - return original_which(name) or "/usr/bin/thing" - - mocker.patch("crux_cli.health.shutil.which", side_effect=fake_which) - - results = h.run_doctor_checks(crux_root=tmp_path, mcp_definitions={}) - node_check = [c for c in results if "node" in c.label][0] - assert not node_check.passed - - -class TestDoctorMissingClaude: - """test_doctor_missing_claude — fails when claude CLI is not installed.""" - - def test_missing_claude(self, mocker, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - - original_which = h.shutil.which - - def fake_which(name): - if name == "claude": - return None - return original_which(name) or "/usr/bin/thing" - - mocker.patch("crux_cli.health.shutil.which", side_effect=fake_which) - - results = h.run_doctor_checks(crux_root=tmp_path, mcp_definitions={}) - claude_check = [c for c in results if "claude" in c.label][0] - assert not claude_check.passed - assert "anthropic" in claude_check.fix_hint.lower() or "claude" in claude_check.fix_hint.lower() - - -class TestDoctorBrokenRegistry: - """test_doctor_broken_registry — fails when registry JSON is invalid.""" - - def test_invalid_json(self, tmp_path): - reg_path = tmp_path / "registry.json" - reg_path.write_text("NOT VALID JSON {{{") - - result = h.check_registry_valid(reg_path) - assert not result.passed - assert "Parse error" in (result.fix_hint or "") - - def test_not_an_object(self, tmp_path): - reg_path = tmp_path / "registry.json" - reg_path.write_text(json.dumps([1, 2, 3])) - - result = h.check_registry_valid(reg_path) - assert not result.passed - - def test_missing_file(self, tmp_path): - result = h.check_registry_valid(tmp_path / "nonexistent.json") - assert not result.passed - - -class TestDoctorStaleProject: - """test_doctor_stale_project — MCP source dir missing on disk.""" - - def test_missing_source_dir(self, tmp_path): - mcp_defs = { - "wikijs-mcp": { - "source_dir": "marketplace/mcps/wikijs-mcp", - }, - } - # Don't create the source dir — it should be flagged - results = h.check_mcp_sources_present(tmp_path, mcp_defs) - assert len(results) == 1 - assert not results[0].passed - assert "wikijs-mcp" in results[0].label - - def test_source_dir_exists(self, tmp_path): - mcp_defs = { - "wikijs-mcp": { - "source_dir": "marketplace/mcps/wikijs-mcp", - }, - } - (tmp_path / "marketplace" / "mcps" / "wikijs-mcp").mkdir(parents=True) - results = h.check_mcp_sources_present(tmp_path, mcp_defs) - assert len(results) == 1 - assert results[0].passed - - -# ===================================================================== -# Individual check functions -# ===================================================================== - - -class TestCheckPythonVersion: - def test_current_python_passes(self): - # Current Python should be >= 3.11 since pyproject requires it - result = h.check_python_version() - assert result.passed - - def test_custom_min_version(self, mocker): - mocker.patch("crux_cli.health.sys.version_info", (3, 10, 0, "final", 0)) - result = h.check_python_version(min_version=(3, 11)) - assert not result.passed - - -class TestCheckToolInstalled: - def test_tool_found(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/bin/git") - result = h.check_tool_installed("git") - assert result.passed - - def test_tool_missing(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value=None) - result = h.check_tool_installed("nonexistent", fix_hint="install it") - assert not result.passed - assert result.fix_hint == "install it" - - -class TestCheckDirectoryStructure: - def test_all_present(self, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills", "src"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - results = h.check_directory_structure(tmp_path) - assert all(c.passed for c in results) - - def test_missing_src(self, tmp_path): - for d in ("marketplace", "marketplace/mcps", "marketplace/skills"): - (tmp_path / d).mkdir(parents=True, exist_ok=True) - results = h.check_directory_structure(tmp_path) - src_check = [c for c in results if "src/" in c.label][0] - assert not src_check.passed - - -class TestCheckBuildArtifacts: - def test_no_build_cmd_skipped(self, tmp_path): - mcp_defs = {"memory": {"type": "npm-package"}} - results = h.check_build_artifacts(tmp_path, mcp_defs) - assert len(results) == 0 - - def test_build_cmd_with_artifacts(self, tmp_path): - src = tmp_path / "marketplace" / "mcps" / "my-mcp" - src.mkdir(parents=True) - (src / "node_modules").mkdir() - mcp_defs = {"my-mcp": {"build_cmd": "npm install", "source_dir": "marketplace/mcps/my-mcp"}} - results = h.check_build_artifacts(tmp_path, mcp_defs) - assert len(results) == 1 - assert results[0].passed - - def test_build_cmd_without_artifacts(self, tmp_path): - src = tmp_path / "marketplace" / "mcps" / "my-mcp" - src.mkdir(parents=True) - mcp_defs = {"my-mcp": {"build_cmd": "npm install", "source_dir": "marketplace/mcps/my-mcp"}} - results = h.check_build_artifacts(tmp_path, mcp_defs) - assert len(results) == 1 - assert not results[0].passed - assert results[0].warning - - -class TestCheckCruxInPath: - def test_crux_in_path(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value="/usr/local/bin/crux") - result = h.check_crux_in_path() - assert result.passed - - def test_crux_not_in_path(self, mocker): - mocker.patch("crux_cli.health.shutil.which", return_value=None) - result = h.check_crux_in_path() - assert not result.passed - - -class TestCheckResult: - def test_repr_pass(self): - c = h.CheckResult("test", passed=True) - assert "PASS" in repr(c) - - def test_repr_fail(self): - c = h.CheckResult("test", passed=False) - assert "FAIL" in repr(c) - - def test_repr_warn(self): - c = h.CheckResult("test", passed=False, warning=True) - assert "WARN" in repr(c) diff --git a/tests/unit/test_init.py b/tests/unit/test_init.py deleted file mode 100644 index ee6c590..0000000 --- a/tests/unit/test_init.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Unit tests for crux init improvements (W2A.1). - -These tests exercise the init, install, and uninstall logic by calling the -underlying library functions directly rather than spawning a subprocess. -""" - -from __future__ import annotations - -from crux_cli.manifest import load_crux_json, save_crux_json -from crux_cli.projects import list_projects, register_project - - -class TestInitCreatesCruxJson: - def test_init_creates_crux_json(self, tmp_path): - """crux init in a directory should create a crux.json with name, mcps, skills.""" - project = tmp_path / "my-app" - project.mkdir() - - crux_json = {"name": "my-app", "mcps": [], "skills": []} - save_crux_json(project, crux_json) - - loaded = load_crux_json(project) - assert loaded is not None - assert loaded["name"] == "my-app" - assert loaded["mcps"] == [] - assert loaded["skills"] == [] - - -class TestInitCurrentDir: - def test_init_current_dir(self, tmp_path): - """Init should work in any directory using the dir name.""" - project = tmp_path / "webapp" - project.mkdir() - - crux_json = {"name": project.name, "mcps": [], "skills": []} - save_crux_json(project, crux_json) - (project / ".gitignore").write_text(".mcp.json\n") - - assert (project / "crux.json").exists() - assert ".mcp.json" in (project / ".gitignore").read_text() - - -class TestInitNamedSubdir: - def test_init_named_subdir(self, tmp_path): - """Init with a name should create a subdirectory.""" - subdir = tmp_path / "myproject" - subdir.mkdir() - - crux_json = {"name": "myproject", "mcps": [], "skills": []} - save_crux_json(subdir, crux_json) - - assert (subdir / "crux.json").exists() - loaded = load_crux_json(subdir) - assert loaded["name"] == "myproject" - - -class TestInitRegistersProject: - def test_init_registers_project(self, tmp_path): - """Init should register the project in projects.json.""" - projects_file = tmp_path / "projects.json" - project = tmp_path / "my-app" - project.mkdir() - - register_project(project, "my-app", projects_file=projects_file) - - tracked = list_projects(projects_file=projects_file) - assert len(tracked) == 1 - assert tracked[0]["name"] == "my-app" - assert tracked[0]["path"] == str(project.resolve()) - - -class TestInitAlreadyExistsErrors: - def test_init_already_exists_errors(self, tmp_path): - """Init should error if crux.json already exists.""" - project = tmp_path / "my-app" - project.mkdir() - (project / "crux.json").write_text('{"name": "my-app"}') - - # The crux.json already exists — calling init should detect this - assert (project / "crux.json").exists() - loaded = load_crux_json(project) - assert loaded is not None # proves we can detect it exists diff --git a/tests/unit/test_install.py b/tests/unit/test_install.py deleted file mode 100644 index 5ca0e72..0000000 --- a/tests/unit/test_install.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Unit tests for crux install / uninstall logic (W2A.2, W2A.3). - -These exercise the sync engine used by install/uninstall rather than spawning -a subprocess — keeping them fast and deterministic. -""" - -from __future__ import annotations - -import json -import shutil - -from crux_cli.manifest import load_crux_json, save_crux_json -from crux_cli.sync import sync_project - - -def _registry(mcps=None, skills=None): - return { - "version": "1.0.0", - "mcp_definitions": mcps or {}, - "skill_definitions": skills or {}, - } - - -# --------------------------------------------------------------------------- -# W2A.2: crux install -# --------------------------------------------------------------------------- - - -class TestInstallSingleMcp: - def test_install_single_mcp(self, tmp_path): - """Installing a single MCP adds it to crux.json and generates .mcp.json.""" - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": [], "skills": []} - # Simulate what cmd_install does: add to mcps, save, then sync - crux_json["mcps"].append("memory") - save_crux_json(project, crux_json) - - registry = _registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - } - ) - success, issues = sync_project(project, registry) - assert success - assert issues == [] - - loaded = load_crux_json(project) - assert "memory" in loaded["mcps"] - assert (project / ".mcp.json").exists() - - -class TestInstallMultipleMcps: - def test_install_multiple_mcps(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["memory", "github-mcp"], "skills": []} - save_crux_json(project, crux_json) - - registry = _registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "mem-pkg"]}, - "github-mcp": {"command": "npx", "args": ["-y", "gh-pkg"]}, - } - ) - success, issues = sync_project(project, registry) - assert success - assert issues == [] - - data = json.loads((project / ".mcp.json").read_text()) - assert "memory" in data["mcpServers"] - assert "github-mcp" in data["mcpServers"] - - -class TestInstallSkillCopiesToClaudeSkills: - def test_install_skill_copies_to_claude_skills(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - - # Create a fake skill source - skill_source = tmp_path / "skills" / "my-skill" - skill_source.mkdir(parents=True) - (skill_source / "SKILL.md").write_text("# My Skill\n") - - crux_json = {"name": "proj", "mcps": [], "skills": ["my-skill"]} - save_crux_json(project, crux_json) - - registry = _registry( - skills={ - "my-skill": {"type": "local", "source_dir": str(skill_source)}, - } - ) - success, issues = sync_project(project, registry) - assert success - assert issues == [] - - dest = project / ".claude" / "skills" / "my-skill" - assert dest.exists() - assert (dest / "SKILL.md").exists() - - -class TestInstallAlreadyInstalledSkips: - def test_install_already_installed_skips(self, tmp_path): - """If an MCP is already in crux.json mcps, it should be a no-op.""" - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["memory"], "skills": []} - save_crux_json(project, crux_json) - - # "memory" is already in mcps — checking membership - loaded = load_crux_json(project) - assert "memory" in loaded["mcps"] - - -class TestInstallNotInRegistryErrors: - def test_install_not_in_registry_errors(self, tmp_path): - """Sync should report an issue for MCPs not in registry.""" - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["nonexistent"], "skills": []} - save_crux_json(project, crux_json) - - registry = _registry() - success, issues = sync_project(project, registry) - assert any("nonexistent" in i for i in issues) - - -class TestInstallTriggersSync: - def test_install_triggers_sync(self, tmp_path): - """After install, .mcp.json should be generated (sync runs automatically).""" - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["memory"], "skills": []} - save_crux_json(project, crux_json) - - registry = _registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - } - ) - sync_project(project, registry) - - assert (project / ".mcp.json").exists() - data = json.loads((project / ".mcp.json").read_text()) - assert "memory" in data["mcpServers"] - - -# --------------------------------------------------------------------------- -# W2A.3: crux uninstall -# --------------------------------------------------------------------------- - - -class TestUninstallRemovesFromCruxJson: - def test_uninstall_removes_from_crux_json(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["memory", "github-mcp"], "skills": []} - save_crux_json(project, crux_json) - - # Simulate uninstall: remove from mcps, save - crux_json["mcps"].remove("memory") - save_crux_json(project, crux_json) - - loaded = load_crux_json(project) - assert "memory" not in loaded["mcps"] - assert "github-mcp" in loaded["mcps"] - - -class TestUninstallRemovesSkillDir: - def test_uninstall_removes_skill_dir(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - - # Simulate an installed skill - skill_dest = project / ".claude" / "skills" / "my-skill" - skill_dest.mkdir(parents=True) - (skill_dest / "SKILL.md").write_text("# test") - - crux_json = {"name": "proj", "mcps": [], "skills": ["my-skill"]} - save_crux_json(project, crux_json) - - # Simulate uninstall: remove from skills, delete dir - crux_json["skills"].remove("my-skill") - save_crux_json(project, crux_json) - if skill_dest.exists(): - shutil.rmtree(skill_dest) - - loaded = load_crux_json(project) - assert "my-skill" not in loaded["skills"] - assert not skill_dest.exists() - - -class TestUninstallTriggersSync: - def test_uninstall_triggers_sync(self, tmp_path): - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": ["memory"], "skills": []} - save_crux_json(project, crux_json) - - registry = _registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - } - ) - - # Install + sync - sync_project(project, registry) - data = json.loads((project / ".mcp.json").read_text()) - assert "memory" in data["mcpServers"] - - # Uninstall + sync - crux_json["mcps"].remove("memory") - save_crux_json(project, crux_json) - sync_project(project, registry) - - data = json.loads((project / ".mcp.json").read_text()) - assert "memory" not in data["mcpServers"] - - -class TestUninstallNotInstalledErrors: - def test_uninstall_not_installed_errors(self, tmp_path): - """Uninstalling something not in crux.json should be detectable.""" - project = tmp_path / "proj" - project.mkdir() - - crux_json = {"name": "proj", "mcps": [], "skills": []} - save_crux_json(project, crux_json) - - loaded = load_crux_json(project) - assert "nonexistent" not in loaded.get("mcps", []) - assert "nonexistent" not in loaded.get("skills", []) diff --git a/tests/unit/test_manifest.py b/tests/unit/test_manifest.py deleted file mode 100644 index d3e5f0d..0000000 --- a/tests/unit/test_manifest.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Unit tests for crux_cli.manifest — registry and crux.json I/O.""" - -import json - -import crux_cli.manifest as m - -# --------------------------------------------------------------------------- -# crux.json tests -# --------------------------------------------------------------------------- - - -class TestLoadCruxJson: - def test_returns_none_when_missing(self, tmp_path): - assert m.load_crux_json(tmp_path) is None - - def test_returns_dict_when_present(self, tmp_path): - data = {"name": "test", "mcps": ["memory"]} - (tmp_path / "crux.json").write_text(json.dumps(data)) - result = m.load_crux_json(tmp_path) - assert result["name"] == "test" - assert result["mcps"] == ["memory"] - - -class TestSaveCruxJson: - def test_creates_file(self, tmp_path): - m.save_crux_json(tmp_path, {"name": "proj", "mcps": []}) - assert (tmp_path / "crux.json").exists() - - def test_overwrites_existing(self, tmp_path): - (tmp_path / "crux.json").write_text(json.dumps({"name": "old"})) - m.save_crux_json(tmp_path, {"name": "new"}) - result = json.loads((tmp_path / "crux.json").read_text()) - assert result["name"] == "new" - - def test_roundtrip_with_load(self, tmp_path): - data = {"name": "x", "version": "0.1.0", "mcps": ["a", "b"], "skills": []} - m.save_crux_json(tmp_path, data) - assert m.load_crux_json(tmp_path) == data - - -# --------------------------------------------------------------------------- -# v1 Registry tests -# --------------------------------------------------------------------------- - - -class TestLoadRegistry: - def test_load_registry_valid(self, tmp_path): - reg_path = tmp_path / "registry.json" - data = { - "version": "1.0.0", - "mcp_definitions": {"my-mcp": {"type": "npm-package"}}, - "skill_definitions": {}, - } - reg_path.write_text(json.dumps(data)) - result = m.load_registry(path=reg_path) - assert result["version"] == "1.0.0" - assert "my-mcp" in result["mcp_definitions"] - - def test_load_registry_missing_creates_empty(self, tmp_path): - reg_path = tmp_path / "registry.json" - assert not reg_path.exists() - result = m.load_registry(path=reg_path) - assert result["version"] == "1.0.0" - assert result["mcp_definitions"] == {} - assert result["skill_definitions"] == {} - assert reg_path.exists() - - def test_load_registry_creates_parent_dirs(self, tmp_path): - reg_path = tmp_path / "deep" / "nested" / "registry.json" - result = m.load_registry(path=reg_path) - assert result["version"] == "1.0.0" - assert reg_path.exists() - - -class TestSaveRegistry: - def test_save_registry_atomic(self, tmp_path): - reg_path = tmp_path / "registry.json" - data = { - "version": "1.0.0", - "mcp_definitions": {"test-mcp": {"type": "uvx-package"}}, - "skill_definitions": {}, - } - m.save_registry(data, path=reg_path) - assert reg_path.exists() - contents = json.loads(reg_path.read_text()) - assert contents["mcp_definitions"]["test-mcp"]["type"] == "uvx-package" - tmp_files = list(tmp_path.glob("*.tmp")) - assert tmp_files == [] - - def test_registry_roundtrip(self, tmp_path): - reg_path = tmp_path / "registry.json" - data = { - "version": "1.0.0", - "mcp_definitions": { - "alpha-mcp": {"type": "npm-package", "command": "npx", "args": ["-y", "alpha"]}, - "beta-mcp": {"type": "uvx-package", "command": "uvx", "args": ["beta"]}, - }, - "skill_definitions": { - "my-skill": {"type": "github", "source": "user/repo"}, - }, - } - m.save_registry(data, path=reg_path) - reloaded = m.load_registry(path=reg_path) - assert reloaded == data - - def test_save_registry_overwrites(self, tmp_path): - reg_path = tmp_path / "registry.json" - m.save_registry({"version": "1.0.0", "mcp_definitions": {"old": {}}, "skill_definitions": {}}, path=reg_path) - m.save_registry({"version": "1.0.0", "mcp_definitions": {"new": {}}, "skill_definitions": {}}, path=reg_path) - result = m.load_registry(path=reg_path) - assert "new" in result["mcp_definitions"] - assert "old" not in result["mcp_definitions"] diff --git a/tests/unit/test_mcp_remove_secrets.py b/tests/unit/test_mcp_remove_secrets.py deleted file mode 100644 index bef0afb..0000000 --- a/tests/unit/test_mcp_remove_secrets.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for secret cleanup during crux mcp remove.""" - -from __future__ import annotations - -import argparse -from unittest.mock import MagicMock, patch - -import pytest - -from crux_cli.cli.commands.mcp import _cleanup_secrets - - -@pytest.fixture -def mcp_data_with_secrets(): - return { - "auth": { - "type": "keychain", - "env_vars": ["API_KEY", "SECRET_TOKEN"], - } - } - - -@pytest.fixture -def mcp_data_no_auth(): - return {} - - -class TestCleanupSecrets: - def test_no_auth_block_does_nothing(self, mcp_data_no_auth): - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_no_auth, args) - - def test_no_env_vars_does_nothing(self): - data = {"auth": {"type": "external-cli"}} - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", data, args) - - @patch("crux_cli.secrets.get_backend") - def test_no_stored_secrets_does_nothing(self, mock_get_backend, mcp_data_with_secrets): - backend = MagicMock() - backend.get.return_value = None - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - backend.delete.assert_not_called() - - @patch("crux_cli.secrets.save_secrets_index") - @patch("crux_cli.secrets.load_secrets_index", return_value={}) - @patch("crux_cli.secrets.get_backend") - def test_remove_secrets_flag_deletes_all(self, mock_get_backend, _mock_idx, _mock_save, mcp_data_with_secrets): - backend = MagicMock() - backend.get.return_value = "stored-value" - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=False, remove_secrets=True) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - assert backend.delete.call_count == 2 - backend.delete.assert_any_call("test-mcp", "API_KEY") - backend.delete.assert_any_call("test-mcp", "SECRET_TOKEN") - - @patch("crux_cli.secrets.get_backend") - def test_keep_secrets_flag_skips_deletion(self, mock_get_backend, mcp_data_with_secrets, capsys): - backend = MagicMock() - backend.get.return_value = "stored-value" - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=True, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - backend.delete.assert_not_called() - assert "Kept secrets" in capsys.readouterr().out - - @patch("crux_cli.secrets.save_secrets_index") - @patch("crux_cli.secrets.load_secrets_index", return_value={"test-mcp": ["API_KEY", "SECRET_TOKEN"]}) - @patch("crux_cli.secrets.get_backend") - @patch("builtins.input", return_value="") - @patch("sys.stdin") - def test_interactive_yes_default( - self, - mock_stdin, - _mock_input, - mock_get_backend, - _mock_idx, - _mock_save, - mcp_data_with_secrets, - capsys, - ): - mock_stdin.isatty.return_value = True - backend = MagicMock() - backend.get.return_value = "stored-value" - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - assert backend.delete.call_count == 2 - assert "Removed 2 secret(s)" in capsys.readouterr().out - - @patch("crux_cli.secrets.get_backend") - @patch("builtins.input", return_value="n") - @patch("sys.stdin") - def test_interactive_no(self, mock_stdin, _mock_input, mock_get_backend, mcp_data_with_secrets, capsys): - mock_stdin.isatty.return_value = True - backend = MagicMock() - backend.get.return_value = "stored-value" - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - backend.delete.assert_not_called() - assert "Kept secrets" in capsys.readouterr().out - - @patch("crux_cli.secrets.get_backend") - @patch("sys.stdin") - def test_non_interactive_keeps_secrets(self, mock_stdin, mock_get_backend, mcp_data_with_secrets, capsys): - mock_stdin.isatty.return_value = False - backend = MagicMock() - backend.get.return_value = "stored-value" - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=False, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - backend.delete.assert_not_called() - assert "kept" in capsys.readouterr().out - - @patch("crux_cli.secrets.get_backend") - def test_partially_stored_only_counts_stored(self, mock_get_backend, mcp_data_with_secrets, capsys): - """Only secrets that are actually stored are counted.""" - backend = MagicMock() - backend.get.side_effect = lambda name, key: "val" if key == "API_KEY" else None - mock_get_backend.return_value = backend - args = argparse.Namespace(keep_secrets=True, remove_secrets=False) - _cleanup_secrets("test-mcp", mcp_data_with_secrets, args) - assert "Kept secrets" in capsys.readouterr().out diff --git a/tests/unit/test_no_secret_leaks.py b/tests/unit/test_no_secret_leaks.py deleted file mode 100644 index ee142ec..0000000 --- a/tests/unit/test_no_secret_leaks.py +++ /dev/null @@ -1,360 +0,0 @@ -"""Security invariant tests — secrets NEVER in the filesystem. - -Core principle: All credentials are stored in OS keychain only. -.mcp.json entries contain env var *names* (CRUX_MCP_NAME, CRUX_AUTH_ENV_VARS), -never actual secret values. The shared launcher scripts fetch secrets at -runtime from the keychain. - -Every test uses a sentinel value and asserts it never appears in generated files. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from crux_cli.secrets import load_secrets_index, save_secrets_index -from crux_cli.sync import _build_keychain_auth_entry, sync_project - -# --------------------------------------------------------------------------- -# Sentinel constant — a distinctive value we control and can search for -# --------------------------------------------------------------------------- - -SENTINEL_SECRET = "SENTINEL_SECRET_VALUE_12345" # noqa: S105 - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_registry(mcps=None, skills=None): - """Build a minimal v1 registry dict.""" - return { - "version": "1.0.0", - "mcp_definitions": mcps or {}, - "skill_definitions": skills or {}, - } - - -def _make_crux_json(project_dir: Path, mcps=None, skills=None): - """Write a crux.json into project_dir.""" - project_dir.mkdir(parents=True, exist_ok=True) - data = {"name": "test-project", "mcps": mcps or [], "skills": skills or []} - (project_dir / "crux.json").write_text(json.dumps(data, indent=2)) - return data - - -def _keychain_mcp_data(command="npx", args=None): - """Return a registry MCP entry with keychain auth.""" - return { - "command": command, - "args": args if args is not None else ["-y", "some-pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY", "SECRET_TOKEN"], - }, - } - - -# --------------------------------------------------------------------------- -# Keychain auth entry tests -# --------------------------------------------------------------------------- - - -class TestKeychainAuthEntryNoLiteralSecrets: - """The keychain auth entry must only contain env var names, not values.""" - - def test_entry_env_contains_only_names(self): - """Sentinel must not appear in the entry dict.""" - mcp_data = { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY"], - "_test_secret": SENTINEL_SECRET, - }, - } - entry = _build_keychain_auth_entry("test-mcp", mcp_data) - - entry_str = json.dumps(entry) - assert SENTINEL_SECRET not in entry_str - # Entry env should only have name-based config - assert entry["env"]["CRUX_MCP_NAME"] == "test-mcp" - assert entry["env"]["CRUX_AUTH_ENV_VARS"] == "API_KEY" - - def test_multiple_vars_only_names_in_env(self): - """All env var names should be in CRUX_AUTH_ENV_VARS, not their values.""" - mcp_data = { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY", "DB_PASSWORD", "SECRET_TOKEN"], - }, - } - entry = _build_keychain_auth_entry("test-mcp", mcp_data) - - env_vars_str = entry["env"]["CRUX_AUTH_ENV_VARS"] - assert env_vars_str == "API_KEY,DB_PASSWORD,SECRET_TOKEN" - assert SENTINEL_SECRET not in json.dumps(entry) - - def test_mcp_service_name_correct(self): - """Entry must reference the correct MCP name for keychain lookup.""" - mcp_data = { - "command": "npx", - "args": [], - "auth": {"env_vars": ["MY_VAR"]}, - } - entry = _build_keychain_auth_entry("my-mcp", mcp_data) - - assert entry["env"]["CRUX_MCP_NAME"] == "my-mcp" - assert SENTINEL_SECRET not in json.dumps(entry) - - -# --------------------------------------------------------------------------- -# .mcp.json tests -# --------------------------------------------------------------------------- - - -class TestMcpJsonNeverContainsSecrets: - """Generated .mcp.json must not contain any secret values.""" - - def test_mcp_json_never_contains_secrets(self, tmp_path): - """sync_project() must not write sentinel to .mcp.json.""" - project = tmp_path / "proj" - _make_crux_json(project, mcps=["authed-mcp"]) - - registry = _make_registry( - mcps={ - "authed-mcp": { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY"], - "_test_secret": SENTINEL_SECRET, - }, - } - } - ) - - success, issues = sync_project(project, registry) - assert success - - raw = (project / ".mcp.json").read_text() - assert SENTINEL_SECRET not in raw - - def test_mcp_json_authed_mcp_uses_shared_launcher(self, tmp_path): - """Authed MCP entries must point to the shared launcher, not embed secrets.""" - project = tmp_path / "proj" - _make_crux_json(project, mcps=["authed-mcp"]) - - registry = _make_registry(mcps={"authed-mcp": _keychain_mcp_data()}) - - sync_project(project, registry) - - data = json.loads((project / ".mcp.json").read_text()) - server = data["mcpServers"]["authed-mcp"] - - assert server["command"].endswith("keychain-auth.sh") - # env block must only contain config names, not secret values - for val in server.get("env", {}).values(): - assert SENTINEL_SECRET not in str(val) - - def test_mcp_json_unauthenticated_mcp_no_secrets(self, tmp_path): - """Unauthenticated MCP entries must also never contain sentinel.""" - project = tmp_path / "proj" - _make_crux_json(project, mcps=["plain-mcp"]) - - registry = _make_registry( - mcps={ - "plain-mcp": { - "command": "npx", - "args": ["-y", "some-package"], - } - } - ) - - sync_project(project, registry) - - raw = (project / ".mcp.json").read_text() - assert SENTINEL_SECRET not in raw - - -# --------------------------------------------------------------------------- -# secrets.json tests -# --------------------------------------------------------------------------- - - -class TestSecretsIndexContainsOnlyKeyNames: - """secrets.json must store only key names, never actual secret values.""" - - def test_secrets_index_contains_only_key_names(self, tmp_path, monkeypatch): - """Saved secrets index holds key names; sentinel value must not appear.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) - - index = { - "my-mcp": ["API_KEY", "SECRET"], - "other-mcp": ["DB_PASSWORD"], - } - save_secrets_index(index) - - idx_path = tmp_path / "secrets.json" - assert idx_path.exists() - raw = idx_path.read_text() - - assert "API_KEY" in raw - assert "SECRET" in raw - assert "DB_PASSWORD" in raw - assert SENTINEL_SECRET not in raw - - def test_secrets_index_round_trip_no_values(self, tmp_path, monkeypatch): - """load_secrets_index / save_secrets_index round-trip preserves structure, not values.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) - - original = {"mcp-a": ["VAR1", "VAR2"], "mcp-b": ["TOKEN"]} - save_secrets_index(original) - - loaded = load_secrets_index() - assert loaded == original - - raw = (tmp_path / "secrets.json").read_text() - assert SENTINEL_SECRET not in raw - - def test_secrets_index_values_are_lists_of_strings(self, tmp_path, monkeypatch): - """Every value in secrets.json must be a list of string key names.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) - - save_secrets_index( - { - "my-mcp": ["API_KEY", "SECRET_TOKEN"], - } - ) - - loaded = load_secrets_index() - for mcp_name, keys in loaded.items(): - assert isinstance(mcp_name, str) - assert isinstance(keys, list) - for key in keys: - assert isinstance(key, str) - assert SENTINEL_SECRET not in key - - -# --------------------------------------------------------------------------- -# Registry tests -# --------------------------------------------------------------------------- - - -class TestRegistryAuthHasNoValues: - """Registry auth metadata must contain only structural metadata, not credentials.""" - - def test_registry_auth_has_no_values(self): - """Registry with auth metadata must not contain the sentinel secret anywhere.""" - registry = _make_registry( - mcps={ - "test-mcp": { - "command": "npx", - "args": ["-y", "pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY", "SECRET_TOKEN"], - }, - } - } - ) - - registry_str = json.dumps(registry) - assert SENTINEL_SECRET not in registry_str - - def test_registry_never_contains_client_secret_field(self): - """client_secret must never appear in registry JSON (it belongs in keychain).""" - registry = _make_registry( - mcps={ - "oauth-mcp": { - "command": "npx", - "args": [], - "auth": { - "type": "oauth", - "authorization_url": "https://example.com/oauth/authorize", - "token_url": "https://example.com/oauth/token", - "client_id": "crux-cli", - "scopes": ["read", "write"], - }, - } - } - ) - - registry_str = json.dumps(registry) - assert "client_secret" not in registry_str - assert SENTINEL_SECRET not in registry_str - - -# --------------------------------------------------------------------------- -# Full cycle test -# --------------------------------------------------------------------------- - - -class TestFullSyncCycleNoSecretLeaks: - """End-to-end: set up registry with keychain MCP, sync project, check all files.""" - - def test_full_sync_cycle_no_secret_leaks(self, tmp_path, monkeypatch): - """Sentinel must be absent from every file generated during a full sync cycle.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) - - crux_home = tmp_path - project_dir = tmp_path / "project" - - registry = _make_registry( - mcps={ - "authed-mcp": { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": { - "type": "keychain", - "env_vars": ["API_KEY", "SECRET_TOKEN"], - }, - } - } - ) - registry_file = crux_home / "registry.json" - registry_file.write_text(json.dumps(registry, indent=2)) - - save_secrets_index({"authed-mcp": ["API_KEY", "SECRET_TOKEN"]}) - - _make_crux_json(project_dir, mcps=["authed-mcp"]) - - success, issues = sync_project(project_dir, registry) - assert success, f"sync_project failed with issues: {issues}" - - # Check all generated files for sentinel - for fpath in [ - registry_file, - crux_home / "secrets.json", - project_dir / ".mcp.json", - ]: - if fpath.exists(): - content = fpath.read_text() - assert SENTINEL_SECRET not in content, f"Sentinel found in {fpath}" - - def test_full_sync_mcp_json_env_config_only(self, tmp_path, monkeypatch): - """End-to-end: .mcp.json env field must only have config, not secrets.""" - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) - - project_dir = tmp_path / "project" - - registry = _make_registry(mcps={"authed-mcp": _keychain_mcp_data()}) - - _make_crux_json(project_dir, mcps=["authed-mcp"]) - sync_project(project_dir, registry) - - data = json.loads((project_dir / ".mcp.json").read_text()) - server = data["mcpServers"]["authed-mcp"] - - # env field should have CRUX_MCP_NAME and CRUX_AUTH_ENV_VARS only - env = server.get("env", {}) - assert "CRUX_MCP_NAME" in env - assert "CRUX_AUTH_ENV_VARS" in env - raw = json.dumps(server) - assert SENTINEL_SECRET not in raw diff --git a/tests/unit/test_paths.py b/tests/unit/test_paths.py index 8f4a15d..ece4bf5 100644 --- a/tests/unit/test_paths.py +++ b/tests/unit/test_paths.py @@ -1,28 +1,17 @@ -"""Tests for crux_cli.paths — Crux home directory path resolution.""" +"""Tests for crux_cli.paths — base resolution helpers.""" + +from __future__ import annotations from pathlib import Path -from crux_cli.paths import ( - config_path, - crux_home, - launchers_dir, - mcps_dir, - projects_path, - registry_path, - sandbox_dir, - secrets_path, - skills_dir, -) +from crux_cli.paths import config_path, crux_home, secrets_path, tokens_path class TestCruxHome: - """Tests for crux_home() resolution.""" - def test_default_is_dot_crux(self, monkeypatch): monkeypatch.delenv("CRUX_HOME", raising=False) monkeypatch.delenv("CRUX_TEST_ROOT", raising=False) - result = crux_home() - assert result == Path.home() / ".crux" + assert crux_home() == Path.home() / ".crux" def test_crux_home_env_override(self, monkeypatch): monkeypatch.delenv("CRUX_TEST_ROOT", raising=False) @@ -34,45 +23,8 @@ def test_crux_test_root_takes_precedence(self, monkeypatch): monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/test-root") assert crux_home() == Path("/tmp/test-root") - def test_crux_test_root_without_crux_home(self, monkeypatch): - monkeypatch.delenv("CRUX_HOME", raising=False) - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/test-root") - assert crux_home() == Path("/tmp/test-root") - class TestPathConstants: - """All path helpers should return paths under crux_home().""" - - def test_registry_path(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert registry_path() == Path("/tmp/fx/registry.json") - - def test_mcps_dir(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert mcps_dir() == Path("/tmp/fx/mcps") - - def test_launchers_dir(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert launchers_dir() == Path("/tmp/fx/mcps/launchers") - - def test_skills_dir(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert skills_dir() == Path("/tmp/fx/skills") - - def test_sandbox_dir(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert sandbox_dir() == Path("/tmp/fx/sandbox") - - def test_projects_path(self, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") - monkeypatch.delenv("CRUX_HOME", raising=False) - assert projects_path() == Path("/tmp/fx/projects.json") - def test_secrets_path(self, monkeypatch): monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") monkeypatch.delenv("CRUX_HOME", raising=False) @@ -83,23 +35,7 @@ def test_config_path(self, monkeypatch): monkeypatch.delenv("CRUX_HOME", raising=False) assert config_path() == Path("/tmp/fx/config.toml") - -class TestTestIsolation: - """CRUX_TEST_ROOT should fully isolate all paths.""" - - def test_all_paths_under_test_root(self, monkeypatch, tmp_path): - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path)) + def test_tokens_path(self, monkeypatch): + monkeypatch.setenv("CRUX_TEST_ROOT", "/tmp/fx") monkeypatch.delenv("CRUX_HOME", raising=False) - paths = [ - crux_home(), - registry_path(), - mcps_dir(), - launchers_dir(), - skills_dir(), - sandbox_dir(), - projects_path(), - secrets_path(), - config_path(), - ] - for p in paths: - assert str(p).startswith(str(tmp_path)), f"{p} is not under {tmp_path}" + assert tokens_path() == Path("/tmp/fx/tokens.json") diff --git a/tests/unit/test_preflight.py b/tests/unit/test_preflight.py deleted file mode 100644 index 8fb35b9..0000000 --- a/tests/unit/test_preflight.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Unit tests for crux_cli.preflight — pre-flight validation.""" - -import json -from unittest.mock import MagicMock - -import pytest - -from crux_cli.preflight import run_preflight - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def base_registry(tmp_path): - """Return a registry dict with known MCPs and skills.""" - # Create source dirs that the checks expect - mcp_source = tmp_path / "mcps" / "wikijs-mcp" - mcp_source.mkdir(parents=True) - # Add a build artifact so build check passes - (mcp_source / "node_modules").mkdir() - - skill_source = tmp_path / "skills" / "claude-xlsx" - skill_source.mkdir(parents=True) - - return { - "version": "1.0.0", - "mcp_definitions": { - "memory": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], - }, - "wikijs-mcp": { - "type": "git-submodule", - "source_dir": str(mcp_source), - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "wikijs-mcp-server"], - "auth": { - "type": "keychain", - "env_vars": ["WIKIJS_URL", "WIKIJS_API_KEY"], - }, - }, - "github-mcp": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-github"], - "auth": { - "type": "github-cli", - "check_cmd": ["gh", "auth", "status"], - "fix_cmd": ["gh", "auth", "login"], - "fix_description": "Authenticate with GitHub CLI", - }, - }, - "needs-build": { - "type": "github", - "source_dir": str(tmp_path / "mcps" / "needs-build"), - "command": "node", - "args": ["index.js"], - "build_cmd": "npm install && npm run build", - }, - }, - "skill_definitions": { - "claude-xlsx": { - "type": "git-submodule", - "source_dir": str(skill_source), - }, - }, - } - - -@pytest.fixture() -def _patch_secrets_index(monkeypatch, tmp_path): - """Patch secrets index to use a temporary file.""" - secrets_file = tmp_path / "secrets.json" - monkeypatch.setattr("crux_cli.paths._resolve_crux_home", lambda: tmp_path) - return secrets_file - - -# --------------------------------------------------------------------------- -# Tests -# --------------------------------------------------------------------------- - - -class TestPreflightMissingMcp: - def test_preflight_missing_mcp(self, base_registry): - """MCP not in registry triggers an error with fix command.""" - result = run_preflight(["nonexistent"], [], registry=base_registry) - assert not result.ok - assert any("nonexistent" in e and "not found" in e for e in result.errors) - assert any("crux mcp add" in e for e in result.errors) - - -class TestPreflightMissingAuth: - def test_preflight_missing_auth(self, base_registry, _patch_secrets_index): - """MCP with env_vars but no stored secrets triggers an error.""" - result = run_preflight(["wikijs-mcp"], [], registry=base_registry) - assert not result.ok - assert any("WIKIJS_URL" in e for e in result.errors) - assert any("crux mcp auth" in e for e in result.errors) - - -class TestPreflightAuthCheckFails: - def test_preflight_auth_check_fails(self, base_registry, mocker): - """MCP with check_cmd that fails triggers an error.""" - mock_run = mocker.patch("crux_cli.preflight.subprocess.run") - mock_result = MagicMock() - mock_result.returncode = 1 - mock_run.return_value = mock_result - - result = run_preflight(["github-mcp"], [], registry=base_registry) - assert not result.ok - assert any("auth check failed" in e for e in result.errors) - assert any("Authenticate with GitHub CLI" in e for e in result.errors) - - def test_preflight_auth_check_cmd_not_found(self, base_registry, mocker): - """MCP with check_cmd where binary is missing triggers an error.""" - mocker.patch( - "crux_cli.preflight.subprocess.run", - side_effect=FileNotFoundError, - ) - result = run_preflight(["github-mcp"], [], registry=base_registry) - assert not result.ok - assert any("not found" in e for e in result.errors) - - -class TestPreflightMissingSkill: - def test_preflight_missing_skill(self, base_registry): - """Skill not in registry triggers an error with fix command.""" - result = run_preflight([], ["nonexistent-skill"], registry=base_registry) - assert not result.ok - assert any("nonexistent-skill" in e and "not found" in e for e in result.errors) - assert any("crux skill add" in e for e in result.errors) - - def test_preflight_skill_source_missing(self, base_registry, tmp_path): - """Skill in registry but source dir missing triggers an error.""" - base_registry["skill_definitions"]["missing-src"] = { - "type": "github", - "source_dir": str(tmp_path / "nonexistent-dir"), - } - result = run_preflight([], ["missing-src"], registry=base_registry) - assert not result.ok - assert any("source missing" in e for e in result.errors) - - -class TestPreflightUnbuiltMcp: - def test_preflight_unbuilt_mcp(self, base_registry, tmp_path): - """MCP with build_cmd but no build artifacts triggers an error.""" - # Create source dir without build artifacts - source = tmp_path / "mcps" / "needs-build" - source.mkdir(parents=True) - - result = run_preflight(["needs-build"], [], registry=base_registry) - assert not result.ok - assert any("unbuilt" in e for e in result.errors) - assert any("npm install" in e for e in result.errors) - - -class TestPreflightAllPass: - def test_preflight_all_pass(self, base_registry, _patch_secrets_index, tmp_path): - """All checks pass for a correctly configured MCP (npm-package, no auth).""" - result = run_preflight(["memory"], [], registry=base_registry) - assert result.ok - assert result.errors == [] - - def test_preflight_all_pass_with_auth(self, base_registry, _patch_secrets_index, tmp_path): - """All checks pass when secrets are stored for authed MCP.""" - # Write secrets index with the required keys - secrets_file = tmp_path / "secrets.json" - secrets_file.write_text(json.dumps({"wikijs-mcp": ["WIKIJS_URL", "WIKIJS_API_KEY"]})) - - result = run_preflight(["wikijs-mcp"], [], registry=base_registry) - assert result.ok - assert result.errors == [] - - def test_preflight_empty_lists(self, base_registry): - """No MCPs or skills to check always passes.""" - result = run_preflight([], [], registry=base_registry) - assert result.ok - assert result.errors == [] diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py deleted file mode 100644 index 0110ab9..0000000 --- a/tests/unit/test_projects.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Unit tests for crux_cli.projects — project tracking CRUD.""" - -from __future__ import annotations - -import json - -from crux_cli.projects import ( - detect_stale_projects, - list_projects, - register_project, - remove_stale_projects, -) - - -class TestRegisterProject: - def test_register_project(self, tmp_path): - projects_file = tmp_path / "projects.json" - project_dir = tmp_path / "my-app" - project_dir.mkdir() - - register_project(project_dir, "my-app", projects_file=projects_file) - - data = json.loads(projects_file.read_text()) - assert len(data["projects"]) == 1 - assert data["projects"][0]["name"] == "my-app" - assert data["projects"][0]["path"] == str(project_dir.resolve()) - assert "registered_at" in data["projects"][0] - - def test_register_project_idempotent(self, tmp_path): - projects_file = tmp_path / "projects.json" - project_dir = tmp_path / "my-app" - project_dir.mkdir() - - register_project(project_dir, "my-app", projects_file=projects_file) - register_project(project_dir, "my-app", projects_file=projects_file) - - data = json.loads(projects_file.read_text()) - assert len(data["projects"]) == 1 - - def test_register_multiple_projects(self, tmp_path): - projects_file = tmp_path / "projects.json" - for name in ("app-a", "app-b", "app-c"): - d = tmp_path / name - d.mkdir() - register_project(d, name, projects_file=projects_file) - - data = json.loads(projects_file.read_text()) - assert len(data["projects"]) == 3 - - -class TestListTrackedProjects: - def test_list_tracked_projects(self, tmp_path): - projects_file = tmp_path / "projects.json" - d = tmp_path / "proj" - d.mkdir() - register_project(d, "proj", projects_file=projects_file) - - result = list_projects(projects_file=projects_file) - assert len(result) == 1 - assert result[0]["name"] == "proj" - - def test_list_empty(self, tmp_path): - projects_file = tmp_path / "projects.json" - result = list_projects(projects_file=projects_file) - assert result == [] - - -class TestStaleProjectDetection: - def test_stale_project_detection(self, tmp_path): - projects_file = tmp_path / "projects.json" - - # Register a project, then delete its directory - d = tmp_path / "gone" - d.mkdir() - register_project(d, "gone", projects_file=projects_file) - d.rmdir() - - stale = detect_stale_projects(projects_file=projects_file) - assert len(stale) == 1 - assert stale[0]["name"] == "gone" - - def test_no_stale_when_all_exist(self, tmp_path): - projects_file = tmp_path / "projects.json" - d = tmp_path / "exists" - d.mkdir() - register_project(d, "exists", projects_file=projects_file) - - stale = detect_stale_projects(projects_file=projects_file) - assert stale == [] - - -class TestRemoveStaleProject: - def test_remove_stale_project(self, tmp_path): - projects_file = tmp_path / "projects.json" - - d1 = tmp_path / "alive" - d1.mkdir() - register_project(d1, "alive", projects_file=projects_file) - - d2 = tmp_path / "dead" - d2.mkdir() - register_project(d2, "dead", projects_file=projects_file) - d2.rmdir() - - removed = remove_stale_projects(projects_file=projects_file) - assert len(removed) == 1 - assert removed[0]["name"] == "dead" - - # Verify only "alive" remains - remaining = list_projects(projects_file=projects_file) - assert len(remaining) == 1 - assert remaining[0]["name"] == "alive" diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py deleted file mode 100644 index d6da6c3..0000000 --- a/tests/unit/test_sandbox.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Unit tests for crux_cli.sandbox — sandbox lifecycle.""" - -import json -import re - -import pytest - -import crux_cli.sandbox as sb - -# --------------------------------------------------------------------------- -# generate_run_id -# --------------------------------------------------------------------------- - - -class TestGenerateRunId: - def test_format_matches_pattern(self): - run_id = sb.generate_run_id() - assert re.match(r"^\d{8}-[0-9a-f]{4}$", run_id) - - def test_uniqueness(self): - ids = {sb.generate_run_id() for _ in range(50)} - # Very unlikely to have fewer than 40 unique IDs from 50 calls - assert len(ids) >= 40 - - -# --------------------------------------------------------------------------- -# create_sandbox -# --------------------------------------------------------------------------- - - -class TestCreateSandbox: - def test_creates_sandbox_and_workspace_dirs(self, patch_sandbox_paths, crux_root): - registry = { - "version": "1.0.0", - "mcp_definitions": {"memory": {"type": "npm-package", "command": "npx", "args": ["-y", "pkg"]}}, - "skill_definitions": {}, - } - path = sb.create_sandbox("20260315-abcd", ["memory"], registry=registry, skip_preflight=True) - assert path.is_dir() - assert (path / "workspace").is_dir() - - def test_writes_mcp_json(self, patch_sandbox_paths, crux_root): - registry = { - "version": "1.0.0", - "mcp_definitions": {"memory": {"type": "npm-package", "command": "npx", "args": ["-y", "pkg"]}}, - "skill_definitions": {}, - } - path = sb.create_sandbox("20260315-abcd", ["memory"], registry=registry, skip_preflight=True) - mcp_json = json.loads((path / ".mcp.json").read_text()) - assert "mcpServers" in mcp_json - assert "memory" in mcp_json["mcpServers"] - - def test_empty_mcps_writes_empty_servers(self, patch_sandbox_paths, crux_root): - registry = {"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}} - path = sb.create_sandbox("20260315-abcd", [], registry=registry, skip_preflight=True) - mcp_json = json.loads((path / ".mcp.json").read_text()) - assert mcp_json["mcpServers"] == {} - - def test_unknown_mcp_skipped_silently(self, patch_sandbox_paths, crux_root): - registry = {"version": "1.0.0", "mcp_definitions": {}, "skill_definitions": {}} - path = sb.create_sandbox("20260315-abcd", ["nonexistent"], registry=registry, skip_preflight=True) - mcp_json = json.loads((path / ".mcp.json").read_text()) - assert mcp_json["mcpServers"] == {} - - -# --------------------------------------------------------------------------- -# write_run_meta / update_run_meta -# --------------------------------------------------------------------------- - - -class TestRunMeta: - def test_write_creates_meta_file(self, patch_sandbox_paths, crux_root): - sandbox_path = crux_root / "sandbox" / "run1" - sandbox_path.mkdir(parents=True) - sb.write_run_meta(sandbox_path, "run1", "do something", ["memory"]) - assert (sandbox_path / "run-meta.json").exists() - - def test_write_includes_all_fields(self, patch_sandbox_paths, crux_root): - sandbox_path = crux_root / "sandbox" / "run1" - sandbox_path.mkdir(parents=True) - meta = sb.write_run_meta(sandbox_path, "run1", "my task", ["memory", "wikijs-mcp"], name="friendly-name") - assert meta["id"] == "run1" - assert meta["task"] == "my task" - assert meta["mcps"] == ["memory", "wikijs-mcp"] - assert meta["name"] == "friendly-name" - assert meta["status"] == "running" - assert "started_at" in meta - - def test_write_uses_run_id_as_default_name(self, patch_sandbox_paths, crux_root): - sandbox_path = crux_root / "sandbox" / "run1" - sandbox_path.mkdir(parents=True) - meta = sb.write_run_meta(sandbox_path, "run1", "task", []) - assert meta["name"] == "run1" - - def test_update_partial(self, patch_sandbox_paths, crux_root): - sandbox_path = crux_root / "sandbox" / "run1" - sandbox_path.mkdir(parents=True) - sb.write_run_meta(sandbox_path, "run1", "task", []) - sb.update_run_meta(sandbox_path, status="done", exit_code=0) - meta = json.loads((sandbox_path / "run-meta.json").read_text()) - assert meta["status"] == "done" - assert meta["exit_code"] == 0 - assert meta["task"] == "task" # original field preserved - - def test_update_creates_file_if_missing(self, patch_sandbox_paths, crux_root): - sandbox_path = crux_root / "sandbox" / "run1" - sandbox_path.mkdir(parents=True) - sb.update_run_meta(sandbox_path, status="done") - meta = json.loads((sandbox_path / "run-meta.json").read_text()) - assert meta["status"] == "done" - - -# --------------------------------------------------------------------------- -# list_runs -# --------------------------------------------------------------------------- - - -class TestListRuns: - def test_empty_sandbox_returns_empty_list(self, patch_sandbox_paths, crux_root): - assert sb.list_runs() == [] - - def test_returns_metadata_dicts(self, patch_sandbox_paths, crux_root): - for run_id in ["run1", "run2"]: - d = crux_root / "sandbox" / run_id - d.mkdir(parents=True) - meta = {"id": run_id, "task": f"task-{run_id}", "status": "done"} - (d / "run-meta.json").write_text(json.dumps(meta)) - runs = sb.list_runs() - assert len(runs) == 2 - assert any(r["id"] == "run1" for r in runs) - - def test_skips_dirs_without_meta_json(self, patch_sandbox_paths, crux_root): - d = crux_root / "sandbox" / "no-meta" - d.mkdir(parents=True) - assert sb.list_runs() == [] - - def test_skips_corrupt_json(self, patch_sandbox_paths, crux_root): - d = crux_root / "sandbox" / "corrupt" - d.mkdir(parents=True) - (d / "run-meta.json").write_text("{ not json }") - assert sb.list_runs() == [] - - def test_skips_non_directories(self, patch_sandbox_paths, crux_root): - (crux_root / "sandbox" / "stray-file.txt").write_text("hello") - assert sb.list_runs() == [] - - -# --------------------------------------------------------------------------- -# clean_runs -# --------------------------------------------------------------------------- - - -class TestCleanRuns: - def test_force_removes_all_sandbox_dirs(self, patch_sandbox_paths, crux_root): - for name in ["a", "b", "c"]: - (crux_root / "sandbox" / name).mkdir(parents=True) - count = sb.clean_runs(force=True) - assert count == 3 - assert list((crux_root / "sandbox").iterdir()) == [] - - def test_force_removes_all_dirs(self, patch_sandbox_paths, crux_root): - (crux_root / "sandbox" / "sandbox1").mkdir() - (crux_root / "sandbox" / "sandbox2").mkdir() - count = sb.clean_runs(force=True) - assert count == 2 - - def test_empty_sandbox_returns_zero(self, patch_sandbox_paths, crux_root): - assert sb.clean_runs(force=True) == 0 - - def test_keep_recent_preserves_latest(self, patch_sandbox_paths, crux_root): - """Non-force mode keeps the most recent 5 completed runs.""" - for i in range(7): - d = crux_root / "sandbox" / f"run{i:03d}" - d.mkdir(parents=True) - meta = {"status": "done"} - (d / "run-meta.json").write_text(json.dumps(meta)) - count = sb.clean_runs(force=False, keep=5) - assert count == 2 # 7 - 5 kept = 2 removed - - def test_no_force_keeps_running(self, patch_sandbox_paths, crux_root): - d = crux_root / "sandbox" / "sandbox1" - d.mkdir(parents=True) - (d / "run-meta.json").write_text(json.dumps({"status": "running"})) - count = sb.clean_runs(force=False, keep=0) - assert count == 0 - assert d.exists() - - -# --------------------------------------------------------------------------- -# load_run_manifest -# --------------------------------------------------------------------------- - - -class TestLoadRunManifest: - def test_loads_valid_file(self, tmp_path): - data = {"name": "test", "task": "do it", "mcps": ["memory"]} - f = tmp_path / "run.json" - f.write_text(json.dumps(data)) - result = sb.load_run_manifest(str(f)) - assert result["name"] == "test" - - def test_missing_file_raises(self, tmp_path): - with pytest.raises((SystemExit, FileNotFoundError)): - sb.load_run_manifest(str(tmp_path / "nonexistent.json")) diff --git a/tests/unit/test_sandbox_extended.py b/tests/unit/test_sandbox_extended.py deleted file mode 100644 index 2d99bb0..0000000 --- a/tests/unit/test_sandbox_extended.py +++ /dev/null @@ -1,401 +0,0 @@ -"""Unit tests for crux_cli.sandbox — extended sandbox lifecycle. - -Covers: sandbox creation, scoped mcp.json, run-meta, agent execution, -run listing, run cleanup, and manifest loading. -""" - -from __future__ import annotations - -import json -import re -from unittest.mock import MagicMock - -import pytest - -from crux_cli.sandbox import ( - clean_runs, - create_sandbox, - generate_run_id, - list_runs, - load_run_manifest, - run_agent, - update_run_meta, - write_run_meta, -) - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture() -def sandbox_home(tmp_path, monkeypatch): - """Point crux_home / sandbox_dir to a temp directory.""" - monkeypatch.setattr("crux_cli.paths._resolve_crux_home", lambda: tmp_path) - (tmp_path / "sandbox").mkdir(exist_ok=True) - return tmp_path - - -@pytest.fixture() -def simple_registry(): - """A minimal registry with one no-auth npm MCP.""" - return { - "version": "1.0.0", - "mcp_definitions": { - "memory": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], - }, - }, - "skill_definitions": {}, - } - - -# --------------------------------------------------------------------------- -# W3B.2: Sandbox creation -# --------------------------------------------------------------------------- - - -class TestSandboxCreatesDirectory: - def test_sandbox_creates_directory(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) - assert path.is_dir() - assert (path / "workspace").is_dir() - assert path.name == "20260316-abcd" - - def test_sandbox_path_under_crux_sandbox(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-1234", ["memory"], registry=simple_registry, skip_preflight=True) - assert path.parent == sandbox_home / "sandbox" - - -class TestSandboxScopedMcpJson: - def test_sandbox_scoped_mcp_json(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) - mcp_json = json.loads((path / ".mcp.json").read_text()) - assert "mcpServers" in mcp_json - assert "memory" in mcp_json["mcpServers"] - server = mcp_json["mcpServers"]["memory"] - assert server.get("command") or server.get("args") is not None - - def test_sandbox_empty_mcps(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - mcp_json = json.loads((path / ".mcp.json").read_text()) - assert mcp_json["mcpServers"] == {} - - -class TestSandboxRunMeta: - def test_sandbox_run_meta(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) - meta = write_run_meta(path, "20260316-abcd", "do stuff", ["memory"]) - assert meta["id"] == "20260316-abcd" - assert meta["task"] == "do stuff" - assert meta["mcps"] == ["memory"] - assert meta["status"] == "running" - assert "started_at" in meta - - stored = json.loads((path / "run-meta.json").read_text()) - assert stored["id"] == "20260316-abcd" - - def test_update_meta_merges(self, sandbox_home, simple_registry): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "task", []) - update_run_meta(path, status="done", exit_code=0) - stored = json.loads((path / "run-meta.json").read_text()) - assert stored["status"] == "done" - assert stored["exit_code"] == 0 - assert stored["task"] == "task" # original preserved - - -class TestSandboxRunIdFormat: - def test_sandbox_run_id_format(self): - run_id = generate_run_id() - assert re.match(r"^\d{8}-[0-9a-f]{4}$", run_id) - - def test_run_id_uniqueness(self): - ids = {generate_run_id() for _ in range(50)} - assert len(ids) >= 40 - - -# --------------------------------------------------------------------------- -# W3B.3: Agent execution -# --------------------------------------------------------------------------- - - -class TestRunInvokesClaude: - def test_run_invokes_claude(self, sandbox_home, simple_registry, mocker): - """run_agent calls claude with correct args (mocked).""" - path = create_sandbox("20260316-abcd", ["memory"], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "test task", ["memory"]) - - mocker.patch("crux_cli.sandbox.shutil.which", return_value="/usr/local/bin/claude") - mock_proc = MagicMock() - mock_proc.returncode = 0 - mock_proc.communicate.return_value = ("", "") - mock_popen = mocker.patch("crux_cli.sandbox.subprocess.Popen", return_value=mock_proc) - - exit_code = run_agent(path, "test task") - - assert exit_code == 0 - mock_popen.assert_called_once() - call_args = mock_popen.call_args - cmd = call_args[0][0] - assert cmd[0] == "/usr/local/bin/claude" - assert "--print" in cmd - assert "test task" in cmd - assert "--mcp-config" in cmd - - -def _mock_popen(mocker, returncode=0): - """Helper to mock subprocess.Popen for run_agent tests.""" - mock_proc = MagicMock() - mock_proc.returncode = returncode - mock_proc.communicate.return_value = ("", "") - mock_proc.pid = 12345 - mocker.patch("crux_cli.sandbox.shutil.which", return_value="/usr/local/bin/claude") - mocker.patch("crux_cli.sandbox.subprocess.Popen", return_value=mock_proc) - return mock_proc - - -class TestRunCapturesExitCode: - def test_run_captures_exit_code(self, sandbox_home, simple_registry, mocker): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "fail task", []) - _mock_popen(mocker, returncode=42) - - exit_code = run_agent(path, "fail task") - assert exit_code == 42 - - def test_run_exit_zero_on_success(self, sandbox_home, simple_registry, mocker): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "ok task", []) - _mock_popen(mocker, returncode=0) - - assert run_agent(path, "ok task") == 0 - - -class TestRunUpdatesMeta: - def test_run_updates_meta(self, sandbox_home, simple_registry, mocker): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "task", []) - _mock_popen(mocker, returncode=0) - - run_agent(path, "task") - - meta = json.loads((path / "run-meta.json").read_text()) - assert meta["status"] == "done" - assert meta["exit_code"] == 0 - assert "ended_at" in meta - assert "duration_seconds" in meta - - def test_run_updates_meta_on_failure(self, sandbox_home, simple_registry, mocker): - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "task", []) - _mock_popen(mocker, returncode=1) - - run_agent(path, "task") - - meta = json.loads((path / "run-meta.json").read_text()) - assert meta["status"] == "failed" - assert meta["exit_code"] == 1 - - -class TestRunTimeoutKillsProcess: - def test_run_timeout_kills_process(self, sandbox_home, simple_registry, mocker): - import subprocess as sp - - path = create_sandbox("20260316-abcd", [], registry=simple_registry, skip_preflight=True) - write_run_meta(path, "20260316-abcd", "slow task", []) - - mock_proc = MagicMock() - mock_proc.pid = 12345 - mock_proc.communicate.side_effect = sp.TimeoutExpired(cmd=["claude"], timeout=5) - mock_proc.wait.return_value = None - mocker.patch("crux_cli.sandbox.shutil.which", return_value="/usr/local/bin/claude") - mocker.patch("crux_cli.sandbox.subprocess.Popen", return_value=mock_proc) - mocker.patch("os.getpgid", return_value=12345) - mocker.patch("os.killpg") - - exit_code = run_agent(path, "slow task", timeout=5) - assert exit_code == 124 - - meta = json.loads((path / "run-meta.json").read_text()) - assert meta["status"] == "timeout" - assert meta["exit_code"] == 124 - - -# --------------------------------------------------------------------------- -# W3B.4: crux run list -# --------------------------------------------------------------------------- - - -class TestRunListShowsRecent: - def test_run_list_shows_recent(self, sandbox_home): - base = sandbox_home / "sandbox" - for i, run_id in enumerate(["20260315-0001", "20260316-0002"]): - d = base / run_id - d.mkdir(parents=True) - meta = { - "id": run_id, - "task": f"task-{i}", - "status": "done", - "started_at": f"2026-03-1{5 + i}T10:00:00", - } - (d / "run-meta.json").write_text(json.dumps(meta)) - - runs = list_runs() - assert len(runs) == 2 - # Newest first - assert runs[0]["id"] == "20260316-0002" - assert runs[1]["id"] == "20260315-0001" - - def test_run_list_includes_task_and_status(self, sandbox_home): - d = sandbox_home / "sandbox" / "20260316-abcd" - d.mkdir(parents=True) - meta = { - "id": "20260316-abcd", - "task": "Build a widget", - "status": "done", - "started_at": "2026-03-16T10:00:00", - "duration_seconds": 42.5, - } - (d / "run-meta.json").write_text(json.dumps(meta)) - - runs = list_runs() - assert len(runs) == 1 - assert runs[0]["task"] == "Build a widget" - assert runs[0]["status"] == "done" - assert runs[0]["duration_seconds"] == 42.5 - - -class TestRunListEmpty: - def test_run_list_empty(self, sandbox_home): - runs = list_runs() - assert runs == [] - - def test_run_list_empty_no_sandbox_dir(self, tmp_path, monkeypatch): - """list_runs returns empty when sandbox dir doesn't exist.""" - monkeypatch.setattr("crux_cli.paths._resolve_crux_home", lambda: tmp_path / "nonexistent") - runs = list_runs() - assert runs == [] - - -# --------------------------------------------------------------------------- -# W3B.5: crux run clean -# --------------------------------------------------------------------------- - - -class TestRunCleanRemovesCompleted: - def test_run_clean_removes_completed(self, sandbox_home): - base = sandbox_home / "sandbox" - # Create 7 completed runs - for i in range(7): - d = base / f"20260310-{i:04x}" - d.mkdir(parents=True) - meta = {"id": d.name, "status": "done", "started_at": f"2026-03-10T{i:02d}:00:00"} - (d / "run-meta.json").write_text(json.dumps(meta)) - - # Create 1 running run - running = base / "20260316-run0" - running.mkdir(parents=True) - (running / "run-meta.json").write_text(json.dumps({"id": "20260316-run0", "status": "running"})) - - removed = clean_runs(keep=5) - # Should remove 2 oldest completed (7 - 5 = 2), keep running - assert removed == 2 - assert running.exists() - # 5 completed remain + 1 running - remaining = list(base.iterdir()) - assert len(remaining) == 6 - - -class TestRunCleanForce: - def test_run_clean_force(self, sandbox_home): - base = sandbox_home / "sandbox" - for name in ["run-a", "run-b", "run-c"]: - (base / name).mkdir(parents=True) - - removed = clean_runs(force=True) - assert removed == 3 - assert list(base.iterdir()) == [] - - def test_run_clean_force_removes_running(self, sandbox_home): - base = sandbox_home / "sandbox" - d = base / "running-run" - d.mkdir(parents=True) - (d / "run-meta.json").write_text(json.dumps({"status": "running"})) - - removed = clean_runs(force=True) - assert removed == 1 - - -class TestRunCleanKeepsRecent: - def test_run_clean_keeps_recent(self, sandbox_home): - base = sandbox_home / "sandbox" - # Create exactly 5 completed - for i in range(5): - d = base / f"20260310-{i:04x}" - d.mkdir(parents=True) - (d / "run-meta.json").write_text(json.dumps({"id": d.name, "status": "done"})) - - removed = clean_runs(keep=5) - assert removed == 0 # nothing to remove, exactly at limit - - def test_run_clean_empty_sandbox(self, sandbox_home): - removed = clean_runs() - assert removed == 0 - - -# --------------------------------------------------------------------------- -# W3B.6: Run manifest -# --------------------------------------------------------------------------- - - -class TestRunFromManifest: - def test_run_from_manifest(self, tmp_path): - manifest = { - "name": "my-run", - "task": "Analyze the codebase", - "mcps": ["memory"], - "skills": [], - "timeout": 120, - } - path = tmp_path / "run.json" - path.write_text(json.dumps(manifest)) - - loaded = load_run_manifest(path) - assert loaded["task"] == "Analyze the codebase" - assert loaded["mcps"] == ["memory"] - assert loaded["timeout"] == 120 - - def test_run_from_manifest_minimal(self, tmp_path): - """Only 'task' is required.""" - path = tmp_path / "run.json" - path.write_text(json.dumps({"task": "do it"})) - - loaded = load_run_manifest(path) - assert loaded["task"] == "do it" - - -class TestRunInvalidManifestErrors: - def test_run_invalid_manifest_errors_missing_file(self, tmp_path): - with pytest.raises(FileNotFoundError, match="not found"): - load_run_manifest(tmp_path / "nonexistent.json") - - def test_run_invalid_manifest_errors_bad_json(self, tmp_path): - path = tmp_path / "bad.json" - path.write_text("{ not valid json }") - with pytest.raises(ValueError, match="Invalid JSON"): - load_run_manifest(path) - - def test_run_invalid_manifest_errors_not_object(self, tmp_path): - path = tmp_path / "array.json" - path.write_text(json.dumps([1, 2, 3])) - with pytest.raises(ValueError, match="JSON object"): - load_run_manifest(path) - - def test_run_invalid_manifest_errors_missing_task(self, tmp_path): - path = tmp_path / "no-task.json" - path.write_text(json.dumps({"name": "test", "mcps": []})) - with pytest.raises(ValueError, match="task"): - load_run_manifest(path) diff --git a/tests/unit/test_setup.py b/tests/unit/test_setup.py deleted file mode 100644 index fad67e3..0000000 --- a/tests/unit/test_setup.py +++ /dev/null @@ -1,576 +0,0 @@ -"""Unit tests for crux_cli.setup_crux — fresh install and migration.""" - -import json -from pathlib import Path -from unittest.mock import patch - -from crux_cli.setup_crux import ( - _convert_entry, - _find_old_marketplace, - check_dependencies, - install_skill, - run_setup, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_old_layout(root: Path, *, with_sources: bool = True) -> Path: - """Build a fake old marketplace layout under *root* and return *root*. - - Creates ``marketplace/marketplace.json`` with a mix of npm-package and - git-submodule entries, plus skill definitions. - """ - mp = root / "marketplace" - mp.mkdir(parents=True, exist_ok=True) - - manifest = { - "name": "crux-test-marketplace", - "owner": {"name": "test"}, - "metadata": {"description": "test", "version": "0.1.0"}, - "mcp_definitions": { - "memory": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], - "tags": ["memory"], - }, - "wikijs-mcp": { - "type": "git-submodule", - "source_dir": "marketplace/mcps/wikijs-mcp", - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "wikijs-mcp-server"], - "tags": ["wiki"], - }, - "unraid-mcp": { - "type": "git-submodule", - "source_dir": "marketplace/mcps/unraid-mcp", - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "unraid-mcp"], - "tags": ["unraid"], - }, - }, - "skill_definitions": { - "claude-xlsx": { - "type": "git-submodule", - "source_dir": "marketplace/skills/xlsx", - "tags": ["excel"], - }, - }, - } - (mp / "marketplace.json").write_text(json.dumps(manifest, indent=2)) - - if with_sources: - # Create source directories so migration can copy them - for mcp_name in ("wikijs-mcp", "unraid-mcp"): - mcp_src = root / "marketplace" / "mcps" / mcp_name - mcp_src.mkdir(parents=True, exist_ok=True) - (mcp_src / "README.md").write_text(f"# {mcp_name}") - - skill_src = root / "marketplace" / "skills" / "xlsx" - skill_src.mkdir(parents=True, exist_ok=True) - (skill_src / "index.js").write_text("// xlsx skill") - - # A launcher file - launcher_dir = root / "marketplace" / "mcps" / "launchers" - launcher_dir.mkdir(parents=True, exist_ok=True) - (launcher_dir / "wikijs-mcp.sh").write_text("#!/bin/bash\nexec uv run wikijs") - - return root - - -def _make_bundled_skill(tmp_path: Path) -> Path: - """Create a fake bundled SKILL.md and return its path.""" - skill = tmp_path / "bundled" / "SKILL.md" - skill.parent.mkdir(parents=True, exist_ok=True) - skill.write_text("# Crux Skill\nTest content.") - return skill - - -# --------------------------------------------------------------------------- -# W4A.1 — Fresh install -# --------------------------------------------------------------------------- - - -class TestSetupCreatesStructure: - """run_setup creates the ~/.crux/ directory tree.""" - - def test_setup_creates_structure(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=tmp_path / "nonexistent", # no old layout - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert crux_dir.is_dir() - assert (crux_dir / "mcps").is_dir() - assert (crux_dir / "mcps" / "launchers").is_dir() - assert (crux_dir / "launchers").is_dir() # shared launchers dir - assert (crux_dir / "skills").is_dir() - assert (crux_dir / "sandbox").is_dir() - assert len(result.dirs_created) > 0 - - -class TestSetupWritesConfig: - """run_setup writes config.toml with platform defaults.""" - - def test_setup_writes_config(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - config = crux_dir / "config.toml" - assert config.exists() - assert result.config_written is True - content = config.read_text() - assert "backend" in content - assert "crux_home" in content - - -class TestSetupInstallsSkill: - """run_setup copies the bundled skill to the Claude skills directory.""" - - def test_setup_installs_skill(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - target = tmp_path / "claude_skills" - - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=target, - bundled_skill_path=skill, - ) - - assert result.skill_installed is True - installed = target / "SKILL.md" - assert installed.exists() - assert "Crux Skill" in installed.read_text() - - -class TestSetupDetectsDependencies: - """run_setup reports missing external tools.""" - - def test_setup_detects_dependencies(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - # Make shutil.which return None for everything - with patch("crux_cli.setup_crux.shutil.which", return_value=None): - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert "uv" in result.missing_deps - assert "git" in result.missing_deps - assert "node" in result.missing_deps - assert "claude" in result.missing_deps - - def test_no_missing_when_all_present(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - with patch("crux_cli.setup_crux.shutil.which", return_value="/usr/bin/fake"): - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - # Python version check might still flag, but external tools should not - tool_names = {"uv", "git", "node", "claude"} - assert tool_names.isdisjoint(set(result.missing_deps)) - - -class TestSetupIdempotent: - """Running setup twice produces the same result.""" - - def test_setup_idempotent(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - kwargs = dict( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - run_setup(**kwargs) - second = run_setup(**kwargs) - - # Second run should not create dirs (already exist) - assert len(second.dirs_created) == 0 - # Config should not be rewritten - assert second.config_written is False - # Skill still installed (overwritten, but that's fine) - assert second.skill_installed is True - - -# --------------------------------------------------------------------------- -# W4A.2 — Migration from old layout -# --------------------------------------------------------------------------- - - -class TestSetupDetectsOldLayout: - """Migration detects the old marketplace/marketplace.json.""" - - def test_setup_detects_old_layout(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert result.migration is not None - assert result.migration.detected is True - - def test_no_old_layout_means_no_migration(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert result.migration is not None - assert result.migration.detected is False - - -class TestSetupMigratesMcps: - """Migration copies MCP source directories to ~/.crux/mcps/.""" - - def test_setup_migrates_mcps(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert "wikijs-mcp" in result.migration.mcps_copied - assert "unraid-mcp" in result.migration.mcps_copied - # npm-package type MCPs have no source_dir, so should NOT be copied - assert "memory" not in result.migration.mcps_copied - - # Verify files actually exist - assert (crux_dir / "mcps" / "wikijs-mcp" / "README.md").exists() - - -class TestSetupMigratesSkills: - """Migration copies skill source directories to ~/.crux/skills/.""" - - def test_setup_migrates_skills(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - assert "claude-xlsx" in result.migration.skills_copied - assert (crux_dir / "skills" / "claude-xlsx" / "index.js").exists() - - -class TestSetupConvertsTypeField: - """Migration converts git-submodule type to github.""" - - def test_setup_converts_type_field(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - registry = json.loads((crux_dir / "registry.json").read_text()) - # git-submodule entries should be converted to github - assert registry["mcp_definitions"]["wikijs-mcp"]["type"] == "github" - assert registry["mcp_definitions"]["unraid-mcp"]["type"] == "github" - # npm-package entries should remain unchanged - assert registry["mcp_definitions"]["memory"]["type"] == "npm-package" - # Skills too - assert registry["skill_definitions"]["claude-xlsx"]["type"] == "github" - - -class TestSetupPreservesAllEntries: - """Migration preserves every entry from the old manifest.""" - - def test_setup_preserves_all_entries(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - registry = json.loads((crux_dir / "registry.json").read_text()) - # All 3 MCPs + 1 skill = 4 entries - assert result.migration.registry_entries == 4 - assert "memory" in registry["mcp_definitions"] - assert "wikijs-mcp" in registry["mcp_definitions"] - assert "unraid-mcp" in registry["mcp_definitions"] - assert "claude-xlsx" in registry["skill_definitions"] - - -class TestSetupMigrationIdempotent: - """Running migration twice does not duplicate data.""" - - def test_setup_migration_idempotent(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - kwargs = dict( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - run_setup(**kwargs) - result2 = run_setup(**kwargs) - - # Second run should not re-copy MCP dirs (they already exist) - assert len(result2.migration.mcps_copied) == 0 - assert len(result2.migration.skills_copied) == 0 - - # Registry should still have exactly the same entries - registry = json.loads((crux_dir / "registry.json").read_text()) - assert len(registry["mcp_definitions"]) == 3 - assert len(registry["skill_definitions"]) == 1 - - -class TestSetupMigrationNeverDeletesOriginal: - """Migration copies but never deletes the original files.""" - - def test_setup_migration_never_deletes_original(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - old_root = _make_old_layout(tmp_path / "old_repo") - skill = _make_bundled_skill(tmp_path) - - run_setup( - search_path=old_root, - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - # Original marketplace structure must still be intact - assert (old_root / "marketplace" / "marketplace.json").exists() - assert (old_root / "marketplace" / "mcps" / "wikijs-mcp" / "README.md").exists() - assert (old_root / "marketplace" / "skills" / "xlsx" / "index.js").exists() - assert (old_root / "marketplace" / "mcps" / "launchers" / "wikijs-mcp.sh").exists() - - -# --------------------------------------------------------------------------- -# W4A.3 — Bundled skill -# --------------------------------------------------------------------------- - - -class TestSkillBundledInPackage: - """The skill file exists in the package data directory.""" - - def test_skill_bundled_in_package(self): - from crux_cli.setup_crux import _BUNDLED_SKILL - - assert _BUNDLED_SKILL.exists(), f"Bundled skill not found at {_BUNDLED_SKILL}" - content = _BUNDLED_SKILL.read_text() - assert "crux" in content.lower() - assert "search" in content.lower() - - -class TestSetupCopiesSkillToClaude: - """install_skill copies the bundled file to the target directory.""" - - def test_setup_copies_skill_to_claude(self, tmp_path): - skill = _make_bundled_skill(tmp_path) - target = tmp_path / "claude" / "skills" / "crux" - - installed = install_skill(bundled_path=skill, target_dir=target) - - assert installed is True - dest = target / "SKILL.md" - assert dest.exists() - assert "Crux Skill" in dest.read_text() - - def test_install_skill_returns_false_when_missing(self, tmp_path): - missing = tmp_path / "nonexistent" / "SKILL.md" - target = tmp_path / "target" - - installed = install_skill(bundled_path=missing, target_dir=target) - - assert installed is False - - -# --------------------------------------------------------------------------- -# Unit tests for internal helpers -# --------------------------------------------------------------------------- - - -class TestConvertEntry: - """_convert_entry transforms git-submodule to github.""" - - def test_git_submodule_becomes_github(self): - entry = {"type": "git-submodule", "source_dir": "marketplace/mcps/wikijs-mcp"} - result = _convert_entry(entry) - assert result["type"] == "github" - assert result["repo"] == "wikijs-mcp" - - def test_npm_package_unchanged(self): - entry = {"type": "npm-package", "command": "npx"} - result = _convert_entry(entry) - assert result["type"] == "npm-package" - - def test_does_not_mutate_original(self): - entry = {"type": "git-submodule", "source_dir": "marketplace/mcps/foo"} - _convert_entry(entry) - assert entry["type"] == "git-submodule" - assert "repo" not in entry - - -class TestCheckDependencies: - """check_dependencies reports missing tools.""" - - def test_all_present(self): - with patch("crux_cli.setup_crux.shutil.which", return_value="/usr/bin/x"): - missing = check_dependencies() - tools = {"uv", "git", "node", "claude"} - assert tools.isdisjoint(set(missing)) - - def test_all_missing(self): - with patch("crux_cli.setup_crux.shutil.which", return_value=None): - missing = check_dependencies() - assert "uv" in missing - assert "git" in missing - assert "node" in missing - assert "claude" in missing - - -class TestFindOldMarketplace: - """_find_old_marketplace locates the old manifest.""" - - def test_finds_when_present(self, tmp_path): - mp = tmp_path / "marketplace" - mp.mkdir() - (mp / "marketplace.json").write_text("{}") - assert _find_old_marketplace(search_path=tmp_path) is not None - - def test_returns_none_when_absent(self, tmp_path): - assert _find_old_marketplace(search_path=tmp_path) is None - - -# --------------------------------------------------------------------------- -# Shared launcher installation -# --------------------------------------------------------------------------- - - -class TestSetupInstallsLaunchers: - """run_setup copies shared launcher scripts to ~/.crux/launchers/.""" - - def test_setup_installs_launcher_scripts(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - result = run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - launchers_dir = crux_dir / "launchers" - assert launchers_dir.is_dir() - assert (launchers_dir / "keychain-auth.sh").exists() - assert (launchers_dir / "http-bridge-auth.sh").exists() - assert len(result.launchers.installed) == 2 - - def test_launcher_scripts_are_executable(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - - run_setup( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - import stat - - for name in ("keychain-auth.sh", "http-bridge-auth.sh"): - script = crux_dir / "launchers" / name - mode = script.stat().st_mode - assert mode & stat.S_IXUSR, f"{name} is not executable" - - def test_launcher_install_idempotent(self, monkeypatch, tmp_path): - crux_dir = tmp_path / ".crux" - monkeypatch.setenv("CRUX_TEST_ROOT", str(crux_dir)) - monkeypatch.delenv("CRUX_HOME", raising=False) - skill = _make_bundled_skill(tmp_path) - kwargs = dict( - search_path=tmp_path / "nonexistent", - skill_target_dir=tmp_path / "claude_skills", - bundled_skill_path=skill, - ) - - run_setup(**kwargs) - result2 = run_setup(**kwargs) - - # Second run still installs (overwrites), which is fine - assert len(result2.launchers.installed) == 2 - assert (crux_dir / "launchers" / "keychain-auth.sh").exists() diff --git a/tests/unit/test_sync.py b/tests/unit/test_sync.py deleted file mode 100644 index 422e43d..0000000 --- a/tests/unit/test_sync.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Unit tests for crux_cli.sync — sync engine and .mcp.json generation.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import pytest - -from crux_cli.sync import ( - _build_http_bridge_entry, - _build_keychain_auth_entry, - _build_server_entry, - sync_project, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_registry(mcps=None, skills=None): - """Build a minimal registry dict.""" - return { - "version": "1.0.0", - "mcp_definitions": mcps or {}, - "skill_definitions": skills or {}, - } - - -def _make_crux_json(project_dir, name="test-project", mcps=None, skills=None): - """Write a crux.json into project_dir and return the dict.""" - data = {"name": name, "mcps": mcps or [], "skills": skills or []} - project_dir.mkdir(parents=True, exist_ok=True) - (project_dir / "crux.json").write_text(json.dumps(data, indent=2)) - return data - - -# --------------------------------------------------------------------------- -# sync_project -# --------------------------------------------------------------------------- - - -class TestSyncGeneratesMcpJson: - def test_sync_generates_mcp_json(self, tmp_path): - project = tmp_path / "proj" - _make_crux_json(project, mcps=["memory"]) - registry = _make_registry( - mcps={ - "memory": { - "type": "npm-package", - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-memory"], - } - } - ) - - success, issues = sync_project(project, registry) - assert success - assert issues == [] - - mcp_json = project / ".mcp.json" - assert mcp_json.exists() - data = json.loads(mcp_json.read_text()) - assert "mcpServers" in data - assert "memory" in data["mcpServers"] - - def test_sync_mcp_json_format_valid(self, tmp_path): - project = tmp_path / "proj" - _make_crux_json(project, mcps=["memory"]) - registry = _make_registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - } - ) - - sync_project(project, registry) - data = json.loads((project / ".mcp.json").read_text()) - - # Must have the Claude Code format: {"mcpServers": {...}} - assert isinstance(data, dict) - assert "mcpServers" in data - server = data["mcpServers"]["memory"] - assert "command" in server - - def test_sync_missing_mcp_errors(self, tmp_path): - project = tmp_path / "proj" - _make_crux_json(project, mcps=["nonexistent"]) - registry = _make_registry() - - success, issues = sync_project(project, registry) - assert success # sync still writes .mcp.json - assert any("nonexistent" in i and "not found" in i for i in issues) - - def test_sync_empty_mcps(self, tmp_path): - project = tmp_path / "proj" - _make_crux_json(project, mcps=[]) - registry = _make_registry() - - success, issues = sync_project(project, registry) - assert success - assert issues == [] - data = json.loads((project / ".mcp.json").read_text()) - assert data["mcpServers"] == {} - - -class TestSyncAuthedMcpUsesSharedLauncher: - """Authed MCPs reference the shared keychain-auth.sh via env config.""" - - def test_sync_authed_mcp_uses_shared_launcher(self, tmp_path): - project = tmp_path / "proj" - _make_crux_json(project, mcps=["authed-mcp"]) - registry = _make_registry( - mcps={ - "authed-mcp": { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": {"env_vars": ["API_KEY"]}, - } - } - ) - - sync_project(project, registry) - - data = json.loads((project / ".mcp.json").read_text()) - server = data["mcpServers"]["authed-mcp"] - assert server["command"].endswith("keychain-auth.sh") - assert server["env"]["CRUX_MCP_NAME"] == "authed-mcp" - assert server["env"]["CRUX_AUTH_ENV_VARS"] == "API_KEY" - # The actual MCP command is passed as args to the shared launcher - assert "npx" in server["args"] - - def test_sync_no_launcher_files_generated(self, tmp_path): - """No per-MCP .sh files should be generated anywhere.""" - project = tmp_path / "proj" - _make_crux_json(project, mcps=["authed-mcp"]) - registry = _make_registry( - mcps={ - "authed-mcp": { - "command": "npx", - "args": ["-y", "pkg"], - "auth": {"env_vars": ["SECRET"]}, - } - } - ) - - sync_project(project, registry) - - # No .sh files should exist in the project or temp directory - sh_files = list(tmp_path.rglob("authed-mcp.sh")) - assert sh_files == [] - - -class TestSyncAllTrackedProjects: - def test_sync_all_tracked_projects(self, tmp_path): - """Verify sync_project works on multiple project dirs.""" - registry = _make_registry( - mcps={ - "memory": {"command": "npx", "args": ["-y", "pkg"]}, - } - ) - - projects = [] - for name in ("proj-a", "proj-b"): - p = tmp_path / name - _make_crux_json(p, name=name, mcps=["memory"]) - projects.append(p) - - for p in projects: - success, issues = sync_project(p, registry) - assert success - assert issues == [] - assert (p / ".mcp.json").exists() - - -# --------------------------------------------------------------------------- -# _build_keychain_auth_entry -# --------------------------------------------------------------------------- - - -class TestKeychainAuthEntry: - """Tests for the shared keychain launcher entry builder.""" - - def test_entry_structure(self): - mcp_data = { - "command": "npx", - "args": ["-y", "some-pkg"], - "auth": {"env_vars": ["API_KEY", "SECRET_TOKEN"]}, - } - entry = _build_keychain_auth_entry("test-mcp", mcp_data) - - assert entry["command"].endswith("keychain-auth.sh") - assert entry["env"]["CRUX_MCP_NAME"] == "test-mcp" - assert entry["env"]["CRUX_AUTH_ENV_VARS"] == "API_KEY,SECRET_TOKEN" - # MCP command + original args are passed as positional args - assert entry["args"][0] == "npx" - assert "-y" in entry["args"] - assert "some-pkg" in entry["args"] - - def test_extra_args_appended(self): - mcp_data = { - "command": "npx", - "args": ["-y", "pkg"], - "auth": {"env_vars": ["K"]}, - } - entry = _build_keychain_auth_entry("test-mcp", mcp_data, extra_args=["--port", "8080"]) - - assert entry["args"][-2:] == ["--port", "8080"] - - def test_source_dir_resolved_to_absolute(self, tmp_path, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / ".crux")) - mcp_data = { - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "serve"], - "source_dir": "mcps/my-mcp", - "auth": {"env_vars": ["K"]}, - } - entry = _build_keychain_auth_entry("my-mcp", mcp_data) - - # {source_dir} should be resolved to an absolute path - assert "{source_dir}" not in str(entry["args"]) - dir_arg = entry["args"][3] # uv, run, --directory, - assert Path(dir_arg).is_absolute() - - def test_rejects_unsafe_mcp_name(self): - mcp_data = {"command": "npx", "args": [], "auth": {"env_vars": ["K"]}} - with pytest.raises(ValueError, match="Unsafe MCP name"): - _build_keychain_auth_entry("../evil", mcp_data) - - def test_rejects_unsafe_env_var(self): - mcp_data = {"command": "npx", "args": [], "auth": {"env_vars": ["BAD VAR"]}} - with pytest.raises(ValueError, match="Unsafe env var"): - _build_keychain_auth_entry("test-mcp", mcp_data) - - -# --------------------------------------------------------------------------- -# _build_http_bridge_entry -# --------------------------------------------------------------------------- - - -class TestHttpBridgeEntry: - """Tests for HTTP bridge entry builder.""" - - def test_bearer_entry_structure(self): - mcp_data = { - "type": "streamable-http", - "url": "https://example.com/mcp", - "auth": { - "type": "bearer", - "keychain_key": "API_TOKEN", - "header_name": "Authorization", - "header_prefix": "Bearer", - }, - } - entry = _build_http_bridge_entry("test-mcp", mcp_data) - - assert entry["command"].endswith("http-bridge-auth.sh") - assert entry["args"] == [] - assert entry["env"]["CRUX_MCP_NAME"] == "test-mcp" - assert entry["env"]["CRUX_BRIDGE_URL"] == "https://example.com/mcp" - assert entry["env"]["CRUX_BRIDGE_AUTH_HEADER"] == "Authorization" - assert entry["env"]["CRUX_BRIDGE_AUTH_PREFIX"] == "Bearer" - assert entry["env"]["CRUX_AUTH_KEYCHAIN_KEY"] == "API_TOKEN" - assert entry["env"]["CRUX_BRIDGE_AUTH_ENV"] == "CRUX_AUTH_TOKEN" - - def test_oauth_uses_access_token_key(self): - mcp_data = { - "type": "streamable-http", - "url": "https://example.com/mcp", - "auth": {"type": "oauth"}, - } - entry = _build_http_bridge_entry("test-mcp", mcp_data) - - assert entry["env"]["CRUX_AUTH_KEYCHAIN_KEY"] == "access_token" - - def test_oauth_client_credentials_uses_access_token_key(self): - mcp_data = { - "type": "streamable-http", - "url": "https://example.com/mcp", - "auth": {"type": "oauth-client-credentials"}, - } - entry = _build_http_bridge_entry("test-mcp", mcp_data) - - assert entry["env"]["CRUX_AUTH_KEYCHAIN_KEY"] == "access_token" - - def test_no_auth_bridge_entry(self): - """HTTP MCP with no auth still gets bridge env vars.""" - mcp_data = { - "type": "streamable-http", - "url": "https://example.com/mcp", - "auth": {}, - } - entry = _build_http_bridge_entry("test-mcp", mcp_data) - - assert entry["env"]["CRUX_BRIDGE_URL"] == "https://example.com/mcp" - assert "CRUX_AUTH_KEYCHAIN_KEY" not in entry["env"] - - -# --------------------------------------------------------------------------- -# _build_server_entry dispatch -# --------------------------------------------------------------------------- - - -class TestBuildServerEntryDispatch: - """Verify _build_server_entry routes to the correct builder.""" - - def test_http_mcp_routes_to_bridge(self): - mcp_data = { - "type": "streamable-http", - "url": "https://example.com/mcp", - "auth": {"type": "bearer", "keychain_key": "TOK"}, - } - entry = _build_server_entry("test-mcp", mcp_data) - assert entry["command"].endswith("http-bridge-auth.sh") - - def test_url_field_routes_to_bridge(self): - mcp_data = {"url": "https://example.com/mcp", "auth": {}} - entry = _build_server_entry("test-mcp", mcp_data) - assert entry["command"].endswith("http-bridge-auth.sh") - - def test_authed_stdio_routes_to_keychain(self): - mcp_data = { - "command": "npx", - "args": ["-y", "pkg"], - "auth": {"env_vars": ["API_KEY"]}, - } - entry = _build_server_entry("test-mcp", mcp_data) - assert entry["command"].endswith("keychain-auth.sh") - - def test_plain_mcp_passthrough(self): - mcp_data = {"command": "npx", "args": ["-y", "pkg"]} - entry = _build_server_entry("test-mcp", mcp_data) - assert entry["command"] == "npx" - assert entry["args"] == ["-y", "pkg"] - - def test_plain_mcp_with_extra_args(self): - mcp_data = {"command": "npx", "args": ["-y", "pkg"]} - entry = _build_server_entry("test-mcp", mcp_data, extra_args=["--port", "8080"]) - assert entry["args"] == ["-y", "pkg", "--port", "8080"] - - def test_plain_mcp_resolves_source_dir(self, tmp_path, monkeypatch): - monkeypatch.setenv("CRUX_TEST_ROOT", str(tmp_path / ".crux")) - mcp_data = { - "command": "uv", - "args": ["run", "--directory", "{source_dir}", "serve"], - "source_dir": "mcps/my-mcp", - } - entry = _build_server_entry("my-mcp", mcp_data) - assert "{source_dir}" not in str(entry["args"]) - assert Path(entry["args"][2]).is_absolute() - - -# --------------------------------------------------------------------------- -# Shared launcher scripts exist as package data -# --------------------------------------------------------------------------- - - -class TestSharedLauncherScriptsExist: - """Verify the bundled shared launcher scripts are present in the package.""" - - def test_keychain_auth_script_exists(self): - from crux_cli.setup_crux import _BUNDLED_LAUNCHERS - - script = _BUNDLED_LAUNCHERS / "keychain-auth.sh" - assert script.exists(), f"Bundled script not found at {script}" - content = script.read_text() - assert "CRUX_MCP_NAME" in content - assert "CRUX_AUTH_ENV_VARS" in content - assert "uname -s" in content # platform detection - - def test_http_bridge_auth_script_exists(self): - from crux_cli.setup_crux import _BUNDLED_LAUNCHERS - - script = _BUNDLED_LAUNCHERS / "http-bridge-auth.sh" - assert script.exists(), f"Bundled script not found at {script}" - content = script.read_text() - assert "CRUX_MCP_NAME" in content - assert "CRUX_AUTH_KEYCHAIN_KEY" in content - assert "crux_cli.bridge" in content - - def test_scripts_have_both_platform_branches(self): - """Both scripts must handle macOS (security) and Linux (secret-tool).""" - from crux_cli.setup_crux import _BUNDLED_LAUNCHERS - - for name in ("keychain-auth.sh", "http-bridge-auth.sh"): - content = (_BUNDLED_LAUNCHERS / name).read_text() - assert "Darwin" in content, f"{name} missing macOS branch" - assert "security find-generic-password" in content, f"{name} missing macOS command" - assert "secret-tool lookup" in content, f"{name} missing Linux command" From 9c2fe41b4a4528f68b827166ba98e450f1445c39 Mon Sep 17 00:00:00 2001 From: Kaushal Paneri Date: Sun, 10 May 2026 22:22:04 -0400 Subject: [PATCH 5/5] docs: rewrite README for v2 harness manager Replace the v1 quickstart and command reference with v2: harness as the unit of composition, symlink-based activation, pointer files, versioning with bump and use -, and the migrate command for v1 projects. Add a concepts section describing the four primitives. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 185 +++++++++++++++++++++++++++++++----------------------- uv.lock | 2 +- 2 files changed, 108 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index f311caa..3da1478 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Crux -**Agentic Tool Manager for Claude Code.** +**Harness manager for Claude Code.** [![CI](https://github.com/crux-cli/crux/actions/workflows/ci.yml/badge.svg)](https://github.com/crux-cli/crux/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/crux-cli)](https://pypi.org/project/crux-cli/) @@ -9,124 +9,153 @@ --- -Crux is a CLI tool for **macOS** and **Linux** that brings package-management to your **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** agentic workflows. Add MCP servers and skills to a local registry, scope them per project, and keep credentials in your OS keychain — never in files. +Crux v2 manages **harnesses** — versioned bundles of agent configuration (`CLAUDE.md`, skills, MCPs, plugins, hooks) for [Claude Code](https://docs.anthropic.com/en/docs/claude-code). Activate a harness with `crux use`, and Crux deploys symlinks into the paths Claude Code already reads from. ### Highlights -- **One registry, every project** — add MCPs and skills once from npm, PyPI, GitHub, or local sources. No more copy-pasting `.mcp.json`. -- **Secrets in your keychain** — API keys live in macOS Keychain, Linux Secret Service, or an age-encrypted vault. Launcher scripts fetch them at runtime. -- **Scoped per project** — each project declares its tools in `crux.json`. Agents see only what's declared — fewer tools means better outputs. -- **Sandboxed execution** — `crux run` creates isolated environments with pre-flight validation. Misconfigurations are caught before your agent starts. -- **Health monitoring** — `crux status` probes every MCP via JSON-RPC. `crux doctor` validates your environment and auto-fixes what it can. -- **Discover & search** — search the official MCP registry from your terminal and add servers with one command. +- **Composition as a first-class object.** A harness is a single TOML bundle that names which skills, MCPs, and plugins make up a configuration. Reuse it across projects instead of copy-pasting. +- **Versioning and rollback.** `crux bump` snapshots the current harness as the next version. `crux use -` rolls back to the previous activation. Every version is preserved on disk. +- **No magic, just symlinks.** Activation places symlinks under `~/.claude/` (or `/.claude/`) pointing into the registry. `ls -la` shows exactly what Crux deployed; non-Crux files are never touched. +- **Per-directory or per-user.** Drop a `crux.toml` pointer in a project to override the user-level default. Resolution walks up from the cwd, then falls back to `~/.crux/active.toml`. +- **Secrets in your keychain.** API keys live in macOS Keychain, Linux Secret Service, or an age-encrypted vault. Launcher scripts fetch them at runtime; nothing ever lands in `.mcp.json`. ## Install -**As a Claude Code plugin** (recommended): - ```bash -claude plugin marketplace add crux-cli/claude-marketplace -claude plugin install crux +curl -LsSf https://raw.githubusercontent.com/crux-cli/crux/main/install.sh | sh +crux setup ``` -**Or via curl:** +Or with [uv](https://docs.astral.sh/uv/): `uv tool install crux-cli && crux setup`. + +## Quickstart ```bash -curl -LsSf https://raw.githubusercontent.com/crux-cli/crux/main/install.sh | sh +# One-time setup +crux setup + +# Stock the registry with primitives +crux registry add mcp filesystem @modelcontextprotocol/server-filesystem --npm +crux registry add skill autoresearch user/autoresearch-skill --github + +# Build a harness and pull primitives into its bundle +crux new coding +crux edit skills coding --add autoresearch +crux edit mcps coding --add filesystem + +# Activate it for everything (user-level) +crux use coding --user + +# Or override per project +cd ~/code/my-app +crux use coding +crux active # prints "coding@v1 (directory, …/crux.toml)" + +# Iterate: bump produces v2, roll back with `use -` +crux bump coding +crux use coding@v2 --user +crux use - --user # back to v1 ``` -Or if you already have [uv](https://docs.astral.sh/uv/): `uv tool install crux-cli && crux setup` +## Concepts -## Get started in three steps +| Primitive | What it is | +|-----------|------------| +| **MCP** | A server providing tools to the agent. Source: npm, uvx, GitHub, local, or HTTP. | +| **Skill** | A reusable capability bundle. Source: local directory or GitHub repo. | +| **Plugin** | A third-party bundle (own CLAUDE.md, hooks, skills). Stored versioned. | +| **Harness** | A composition referencing the above + its own CLAUDE.md + its own hooks. | -**1. Build your registry** — add MCP servers and skills from any source: +Harnesses are stored at `~/.crux/registry/harnesses///` with a `bundle.toml`: -```bash -crux add mcp filesystem --npx @modelcontextprotocol/server-filesystem -crux add mcp wikijs --github jaalbin24/wikijs-mcp-server -crux add skill autoresearch --github user/autoresearch-skill -``` - -**2. Store credentials securely** — API keys go in your OS keychain: +```toml +[harness] +name = "coding" +version = "v3" +description = "Tuned for careful refactors" -```bash -crux secret set wikijs WIKIJS_API_KEY -crux secret set github GITHUB_TOKEN -``` +[skills] +include = ["autoresearch"] -**3. Use in a project or a one-off sandbox:** +[mcps] +include = ["filesystem", "wikijs"] -*Project mode* — declare what each project needs in a `crux.json` manifest: +[plugins] +include = ["awesome-coding@v2"] -```bash -crux init homelab-assistant && cd homelab-assistant -crux install wikijs filesystem autoresearch -crux status +[hooks] +pre_tool_use = "hooks/pre.sh" ``` -*Sandbox mode* — run an agent with a specific set of tools, without a full project: +A pointer file names the active harness: -```bash -crux run "Update the wiki with latest research" \ - --mcps wikijs --skills autoresearch +```toml +# /crux.toml or ~/.crux/active.toml +harness = "coding@v3" ``` -## Replaces manual management of - -| What | Without Crux | With Crux | -|------|-------------|-----------| -| **MCP config** | Hand-edited `.mcp.json` per project | `crux.json` manifest + `crux sync` | -| **Credentials** | Plaintext `.env` files committed to git | OS keychain, fetched at runtime | -| **Tool scoping** | Every agent sees every tool | Each project declares its own subset | -| **Skills** | Files manually copied between machines | Registry with `crux add skill` | +`@` is optional — omitted means *latest*. ## Commands ``` Setup: - crux setup Initialize ~/.crux and environment - crux doctor Diagnose and auto-fix environment issues + crux setup Initialize ~/.crux + crux doctor Diagnose the environment + crux migrate [--name ] Migrate cwd's crux.json (v1.x) Registry: - crux add mcp Register an MCP (npm, PyPI, GitHub, local) - crux add skill Register a skill - crux remove Unregister an MCP or skill - crux list List everything in the registry - crux search Search the official MCP Registry - crux upgrade [] Update cloned sources to latest - -Project: - crux init [] Create a project with crux.json - crux install Add MCPs/skills to project and sync - crux uninstall Remove MCPs/skills from project and sync - crux sync [--all] Generate .mcp.json from crux.json - crux status [--all] Show MCP server health + crux registry add mcp [--npm|--uvx|--github|--local|--http] [--keychain VAR,VAR] + crux registry add skill [--github] + crux registry add plugin [--version vN] + crux registry remove [--force] + crux registry list Secrets: - crux secret set Store a secret in OS keystore - crux secret get Retrieve a secret - crux secret list [] List stored secrets (values masked) - -Sandbox: - crux run Execute agent with scoped MCP access - crux run --file Execute from a reusable run manifest - crux run list List recent runs - crux run clean Remove completed sandboxes + crux secret set [--value V] + crux secret list [] + crux secret remove + +Harness lifecycle: + crux new Create at v1 + crux bump Snapshot latest as vN+1 + crux list [] List harnesses or versions + crux show [@] Display a bundle + +Harness editing: + crux edit claude [] Open $EDITOR on CLAUDE.md + crux edit skills [] [--add N --remove N ...] + crux edit mcps [] [--add N --remove N ...] + crux edit plugins [] [--add N --remove N ...] + crux edit hooks [] Open $EDITOR on hooks/ + +Activation: + crux use [@] [--user] Activate; rebuild symlinks + crux use - [--user] Roll back to previous + crux use --none [--user] Deactivate + crux active Show resolved active harness ``` -## Security +## Migration from v1.x -Crux takes an opinionated stance: **there is no insecure-but-easier path.** +```bash +cd my-v1-project +crux migrate +# crux.json -> harness@v1 + crux.toml pointer +``` + +The migration creates a harness named after the project (override with `--name`), seeds it with the MCPs and skills from `crux.json`, writes a directory-level pointer, and deletes the old `crux.json`. The harness is at `~/.crux/registry/harnesses//v1/`. + +## Security -- Secrets never appear in any file on disk — only in your OS keystore -- Launcher scripts contain keystore lookup commands, not credential values -- Generated `.mcp.json` never contains secrets -- Each sandbox gets only the MCPs explicitly declared for that run -- Path traversal protections on all file operations +- Secrets live in your OS keystore, not in files on disk. +- Launcher scripts hold lookup commands, not credential values. +- Generated `.mcp.json` references env-var names, never the values. +- `crux use` refuses to overwrite any file or symlink it didn't place. ## Documentation -Full docs, guides, and API reference at [crux-cli.github.io/crux](https://crux-cli.github.io/crux). +Full docs at [crux-cli.github.io/crux](https://crux-cli.github.io/crux). ## Development diff --git a/uv.lock b/uv.lock index c5864cf..2409938 100644 --- a/uv.lock +++ b/uv.lock @@ -259,7 +259,7 @@ toml = [ [[package]] name = "crux-cli" -version = "1.0.5" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "packaging" },