mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-09-09 22:31:07 +00:00
1760 lines
65 KiB
Python
1760 lines
65 KiB
Python
"""Smriti CLI entry point.
|
|
|
|
Commands for agent and programmatic use:
|
|
|
|
smriti space list
|
|
smriti space create <name> [--description] [--project-root <path>] [--no-project-root]
|
|
smriti space set-project-root <space> <path>
|
|
smriti space delete <space> [-y]
|
|
smriti doctor
|
|
smriti state <space> [--preview]
|
|
smriti current <space>
|
|
smriti fork <checkpoint-id> [--branch <name>]
|
|
smriti restore <checkpoint-id>
|
|
smriti compare <checkpoint-a> <checkpoint-b>
|
|
smriti checkpoint create <space> [--session <id>]
|
|
[--project-root <path>] [--no-project-root]
|
|
[--author-agent <name>] # reads JSON from stdin
|
|
smriti checkpoint create <space> --extract # reads markdown, LLM extracts fields
|
|
smriti checkpoint create <space> --extract --dry-run # preview extracted payload, no commit
|
|
smriti checkpoint show <checkpoint-id>
|
|
smriti checkpoint list <space>
|
|
smriti checkpoint review <checkpoint-id>
|
|
smriti checkpoint delete <checkpoint-id> [--cascade] [-y]
|
|
smriti worktree open <space> --agent <id>
|
|
smriti worktree list <space>
|
|
smriti worktree show <worktree-id>
|
|
smriti worktree close <worktree-id>
|
|
|
|
Multi-branch workflow: use `smriti fork <checkpoint>` to start a new
|
|
session on a new branch, then `smriti checkpoint create <space> --session
|
|
<fork-session-id>` to write a checkpoint on that branch, then `smriti
|
|
compare <a> <b>` to see how the branches diverged, and `smriti restore
|
|
<checkpoint>` 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 <path>.\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 <id>, 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 <target>")
|
|
|
|
|
|
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 <id> --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 <path>",
|
|
)
|
|
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())
|