agent-memory-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agent_memory/cli.py ADDED
@@ -0,0 +1,437 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Command-line interface: ``agent-memory``."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Callable, List, Optional, Sequence, Tuple
12
+
13
+ from . import __version__, archive, debrief, doctor, org, setup, startup, status, sync, workflow
14
+ from .home import BINDING_FILE, HomeError, HomeResolution, find_project, resolve_home
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FAILED = 1
18
+ EXIT_USAGE = 2
19
+ #: ``setup`` found something it will not write over; the report names it.
20
+ EXIT_CONFLICT = 3
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ common = argparse.ArgumentParser(add_help=False)
25
+ common.add_argument(
26
+ "--home",
27
+ metavar="PATH",
28
+ help="memory home (default: $AGENT_MEMORY_HOME, then $OACP_HOME, then a binding or workspace marker "
29
+ "found above the working directory, else ~/agent-memory)",
30
+ )
31
+ agent = argparse.ArgumentParser(add_help=False)
32
+ agent.add_argument(
33
+ "--agent",
34
+ metavar="NAME",
35
+ help=f"the agent the commit is published under (default: ${sync.ENV_AGENT}, then $AGENT_NAME, then $USER)",
36
+ )
37
+ parser = argparse.ArgumentParser(
38
+ prog="agent-memory",
39
+ description="Cross-session memory for coding agents: plain files, git-native, no server.",
40
+ allow_abbrev=False,
41
+ )
42
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
43
+ commands = parser.add_subparsers(dest="command", metavar="<command>")
44
+
45
+ def add(name: str, handler: Callable[[argparse.Namespace], int], help_text: str, *parents: argparse.ArgumentParser) -> argparse.ArgumentParser:
46
+ command = commands.add_parser(name, parents=[common, *parents], help=help_text, description=help_text)
47
+ command.set_defaults(handler=handler)
48
+ return command
49
+
50
+ status_ = add(
51
+ "status",
52
+ _status,
53
+ "Show which home resolves, which rule chose it, whether the layout is in place, and where the sync stands. "
54
+ "Exit 1 when the tree is dirty or diverged.",
55
+ )
56
+ status_.add_argument(
57
+ "--fetch", action="store_true", help="contact the remote before counting ahead/behind (no network otherwise)"
58
+ )
59
+ doctor_ = add(
60
+ "doctor",
61
+ _doctor,
62
+ "Check the home's setup and health: the debrief store's layout and the sync repository. "
63
+ "Reads no memory content; repairs nothing. Exit 1 on an error row.",
64
+ )
65
+ doctor_.add_argument("--json", dest="json_output", action="store_true", help="emit the report as JSON")
66
+ init_ = add(
67
+ "init",
68
+ _init,
69
+ "Create the memory home from the templates bundled in the package: the org tier, and with --project that "
70
+ "project's tier; with --repo, bind that repository to the home. No git, no network.",
71
+ )
72
+ init_.add_argument(
73
+ "--project", metavar="ID", help="also create this project's memory tier (derived from --repo's name when omitted)"
74
+ )
75
+ init_.add_argument(
76
+ "--repo", metavar="PATH", help=f"record a {BINDING_FILE} binding in this repository, after every other check"
77
+ )
78
+ org_ = commands.add_parser("org", help="Org-tier commands.", description="Org-tier commands.")
79
+ org_commands = org_.add_subparsers(dest="org_command", metavar="<command>")
80
+ org_init = org_commands.add_parser(
81
+ "init",
82
+ parents=[common],
83
+ help="Scaffold the org tier of an existing home from the bundled templates.",
84
+ description="Scaffold the org tier of an existing home from the bundled templates.",
85
+ )
86
+ org_init.set_defaults(handler=_org_init)
87
+ enable = add(
88
+ "enable", _enable, "Make the home a sync repository: git, the managed ignore block, the marker, one commit.", agent
89
+ )
90
+ enable.add_argument("--remote", metavar="URL", help="git remote to sync with (added or updated as 'origin')")
91
+ clone_ = add("clone", _clone, "Clone a memory repository into the home.")
92
+ clone_.add_argument("url", help="git remote URL to clone")
93
+ clone_.add_argument("--force", action="store_true", help="move a non-empty home aside before cloning")
94
+ add("pull", _pull, "Fast-forward the home from its upstream when the tree is clean and not ahead.")
95
+ add("push", _push, "Commit the allowlisted memory changes and push them when a remote exists.", agent)
96
+ add("disable", _disable, "Remove the sync marker; the repository stays in place.")
97
+ for name, handler, help_text, positional, positional_help in (
98
+ ("archive", _archive, "Move a supplementary memory file into the project's memory/archive/.",
99
+ "memory_file", "basename of the active memory file to archive"),
100
+ ("restore", _restore, "Move an archived memory file back into the project's active memory.",
101
+ "archived_file", "basename of the archived file to restore (<UTC timestamp>_<basename>)"),
102
+ ):
103
+ command = add(name, handler, help_text)
104
+ command.add_argument("project", help="project name under projects/")
105
+ command.add_argument(positional, help=positional_help)
106
+ command.add_argument("--dry-run", action="store_true", help="perform every check and report; move nothing")
107
+ command.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON")
108
+ setup_ = add(
109
+ "setup",
110
+ _setup,
111
+ "Install the runtime's session-start memory hook in a repository: the script, its registration, a receipt "
112
+ "in the home; retire the legacy hooks by exact match. Never writes over an edited file or through a symlink "
113
+ "(exit 3 names what it kept). No push hook, ever.",
114
+ )
115
+ setup_.add_argument("runtime", choices=setup.RUNTIMES, help="the runtime whose hook to install")
116
+ setup_.add_argument("--repo", metavar="PATH", help="the repository (default: the nearest .git above the working directory)")
117
+ setup_.add_argument("--dry-run", action="store_true", help="print the plan; write nothing")
118
+ setup_.add_argument("--json", dest="json_output", action="store_true", help="emit the plan or result as JSON")
119
+ startup_ = add(
120
+ "startup",
121
+ _startup,
122
+ "Print the session-start manifest: the memory files to read, in order, with their readability, size and "
123
+ "age, and where the sync stands. Content is never included. With --pull, fast-forward the home first.",
124
+ )
125
+ startup_.add_argument("--runtime", choices=startup.RUNTIMES, required=True, help="shape the output for this runtime's hook")
126
+ startup_.add_argument("--project", metavar="ID", help="the project tier to list (default: the one the binding or marker names)")
127
+ startup_.add_argument("--pull", action="store_true", help="pull the home before listing; a failed pull is a warning")
128
+ startup_.add_argument("--json", dest="json_output", action="store_true", help="emit the manifest as JSON")
129
+ startup_.add_argument(
130
+ "--max-chars",
131
+ type=_max_chars,
132
+ default=startup.DEFAULT_MAX_CHARS,
133
+ metavar="N",
134
+ help=f"cut the rendered text, its notice included, at N characters (at least {startup.MIN_MAX_CHARS})",
135
+ )
136
+ capture_ = add(
137
+ "capture",
138
+ _capture,
139
+ "Record one decision in the project's decision_log.md, newest first under today's UTC date, with its "
140
+ "provenance (agent, time, source). The project comes from --project, else from the repository's binding or "
141
+ "workspace marker.",
142
+ )
143
+ capture_.add_argument("decision", help="the decision, one sentence")
144
+ capture_.add_argument("--why", metavar="TEXT", help="the reason, one sentence")
145
+ capture_.add_argument("--source", metavar="REF", help="where it was decided: a PR, an issue, a message")
146
+ capture_.add_argument(
147
+ "--agent", metavar="NAME", help=f"who is capturing (default: ${workflow.DEFAULT_AGENT_ENV}, else the user)"
148
+ )
149
+ capture_.add_argument("--project", metavar="ID", help="the project whose log to write (default: the bound one)")
150
+ capture_.add_argument("--dry-run", action="store_true", help="compose the entry and report; write nothing")
151
+ capture_.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON")
152
+ recall_ = add(
153
+ "recall",
154
+ _recall,
155
+ "Print the memory files the startup manifest lists, in its order, with their content, cut at a character "
156
+ "budget: the bounded read at session start. archive/, events/ and debriefs/ are never loaded.",
157
+ )
158
+ recall_.add_argument("--project", metavar="ID", help="the project tier to read (default: the bound one)")
159
+ recall_.add_argument(
160
+ "--runtime",
161
+ choices=startup.RUNTIMES,
162
+ help=f"the runtime reading (default: ${workflow.DEFAULT_AGENT_ENV} when it names one, else {startup.RUNTIME_CLAUDE})",
163
+ )
164
+ recall_.add_argument(
165
+ "--max-chars",
166
+ type=_max_chars,
167
+ default=startup.DEFAULT_MAX_CHARS,
168
+ metavar="N",
169
+ help=f"cut the text, its notice included, at N characters (at least {startup.MIN_MAX_CHARS})",
170
+ )
171
+ recall_.add_argument("--json", dest="json_output", action="store_true", help="emit the result as JSON")
172
+ debrief_ = commands.add_parser("debrief", help="Debrief-store commands.", description="Debrief-store commands.")
173
+ debrief_commands = debrief_.add_subparsers(dest="debrief_command", metavar="<command>")
174
+ write_help = (
175
+ "Publish one session debrief into the home's debrief store, failure-atomically: the canonical path only ever "
176
+ "holds a complete, verified record. Exit 0 published (or an identical record was already there), 1 on a "
177
+ "validation error, 2 on a publication failure."
178
+ )
179
+ write = debrief_commands.add_parser("write", parents=[common], help=write_help, description=write_help)
180
+ write.set_defaults(handler=_debrief_write)
181
+ write.add_argument("--project", required=True, help="workspace project name")
182
+ write.add_argument("--agent", required=True, help="writing agent name")
183
+ write.add_argument("--runtime", required=True, help="runtime family (claude, codex, ...)")
184
+ write.add_argument("--session", required=True, help="short session id: 1-32 lowercase alphanumerics, no hyphens")
185
+ write.add_argument("--started-utc", required=True, help="session start, ISO 8601 UTC (Z)")
186
+ write.add_argument("--ended-utc", required=True, help="session end, ISO 8601 UTC (Z)")
187
+ write.add_argument("--body-file", required=True, help="path to the debrief body in Markdown, or '-' to read stdin")
188
+ # The name the writer carried before it became this verb; accepted, unadvertised, through v0.1.x.
189
+ write.add_argument("--oacp-dir", dest="home", metavar="PATH", help=argparse.SUPPRESS)
190
+ write.add_argument("--dry-run", action="store_true", help="validate and compose the record, print it, and write nothing")
191
+ write.add_argument("--json", dest="json_output", action="store_true", help="emit a machine-readable result")
192
+ return parser
193
+
194
+
195
+ def _max_chars(value: str) -> int:
196
+ try:
197
+ number = int(value)
198
+ except ValueError as exc:
199
+ raise argparse.ArgumentTypeError(f"expected an integer, got {value!r}") from exc
200
+ if number < startup.MIN_MAX_CHARS:
201
+ raise argparse.ArgumentTypeError(f"must be at least {startup.MIN_MAX_CHARS}, got {number}")
202
+ return number
203
+
204
+
205
+ def _status(args: argparse.Namespace) -> int:
206
+ readout = status.inspect(resolve_home(args.home), fetch=args.fetch)
207
+ print("\n".join(readout.lines()))
208
+ return readout.exit_code
209
+
210
+
211
+ def _doctor(args: argparse.Namespace) -> int:
212
+ home = resolve_home(args.home).path
213
+ if not home.is_dir():
214
+ print(f"agent-memory: error: {home} is not a directory", file=sys.stderr)
215
+ return EXIT_FAILED
216
+ categories = doctor.run_doctor(home)
217
+ memory_lint = doctor.find_memory_lint()
218
+ if args.json_output:
219
+ print(json.dumps(doctor.to_json(categories, memory_lint=memory_lint), indent=2))
220
+ else:
221
+ sys.stdout.write(doctor.report(categories, memory_lint=memory_lint))
222
+ return EXIT_FAILED if doctor.has_errors(categories) else EXIT_OK
223
+
224
+
225
+ def _init(args: argparse.Namespace) -> int:
226
+ repo = Path(args.repo) if args.repo else None
227
+ report = org.init(resolve_home(args.home).path, project=args.project, repo=repo)
228
+ print("\n".join(report.lines()))
229
+ return EXIT_OK
230
+
231
+
232
+ def _org_init(args: argparse.Namespace) -> int:
233
+ print("\n".join(org.org_init(resolve_home(args.home).path).lines()))
234
+ return EXIT_OK
235
+
236
+
237
+ def _enable(args: argparse.Namespace) -> int:
238
+ return _report(sync.init(resolve_home(args.home).path, remote=args.remote, agent=args.agent))
239
+
240
+
241
+ def _clone(args: argparse.Namespace) -> int:
242
+ return _report(sync.clone(resolve_home(args.home).path, args.url, force=args.force))
243
+
244
+
245
+ def _pull(args: argparse.Namespace) -> int:
246
+ return _report(sync.pull(resolve_home(args.home).path))
247
+
248
+
249
+ def _push(args: argparse.Namespace) -> int:
250
+ return _report(sync.push(resolve_home(args.home).path, agent=args.agent))
251
+
252
+
253
+ def _disable(args: argparse.Namespace) -> int:
254
+ return _report(sync.disable(resolve_home(args.home).path))
255
+
256
+
257
+ def _archive(args: argparse.Namespace) -> int:
258
+ result = archive.archive(resolve_home(args.home).path, args.project, args.memory_file, dry_run=args.dry_run)
259
+ if args.json_output:
260
+ print(json.dumps(result, indent=2, sort_keys=True))
261
+ else:
262
+ verb = "Would archive" if args.dry_run else "Archived"
263
+ print(f"{verb} memory/{result['memory_file']} -> memory/{archive.ARCHIVE_DIR}/{result['archived_file']}")
264
+ return EXIT_OK
265
+
266
+
267
+ def _restore(args: argparse.Namespace) -> int:
268
+ result = archive.restore(resolve_home(args.home).path, args.project, args.archived_file, dry_run=args.dry_run)
269
+ if args.json_output:
270
+ print(json.dumps(result, indent=2, sort_keys=True))
271
+ else:
272
+ verb = "Would restore" if args.dry_run else "Restored"
273
+ print(f"{verb} memory/{archive.ARCHIVE_DIR}/{result['archived_file']} -> memory/{result['restored_file']}")
274
+ return EXIT_OK
275
+
276
+
277
+ def _setup(args: argparse.Namespace) -> int:
278
+ spec = setup.SPECS[args.runtime]
279
+ repo = Path(args.repo) if args.repo else setup.detect_repo(Path.cwd())
280
+ result = setup.run_setup(spec, repo, resolve_home(args.home).path, dry_run=args.dry_run)
281
+ if args.json_output:
282
+ print(json.dumps(setup.to_json(result), indent=2))
283
+ else:
284
+ print("\n".join(setup.lines(result)))
285
+ return EXIT_CONFLICT if result.plan.conflicts else EXIT_OK
286
+
287
+
288
+ def _resolve_project(flag: Optional[str], resolution: HomeResolution) -> Tuple[Optional[str], Optional[str], List[str]]:
289
+ """``(project, source, notes)``: the flag, else what chose the home, else the repository's binding or marker."""
290
+ if flag:
291
+ return flag, "flag", []
292
+ if resolution.project:
293
+ return resolution.project, resolution.source, []
294
+ # A flag or an environment variable chose the home; the repository's binding or marker still names the project.
295
+ found = find_project(resolution.path, Path.cwd())
296
+ return found.project, found.source, [found.note] if found.note else []
297
+
298
+
299
+ def _capture(args: argparse.Namespace) -> int:
300
+ resolution = resolve_home(args.home)
301
+ project, _, notes = _resolve_project(args.project, resolution)
302
+ if project is None:
303
+ for note in notes:
304
+ print(f"agent-memory: {note}", file=sys.stderr)
305
+ print(
306
+ "agent-memory: error: no project resolved; pass --project or bind the repository with `agent-memory init --repo .`",
307
+ file=sys.stderr,
308
+ )
309
+ return EXIT_USAGE
310
+ result = workflow.capture(
311
+ resolution.path, project, args.decision, why=args.why, source=args.source, agent=args.agent, dry_run=args.dry_run
312
+ )
313
+ if args.json_output:
314
+ print(json.dumps(result, indent=2))
315
+ else:
316
+ verb = "would capture" if args.dry_run else "captured"
317
+ print(f"{verb}: {result['path']} (## {result['date']})")
318
+ print(result["entry"])
319
+ return EXIT_OK
320
+
321
+
322
+ def _recall(args: argparse.Namespace) -> int:
323
+ resolution = resolve_home(args.home)
324
+ project, project_source, notes = _resolve_project(args.project, resolution)
325
+ runtime = args.runtime or workflow.default_runtime()
326
+ result = workflow.recall(
327
+ resolution.path,
328
+ project=project,
329
+ runtime=runtime,
330
+ max_chars=args.max_chars,
331
+ home_source=resolution.source,
332
+ project_source=project_source,
333
+ notes=notes,
334
+ )
335
+ if args.json_output:
336
+ print(json.dumps(result, indent=2))
337
+ else:
338
+ sys.stdout.write(result["text"])
339
+ return EXIT_OK
340
+
341
+
342
+ def _startup(args: argparse.Namespace) -> int:
343
+ resolution = resolve_home(args.home)
344
+ project, project_source, notes = _resolve_project(args.project, resolution)
345
+ manifest = startup.build_manifest(
346
+ resolution.path,
347
+ runtime=args.runtime,
348
+ project=project,
349
+ pull=args.pull,
350
+ home_source=resolution.source,
351
+ project_source=project_source,
352
+ notes=notes,
353
+ )
354
+ if args.json_output:
355
+ print(json.dumps(manifest, indent=2))
356
+ elif args.runtime == startup.RUNTIME_CODEX:
357
+ print(json.dumps(startup.render_codex_hook(manifest, max_chars=args.max_chars)))
358
+ else:
359
+ sys.stdout.write(startup.render_text(manifest, max_chars=args.max_chars))
360
+ return EXIT_OK
361
+
362
+
363
+ def _debrief_write(args: argparse.Namespace) -> int:
364
+ home = resolve_home(args.home).path
365
+ try:
366
+ if args.body_file == "-":
367
+ body = sys.stdin.buffer.read()
368
+ else:
369
+ body = Path(args.body_file).expanduser().read_bytes()
370
+ except OSError as exc:
371
+ print(f"ERROR: cannot read body: {exc}", file=sys.stderr)
372
+ return EXIT_FAILED
373
+
374
+ try:
375
+ result = debrief.write_debrief(
376
+ home=home,
377
+ project=args.project,
378
+ agent=args.agent,
379
+ runtime=args.runtime,
380
+ session=args.session,
381
+ started_utc=args.started_utc,
382
+ ended_utc=args.ended_utc,
383
+ body=body,
384
+ dry_run=args.dry_run,
385
+ )
386
+ except debrief.WriterError as exc:
387
+ print(f"ERROR: {exc}", file=sys.stderr)
388
+ return exc.code
389
+ except OSError as exc:
390
+ print(f"ERROR: publication failed: {exc}", file=sys.stderr)
391
+ return 2
392
+
393
+ if args.json_output:
394
+ print(
395
+ json.dumps(
396
+ {
397
+ "path": str(result.path),
398
+ "status": result.status,
399
+ "content_sha256": result.content_sha256,
400
+ "schema_version": debrief.SCHEMA_VERSION,
401
+ },
402
+ indent=2,
403
+ )
404
+ )
405
+ else:
406
+ print(f"{result.status}: {result.path}")
407
+ print(f"content_sha256: {result.content_sha256}")
408
+
409
+ if args.dry_run:
410
+ print("--- record preview (nothing was written) ---", file=sys.stderr)
411
+ sys.stderr.flush()
412
+ sys.stderr.buffer.write(result.record)
413
+ sys.stderr.buffer.flush()
414
+ return EXIT_OK
415
+
416
+
417
+ def _report(outcome: sync.Outcome) -> int:
418
+ if outcome.lines:
419
+ print("\n".join(outcome.lines), file=sys.stdout if outcome.ok else sys.stderr)
420
+ return EXIT_OK if outcome.ok else EXIT_FAILED
421
+
422
+
423
+ def main(argv: Optional[Sequence[str]] = None) -> int:
424
+ parser = build_parser()
425
+ args = parser.parse_args(argv)
426
+ handler = getattr(args, "handler", None)
427
+ if handler is None:
428
+ parser.print_help(sys.stderr)
429
+ return EXIT_USAGE
430
+ try:
431
+ return handler(args)
432
+ except HomeError as exc:
433
+ print(f"agent-memory: error: {exc}", file=sys.stderr)
434
+ return EXIT_USAGE
435
+ except (sync.SyncError, archive.ArchiveError, org.ScaffoldError, setup.SetupError, workflow.WorkflowError) as exc:
436
+ print(f"agent-memory: error: {exc}", file=sys.stderr)
437
+ return EXIT_FAILED