diff --git a/REPO_STRUCTURE.md b/REPO_STRUCTURE.md index b13c64b..a9b2fe3 100644 --- a/REPO_STRUCTURE.md +++ b/REPO_STRUCTURE.md @@ -114,9 +114,11 @@ smriti/ │ ├── pyproject.toml Installable as `pip install -e ./cli` │ │ → `smriti` + `smriti-mcp` on PATH │ ├── smriti_cli/ -│ │ ├── main.py argparse dispatcher: init, doctor, space, state, -│ │ │ current, checkpoint, fork, restore, compare, -│ │ │ branch, claim, worktree, skills, metrics +│ │ ├── main.py argparse dispatcher: init, doctor, status, space, +│ │ │ state, current, checkpoint, fork, restore, +│ │ │ compare, branch, claim, worktree, skills, metrics +│ │ ├── attachment.py Project attachment: read/write `.smriti.json`, +│ │ │ the durable repo ↔ space binding │ │ ├── mcp_server.py FastMCP server (21 tools, stdio transport) │ │ ├── client.py SmritiClient HTTP wrapper (includes claims/worktrees) │ │ ├── formatters.py Continuation-oriented markdown renderers diff --git a/cli/README.md b/cli/README.md index 3749e4b..2eff871 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,6 +28,39 @@ CLI. For solo use, start the backend with `make dev-local`; for Postgres/shared-team use, start it with `make dev-postgres`. The CLI and MCP server keep talking to the same HTTP API either way. +## Project attachment + +`smriti init ` attaches the current repo to a space by writing a +small `.smriti.json` at the repo root: + +```json +{ + "space": "my-project", + "api_url": "http://localhost:8000", + "attached_at": "2026-05-19T01:00:00Z" +} +``` + +Once a repo is attached, the `` argument is **optional** on +`state`, `current`, `metrics`, `checkpoint create/list`, `claim +create/list`, and `worktree open/list`. The CLI walks up from the current +directory, finds the nearest `.smriti.json`, and uses its space — so a +session opened anywhere inside the repo stays connected without +re-passing the space. An explicit `` argument always overrides the +attachment. + +`.smriti.json` is meant to be **committed**: the binding then travels with +the repo, so a fresh clone, a teammate, or a second agent (Claude, Codex) +opened in the project all attach to the same space automatically. Use +different spaces for different repos and each repo stays bound to its own. + +`api_url` in the file is only a default — an explicit `--api-url` flag or +`SMRITI_API_URL` always wins, so a committed file stays correct across +machines. + +`smriti status` reports a repo's attachment, backend health, and open +work — the quick day-two "am I still wired up?" check. + ## MCP server Run Smriti as a local MCP server so agents inside Claude Code, Cursor, or Windsurf can read and write reasoning state natively — no subprocess-shelling to the `smriti` binary. @@ -138,14 +171,16 @@ The installer is version-aware: it refuses to overwrite a destination whose inst ## Commands ``` -smriti init [--description "..."] # one-step agent onboarding +smriti init [--description "..."] # one-step agent onboarding + attach repo smriti doctor # backend/runtime diagnostics +smriti status # this repo's attachment, health, and open work smriti quickstart [--remove | --reset] # seed a demo space + guided walkthrough smriti space list smriti space create [--description "..."] smriti space delete [-y] +# is optional below once the repo is attached (see Project attachment). smriti state # multi-branch continuation brief (full artifacts by default) smriti state --preview # truncate artifacts to a short preview smriti state --main-only # legacy single-HEAD path (pre-V4 behaviour) diff --git a/cli/smriti_cli/attachment.py b/cli/smriti_cli/attachment.py new file mode 100644 index 0000000..4d2ce06 --- /dev/null +++ b/cli/smriti_cli/attachment.py @@ -0,0 +1,142 @@ +"""Project attachment — durable repo ↔ space binding. + +A project is "attached" to a Smriti space by a small `.smriti.json` file at +the repo root. Once written, every Smriti command run anywhere inside that +directory tree resolves its space from this file, so agents and humans no +longer pass `` explicitly on each call. + +The file is meant to be committed: the binding then travels with the repo, +so a fresh clone, a teammate, or a second agent opened in the project all +attach to the same space automatically. + +Schema (forward-compatible — unknown keys are preserved on rewrite): + + { + "space": "smriti-dev", # required: space name or UUID + "api_url": "http://localhost:8000", # optional default backend URL + "attached_at": "2026-05-19T01:00:00Z" # optional, informational + } + +`api_url` is only a default — an explicit `--api-url` flag or the +`SMRITI_API_URL` environment variable always wins, so a committed file with +a local-mode URL stays correct across machines. +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ATTACHMENT_FILENAME = ".smriti.json" + + +class AttachmentError(Exception): + """Raised when an attachment file exists but is malformed.""" + + +class Attachment: + """A parsed `.smriti.json` and the path it was read from.""" + + def __init__(self, path: Path, data: dict[str, Any]): + self.path = path + self.data = data + + @property + def space(self) -> str | None: + value = self.data.get("space") + return value if isinstance(value, str) and value.strip() else None + + @property + def api_url(self) -> str | None: + value = self.data.get("api_url") + return value if isinstance(value, str) and value.strip() else None + + @property + def root(self) -> Path: + """The directory the project is attached from (where the file lives).""" + return self.path.parent + + +def _candidate_dirs(start: Path) -> list[Path]: + """Return `start` and every ancestor, nearest first.""" + start = start.resolve() + return [start, *start.parents] + + +def find_attachment_file(start: str | os.PathLike[str] | None = None) -> Path | None: + """Walk up from `start` (default cwd) looking for `.smriti.json`. + + Returns the path to the nearest attachment file, or None if none is + found between `start` and the filesystem root. + """ + begin = Path(start) if start is not None else Path.cwd() + try: + dirs = _candidate_dirs(begin) + except OSError: + return None + for directory in dirs: + candidate = directory / ATTACHMENT_FILENAME + if candidate.is_file(): + return candidate + return None + + +def load_attachment(start: str | os.PathLike[str] | None = None) -> Attachment | None: + """Find and parse the nearest `.smriti.json`, or return None. + + Raises AttachmentError if a file is found but cannot be parsed or lacks + a usable `space` — a corrupt binding should be loud, not silently + ignored, since every command would otherwise fall back to "no space". + """ + path = find_attachment_file(start) + if path is None: + return None + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (ValueError, OSError) as e: + raise AttachmentError(f"Could not read attachment file {path}: {e}") + if not isinstance(raw, dict): + raise AttachmentError( + f"Attachment file {path} is not a JSON object." + ) + attachment = Attachment(path, raw) + if attachment.space is None: + raise AttachmentError( + f"Attachment file {path} is missing a non-empty \"space\" field." + ) + return attachment + + +def write_attachment( + directory: str | os.PathLike[str], + space: str, + api_url: str | None = None, +) -> Path: + """Write (or update) `.smriti.json` in `directory`, returning its path. + + Existing unknown keys are preserved so a newer file written by a future + CLI version is not clobbered by an older one. + """ + target_dir = Path(directory) + path = target_dir / ATTACHMENT_FILENAME + + data: dict[str, Any] = {} + if path.is_file(): + try: + existing = json.loads(path.read_text(encoding="utf-8")) + if isinstance(existing, dict): + data = existing + except (ValueError, OSError): + data = {} + + data["space"] = space + if api_url: + data["api_url"] = api_url + data["attached_at"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + target_dir.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return path diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index ec05803..7a25561 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -7,8 +7,9 @@ Commands for agent and programmatic use: smriti space set-project-root smriti space delete [-y] smriti doctor - smriti state [--preview] - smriti current + smriti status + smriti state [] [--preview] + smriti current [] smriti fork [--branch ] smriti restore smriti compare @@ -44,6 +45,12 @@ 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). + +`smriti init` attaches the repo to a space by writing `.smriti.json` at +the repo root. Once attached, the `` argument is optional on +state/current/metrics/checkpoint/claim/worktree commands — it resolves +from the nearest `.smriti.json` walking up from the working directory. +`smriti status` reports the attachment, backend health, and open work. """ from __future__ import annotations @@ -59,6 +66,12 @@ from importlib import metadata from pathlib import Path from typing import Any +from .attachment import ( + ATTACHMENT_FILENAME, + AttachmentError, + load_attachment, + write_attachment, +) from .client import SmritiClient, SmritiError from .formatters import ( format_checkpoint, @@ -178,6 +191,67 @@ def _build_cwd_info() -> dict: return info +def _attach_root() -> str: + """Directory to anchor the project attachment file to. + + Prefer the git repo root so `.smriti.json` lands beside the repo's other + project files and is found from any subdirectory. Fall back to the + current working directory when not inside a git repo. + """ + return _git_output("rev-parse", "--show-toplevel") or os.getcwd() + + +def _resolve_space_name(args: argparse.Namespace) -> str: + """Return the space for a command: explicit arg, else the attached space. + + Commands that take an optional `` positional call this. An + explicit argument always wins. Otherwise the project attachment + (`.smriti.json`, written by `smriti init`) supplies the space, so a + session opened anywhere in the repo stays bound to the right space + without re-passing it. + """ + explicit = getattr(args, "space", None) + if explicit: + return explicit + try: + attachment = load_attachment() + except AttachmentError as e: + _fail(f"error: {e}") + raise SystemExit(1) # unreachable; _fail exits + if attachment is not None: + space = attachment.space + if space: + return space + _fail( + f"error: no space given and no {ATTACHMENT_FILENAME} found in this " + "directory or any parent.\n" + "Attach this project once with `smriti init `, then commands " + "resolve the space automatically. Or pass a space name explicitly." + ) + raise SystemExit(1) # unreachable; _fail exits + + +def _resolve_api_url(args: argparse.Namespace) -> str | None: + """Pick the backend URL: explicit --api-url > $SMRITI_API_URL > attachment. + + Returns None when nothing project-specific applies, letting SmritiClient + fall back to the env var or its built-in default. The attachment's + api_url is only a last-resort default, so a committed `.smriti.json` + never overrides a machine-specific flag or environment variable. + """ + if getattr(args, "api_url", None): + return args.api_url + if os.environ.get("SMRITI_API_URL"): + return None + try: + attachment = load_attachment() + except AttachmentError: + return None + if attachment is not None and attachment.api_url: + return attachment.api_url + return None + + def _build_smriti_source_info() -> dict: """Detect the Smriti source checkout backing this CLI, if available. @@ -282,14 +356,20 @@ def _smriti_mcp_executable() -> str: return path_entry or "smriti-mcp" -def _build_session_start_hook_command(space_name: str, api_url: str | None = None) -> str: +def _build_session_start_hook_command() -> str: + """Build the SessionStart hook command. + + The command deliberately omits an explicit space: `smriti state + --compact` resolves the space from the project's `.smriti.json` + attachment. That keeps the hook byte-identical across every project and + stable when a project is re-attached to a different space — the binding + lives in one place (the attachment file), not duplicated into the hook + string. + """ args = [shlex.quote(_smriti_hook_executable())] - if api_url: - args.extend(["--api-url", shlex.quote(api_url)]) args.extend( [ "state", - shlex.quote(space_name), "--compact", "2>/dev/null", "||", @@ -299,10 +379,7 @@ def _build_session_start_hook_command(space_name: str, api_url: str | None = Non ), ] ) - command = " ".join( - args - ) - return command + return " ".join(args) def _is_smriti_session_start_entry(entry: Any) -> bool: @@ -520,6 +597,103 @@ def cmd_doctor(client: SmritiClient, args: argparse.Namespace) -> None: print(format_doctor(report), end="") +def cmd_status(client: SmritiClient, args: argparse.Namespace) -> None: + """Show how the current directory is connected to Smriti. + + The day-two continuity check: which space this project is attached to, + whether the backend is reachable, and what work is open — answered + without having to remember or pass the space name. + """ + try: + attachment = load_attachment() + attach_error: str | None = None + except AttachmentError as e: + attachment = None + attach_error = str(e) + + space_name = attachment.space if attachment else None + report: dict[str, Any] = { + "attached": attachment is not None, + "attachment_file": str(attachment.path) if attachment else None, + "attach_error": attach_error, + "space": space_name, + "api_url": client.base_url, + "backend_reachable": False, + "space_resolved": None, + "active_claims": None, + "head_commit_id": None, + } + + try: + client.get_health() + report["backend_reachable"] = True + except SmritiError: + report["backend_reachable"] = False + + space_obj: dict | None = None + if report["backend_reachable"] and space_name: + try: + space_obj = client.resolve_space(space_name) + except SmritiError: + space_obj = None + if space_obj is not None: + report["space_resolved"] = { + "id": space_obj["id"], + "name": space_obj["name"], + "project_root": space_obj.get("project_root"), + } + try: + report["active_claims"] = client.list_claims(space_obj["id"]) + except SmritiError: + report["active_claims"] = None + try: + head = client.get_head(space_obj["id"]) + report["head_commit_id"] = head.get("commit_id") + except SmritiError: + report["head_commit_id"] = None + + if args.json: + _print_json(report) + return + + print() + if attachment is not None: + print(f' Project attached to space "{space_name}"') + print(f" via {attachment.path}") + elif attach_error: + print(f" Attachment file is invalid: {attach_error}") + else: + print(" This project is not attached to a Smriti space.") + print(" Attach it once with: smriti init ") + print() + + state_word = "reachable" if report["backend_reachable"] else "NOT reachable" + print(f" Backend: {state_word} at {client.base_url}") + if not report["backend_reachable"]: + print(" Start it with `make dev-local` (solo) or `make dev-postgres` (team).") + elif space_name and space_obj is None: + print(f' Space "{space_name}" was not found on this backend.') + print(" Re-attach with `smriti init ` or check the backend.") + elif space_obj is not None: + print(f" Space id: {space_obj['id']}") + print(f" Space project_root: {space_obj.get('project_root') or '(not set)'}") + claims = report["active_claims"] or [] + print(f" Active claims: {len(claims)}") + for c in claims: + print( + f" - [{c.get('intent_type', '?')}] {c.get('scope', '?')} " + f"({c.get('agent', '?')})" + ) + print( + f" HEAD checkpoint: {report['head_commit_id']}" + if report["head_commit_id"] + else " HEAD checkpoint: none yet" + ) + print() + print(" Continue: smriti state · smriti current") + print() + + def cmd_space_list(client: SmritiClient, args: argparse.Namespace) -> None: spaces = client.list_spaces() if args.json: @@ -597,7 +771,7 @@ def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None: 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) + space = client.resolve_space(_resolve_space_name(args)) if args.main_only: # Legacy path — main branch HEAD only. Two round trips. @@ -819,7 +993,7 @@ def _build_current_payload_from_existing(client: SmritiClient, space: dict) -> d 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) + space = client.resolve_space(_resolve_space_name(args)) try: data = client.get_current_state(space["id"]) except SmritiError as e: @@ -834,7 +1008,7 @@ def cmd_current(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> None: - space = client.resolve_space(args.space) + space = client.resolve_space(_resolve_space_name(args)) if args.extract and args.from_json: _fail("--extract and --from-json are mutually exclusive.") @@ -939,7 +1113,7 @@ def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_checkpoint_list(client: SmritiClient, args: argparse.Namespace) -> None: - space = client.resolve_space(args.space) + space = client.resolve_space(_resolve_space_name(args)) commits = client.list_commits(space["id"], branch=args.branch) if args.json: _print_json(commits) @@ -1176,17 +1350,39 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: ) return - # 2. Create or connect space. + # 2. Create or connect space, bound to this project checkout. + attach_root = _attach_root() try: space = client.resolve_space(space_name) results.append(f'Space "{space_name}" exists (id: {space["id"][:8]}…)') + if not space.get("project_root"): + try: + space = client.set_project_root(space["id"], attach_root) + results.append(f"Bound space project_root → {attach_root}") + except SmritiError: + pass except Exception: space = client.create_space( name=space_name, description=args.description or "", + project_root=attach_root, ) results.append(f'Space "{space_name}" created (id: {space["id"][:8]}…)') + # 2b. Write the local project attachment. Every Smriti command run + # anywhere in this repo now resolves the space from this file, so + # sessions stay connected without re-passing . + attachment_path = write_attachment(attach_root, space_name, client.base_url) + try: + attachment_display = attachment_path.relative_to(Path.cwd()) + except ValueError: + attachment_display = attachment_path + results.append(f'Project attached to "{space_name}" → {attachment_display}') + next_steps.append( + f"Commit {ATTACHMENT_FILENAME} so the attachment travels with the repo:\n" + f" git add {ATTACHMENT_FILENAME} && git commit -m \"Attach project to Smriti space\"" + ) + # 3. Install Claude Code skill pack. claude_result = install_skill("claude-code") if claude_result.action == "created": @@ -1205,7 +1401,7 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: # Non-Smriti content exists — do not overwrite. codex_safe = False results.append( - f"Skipped Codex skill pack — AGENTS.md has existing non-Smriti content" + "Skipped Codex skill pack — AGENTS.md has existing non-Smriti content" ) next_steps.append( "Install Codex skill pack manually (will overwrite AGENTS.md):\n" @@ -1231,7 +1427,7 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: # 5. Generate SessionStart hook. settings_path = Path(".claude/settings.json") - hook_command = _build_session_start_hook_command(space_name, client.base_url) + hook_command = _build_session_start_hook_command() hook_entry = { "type": "command", "command": hook_command, @@ -1289,8 +1485,9 @@ def cmd_init(client: SmritiClient, args: argparse.Namespace) -> None: ) next_steps.append( "Verify activation:\n" - " smriti doctor\n" - f" smriti state {space_name} --compact" + " smriti doctor backend/runtime health\n" + " smriti status this project's attachment and open work\n" + " smriti state continuation brief (space resolved from attachment)" ) # 7. Output. @@ -1448,7 +1645,7 @@ def cmd_quickstart(client: SmritiClient, args: argparse.Namespace) -> None: 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) + space = client.resolve_space(_resolve_space_name(args)) result = client.close_branch(space["id"], args.branch_name, args.disposition) if args.json: _print_json(result) @@ -1464,7 +1661,7 @@ def cmd_branch_close(client: SmritiClient, args: argparse.Namespace) -> None: 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) + space = client.resolve_space(_resolve_space_name(args)) head = client.get_head(space["id"]) base_commit_id = head.get("commit_id") # auto-bind to current HEAD @@ -1509,7 +1706,7 @@ def cmd_claim_abandon(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_claim_list(client: SmritiClient, args: argparse.Namespace) -> None: """List active claims for a space.""" - space = client.resolve_space(args.space) + space = client.resolve_space(_resolve_space_name(args)) claims = client.list_claims(space["id"], include_expired=args.all) if args.json: _print_json(claims) @@ -1566,7 +1763,7 @@ def _print_worktree_table(worktrees: list[dict]) -> None: 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) + space = client.resolve_space(_resolve_space_name(args)) worktree = client.create_worktree( space_id=space["id"], agent=args.agent, @@ -1582,7 +1779,7 @@ def cmd_worktree_open(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_worktree_list(client: SmritiClient, args: argparse.Namespace) -> None: """List worktrees for a space.""" - space = client.resolve_space(args.space) + space = client.resolve_space(_resolve_space_name(args)) worktrees = client.list_worktrees(space["id"], include_closed=args.include_closed) if args.json: _print_json(worktrees) @@ -1637,7 +1834,7 @@ def cmd_restore(client: SmritiClient, args: argparse.Namespace) -> None: def cmd_metrics(client: SmritiClient, args: argparse.Namespace) -> None: """Print project-level KPIs for a space.""" - space = client.resolve_space(args.space) + space = client.resolve_space(_resolve_space_name(args)) data = client.get_space_metrics(space["id"]) if args.json: _print_json(data) @@ -1678,6 +1875,16 @@ def _build_parser() -> argparse.ArgumentParser: doctor_parser.add_argument("--json", action="store_true", help="Output structured JSON") doctor_parser.set_defaults(func=cmd_doctor) + # status — project attachment + day-two continuity check + status_parser = subparsers.add_parser( + "status", + help="Show this project's Smriti attachment, backend health, and open work", + ) + status_parser.add_argument( + "--json", action="store_true", help="Output structured JSON" + ) + status_parser.set_defaults(func=cmd_status) + # quickstart — seed a curated demo space so the product clicks fast quickstart_parser = subparsers.add_parser( "quickstart", @@ -1764,7 +1971,12 @@ def _build_parser() -> argparse.ArgumentParser: "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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) state_parser.add_argument( "--preview", action="store_true", @@ -1813,7 +2025,12 @@ def _build_parser() -> argparse.ArgumentParser: "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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) current_parser.add_argument("--json", action="store_true", help="Output structured JSON") current_parser.set_defaults(func=cmd_current) @@ -1825,7 +2042,12 @@ def _build_parser() -> argparse.ArgumentParser: "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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) cp_create.add_argument( "--from-json", help="Path to a JSON file with the checkpoint payload (use '-' for stdin)", @@ -1884,7 +2106,12 @@ def _build_parser() -> argparse.ArgumentParser: 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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) 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) @@ -2005,7 +2232,12 @@ def _build_parser() -> argparse.ArgumentParser: 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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) 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) @@ -2031,7 +2263,12 @@ def _build_parser() -> argparse.ArgumentParser: 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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) 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) @@ -2044,7 +2281,12 @@ def _build_parser() -> argparse.ArgumentParser: 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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) 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) @@ -2061,7 +2303,12 @@ def _build_parser() -> argparse.ArgumentParser: "behind, 0 when even, or — when unknown." ), ) - wt_list.add_argument("space", help="Space name or UUID") + wt_list.add_argument( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) 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) @@ -2137,7 +2384,12 @@ def _build_parser() -> argparse.ArgumentParser: # ── 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( + "space", + nargs="?", + default=None, + help="Space name or UUID (optional when the project is attached via .smriti.json)", + ) metrics_parser.add_argument("--json", action="store_true", help="Output raw JSON") metrics_parser.set_defaults(func=cmd_metrics) @@ -2148,7 +2400,7 @@ def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv) - client = SmritiClient(base_url=args.api_url) + client = SmritiClient(base_url=_resolve_api_url(args)) try: args.func(client, args) diff --git a/cli/tests/test_attachment.py b/cli/tests/test_attachment.py new file mode 100644 index 0000000..05438f0 --- /dev/null +++ b/cli/tests/test_attachment.py @@ -0,0 +1,255 @@ +"""Tests for project attachment — the durable repo ↔ space binding. + +Covers: +- The attachment module: find / load / write `.smriti.json` +- CLI space resolution: explicit arg vs. attachment fallback +- CLI api_url resolution precedence +- `smriti status` output, attached and unattached +- `` is optional on the parser for attachment-aware commands +""" +from __future__ import annotations + +import argparse +import json +from unittest.mock import MagicMock + +import pytest + +from smriti_cli import attachment +from smriti_cli import main as cli_main +from smriti_cli.attachment import ( + Attachment, + AttachmentError, + find_attachment_file, + load_attachment, + write_attachment, +) +from smriti_cli.client import SmritiClient, SmritiError + + +# ── attachment module ─────────────────────────────────────────────────────── + + +def test_write_then_load_roundtrip(tmp_path): + write_attachment(tmp_path, "my-space", api_url="http://localhost:8000") + loaded = load_attachment(tmp_path) + assert loaded is not None + assert loaded.space == "my-space" + assert loaded.api_url == "http://localhost:8000" + assert loaded.root == tmp_path.resolve() + + +def test_write_attachment_records_timestamp(tmp_path): + path = write_attachment(tmp_path, "s") + data = json.loads(path.read_text()) + assert "attached_at" in data + assert data["attached_at"].endswith("Z") + + +def test_write_attachment_preserves_unknown_keys(tmp_path): + path = tmp_path / attachment.ATTACHMENT_FILENAME + path.write_text(json.dumps({"space": "old", "future_key": "keep-me"})) + write_attachment(tmp_path, "new-space") + data = json.loads(path.read_text()) + assert data["space"] == "new-space" + assert data["future_key"] == "keep-me" + + +def test_find_attachment_walks_up_to_parent(tmp_path): + write_attachment(tmp_path, "root-space") + nested = tmp_path / "a" / "b" / "c" + nested.mkdir(parents=True) + found = find_attachment_file(nested) + assert found == (tmp_path / attachment.ATTACHMENT_FILENAME) + + +def test_find_attachment_returns_none_when_absent(tmp_path): + assert find_attachment_file(tmp_path) is None + + +def test_load_attachment_returns_none_when_absent(tmp_path): + assert load_attachment(tmp_path) is None + + +def test_load_attachment_rejects_malformed_json(tmp_path): + (tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{not json") + with pytest.raises(AttachmentError): + load_attachment(tmp_path) + + +def test_load_attachment_rejects_missing_space(tmp_path): + (tmp_path / attachment.ATTACHMENT_FILENAME).write_text(json.dumps({"api_url": "x"})) + with pytest.raises(AttachmentError): + load_attachment(tmp_path) + + +def test_load_attachment_rejects_non_object(tmp_path): + (tmp_path / attachment.ATTACHMENT_FILENAME).write_text(json.dumps(["not", "a", "dict"])) + with pytest.raises(AttachmentError): + load_attachment(tmp_path) + + +def test_attachment_blank_space_is_treated_as_missing(tmp_path): + att = Attachment(tmp_path / ".smriti.json", {"space": " "}) + assert att.space is None + + +# ── CLI space resolution ──────────────────────────────────────────────────── + + +def test_resolve_space_name_prefers_explicit_arg(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write_attachment(tmp_path, "attached-space") + args = argparse.Namespace(space="explicit-space") + assert cli_main._resolve_space_name(args) == "explicit-space" + + +def test_resolve_space_name_falls_back_to_attachment(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + write_attachment(tmp_path, "attached-space") + args = argparse.Namespace(space=None) + assert cli_main._resolve_space_name(args) == "attached-space" + + +def test_resolve_space_name_fails_without_arg_or_attachment(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = argparse.Namespace(space=None) + with pytest.raises(SystemExit): + cli_main._resolve_space_name(args) + + +def test_resolve_space_name_fails_loudly_on_corrupt_attachment(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{broken") + args = argparse.Namespace(space=None) + with pytest.raises(SystemExit): + cli_main._resolve_space_name(args) + + +# ── CLI api_url resolution ────────────────────────────────────────────────── + + +def test_resolve_api_url_prefers_explicit_flag(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SMRITI_API_URL", raising=False) + write_attachment(tmp_path, "s", api_url="http://attachment:9000") + args = argparse.Namespace(api_url="http://flag:1234") + assert cli_main._resolve_api_url(args) == "http://flag:1234" + + +def test_resolve_api_url_defers_to_env_var(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("SMRITI_API_URL", "http://env:5555") + write_attachment(tmp_path, "s", api_url="http://attachment:9000") + args = argparse.Namespace(api_url=None) + # Returns None so SmritiClient itself reads the env var. + assert cli_main._resolve_api_url(args) is None + + +def test_resolve_api_url_falls_back_to_attachment(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SMRITI_API_URL", raising=False) + write_attachment(tmp_path, "s", api_url="http://attachment:9000") + args = argparse.Namespace(api_url=None) + assert cli_main._resolve_api_url(args) == "http://attachment:9000" + + +def test_resolve_api_url_none_when_nothing_set(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SMRITI_API_URL", raising=False) + args = argparse.Namespace(api_url=None) + assert cli_main._resolve_api_url(args) is None + + +# ── parser: is optional for attachment-aware commands ──────────────── + + +@pytest.mark.parametrize( + "argv", + [ + ["state"], + ["current"], + ["claim", "list"], + ["worktree", "list"], + ["metrics"], + ["checkpoint", "list"], + ], +) +def test_space_positional_is_optional(argv): + args = cli_main._build_parser().parse_args(argv) + assert args.space is None + + +def test_space_positional_still_accepts_explicit_value(): + args = cli_main._build_parser().parse_args(["state", "my-space"]) + assert args.space == "my-space" + + +# ── smriti status ─────────────────────────────────────────────────────────── + + +def _status_client(reachable=True): + client = MagicMock(spec=SmritiClient) + client.base_url = "http://localhost:8000" + if reachable: + client.get_health.return_value = {"status": "ok"} + else: + client.get_health.side_effect = SmritiError("unreachable") + return client + + +def test_status_unattached(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + client = _status_client() + args = argparse.Namespace(json=True) + cli_main.cmd_status(client, args) + report = json.loads(capsys.readouterr().out) + assert report["attached"] is False + assert report["space"] is None + assert report["backend_reachable"] is True + + +def test_status_attached_reports_space_and_claims(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + write_attachment(tmp_path, "bound-space") + client = _status_client() + client.resolve_space.return_value = { + "id": "space-uuid", + "name": "bound-space", + "project_root": str(tmp_path), + } + client.list_claims.return_value = [ + {"intent_type": "implement", "scope": "do a thing", "agent": "claude-code"} + ] + client.get_head.return_value = {"commit_id": "commit-1"} + args = argparse.Namespace(json=True) + cli_main.cmd_status(client, args) + report = json.loads(capsys.readouterr().out) + assert report["attached"] is True + assert report["space"] == "bound-space" + assert report["space_resolved"]["id"] == "space-uuid" + assert len(report["active_claims"]) == 1 + assert report["head_commit_id"] == "commit-1" + + +def test_status_attached_but_backend_down(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + write_attachment(tmp_path, "bound-space") + client = _status_client(reachable=False) + args = argparse.Namespace(json=True) + cli_main.cmd_status(client, args) + report = json.loads(capsys.readouterr().out) + assert report["attached"] is True + assert report["backend_reachable"] is False + assert report["space_resolved"] is None + + +def test_status_corrupt_attachment_is_reported(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(tmp_path) + (tmp_path / attachment.ATTACHMENT_FILENAME).write_text("{broken") + client = _status_client() + args = argparse.Namespace(json=True) + cli_main.cmd_status(client, args) + report = json.loads(capsys.readouterr().out) + assert report["attached"] is False + assert report["attach_error"] is not None diff --git a/cli/tests/test_init.py b/cli/tests/test_init.py index f344069..cfaa167 100644 --- a/cli/tests/test_init.py +++ b/cli/tests/test_init.py @@ -48,22 +48,23 @@ def test_init_parser_wiring(): assert args.func is cli_main.cmd_init -def test_session_start_hook_command_shell_quotes(monkeypatch): +def test_session_start_hook_command_resolves_space_from_attachment(monkeypatch): monkeypatch.setattr( cli_main, "_smriti_hook_executable", lambda: "/Applications/Smriti Tools/bin/smriti", ) - command = cli_main._build_session_start_hook_command( - "my project", - "http://localhost:8000", - ) + command = cli_main._build_session_start_hook_command() + # No explicit space and no --api-url: the hook resolves both from the + # project's .smriti.json attachment, so it is byte-identical across + # every project and survives re-attachment to a different space. assert command.startswith( - "'/Applications/Smriti Tools/bin/smriti' --api-url " - "http://localhost:8000 state 'my project' --compact" + "'/Applications/Smriti Tools/bin/smriti' state --compact" ) + assert " state " in command # the smriti-hook detector relies on this + assert "--api-url" not in command assert "backend/.venv/bin/smriti" not in command @@ -87,6 +88,7 @@ def test_smriti_session_start_detector_handles_quoted_executable(): def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch): """Init with a fresh project: creates space, installs both skill packs.""" monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli_main, "_attach_root", lambda: str(tmp_path)) # Space doesn't exist — resolve fails, create succeeds from smriti_cli.client import SmritiError @@ -100,8 +102,14 @@ def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch): args.api_url = None cli_main.cmd_init(mock_client, args) - # Space was created - mock_client.create_space.assert_called_once_with(name="test-project", description="") + # Space was created, bound to the project checkout + mock_client.create_space.assert_called_once_with( + name="test-project", description="", project_root=str(tmp_path) + ) + + # Project attachment file was written + attachment = json.loads((tmp_path / ".smriti.json").read_text()) + assert attachment["space"] == "test-project" # Claude skill pack was installed assert (tmp_path / ".claude" / "skills" / "smriti" / "SKILL.md").exists() @@ -110,14 +118,11 @@ def test_init_creates_space_and_skill_packs(mock_client, tmp_path, monkeypatch): assert (tmp_path / "AGENTS.md").exists() assert "smriti_skill_pack_version" in (tmp_path / "AGENTS.md").read_text() - # SessionStart hook was generated + # SessionStart hook was generated — space-less, resolves from attachment settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) assert "SessionStart" in settings.get("hooks", {}) command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] - assert command.startswith( - "/opt/smriti/bin/smriti --api-url " - "http://localhost:8000 state test-project --compact" - ) + assert command.startswith("/opt/smriti/bin/smriti state --compact") assert "backend/.venv/bin/smriti" not in command assert "--preview" not in command @@ -238,13 +243,12 @@ def test_init_updates_stale_smriti_session_start_hook( ] assert len(commands) == 3 assert all( - command.startswith( - "/opt/smriti/bin/smriti --api-url http://localhost:8000 state p --compact" - ) + command.startswith("/opt/smriti/bin/smriti state --compact") for command in commands ) assert all("backend/.venv/bin/smriti" not in command for command in commands) assert all("--preview" not in command for command in commands) + assert all(" p " not in command for command in commands) def test_init_preserves_unrelated_session_start_hooks( @@ -273,16 +277,21 @@ def test_init_preserves_unrelated_session_start_hooks( assert len(entries) == 4 assert any( entry["hooks"][0]["command"].startswith( - "/opt/smriti/bin/smriti --api-url http://localhost:8000 state p --compact" + "/opt/smriti/bin/smriti state --compact" ) for entry in entries[1:] ) -def test_init_json_preserves_api_url_for_hooks_and_mcp( +def test_init_json_preserves_api_url_for_attachment_and_mcp( mock_client, tmp_path, monkeypatch, capsys ): - """Custom backend URLs should survive generated hooks and MCP hints.""" + """Custom backend URLs should survive into the attachment and MCP hint. + + The SessionStart hook no longer carries --api-url — the URL lives in + `.smriti.json` instead, and the CLI resolves it from there. This keeps + the hook identical across projects while still honouring a custom URL. + """ monkeypatch.chdir(tmp_path) mock_client.base_url = "http://127.0.0.1:8999" mock_client.resolve_space.return_value = {"id": "uuid", "name": "p"} @@ -299,8 +308,11 @@ def test_init_json_preserves_api_url_for_hooks_and_mcp( payload = json.loads(capsys.readouterr().out) settings = json.loads((tmp_path / ".claude" / "settings.json").read_text()) command = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + attachment = json.loads((tmp_path / ".smriti.json").read_text()) - assert "--api-url http://127.0.0.1:8999" in command + # The hook is space-less and URL-less; the URL lives in the attachment. + assert "--api-url" not in command + assert attachment["api_url"] == "http://127.0.0.1:8999" assert any("/opt/smriti/bin/smriti-mcp" in step for step in payload["next_steps"]) assert any("http://127.0.0.1:8999" in step for step in payload["next_steps"])