Add CLI for agent and programmatic access

Introduce a thin Python CLI that wraps the backend REST API. Seven
commands: space list, space create, state, checkpoint create,
checkpoint show, checkpoint list, checkpoint review. Reads piped
JSON on stdin for checkpoint create, prints a continuation-oriented
markdown brief for state. Supports --json on every command for
structured output.

Fixes a V2 schema drift where the commit response omitted
assumptions and artifacts, so the CLI can read full checkpoints
via the cleaner V2 single-resource endpoints. Updates README,
ARCHITECTURE, and DECISIONS to frame Smriti as a reasoning-state
backend with the chat UI and CLI as two clients of the same core.
This commit is contained in:
Himanshu Dongre 2026-04-11 11:10:01 +05:30
parent 9408fae00c
commit 89b6df16cf
11 changed files with 905 additions and 15 deletions

View file

@ -304,12 +304,11 @@ pivot, not an incremental change.
| Prefix | Status | Purpose |
|---|---|---|
| `/api/v1` | Legacy | Transcript paste ingestion → session/artifact pipeline. Not part of current workflow. |
| `/api/v2` | Legacy | Agent-push model: repos, commits, context packs. Direct API-to-API handoff. Not part of current UI workflow. |
| `/api/v4` | Current | Chat sessions, message sending, provider management. The primary API. |
| `/api/v5` | Current | Checkpoint drafting. Isolated from chat API by design. |
| `/api/v2` | Partial | Space CRUD, checkpoint read by ID, checkpoint list by space. `CommitResponse` includes `assumptions` and `artifacts` so programmatic clients (CLI, agents) can read full checkpoints via this surface. |
| `/api/v4` | Current | Chat sessions, message sending, provider management. The primary chat API. Also the canonical checkpoint write path (`POST /chat/commit`) because it accepts the full schema including `assumptions` and `artifacts`. |
| `/api/v5` | Current | Checkpoint drafting, review, fork, compare, lineage. Isolated from chat API by design. |
V1 and V2 endpoints remain registered for compatibility. They are not used by the
current frontend. New development targets V4 and V5 exclusively.
V1 remains registered for compatibility but is not used by the frontend or CLI.
The split between V4 and V5 is intentional: checkpoint operations (which involve a
background LLM call and structured extraction) are separated from the real-time chat
@ -317,6 +316,31 @@ path. This allows different latency budgets and error handling strategies for ea
---
## Smriti as an agent-facing backend
The REST API is the canonical interface to Smriti. The chat UI and the CLI are
both clients of the same model. Nothing in the core (checkpoints, assumptions,
decisions, artifacts, fork, compare, review) is specific to one surface.
Two clients ship today:
- **Chat UI** (`frontend/`) — the human inspection and steering surface.
Reads and writes via V2/V4/V5 endpoints. Drives the live conversation runtime
(`POST /api/v4/chat/send`) that injects checkpoint context into model calls.
- **CLI** (`cli/`) — the programmatic surface for coding agents and scripts.
Reads via V2 (`GET /commits/{id}`, `GET /repos/{id}/commits`) and V4
(`GET /chat/spaces/{id}/head`). Writes via V4 (`POST /chat/commit`) with the
full schema including `assumptions` and `artifacts`. Triggers review via V5
(`POST /checkpoint/{id}/review`). Does not touch `/chat/send` — agents run
their own reasoning in their own context, using their own LLM provider.
Smriti is their shared memory, not their runtime.
Agents interact with structured state (checkpoints) via the CLI. The chat UI
and CLI can be used simultaneously on the same project: the human steers and
inspects, the agent reads and writes. Both see the same reasoning state.
---
## Frontend State Management
The frontend manages three pieces of state relevant to checkpoint isolation:

View file

