cgcardona / muse public
cat_object.py python
293 lines 9.5 KB
12901c5a Initial extraction from tellurstori/maestro cgcardona <gabriel@tellurstori.com> 4d ago
1 """muse cat-object — inspect a raw object in the Muse content-addressed store.
2
3 Mirrors ``git cat-file`` plumbing semantics for Muse's three object types:
4
5 - **object** — a content-addressed file blob (``MuseCliObject``)
6 - **snapshot** — an immutable manifest mapping paths to object IDs
7 (``MuseCliSnapshot``)
8 - **commit** — a versioned record pointing to a snapshot and its parent
9 (``MuseCliCommit``)
10
11 The command tries each table in order (object → snapshot → commit) until it
12 finds a match, so a full 64-character SHA-256 hash is always unambiguous.
13 Short prefixes are NOT supported — callers must supply the full hash (as
14 returned by ``muse log``, ``muse commit``, etc.).
15
16 Output modes
17 ------------
18
19 Default (no flags) — human-readable metadata summary::
20
21 type: object
22 object_id: a1b2c3d4...
23 size: 4096 bytes
24 created_at: 2026-02-27T17:30:00+00:00
25
26 ``-t / --type`` — print only the type string (one of ``object``,
27 ``snapshot``, ``commit``) and exit, mirroring ``git cat-file -t``::
28
29 object
30
31 ``-p / --pretty`` — pretty-print the object contents:
32
33 - For **objects**: prints size and creation time (binary blobs have no
34 text representation stored in the DB; the raw bytes live on disk).
35 - For **snapshots**: prints the manifest JSON (path → object_id mapping).
36 - For **commits**: prints all commit fields as indented JSON.
37
38 Agent use case
39 --------------
40 AI agents use ``muse cat-object`` to inspect object metadata before
41 deciding whether to re-fetch, export, or reference an artifact. The
42 ``-p`` flag gives structured JSON that agents can parse directly.
43 """
44 from __future__ import annotations
45
46 import asyncio
47 import json
48 import logging
49
50 import typer
51 from sqlalchemy.ext.asyncio import AsyncSession
52
53 from maestro.muse_cli._repo import require_repo
54 from maestro.muse_cli.db import open_session
55 from maestro.muse_cli.errors import ExitCode
56 from maestro.muse_cli.models import MuseCliCommit, MuseCliObject, MuseCliSnapshot
57
58 logger = logging.getLogger(__name__)
59
60 app = typer.Typer()
61
62 # ---------------------------------------------------------------------------
63 # Result type
64 # ---------------------------------------------------------------------------
65
66 ObjectType = str # "object" | "snapshot" | "commit"
67
68
69 class CatObjectResult:
70 """Structured result from looking up a Muse object by ID.
71
72 Wraps whichever ORM row was found and records its type so renderers
73 don't need to isinstance-check.
74
75 Args:
76 object_type: One of ``"object"``, ``"snapshot"``, or ``"commit"``.
77 row: The ORM row (``MuseCliObject | MuseCliSnapshot |
78 MuseCliCommit``).
79 """
80
81 def __init__(
82 self,
83 *,
84 object_type: ObjectType,
85 row: MuseCliObject | MuseCliSnapshot | MuseCliCommit,
86 ) -> None:
87 self.object_type = object_type
88 self.row = row
89
90 def to_dict(self) -> dict[str, object]:
91 """Serialise to a JSON-compatible dict.
92
93 The shape varies by object type and is intended for agent
94 consumption via ``-p``.
95 """
96 if self.object_type == "object":
97 obj = self.row
98 assert isinstance(obj, MuseCliObject)
99 return {
100 "type": "object",
101 "object_id": obj.object_id,
102 "size_bytes": obj.size_bytes,
103 "created_at": obj.created_at.isoformat(),
104 }
105 if self.object_type == "snapshot":
106 snap = self.row
107 assert isinstance(snap, MuseCliSnapshot)
108 return {
109 "type": "snapshot",
110 "snapshot_id": snap.snapshot_id,
111 "manifest": snap.manifest,
112 "created_at": snap.created_at.isoformat(),
113 }
114 # commit
115 commit = self.row
116 assert isinstance(commit, MuseCliCommit)
117 return {
118 "type": "commit",
119 "commit_id": commit.commit_id,
120 "repo_id": commit.repo_id,
121 "branch": commit.branch,
122 "parent_commit_id": commit.parent_commit_id,
123 "parent2_commit_id": commit.parent2_commit_id,
124 "snapshot_id": commit.snapshot_id,
125 "message": commit.message,
126 "author": commit.author,
127 "committed_at": commit.committed_at.isoformat(),
128 "created_at": commit.created_at.isoformat(),
129 "metadata": commit.commit_metadata,
130 }
131
132
133 # ---------------------------------------------------------------------------
134 # Async core — fully injectable for tests
135 # ---------------------------------------------------------------------------
136
137
138 async def _lookup_object(
139 session: AsyncSession,
140 object_id: str,
141 ) -> CatObjectResult | None:
142 """Probe all three object tables and return the first match.
143
144 Lookup order: MuseCliObject → MuseCliSnapshot → MuseCliCommit.
145 Returns ``None`` when the ID is not found in any table.
146
147 Args:
148 session: An open async DB session.
149 object_id: The full SHA-256 hash to look up (64 hex chars).
150 """
151 obj = await session.get(MuseCliObject, object_id)
152 if obj is not None:
153 return CatObjectResult(object_type="object", row=obj)
154
155 snap = await session.get(MuseCliSnapshot, object_id)
156 if snap is not None:
157 return CatObjectResult(object_type="snapshot", row=snap)
158
159 commit = await session.get(MuseCliCommit, object_id)
160 if commit is not None:
161 return CatObjectResult(object_type="commit", row=commit)
162
163 return None
164
165
166 async def _cat_object_async(
167 *,
168 session: AsyncSession,
169 object_id: str,
170 type_only: bool,
171 pretty: bool,
172 ) -> None:
173 """Core cat-object logic — fully injectable for tests.
174
175 Resolves *object_id* across all three Muse object tables and prints
176 the requested representation. Exits non-zero when the object is not found.
177
178 Args:
179 session: Open async DB session.
180 object_id: Full SHA-256 hash to look up.
181 type_only: When ``True``, print only the type string and exit.
182 pretty: When ``True``, pretty-print the object's content as JSON.
183 """
184 result = await _lookup_object(session, object_id)
185 if result is None:
186 typer.echo(f"❌ Object not found: {object_id}")
187 raise typer.Exit(code=ExitCode.USER_ERROR)
188
189 if type_only:
190 typer.echo(result.object_type)
191 return
192
193 if pretty:
194 typer.echo(json.dumps(result.to_dict(), indent=2))
195 return
196
197 # Default: human-readable metadata summary
198 _render_metadata(result)
199
200
201 def _render_metadata(result: CatObjectResult) -> None:
202 """Print a terse human-readable metadata block for the found object."""
203 if result.object_type == "object":
204 obj = result.row
205 assert isinstance(obj, MuseCliObject)
206 typer.echo(f"type: object")
207 typer.echo(f"object_id: {obj.object_id}")
208 typer.echo(f"size: {obj.size_bytes} bytes")
209 typer.echo(f"created_at: {obj.created_at.isoformat()}")
210 return
211
212 if result.object_type == "snapshot":
213 snap = result.row
214 assert isinstance(snap, MuseCliSnapshot)
215 file_count = len(snap.manifest) if snap.manifest else 0
216 typer.echo(f"type: snapshot")
217 typer.echo(f"snapshot_id: {snap.snapshot_id}")
218 typer.echo(f"files: {file_count}")
219 typer.echo(f"created_at: {snap.created_at.isoformat()}")
220 return
221
222 # commit
223 commit = result.row
224 assert isinstance(commit, MuseCliCommit)
225 typer.echo(f"type: commit")
226 typer.echo(f"commit_id: {commit.commit_id}")
227 typer.echo(f"branch: {commit.branch}")
228 typer.echo(f"snapshot: {commit.snapshot_id}")
229 typer.echo(f"message: {commit.message}")
230 if commit.parent_commit_id:
231 typer.echo(f"parent: {commit.parent_commit_id}")
232 typer.echo(f"committed_at: {commit.committed_at.isoformat()}")
233
234
235 # ---------------------------------------------------------------------------
236 # Typer command
237 # ---------------------------------------------------------------------------
238
239
240 @app.callback(invoke_without_command=True)
241 def cat_object(
242 ctx: typer.Context,
243 object_id: str = typer.Argument(
244 ...,
245 help="Full SHA-256 object ID to look up (64 hex characters).",
246 metavar="<object-id>",
247 ),
248 type_only: bool = typer.Option(
249 False,
250 "-t",
251 "--type",
252 help="Print only the type of the object (object, snapshot, or commit).",
253 ),
254 pretty: bool = typer.Option(
255 False,
256 "-p",
257 "--pretty",
258 help=(
259 "Pretty-print the object content as JSON. "
260 "For snapshots: manifest. For commits: all fields. "
261 "For objects: size and creation time."
262 ),
263 ),
264 ) -> None:
265 """Read and display a stored Muse object by its SHA-256 hash.
266
267 Probes the object store (blob, snapshot, commit) for the given ID and
268 prints a summary. Use ``-t`` to get just the type, or ``-p`` to get
269 the full JSON representation.
270 """
271 if type_only and pretty:
272 typer.echo("❌ --type and --pretty are mutually exclusive.")
273 raise typer.Exit(code=ExitCode.USER_ERROR)
274
275 require_repo()
276
277 async def _run() -> None:
278 async with open_session() as session:
279 await _cat_object_async(
280 session=session,
281 object_id=object_id,
282 type_only=type_only,
283 pretty=pretty,
284 )
285
286 try:
287 asyncio.run(_run())
288 except typer.Exit:
289 raise
290 except Exception as exc:
291 typer.echo(f"❌ muse cat-object failed: {exc}")
292 logger.error("❌ muse cat-object error: %s", exc, exc_info=True)
293 raise typer.Exit(code=ExitCode.INTERNAL_ERROR)