Merge session-continuity: project attachment and ambient space resolution

Overnight productization sprint. Smriti now feels like persistent
infrastructure that follows the developer: attach a repo to a space once
(`smriti init`), and every session opened in that repo resolves the space
automatically from a committed `.smriti.json` — no re-passing <space>,
no re-wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Himanshu Dongre 2026-05-19 00:11:03 +05:30
commit eac1754934
6 changed files with 758 additions and 60 deletions

View file

@ -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

View file

@ -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 <space>` 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 `<space>` 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 `<space>` 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 <space> [--description "..."] # one-step agent onboarding
smriti init <space> [--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 <name> [--description "..."]
smriti space delete <space> [-y]
# <space> is optional below once the repo is attached (see Project attachment).
smriti state <space> # multi-branch continuation brief (full artifacts by default)
smriti state <space> --preview # truncate artifacts to a short preview
smriti state <space> --main-only # legacy single-HEAD path (pre-V4 behaviour)

View file

@ -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 `<space>` 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

View file

@ -7,8 +7,9 @@ Commands for agent and programmatic use:
smriti space set-project-root <space> <path>
smriti space delete <space> [-y]
smriti doctor
smriti state <space> [--preview]
smriti current <space>
smriti status
smriti state [<space>] [--preview]
smriti current [<space>]
smriti fork <checkpoint-id> [--branch <name>]
smriti restore <checkpoint-id>
smriti compare <checkpoint-a> <checkpoint-b>
@ -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 `<space>` 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 `<space>` 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 <space>`, 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 <space>")
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 <space>` 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 <space>.
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 <path>",
)
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)

View file

@ -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
- `<space>` 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: <space> 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

View file

@ -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"])