@ -181,6 +181,54 @@ the model's responses are grounded in actual content rather than just summaries
what was discussed. Artifact content is capped at 2000 characters per artifact in
the prompt to manage context size.
### Why a CLI is the first agent-facing surface, not MCP
Agents need a way to read and write Smriti's reasoning state from inside their
tool loops. The two realistic transports are a CLI they can invoke via shell
commands, and an MCP server they can call as structured tools. MCP is the better
long-term answer: structured tool calls, native integration with hosts that
support it, no shell indirection.
The CLI was chosen for V1 anyway. Reasons:
- Fastest path to a real end-to-end test. A CLI can be installed and wired into
any agent that runs shell commands on the same day it ships.
- Protocol-agnostic. Works with any host, including ones that do not speak MCP
or treat MCP inconsistently.
- MCP is an evolving protocol with different levels of support per host. A CLI
is a stable contract.
- MCP is a wrapper around the same operations the CLI already exposes. Adding
MCP later as a second transport over the same underlying commands is a
smaller, cleaner move than building MCP first without knowing which commands
agents actually use.
MCP is the expected second transport. It gets added once the basic handoff loop
has been proven in real use.
### Why the chat UI remains alongside agent-facing surfaces
It would be tempting to frame Smriti as "an agent backend" and deprecate the
chat UI as legacy. That would be a mistake. The chat UI is the human inspection
and steering surface over the same reasoning state that agents are writing to.
When two agents have been working on a project and one of them has gone in the
wrong direction, the human needs a way to look at what happened, fork or restore
cleanly, and point the next agent at a better state.
The chat UI is not a stepping stone to an agent-only product. It is the
permanent human-in-the-loop interface over shared reasoning state.
### Why agents do not touch the live chat API (`/chat/send`)
The V4 chat send endpoint drives the live conversation runtime: it accepts user
messages, manages provider routing, injects checkpoint context, and stores
turns. Agents should not invoke it. Agents write directly to structured state
(checkpoints via `/chat/commit`) and read structured state (HEAD + commit
fetch). They run their own reasoning in their own context, using their own LLM
provider. Smriti is their shared memory, not their runtime.
This keeps the chat runtime focused on human-driven exploration, and keeps the
agent surface narrow and composable.
---
## Open questions and deferred decisions

View file

@ -135,18 +135,26 @@ It is stricter than typical chat systems. But it felt important to try.
---
## This is not just for chat
## Smriti is a backend, not just a chat app
I initially built this thinking about chat. But the more I worked on it, the more it felt like this might matter more for agents.
I initially built this thinking about chat, but the more I worked on it, the more it felt like a reasoning-state backend that happens to have a chat UI on top. Agents have the same drift / recovery / handoff problems as humans, just worse.
Because agents have the same problem, just worse:
The concrete use case that drove this direction: working on a coding project and wanting to switch between different coding agents mid-project. Context reset every time. Markdown handoff files that broke down the moment reasoning branched. The strengths Smriti already had — checkpoints, restore, fork, compare, assumptions, artifacts, model interchangeability — mapped directly onto that pain.
- multi-step reasoning chains
- hard to debug
- hard to reproduce
- no clean way to "go back"
So Smriti now has two surfaces on the same core:
With checkpoints, you can persist intermediate reasoning, resume from a known state, fork execution paths, and compare outcomes across runs.
1. **The chat UI**: how a human reads, steers, and debugs shared reasoning state. Still the primary way I inspect what is happening in a project.
2. **A CLI**: how a coding agent reads and writes the same reasoning state from a shell tool loop.
One project, one Smriti Space, multiple agents reading from and writing to the same structured state. Agents don't need to know about each other. They just need to know how to read the current state and write a checkpoint when they reach an inflection point.
See `cli/README.md` for the agent-facing CLI. Quick taste:
```bash
smriti state my-project # continuation brief
cat checkpoint.json | smriti checkpoint create my-project
smriti checkpoint review <id> # consistency check before continuing
```
---
@ -178,6 +186,16 @@ Backend: http://localhost:8000
There is also a mock mode if you don't want to deal with API keys.
### CLI (for agents and scripts)
```bash
cd cli
pip install -e .
smriti space list
```
The CLI wraps the backend API. See `cli/README.md` for the full command list and the agent handoff workflow.
---
## Docker (if you prefer that)
@ -296,7 +314,9 @@ There is also a mock mode for trying the product without API keys.
## Where this is going
The same idea that makes reasoning recoverable in chat also applies when the reasoning happens more autonomously. If something is making decisions over multiple steps, you want to be able to inspect what it decided, go back to where it was still on track, and try a different path. That does not require a different system. It requires the same one.
The chat UI is not going away — it is how I read, steer, and debug what is happening. But the thing underneath both the chat UI and the CLI is a shared reasoning-state layer that any client can read from and write to. Coding agents are the first real programmatic client. More transports (including MCP) can come later, but only after the basic loop is proven in real use.
What I care about right now: can you use two different coding agents on the same project, hand off cleanly between them via Smriti, and have the receiving agent pick up where the sender left off without re-explaining context?
---

