diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index fd08d9e..5668b1a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -2,14 +2,27 @@ "name": "clodex", "version": "0.1.1", "description": "Claude Code and Codex CLI dual-agent workflow orchestration.", - "skills": { - "clodex-workflow": { - "path": "skills/clodex-workflow/SKILL.md" - } + "author": { + "name": "9thLevelSoftware", + "url": "https://github.com/9thLevelSoftware" }, - "commands": { - "clodex-plan": "dist/.claude/commands/clodex/clodex-plan.md", - "clodex-build": "dist/.claude/commands/clodex/clodex-build.md", - "clodex-audit": "dist/.claude/commands/clodex/clodex-audit.md" + "homepage": "https://github.com/9thLevelSoftware/Clodex#readme", + "repository": "https://github.com/9thLevelSoftware/Clodex", + "keywords": [ + "codex", + "mcp", + "agent-harness", + "workflow" + ], + "commands": [ + "./dist/.claude/commands/clodex/" + ], + "mcpServers": { + "clodex": { + "command": "node", + "args": [ + "${CLAUDE_PLUGIN_ROOT}/npm/clodex-mcp-server.js" + ] + } } } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index ab122d8..d67c4e0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -2,15 +2,16 @@ "name": "clodex", "version": "0.1.1", "description": "Claude Code and Codex CLI dual-agent workflow orchestration.", - "skills": { - "clodex-workflow": { - "path": "skills/clodex-workflow/SKILL.md" - } + "author": { + "name": "9thLevelSoftware", + "url": "https://github.com/9thLevelSoftware" }, - "mcp_servers": { - "clodex": { - "command": "python", - "args": ["-m", "clodex", "mcp-server"] - } - } + "homepage": "https://github.com/9thLevelSoftware/Clodex#readme", + "repository": "https://github.com/9thLevelSoftware/Clodex", + "keywords": [ + "codex", + "mcp", + "agent-harness", + "workflow" + ] } diff --git a/README.md b/README.md index b93258f..774d9a2 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,52 @@ agreement. `clodex init` writes managed Clodex blocks to `CLAUDE.md`, `AGENTS.md`, and `CLODEX.md`. By default it also configures the repo MCP server through `.mcp.json` and `.codex/config.toml`; use `--no-mcp-config` when you want -instructions only. +instructions only. From a subdirectory, `clodex init` still works on the repository root. + +Those two files are meant to be committed, so they use the portable `clodex mcp-server` +command (wrapped as `cmd /c clodex mcp-server` when `init` runs on Windows and only a `.cmd` +shim exists, because Windows MCP clients cannot start those directly). Re-run `clodex init` +if a teammate on another platform needs the other form. + +### User-level install (`clodex init --global`) + +Writes where each agent actually reads user-level files, not your home directory root: + +| What | Where | +| --- | --- | +| Claude instructions | `~/.claude/CLAUDE.md` (or `$CLAUDE_CONFIG_DIR/CLAUDE.md`) | +| Codex instructions | `~/.codex/AGENTS.md` (or `$CODEX_HOME/AGENTS.md`) | +| Codex MCP server | `~/.codex/config.toml` | +| Claude MCP server | registered with `claude mcp add-json clodex ... --scope user` (Claude Code owns `~/.claude.json`) | + +These use an absolute, environment-independent command (the Node launcher for npm installs, +otherwise your Python), so they work regardless of PATH or working directory. Claude Code starts +user-scope servers with `~/.claude` as the working directory, so Clodex finds your project from +`$CLAUDE_PROJECT_DIR`, which Claude Code sets. If the `claude` CLI is not on PATH, `init --global` +prints the command to run yourself. + +### Claude Code hooks + +```bash +clodex hooks install # .claude/settings.local.json (default, not committed) +clodex hooks install --scope user +clodex hooks uninstall +``` + +Records `SessionStart`, `SessionEnd`, `UserPromptSubmit`, `SubagentStart/Stop`, `TaskCreated`, +`TaskCompleted`, `WorktreeCreate/Remove` and `Stop` events to +`.clodex/runs//events/claude-hooks.jsonl` and the trace. Your other hooks and settings +are left alone and re-running is safe. The hook command is silent and never exits with code 2 +(Claude Code treats that as "block"). Tool events are deliberately not hooked: each hook is a +process, which would slow every tool call. + +### Plugin + +The repository is also a Claude Code plugin (`claude --plugin-dir `): slash commands +`/clodex:clodex-plan`, `/clodex:clodex-build` and `/clodex:clodex-audit`, the `clodex-workflow` +skill, and the MCP server (needs `node`; the launcher finds Python). `claude plugin validate . +--strict` passes. `install.sh` installs a self-contained copy under `~/.clodex/plugin` and a +launcher in `~/.local/bin`; re-running replaces rather than nests, and `uninstall.sh` removes it. Native coordination uses these MCP tools: @@ -121,7 +166,7 @@ PowerShell: | `clodex clean ` | Remove the kept git worktree of a finished run (artifacts and patch stay) | | `clodex task start/get/cancel/list` | Manage durable async runs | | `clodex trace export ` | Print a run trace as JSONL | -| `clodex hooks print/install/ingest` | Generate or ingest Claude Code hook events | +| `clodex hooks print/install/uninstall/ingest` | Manage the Claude Code hook integration (`install --scope local\|project\|user`) | | `clodex eval run` | Run local harness smoke evals | | `clodex queue add/list/update` | Manage the local task ledger | | `clodex status` | Show recent tasks and runs | diff --git a/clodex/cli.py b/clodex/cli.py index ca4dd04..e76f5d0 100644 --- a/clodex/cli.py +++ b/clodex/cli.py @@ -10,7 +10,7 @@ from .config import ConfigError, resolve_repo_root from .doctor import run_doctor from .evals import run_local_evals -from .hooks import hook_config, ingest_hook_event +from .hooks import derive_run_id, hook_config, ingest_hook_event, install_hooks, parse_hook_payload from .mcp_server import main as mcp_main from .migrate import migrate_contract from .models import ModelRetiredError @@ -143,11 +143,15 @@ def _main(argv: list[str] | None = None) -> int: hooks = sub.add_parser("hooks", help="Print or ingest Claude Code hook events") hooks_sub = hooks.add_subparsers(dest="hooks_command", required=True) - hooks_sub.add_parser("print") - hooks_install = hooks_sub.add_parser("install") - hooks_install.add_argument("--dry-run", action="store_true", required=True) - hooks_ingest = hooks_sub.add_parser("ingest") - hooks_ingest.add_argument("--run-id", default="manual-hook-event") + hooks_sub.add_parser("print", help="Print the hooks block for a Claude Code settings file") + for name, help_text in (("install", "Add Clodex's hooks to a Claude Code settings file"), ("uninstall", "Remove Clodex's hooks again")): + hooks_edit = hooks_sub.add_parser(name, help=help_text) + hooks_edit.add_argument("--scope", choices=["local", "project", "user"], default="local", help="local: .claude/settings.local.json (default), project: .claude/settings.json, user: ~/.claude/settings.json") + hooks_edit.add_argument("--dry-run", action="store_true") + hooks_edit.add_argument("--force", action="store_true", help="Replace a settings file that is not valid JSON") + hooks_ingest = hooks_sub.add_parser("ingest", help="Record one hook event from stdin (used by the installed hooks)") + hooks_ingest.add_argument("--run-id", default=None, help="Default: $CLODEX_RUN_ID, else the session id from the payload") + hooks_ingest.add_argument("--verbose", action="store_true", help="Print the result (hooks must stay silent: stdout can reach Claude's context)") sub.add_parser("status", help="Show recent tasks and runs") sub.add_parser("mcp-server", help="Run the Clodex MCP stdio server") @@ -316,14 +320,23 @@ def handle_trace(args: argparse.Namespace, workflow: ClodexWorkflow, as_json: bo def handle_hooks(args: argparse.Namespace, as_json: bool) -> int: if args.hooks_command == "print": - print_output(hook_config(resolve_repo_root()), as_json) + print_output(hook_config(), as_json) return 0 - if args.hooks_command == "install": - print_output({"dry_run": True, "config": hook_config(resolve_repo_root())}, as_json) + if args.hooks_command in {"install", "uninstall"}: + result = install_hooks(resolve_repo_root(), args.scope, dry_run=args.dry_run, remove=args.hooks_command == "uninstall", force=args.force) + print_output(result, as_json) return 0 if args.hooks_command == "ingest": - payload = json.loads(sys.stdin.read() or "{}") - print_output(ingest_hook_event(resolve_repo_root(), args.run_id, payload), as_json) + # A hook that exits 2 blocks the action it is attached to, and its stdout can reach + # Claude's context: so never fail loudly, and stay silent unless asked. + try: + payload = parse_hook_payload(sys.stdin.read()) + result = ingest_hook_event(resolve_repo_root(), derive_run_id(args.run_id, payload), payload) + except Exception as exc: # noqa: BLE001 + print(f"clodex hooks ingest: {exc}", file=sys.stderr) + return 1 + if args.verbose or as_json: + print_output(result, as_json) return 0 return 2 diff --git a/clodex/config.py b/clodex/config.py index 92e4ceb..8dcce5f 100644 --- a/clodex/config.py +++ b/clodex/config.py @@ -155,9 +155,17 @@ def tracing(self) -> dict[str, Any]: def resolve_repo_root() -> Path: - """The repo Clodex should work on: $CLODEX_REPO_ROOT if set (MCP clients start servers from anywhere), else the git root above the cwd.""" - override = os.environ.get("CLODEX_REPO_ROOT") - return find_repo_root(Path(override)) if override else find_repo_root() + """The repo Clodex should work on. + + $CLODEX_REPO_ROOT wins. Claude Code sets $CLAUDE_PROJECT_DIR for the MCP servers and hooks it + starts, which matters because user-scope servers run with ~/.claude as their cwd. Otherwise + use the git root above the current directory. + """ + for variable in ("CLODEX_REPO_ROOT", "CLAUDE_PROJECT_DIR"): + override = os.environ.get(variable) + if override and Path(override).is_dir(): + return find_repo_root(Path(override)) + return find_repo_root() def find_repo_root(start: Path | None = None) -> Path: diff --git a/clodex/hooks.py b/clodex/hooks.py index abb785a..e4eb451 100644 --- a/clodex/hooks.py +++ b/clodex/hooks.py @@ -1,51 +1,163 @@ +"""Claude Code hook integration: record session events into the Clodex trace. + +Hook semantics that matter here (Claude Code hooks reference): a command hook that exits with +code 2 *blocks* the action it is attached to, and its stdout is added to Claude's context for +some events. So ingestion must never exit 2 and must print nothing unless asked to. +""" + from __future__ import annotations import json -import sys +import os +import re from pathlib import Path from typing import Any from .config import load_config +from .launcher import clodex_argv from .state import StateStore from .trace import TraceWriter - +# Event names as spelled in the Claude Code hooks reference. Tool events (PreToolUse / +# PostToolUse) are left out on purpose: each hook starts a process, which would slow every +# tool call, and FileChanged requires a matcher. HOOK_EVENTS = [ "SessionStart", "SessionEnd", "UserPromptSubmit", - "PreToolUse", - "PostToolUse", "SubagentStart", "SubagentStop", "TaskCreated", "TaskCompleted", - "WorktreeCreated", - "WorktreeRemoved", - "FileChanged", + "WorktreeCreate", + "WorktreeRemove", "Stop", ] +HOOK_TIMEOUT_SECONDS = 10 +DEFAULT_RUN_ID = "manual-hook-event" +SCOPES = ("local", "project", "user") +_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") -def hook_config(repo_root: Path | None = None) -> dict[str, Any]: - config = load_config(repo_root) - command = f"{sys.executable} -m clodex hooks ingest" - return { - "hooks": { - event: [{"type": "command", "command": command}] - for event in HOOK_EVENTS - }, - "clodex": { - "repo_root": str(config.repo_root), - "events": HOOK_EVENTS, - }, - } +def safe_run_id(value: str) -> str: + """A run id that is safe to use as a directory name; raises ValueError otherwise.""" + if not isinstance(value, str) or not _RUN_ID.match(value) or ".." in value: + raise ValueError(f"Invalid run id: {value!r} (use letters, digits, '.', '_' or '-', at most 128 characters)") + return value + + +def derive_run_id(explicit: str | None, payload: dict[str, Any], environ: dict[str, str] | None = None) -> str: + """--run-id if given (validated), else $CLODEX_RUN_ID or the session id, else a shared default.""" + if explicit: + return safe_run_id(explicit) + env = os.environ if environ is None else environ + for candidate in (env.get("CLODEX_RUN_ID"), payload.get("session_id")): + if candidate: + try: + return safe_run_id(str(candidate)) + except ValueError: + continue # untrusted input: fall back rather than fail a hook + return DEFAULT_RUN_ID + + +def hook_handler() -> dict[str, Any]: + argv = clodex_argv("hooks", "ingest") + return {"type": "command", "command": argv[0], "args": argv[1:], "timeout": HOOK_TIMEOUT_SECONDS} + + +def hook_config() -> dict[str, Any]: + """The `hooks` block, ready to paste into a Claude Code settings file.""" + handler = hook_handler() + return {"hooks": {event: [{"hooks": [dict(handler)]}] for event in HOOK_EVENTS}} + + +def is_clodex_handler(handler: Any) -> bool: + return isinstance(handler, dict) and list(handler.get("args") or [])[-2:] == ["hooks", "ingest"] + + +def merge_hooks(settings: dict[str, Any], *, remove: bool = False) -> dict[str, Any]: + """Add (or with `remove`, strip) Clodex's hook entries, leaving every other hook untouched.""" + merged = json.loads(json.dumps(settings)) + hooks = merged.get("hooks") + if hooks is None: + hooks = {} + if not isinstance(hooks, dict): + raise ValueError("settings 'hooks' must be an object") + handler = hook_handler() + for event in HOOK_EVENTS: + groups = hooks.get(event, []) + if not isinstance(groups, list): + raise ValueError(f"settings hooks.{event} must be an array") + kept = [] + for group in groups: + inner = [h for h in (group.get("hooks") or []) if not is_clodex_handler(h)] if isinstance(group, dict) else None + if inner is None: + kept.append(group) + elif inner: + kept.append({**group, "hooks": inner}) + if not remove: + kept.append({"hooks": [dict(handler)]}) + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + if hooks: + merged["hooks"] = hooks + else: + merged.pop("hooks", None) + return merged + + +def settings_path(repo_root: Path, scope: str) -> Path: + if scope == "local": + return repo_root / ".claude" / "settings.local.json" + if scope == "project": + return repo_root / ".claude" / "settings.json" + if scope == "user": + home = os.environ.get("CLAUDE_CONFIG_DIR") + return (Path(home) if home else Path.home() / ".claude") / "settings.json" + raise ValueError(f"Unknown scope: {scope} (use one of {', '.join(SCOPES)})") + + +def install_hooks(repo_root: Path, scope: str = "local", *, dry_run: bool = False, remove: bool = False, force: bool = False) -> dict[str, Any]: + """Merge Clodex's hooks into a Claude Code settings file (or remove them again).""" + path = settings_path(repo_root, scope) + existing_text = "" + newline = "\n" + if path.exists(): + with path.open(encoding="utf-8", newline="") as handle: + existing_text = handle.read() + newline = "\r\n" if "\r\n" in existing_text else "\n" + try: + settings = json.loads(existing_text) if existing_text.strip() else {} + if not isinstance(settings, dict): + raise ValueError("settings root must be an object") + merged = merge_hooks(settings, remove=remove) + except ValueError as exc: + if not force: + raise ValueError(f"Cannot update {path}: {exc} (use --force to replace it)") from exc + merged = merge_hooks({}, remove=remove) + rendered = (json.dumps(merged, indent=2) + "\n").replace("\n", newline) + if not path.exists(): + action = "unchanged" if remove else "create" + else: + action = "unchanged" if rendered == existing_text else ("remove" if remove else "update") + if not dry_run and action in {"create", "update", "remove"}: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + handle.write(rendered) + note = "project scope is meant to be committed but contains machine-specific paths; prefer --scope local" if scope == "project" and not remove else None + return {"path": str(path), "scope": scope, "action": action, "dry_run": dry_run, **({"note": note} if note else {}), "preview": merged} def ingest_hook_event(repo_root: Path | None, run_id: str, payload: dict[str, Any]) -> dict[str, Any]: config = load_config(repo_root) + run_id = safe_run_id(run_id) + runs_root = config.runs_root.resolve() + run_dir = (runs_root / run_id).resolve() + if run_dir.parent != runs_root: # belt and braces on top of the id check + raise ValueError(f"Invalid run id: {run_id!r}") state = StateStore(config.state_path) - run_dir = config.runs_root / run_id events_dir = run_dir / "events" events_dir.mkdir(parents=True, exist_ok=True) hook_file = events_dir / "claude-hooks.jsonl" @@ -53,3 +165,14 @@ def ingest_hook_event(repo_root: Path | None, run_id: str, payload: dict[str, An handle.write(json.dumps(payload, sort_keys=True) + "\n") TraceWriter(run_dir, run_id, state).event("hook.ingest", payload) return {"run_id": run_id, "event_file": str(hook_file)} + + +def parse_hook_payload(text: str) -> dict[str, Any]: + """The JSON object Claude Code sends on stdin; anything else is kept (truncated) rather than lost.""" + if not text.strip(): + return {} + try: + payload = json.loads(text) + except ValueError: + return {"raw": text[:2000]} + return payload if isinstance(payload, dict) else {"value": payload} diff --git a/clodex/launcher.py b/clodex/launcher.py new file mode 100644 index 0000000..1a80f6a --- /dev/null +++ b/clodex/launcher.py @@ -0,0 +1,56 @@ +"""How other programs (Claude Code hooks, MCP clients) should start Clodex. + +Those programs spawn Clodex without our shell environment: no PYTHONPATH, sometimes a +different PATH, and on Windows they cannot spawn a `.cmd` shim directly. So instead of +trusting a bare `clodex` on PATH, build a command that works wherever Clodex is installed. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path +from typing import Any + +import clodex + + +def _is_installed() -> bool: + """True when the package lives in site-packages (pip) rather than a source checkout.""" + parts = Path(clodex.__file__).resolve().parts + return "site-packages" in parts or "dist-packages" in parts + + +def _bootstrap(root: Path) -> str: + # A source checkout is not importable from a bare interpreter, so put it on sys.path inline. + return f"import sys; sys.path.insert(0, {str(root)!r}); from clodex.cli import main; raise SystemExit(main(sys.argv[1:]))" + + +def clodex_argv(*subcommand: str, npm_launcher: str | None = None, python: str | None = None, installed: bool | None = None) -> list[str]: + """An absolute, environment-independent command line that runs `clodex `.""" + launcher = npm_launcher if npm_launcher is not None else os.environ.get("CLODEX_NPM_LAUNCHER") + if launcher and Path(launcher).is_file(): + return [shutil.which("node") or "node", str(launcher), *subcommand] + interpreter = python or sys.executable + if _is_installed() if installed is None else installed: + return [interpreter, "-m", "clodex", *subcommand] + return [interpreter, "-c", _bootstrap(Path(clodex.__file__).resolve().parents[1]), *subcommand] + + +def mcp_server_entry(*, portable: bool, windows: bool | None = None, which: Any = shutil.which) -> dict[str, Any]: + """The MCP server definition for `command`/`args`. + + `portable` is for files that get committed and shared (`.mcp.json`, `.codex/config.toml`): + use the bare `clodex` command, wrapped in `cmd /c` where only a `.cmd` shim exists, since + Windows MCP clients cannot spawn those directly. Otherwise (user-level config for this + machine) use the absolute, environment-independent command. + """ + if portable: + windows = os.name == "nt" if windows is None else windows + exe = which("clodex") + if windows and exe and str(exe).lower().endswith((".cmd", ".bat")): + return {"command": "cmd", "args": ["/c", "clodex", "mcp-server"]} + return {"command": "clodex", "args": ["mcp-server"]} + argv = clodex_argv("mcp-server") + return {"command": argv[0], "args": argv[1:]} diff --git a/clodex/native.py b/clodex/native.py index ddf1b2d..82ee7df 100644 --- a/clodex/native.py +++ b/clodex/native.py @@ -3,6 +3,7 @@ import json import os import shutil +import subprocess import sys import tomllib from dataclasses import dataclass @@ -10,6 +11,7 @@ from typing import Any from .doctor import run_doctor +from .launcher import mcp_server_entry BEGIN_MARKER = "" @@ -34,10 +36,6 @@ class NativeFilePlan: preview: str -def normalize_block_body(body: str) -> str: - return body if body.endswith("\n") else body + "\n" - - def replace_managed_block(existing: str, body: str, *, force: bool = False) -> tuple[str, bool]: return _replace_managed_block(existing, body, BEGIN_MARKER, END_MARKER, force=force) @@ -118,11 +116,11 @@ def build_agents_block() -> str: """ -def clodex_mcp_server_entry() -> dict[str, Any]: - return {"command": "clodex", "args": ["mcp-server"]} +def clodex_mcp_server_entry(portable: bool = True) -> dict[str, Any]: + return mcp_server_entry(portable=portable) -def render_mcp_json(existing: str, *, force: bool = False) -> str: +def render_mcp_json(existing: str, *, force: bool = False, entry: dict[str, Any] | None = None) -> str: newline = _detect_newline(existing) text = existing.strip() if not text: @@ -148,18 +146,18 @@ def render_mcp_json(existing: str, *, force: bool = False) -> str: raise ManagedBlockError(".mcp.json mcpServers must be an object") servers = {} data["mcpServers"] = servers - servers["clodex"] = clodex_mcp_server_entry() + servers["clodex"] = entry or clodex_mcp_server_entry() return (json.dumps(data, indent=2, sort_keys=True) + "\n").replace("\n", newline) -def codex_mcp_block() -> str: - return """[mcp_servers.clodex] -command = "clodex" -args = ["mcp-server"] -""" +def codex_mcp_block(entry: dict[str, Any] | None = None) -> str: + entry = entry or clodex_mcp_server_entry() + # JSON string escaping is valid TOML basic-string escaping (Windows paths, quotes, unicode). + return f"[mcp_servers.clodex]\ncommand = {json.dumps(entry['command'])}\nargs = {json.dumps(entry['args'])}\n" -def render_codex_toml(existing: str, *, force: bool = False) -> str: +def render_codex_toml(existing: str, *, force: bool = False, entry: dict[str, Any] | None = None) -> str: + entry = entry or clodex_mcp_server_entry() has_unmanaged_clodex_table = _has_unmanaged_codex_mcp_table(existing) if existing.strip(): try: @@ -182,28 +180,28 @@ def render_codex_toml(existing: str, *, force: bool = False) -> str: if not force: raise ManagedBlockError(f".codex/config.toml already contains unmanaged [{CODEX_MCP_TABLE}]") existing = _remove_unmanaged_codex_mcp_tables(existing) - rendered = replace_toml_managed_block(existing, codex_mcp_block(), force=force)[0] - _validate_rendered_codex_toml(rendered) + rendered = replace_toml_managed_block(existing, codex_mcp_block(entry), force=force)[0] + _validate_rendered_codex_toml(rendered, entry) return rendered -def _validate_rendered_codex_toml(rendered: str) -> None: +def _validate_rendered_codex_toml(rendered: str, entry: dict[str, Any]) -> None: try: data = tomllib.loads(rendered) except tomllib.TOMLDecodeError as exc: raise ManagedBlockError(f"Rendered .codex/config.toml is invalid: {exc}") from exc - if not _has_clodex_mcp_command(data): + if not _has_clodex_mcp_command(data, entry): raise ManagedBlockError(f"Rendered .codex/config.toml is missing [{CODEX_MCP_TABLE}] command") -def _has_clodex_mcp_command(data: Any) -> bool: +def _has_clodex_mcp_command(data: Any, entry: dict[str, Any]) -> bool: if not isinstance(data, dict): return False servers = data.get("mcp_servers") if not isinstance(servers, dict): return False clodex = servers.get("clodex") - return isinstance(clodex, dict) and clodex.get("command") == "clodex" + return isinstance(clodex, dict) and clodex.get("command") == entry["command"] and clodex.get("args") == entry["args"] def build_clodex_policy_block() -> str: @@ -234,6 +232,71 @@ def repo_native_targets(repo_root: Path, *, no_mcp_config: bool = False) -> list return targets +def _claude_config_dir() -> Path: + override = os.environ.get("CLAUDE_CONFIG_DIR") + return Path(override) if override else Path.home() / ".claude" + + +def _claude_user_json() -> Path: + """Claude Code keeps user-scope MCP servers in ~/.claude.json (or $CLAUDE_CONFIG_DIR/.claude.json).""" + override = os.environ.get("CLAUDE_CONFIG_DIR") + return (Path(override) if override else Path.home()) / ".claude.json" + + +def _codex_home() -> Path: + override = os.environ.get("CODEX_HOME") + return Path(override) if override else Path.home() / ".codex" + + +def native_targets(root: Path, *, global_mode: bool = False, no_mcp_config: bool = False) -> list[tuple[Path, str, str]]: + """The files `init` manages. Global mode writes where each agent actually looks for user-level files.""" + if not global_mode: + return repo_native_targets(root, no_mcp_config=no_mcp_config) + targets = [ + (_claude_config_dir() / "CLAUDE.md", "managed", build_claude_block()), + (_codex_home() / "AGENTS.md", "managed", build_agents_block()), + ] + if not no_mcp_config: + targets.append((_codex_home() / "config.toml", "toml", "")) + return targets + + +def claude_user_mcp_commands() -> list[list[str]]: + """Register the server in Claude Code's user scope via its CLI (it owns ~/.claude.json). + + `claude mcp add-json` keeps an existing server of the same name untouched, so remove first. + """ + entry = clodex_mcp_server_entry(portable=False) + return [ + ["claude", "mcp", "remove", "clodex", "--scope", "user"], + ["claude", "mcp", "add-json", "clodex", json.dumps({"type": "stdio", **entry}), "--scope", "user"], + ] + + +def register_claude_user_mcp(runner: Any = subprocess.run, which: Any = shutil.which) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + for argv in claude_user_mcp_commands(): + shown = " ".join(argv[:5]) + (" ..." if len(argv) > 5 else "") + if not which("claude"): + results.append({"command": shown, "skipped": "the claude CLI is not on PATH; run this command yourself"}) + continue + completed = runner(argv, capture_output=True, text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, check=False) + results.append({"command": shown, "returncode": completed.returncode}) + return results + + +def claude_user_mcp_status() -> dict[str, Any]: + path = _claude_user_json() + try: + servers = json.loads(path.read_text(encoding="utf-8")).get("mcpServers") or {} + except (OSError, ValueError, AttributeError): + return {"path": str(path), "registered": False} + entry = servers.get("clodex") if isinstance(servers, dict) else None + expected = clodex_mcp_server_entry(portable=False) + matches = isinstance(entry, dict) and entry.get("command") == expected["command"] and entry.get("args") == expected["args"] + return {"path": str(path), "registered": isinstance(entry, dict), "current": bool(matches)} + + def target_status(path: Path, desired: str) -> str: if not path.exists(): return "missing" @@ -244,14 +307,15 @@ def target_status(path: Path, desired: str) -> str: return "current" if current == desired else "stale" -def _planned_content(path: Path, kind: str, body: str, *, force: bool) -> str: +def _planned_content(path: Path, kind: str, body: str, *, force: bool, portable: bool = True) -> str: existing = _read_text_preserve_newlines(path) if path.exists() else "" + entry = clodex_mcp_server_entry(portable=portable) if kind == "managed": return replace_managed_block(existing, body, force=force)[0] if kind == "json": - return render_mcp_json(existing, force=force) + return render_mcp_json(existing, force=force, entry=entry) if kind == "toml": - return render_codex_toml(existing, force=force) + return render_codex_toml(existing, force=force, entry=entry) raise ValueError(f"unknown native target kind: {kind}") @@ -265,9 +329,10 @@ def plan_native_install( ) -> dict[str, Any]: target_root = Path.home() if global_mode else repo_root files: list[dict[str, Any]] = [] - for path, kind, body in repo_native_targets(target_root, no_mcp_config=no_mcp_config): + for path, kind, body in native_targets(target_root, global_mode=global_mode, no_mcp_config=no_mcp_config): try: - content = _planned_content(path, kind, body, force=force) + # Shared repo files stay portable; user-level files may hold machine-specific absolute paths. + content = _planned_content(path, kind, body, force=force, portable=not global_mode) except UnicodeDecodeError as exc: files.append( { @@ -317,11 +382,13 @@ def plan_native_install( } ) _mark_preflight_conflicts(files, files) + commands = [" ".join(argv[:5]) + (" ..." if len(argv) > 5 else "") for argv in claude_user_mcp_commands()] if global_mode and not no_mcp_config else [] return { "mode": "global" if global_mode else "repo", "dry_run": dry_run, "root": str(target_root), "files": files, + "commands": commands, } @@ -351,6 +418,8 @@ def apply_native_install( path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8", newline="") as handle: handle.write(item["preview"]) + if plan["commands"]: + plan["commands_run"] = register_claude_user_mcp() return plan @@ -370,12 +439,15 @@ def native_status( ) files = [_status_entry(item) for item in plan["files"]] ok = all(item["status"] == "current" and item["action"] == "unchanged" for item in files) - return { + status: dict[str, Any] = { "ok": ok, "mode": plan["mode"], "root": plan["root"], "files": files, } + if global_mode and not no_mcp_config: + status["claude_user_mcp"] = claude_user_mcp_status() + return status def native_doctor( @@ -645,10 +717,6 @@ def _is_codex_mcp_table_path(parts: list[str]) -> bool: return parts[:2] == ["mcp_servers", "clodex"] -def _is_toml_table_header(line: str) -> bool: - return _toml_table_parts(line) is not None - - def _toml_table_parts(line: str) -> list[str] | None: value = line.strip() if not value.startswith("["): diff --git a/dist/.claude/commands/clodex/clodex-audit.md b/dist/.claude/commands/clodex/clodex-audit.md index 33fcc33..94c227f 100644 --- a/dist/.claude/commands/clodex/clodex-audit.md +++ b/dist/.claude/commands/clodex/clodex-audit.md @@ -1,9 +1,16 @@ +--- +description: Audit the current uncommitted changes with Claude and Codex +argument-hint: [--base REF | --commit SHA] +allowed-tools: Bash(clodex audit:*) +--- + # /clodex-audit Run: ```bash -clodex audit --diff +clodex audit $ARGUMENTS ``` -Use this to audit current uncommitted changes with Claude and Codex. +With no arguments this audits the current uncommitted changes with Claude and Codex. +Pass `--base ` to audit everything since a branch diverged, or `--commit ` for one commit. diff --git a/dist/.claude/commands/clodex/clodex-build.md b/dist/.claude/commands/clodex/clodex-build.md index 3796435..8404707 100644 --- a/dist/.claude/commands/clodex/clodex-build.md +++ b/dist/.claude/commands/clodex/clodex-build.md @@ -1,3 +1,9 @@ +--- +description: Plan with Claude, implement with Codex, then audit with both +argument-hint: +allowed-tools: Bash(clodex build:*) +--- + # /clodex-build Run: diff --git a/dist/.claude/commands/clodex/clodex-plan.md b/dist/.claude/commands/clodex/clodex-plan.md index 038f5ef..78ed7a8 100644 --- a/dist/.claude/commands/clodex/clodex-plan.md +++ b/dist/.claude/commands/clodex/clodex-plan.md @@ -1,3 +1,9 @@ +--- +description: Plan a task with Claude only (no implementation) +argument-hint: +allowed-tools: Bash(clodex plan:*) +--- + # /clodex-plan Run: diff --git a/install.sh b/install.sh index 82837c7..c114225 100644 --- a/install.sh +++ b/install.sh @@ -15,7 +15,7 @@ Clodex Installer v${SCRIPT_VERSION} Usage: install.sh [OPTIONS] Options: - -t, --target PATH Install plugin metadata to PATH (default: ~/.clodex) + -t, --target PATH Install the plugin payload under PATH (default: ~/.clodex) -b, --bin PATH Install clodex launcher to PATH (default: ~/.local/bin) -f, --force Overwrite existing launcher --dry-run Show actions without writing files @@ -118,31 +118,45 @@ log "Source: $SCRIPT_DIR" log "Target: $TARGET_DIR" log "Bin: $BIN_DIR" +LAUNCHER="$BIN_DIR/clodex" +PLUGIN_DIR="$TARGET_DIR/plugin" + if [[ "$DRY_RUN" == true ]]; then - log "dry-run: would install clodex launcher and plugin metadata" + log "dry-run: would install the plugin payload to $PLUGIN_DIR and the launcher to $LAUNCHER" exit 0 fi -mkdir -p "$TARGET_DIR" "$BIN_DIR" -cp -R "$SCRIPT_DIR/.claude-plugin" "$TARGET_DIR/claude-plugin" -cp -R "$SCRIPT_DIR/.codex-plugin" "$TARGET_DIR/codex-plugin" -cp -R "$SCRIPT_DIR/dist" "$TARGET_DIR/dist" -cp -R "$SCRIPT_DIR/skills/clodex-workflow" "$TARGET_DIR/clodex-workflow-skill" - -LAUNCHER="$BIN_DIR/clodex" +# Check everything that can refuse *before* writing anything, so a refusal never leaves a partial install. if [[ -e "$LAUNCHER" && "$FORCE" != true ]]; then log "launcher exists: $LAUNCHER" log "rerun with --force to overwrite" exit 1 fi +mkdir -p "$TARGET_DIR" "$BIN_DIR" + +# One self-contained directory that keeps the layout the plugin manifests refer to +# ("./dist/...", "${CLAUDE_PLUGIN_ROOT}/npm/..."). It is staged and swapped in, so re-running +# replaces it (never nests) and the install no longer depends on this checkout staying put. +PAYLOAD=(clodex .claude-plugin .codex-plugin dist skills npm package.json README.md CLODEX.md) +STAGE="$(mktemp -d "$TARGET_DIR/.plugin-stage.XXXXXX")" +trap 'rm -rf "$STAGE"' EXIT +for item in "${PAYLOAD[@]}"; do + cp -R "$SCRIPT_DIR/$item" "$STAGE/$item" +done +find "$STAGE" -name '__pycache__' -type d -prune -exec rm -rf {} + +rm -rf "$PLUGIN_DIR" +mv "$STAGE" "$PLUGIN_DIR" +trap - EXIT + cat > "$LAUNCHER" < subprocess.CompletedProcess: + """Run a command the way a host program would: no PYTHONPATH from us, stdin piped.""" + clean = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + return subprocess.run(argv, cwd=cwd, input=stdin, env={**clean, **(extra_env or {})}, capture_output=True, text=True, encoding="utf-8") + + +class LauncherTests(unittest.TestCase): + def test_command_shapes(self): + with tempfile.TemporaryDirectory() as tmp: + launcher = Path(tmp) / "clodex.js" + launcher.write_text("//", encoding="utf-8") + npm = clodex_argv("hooks", "ingest", npm_launcher=str(launcher)) + self.assertEqual(npm[1:], [str(launcher), "hooks", "ingest"]) + self.assertEqual(clodex_argv("x", npm_launcher="", python="py", installed=True), ["py", "-m", "clodex", "x"]) + source = clodex_argv("x", npm_launcher="", python="py", installed=False) + self.assertEqual((source[0], source[1], source[3]), ("py", "-c", "x")) + self.assertIn(repr(str(ROOT)), source[2]) # embedded as a Python literal + + def test_source_checkout_command_runs_without_pythonpath(self): + argv = clodex_argv("--json", "status", npm_launcher="", installed=False) + with TempRepo() as repo: + result = run_command(argv, repo) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("runs", json.loads(result.stdout)) + + def test_mcp_entry_portable_and_absolute(self): + self.assertEqual(mcp_server_entry(portable=True, windows=False, which=lambda _: None), {"command": "clodex", "args": ["mcp-server"]}) + self.assertEqual(mcp_server_entry(portable=True, windows=False, which=lambda _: "/usr/bin/clodex"), {"command": "clodex", "args": ["mcp-server"]}) + cmd = mcp_server_entry(portable=True, windows=True, which=lambda _: "C:\\npm\\clodex.CMD") + self.assertEqual(cmd, {"command": "cmd", "args": ["/c", "clodex", "mcp-server"]}) + self.assertEqual(mcp_server_entry(portable=True, windows=True, which=lambda _: "C:\\py\\clodex.exe")["command"], "clodex") + self.assertEqual(mcp_server_entry(portable=True, windows=True, which=lambda _: None)["command"], "clodex") + absolute = mcp_server_entry(portable=False) + self.assertTrue(Path(absolute["command"]).is_absolute() or absolute["command"] == "node") + self.assertEqual(absolute["args"][-1], "mcp-server") + + +class RepoRootTests(unittest.TestCase): + def test_precedence_clodex_then_claude_project_dir_then_cwd(self): + with TempRepo() as one, TempRepo() as two, TempRepo() as three: + with env(CLODEX_REPO_ROOT=str(one), CLAUDE_PROJECT_DIR=str(two)): + self.assertEqual(resolve_repo_root(), one.resolve()) + with env(CLODEX_REPO_ROOT=None, CLAUDE_PROJECT_DIR=str(two)): + self.assertEqual(resolve_repo_root(), two.resolve()) + with env(CLODEX_REPO_ROOT=None, CLAUDE_PROJECT_DIR=str(three / "nope")), mock.patch("clodex.config.Path.cwd", return_value=one): + self.assertEqual(resolve_repo_root(), one.resolve(), "a path that is not a directory is ignored") + + def test_claude_project_dir_finds_the_repo_even_from_the_claude_config_dir(self): + # User-scope MCP servers start with ~/.claude as their cwd; CLAUDE_PROJECT_DIR points at the project. + with TempRepo() as repo, tempfile.TemporaryDirectory() as claude_home: + sub = repo / "pkg" + sub.mkdir() + result = run_command([sys.executable, "-m", "clodex", "--json", "status"], Path(claude_home), extra_env={"PYTHONPATH": str(ROOT), "CLAUDE_PROJECT_DIR": str(sub), "CLODEX_REPO_ROOT": ""}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((repo / ".clodex" / "state.sqlite3").is_file()) + + +class HookConfigTests(unittest.TestCase): + def test_shape_and_event_names_match_the_claude_code_reference(self): + config = hook_config() + self.assertEqual(list(config), ["hooks"], "must be pasteable into a settings file as-is") + self.assertTrue(set(HOOK_EVENTS) <= DOCUMENTED_EVENTS, sorted(set(HOOK_EVENTS) - DOCUMENTED_EVENTS)) + for forbidden in ("WorktreeCreated", "WorktreeRemoved", "PreToolUse", "PostToolUse", "FileChanged"): + self.assertNotIn(forbidden, config["hooks"]) + for event, groups in config["hooks"].items(): + self.assertEqual(len(groups), 1) + (handler,) = groups[0]["hooks"] + self.assertEqual(handler["type"], "command") + self.assertTrue(handler["command"] and isinstance(handler["args"], list)) + self.assertEqual(handler["args"][-2:], ["hooks", "ingest"]) + self.assertIsInstance(handler["timeout"], int) + self.assertTrue(is_clodex_handler(handler)) + + def test_merge_is_idempotent_preserves_other_hooks_and_replaces_stale_entries(self): + user = { + "theme": "dark", + "hooks": { + "Stop": [{"hooks": [{"type": "command", "command": "notify-send", "args": ["done"]}]}], + "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "lint"}]}], + }, + } + once = merge_hooks(user) + self.assertEqual(once["theme"], "dark") + self.assertEqual(once["hooks"]["PreToolUse"], user["hooks"]["PreToolUse"]) + stop = once["hooks"]["Stop"] + self.assertEqual(len(stop), 2) + self.assertEqual(stop[0], user["hooks"]["Stop"][0]) + self.assertEqual(merge_hooks(once), once, "idempotent") + stale = json.loads(json.dumps(once)) + stale["hooks"]["Stop"][1]["hooks"][0]["command"] = "/old/python" + refreshed = merge_hooks(stale) + self.assertEqual(len(refreshed["hooks"]["Stop"]), 2) + self.assertEqual(refreshed["hooks"]["Stop"][1]["hooks"][0]["command"], hook_handler()["command"]) + + def test_remove_strips_only_clodex_and_drops_emptied_events(self): + user = {"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "mine"}]}]}} + removed = merge_hooks(merge_hooks(user), remove=True) + self.assertEqual(removed, user) + self.assertEqual(merge_hooks(merge_hooks({}), remove=True), {}) + + def test_malformed_settings_are_rejected(self): + with self.assertRaises(ValueError): + merge_hooks({"hooks": []}) + with self.assertRaises(ValueError): + merge_hooks({"hooks": {"Stop": {}}}) + + +class InstallHooksTests(unittest.TestCase): + def test_scopes_map_to_the_documented_settings_files(self): + with TempRepo() as repo, tempfile.TemporaryDirectory() as home, env(CLAUDE_CONFIG_DIR=None): + with mock.patch.object(Path, "home", return_value=Path(home)): + self.assertEqual(settings_path(repo, "local"), repo / ".claude" / "settings.local.json") + self.assertEqual(settings_path(repo, "project"), repo / ".claude" / "settings.json") + self.assertEqual(settings_path(repo, "user"), Path(home) / ".claude" / "settings.json") + with env(CLAUDE_CONFIG_DIR=home): + self.assertEqual(settings_path(repo, "user"), Path(home) / "settings.json") + with self.assertRaises(ValueError): + settings_path(repo, "global") + + def test_install_dry_run_create_update_unchanged_and_uninstall(self): + with TempRepo() as repo: + path = repo / ".claude" / "settings.local.json" + dry = install_hooks(repo, dry_run=True) + self.assertEqual((dry["action"], path.exists()), ("create", False)) + created = install_hooks(repo) + self.assertEqual(created["action"], "create") + data = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(sorted(data["hooks"]), sorted(HOOK_EVENTS)) + self.assertEqual(install_hooks(repo)["action"], "unchanged") + path.write_text(json.dumps({"permissions": {"allow": ["Bash(ls)"]}}), encoding="utf-8") + self.assertEqual(install_hooks(repo)["action"], "update") + self.assertEqual(json.loads(path.read_text(encoding="utf-8"))["permissions"], {"allow": ["Bash(ls)"]}) + self.assertEqual(install_hooks(repo, remove=True)["action"], "remove") + self.assertEqual(json.loads(path.read_text(encoding="utf-8")), {"permissions": {"allow": ["Bash(ls)"]}}) + self.assertEqual(install_hooks(repo, remove=True)["action"], "unchanged") + + def test_invalid_json_is_refused_unless_forced_and_crlf_is_kept(self): + with TempRepo() as repo: + path = repo / ".claude" / "settings.local.json" + path.parent.mkdir() + path.write_text("{not json", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "--force"): + install_hooks(repo) + self.assertEqual(path.read_text(encoding="utf-8"), "{not json") + install_hooks(repo, force=True) + self.assertIn("hooks", json.loads(path.read_text(encoding="utf-8"))) + path.write_bytes(b'{\r\n "theme": "dark"\r\n}\r\n') + install_hooks(repo) + raw = path.read_bytes() + self.assertIn(b"\r\n", raw) + self.assertEqual(raw.replace(b"\r\n", b"").count(b"\n"), 0, "no bare LF mixed into a CRLF file") + + def test_project_scope_warns_about_machine_specific_paths(self): + with TempRepo() as repo: + self.assertIn("machine-specific", install_hooks(repo, "project", dry_run=True)["note"]) + self.assertNotIn("note", install_hooks(repo, "local", dry_run=True)) + + +class IngestSafetyTests(unittest.TestCase): + def test_run_ids(self): + for good in ("run-1", "0f8fad5b-d9cb-469f-a165-70867728950e", "a.b_c-d", "x" * 128): + self.assertEqual(safe_run_id(good), good) + for bad in ("", ".", "..", "../x", "a/b", "a\\b", "-leading", "x" * 129, "a..b", "a b", None, 5): + with self.assertRaises(ValueError, msg=repr(bad)): + safe_run_id(bad) + + def test_derivation_order_and_untrusted_fallback(self): + self.assertEqual(derive_run_id("explicit", {"session_id": "s"}, {"CLODEX_RUN_ID": "e"}), "explicit") + self.assertEqual(derive_run_id(None, {"session_id": "s"}, {"CLODEX_RUN_ID": "e"}), "e") + self.assertEqual(derive_run_id(None, {"session_id": "s1"}, {}), "s1") + self.assertEqual(derive_run_id(None, {"session_id": "../../etc"}, {}), DEFAULT_RUN_ID, "a hostile payload must not fail or escape") + self.assertEqual(derive_run_id(None, {}, {}), DEFAULT_RUN_ID) + with self.assertRaises(ValueError): + derive_run_id("../x", {}, {}) + + def test_ingest_refuses_traversal_and_writes_under_runs_root(self): + with TempRepo() as repo: + with self.assertRaises(ValueError): + ingest_hook_event(repo, "../escape", {"a": 1}) + self.assertFalse((repo / ".clodex" / "escape").exists()) + result = ingest_hook_event(repo, "run-ok", {"hook_event_name": "Stop"}) + written = Path(result["event_file"]) + self.assertEqual(written.parent.parent, (repo / ".clodex" / "runs" / "run-ok").resolve()) + self.assertEqual(json.loads(written.read_text(encoding="utf-8").strip())["hook_event_name"], "Stop") + + def test_payload_parsing_keeps_anything_it_cannot_parse(self): + self.assertEqual(parse_hook_payload(""), {}) + self.assertEqual(parse_hook_payload('{"a": 1}'), {"a": 1}) + self.assertEqual(parse_hook_payload("not json")["raw"], "not json") + self.assertEqual(parse_hook_payload("[1, 2]"), {"value": [1, 2]}) + self.assertEqual(len(parse_hook_payload("x" * 5000)["raw"]), 2000) + + +class HookCommandTests(unittest.TestCase): + """The commands Claude Code will actually run.""" + + def run_ingest(self, repo: Path, stdin: str, *extra: str) -> subprocess.CompletedProcess: + return run_command([sys.executable, "-m", "clodex", "hooks", "ingest", *extra], repo, stdin, extra_env={"PYTHONPATH": str(ROOT)}) + + def test_success_is_silent_and_uses_the_session_id(self): + with TempRepo() as repo: + result = self.run_ingest(repo, json.dumps({"session_id": "sess-1", "hook_event_name": "SessionStart"})) + self.assertEqual((result.returncode, result.stdout), (0, ""), "stdout can reach Claude's context") + event_file = repo / ".clodex" / "runs" / "sess-1" / "events" / "claude-hooks.jsonl" + self.assertTrue(event_file.is_file()) + + def test_failures_exit_1_never_2_because_2_blocks_the_hooked_action(self): + with TempRepo() as repo: + bad_id = self.run_ingest(repo, "{}", "--run-id", "../x") + self.assertEqual(bad_id.returncode, 1) + self.assertIn("Invalid run id", bad_id.stderr) + self.assertEqual(bad_id.stdout, "") + garbage = self.run_ingest(repo, "}{ not json") + self.assertEqual((garbage.returncode, garbage.stdout), (0, "")) + recorded = (repo / ".clodex" / "runs" / DEFAULT_RUN_ID / "events" / "claude-hooks.jsonl").read_text(encoding="utf-8") + self.assertIn("not json", recorded) + + def test_verbose_prints_the_result(self): + with TempRepo() as repo: + result = self.run_ingest(repo, "{}", "--verbose", "--run-id", "v1") + self.assertEqual(result.returncode, 0) + self.assertIn("claude-hooks.jsonl", result.stdout) + + def test_configured_hook_command_works_from_a_bare_environment(self): + handler = hook_handler() + with TempRepo() as repo: + result = run_command([handler["command"], *handler["args"]], repo, json.dumps({"session_id": "bare-env", "hook_event_name": "Stop"})) + self.assertEqual((result.returncode, result.stdout), (0, ""), result.stderr) + self.assertTrue((repo / ".clodex" / "runs" / "bare-env" / "events" / "claude-hooks.jsonl").is_file()) + + def test_install_and_print_through_the_cli(self): + with TempRepo() as repo: + printed = json.loads(run_command([sys.executable, "-m", "clodex", "--json", "hooks", "print"], repo, extra_env={"PYTHONPATH": str(ROOT)}).stdout) + self.assertEqual(list(printed), ["hooks"]) + result = run_command([sys.executable, "-m", "clodex", "--json", "hooks", "install"], repo, extra_env={"PYTHONPATH": str(ROOT)}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["action"], "create") + self.assertTrue((repo / ".claude" / "settings.local.json").is_file()) + removed = run_command([sys.executable, "-m", "clodex", "--json", "hooks", "uninstall"], repo, extra_env={"PYTHONPATH": str(ROOT)}) + self.assertEqual(json.loads(removed.stdout)["action"], "remove") + + +class NativeInitTests(unittest.TestCase): + def setUp(self): + self.home_cm = tempfile.TemporaryDirectory() + self.home = Path(self.home_cm.name) + self.addCleanup(self.home_cm.cleanup) + patcher = mock.patch.object(Path, "home", return_value=self.home) + patcher.start() + self.addCleanup(patcher.stop) + env_ctx = env(CLAUDE_CONFIG_DIR=None, CODEX_HOME=None) + env_ctx.__enter__() + self.addCleanup(env_ctx.__exit__, None, None, None) + + def paths(self, **kwargs) -> set[Path]: + return {path for path, _kind, _body in native.native_targets(self.home, global_mode=True, **kwargs)} + + def test_global_mode_writes_where_the_agents_actually_look(self): + self.assertEqual( + self.paths(), + {self.home / ".claude" / "CLAUDE.md", self.home / ".codex" / "AGENTS.md", self.home / ".codex" / "config.toml"}, + ) + self.assertEqual(self.paths(no_mcp_config=True), {self.home / ".claude" / "CLAUDE.md", self.home / ".codex" / "AGENTS.md"}) + for stale in ("CLAUDE.md", "AGENTS.md", "CLODEX.md", ".mcp.json"): + self.assertNotIn(self.home / stale, self.paths()) + + def test_global_mode_honors_config_dir_overrides(self): + with env(CLAUDE_CONFIG_DIR=str(self.home / "cc"), CODEX_HOME=str(self.home / "cx")): + self.assertEqual(self.paths(), {self.home / "cc" / "CLAUDE.md", self.home / "cx" / "AGENTS.md", self.home / "cx" / "config.toml"}) + + def test_repo_mode_keeps_the_project_files(self): + with TempRepo() as repo: + targets = {path.relative_to(repo).as_posix() for path, _k, _b in native.native_targets(repo)} + self.assertEqual(targets, {"CLAUDE.md", "AGENTS.md", "CLODEX.md", ".mcp.json", ".codex/config.toml"}) + + def test_shared_repo_files_are_portable_and_user_level_files_are_absolute(self): + with TempRepo() as repo: + plan = native.plan_native_install(repo, dry_run=True) + toml = next(item for item in plan["files"] if item["path"].endswith("config.toml"))["preview"] + self.assertEqual(tomllib.loads(toml)["mcp_servers"]["clodex"], {"command": "clodex", "args": ["mcp-server"]}) + mcp = json.loads(next(item for item in plan["files"] if item["path"].endswith(".mcp.json"))["preview"]) + self.assertEqual(mcp["mcpServers"]["clodex"], {"command": "clodex", "args": ["mcp-server"]}) + glob = native.plan_native_install(self.home, dry_run=True, global_mode=True) + server = tomllib.loads(next(item for item in glob["files"] if item["path"].endswith("config.toml"))["preview"])["mcp_servers"]["clodex"] + self.assertNotEqual(server["command"], "clodex") + self.assertEqual(server["args"][-1], "mcp-server") + + def test_toml_block_escapes_windows_paths_and_validates(self): + entry = {"command": "C:\\Program Files\\nodejs\\node.exe", "args": ["C:\\clodex\\npm\\clodex.js", "mcp-server"]} + rendered = native.render_codex_toml('model = "x"\n', entry=entry) + self.assertEqual(tomllib.loads(rendered)["mcp_servers"]["clodex"], entry) + self.assertEqual(tomllib.loads(rendered)["model"], "x") + + def test_plan_lists_the_claude_registration_command_only_in_global_mode(self): + with TempRepo() as repo: + self.assertEqual(native.plan_native_install(repo, dry_run=True)["commands"], []) + plan = native.plan_native_install(self.home, dry_run=True, global_mode=True) + self.assertEqual(len(plan["commands"]), 2) + self.assertIn("claude mcp remove clodex", plan["commands"][0]) + self.assertIn("claude mcp add-json clodex", plan["commands"][1]) + self.assertEqual(native.plan_native_install(self.home, dry_run=True, global_mode=True, no_mcp_config=True)["commands"], []) + + def test_registration_removes_then_adds_and_degrades_without_the_cli(self): + calls: list[list[str]] = [] + + def runner(argv, **_kwargs): + calls.append(argv) + return subprocess.CompletedProcess(argv, 0) + + results = native.register_claude_user_mcp(runner=runner, which=lambda name: "/bin/claude") + self.assertEqual([c[:4] for c in calls], [["claude", "mcp", "remove", "clodex"], ["claude", "mcp", "add-json", "clodex"]]) + self.assertTrue(all(call[-2:] == ["--scope", "user"] for call in calls)) + payload = json.loads(calls[1][4]) + self.assertEqual(payload["type"], "stdio") + self.assertEqual(payload["args"][-1], "mcp-server") + self.assertEqual([r["returncode"] for r in results], [0, 0]) + skipped = native.register_claude_user_mcp(runner=runner, which=lambda name: None) + self.assertTrue(all("skipped" in r for r in skipped)) + + def test_apply_global_writes_files_and_registers(self): + with mock.patch.object(native, "register_claude_user_mcp", return_value=[{"command": "claude ...", "returncode": 0}]) as register: + result = native.apply_native_install(self.home, global_mode=True) + register.assert_called_once() + self.assertEqual(result["commands_run"], [{"command": "claude ...", "returncode": 0}]) + self.assertTrue((self.home / ".claude" / "CLAUDE.md").is_file()) + self.assertTrue((self.home / ".codex" / "AGENTS.md").is_file()) + self.assertTrue((self.home / ".codex" / "config.toml").is_file()) + self.assertFalse((self.home / "CLAUDE.md").exists()) + + def test_user_scope_status_reads_the_claude_json(self): + self.assertEqual(native.claude_user_mcp_status()["registered"], False) + expected = native.clodex_mcp_server_entry(portable=False) + (self.home / ".claude.json").write_text(json.dumps({"mcpServers": {"clodex": {"type": "stdio", **expected}}}), encoding="utf-8") + status = native.claude_user_mcp_status() + self.assertEqual((status["registered"], status["current"]), (True, True)) + (self.home / ".claude.json").write_text(json.dumps({"mcpServers": {"clodex": {"command": "old"}}}), encoding="utf-8") + status = native.claude_user_mcp_status() + self.assertEqual((status["registered"], status["current"]), (True, False)) + (self.home / ".claude.json").write_text("{not json", encoding="utf-8") + self.assertEqual(native.claude_user_mcp_status()["registered"], False) + + def test_dead_helpers_are_gone(self): + self.assertFalse(hasattr(native, "normalize_block_body")) + self.assertFalse(hasattr(native, "_is_toml_table_header")) + + @unittest.skipUnless(shutil.which("claude"), "needs the real claude CLI") + def test_real_claude_cli_registration_is_idempotent_and_updates_in_place(self): + # Isolated: CLAUDE_CONFIG_DIR keeps this away from the developer's real ~/.claude.json. + with env(CLAUDE_CONFIG_DIR=str(self.home)): + first = native.register_claude_user_mcp() + self.assertEqual([r["returncode"] for r in first][1], 0, first) + self.assertEqual(native.claude_user_mcp_status()["current"], True) + self.assertEqual(native.register_claude_user_mcp()[1]["returncode"], 0, "second run must succeed too") + changed = {"command": "node", "args": ["different.js"]} + with mock.patch.object(native, "clodex_mcp_server_entry", return_value=changed): + native.register_claude_user_mcp() + servers = json.loads((self.home / ".claude.json").read_text(encoding="utf-8"))["mcpServers"] + self.assertEqual(servers["clodex"]["args"], ["different.js"], "add-json alone would have kept the old value") + + +class ManifestTests(unittest.TestCase): + def test_claude_plugin_manifest_follows_the_documented_schema(self): + manifest = json.loads((ROOT / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8")) + documented = {"$schema", "name", "displayName", "version", "description", "author", "homepage", "repository", "license", "keywords", "metadata", "defaultEnabled", "dependencies", "settings", "userConfig", "channels", "skills", "commands", "agents", "hooks", "mcpServers", "lspServers", "outputStyles", "workflows", "experimental"} + self.assertTrue(set(manifest) <= documented, set(manifest) - documented) + self.assertRegex(manifest["name"], r"^[a-z0-9]+(-[a-z0-9]+)*$") + for command_path in manifest["commands"]: + self.assertTrue(command_path.startswith("./"), command_path) + self.assertTrue((ROOT / command_path).exists(), command_path) + self.assertNotIsInstance(manifest.get("skills"), dict, "skills takes paths, not an object map") + self.assertTrue((ROOT / "skills" / "clodex-workflow" / "SKILL.md").is_file(), "the default skills/ directory is scanned") + server = manifest["mcpServers"]["clodex"] + script = server["args"][0].replace("${CLAUDE_PLUGIN_ROOT}", str(ROOT)) + self.assertTrue(Path(script).is_file(), script) + + def test_codex_manifest_has_only_documented_keys(self): + manifest = json.loads((ROOT / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + for undocumented in ("mcp_servers", "skills"): + self.assertNotIn(undocumented, manifest) + self.assertEqual(manifest["name"], "clodex") + + def test_slash_commands_have_descriptions(self): + for path in sorted((ROOT / "dist" / ".claude" / "commands" / "clodex").glob("*.md")): + text = path.read_text(encoding="utf-8") + self.assertTrue(text.startswith("---\n"), path.name) + self.assertIn("\ndescription: ", text.split("---", 2)[1], path.name) + + @unittest.skipUnless(shutil.which("claude"), "needs the real claude CLI") + def test_real_claude_plugin_validate_passes_strictly(self): + result = subprocess.run(["claude", "plugin", "validate", str(ROOT), "--strict"], capture_output=True, text=True, encoding="utf-8", stdin=subprocess.DEVNULL) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("Validation passed", result.stdout) + + +@unittest.skipIf(os.name == "nt", "the installers are bash scripts for macOS/Linux") +class InstallerTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + base = Path(self.tmp.name) + self.target, self.bin = base / "target", base / "bin" + fake = base / "fakebin" + fake.mkdir() + for name in ("claude", "codex"): + (fake / name).write_text("#!/bin/sh\necho fake\n", encoding="utf-8") + (fake / name).chmod(0o755) + self.env = {**os.environ, "PATH": f"{fake}{os.pathsep}{os.environ['PATH']}"} + + def run_script(self, name: str, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["bash", str(ROOT / name), *args], env=self.env, capture_output=True, text=True, stdin=subprocess.DEVNULL, cwd=self.tmp.name) + + def tree(self) -> list[str]: + plugin = self.target / "plugin" + return sorted(str(p.relative_to(plugin)) for p in plugin.rglob("*")) + + def install(self, *extra: str) -> subprocess.CompletedProcess: + return self.run_script("install.sh", "--target", str(self.target), "--bin", str(self.bin), *extra) + + def test_install_is_self_contained_idempotent_and_never_nests(self): + first = self.install() + self.assertEqual(first.returncode, 0, first.stdout + first.stderr) + before = self.tree() + plugin = self.target / "plugin" + for expected in (".claude-plugin/plugin.json", ".codex-plugin/plugin.json", "dist/.claude/commands/clodex/clodex-plan.md", "skills/clodex-workflow/SKILL.md", "npm/clodex-mcp-server.js", "clodex/cli.py", "clodex/_vendor/yaml/__init__.py"): + self.assertTrue((plugin / expected).is_file(), expected) + self.assertFalse(any("__pycache__" in item for item in before)) + self.assertFalse((plugin / "dist" / "dist").exists()) + refused = self.install() + self.assertEqual(refused.returncode, 1) + self.assertIn("--force", refused.stdout) + self.assertEqual(self.tree(), before, "a refusal must leave the install untouched") + forced = self.install("--force") + self.assertEqual(forced.returncode, 0, forced.stdout + forced.stderr) + self.assertEqual(self.tree(), before, "re-installing replaces rather than nests") + self.assertEqual([p.name for p in self.target.iterdir()], ["plugin"], "no staging directory left behind") + + def test_installed_launcher_runs_without_the_source_checkout(self): + self.assertEqual(self.install().returncode, 0) + launcher = (self.bin / "clodex").read_text(encoding="utf-8") + self.assertIn(str(self.target / "plugin"), launcher) + self.assertNotIn(str(ROOT), launcher) + with TempRepo() as repo: + result = subprocess.run([str(self.bin / "clodex"), "--json", "status"], cwd=repo, env={k: v for k, v in self.env.items() if k != "PYTHONPATH"}, capture_output=True, text=True, stdin=subprocess.DEVNULL) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("runs", json.loads(result.stdout)) + + def test_uninstall_removes_the_payload_and_legacy_layout(self): + self.assertEqual(self.install().returncode, 0) + for legacy in ("claude-plugin", "codex-plugin", "dist", "clodex-workflow-skill"): + (self.target / legacy).mkdir() + result = self.run_script("uninstall.sh", "--yes", "--target", str(self.target), "--bin", str(self.bin)) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertFalse(self.target.exists(), "the emptied target directory is removed too") + self.assertFalse((self.bin / "clodex").exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/uninstall.sh b/uninstall.sh index 85620bb..f09e5f3 100644 --- a/uninstall.sh +++ b/uninstall.sh @@ -62,6 +62,8 @@ add_if_exists() { } add_if_exists "$BIN_DIR/clodex" +add_if_exists "$TARGET_DIR/plugin" +# Layout used by installers before 0.2.0: add_if_exists "$TARGET_DIR/claude-plugin" add_if_exists "$TARGET_DIR/codex-plugin" add_if_exists "$TARGET_DIR/dist"