smriti/cli/smriti_cli/attachment.py
Himanshu Dongre b3734f3dc3 Add durable project attachment so repos remember their Smriti space
Smriti was repo-local wiring: every command needed an explicit <space>, and
the SessionStart hook hard-coded the space name, so working across projects
and sessions meant re-stating the space constantly.

Add a `.smriti.json` attachment file at the repo root binding the repo to a
space. The CLI resolves `<space>` from it — walking up from the working
directory the way git finds `.git` — so `<space>` is now optional on the
everyday commands (state, current, metrics, claim, checkpoint, branch,
worktree). `smriti attach <space>` is the explicit binding verb (`smriti
attach` with no argument shows the current binding); `smriti init` also
writes the attachment. The generated SessionStart hook is now space-agnostic
— `smriti state --compact` resolves from the attachment, so the hook is
identical for every project and survives re-attaching.

`space delete` still requires an explicit space. No backend changes —
attach/init reuse the existing set_project_root endpoint.
2026-05-18 00:52:37 +05:30

79 lines
2.7 KiB
Python

"""Durable project <-> Smriti space attachment.
A repo is "attached" to a Smriti space by a `.smriti.json` file at its root.
Once attached, the CLI resolves the space automatically: agents and humans
stop passing `<space>` on every command, and a session opened anywhere in
the repo connects to the right space.
The file is intentionally tiny and git-committable. It records the space
*name* — the portable, human-meaningful key — plus the space id as a hint.
Resolution walks up from the working directory, the way git finds `.git`,
so any subdirectory of an attached repo resolves the same space.
A malformed or unreadable attachment is treated as "not attached" rather
than an error: a broken file must never make a command crash.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
ATTACHMENT_FILENAME = ".smriti.json"
ATTACHMENT_VERSION = 1
def find_attachment_file(start: str | os.PathLike[str] | None = None) -> Optional[Path]:
"""Walk up from `start` (default: cwd) for a `.smriti.json` file.
Returns the Path of the first one found, or None. Mirrors how git
locates `.git`, so the CLI resolves the space from any subdirectory
of an attached repo.
"""
try:
current = Path(start or os.getcwd()).resolve()
except OSError:
return None
for directory in (current, *current.parents):
candidate = directory / ATTACHMENT_FILENAME
if candidate.is_file():
return candidate
return None
def read_attachment(start: str | os.PathLike[str] | None = None) -> Optional[dict]:
"""Return the attachment record for the repo containing `start`, or None.
The record has at least a `space` (name); it may also carry `space_id`
and `version`. A malformed file resolves to None — never raises.
"""
path = find_attachment_file(start)
if path is None:
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return None
if not isinstance(data, dict) or not data.get("space"):
return None
return data
def write_attachment(
repo_dir: str | os.PathLike[str],
space_name: str,
space_id: str | None = None,
) -> Path:
"""Write `.smriti.json` into `repo_dir`, recording the repo->space binding.
Returns the path written. Overwrites any existing attachment — re-attaching
a repo to a different space is a supported, explicit action.
"""
path = Path(repo_dir) / ATTACHMENT_FILENAME
record: dict = {"version": ATTACHMENT_VERSION, "space": space_name}
if space_id:
record["space_id"] = space_id
path.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
return path