View file

@ -46,14 +46,16 @@ class CommitResponse(BaseModel):
summary: str
objective: str
decisions: list
assumptions: list
tasks: list
open_questions: list
entities: list
artifacts: list
context_blob: dict
raw_source_text: str | None
metadata_: dict = Field(serialization_alias="metadata")
created_at: datetime
model_config = {"from_attributes": True}
import logging

103
cli/README.md Normal file
View file

@ -0,0 +1,103 @@
# smriti-cli
Command-line access to Smriti's reasoning-state backend. Built for coding agents and scripts — pipe JSON in, get readable markdown out.
## Install
From the repo root:
```bash
cd cli
pip install -e .
```
This installs a `smriti` command on your PATH.
## Configuration
Set the backend URL via env var (defaults to `http://localhost:8000`):
```bash
export SMRITI_API_URL=http://localhost:8000
```
Or pass `--api-url` on any command.
## Commands
```
smriti space list
smriti space create <name> [--description "..."]
smriti state <space> # continuation brief
smriti state <space> --full-artifacts # include full artifacts
smriti state <space> --json # structured output
smriti checkpoint create <space> # reads JSON from stdin
smriti checkpoint create <space> --from-json <path> # from file
smriti checkpoint show <checkpoint-id>
smriti checkpoint list <space>
smriti checkpoint review <checkpoint-id>
```
Every command supports `--json` for structured output.
## Typical agent workflow
Read current project state:
```bash
smriti state my-project
```
Write a checkpoint from a JSON object piped on stdin:
```bash
cat <<'JSON' | smriti checkpoint create my-project
{
"message": "Decided to use Pydantic for state validation",
"objective": "Build runtime-enforced state layer",
"summary": "...",
"decisions": ["Use Pydantic BaseModel for state", "extra=forbid blocks injection"],
"assumptions": ["Latency cost is acceptable"],
"tasks": ["Benchmark validation overhead"],
"open_questions": ["How to handle shared state across agents"],
"entities": ["Pydantic", "BaseModel"],
"artifacts": [
{"id": "a1", "type": "text", "label": "Draft implementation", "content": "..."}
]
}
JSON
```
Review a specific checkpoint for consistency issues:
```bash
smriti checkpoint review <checkpoint-id>
```
## Checkpoint payload schema
Only `message` is required. Every other field defaults to empty.
| Field | Type | Notes |
|---|---|---|
| `message` | string | Short title (required) |
| `objective` | string | What you are working toward |
| `summary` | string | Narrative of what was figured out |
| `decisions` | string[] | Explicit choices made |
| `assumptions` | string[] | Things taken for granted |
| `tasks` | string[] | Concrete action items |
| `open_questions` | string[] | Unresolved issues |
| `entities` | string[] | Key concepts, tools, names |
| `artifacts` | object[] | `{id, type, label, content}` entries |
## Space resolution
`<space>` arguments accept either the space name or the UUID. Names are matched exactly first, then case-insensitively. If multiple spaces match, the CLI asks you to use a UUID.
## Exit codes
- `0` success
- `1` API error, invalid input, or backend unreachable
- `130` interrupted (Ctrl+C)

24
cli/pyproject.toml Normal file
View file

@ -0,0 +1,24 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "smriti-cli"
version = "0.1.0"
description = "Command-line interface for Smriti, the reasoning-state backend for coding agents."
requires-python = ">=3.11"
readme = "README.md"
license = { text = "MIT" }
authors = [
{ name = "Himanshu Dongre" },
]
dependencies = [
"requests>=2.31.0",
]
[project.scripts]
smriti = "smriti_cli.main:main"
[tool.setuptools.packages.find]
where = ["."]
include = ["smriti_cli*"]

View file

@ -0,0 +1,3 @@
"""Smriti CLI — programmatic access to Smriti's reasoning-state backend."""
__version__ = "0.1.0"

