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