mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
A fresh Smriti install with no provider configured could still run `smriti checkpoint create --extract`, which silently returned MockAdapter content like "Mock decision from provider". If committed, that placeholder text became part of the user's real reasoning state. Root cause: backend/app/api/routes/checkpoint.py:417 called `get_adapter(cfg.background.provider, allow_mock=True)`. The `allow_mock=True` flag means the registry quietly returns MockAdapter when no API key is configured, rather than raising. The CLI received the canned mock JSON and committed it without inspecting whether it came from a real LLM. The extract endpoint was the only route in the codebase with this pattern — draft, review, chat title, and chat send already correctly pass `allow_mock=False`. The new contract: - Core Smriti (setup, doctor, quickstart, state/current/metrics, claims, attach, manual JSON checkpoints) requires no API key. - Real LLM-backed paths (`--extract`, draft, review, chat send) require a configured provider — OpenAI / Anthropic / OpenRouter / generic OpenAI-compatible (local models like Ollama). - Mock extraction still works for tests and demos, but only when the caller explicitly opts in (use_mock=true on the HTTP payload). It is never silently the default. Backend: - POST /api/v5/checkpoint/extract now passes allow_mock=False and catches ProviderNotConfiguredError, returning HTTP 412 with a structured detail: error code, human message, the provider it tried, and a list of fix paths the CLI surfaces. - CheckpointExtractResponse gains `provider` and `model` echo fields (additive, default empty) so callers can confirm what answered. CLI: - checkpoint create --extract catches 412 and prints the actionable fix list; exits 78 (EX_CONFIG). Defense in depth: even on a 200, refuses to commit if response.provider == "mock" on the default path (so any future regression in the backend is still caught). - smriti doctor surfaces background provider state prominently: `ready (real LLM extraction enabled)` or `⚠ MOCK or DISABLED — … will fail until a provider is configured`. - smriti doctor --strict exits 78 when the background provider is mock/disabled or the backend is unreachable. Safe to wire into CI before any --extract step. - On a successful --extract commit, the CLI shows `extracted via <provider>/<model>` under the commit confirmation. Docs: - README: new "Provider configuration (LLM-backed features)" section drawing the boundary explicitly; mentions the generic provider for local OpenAI-compatible models; flags mock as test-only. - .env.example: rewrote the provider section so an empty key or a model-without-a-key is not interpreted as "ready". - Skill pack template: new §4.1 "Before your first --extract: verify the provider" telling agents to run `smriti doctor`, refuse --extract when background_provider is mock/disabled, and fall back to manual JSON checkpoints or ask the human to configure a provider. Re-rendered to AGENTS.md (Codex target). The Claude Code target (.claude/skills/smriti/SKILL.md) is gitignored per-user install; rerun `smriti skills install claude-code` to refresh. - website/index.html: Try-it lede now spells out which features need a provider rather than gesturing at "optional LLM features". Tests: - test_extract_without_provider_fails_loud: regression for the bug — monkeypatches get_adapter to raise ProviderNotConfiguredError, asserts HTTP 412 with the structured detail shape, and asserts the response body contains neither "Mock decision from provider" nor "Mock Checkpoint". This test would fail on pre-fix code. - test_extract_with_provider_echoes_provider_and_model: pins the green path — provider and model must be echoed and must not be "mock" when the real adapter answers. - test_extract_happy_path_with_mock: unchanged, still pins the explicit use_mock=true contract. - Full backend integration suite: 165 passed locally (with the pre-existing real-provider draft test passing under backend/config/providers.yaml).
2706 lines
100 KiB
Python
2706 lines
100 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 shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from importlib import metadata
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import attachment
|
|
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 _fail_provider_not_configured(detail: dict, code: int = 78) -> None:
|
|
"""Render the backend's structured 'provider_not_configured' error as a
|
|
clear, actionable message and exit non-zero.
|
|
|
|
Exit code 78 = EX_CONFIG (configuration error) so scripts can distinguish
|
|
a missing-provider failure from a generic CLI error.
|
|
"""
|
|
provider = detail.get("provider") or "background"
|
|
msg = detail.get("message") or (
|
|
f"Background LLM provider '{provider}' is not configured."
|
|
)
|
|
fix = detail.get("fix") or []
|
|
lines: list[str] = []
|
|
lines.append("error: " + msg)
|
|
lines.append("")
|
|
lines.append("To fix this, do one of:")
|
|
for item in fix:
|
|
lines.append(f" - {item}")
|
|
lines.append("")
|
|
lines.append("Run `smriti doctor` to confirm provider status after configuring.")
|
|
_fail("\n".join(lines), code=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",
|
|
"activation_health",
|
|
}
|
|
|
|
|
|
def _git_output_at(cwd: str | os.PathLike[str] | None, *args: str) -> str | None:
|
|
"""Return trimmed git output for diagnostics, or None outside a git repo."""
|
|
cmd = ["git", *args]
|
|
if cwd is not None:
|
|
cmd = ["git", "-C", str(cwd), *args]
|
|
try:
|
|
result = subprocess.run(
|
|
cmd,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return None
|
|
return result.stdout.strip() or None
|
|
|
|
|
|
def _git_output(*args: str) -> str | None:
|
|
"""Return trimmed git output for the current directory."""
|
|
return _git_output_at(None, *args)
|
|
|
|
|
|
def _git_context(cwd: str | os.PathLike[str] | None = None) -> dict:
|
|
root = _git_output_at(cwd, "rev-parse", "--show-toplevel")
|
|
sha = _git_output_at(cwd, "rev-parse", "HEAD")
|
|
short = _git_output_at(cwd, "rev-parse", "--short", "HEAD")
|
|
branch = (
|
|
_git_output_at(cwd, "branch", "--show-current")
|
|
or _git_output_at(cwd, "rev-parse", "--abbrev-ref", "HEAD")
|
|
)
|
|
return {
|
|
"git_root": root,
|
|
"git_sha": sha,
|
|
"git_sha_short": short,
|
|
"branch": branch,
|
|
}
|
|
|
|
|
|
def _checkpoint_repo_state() -> dict:
|
|
"""Git HEAD/branch of the current repo, recorded on a checkpoint so later
|
|
`smriti state` runs can detect how far the repo has drifted since it.
|
|
|
|
Empty when not inside a git repo (or for clients with no repo to inspect).
|
|
"""
|
|
info = _git_context()
|
|
head = info.get("git_sha")
|
|
if not head:
|
|
return {}
|
|
branch = info.get("branch")
|
|
return {
|
|
"head": head,
|
|
"head_short": info.get("git_sha_short"),
|
|
"branch": None if branch == "HEAD" else branch,
|
|
}
|
|
|
|
|
|
def _git_porcelain_count(cwd: str | os.PathLike[str] | None, prefix: str) -> int:
|
|
out = _git_output_at(cwd, "status", "--porcelain")
|
|
if not out:
|
|
return 0
|
|
return sum(1 for line in out.splitlines() if line.startswith(prefix))
|
|
|
|
|
|
def _git_dirty_count(cwd: str | os.PathLike[str] | None) -> int:
|
|
out = _git_output_at(cwd, "status", "--porcelain")
|
|
if not out:
|
|
return 0
|
|
return sum(1 for line in out.splitlines() if not line.startswith("??"))
|
|
|
|
|
|
def _git_ahead_behind(cwd: str | os.PathLike[str] | None) -> tuple[int | None, int | None]:
|
|
out = _git_output_at(cwd, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
|
|
if not out:
|
|
return None, None
|
|
parts = out.split()
|
|
if len(parts) != 2:
|
|
return None, None
|
|
try:
|
|
# With "@{upstream}...HEAD", the left side is commits only on upstream
|
|
# (local is behind) and the right side is commits only on HEAD (local is ahead).
|
|
behind = int(parts[0])
|
|
ahead = int(parts[1])
|
|
except ValueError:
|
|
return None, None
|
|
return ahead, behind
|
|
|
|
|
|
def _paths_same(a: str | None, b: str | None) -> bool | None:
|
|
if not a or not b:
|
|
return None
|
|
try:
|
|
return Path(a).expanduser().resolve() == Path(b).expanduser().resolve()
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _git_rev_count(cwd: str | os.PathLike[str] | None, rev_range: str) -> int | None:
|
|
"""Count commits in a local git range (e.g. "A..B"). None when the range
|
|
cannot be resolved — typically because one side is not in this repo."""
|
|
out = _git_output_at(cwd, "rev-list", "--count", rev_range)
|
|
if out is None:
|
|
return None
|
|
try:
|
|
return int(out)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _compare_to_checkpoint(
|
|
commit: dict | None,
|
|
git_root: str,
|
|
current_head: str | None,
|
|
current_branch: str | None,
|
|
) -> dict | None:
|
|
"""Compare the working repo against the git HEAD/branch the latest
|
|
checkpoint recorded.
|
|
|
|
Returns a render-ready dict plus drift signals, or None when the checkpoint
|
|
carries no recorded git state — checkpoints created before this feature, or
|
|
by the MCP server, have none.
|
|
"""
|
|
recorded = ((commit or {}).get("context_blob") or {}).get("repo_state") or {}
|
|
ckpt_head = recorded.get("head")
|
|
if not ckpt_head:
|
|
return None
|
|
|
|
ckpt_branch = recorded.get("branch")
|
|
branch_changed = bool(
|
|
ckpt_branch and current_branch and ckpt_branch != current_branch
|
|
)
|
|
|
|
ahead: int | None = 0
|
|
behind: int | None = 0
|
|
if current_head and ckpt_head == current_head:
|
|
relation = "in_sync"
|
|
else:
|
|
ahead = _git_rev_count(git_root, f"{ckpt_head}..HEAD")
|
|
behind = _git_rev_count(git_root, f"HEAD..{ckpt_head}")
|
|
if ahead is None or behind is None:
|
|
relation = "unknown" # checkpoint commit not in this repo's history
|
|
elif ahead and behind:
|
|
relation = "diverged"
|
|
elif ahead:
|
|
relation = "ahead"
|
|
elif behind:
|
|
relation = "behind"
|
|
else:
|
|
relation = "in_sync"
|
|
|
|
signals: list[dict[str, str]] = []
|
|
if relation == "ahead":
|
|
signals.append({
|
|
"kind": "ahead_of_checkpoint",
|
|
"message": (
|
|
f"repo is {ahead} commit(s) ahead of the last checkpoint — "
|
|
"recorded state may be stale"
|
|
),
|
|
"severity": "attention",
|
|
})
|
|
elif relation == "diverged":
|
|
signals.append({
|
|
"kind": "diverged_from_checkpoint",
|
|
"message": (
|
|
"repo history has diverged from the last checkpoint — "
|
|
"recorded state may be stale"
|
|
),
|
|
"severity": "attention",
|
|
})
|
|
elif relation == "unknown":
|
|
signals.append({
|
|
"kind": "checkpoint_commit_missing",
|
|
"message": "the last checkpoint's commit is not in this repo's history",
|
|
"severity": "attention",
|
|
})
|
|
if branch_changed:
|
|
signals.append({
|
|
"kind": "checkpoint_branch_changed",
|
|
"message": (
|
|
f"the last checkpoint was taken on a different branch (`{ckpt_branch}`)"
|
|
),
|
|
"severity": "attention",
|
|
})
|
|
|
|
return {
|
|
"head_short": recorded.get("head_short") or ckpt_head[:7],
|
|
"branch": ckpt_branch,
|
|
"relation": relation,
|
|
"ahead": ahead,
|
|
"behind": behind,
|
|
"branch_changed": branch_changed,
|
|
"signals": signals,
|
|
}
|
|
|
|
|
|
def _build_repo_state(space: dict, commit: dict | None = None) -> dict | None:
|
|
"""Inspect the caller's local git repo for state/drift rendering.
|
|
|
|
This is intentionally read-only and cheap: no fetch, no network, no merge
|
|
base search beyond local refs. Remote freshness is represented only by the
|
|
current upstream ahead/behind counters already present in the local clone.
|
|
When the latest checkpoint recorded its git state, the result also carries
|
|
a checkpoint-relative comparison (see `_compare_to_checkpoint`).
|
|
"""
|
|
info = _git_context()
|
|
git_root = info.get("git_root")
|
|
if not git_root:
|
|
return None
|
|
|
|
ahead, behind = _git_ahead_behind(git_root)
|
|
branch = info.get("branch")
|
|
detached = branch == "HEAD"
|
|
canonical_root = space.get("project_root")
|
|
root_matches = _paths_same(git_root, canonical_root)
|
|
|
|
dirty = _git_dirty_count(git_root)
|
|
untracked = _git_porcelain_count(git_root, "??")
|
|
|
|
signals: list[dict[str, str]] = []
|
|
if root_matches is False:
|
|
signals.append({
|
|
"kind": "project_root_mismatch",
|
|
"message": "current git root differs from this space's project_root",
|
|
"severity": "attention",
|
|
})
|
|
if dirty:
|
|
signals.append({
|
|
"kind": "dirty_worktree",
|
|
"message": f"{dirty} tracked file(s) have uncommitted changes",
|
|
"severity": "attention",
|
|
})
|
|
if untracked:
|
|
signals.append({
|
|
"kind": "untracked_files",
|
|
"message": f"{untracked} untracked file(s) present",
|
|
"severity": "attention",
|
|
})
|
|
if detached:
|
|
signals.append({
|
|
"kind": "detached_head",
|
|
"message": "repository is on a detached HEAD",
|
|
"severity": "attention",
|
|
})
|
|
if behind:
|
|
signals.append({
|
|
"kind": "behind_upstream",
|
|
"message": f"local branch is {behind} commit(s) behind upstream",
|
|
"severity": "attention",
|
|
})
|
|
if ahead:
|
|
signals.append({
|
|
"kind": "ahead_upstream",
|
|
"message": f"local branch is {ahead} commit(s) ahead of upstream",
|
|
"severity": "info",
|
|
})
|
|
|
|
# Checkpoint-relative drift: how far the working repo has moved since the
|
|
# latest checkpoint recorded its git HEAD/branch.
|
|
checkpoint = _compare_to_checkpoint(
|
|
commit, git_root, info.get("git_sha"), None if detached else branch
|
|
)
|
|
if checkpoint:
|
|
signals.extend(checkpoint["signals"])
|
|
|
|
return {
|
|
"git_root": git_root,
|
|
"branch": None if detached else branch,
|
|
"detached": detached,
|
|
"head": info.get("git_sha"),
|
|
"head_short": info.get("git_sha_short"),
|
|
"dirty_files": dirty,
|
|
"untracked_files": untracked,
|
|
"ahead": ahead,
|
|
"behind": behind,
|
|
"upstream_known": ahead is not None and behind is not None,
|
|
"project_root": canonical_root,
|
|
"project_root_matches": root_matches,
|
|
"checkpoint": checkpoint,
|
|
"signals": signals,
|
|
}
|
|
|
|
|
|
def _is_smriti_source_root(path: str | None) -> bool:
|
|
if not path:
|
|
return False
|
|
root = Path(path)
|
|
return (
|
|
(root / "cli" / "smriti_cli" / "main.py").exists()
|
|
and (root / "backend" / "app" / "main.py").exists()
|
|
)
|
|
|
|
|
|
def _build_cwd_info() -> dict:
|
|
info = _git_context()
|
|
info["path"] = os.getcwd()
|
|
info["is_smriti_source"] = _is_smriti_source_root(info.get("git_root"))
|
|
return info
|
|
|
|
|
|
def _build_smriti_source_info() -> dict:
|
|
"""Detect the Smriti source checkout backing this CLI, if available.
|
|
|
|
`smriti doctor` is often run from a user's project repo. Runtime freshness
|
|
must compare the backend to Smriti's own source checkout, not the caller's
|
|
app repo. Editable installs have `__file__` inside the Smriti checkout; a
|
|
wheel/global install may not, in which case the git comparison is simply
|
|
unavailable rather than a mismatch.
|
|
"""
|
|
module_dir = Path(__file__).resolve().parent
|
|
info = _git_context(module_dir)
|
|
info["path"] = str(module_dir)
|
|
info["is_smriti_source"] = _is_smriti_source_root(info.get("git_root"))
|
|
if not info["is_smriti_source"]:
|
|
info["git_root"] = None
|
|
info["git_sha"] = None
|
|
info["git_sha_short"] = None
|
|
info["branch"] = None
|
|
return info
|
|
|
|
|
|
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 _resolve_executable_path(value: str | None) -> str | None:
|
|
"""Resolve a command path for diagnostics without requiring it to exist."""
|
|
if not value:
|
|
return None
|
|
candidate = Path(value)
|
|
if not candidate.is_absolute():
|
|
found = shutil.which(value)
|
|
if found:
|
|
candidate = Path(found)
|
|
try:
|
|
return str(candidate.expanduser().resolve())
|
|
except OSError:
|
|
return str(candidate)
|
|
|
|
|
|
def _package_version() -> str | None:
|
|
try:
|
|
return metadata.version("smriti-cli")
|
|
except metadata.PackageNotFoundError:
|
|
return None
|
|
|
|
|
|
def _build_cli_info() -> dict:
|
|
"""Return local CLI-source details so stale PATH wrappers are visible."""
|
|
invoked = _resolve_executable_path(sys.argv[0])
|
|
path_entry = _resolve_executable_path(shutil.which("smriti"))
|
|
match: bool | None
|
|
if invoked and path_entry:
|
|
match = invoked == path_entry
|
|
elif invoked or path_entry:
|
|
match = False
|
|
else:
|
|
match = None
|
|
|
|
return {
|
|
"executable": invoked,
|
|
"path_entry": path_entry,
|
|
"path_matches_executable": match,
|
|
"package_version": _package_version(),
|
|
}
|
|
|
|
|
|
def _smriti_hook_executable() -> str:
|
|
"""Choose a portable smriti executable for generated startup hooks.
|
|
|
|
Prefer the script that is running `smriti init` when it looks like the
|
|
installed `smriti` entry point. Fall back to the first `smriti` on PATH,
|
|
then to the bare command. We deliberately avoid repo-relative paths such
|
|
as `backend/.venv/bin/smriti` because init runs inside the user's target
|
|
project, not necessarily inside the Smriti checkout.
|
|
"""
|
|
invoked = _resolve_executable_path(sys.argv[0])
|
|
if invoked:
|
|
invoked_path = Path(invoked)
|
|
if invoked_path.name == "smriti" and os.access(invoked_path, os.X_OK):
|
|
return invoked
|
|
|
|
path_entry = _resolve_executable_path(shutil.which("smriti"))
|
|
return path_entry or "smriti"
|
|
|
|
|
|
def _smriti_mcp_executable() -> str:
|
|
"""Choose a practical MCP server executable for generated config hints."""
|
|
smriti_exe = _smriti_hook_executable()
|
|
if smriti_exe != "smriti":
|
|
sibling = Path(smriti_exe).with_name("smriti-mcp")
|
|
if sibling.exists() and os.access(sibling, os.X_OK):
|
|
return str(sibling)
|
|
|
|
path_entry = _resolve_executable_path(shutil.which("smriti-mcp"))
|
|
return path_entry or "smriti-mcp"
|
|
|
|
|
|
def _build_session_start_hook_command(api_url: str | None = None) -> str:
|
|
"""Build the SessionStart hook command.
|
|
|
|
The hook is space-agnostic: `smriti state --compact` resolves the space
|
|
from the repo's `.smriti.json` attachment, so the same hook works for
|
|
every attached project and survives re-attaching to a different space.
|
|
"""
|
|
args = [shlex.quote(_smriti_hook_executable())]
|
|
if api_url:
|
|
args.extend(["--api-url", shlex.quote(api_url)])
|
|
args.extend(
|
|
[
|
|
"state",
|
|
"--compact",
|
|
"2>/dev/null",
|
|
"||",
|
|
"echo",
|
|
shlex.quote(
|
|
"Smriti backend not reachable. Start with: make dev-local"
|
|
),
|
|
]
|
|
)
|
|
return " ".join(args)
|
|
|
|
|
|
def _resolve_space(client: SmritiClient, args: argparse.Namespace) -> dict:
|
|
"""Resolve the space a command should act on.
|
|
|
|
Precedence:
|
|
1. an explicit `<space>` argument, when the command was given one;
|
|
2. otherwise the repo's `.smriti.json` attachment.
|
|
|
|
Fails with actionable guidance when neither is available, so an agent
|
|
in an un-attached repo gets a clear next step instead of a stack trace.
|
|
"""
|
|
explicit = getattr(args, "space", None)
|
|
if explicit:
|
|
return client.resolve_space(explicit)
|
|
|
|
record = attachment.read_attachment()
|
|
if record is None:
|
|
_fail(
|
|
"No space given, and this directory is not attached to a Smriti space.\n"
|
|
"Attach it once with `smriti attach <space>` (or `smriti init <space>`),\n"
|
|
"or pass the space explicitly: `smriti <command> <space>`."
|
|
)
|
|
|
|
# Prefer the recorded id; fall back to the name when the id misses — the
|
|
# attachment is git-committable and may be read against another backend.
|
|
space_id = record.get("space_id")
|
|
if space_id:
|
|
try:
|
|
return client.get_space(space_id)
|
|
except SmritiError as e:
|
|
if e.status != 404:
|
|
raise
|
|
try:
|
|
return client.resolve_space(record["space"])
|
|
except SmritiError:
|
|
_fail(
|
|
f'This repo is attached to Smriti space "{record["space"]}", but it '
|
|
f"was not found on the backend at {client.base_url}.\n"
|
|
"Re-attach with `smriti attach <space>`, or start/point at the right backend."
|
|
)
|
|
|
|
|
|
def _is_smriti_session_start_entry(entry: Any) -> bool:
|
|
if not isinstance(entry, dict):
|
|
return False
|
|
hooks = entry.get("hooks")
|
|
if not isinstance(hooks, list):
|
|
return False
|
|
for hook in hooks:
|
|
if not isinstance(hook, dict):
|
|
continue
|
|
command = hook.get("command")
|
|
normalized = command.replace("'", "").replace('"', "") if isinstance(command, str) else ""
|
|
if "smriti" in normalized and " state " in normalized:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _background_provider_check(providers: dict) -> str:
|
|
bg = providers.get("background_intelligence") if providers else None
|
|
if not bg:
|
|
return "unknown"
|
|
return "ready" if bg.get("configured") else "mock_or_disabled"
|
|
|
|
|
|
def _build_doctor_report(client: SmritiClient) -> dict:
|
|
"""Build a small diagnostics report without attempting repairs."""
|
|
source_info = _build_smriti_source_info()
|
|
cwd_info = _build_cwd_info()
|
|
if not source_info.get("git_sha") and cwd_info.get("is_smriti_source"):
|
|
source_info = dict(cwd_info)
|
|
source_sha = source_info.get("git_sha")
|
|
|
|
report = {
|
|
"api_url": client.base_url,
|
|
"backend": {
|
|
"reachable": False,
|
|
"status": None,
|
|
"git_sha": None,
|
|
"capabilities": [],
|
|
"error": None,
|
|
},
|
|
# Backward-compatible alias: historically "local" meant cwd. It now
|
|
# means the local Smriti source/install used for runtime comparison.
|
|
"local": source_info,
|
|
"source": source_info,
|
|
"cwd": cwd_info,
|
|
"cli": _build_cli_info(),
|
|
"checks": {
|
|
"runtime_match": "unknown",
|
|
"missing_capabilities": None,
|
|
"cli_path": "unknown",
|
|
"background_provider": "unknown",
|
|
},
|
|
"hints": [],
|
|
}
|
|
|
|
try:
|
|
health = client.get_health()
|
|
except SmritiError as e:
|
|
report["backend"]["error"] = str(e)
|
|
report["hints"].append(
|
|
"Backend is not reachable; start Smriti with `make dev-local` "
|
|
"for solo/local mode or `make dev-postgres` for shared/team mode, "
|
|
"then rerun `smriti doctor`."
|
|
)
|
|
cli_info = report.get("cli") or {}
|
|
if cli_info.get("path_matches_executable") is False:
|
|
report["checks"]["cli_path"] = "mismatch"
|
|
report["hints"].append(
|
|
"The `smriti` on PATH differs from this doctor executable. "
|
|
"Activate the intended environment or update PATH before daily use."
|
|
)
|
|
return report
|
|
|
|
capabilities = sorted(health.get("capabilities") or [])
|
|
backend_sha = health.get("git_sha")
|
|
database = health.get("database") or {}
|
|
providers = health.get("providers") or {}
|
|
missing = sorted(EXPECTED_HEALTH_CAPABILITIES - set(capabilities))
|
|
match = _git_sha_matches(backend_sha, source_sha)
|
|
|
|
report["backend"].update({
|
|
"reachable": True,
|
|
"status": health.get("status"),
|
|
"git_sha": backend_sha,
|
|
"capabilities": capabilities,
|
|
"database": database,
|
|
"providers": providers,
|
|
"error": None,
|
|
})
|
|
report["checks"]["missing_capabilities"] = missing
|
|
report["checks"]["background_provider"] = _background_provider_check(providers)
|
|
|
|
cli_info = report.get("cli") or {}
|
|
cli_match = cli_info.get("path_matches_executable")
|
|
if cli_match is True:
|
|
report["checks"]["cli_path"] = "ok"
|
|
elif cli_match is False:
|
|
report["checks"]["cli_path"] = "mismatch"
|
|
report["hints"].append(
|
|
"The `smriti` on PATH differs from this doctor executable. "
|
|
"Activate the intended environment or update PATH before daily use."
|
|
)
|
|
else:
|
|
report["checks"]["cli_path"] = "unknown"
|
|
|
|
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 the local Smriti source HEAD; "
|
|
"restart the backend after syncing code, or check out the commit "
|
|
"the backend is running."
|
|
)
|
|
else:
|
|
report["checks"]["runtime_match"] = "not_applicable"
|
|
if source_info.get("git_root"):
|
|
report["hints"].append(
|
|
"Could not compare backend git_sha with the local Smriti source checkout."
|
|
)
|
|
|
|
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."
|
|
)
|
|
if not database:
|
|
report["hints"].append(
|
|
"Backend /health did not report database mode; restart after updating "
|
|
"the backend if you expected activation diagnostics."
|
|
)
|
|
|
|
bg = providers.get("background_intelligence") if providers else None
|
|
if not bg:
|
|
report["hints"].append(
|
|
"Backend /health did not report provider status; provider/mock "
|
|
"confidence is unknown."
|
|
)
|
|
elif not bg.get("configured"):
|
|
provider = bg.get("provider") or "background"
|
|
report["hints"].append(
|
|
f"Background intelligence provider `{provider}` is not configured; "
|
|
"checkpoint extract/review flows may use mock output or fail."
|
|
)
|
|
|
|
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.
|
|
|
|
With --strict, exits non-zero if any critical check fails. The current
|
|
strict checks are:
|
|
- backend reachable
|
|
- background provider configured for real LLM extraction (--extract,
|
|
draft, review). When this fails strict, agents and CI should refuse
|
|
to use --extract; manual JSON checkpointing still works.
|
|
"""
|
|
report = _build_doctor_report(client)
|
|
if args.json:
|
|
_print_json(report)
|
|
else:
|
|
print(format_doctor(report), end="")
|
|
|
|
if getattr(args, "strict", False):
|
|
failures: list[str] = []
|
|
backend = report.get("backend") or {}
|
|
if not backend.get("reachable"):
|
|
failures.append("backend is not reachable")
|
|
checks = report.get("checks") or {}
|
|
bg = checks.get("background_provider")
|
|
if bg == "mock_or_disabled":
|
|
failures.append(
|
|
"background provider is mock_or_disabled — `smriti checkpoint create "
|
|
"--extract`, draft, and review will fail. Configure a provider."
|
|
)
|
|
elif bg == "unknown":
|
|
failures.append("background provider status is unknown (backend reachable?).")
|
|
if failures:
|
|
print("", file=sys.stderr)
|
|
print("strict mode: " + str(len(failures)) + " check(s) failed:", file=sys.stderr)
|
|
for f in failures:
|
|
print(f" - {f}", file=sys.stderr)
|
|
sys.exit(78) # EX_CONFIG
|
|
|
|
|
|
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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
commits = client.list_commits(space["id"])
|
|
commit_count = len(commits)
|
|
|
|
# Is this the space the current repo is attached to?
|
|
record = attachment.read_attachment()
|
|
is_attached = bool(record) and (
|
|
record.get("space_id") == space["id"]
|
|
or record.get("space") == space["name"]
|
|
)
|
|
|
|
# Destructive-delete guard. `-y` skips the confirmation prompt, but it must
|
|
# NOT, on its own, delete a space that holds real work or is attached to a
|
|
# repo. Those need an explicit, separate --force signal — the same
|
|
# "name the stronger flag" shape as `checkpoint delete --cascade`.
|
|
blockers: list[str] = []
|
|
if commit_count > 0:
|
|
blockers.append(
|
|
f"it holds {commit_count} checkpoint(s); deletion cascades to all of "
|
|
f"them plus every session and turn, and cannot be undone"
|
|
)
|
|
if is_attached:
|
|
blockers.append(
|
|
"this repo is attached to it (.smriti.json); deleting it unbinds the repo"
|
|
)
|
|
if blockers and not args.force:
|
|
lines = [f"Refusing to delete space '{space['name']}' (`{space['id']}`):"]
|
|
lines += [f" - {b}" for b in blockers]
|
|
lines.append("")
|
|
lines.append(
|
|
" -y is not enough for a destructive delete like this. If you are "
|
|
"certain,\n re-run with --force (required in addition to -y or the prompt)."
|
|
)
|
|
_fail("\n".join(lines))
|
|
|
|
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 is_attached:
|
|
preview += "\n This repo is attached to this space — deleting it unbinds the repo."
|
|
if not _confirm(preview, args.yes):
|
|
_fail("Cancelled.", code=0)
|
|
client.delete_space(space["id"], force=args.force)
|
|
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 = _resolve_space(client, args)
|
|
|
|
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)
|
|
repo_state = _build_repo_state(space, commit)
|
|
if args.json:
|
|
payload = {"space": space, "head": head, "commit": commit}
|
|
if repo_state is not None:
|
|
payload["repo_state"] = repo_state
|
|
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,
|
|
repo_state=repo_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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
|
|
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()
|
|
try:
|
|
extracted = client.extract_checkpoint_content(content)
|
|
except SmritiError as e:
|
|
# Provider not configured: backend returns 412 with a structured
|
|
# detail. Surface the actionable fix list and exit non-zero — do
|
|
# NOT fall back to mock or hand-written content silently.
|
|
if e.status == 412 and isinstance(e.detail, dict) and e.detail.get("error") == "provider_not_configured":
|
|
_fail_provider_not_configured(e.detail)
|
|
raise
|
|
# Defense in depth: if the backend somehow returned mock content on
|
|
# the default path (use_mock=False), refuse to commit it. Mock
|
|
# output committed into a real project pollutes reasoning state.
|
|
if extracted.get("provider") == "mock":
|
|
_fail(
|
|
"error: extract returned mock content (provider=mock) on the default path.\n"
|
|
"Refusing to commit. This is a backend bug — please report it.\n"
|
|
"If you intentionally want mock content (tests/demos), build the payload manually.",
|
|
)
|
|
# 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", []),
|
|
}
|
|
# Stash provider/model so we can show them on commit confirmation.
|
|
_extract_provider = extracted.get("provider") or ""
|
|
_extract_model = extracted.get("model") or ""
|
|
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.")
|
|
_extract_provider = ""
|
|
_extract_model = ""
|
|
|
|
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
|
|
|
|
# Record local git HEAD/branch so later `smriti state` runs can detect
|
|
# how far the repo has drifted since this checkpoint. Best-effort: empty
|
|
# outside a git repo, which the backend stores as no repo_state.
|
|
repo_state = _checkpoint_repo_state()
|
|
if repo_state:
|
|
commit_payload["repo_state"] = repo_state
|
|
|
|
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', '')}")
|
|
if _extract_provider:
|
|
print(f" extracted via {_extract_provider}/{_extract_model}")
|
|
|
|
|
|
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 = _resolve_space(client, args)
|
|
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 .skill_pack import install as install_skill
|
|
|
|
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]}…)')
|
|
|
|
# 2b. Attach this repo to the space — a durable .smriti.json binding so
|
|
# commands and the session hook resolve the space automatically.
|
|
repo_dir = _git_output("rev-parse", "--show-toplevel") or os.getcwd()
|
|
try:
|
|
client.set_project_root(space["id"], repo_dir)
|
|
except Exception:
|
|
pass # project_root is a backend hint; the attachment is the source of truth
|
|
attachment_path = attachment.write_attachment(repo_dir, space["name"], space["id"])
|
|
results.append(f"Repo attached to space → {attachment_path}")
|
|
|
|
# 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 = _build_session_start_hook_command(client.base_url)
|
|
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 = {}
|
|
|
|
existing.setdefault("hooks", {})
|
|
existing_session_start = existing["hooks"].get("SessionStart")
|
|
if not isinstance(existing_session_start, list):
|
|
existing_session_start = []
|
|
|
|
non_smriti_hooks = [
|
|
entry for entry in existing_session_start
|
|
if not _is_smriti_session_start_entry(entry)
|
|
]
|
|
merged_session_start = non_smriti_hooks + target_hooks["SessionStart"]
|
|
|
|
if existing_session_start == merged_session_start:
|
|
results.append("SessionStart hook already configured → .claude/settings.json")
|
|
else:
|
|
existing["hooks"]["SessionStart"] = merged_session_start
|
|
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
settings_path.write_text(
|
|
_json.dumps(existing, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
action = "updated" if existing_session_start else "configured"
|
|
results.append(f"SessionStart hook {action} → .claude/settings.json")
|
|
|
|
# 6. MCP config reminder.
|
|
mcp_config = {
|
|
"mcpServers": {
|
|
"smriti": {
|
|
"command": _smriti_mcp_executable(),
|
|
"env": {"SMRITI_API_URL": client.base_url},
|
|
}
|
|
}
|
|
}
|
|
next_steps.append(
|
|
"Configure MCP in your host (if using Claude Code / Cursor / Windsurf):\n"
|
|
f" {_json.dumps(mcp_config)}"
|
|
)
|
|
next_steps.append(
|
|
"Verify activation:\n"
|
|
" smriti doctor\n"
|
|
" smriti state --compact"
|
|
)
|
|
|
|
# 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()
|
|
|
|
|
|
# ── attach subcommand handler ───────────────────────────────────────────────
|
|
|
|
|
|
def cmd_attach(client: SmritiClient, args: argparse.Namespace) -> None:
|
|
"""Attach this repo to a Smriti space — a durable `.smriti.json` binding.
|
|
|
|
With no <space>: print the repo's current attachment.
|
|
With <space>: resolve or create the space, write `.smriti.json` at the
|
|
repo root, and set the space's project_root. Afterward, commands in this
|
|
repo resolve the space automatically — no <space> argument needed.
|
|
"""
|
|
# No space → show the current attachment and stop.
|
|
if not args.space:
|
|
record = attachment.read_attachment()
|
|
path = attachment.find_attachment_file()
|
|
if record is None:
|
|
if args.json:
|
|
_print_json({"attached": False})
|
|
else:
|
|
print("This directory is not attached to a Smriti space.")
|
|
print("Attach it once: smriti attach <space>")
|
|
return
|
|
if args.json:
|
|
_print_json({
|
|
"attached": True,
|
|
"space": record.get("space"),
|
|
"space_id": record.get("space_id"),
|
|
"attachment": str(path) if path else None,
|
|
})
|
|
return
|
|
print(f'Attached to Smriti space "{record.get("space")}".')
|
|
if record.get("space_id"):
|
|
print(f" space id: {record['space_id']}")
|
|
print(f" attachment: {path}")
|
|
return
|
|
|
|
# Verify backend reachability.
|
|
try:
|
|
client.list_spaces()
|
|
except SmritiError:
|
|
_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
|
|
|
|
# Resolve the space, or create it if it does not exist yet.
|
|
target_name = args.space
|
|
try:
|
|
space = client.resolve_space(target_name)
|
|
created = False
|
|
except SmritiError:
|
|
space = client.create_space(
|
|
name=target_name, description=args.description or ""
|
|
)
|
|
created = True
|
|
|
|
repo_dir = _git_output("rev-parse", "--show-toplevel") or os.getcwd()
|
|
|
|
# Set the backend's project_root hint (best-effort). The `.smriti.json`
|
|
# attachment is the source of truth for repo → space resolution.
|
|
try:
|
|
client.set_project_root(space["id"], repo_dir)
|
|
except SmritiError:
|
|
pass
|
|
|
|
path = attachment.write_attachment(repo_dir, space["name"], space["id"])
|
|
|
|
if args.json:
|
|
_print_json({
|
|
"space": space["name"],
|
|
"space_id": space["id"],
|
|
"attachment": str(path),
|
|
"project_root": repo_dir,
|
|
"created": created,
|
|
})
|
|
return
|
|
|
|
verb = "created and attached" if created else "attached"
|
|
print()
|
|
print(f' ✓ Repo {verb} to Smriti space "{space["name"]}"')
|
|
print(f" attachment: {path}")
|
|
print(f" project root: {repo_dir}")
|
|
print()
|
|
print(" Commands in this repo now resolve the space automatically —")
|
|
print(" `smriti state`, `smriti current`, `smriti claim create …` need no <space>.")
|
|
print()
|
|
|
|
|
|
# ── quickstart subcommand handler ───────────────────────────────────────────
|
|
|
|
|
|
def _render_quickstart(args: argparse.Namespace, payload: dict) -> None:
|
|
"""Emit a quickstart result — JSON when --json, otherwise human-readable."""
|
|
if args.json:
|
|
_print_json(payload)
|
|
return
|
|
|
|
space = payload["space"]
|
|
action = payload["action"]
|
|
print()
|
|
|
|
if action == "removed":
|
|
print(f' ✓ Removed the demo space "{space}".')
|
|
print()
|
|
return
|
|
|
|
if action == "nothing-to-remove":
|
|
print(f' No demo space to remove — "{space}" does not exist.')
|
|
print()
|
|
return
|
|
|
|
if action == "exists":
|
|
print(f' The demo space "{space}" is already seeded.')
|
|
print()
|
|
print(f" smriti current {space} explore it")
|
|
print(" smriti quickstart --reset rebuild it from scratch")
|
|
print(" smriti quickstart --remove delete it")
|
|
print()
|
|
return
|
|
|
|
# action == "seeded"
|
|
claims = payload["claims"]
|
|
active = sum(1 for c in claims if c.get("status") != "done")
|
|
n_checkpoints = len(payload["checkpoints"])
|
|
print(f' ✓ Seeded the demo space "{space}".')
|
|
print(
|
|
f" {n_checkpoints} checkpoints · 2 agents · "
|
|
f"1 branch explored and dropped · "
|
|
f"{len(claims)} work claims ({active} still active)"
|
|
)
|
|
print()
|
|
print(' This is one small, finished feature — "add rate limiting to the')
|
|
print(' API" — captured the way Smriti captures reasoning: the decisions,')
|
|
print(" the assumptions under them, a branch that was tried and dropped,")
|
|
print(" and a hand-off between two agents. Smriti's value is this")
|
|
print(" accumulated state — quickstart just gives you some on day one.")
|
|
print()
|
|
print(" Walk through it — about three minutes:")
|
|
print()
|
|
for i, step in enumerate(payload["guide"], 1):
|
|
print(f" {i}. {step['command']}")
|
|
print(f" {step['note']}")
|
|
print()
|
|
print(" Then open the dashboard: http://localhost:5173")
|
|
print()
|
|
print(" Done exploring? smriti quickstart --remove")
|
|
print()
|
|
|
|
|
|
def cmd_quickstart(client: SmritiClient, args: argparse.Namespace) -> None:
|
|
"""Seed a curated demo space so a new user sees what Smriti is for.
|
|
|
|
The empty-room problem: a fresh install opens to an empty space, and
|
|
Smriti's value only shows once reasoning has accumulated. `quickstart`
|
|
seeds one small, finished, realistic project — built by two agents, with
|
|
a branch that was explored and dropped — and prints a short walkthrough.
|
|
|
|
Default: seed `smriti-demo`. --remove deletes it; --reset removes then
|
|
re-seeds. Idempotent — re-running without flags when the demo already
|
|
exists just reprints how to explore or rebuild it.
|
|
"""
|
|
from . import quickstart as qs
|
|
|
|
# Backend reachability — same failure guidance as `smriti init`.
|
|
try:
|
|
client.list_spaces()
|
|
except SmritiError:
|
|
_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
|
|
|
|
# Removal path — both --remove and --reset clear an existing demo space.
|
|
if args.remove or args.reset:
|
|
existing = qs.find_demo_space(client)
|
|
if existing is not None and qs.is_demo_space(existing):
|
|
if not _confirm(
|
|
f'Delete the demo space "{qs.DEMO_SPACE_NAME}" and all '
|
|
f"its checkpoints?",
|
|
args.yes,
|
|
):
|
|
_fail("Cancelled.", code=0)
|
|
result = qs.remove_demo_space(client)
|
|
if not result["removed"] and result.get("reason") == "not-a-demo-space":
|
|
_fail(
|
|
f'error: A space named "{qs.DEMO_SPACE_NAME}" exists but was '
|
|
f"not created by quickstart — it lacks the demo marker.\n"
|
|
f"Refusing to delete it. To remove it yourself, run:\n"
|
|
f" smriti space delete {qs.DEMO_SPACE_NAME}"
|
|
)
|
|
return
|
|
if args.remove:
|
|
action = "removed" if result["removed"] else "nothing-to-remove"
|
|
_render_quickstart(args, {"action": action, "space": qs.DEMO_SPACE_NAME})
|
|
return
|
|
# --reset: fall through and re-seed.
|
|
|
|
# Seeding path.
|
|
if qs.find_demo_space(client) is not None:
|
|
_render_quickstart(args, {"action": "exists", "space": qs.DEMO_SPACE_NAME})
|
|
return
|
|
|
|
seed = qs.seed_demo_space(client)
|
|
_render_quickstart(
|
|
args,
|
|
{
|
|
"action": "seeded",
|
|
"space": qs.DEMO_SPACE_NAME,
|
|
"space_id": seed["space_id"],
|
|
"checkpoints": seed["checkpoints"],
|
|
"claims": seed["claims"],
|
|
"guide": qs.build_guide(seed),
|
|
},
|
|
)
|
|
|
|
|
|
# ── branch subcommand handlers ──────────────────────────────────────────────
|
|
|
|
|
|
def cmd_branch_close(client: SmritiClient, args: argparse.Namespace) -> None:
|
|
"""Set the disposition of a branch (integrated, abandoned, or active)."""
|
|
space = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
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 = _resolve_space(client, args)
|
|
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)
|
|
|
|
# attach — bind this repo to a Smriti space (durable .smriti.json)
|
|
attach_parser = subparsers.add_parser(
|
|
"attach",
|
|
help="Attach this repo to a Smriti space so commands resolve it automatically",
|
|
)
|
|
attach_parser.add_argument(
|
|
"space",
|
|
nargs="?",
|
|
help="Space to attach to (omit to show the repo's current attachment)",
|
|
)
|
|
attach_parser.add_argument(
|
|
"--description", default="", help="Description, used only if the space is created"
|
|
)
|
|
attach_parser.add_argument("--json", action="store_true")
|
|
attach_parser.set_defaults(func=cmd_attach)
|
|
|
|
# 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.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help=(
|
|
"Exit non-zero (EX_CONFIG / 78) when any critical check fails — "
|
|
"currently: backend unreachable, or background provider mock_or_disabled "
|
|
"(blocks `checkpoint create --extract`, draft, review)."
|
|
),
|
|
)
|
|
doctor_parser.set_defaults(func=cmd_doctor)
|
|
|
|
# quickstart — seed a curated demo space so the product clicks fast
|
|
quickstart_parser = subparsers.add_parser(
|
|
"quickstart",
|
|
help="Seed a curated demo space (smriti-demo) and print a guided walkthrough",
|
|
)
|
|
quickstart_mode = quickstart_parser.add_mutually_exclusive_group()
|
|
quickstart_mode.add_argument(
|
|
"--remove",
|
|
action="store_true",
|
|
help="Delete the demo space instead of seeding it",
|
|
)
|
|
quickstart_mode.add_argument(
|
|
"--reset",
|
|
action="store_true",
|
|
help="Delete the demo space if present, then seed a fresh one",
|
|
)
|
|
quickstart_parser.add_argument(
|
|
"-y",
|
|
"--yes",
|
|
action="store_true",
|
|
help="Skip the confirmation prompt when removing the demo space",
|
|
)
|
|
quickstart_parser.add_argument("--json", action="store_true")
|
|
quickstart_parser.set_defaults(func=cmd_quickstart)
|
|
|
|
# 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(
|
|
"--force",
|
|
action="store_true",
|
|
help="Required to delete a non-empty or attached space (irreversible)",
|
|
)
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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", nargs="?", help="Space name or UUID (optional — defaults to the attached space)")
|
|
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())
|