View file

@ -0,0 +1,8 @@
"""Allow `python -m smriti_cli` as an entry point."""
import sys
from .main import main
if __name__ == "__main__":
sys.exit(main())

157
cli/smriti_cli/client.py Normal file
View file

@ -0,0 +1,157 @@
"""Thin HTTP wrapper over the Smriti REST API.
No fancy features. Each method maps to a single endpoint. The CLI layer on
top composes these into user-facing commands and formats output.
All methods raise SmritiError on any non-2xx response. Callers should let
these bubble up to main() which turns them into clean exit codes.
"""
from __future__ import annotations
import os
from typing import Any
import requests
DEFAULT_API_URL = "http://localhost:8000"
class SmritiError(Exception):
"""Raised for any API error or unreachable backend."""
def __init__(self, message: str, status: int | None = None):
super().__init__(message)
self.status = status
class SmritiClient:
def __init__(self, base_url: str | None = None, timeout: float = 60.0):
self.base_url = (base_url or os.environ.get("SMRITI_API_URL") or DEFAULT_API_URL).rstrip("/")
self.timeout = timeout
self._session = requests.Session()
# ── internals ──────────────────────────────────────────────────────────
def _request(self, method: str, path: str, *, json: Any = None, params: dict | None = None) -> Any:
url = f"{self.base_url}{path}"
try:
resp = self._session.request(
method=method,
url=url,
json=json,
params=params,
timeout=self.timeout,
)
except requests.ConnectionError as e:
raise SmritiError(
f"Could not reach Smriti at {self.base_url}. "
f"Is the backend running? ({e})"
)
except requests.Timeout:
raise SmritiError(f"Request to {url} timed out after {self.timeout}s")
if not resp.ok:
detail = None
try:
detail = resp.json().get("detail")
except Exception:
detail = resp.text[:200]
raise SmritiError(
f"{method} {path} failed: HTTP {resp.status_code} — {detail}",
status=resp.status_code,
)
if resp.status_code == 204 or not resp.content:
return None
return resp.json()
# ── spaces (V2) ────────────────────────────────────────────────────────
def list_spaces(self) -> list[dict]:
return self._request("GET", "/api/v2/repos")
def get_space(self, space_id: str) -> dict:
return self._request("GET", f"/api/v2/repos/{space_id}")
def create_space(self, name: str, description: str = "") -> dict:
return self._request(
"POST",
"/api/v2/repos",
json={"name": name, "description": description},
)
def resolve_space(self, name_or_id: str) -> dict:
"""Look up a space by UUID or by name. Returns the full space dict.
Tries UUID lookup first; falls back to scanning the space list and
matching on name (case-sensitive exact match, then case-insensitive).
"""
# UUID-ish shape
if len(name_or_id) == 36 and name_or_id.count("-") == 4:
try:
return self.get_space(name_or_id)
except SmritiError as e:
if e.status != 404:
raise
# fall through to name lookup
spaces = self.list_spaces()
exact = [s for s in spaces if s["name"] == name_or_id]
if len(exact) == 1:
return exact[0]
if len(exact) > 1:
raise SmritiError(
f"Multiple spaces named '{name_or_id}'. Use the UUID to disambiguate."
)
lower = name_or_id.lower()
case_insensitive = [s for s in spaces if s["name"].lower() == lower]
if len(case_insensitive) == 1:
return case_insensitive[0]
if len(case_insensitive) > 1:
raise SmritiError(
f"Multiple spaces matching '{name_or_id}' (case-insensitive). "
f"Use the UUID to disambiguate."
)
raise SmritiError(f"No space found matching '{name_or_id}'")
# ── checkpoints ────────────────────────────────────────────────────────
def get_commit(self, commit_id: str) -> dict:
return self._request("GET", f"/api/v2/commits/{commit_id}")
def list_commits(self, space_id: str, branch: str | None = None) -> list[dict]:
params = {"branch": branch} if branch else None
return self._request("GET", f"/api/v2/repos/{space_id}/commits", params=params)
def get_head(self, space_id: str) -> dict:
return self._request("GET", f"/api/v4/chat/spaces/{space_id}/head")
def create_chat_commit(self, payload: dict) -> dict:
"""Create a checkpoint via the V4 commit endpoint (full schema).
Requires session_id. The CLI handles session creation if the caller
does not supply one.
"""
return self._request("POST", "/api/v4/chat/commit", json=payload)
def review_checkpoint(self, commit_id: str) -> dict:
return self._request("POST", f"/api/v5/checkpoint/{commit_id}/review")
# ── sessions (used internally by CLI) ──────────────────────────────────
def create_session(self, repo_id: str, title: str = "", provider: str = "anthropic", model: str = "claude-sonnet-4-6") -> dict:
return self._request(
"POST",
"/api/v4/chat/sessions",
json={
"repo_id": repo_id,
"title": title or "agent-session",
"provider": provider,
"model": model,
"seed_from": "none",
},
)

