"""Smriti CLI entry point. Commands for agent and programmatic use: smriti space list smriti space create [--description] [--project-root ] [--no-project-root] smriti space set-project-root smriti space delete [-y] smriti doctor smriti state [--preview] smriti current smriti fork [--branch ] smriti restore smriti compare smriti checkpoint create [--session ] [--project-root ] [--no-project-root] [--author-agent ] # reads JSON from stdin smriti checkpoint create --extract # reads markdown, LLM extracts fields smriti checkpoint create --extract --dry-run # preview extracted payload, no commit smriti checkpoint show smriti checkpoint list smriti checkpoint review smriti checkpoint delete [--cascade] [-y] smriti worktree open --agent smriti worktree list smriti worktree show smriti worktree close Multi-branch workflow: use `smriti fork ` to start a new session on a new branch, then `smriti checkpoint create --session ` to write a checkpoint on that branch, then `smriti compare ` to see how the branches diverged, and `smriti restore ` to read any checkpoint as a continuation brief. `smriti checkpoint create` auto-captures the current working directory as the checkpoint's project_root and can tag the checkpoint with an explicit `--author-agent`. Pipe freeform agent markdown to `--extract` to have the background LLM fill in decisions/assumptions/tasks/etc for you instead of hand-writing JSON; add `--dry-run` to preview first. `smriti state` shows full artifact content by default; pass `--preview` for the truncated brief. Every command supports --json for structured output. Default output is a readable markdown brief. SMRITI_API_URL env var sets the backend URL (default http://localhost:8000). """ from __future__ import annotations import argparse import json import os import subprocess import sys from typing import Any from .client import SmritiClient, SmritiError from .formatters import ( format_checkpoint, format_commit_list, format_compare_result, format_doctor, format_fork_result, format_metrics, format_project_current, format_restore_brief, format_review, format_space_list, format_state_brief, format_worktree_ahead, format_worktree_dirty, ) def _print_json(data: Any) -> None: print(json.dumps(data, indent=2, default=str)) def _fail(message: str, code: int = 1) -> None: print(message, file=sys.stderr) sys.exit(code) def _confirm(preview: str, yes_flag: bool) -> bool: """Interactive 'Type yes' if stdin is a TTY, otherwise require --yes. Destructive commands must be approved explicitly. When stdin is piped (agent / script use) we refuse without --yes; when interactive we require the full word 'yes' typed back. """ print(preview, file=sys.stderr) if yes_flag: return True if not sys.stdin.isatty(): print( "error: refusing to proceed without --yes in non-interactive mode.", file=sys.stderr, ) return False try: resp = input("Type 'yes' to confirm: ").strip().lower() except EOFError: return False return resp == "yes" EXPECTED_HEALTH_CAPABILITIES = { "claims", "structured_tasks", "task_ids", "checkpoint_notes", "branch_disposition", "freshness", "compact_state", "worktrees", "worktree_binding", } def _git_output(*args: str) -> str | None: """Return trimmed git output for diagnostics, or None outside a git repo.""" try: result = subprocess.run( ["git", *args], check=True, capture_output=True, text=True, ) except (OSError, subprocess.CalledProcessError): return None return result.stdout.strip() or None def _git_sha_matches(backend_sha: str | None, local_sha: str | None) -> bool | None: if not backend_sha or not local_sha: return None backend = backend_sha.strip() local = local_sha.strip() if not backend or not local: return None return local.startswith(backend) or backend.startswith(local) def _build_doctor_report(client: SmritiClient) -> dict: """Build a small diagnostics report without attempting repairs.""" local_sha = _git_output("rev-parse", "HEAD") local_branch = ( _git_output("branch", "--show-current") or _git_output("rev-parse", "--abbrev-ref", "HEAD") ) local_short = _git_output("rev-parse", "--short", "HEAD") report = { "api_url": client.base_url, "backend": { "reachable": False, "status": None, "git_sha": None, "capabilities": [], "error": None, }, "local": { "git_sha": local_sha, "git_sha_short": local_short, "branch": local_branch, }, "checks": { "runtime_match": "unknown", "missing_capabilities": None, }, "hints": [], } try: health = client.get_health() except SmritiError as e: report["backend"]["error"] = str(e) report["hints"].append( "Backend is not reachable; start Smriti and rerun `smriti doctor`." ) return report capabilities = sorted(health.get("capabilities") or []) backend_sha = health.get("git_sha") missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities)) match = _git_sha_matches(backend_sha, local_sha) report["backend"].update({ "reachable": True, "status": health.get("status"), "git_sha": backend_sha, "capabilities": capabilities, "error": None, }) report["checks"]["missing_capabilities"] = missing if match is True: report["checks"]["runtime_match"] = "ok" elif match is False: report["checks"]["runtime_match"] = "mismatch" report["hints"].append( "Backend git_sha differs from local HEAD; restart the backend " "after syncing code, or check out the commit the backend is running." ) else: report["checks"]["runtime_match"] = "unknown" report["hints"].append( "Could not compare backend git_sha with local HEAD." ) if health.get("status") != "ok": report["hints"].append( f"Backend status is `{health.get('status') or 'unknown'}` instead of `ok`." ) if missing: report["hints"].append( "Backend is missing capabilities expected by this CLI: " + ", ".join(missing) + ". Pull/restart the backend if you expected newer behavior." ) return report _USAGE_HINT = ( "No checkpoint JSON provided. Pipe JSON on stdin, or use --from-json .\n" "Example:\n" ' echo \'{"message":"...","summary":"..."}\' | smriti checkpoint create my-space' ) def _read_raw_content() -> str: """Read freeform content from stdin. Used by --extract mode. Fails cleanly if stdin is a tty (no content piped).""" if sys.stdin.isatty(): _fail( "No content provided for --extract. Pipe a markdown document on stdin:\n" " cat handoff.md | smriti checkpoint create my-space --extract" ) raw = sys.stdin.read() if not raw.strip(): _fail("Empty content on stdin for --extract.") return raw def _read_checkpoint_json(args: argparse.Namespace) -> dict: """Read the checkpoint JSON payload from stdin or from --from-json. Piping-first: if stdin is not a tty, read from stdin. Otherwise, require --from-json. No interactive prompt — this CLI is meant to be invoked by agents and scripts. """ if args.from_json: if args.from_json == "-": raw = sys.stdin.read() else: with open(args.from_json, "r") as f: raw = f.read() elif not sys.stdin.isatty(): raw = sys.stdin.read() else: _fail(_USAGE_HINT) return {} # unreachable if not raw.strip(): _fail(_USAGE_HINT) try: return json.loads(raw) except json.JSONDecodeError as e: _fail(f"error: invalid JSON input — {e}") return {} # unreachable # ── command handlers ───────────────────────────────────────────────────── def cmd_doctor(client: SmritiClient, args: argparse.Namespace) -> None: """Print narrow backend/runtime diagnostics.""" report = _build_doctor_report(client) if args.json: _print_json(report) else: print(format_doctor(report), end="") def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None: spaces = client.list_spaces() if args.json: _print_json(spaces) else: print(format_space_list(spaces), end="") def cmd_space_create(client: SmritiClient, args: argparse.Namespace) -> None: if args.no_project_root: project_root: str | None = None elif args.project_root: project_root = args.project_root else: project_root = os.getcwd() space = client.create_space( name=args.name, description=args.description or "", project_root=project_root, ) if args.json: _print_json(space) else: print(f"Created space: {space['name']} `{space['id']}`") if project_root is not None: print(f"Project root: {project_root}") def cmd_space_set_project_root(client: SmritiClient, args: argparse.Namespace) -> None: space = client.resolve_space(args.space) if args.here or args.path in {".", "--here"}: path = os.getcwd() elif args.path: path = args.path else: _fail("error: path is required unless --here is passed") updated = client.set_project_root(space["id"], path) if args.json: _print_json(updated) else: print(f"Set project_root for '{updated['name']}' to {updated['project_root']}") def cmd_space_delete(client: SmritiClient, args: argparse.Namespace) -> None: space = client.resolve_space(args.space) commits = client.list_commits(space["id"]) commit_count = len(commits) preview = ( f"Delete space '{space['name']}' (`{space['id']}`)?\n" f" This will permanently delete {commit_count} checkpoint(s) " f"and all sessions/turns under this space." ) if not _confirm(preview, args.yes): _fail("Cancelled.", code=0) client.delete_space(space["id"]) if args.json: _print_json( {"deleted": True, "space_id": space["id"], "commits_deleted": commit_count} ) else: print(f"Deleted space '{space['name']}' and its {commit_count} checkpoint(s).") def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None: """Print the continuation brief for a space. Default path: one round trip through the multi-branch state endpoint, which returns the main-branch brief plus up to 5 active non-main branches and a lightweight divergence signal. The extensions are elided cleanly when the project has no fork activity, so single- agent projects see output identical to the pre-build format. --main-only restores the legacy two-call path (get_head + get_commit) and produces main-branch-only output. Useful for scripts that parsed the old shape or for debugging the endpoint in isolation. """ space = client.resolve_space(args.space) if args.main_only: # Legacy path — main branch HEAD only. Two round trips. head = client.get_head(space["id"]) if not head.get("commit_id"): _print_no_checkpoints(space, args) return commit = client.get_commit(head["commit_id"]) space_state: dict | None = None else: # New default path — one round trip, multi-branch aware. since = getattr(args, "since", None) or "" state = client.get_space_state(space["id"], since=since) head = state.get("head") or {} commit = state.get("commit") or {} if not head.get("commit_id"): _print_no_checkpoints(state.get("space") or space, args) return # Strip the duplicated head/commit so `space_state` only carries # the additive payload the formatter consumes (active_branches, # active_claims, divergence). Keeps the JSON output sensible too. space_state = { "active_branches": state.get("active_branches") or [], "active_claims": state.get("active_claims") or [], "divergence": state.get("divergence"), "freshness": state.get("freshness"), } full_artifacts = not args.preview and not args.compact compact = args.compact show_stats = getattr(args, "stats", False) if args.json: payload = {"space": space, "head": head, "commit": commit} if space_state is not None: payload["active_branches"] = space_state["active_branches"] payload["active_claims"] = space_state["active_claims"] payload["divergence"] = space_state["divergence"] _print_json(payload) else: print( format_state_brief( space, head, commit, full_artifacts=full_artifacts, compact=compact, stats=show_stats, space_state=space_state, ), end="", ) def _print_no_checkpoints(space: dict, args: argparse.Namespace) -> None: """Render the empty-space message. Shared between the default and --main-only paths so both produce the same output on an empty space.""" if args.json: _print_json({"space": space, "head": {}, "commit": None}) return print(f"# {space.get('name', 'Untitled space')}") if space.get("description"): print(space["description"]) if space.get("project_root"): print(f"Project root: {space['project_root']}") print() print("No checkpoints yet. Create one with `smriti checkpoint create`.") def _current_task_item(raw: Any) -> dict: if isinstance(raw, str): return {"text": raw} if isinstance(raw, dict): return raw return {"text": str(raw)} def _current_open_tasks_by_intent(tasks: list) -> dict[str, list[dict]]: grouped: dict[str, list[dict]] = {} for raw in tasks: task = _current_task_item(raw) status = task.get("status", "open") if status and status != "open": continue intent = task.get("intent_hint") or task.get("intent_type") or "other" grouped.setdefault(intent, []).append(task) return grouped def _current_milestones(commits: list[dict], limit: int = 5) -> list[dict]: milestones: list[dict] = [] for commit in commits: metadata = commit.get("metadata") or commit.get("metadata_") or {} notes = metadata.get("notes") or [] for note in notes: if note.get("kind") != "milestone": continue milestones.append({ "commit_hash": commit.get("commit_hash"), "message": commit.get("message"), "note": note.get("text") or commit.get("message"), "author": note.get("author") or commit.get("author_agent"), "created_at": note.get("created_at") or commit.get("created_at"), }) if len(milestones) >= limit: return milestones # Lineage/current endpoints may expose only note kind summaries. When # the note text is unavailable, still surface the checkpoint as a # milestone so the current-state view does not hide the marker. note_kinds = commit.get("note_kinds") or [] if "milestone" in note_kinds: milestones.append({ "commit_hash": commit.get("commit_hash"), "message": commit.get("message"), "note": commit.get("message"), "author_agent": commit.get("author_agent"), "created_at": commit.get("created_at"), }) if len(milestones) >= limit: return milestones return milestones def _current_activity(commits: list[dict], limit: int = 5) -> list[dict]: return [ { "id": c.get("id"), "commit_hash": c.get("commit_hash"), "message": c.get("message"), "author_agent": c.get("author_agent"), "branch_name": c.get("branch_name"), "created_at": c.get("created_at"), } for c in commits[:limit] ] def _build_current_payload_from_existing(client: SmritiClient, space: dict) -> dict: """Compose the Project Current State contract from shipped endpoints. This keeps the CLI usable while the backend-owned compact endpoint rolls out in parallel. Once the endpoint is present, `cmd_current` will prefer it and skip this compatibility path. """ space_id = space["id"] state = client.get_space_state(space_id) commits = client.list_commits(space_id) try: metrics = client.get_space_metrics(space_id) except SmritiError as e: if e.status not in (404, 405): raise metrics = {} commit = state.get("commit") or {} active_work = state.get("active_claims") or [] active_branches = state.get("active_branches") or [] open_tasks = _current_open_tasks_by_intent(commit.get("tasks") or []) open_task_count = sum(len(items) for items in open_tasks.values()) milestones = _current_milestones(commits) attention: list[dict] = [] if not commit: attention.append({ "severity": "setup", "message": "No checkpoints yet; create the first checkpoint to establish project direction.", }) divergence = state.get("divergence") or {} if divergence.get("pairs"): attention.append({ "severity": "risk", "message": "Active branch divergence detected; run `smriti compare` before reconciling.", }) if active_branches: attention.append({ "severity": "branch", "message": f"{len(active_branches)} active branch(es) need disposition when resolved.", }) open_questions = commit.get("open_questions") or [] if open_questions: attention.append({ "severity": "question", "message": f"{len(open_questions)} open question(s) on the latest checkpoint.", }) if open_task_count > 0 and not active_work: attention.append({ "severity": "next", "message": f"{open_task_count} open task(s) are available with no active claim.", }) coord = metrics.get("coordination") or {} state_quality = metrics.get("state_quality") or {} branch_metrics = metrics.get("branches") or {} counts = { "checkpoints": coord.get("total_checkpoints", len(commits)), "active_claims": len(active_work), "active_branches": branch_metrics.get("active", len(active_branches)), "open_tasks": open_task_count, "milestones": state_quality.get("milestone_count", len(milestones)), "attention": len(attention), } return { "space_id": space_id, "name": space.get("name"), "description": space.get("description") or "", "current_direction": ( commit.get("objective") or commit.get("summary") or commit.get("message") or "" ), "counts": counts, "attention": attention, "active_work": active_work, "recent_milestones": milestones, "open_tasks_by_intent": open_tasks, "recent_activity": _current_activity(commits), } def cmd_current(client: SmritiClient, args: argparse.Namespace) -> None: """Print the compact Project Current State surface for a space.""" space = client.resolve_space(args.space) try: data = client.get_current_state(space["id"]) except SmritiError as e: if e.status not in (404, 405): raise data = _build_current_payload_from_existing(client, space) if args.json: _print_json(data) else: print(format_project_current(data), end="") def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> None: space = client.resolve_space(args.space) if args.extract and args.from_json: _fail("--extract and --from-json are mutually exclusive.") if args.extract: # Read freeform markdown from stdin, send to the extract endpoint, # and use the returned fields as the commit payload. No hand-written # JSON required. content = _read_raw_content() extracted = client.extract_checkpoint_content(content) # Extractor returns `title`; checkpoints store `message`. Map it. # If the LLM returned an empty title, fall back to a generic label # so the required `message` field is always populated. payload = { "message": (extracted.get("title") or "").strip() or "Extracted checkpoint", "objective": extracted.get("objective", ""), "summary": extracted.get("summary", ""), "decisions": extracted.get("decisions", []), "assumptions": extracted.get("assumptions", []), "tasks": extracted.get("tasks", []), "open_questions": extracted.get("open_questions", []), "entities": extracted.get("entities", []), "artifacts": extracted.get("artifacts", []), } else: payload = _read_checkpoint_json(args) if not isinstance(payload, dict): _fail("Checkpoint JSON must be an object, got: " + type(payload).__name__) if not payload.get("message"): _fail("Checkpoint JSON must include a 'message' field.") if args.dry_run: # Print the full payload (extracted or hand-written) as JSON and # exit without creating a checkpoint. Useful for reviewing the # extractor's output before committing. _print_json(payload) return # The V4 commit endpoint requires a session_id. Agents typically do not # have one — the CLI creates a lightweight session on demand and attaches # the checkpoint to it. With --session , attach the checkpoint to an # existing session instead (used for fork workflows where the caller # already ran `smriti fork` and wants to write a checkpoint on the new # branch). if args.session: session_id = args.session else: session = client.create_session( repo_id=space["id"], title=f"cli: {payload['message'][:80]}", ) session_id = session["id"] # project_root precedence: CLI flag > payload field > auto-capture cwd # (unless --no-project-root is passed, in which case the field stays null). if args.no_project_root: project_root: str | None = None elif args.project_root: project_root = args.project_root elif payload.get("project_root"): project_root = payload["project_root"] else: project_root = os.getcwd() # author_agent precedence: CLI flag > payload field > None (backend falls # back to session.active_provider for nothing-specified). author_agent = args.author_agent or payload.get("author_agent") commit_payload: dict = { "repo_id": space["id"], "session_id": session_id, "message": payload["message"], "summary": payload.get("summary", ""), "objective": payload.get("objective", ""), "decisions": payload.get("decisions", []), "assumptions": payload.get("assumptions", []), "tasks": payload.get("tasks", []), "open_questions": payload.get("open_questions", []), "entities": payload.get("entities", []), "artifacts": payload.get("artifacts", []), } if project_root is not None: commit_payload["project_root"] = project_root if author_agent is not None: commit_payload["author_agent"] = author_agent commit = client.create_chat_commit(commit_payload) if args.json: _print_json(commit) else: h = commit.get("commit_hash", "") print(f"Created checkpoint: `{h[:7]}` {commit.get('message', '')}") def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None: commit = client.get_commit(args.checkpoint_id) if args.json: _print_json(commit) else: print(format_checkpoint(commit, full_artifacts=args.full_artifacts), end="") def cmd_checkpoint_list(client: SmritiClient, args: argparse.Namespace) -> None: space = client.resolve_space(args.space) commits = client.list_commits(space["id"], branch=args.branch) if args.json: _print_json(commits) else: print(format_commit_list(commits), end="") def cmd_checkpoint_review(client: SmritiClient, args: argparse.Namespace) -> None: result = client.review_checkpoint(args.checkpoint_id) if args.json: _print_json(result) else: print(format_review(result), end="") def cmd_checkpoint_delete(client: SmritiClient, args: argparse.Namespace) -> None: commit = client.get_commit(args.checkpoint_id) preview = ( f"Delete checkpoint '{commit.get('message', '')}' " f"(`{commit['commit_hash'][:7]}`)?" ) if args.cascade: preview += "\n --cascade set: descendant commits and forked sessions will also be deleted." if not _confirm(preview, args.yes): _fail("Cancelled.", code=0) try: client.delete_commit(args.checkpoint_id, cascade=args.cascade) except SmritiError as e: if e.status == 409 and isinstance(e.detail, dict): deps = e.detail.get("dependents", {}) or {} lines = [f"Refusing to delete: {e.detail.get('message', str(e))}"] for c in deps.get("child_commits", []): lines.append(f" - child commit: {c['label']} ({c['id']})") for s in deps.get("forked_sessions", []): lines.append(f" - forked session: {s['label']} ({s['id']})") lines.append(" Re-run with --cascade to delete the subtree.") _fail("\n".join(lines)) raise if args.json: _print_json( { "deleted": True, "checkpoint_id": args.checkpoint_id, "cascade": args.cascade, } ) else: note = " (cascade)" if args.cascade else "" print(f"Deleted checkpoint `{commit['commit_hash'][:7]}`{note}.") def cmd_checkpoint_note(client: SmritiClient, args: argparse.Namespace) -> None: """Add a note to a checkpoint.""" result = client.add_checkpoint_note( checkpoint_id=args.checkpoint_id, text=args.text, author=args.author, kind=args.kind, ) if args.json: _print_json(result) else: kind_label = f" [{result['kind']}]" if result['kind'] != 'note' else "" print(f"Note added to checkpoint `{result['checkpoint_id'][:8]}…`{kind_label}") def cmd_fork(client: SmritiClient, args: argparse.Namespace) -> None: # Fetch the checkpoint first to derive space_id. This also gives us the # source message for the output line so the user sees what they forked. commit = client.get_commit(args.checkpoint_id) space_id = commit.get("repo_id", "") fork = client.fork_session( space_id=str(space_id), checkpoint_id=args.checkpoint_id, branch_name=args.branch or "", ) if args.json: _print_json(fork) else: print(format_fork_result(fork, commit), end="") def cmd_compare(client: SmritiClient, args: argparse.Namespace) -> None: result = client.compare_checkpoints(args.checkpoint_a, args.checkpoint_b) if args.json: _print_json(result) else: print(format_compare_result(result, full_artifacts=args.full_artifacts), end="") # ── skills subcommand handlers ────────────────────────────────────────────── def cmd_skills_list(client: SmritiClient, args: argparse.Namespace) -> None: """List available skill pack targets and the template version. Does not talk to the backend — the skill pack is a local package resource. The `client` arg is unused but kept for signature uniformity with the other command handlers. """ from .skill_pack import get_version, list_targets version = get_version() targets = list_targets() if args.json: _print_json({ "version": version, "targets": [ { "key": t.key, "display_name": t.display_name, "default_destination": str(t.default_destination), "primary_mode": t.primary_mode, "description": t.description, } for t in targets ], }) return print(f"Smriti skill pack v{version}") print() print("Available targets:") for t in targets: print(f" {t.key:<14} {t.description}") print(f" {'':14} → {t.default_destination}") print() print("Install with: smriti skills install ") def cmd_skills_show(client: SmritiClient, args: argparse.Namespace) -> None: """Print the rendered skill pack for a target to stdout. Useful for piping to a custom location: `smriti skills show codex > my-AGENTS.md`. """ from .skill_pack import render try: content = render(args.target) except ValueError as e: _fail(f"error: {e}") return print(content, end="") def cmd_skills_install(client: SmritiClient, args: argparse.Namespace) -> None: """Install the rendered skill pack for a target. Writes to the target's default destination unless --destination overrides. Refuses to overwrite an existing same-or-newer version without --force. --dry-run returns the rendered content without touching disk. """ from pathlib import Path from .skill_pack import install destination = Path(args.destination) if args.destination else None try: result = install( args.target, destination=destination, force=args.force, dry_run=args.dry_run, ) except ValueError as e: _fail(f"error: {e}") return if args.json: _print_json({ "target": result.target.key, "destination": str(result.destination), "action": result.action, "version": result.version, "previous_version": result.previous_version, }) return if result.action == "dry_run": print( f"Dry run — would write {result.destination} " f"(version {result.version}, not creating)." ) print() print(result.content, end="") return if result.action == "skipped": msg = ( f"Skipped: {result.destination} already has skill pack version " f"{result.previous_version} (template is {result.version}). " f"Use --force to overwrite." ) _fail(msg) return verb = "Overwrote" if result.action == "overwritten" else "Installed" prev = ( f" (was version {result.previous_version})" if result.previous_version else "" ) print( f"{verb} skill pack for {result.target.display_name} " f"at {result.destination} — version {result.version}{prev}" ) # ── init command handler ───────────────────────────────────────────────────── def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: """One-step agent onboarding: create space, install skill packs, configure SessionStart hook. Idempotent — safe to run twice.""" import json as _json from pathlib import Path from .skill_pack import get_version, install as install_skill, renderer space_name = args.space results: list[str] = [] next_steps: list[str] = [] # 1. Verify backend reachability. try: client.list_spaces() results.append("Backend reachable at " + client.base_url) except Exception: _fail( f"error: Cannot reach Smriti backend at {client.base_url}.\n" "Start the backend with `make dev-local` for solo/local mode, " "or `make dev-postgres` for Postgres/shared-team mode." ) return # 2. Create or connect space. try: space = client.resolve_space(space_name) results.append(f'Space "{space_name}" exists (id: {space["id"][:8]}…)') except Exception: space = client.create_space( name=space_name, description=args.description or "", ) results.append(f'Space "{space_name}" created (id: {space["id"][:8]}…)') # 3. Install Claude Code skill pack. claude_result = install_skill("claude-code") if claude_result.action == "created": results.append(f"Skill pack installed for Claude Code → {claude_result.destination}") elif claude_result.action == "overwritten": results.append(f"Skill pack upgraded for Claude Code → {claude_result.destination}") elif claude_result.action == "skipped": results.append(f"Skill pack for Claude Code is current (v{claude_result.version})") # 4. Install Codex skill pack — with safety check. agents_path = Path("AGENTS.md") codex_safe = True if agents_path.exists(): content = agents_path.read_text(encoding="utf-8") if "smriti_skill_pack_version" not in content and content.strip(): # Non-Smriti content exists — do not overwrite. codex_safe = False results.append( f"Skipped Codex skill pack — AGENTS.md has existing non-Smriti content" ) next_steps.append( "Install Codex skill pack manually (will overwrite AGENTS.md):\n" " smriti skills install codex --force\n" " git add AGENTS.md && git commit -m \"Add Smriti skill pack for Codex\"" ) if codex_safe: codex_result = install_skill("codex", destination=agents_path) if codex_result.action == "created": results.append(f"Skill pack installed for Codex → {codex_result.destination}") next_steps.append( "Commit AGENTS.md so Codex can see it:\n" " git add AGENTS.md && git commit -m \"Add Smriti skill pack for Codex\"" ) elif codex_result.action == "overwritten": results.append(f"Skill pack upgraded for Codex → {codex_result.destination}") next_steps.append( "Commit the updated AGENTS.md:\n" " git add AGENTS.md && git commit" ) elif codex_result.action == "skipped": results.append(f"Skill pack for Codex is current (v{codex_result.version})") # 5. Generate SessionStart hook. settings_path = Path(".claude/settings.json") hook_command = ( f"backend/.venv/bin/smriti state {space_name} --preview 2>/dev/null " f"|| echo 'Smriti backend not reachable. Start with: make dev-local'" ) hook_entry = { "type": "command", "command": hook_command, } target_hooks = { "SessionStart": [ {"matcher": "startup", "hooks": [hook_entry]}, {"matcher": "compact", "hooks": [hook_entry]}, {"matcher": "resume", "hooks": [hook_entry]}, ] } if settings_path.exists(): try: existing = _json.loads(settings_path.read_text(encoding="utf-8")) except (ValueError, OSError): existing = {} else: existing = {} if "hooks" in existing and "SessionStart" in existing.get("hooks", {}): results.append("SessionStart hook already configured → .claude/settings.json") else: existing.setdefault("hooks", {}) existing["hooks"]["SessionStart"] = target_hooks["SessionStart"] settings_path.parent.mkdir(parents=True, exist_ok=True) settings_path.write_text( _json.dumps(existing, indent=2) + "\n", encoding="utf-8" ) results.append("SessionStart hook configured → .claude/settings.json") # 6. MCP config reminder. next_steps.append( "Configure MCP in your host (if using Claude Code / Cursor / Windsurf):\n" ' {"mcpServers": {"smriti": {"command": "smriti-mcp", ' '"env": {"SMRITI_API_URL": "http://localhost:8000"}}}}' ) next_steps.append(f"Start working:\n smriti state {space_name}") # 7. Output. if args.json: _print_json({ "space": space_name, "results": results, "next_steps": next_steps, }) return print() for r in results: print(f" ✓ {r}") if next_steps: print() print(" Next steps:") for i, step in enumerate(next_steps, 1): print(f" {i}. {step}") print() # ── branch subcommand handlers ────────────────────────────────────────────── def cmd_branch_close(client: SmritiClient, args: argparse.Namespace) -> None: """Set the disposition of a branch (integrated, abandoned, or active).""" space = client.resolve_space(args.space) result = client.close_branch(space["id"], args.branch_name, args.disposition) if args.json: _print_json(result) else: print( f"Branch `{result['branch_name']}` marked `{result['disposition']}` " f"({result['sessions_updated']} session(s) updated)." ) # ── claim subcommand handlers ─────────────────────────────────────────────── def cmd_claim_create(client: SmritiClient, args: argparse.Namespace) -> None: """Create a work claim — declare intent before starting work.""" space = client.resolve_space(args.space) head = client.get_head(space["id"]) base_commit_id = head.get("commit_id") # auto-bind to current HEAD claim = client.create_claim( space_id=space["id"], agent=args.agent, scope=args.scope, branch_name=args.branch or "main", base_commit_id=base_commit_id, task_id=getattr(args, "task_id", None), worktree_id=getattr(args, "worktree", None), intent_type=args.intent_type, ttl_hours=args.ttl, ) if args.json: _print_json(claim) else: print( f"Claimed: [{claim['intent_type']}] \"{claim['scope']}\" " f"on `{claim['branch_name']}` by `{claim['agent']}` " f"(id: {claim['id']}, expires in {args.ttl}h)" ) def cmd_claim_done(client: SmritiClient, args: argparse.Namespace) -> None: """Mark a claim as done.""" claim = client.update_claim(args.claim_id, "done") if args.json: _print_json(claim) else: print(f"Claim `{claim['id']}` marked done.") def cmd_claim_abandon(client: SmritiClient, args: argparse.Namespace) -> None: """Mark a claim as abandoned.""" claim = client.update_claim(args.claim_id, "abandoned") if args.json: _print_json(claim) else: print(f"Claim `{claim['id']}` marked abandoned.") def cmd_claim_list(client: SmritiClient, args: argparse.Namespace) -> None: """List active claims for a space.""" space = client.resolve_space(args.space) claims = client.list_claims(space["id"], include_expired=args.all) if args.json: _print_json(claims) return if not claims: print("No active claims.") return print(f"{len(claims)} active claim(s):") for c in claims: agent = c.get("agent", "?") scope = c.get("scope", "?") intent = c.get("intent_type", "implement") branch = c.get("branch_name", "main") print(f" - `{agent}` [{intent}] on `{branch}` — {scope} (id: {c['id']})") # ── worktree subcommand handlers ──────────────────────────────────────────── def _short_id(value: str) -> str: return f"{value[:8]}…" if len(value) > 8 else value def _display_path(path: str) -> str: home = os.path.expanduser("~") if path == home: return "~" if path.startswith(home + os.sep): return "~" + path[len(home):] return path def _print_worktree_table(worktrees: list[dict]) -> None: headers = ["ID", "AGENT", "BRANCH", "DIRTY", "AHEAD", "PATH"] rows = [ [ _short_id(str(w.get("id", ""))), str(w.get("agent", "")), str(w.get("branch_name", "")), format_worktree_dirty(w), format_worktree_ahead(w), _display_path(str(w.get("path", ""))), ] for w in worktrees ] widths = [ max(len(headers[i]), *(len(row[i]) for row in rows)) for i in range(len(headers)) ] print(" ".join(headers[i].ljust(widths[i]) for i in range(len(headers)))) for row in rows: print(" ".join(row[i].ljust(widths[i]) for i in range(len(row)))) def cmd_worktree_open(client: SmritiClient, args: argparse.Namespace) -> None: """Create a git worktree for an agent and print its path.""" space = client.resolve_space(args.space) worktree = client.create_worktree( space_id=space["id"], agent=args.agent, branch_name=args.branch, base_commit_sha=args.base_commit, base_path=args.base_path, ) if args.json: _print_json(worktree) else: print(worktree["path"]) def cmd_worktree_list(client: SmritiClient, args: argparse.Namespace) -> None: """List worktrees for a space.""" space = client.resolve_space(args.space) worktrees = client.list_worktrees(space["id"], include_closed=args.include_closed) if args.json: _print_json(worktrees) return if not worktrees: print("No worktrees." if args.include_closed else "No active worktrees.") return _print_worktree_table(worktrees) def cmd_worktree_show(client: SmritiClient, args: argparse.Namespace) -> None: """Show one worktree.""" worktree = client.get_worktree(args.worktree_id) if args.json: _print_json(worktree) return print(f"id: {worktree['id']}") print(f"status: {worktree['status']}") print(f"agent: {worktree['agent']}") print(f"branch: {worktree['branch_name']}") print(f"base_commit: {worktree.get('base_commit_sha') or ''}") print(f"path: {worktree['path']}") print(f"created_at: {worktree['created_at']}") if worktree.get("closed_at"): print(f"closed_at: {worktree['closed_at']}") def cmd_worktree_close(client: SmritiClient, args: argparse.Namespace) -> None: """Close/remove a git worktree.""" worktree = client.close_worktree(args.worktree_id, force=args.force) if args.json: _print_json(worktree) else: print(f"Closed worktree `{worktree['id']}` at {worktree['path']}.") def cmd_restore(client: SmritiClient, args: argparse.Namespace) -> None: commit = client.get_commit(args.checkpoint_id) space = client.get_space(str(commit.get("repo_id", ""))) # Default to full artifacts (agent-first, matching `smriti state`). # --preview restores the old truncated behaviour; --full-artifacts is # kept as a no-op alias so existing scripts still work. full_artifacts = not args.preview if args.json: _print_json({"space": space, "commit": commit}) else: print( format_restore_brief(space, commit, full_artifacts=full_artifacts), end="", ) def cmd_metrics(client: SmritiClient, args: argparse.Namespace) -> None: """Print project-level KPIs for a space.""" space = client.resolve_space(args.space) data = client.get_space_metrics(space["id"]) if args.json: _print_json(data) else: print(format_metrics(data), end="") # ── argparse wiring ────────────────────────────────────────────────────── def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="smriti", description="Command-line access to Smriti's reasoning-state backend.", ) parser.add_argument( "--api-url", help="Smriti backend URL (default: $SMRITI_API_URL or http://localhost:8000)", ) subparsers = parser.add_subparsers(dest="command", required=True) # init — one-step agent onboarding init_parser = subparsers.add_parser( "init", help="One-step agent onboarding: create space, install skill packs, configure hook", ) init_parser.add_argument("space", help="Space name for this project") init_parser.add_argument("--description", help="Space description", default="") init_parser.add_argument("--json", action="store_true") init_parser.set_defaults(func=cmd_init) # doctor — local/runtime diagnostics doctor_parser = subparsers.add_parser( "doctor", help="Diagnose backend reachability and runtime/code freshness", ) doctor_parser.add_argument("--json", action="store_true", help="Output structured JSON") doctor_parser.set_defaults(func=cmd_doctor) # space space_parser = subparsers.add_parser("space", help="Manage Smriti spaces (projects)") space_sub = space_parser.add_subparsers(dest="subcommand", required=True) sp_list = space_sub.add_parser("list", help="List all spaces") sp_list.add_argument("--json", action="store_true", help="Output structured JSON") sp_list.set_defaults(func=cmd_space_list) sp_create = space_sub.add_parser("create", help="Create a new space") sp_create.add_argument("name", help="Space name") sp_create.add_argument("--description", help="Optional description", default="") root_group = sp_create.add_mutually_exclusive_group() root_group.add_argument( "--project-root", help="Canonical project checkout path for worktree operations " "(default: current working directory)", ) root_group.add_argument( "--no-project-root", action="store_true", help="Leave the space without a canonical project_root", ) sp_create.add_argument("--json", action="store_true", help="Output structured JSON") sp_create.set_defaults(func=cmd_space_create) sp_set_project_root = space_sub.add_parser( "set-project-root", help="Set a space's canonical project_root", ) sp_set_project_root.add_argument("space", help="Space name or UUID") sp_set_project_root.add_argument( "path", nargs="?", help="Project checkout path. Use '.' for the current directory.", ) sp_set_project_root.add_argument( "--here", action="store_true", help="Set project_root to the current directory.", ) sp_set_project_root.add_argument( "--json", action="store_true", help="Output structured JSON" ) sp_set_project_root.set_defaults(func=cmd_space_set_project_root) sp_delete = space_sub.add_parser( "delete", help="Delete a space and all its checkpoints, sessions, and turns", ) sp_delete.add_argument("space", help="Space name or UUID") sp_delete.add_argument( "-y", "--yes", action="store_true", help="Skip confirmation prompt" ) sp_delete.add_argument("--json", action="store_true", help="Output structured JSON") sp_delete.set_defaults(func=cmd_space_delete) # state state_parser = subparsers.add_parser( "state", help="Print a continuation-oriented brief of the current project state", ) state_parser.add_argument("space", help="Space name or UUID") state_parser.add_argument( "--preview", action="store_true", help="Truncate artifact content to a short preview (default: show full)", ) # Back-compat: --full-artifacts is a no-op because full is now the # default. Kept so existing scripts don't break. state_parser.add_argument( "--full-artifacts", action="store_true", help="(default) Include full artifact content. Kept for backwards " "compatibility; the default is now always full. Use --preview to " "truncate instead.", ) state_parser.add_argument( "--main-only", dest="main_only", action="store_true", help="Show only main-branch HEAD (legacy pre-V4 behaviour). " "Default: multi-branch state — main brief plus any active " "non-main branches and divergence signal.", ) state_parser.add_argument( "--compact", action="store_true", help="Omit artifact content, show labels only. Saves tokens for " "session-start injection. Full content recoverable via " "smriti checkpoint show --full-artifacts.", ) state_parser.add_argument( "--stats", action="store_true", help="Show compact-mode savings (artifact chars saved, percent reduction). " "Only meaningful with --compact.", ) state_parser.add_argument( "--since", help="Checkpoint ID to check freshness against. Shows whether HEAD " "has moved since that checkpoint and lists new checkpoints.", ) state_parser.add_argument("--json", action="store_true", help="Output structured JSON") state_parser.set_defaults(func=cmd_state) # current — compact founder/agent current-state surface current_parser = subparsers.add_parser( "current", help="Print the compact Project Current State surface for a space", ) current_parser.add_argument("space", help="Space name or UUID") current_parser.add_argument("--json", action="store_true", help="Output structured JSON") current_parser.set_defaults(func=cmd_current) # checkpoint cp_parser = subparsers.add_parser("checkpoint", help="Manage checkpoints") cp_sub = cp_parser.add_subparsers(dest="subcommand", required=True) cp_create = cp_sub.add_parser( "create", help="Create a checkpoint from JSON on stdin or --from-json ", ) cp_create.add_argument("space", help="Space name or UUID") cp_create.add_argument( "--from-json", help="Path to a JSON file with the checkpoint payload (use '-' for stdin)", ) cp_create.add_argument( "--extract", action="store_true", help="Read stdin as freeform markdown and use the LLM extractor to " "produce the checkpoint payload automatically. Mutually exclusive " "with --from-json.", ) cp_create.add_argument( "--dry-run", dest="dry_run", action="store_true", help="Print the checkpoint payload (extracted or hand-written) as " "JSON and exit without creating a checkpoint. Useful for " "reviewing the extractor's output before committing.", ) cp_create.add_argument( "--session", help="Attach the checkpoint to an existing session UUID instead of " "creating a new one (used for fork workflows: pair with `smriti fork`)", ) cp_create.add_argument( "--project-root", dest="project_root", help="Explicit working directory path to record on the checkpoint. " "Default: the CLI's current working directory.", ) cp_create.add_argument( "--no-project-root", dest="no_project_root", action="store_true", help="Do not record any project_root on the checkpoint (overrides " "the default cwd auto-capture).", ) cp_create.add_argument( "--author-agent", dest="author_agent", help="Tag the checkpoint with an explicit agent identifier " "(e.g. 'claude-code', 'codex-local'). Default: None; the " "backend falls back to the session's active provider.", ) cp_create.add_argument("--json", action="store_true", help="Output structured JSON") cp_create.set_defaults(func=cmd_checkpoint_create) cp_show = cp_sub.add_parser("show", help="Print a specific checkpoint as markdown") cp_show.add_argument("checkpoint_id", help="Checkpoint UUID") cp_show.add_argument( "--full-artifacts", action="store_true", help="Include full artifact content", ) cp_show.add_argument("--json", action="store_true", help="Output structured JSON") cp_show.set_defaults(func=cmd_checkpoint_show) cp_list = cp_sub.add_parser("list", help="List checkpoints in a space") cp_list.add_argument("space", help="Space name or UUID") cp_list.add_argument("--branch", help="Filter by branch name") cp_list.add_argument("--json", action="store_true", help="Output structured JSON") cp_list.set_defaults(func=cmd_checkpoint_list) cp_review = cp_sub.add_parser("review", help="Run consistency review on a checkpoint") cp_review.add_argument("checkpoint_id", help="Checkpoint UUID") cp_review.add_argument("--json", action="store_true", help="Output structured JSON") cp_review.set_defaults(func=cmd_checkpoint_review) cp_delete = cp_sub.add_parser( "delete", help="Delete a checkpoint. Refuses if it has children; pass --cascade to force.", ) cp_delete.add_argument("checkpoint_id", help="Checkpoint UUID") cp_delete.add_argument( "--cascade", action="store_true", help="Also delete descendant commits and forked sessions", ) cp_delete.add_argument( "-y", "--yes", action="store_true", help="Skip confirmation prompt" ) cp_delete.add_argument("--json", action="store_true", help="Output structured JSON") cp_delete.set_defaults(func=cmd_checkpoint_delete) cp_note = cp_sub.add_parser( "note", help="Add a note to a checkpoint (additive, does not modify checkpoint fields)", ) cp_note.add_argument("checkpoint_id", help="Checkpoint UUID to annotate") cp_note.add_argument("--text", required=True, help="Note text (max 2000 chars)") cp_note.add_argument("--author", default="founder", help="Author name (default: founder)") cp_note.add_argument( "--kind", default="note", choices=["note", "milestone", "noise"], help="Note kind (default: note)", ) cp_note.add_argument("--json", action="store_true") cp_note.set_defaults(func=cmd_checkpoint_note) # fork (top-level: crosses checkpoint → session) fork_parser = subparsers.add_parser( "fork", help="Fork a new session from an existing checkpoint", ) fork_parser.add_argument("checkpoint_id", help="Checkpoint UUID to fork from") fork_parser.add_argument( "--branch", help="Branch name for the new session (default: branch-YYYY-MM-DD)", ) fork_parser.add_argument("--json", action="store_true", help="Output structured JSON") fork_parser.set_defaults(func=cmd_fork) # restore (top-level: reads a specific checkpoint as a continuation brief) restore_parser = subparsers.add_parser( "restore", help="Print a continuation-oriented brief of a specific checkpoint", ) restore_parser.add_argument("checkpoint_id", help="Checkpoint UUID") restore_parser.add_argument( "--preview", action="store_true", help="Truncate artifact content to a short preview (default: show full)", ) # Back-compat: --full-artifacts is a no-op because full is now the # default. Kept so existing scripts do not break. Matches smriti state. restore_parser.add_argument( "--full-artifacts", action="store_true", help="(default) Include full artifact content. Kept for backwards " "compatibility; the default is now always full. Use --preview to " "truncate instead.", ) restore_parser.add_argument("--json", action="store_true", help="Output structured JSON") restore_parser.set_defaults(func=cmd_restore) # compare (top-level: operates on two checkpoints) compare_parser = subparsers.add_parser( "compare", help="Compare two checkpoints and show the structured diff", ) compare_parser.add_argument("checkpoint_a", help="First checkpoint UUID (side A)") compare_parser.add_argument("checkpoint_b", help="Second checkpoint UUID (side B)") compare_parser.add_argument( "--full-artifacts", action="store_true", help="Include full artifact content in the diff view", ) compare_parser.add_argument("--json", action="store_true", help="Output structured JSON") compare_parser.set_defaults(func=cmd_compare) # branch — branch lifecycle / disposition branch_parser = subparsers.add_parser( "branch", help="Manage branch lifecycle (mark branches as integrated or abandoned)", ) branch_sub = branch_parser.add_subparsers(dest="subcommand", required=True) br_close = branch_sub.add_parser( "close", help="Set the disposition of a branch (integrated, abandoned, or active)", ) br_close.add_argument("space", help="Space name or UUID") br_close.add_argument("branch_name", help="Branch name to update") br_close.add_argument( "--disposition", default="integrated", choices=["integrated", "abandoned", "active"], help="New disposition for the branch (default: integrated)", ) br_close.add_argument("--json", action="store_true") br_close.set_defaults(func=cmd_branch_close) # claim — work claims for pre-work intent visibility claim_parser = subparsers.add_parser( "claim", help="Manage work claims (pre-work intent visibility for multi-agent coordination)", ) claim_sub = claim_parser.add_subparsers(dest="subcommand", required=True) cl_create = claim_sub.add_parser("create", help="Declare intent before starting work") cl_create.add_argument("space", help="Space name or UUID") cl_create.add_argument("--agent", required=True, help="Your agent identifier (e.g. claude-code)") cl_create.add_argument("--scope", required=True, help="One sentence describing what you are about to work on") cl_create.add_argument("--branch", help="Branch name (default: main)", default=None) cl_create.add_argument( "--intent-type", dest="intent_type", default="implement", choices=["implement", "review", "investigate", "docs", "test"], help="Type of work (default: implement)", ) cl_create.add_argument("--task-id", dest="task_id", default=None, help="Optional structured task ID this claim covers") cl_create.add_argument("--worktree", dest="worktree", default=None, help="Optional worktree UUID to bind to this claim") cl_create.add_argument("--ttl", type=float, default=4.0, help="Hours until expiration (default: 4)") cl_create.add_argument("--json", action="store_true") cl_create.set_defaults(func=cmd_claim_create) cl_done = claim_sub.add_parser("done", help="Mark a claim as done") cl_done.add_argument("claim_id", help="Claim UUID") cl_done.add_argument("--json", action="store_true") cl_done.set_defaults(func=cmd_claim_done) cl_abandon = claim_sub.add_parser("abandon", help="Mark a claim as abandoned") cl_abandon.add_argument("claim_id", help="Claim UUID") cl_abandon.add_argument("--json", action="store_true") cl_abandon.set_defaults(func=cmd_claim_abandon) cl_list = claim_sub.add_parser("list", help="List active claims for a space") cl_list.add_argument("space", help="Space name or UUID") cl_list.add_argument("--all", action="store_true", help="Include expired/done/abandoned claims") cl_list.add_argument("--json", action="store_true") cl_list.set_defaults(func=cmd_claim_list) # worktree — filesystem/index isolation for agent work worktree_parser = subparsers.add_parser( "worktree", help="Manage git worktrees for isolated agent working directories", ) worktree_sub = worktree_parser.add_subparsers(dest="subcommand", required=True) wt_open = worktree_sub.add_parser("open", help="Create a worktree for an agent") wt_open.add_argument("space", help="Space name or UUID") wt_open.add_argument("--agent", required=True, help="Agent identifier (e.g. claude-code)") wt_open.add_argument("--branch", help="Branch name for the new worktree", default=None) wt_open.add_argument("--base-commit", dest="base_commit", help="Git SHA to base the worktree on", default=None) wt_open.add_argument("--base-path", dest="base_path", help="Absolute target path for the new worktree", default=None) wt_open.add_argument("--json", action="store_true") wt_open.set_defaults(func=cmd_worktree_open) wt_list = worktree_sub.add_parser( "list", help="List worktrees for a space", description=( "List worktrees for a space. DIRTY shows dirty file count when " "available. AHEAD shows +N when ahead of origin/main, -N when " "behind, 0 when even, or — when unknown." ), ) wt_list.add_argument("space", help="Space name or UUID") wt_list.add_argument("--include-closed", action="store_true", help="Include closed worktrees") wt_list.add_argument("--json", action="store_true") wt_list.set_defaults(func=cmd_worktree_list) wt_show = worktree_sub.add_parser("show", help="Show one worktree") wt_show.add_argument("worktree_id", help="Worktree UUID") wt_show.add_argument("--json", action="store_true") wt_show.set_defaults(func=cmd_worktree_show) wt_close = worktree_sub.add_parser("close", help="Close/remove a worktree") wt_close.add_argument("worktree_id", help="Worktree UUID") wt_close.add_argument("--force", action="store_true", help="Force removal even if dirty") wt_close.add_argument("--json", action="store_true") wt_close.set_defaults(func=cmd_worktree_close) # skills — install the Smriti agent skill pack into an agent host's # project directory. The skill pack teaches agents when and why to # use Smriti's tools (the load-bearing anti-pattern section is in # the template at section 5). This group does not hit the backend — # rendering is local. skills_parser = subparsers.add_parser( "skills", help="Install the Smriti agent skill pack for Claude Code / Codex", ) skills_sub = skills_parser.add_subparsers(dest="subcommand", required=True) sk_list = skills_sub.add_parser( "list", help="List available skill pack targets and the template version", ) sk_list.add_argument("--json", action="store_true", help="Output structured JSON") sk_list.set_defaults(func=cmd_skills_list) sk_show = skills_sub.add_parser( "show", help="Print the rendered skill pack for a target to stdout", ) sk_show.add_argument( "target", choices=["claude-code", "codex"], help="Which target to render", ) sk_show.set_defaults(func=cmd_skills_show) sk_install = skills_sub.add_parser( "install", help="Install the rendered skill pack to the target's destination", ) sk_install.add_argument( "target", choices=["claude-code", "codex"], help="Which target to install", ) sk_install.add_argument( "--destination", help="Override the target's default destination path " "(e.g. --destination my-AGENTS.md)", ) sk_install.add_argument( "--dry-run", dest="dry_run", action="store_true", help="Render the skill pack and print it without writing to disk", ) sk_install.add_argument( "--force", action="store_true", help="Overwrite an existing skill pack file even if its version is " "equal to or newer than the template's. Use with care.", ) sk_install.add_argument("--json", action="store_true", help="Output structured JSON") sk_install.set_defaults(func=cmd_skills_install) # ── metrics ──────────────────────────────────────────────────────── metrics_parser = subparsers.add_parser("metrics", help="Project-level KPIs for a space") metrics_parser.add_argument("space", help="Space name or UUID") metrics_parser.add_argument("--json", action="store_true", help="Output raw JSON") metrics_parser.set_defaults(func=cmd_metrics) return parser def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv) client = SmritiClient(base_url=args.api_url) try: args.func(client, args) except SmritiError as e: _fail(f"error: {e}") except (json.JSONDecodeError, ValueError) as e: _fail(f"error: invalid JSON input — {e}") except FileNotFoundError as e: _fail(f"error: file not found — {e}") except KeyboardInterrupt: return 130 return 0 if __name__ == "__main__": sys.exit(main())