View file

@ -0,0 +1,205 @@
"""Markdown output formatters for the Smriti CLI.
The default output of every command is a readable markdown brief shaped
so an agent or a human can paste it directly into a working context.
Use --json on any command for structured output instead.
"""
from __future__ import annotations
from datetime import datetime, timezone
def _relative_time(iso_ts: str) -> str:
"""Format an ISO-8601 UTC timestamp as a relative-time string."""
try:
then = datetime.fromisoformat(iso_ts.replace("Z", "+00:00"))
except Exception:
return iso_ts
now = datetime.now(timezone.utc)
delta = now - then
secs = int(delta.total_seconds())
if secs < 60:
return "just now"
mins = secs // 60
if mins < 60:
return f"{mins}m ago"
hrs = mins // 60
if hrs < 24:
return f"{hrs}h ago"
days = hrs // 24
if days < 30:
return f"{days}d ago"
return then.strftime("%Y-%m-%d")
def _short_hash(commit_hash: str | None) -> str:
return commit_hash[:7] if commit_hash else "?"
def _list_section(heading: str, items: list[str]) -> str:
if not items:
return ""
lines = [f"## {heading}"]
for item in items:
lines.append(f"- {item}")
return "\n".join(lines) + "\n"
def _artifact_section(artifacts: list[dict], preview_chars: int = 800, full: bool = False) -> str:
if not artifacts:
return ""
lines = ["## Attached artifacts"]
for art in artifacts:
label = art.get("label") or "Untitled"
content = art.get("content") or ""
lines.append(f"### {label}")
if full or len(content) <= preview_chars:
lines.append(content)
else:
lines.append(content[:preview_chars] + "\n\n[… truncated, use --full-artifacts to see all]")
lines.append("")
return "\n".join(lines) + "\n"
def format_state_brief(
space: dict,
head: dict,
commit: dict,
*,
full_artifacts: bool = False,
) -> str:
"""A continuation-oriented markdown brief for the current project state.
Intended to be pasted directly into an agent's (or human's) working
context. Sections are elided cleanly when empty.
"""
parts: list[str] = []
parts.append(f"# {space.get('name', 'Untitled space')}\n")
if space.get("description"):
parts.append(space["description"].rstrip() + "\n")
commit_hash = commit.get("commit_hash")
created_at = commit.get("created_at") or head.get("commit_hash") or ""
parts.append(
f"Latest checkpoint: `{_short_hash(commit_hash)}`"
+ (f" · {_relative_time(created_at)}" if created_at else "")
+ "\n"
)
if commit.get("objective"):
parts.append(f"## Current objective\n{commit['objective'].rstrip()}\n")
if commit.get("summary"):
parts.append(f"## Where we are\n{commit['summary'].rstrip()}\n")
decisions = commit.get("decisions") or []
assumptions = commit.get("assumptions") or []
open_questions = commit.get("open_questions") or []
tasks = commit.get("tasks") or []
entities = commit.get("entities") or []
artifacts = commit.get("artifacts") or []
parts.append(_list_section("Decisions", decisions))
parts.append(_list_section("Assumptions we are relying on", assumptions))
parts.append(_list_section("Open questions", open_questions))
parts.append(_list_section("In progress", tasks))
parts.append(_artifact_section(artifacts, full=full_artifacts))
if entities:
parts.append(f"## Key entities\n{', '.join(entities)}\n")
return "\n".join(p for p in parts if p).rstrip() + "\n"
def format_checkpoint(commit: dict, *, full_artifacts: bool = False) -> str:
"""Readable markdown for a single checkpoint."""
parts: list[str] = []
parts.append(f"# {commit.get('message', 'Untitled checkpoint')}\n")
commit_hash = commit.get("commit_hash")
created_at = commit.get("created_at") or ""
branch = commit.get("branch_name") or "main"
meta_line = f"`{_short_hash(commit_hash)}`"
if created_at:
meta_line += f" · {_relative_time(created_at)}"
meta_line += f" · branch `{branch}`"
parts.append(meta_line + "\n")
if commit.get("objective"):
parts.append(f"## Objective\n{commit['objective'].rstrip()}\n")
if commit.get("summary"):
parts.append(f"## Summary\n{commit['summary'].rstrip()}\n")
parts.append(_list_section("Decisions", commit.get("decisions") or []))
parts.append(_list_section("Assumptions", commit.get("assumptions") or []))
parts.append(_list_section("Tasks", commit.get("tasks") or []))
parts.append(_list_section("Open questions", commit.get("open_questions") or []))
parts.append(_artifact_section(commit.get("artifacts") or [], full=full_artifacts))
entities = commit.get("entities") or []
if entities:
parts.append(f"## Entities\n{', '.join(entities)}\n")
return "\n".join(p for p in parts if p).rstrip() + "\n"
def format_space_list(spaces: list[dict]) -> str:
if not spaces:
return "No spaces yet. Create one with `smriti space create <name>`.\n"
lines = [f"{len(spaces)} space(s):", ""]
for s in spaces:
desc = (s.get("description") or "").strip()
desc_line = f" {desc}" if desc else ""
lines.append(f"- **{s['name']}** `{s['id']}`")
if desc_line:
lines.append(desc_line)
return "\n".join(lines) + "\n"
def format_commit_list(commits: list[dict]) -> str:
if not commits:
return "No checkpoints yet.\n"
lines = [f"{len(commits)} checkpoint(s):", ""]
for c in commits:
created = _relative_time(c.get("created_at") or "")
branch = c.get("branch_name") or "main"
marker = "" if branch == "main" else f" (branch: {branch})"
lines.append(
f"- `{_short_hash(c.get('commit_hash'))}` {c.get('message', 'Untitled')}"
f" · {created}{marker}"
)
return "\n".join(lines) + "\n"
_REVIEW_ISSUE_LABELS = {
"contradiction": "Possible contradiction",
"hidden_assumption": "Hidden assumption",
"resolved_question": "Possibly resolved",
"unused_entity": "Possibly unused entity",
}
def format_review(result: dict) -> str:
issues = result.get("issues") or []
suggestions = result.get("suggestions") or []
if not issues and not suggestions:
return "No issues found — reasoning looks consistent.\n"
parts = ["# Checkpoint review\n"]
if issues:
parts.append(f"Found {len(issues)} issue(s):\n")
for i, issue in enumerate(issues, 1):
label = _REVIEW_ISSUE_LABELS.get(issue.get("type"), issue.get("type", "Issue"))
parts.append(f"{i}. **{label}**")
parts.append(f" {issue.get('description', '').strip()}")
parts.append("")
if suggestions:
parts.append("## Suggestions")
for s in suggestions:
parts.append(f"- {s}")
parts.append("")
return "\n".join(parts).rstrip() + "\n"

296
cli/smriti_cli/main.py Normal file
View file

@ -0,0 +1,296 @@
"""Smriti CLI entry point.
Seven commands for agent and programmatic use:
smriti space list
smriti space create <name> [--description]
smriti state <space>
smriti checkpoint create <space> # reads JSON from stdin
smriti checkpoint show <checkpoint-id>
smriti checkpoint list <space>
smriti checkpoint review <checkpoint-id>
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 sys
from typing import Any
from .client import SmritiClient, SmritiError
from .formatters import (
format_checkpoint,
format_commit_list,
format_review,
format_space_list,
format_state_brief,
)
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)
_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_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_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:
space = client.create_space(name=args.name, description=args.description or "")
if args.json:
_print_json(space)
else:
print(f"Created space: {space['name']} `{space['id']}`")
def cmd_state(client: SmritiClient, args: argparse.Namespace) -> None:
space = client.resolve_space(args.space)
head = client.get_head(space["id"])
if not head.get("commit_id"):
# Space exists but has no checkpoints yet.
if args.json:
_print_json({"space": space, "head": head, "commit": None})
return
print(f"# {space['name']}")
if space.get("description"):
print(space["description"])
print()
print("No checkpoints yet. Create one with `smriti checkpoint create`.")
return
commit = client.get_commit(head["commit_id"])
if args.json:
_print_json({"space": space, "head": head, "commit": commit})
else:
print(
format_state_brief(space, head, commit, full_artifacts=args.full_artifacts),
end="",
)
def cmd_checkpoint_create(client: SmritiClient, args: argparse.Namespace) -> None:
space = client.resolve_space(args.space)
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.")
# 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. Agents should not care about the session.
session = client.create_session(
repo_id=space["id"],
title=f"cli: {payload['message'][:80]}",
)
commit_payload = {
"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", []),
}
commit = client.create_chat_commit(commit_payload)
if args.json:
_print_json(commit)
else:
h = commit.get("commit_hash", "")
print(f"Created checkpoint: `{h[:7]}` {commit.get('message', '')}")
def cmd_checkpoint_show(client: SmritiClient, args: argparse.Namespace) -> None:
commit = client.get_commit(args.checkpoint_id)
if args.json:
_print_json(commit)
else:
print(format_checkpoint(commit, full_artifacts=args.full_artifacts), end="")
def cmd_checkpoint_list(client: SmritiClient, args: argparse.Namespace) -> None:
space = client.resolve_space(args.space)
commits = client.list_commits(space["id"], branch=args.branch)
if args.json:
_print_json(commits)
else:
print(format_commit_list(commits), end="")
def cmd_checkpoint_review(client: SmritiClient, args: argparse.Namespace) -> None:
result = client.review_checkpoint(args.checkpoint_id)
if args.json:
_print_json(result)
else:
print(format_review(result), end="")
# ── 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)
# 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="")
sp_create.add_argument("--json", action="store_true", help="Output structured JSON")
sp_create.set_defaults(func=cmd_space_create)
# state
state_parser = subparsers.add_parser(
"state",
help="Print a continuation-oriented brief of the current project state",
)
state_parser.add_argument("space", help="Space name or UUID")
state_parser.add_argument(
"--full-artifacts",
action="store_true",
help="Include full artifact content (default truncates previews)",
)
state_parser.add_argument("--json", action="store_true", help="Output structured JSON")
state_parser.set_defaults(func=cmd_state)
# checkpoint
cp_parser = subparsers.add_parser("checkpoint", help="Manage checkpoints")
cp_sub = cp_parser.add_subparsers(dest="subcommand", required=True)
cp_create = cp_sub.add_parser(
"create",
help="Create a checkpoint from JSON on stdin or --from-json <path>",
)
cp_create.add_argument("space", help="Space name or UUID")
cp_create.add_argument(
"--from-json",
help="Path to a JSON file with the checkpoint payload (use '-' for stdin)",
)
cp_create.add_argument("--json", action="store_true", help="Output structured JSON")
cp_create.set_defaults(func=cmd_checkpoint_create)
cp_show = cp_sub.add_parser("show", help="Print a specific checkpoint as markdown")
cp_show.add_argument("checkpoint_id", help="Checkpoint UUID")
cp_show.add_argument(
"--full-artifacts",
action="store_true",
help="Include full artifact content",
)
cp_show.add_argument("--json", action="store_true", help="Output structured JSON")
cp_show.set_defaults(func=cmd_checkpoint_show)
cp_list = cp_sub.add_parser("list", help="List checkpoints in a space")
cp_list.add_argument("space", help="Space name or UUID")
cp_list.add_argument("--branch", help="Filter by branch name")
cp_list.add_argument("--json", action="store_true", help="Output structured JSON")
cp_list.set_defaults(func=cmd_checkpoint_list)
cp_review = cp_sub.add_parser("review", help="Run consistency review on a checkpoint")
cp_review.add_argument("checkpoint_id", help="Checkpoint UUID")
cp_review.add_argument("--json", action="store_true", help="Output structured JSON")
cp_review.set_defaults(func=cmd_checkpoint_review)
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())