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.
@@ -0,0 +1,1015 @@
1
+ # SPDX-FileCopyrightText: 2026 Kiloloop
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Install a runtime's memory hook: ``agent-memory setup <runtime>``.
4
+
5
+ One planner and one applier serve every runtime; a :class:`RuntimeSpec`
6
+ carries what differs (the settings file, the script path, the hook event
7
+ and matcher, the entry's fields, and the legacy commands to retire). The
8
+ plan is computed in full, against the repository as it is, before a byte is
9
+ written, and ``--dry-run`` prints exactly that plan. Every step is
10
+ idempotent, so an interrupted apply is resumed by running setup again.
11
+
12
+ The grammar, each rule pinned by a test:
13
+
14
+ 1. The hook script is written from a shipped template. A file already there
15
+ is regenerated only while its digest matches a template this or an earlier
16
+ version shipped; any other content is a named conflict and is kept. A
17
+ template that lost its execute bit is made executable again, and a script
18
+ that cannot be made runnable is never registered.
19
+ 2. The registration is added once, by exact command; an entry that carries
20
+ it is never edited, wherever it sits.
21
+ 3. Legacy registrations are retired by exact command, and a legacy flag is
22
+ removed from one simple command only, never from a compound one. Their
23
+ scripts are removed only when every one of these holds: the settings file
24
+ was read in full, no command anywhere in it still mentions the script,
25
+ the digest is the template that wrote it, and no symlink lies between the
26
+ repository and the file. Anything else is kept and named.
27
+ 4. Nothing is written through a symlink: a linked script, settings file or
28
+ hook directory is reported with its target and left to its owner.
29
+ 5. A receipt in the home records what was installed, at which version and
30
+ digest, and what was retired, so a later run and a fleet census can tell
31
+ this tool's files from everybody else's.
32
+ 6. No push hook is ever installed; a session's end is the runtime's own.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import datetime as dt
38
+ import hashlib
39
+ import json
40
+ import os
41
+ import re
42
+ import shlex
43
+ import stat
44
+ import tempfile
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+ from typing import Any, Dict, Iterator, List, Mapping, Optional, Sequence, Tuple
48
+
49
+ from .. import __version__, layout, workflow
50
+
51
+ RECEIPT_SCHEMA_VERSION = 1
52
+ SCRIPT_MODE = 0o755
53
+ #: The verb the generated hook runs; it pulls, then prints the bounded read manifest.
54
+ MANIFEST_VERB = "startup"
55
+
56
+ WRITE_SCRIPT = "write_script"
57
+ REGENERATE_SCRIPT = "regenerate_script"
58
+ CHMOD_SCRIPT = "chmod_script"
59
+ SCRIPT_IN_PLACE = "script_in_place"
60
+ SCRIPT_CONFLICT = "script_conflict"
61
+ CREATE_SETTINGS = "create_settings"
62
+ REGISTER = "register"
63
+ REGISTERED = "registered"
64
+ REGISTRATION_HELD = "registration_held"
65
+ RETIRE_REGISTRATION = "retire_registration"
66
+ STRIP_FLAG = "strip_flag"
67
+ KEEP_FLAG = "keep_flag"
68
+ SETTINGS_CONFLICT = "settings_conflict"
69
+ REMOVE_LEGACY_FILE = "remove_legacy_file"
70
+ KEEP_LEGACY_FILE = "keep_legacy_file"
71
+ WRITE_WORKFLOW = "write_workflow"
72
+ REGENERATE_WORKFLOW = "regenerate_workflow"
73
+ WORKFLOW_IN_PLACE = "workflow_in_place"
74
+ WORKFLOW_CONFLICT = "workflow_conflict"
75
+
76
+ CHANGING_ACTIONS = frozenset(
77
+ {
78
+ WRITE_SCRIPT,
79
+ REGENERATE_SCRIPT,
80
+ CHMOD_SCRIPT,
81
+ WRITE_WORKFLOW,
82
+ REGENERATE_WORKFLOW,
83
+ CREATE_SETTINGS,
84
+ REGISTER,
85
+ RETIRE_REGISTRATION,
86
+ STRIP_FLAG,
87
+ REMOVE_LEGACY_FILE,
88
+ }
89
+ )
90
+ CONFLICT_ACTIONS = frozenset({SCRIPT_CONFLICT, SETTINGS_CONFLICT, WORKFLOW_CONFLICT})
91
+
92
+ #: How each action reads in the report: (marker, done, planned).
93
+ VERBS: Dict[str, Tuple[str, str, str]] = {
94
+ WRITE_SCRIPT: ("+", "written", "would write"),
95
+ REGENERATE_SCRIPT: ("~", "regenerated", "would regenerate"),
96
+ CHMOD_SCRIPT: ("~", "made executable", "would make executable"),
97
+ SCRIPT_IN_PLACE: ("=", "in place", "in place"),
98
+ SCRIPT_CONFLICT: ("!", "conflict, kept", "conflict, would keep"),
99
+ CREATE_SETTINGS: ("+", "created", "would create"),
100
+ REGISTER: ("+", "registered", "would register"),
101
+ REGISTERED: ("=", "already registered", "already registered"),
102
+ REGISTRATION_HELD: ("!", "not registered", "would not register"),
103
+ RETIRE_REGISTRATION: ("-", "retired", "would retire"),
104
+ STRIP_FLAG: ("-", "retired flag", "would retire flag"),
105
+ KEEP_FLAG: ("!", "flag kept", "would keep flag"),
106
+ SETTINGS_CONFLICT: ("!", "conflict, not edited", "conflict, would not edit"),
107
+ REMOVE_LEGACY_FILE: ("-", "removed", "would remove"),
108
+ KEEP_LEGACY_FILE: ("!", "kept", "would keep"),
109
+ WRITE_WORKFLOW: ("+", "written", "would write"),
110
+ REGENERATE_WORKFLOW: ("~", "regenerated", "would regenerate"),
111
+ WORKFLOW_IN_PLACE: ("=", "in place", "in place"),
112
+ WORKFLOW_CONFLICT: ("!", "conflict, kept", "conflict, would keep"),
113
+ }
114
+
115
+ #: Tokens the shell reads as operators; a command carrying one is compound and is never edited.
116
+ _SHELL_OPERATOR_CHARS = frozenset("();<>|&$`")
117
+
118
+ SCRIPT_TEMPLATE = """\
119
+ #!/usr/bin/env bash
120
+ # agent-memory SessionStart hook for {name}; managed by `agent-memory setup {name}`.
121
+ # A rerun regenerates this file only while its digest matches a shipped template;
122
+ # an edited file is reported as a conflict and kept as it is.
123
+ set -u
124
+ export AGENT_MEMORY_AGENT="${{AGENT_MEMORY_AGENT:-{name}}}"
125
+ report() {{
126
+ {report_body}
127
+ }}
128
+ if ! command -v agent-memory >/dev/null 2>&1; then
129
+ report "agent-memory: command not found; memory not pulled and no startup manifest (install agent-memory-cli)."
130
+ exit 0
131
+ fi
132
+ agent-memory {verb} --runtime {name} --pull || report "agent-memory: {verb} exited $?; local memory may be stale."
133
+ exit 0
134
+ """
135
+
136
+
137
+ class SetupError(Exception):
138
+ """A precondition failed; nothing was changed."""
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class RuntimeSpec:
143
+ """What one runtime's hook installation looks like."""
144
+
145
+ name: str
146
+ #: The runtime's hook settings file, repository-relative.
147
+ settings_file: str
148
+ #: The hook script, repository-relative; it is also the registered command.
149
+ script_file: str
150
+ event: str
151
+ matcher: str
152
+ timeout: int
153
+ #: Shell lines of the script's ``report`` function: how a warning reaches the runtime.
154
+ report_body: str
155
+ #: Extra fields on the hook entry (a status message, say).
156
+ hook_fields: Mapping[str, Any] = field(default_factory=dict)
157
+ #: Top-level keys a settings file created from scratch starts with.
158
+ fresh_settings: Mapping[str, Any] = field(default_factory=dict)
159
+ #: Registrations retired by exact command, per event.
160
+ legacy_registrations: Mapping[str, Tuple[str, ...]] = field(default_factory=dict)
161
+ #: Files removed only on a digest match, per repository-relative path.
162
+ legacy_files: Mapping[str, Tuple[str, ...]] = field(default_factory=dict)
163
+ #: ``(argv prefix, flag)``: the flag is removed from any hook command with that prefix.
164
+ legacy_flag: Optional[Tuple[Tuple[str, ...], str]] = None
165
+ #: Digests of this runtime's script as earlier versions shipped it; those regenerate.
166
+ previous_template_digests: Tuple[str, ...] = ()
167
+ #: The workflow file, repository-relative: this runtime's thin wrapper of the shipped workflow text.
168
+ workflow_file: str = ""
169
+ #: Digests of this runtime's workflow file as earlier versions shipped it; those regenerate.
170
+ previous_workflow_digests: Tuple[str, ...] = ()
171
+
172
+ def hook(self) -> Dict[str, Any]:
173
+ return {"type": "command", "command": self.script_file, "timeout": self.timeout, **dict(self.hook_fields)}
174
+
175
+ def entry(self) -> Dict[str, Any]:
176
+ return {"matcher": self.matcher, "hooks": [self.hook()]}
177
+
178
+
179
+ def script_text(spec: RuntimeSpec) -> str:
180
+ """The hook script for ``spec``, byte for byte what setup writes."""
181
+ return SCRIPT_TEMPLATE.format(name=spec.name, report_body=spec.report_body, verb=MANIFEST_VERB)
182
+
183
+
184
+ def template_digest(spec: RuntimeSpec) -> str:
185
+ return _digest_bytes(script_text(spec).encode("utf-8"))
186
+
187
+
188
+ def known_template_digests(spec: RuntimeSpec) -> Tuple[str, ...]:
189
+ """Every digest a file at the script path may carry and still count as this tool's."""
190
+ return (template_digest(spec), *spec.previous_template_digests)
191
+
192
+
193
+ def workflow_text(spec: RuntimeSpec) -> str:
194
+ """The workflow file for ``spec``, byte for byte what setup writes: the shipped text with the runtime's name."""
195
+ return workflow.workflow_text(spec.name)
196
+
197
+
198
+ def workflow_digest(spec: RuntimeSpec) -> str:
199
+ return _digest_bytes(workflow_text(spec).encode("utf-8"))
200
+
201
+
202
+ def known_workflow_digests(spec: RuntimeSpec) -> Tuple[str, ...]:
203
+ """Every digest a file at the workflow path may carry and still count as this tool's."""
204
+ return (workflow_digest(spec), *spec.previous_workflow_digests)
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Step:
209
+ """One planned action on one repository-relative path."""
210
+
211
+ action: str
212
+ path: str
213
+ detail: str = ""
214
+
215
+ @property
216
+ def conflict(self) -> bool:
217
+ return self.action in CONFLICT_ACTIONS
218
+
219
+ @property
220
+ def changes(self) -> bool:
221
+ return self.action in CHANGING_ACTIONS
222
+
223
+
224
+ @dataclass
225
+ class Plan:
226
+ """Everything an apply will do, computed before it does any of it."""
227
+
228
+ spec: RuntimeSpec
229
+ repo: Path
230
+ home: Path
231
+ steps: List[Step]
232
+ #: Bytes to place at the script path; ``None`` when the script is not written.
233
+ script_write: Optional[bytes]
234
+ #: Whether the script write replaces an earlier template (else it creates the file).
235
+ script_regenerate: bool
236
+ #: Whether a template already at the script path only needs its execute bit back.
237
+ script_chmod: bool
238
+ #: Text to write to the settings file; ``None`` when it is not written.
239
+ settings_write: Optional[str]
240
+ removals: List[Path]
241
+ receipt_path: Path
242
+ receipt: Dict[str, Any]
243
+ receipt_write: bool
244
+ #: Bytes to place at the workflow path; ``None`` when it is not written.
245
+ workflow_write: Optional[bytes] = None
246
+ #: Whether the workflow write replaces an earlier template (else it creates the file).
247
+ workflow_regenerate: bool = False
248
+
249
+ @property
250
+ def conflicts(self) -> List[Step]:
251
+ return [step for step in self.steps if step.conflict]
252
+
253
+ @property
254
+ def changed(self) -> bool:
255
+ return any(step.changes for step in self.steps) or self.receipt_write
256
+
257
+
258
+ @dataclass(frozen=True)
259
+ class Result:
260
+ plan: Plan
261
+ dry_run: bool
262
+
263
+
264
+ # --- planning ---------------------------------------------------------------
265
+
266
+
267
+ def plan_setup(
268
+ spec: RuntimeSpec,
269
+ repo: Path,
270
+ home: Path,
271
+ *,
272
+ version: str = __version__,
273
+ now: Optional[dt.datetime] = None,
274
+ ) -> Plan:
275
+ """Inspect ``repo`` and ``home`` and decide every step; nothing is written."""
276
+ repo = _absolute(repo)
277
+ home = _absolute(home)
278
+ if not repo.is_dir():
279
+ raise SetupError(f"{repo} is not a directory")
280
+ if not home.is_dir():
281
+ raise SetupError(f"memory home {home} does not exist; create it with `agent-memory init` first")
282
+
283
+ steps: List[Step] = []
284
+ script = _plan_script(spec, repo, steps)
285
+ settings_write, data_after, settings_state = _plan_settings(spec, repo, steps, script_present=script.present)
286
+ removals = _plan_legacy_files(spec, repo, data_after, settings_state, steps)
287
+ flow = _plan_workflow(spec, repo, steps)
288
+
289
+ receipt_path = home / layout.SETUP_DIR / spec.name / f"{_repo_key(repo)}.json"
290
+ previous = _load_json(receipt_path)
291
+ receipt = _receipt(
292
+ spec,
293
+ repo,
294
+ home,
295
+ version=version,
296
+ steps=steps,
297
+ script_state=script.state,
298
+ script_digest=script.digest,
299
+ script_mode=script.mode,
300
+ settings_state=settings_state,
301
+ settings_write=settings_write,
302
+ workflow_state=flow.state,
303
+ workflow_digest_value=flow.digest,
304
+ previous=previous if isinstance(previous, dict) else None,
305
+ now=now or dt.datetime.now(dt.timezone.utc),
306
+ )
307
+ receipt_write = not isinstance(previous, dict) or _without_stamp(previous) != _without_stamp(receipt)
308
+ return Plan(
309
+ spec,
310
+ repo,
311
+ home,
312
+ steps,
313
+ script.write,
314
+ script.regenerate,
315
+ script.chmod,
316
+ settings_write,
317
+ removals,
318
+ receipt_path,
319
+ receipt,
320
+ receipt_write,
321
+ workflow_write=flow.write,
322
+ workflow_regenerate=flow.regenerate,
323
+ )
324
+
325
+
326
+ @dataclass(frozen=True)
327
+ class _ScriptPlan:
328
+ #: Bytes to place at the script path; ``None`` when nothing is written there.
329
+ write: Optional[bytes]
330
+ regenerate: bool
331
+ #: ``written``, ``regenerated``, ``in_place`` or ``conflict``.
332
+ state: str
333
+ #: The digest the path will carry after the apply, when it can be known.
334
+ digest: Optional[str]
335
+ #: Whether a runnable file will be at the path after the apply, so registering it makes sense.
336
+ present: bool
337
+ #: The permission bits the path will carry after the apply; ``None`` when nothing is there.
338
+ mode: Optional[int] = None
339
+ #: Whether the apply only restores the execute bit of a template already in place.
340
+ chmod: bool = False
341
+
342
+
343
+ def _plan_script(spec: RuntimeSpec, repo: Path, steps: List[Step]) -> _ScriptPlan:
344
+ rel = spec.script_file
345
+ script = repo / rel
346
+ text = script_text(spec).encode("utf-8")
347
+ current = template_digest(spec)
348
+ known = known_template_digests(spec)
349
+
350
+ link = _linked_component(repo, script)
351
+ if link is not None:
352
+ target = _realpath(link)
353
+ content = _digest_file(script)
354
+ mode = _mode_of(script, None)
355
+ if content in known and _executable(mode):
356
+ steps.append(
357
+ Step(SCRIPT_IN_PLACE, rel, f"through the symlink {_rel(repo, link)} -> {target}; shared source left as it is")
358
+ )
359
+ return _ScriptPlan(None, False, "in_place", content, True, mode)
360
+ if content is None:
361
+ what = "is missing or unreadable there"
362
+ elif content not in known:
363
+ what = "differs from every shipped template"
364
+ else:
365
+ what = f"is a shipped template but not executable (mode {_octal(mode)}), and its mode is not changed through the link"
366
+ steps.append(
367
+ Step(
368
+ SCRIPT_CONFLICT,
369
+ rel,
370
+ f"{_rel(repo, link)} is a symlink to {target}; the shared source {what} and is not written through",
371
+ )
372
+ )
373
+ return _ScriptPlan(None, False, "conflict", content, content is not None and _executable(mode), mode)
374
+ if os.path.lexists(script):
375
+ if not script.is_file():
376
+ steps.append(Step(SCRIPT_CONFLICT, rel, "exists and is not a regular file"))
377
+ return _ScriptPlan(None, False, "conflict", None, False)
378
+ content = _digest_file(script)
379
+ mode = _mode_of(script, None)
380
+ if content is None:
381
+ steps.append(Step(SCRIPT_CONFLICT, rel, "exists and cannot be read"))
382
+ return _ScriptPlan(None, False, "conflict", None, False, mode)
383
+ if content == current:
384
+ if _executable(mode):
385
+ steps.append(Step(SCRIPT_IN_PLACE, rel, f"digest {_short(current)} is the shipped template"))
386
+ return _ScriptPlan(None, False, "in_place", content, True, mode)
387
+ steps.append(Step(CHMOD_SCRIPT, rel, f"digest {_short(current)} is the shipped template at mode {_octal(mode)}"))
388
+ return _ScriptPlan(None, False, "in_place", content, True, SCRIPT_MODE, chmod=True)
389
+ if content in known:
390
+ steps.append(Step(REGENERATE_SCRIPT, rel, f"digest {_short(content)} is an earlier template; now {_short(current)}"))
391
+ return _ScriptPlan(text, True, "regenerated", current, True, SCRIPT_MODE)
392
+ steps.append(Step(SCRIPT_CONFLICT, rel, f"digest {_short(content)} matches no shipped template; edited, kept as it is"))
393
+ return _ScriptPlan(None, False, "conflict", content, _executable(mode), mode)
394
+ steps.append(Step(WRITE_SCRIPT, rel, f"template digest {_short(current)}"))
395
+ return _ScriptPlan(text, False, "written", current, True, SCRIPT_MODE)
396
+
397
+
398
+ @dataclass(frozen=True)
399
+ class _FilePlan:
400
+ #: Bytes to place at the path; ``None`` when nothing is written there.
401
+ write: Optional[bytes]
402
+ regenerate: bool
403
+ #: ``written``, ``regenerated``, ``in_place``, ``conflict``, or ``absent`` when the runtime has no such file.
404
+ state: str
405
+ digest: Optional[str]
406
+
407
+
408
+ def _plan_workflow(spec: RuntimeSpec, repo: Path, steps: List[Step]) -> _FilePlan:
409
+ """The workflow file follows the script's rules without the execute bit: written once, regenerated only
410
+ while its digest is a shipped template, kept and named as a conflict when edited, never written through a link."""
411
+ if not spec.workflow_file:
412
+ return _FilePlan(None, False, "absent", None)
413
+ rel = spec.workflow_file
414
+ target = repo / rel
415
+ text = workflow_text(spec).encode("utf-8")
416
+ current = workflow_digest(spec)
417
+ known = known_workflow_digests(spec)
418
+
419
+ link = _linked_component(repo, target)
420
+ if link is not None:
421
+ content = _digest_file(target)
422
+ if content in known:
423
+ steps.append(
424
+ Step(WORKFLOW_IN_PLACE, rel, f"through the symlink {_rel(repo, link)} -> {_realpath(link)}; shared source left as it is")
425
+ )
426
+ return _FilePlan(None, False, "in_place", content)
427
+ what = "is missing or unreadable there" if content is None else "differs from every shipped template"
428
+ steps.append(
429
+ Step(WORKFLOW_CONFLICT, rel, f"{_rel(repo, link)} is a symlink to {_realpath(link)}; the shared source {what} and is not written through")
430
+ )
431
+ return _FilePlan(None, False, "conflict", content)
432
+ if os.path.lexists(target):
433
+ if not target.is_file():
434
+ steps.append(Step(WORKFLOW_CONFLICT, rel, "exists and is not a regular file"))
435
+ return _FilePlan(None, False, "conflict", None)
436
+ content = _digest_file(target)
437
+ if content is None:
438
+ steps.append(Step(WORKFLOW_CONFLICT, rel, "exists and cannot be read"))
439
+ return _FilePlan(None, False, "conflict", None)
440
+ if content == current:
441
+ steps.append(Step(WORKFLOW_IN_PLACE, rel, f"digest {_short(current)} is the shipped template"))
442
+ return _FilePlan(None, False, "in_place", content)
443
+ if content in known:
444
+ steps.append(Step(REGENERATE_WORKFLOW, rel, f"digest {_short(content)} is an earlier template; now {_short(current)}"))
445
+ return _FilePlan(text, True, "regenerated", current)
446
+ steps.append(Step(WORKFLOW_CONFLICT, rel, f"digest {_short(content)} matches no shipped template; edited, kept as it is"))
447
+ return _FilePlan(None, False, "conflict", content)
448
+ steps.append(Step(WRITE_WORKFLOW, rel, f"template digest {_short(current)}"))
449
+ return _FilePlan(text, False, "written", current)
450
+
451
+
452
+ def _executable(mode: Optional[int]) -> bool:
453
+ """Whether the owner, who runs the hook, may execute a file of ``mode``."""
454
+ return mode is not None and bool(mode & stat.S_IXUSR)
455
+
456
+
457
+ def _plan_settings(
458
+ spec: RuntimeSpec, repo: Path, steps: List[Step], *, script_present: bool
459
+ ) -> Tuple[Optional[str], Optional[Dict[str, Any]], str]:
460
+ rel = spec.settings_file
461
+ settings = repo / rel
462
+
463
+ link = _linked_component(repo, settings)
464
+ if link is not None:
465
+ steps.append(
466
+ Step(
467
+ SETTINGS_CONFLICT,
468
+ rel,
469
+ f"{_rel(repo, link)} is a symlink to {_realpath(link)}; the shared source is not edited, "
470
+ f"register {spec.script_file} there yourself",
471
+ )
472
+ )
473
+ return None, _load_json(settings), "conflict"
474
+
475
+ before: Optional[str] = None
476
+ created = False
477
+ if os.path.lexists(settings):
478
+ if not settings.is_file():
479
+ steps.append(Step(SETTINGS_CONFLICT, rel, "exists and is not a regular file"))
480
+ return None, None, "conflict"
481
+ try:
482
+ before = settings.read_text(encoding="utf-8")
483
+ data = json.loads(before)
484
+ except (OSError, ValueError) as exc:
485
+ steps.append(Step(SETTINGS_CONFLICT, rel, f"cannot be read as JSON: {exc}"))
486
+ return None, None, "conflict"
487
+ if not isinstance(data, dict) or ("hooks" in data and not isinstance(data["hooks"], dict)):
488
+ steps.append(Step(SETTINGS_CONFLICT, rel, "expected a JSON object whose 'hooks' is an object"))
489
+ return None, None, "conflict"
490
+ else:
491
+ data = dict(spec.fresh_settings)
492
+ created = True
493
+ steps.append(Step(CREATE_SETTINGS, rel, ""))
494
+
495
+ hooks: Dict[str, Any] = data.setdefault("hooks", {})
496
+ own = hooks.get(spec.event)
497
+ if own is not None and not isinstance(own, list):
498
+ steps.append(Step(SETTINGS_CONFLICT, rel, f"hooks.{spec.event} is not a list"))
499
+ return None, data, "conflict"
500
+
501
+ changed = created
502
+ # The legacy hooks go only once their replacement can run: a repository is never left with no memory hook.
503
+ if script_present:
504
+ for event, commands in spec.legacy_registrations.items():
505
+ entries = hooks.get(event)
506
+ if not isinstance(entries, list):
507
+ continue
508
+ for command in commands:
509
+ if _remove_command(entries, command):
510
+ steps.append(Step(RETIRE_REGISTRATION, rel, f"{event}: {command}"))
511
+ changed = True
512
+ if not entries:
513
+ del hooks[event]
514
+ if spec.legacy_flag is not None and isinstance(own, list):
515
+ prefix, flag = spec.legacy_flag
516
+ for old, new, why in _strip_flag(own, prefix, flag):
517
+ if new is None:
518
+ steps.append(Step(KEEP_FLAG, rel, f"{spec.event}: {flag} left in `{old}`; {why}"))
519
+ continue
520
+ steps.append(Step(STRIP_FLAG, rel, f"{spec.event}: {flag} removed from `{old}`, now `{new}`"))
521
+ changed = True
522
+
523
+ entries = hooks.setdefault(spec.event, [])
524
+ label = f"{spec.event} {spec.matcher!r}: {spec.script_file}"
525
+ if _command_registered(entries, spec.script_file):
526
+ steps.append(Step(REGISTERED, rel, label))
527
+ elif not script_present:
528
+ steps.append(
529
+ Step(
530
+ REGISTRATION_HELD,
531
+ rel,
532
+ f"{label}; the script is not in place, so nothing is registered to run it and the legacy hooks stay",
533
+ )
534
+ )
535
+ else:
536
+ entries.append(spec.entry())
537
+ steps.append(Step(REGISTER, rel, label))
538
+ changed = True
539
+ if not entries:
540
+ del hooks[spec.event]
541
+
542
+ if not changed:
543
+ return None, data, "unchanged"
544
+ after = _dumps(data)
545
+ if after == before:
546
+ return None, data, "unchanged"
547
+ return after, data, "created" if created else "updated"
548
+
549
+
550
+ def _plan_legacy_files(
551
+ spec: RuntimeSpec, repo: Path, data_after: Optional[Dict[str, Any]], settings_state: str, steps: List[Step]
552
+ ) -> List[Path]:
553
+ """Legacy scripts to remove: only a local, unedited template that the fully read settings no longer mention.
554
+
555
+ Every uncertainty keeps the file: a settings file that could not be read in full
556
+ (so the registrations are unknown), a command anywhere in it that still names
557
+ the script, however it is invoked, a symlink between the repository and the file,
558
+ a digest that is not the template's.
559
+ """
560
+ removals: List[Path] = []
561
+ inspected = settings_state != "conflict" and isinstance(data_after, dict)
562
+ hooks = data_after.get("hooks") if inspected and isinstance(data_after, dict) else None
563
+ for rel, digests in spec.legacy_files.items():
564
+ path = repo / rel
565
+ if not os.path.lexists(path):
566
+ continue
567
+ link = _linked_component(repo, path)
568
+ if link is not None:
569
+ steps.append(Step(KEEP_LEGACY_FILE, rel, f"{_rel(repo, link)} is a symlink to {_realpath(link)}; not removed"))
570
+ continue
571
+ if not path.is_file():
572
+ steps.append(Step(KEEP_LEGACY_FILE, rel, "not a regular file; not removed"))
573
+ continue
574
+ digest = _digest_file(path)
575
+ if digest is None or digest not in digests:
576
+ shown = "unreadable" if digest is None else f"digest {_short(digest)}"
577
+ steps.append(Step(KEEP_LEGACY_FILE, rel, f"{shown} is not the template that installed it; edited, kept"))
578
+ continue
579
+ if not inspected:
580
+ steps.append(Step(KEEP_LEGACY_FILE, rel, f"{spec.settings_file} could not be read in full; still registered for all this tool knows"))
581
+ continue
582
+ mention = _mentioned_anywhere(hooks, rel)
583
+ if mention is not None:
584
+ what = "still registered" if mention == rel else f"still named by `{mention}`"
585
+ steps.append(Step(KEEP_LEGACY_FILE, rel, f"{what}; not removed"))
586
+ continue
587
+ steps.append(Step(REMOVE_LEGACY_FILE, rel, f"digest {_short(digest)} is the legacy template"))
588
+ removals.append(path)
589
+ return removals
590
+
591
+
592
+ # --- the receipt ------------------------------------------------------------
593
+
594
+
595
+ def _receipt(
596
+ spec: RuntimeSpec,
597
+ repo: Path,
598
+ home: Path,
599
+ *,
600
+ version: str,
601
+ steps: Sequence[Step],
602
+ script_state: str,
603
+ script_digest: Optional[str],
604
+ script_mode: Optional[int],
605
+ settings_state: str,
606
+ settings_write: Optional[str],
607
+ workflow_state: str,
608
+ workflow_digest_value: Optional[str],
609
+ previous: Optional[Dict[str, Any]],
610
+ now: dt.datetime,
611
+ ) -> Dict[str, Any]:
612
+ """The receipt as it stands after the apply: states, not actions, so a no-op rerun leaves it as it is.
613
+
614
+ ``retired`` is a history: what earlier runs retired stays recorded, and this run's
615
+ retirements are added once.
616
+ """
617
+ script = repo / spec.script_file
618
+ settings = repo / spec.settings_file
619
+ if settings_write is not None:
620
+ settings_digest: Optional[str] = _digest_bytes(settings_write.encode("utf-8"))
621
+ else:
622
+ settings_digest = _digest_file(settings)
623
+ if settings_state == "conflict":
624
+ registration_state = "conflict"
625
+ elif any(step.action == REGISTRATION_HELD for step in steps):
626
+ registration_state = "held"
627
+ else:
628
+ registration_state = "registered"
629
+ managed = [
630
+ {
631
+ "path": spec.script_file,
632
+ "resolved": _realpath(script),
633
+ "symlink": _linked_component(repo, script) is not None,
634
+ "state": "conflict" if script_state == "conflict" else "installed",
635
+ "digest": script_digest,
636
+ "template_digest": template_digest(spec),
637
+ "mode": _octal(script_mode) if script_mode is not None else None,
638
+ },
639
+ {
640
+ "path": spec.settings_file,
641
+ "resolved": _realpath(settings),
642
+ "symlink": _linked_component(repo, settings) is not None,
643
+ "state": registration_state,
644
+ "digest": settings_digest,
645
+ "registration": {"event": spec.event, "matcher": spec.matcher, "command": spec.script_file},
646
+ },
647
+ ]
648
+ if spec.workflow_file:
649
+ flow = repo / spec.workflow_file
650
+ managed.append(
651
+ {
652
+ "path": spec.workflow_file,
653
+ "resolved": _realpath(flow),
654
+ "symlink": _linked_component(repo, flow) is not None,
655
+ "state": "conflict" if workflow_state == "conflict" else "installed",
656
+ "digest": workflow_digest_value,
657
+ "template_digest": workflow_digest(spec),
658
+ }
659
+ )
660
+ retired: List[Dict[str, Any]] = []
661
+ if previous is not None and isinstance(previous.get("retired"), list):
662
+ retired.extend(item for item in previous["retired"] if isinstance(item, dict))
663
+ for step in steps:
664
+ if step.action in (RETIRE_REGISTRATION, STRIP_FLAG):
665
+ item: Dict[str, Any] = {"kind": "registration", "path": step.path, "detail": step.detail}
666
+ elif step.action in (REMOVE_LEGACY_FILE, KEEP_LEGACY_FILE):
667
+ item = {"kind": "file", "path": step.path, "removed": step.action == REMOVE_LEGACY_FILE, "detail": step.detail}
668
+ else:
669
+ continue
670
+ if item not in retired:
671
+ retired.append(item)
672
+ return {
673
+ "schema_version": RECEIPT_SCHEMA_VERSION,
674
+ "tool": "agent-memory",
675
+ "version": version,
676
+ "runtime": spec.name,
677
+ "repo": str(repo),
678
+ "repo_resolved": _realpath(repo),
679
+ "home": str(home),
680
+ "written_at_utc": _iso(now),
681
+ "managed": managed,
682
+ "retired": retired,
683
+ "conflicts": [{"path": step.path, "detail": step.detail} for step in steps if step.conflict],
684
+ }
685
+
686
+
687
+ def _without_stamp(receipt: Dict[str, Any]) -> Dict[str, Any]:
688
+ return {key: value for key, value in receipt.items() if key != "written_at_utc"}
689
+
690
+
691
+ def _repo_key(repo: Path) -> str:
692
+ return hashlib.sha256(_realpath(repo).encode("utf-8")).hexdigest()[:16]
693
+
694
+
695
+ # --- applying ---------------------------------------------------------------
696
+
697
+
698
+ def apply_plan(plan: Plan) -> None:
699
+ """Perform the plan's writes in a fixed order; each is safe to repeat after an interruption."""
700
+ spec = plan.spec
701
+ if plan.script_write is not None:
702
+ script = plan.repo / spec.script_file
703
+ script.parent.mkdir(parents=True, exist_ok=True)
704
+ if plan.script_regenerate:
705
+ _replace_file(script, plan.script_write, SCRIPT_MODE)
706
+ else:
707
+ _create_file(script, plan.script_write, SCRIPT_MODE)
708
+ elif plan.script_chmod:
709
+ _chmod_file(plan.repo / spec.script_file, SCRIPT_MODE)
710
+ if plan.workflow_write is not None:
711
+ flow = plan.repo / spec.workflow_file
712
+ flow.parent.mkdir(parents=True, exist_ok=True)
713
+ if plan.workflow_regenerate:
714
+ _replace_file(flow, plan.workflow_write, _mode_of(flow, 0o644))
715
+ else:
716
+ _create_file(flow, plan.workflow_write, 0o644)
717
+ if plan.settings_write is not None:
718
+ settings = plan.repo / spec.settings_file
719
+ settings.parent.mkdir(parents=True, exist_ok=True)
720
+ _replace_file(settings, plan.settings_write.encode("utf-8"), _mode_of(settings, 0o644))
721
+ for path in plan.removals:
722
+ try:
723
+ path.unlink()
724
+ except FileNotFoundError:
725
+ pass
726
+ if plan.receipt_write:
727
+ plan.receipt_path.parent.mkdir(parents=True, exist_ok=True)
728
+ _replace_file(plan.receipt_path, _dumps(plan.receipt).encode("utf-8"), _mode_of(plan.receipt_path, 0o644))
729
+
730
+
731
+ def run_setup(spec: RuntimeSpec, repo: Path, home: Path, *, dry_run: bool = False, version: str = __version__) -> Result:
732
+ """Plan, and unless ``dry_run``, apply."""
733
+ plan = plan_setup(spec, repo, home, version=version)
734
+ if not dry_run:
735
+ apply_plan(plan)
736
+ return Result(plan, dry_run)
737
+
738
+
739
+ def detect_repo(start: Path) -> Path:
740
+ """The nearest directory at or above ``start`` holding a ``.git`` entry, else ``start`` itself."""
741
+ start = _absolute(start)
742
+ for directory in (start, *start.parents):
743
+ if os.path.lexists(directory / ".git"):
744
+ return directory
745
+ return start
746
+
747
+
748
+ # --- reporting --------------------------------------------------------------
749
+
750
+
751
+ def lines(result: Result) -> List[str]:
752
+ plan = result.plan
753
+ out = [f"agent-memory setup {plan.spec.name}: {plan.repo}", f"home: {plan.home}"]
754
+ for step in plan.steps:
755
+ marker, done, planned = VERBS[step.action]
756
+ verb = planned if result.dry_run else done
757
+ detail = f" ({step.detail})" if step.detail else ""
758
+ out.append(f" {marker} {step.path}: {verb}{detail}")
759
+ receipt_verb = ("would write" if result.dry_run else "written") if plan.receipt_write else "unchanged"
760
+ out.append(f"receipt: {plan.receipt_path} ({receipt_verb})")
761
+ if plan.conflicts:
762
+ out.append(f"conflicts: {len(plan.conflicts)}; nothing marked ! was written. Resolve them and run setup again.")
763
+ if result.dry_run:
764
+ out.append("dry run: nothing was written.")
765
+ elif not plan.changed:
766
+ out.append("nothing to do: the hook and the workflow file are installed and in place.")
767
+ return out
768
+
769
+
770
+ def to_json(result: Result) -> Dict[str, Any]:
771
+ plan = result.plan
772
+ return {
773
+ "schema_version": 1,
774
+ "action": "setup",
775
+ "runtime": plan.spec.name,
776
+ "repo": str(plan.repo),
777
+ "home": str(plan.home),
778
+ "dry_run": result.dry_run,
779
+ "changed": plan.changed,
780
+ "steps": [
781
+ {"action": step.action, "path": step.path, "detail": step.detail, "changes": step.changes, "conflict": step.conflict}
782
+ for step in plan.steps
783
+ ],
784
+ "conflicts": [step.path for step in plan.conflicts],
785
+ "receipt": {"path": str(plan.receipt_path), "written": plan.receipt_write and not result.dry_run},
786
+ }
787
+
788
+
789
+ # --- hook-list surgery, shared by every runtime -----------------------------
790
+
791
+
792
+ def _command_registered(entries: Sequence[Any], command: str) -> bool:
793
+ for entry in entries:
794
+ for hook in _hooks_of(entry):
795
+ if hook.get("command") == command:
796
+ return True
797
+ return False
798
+
799
+
800
+ def _mentioned_anywhere(hooks: Any, path: str) -> Optional[str]:
801
+ """The first hook command, under any event, whose text names ``path`` or its basename; ``None`` when none does.
802
+
803
+ A custom wrapper (``bash .claude/hooks/x.sh``) still runs the file, so any mention
804
+ keeps it; only an exact command is ever retired.
805
+ """
806
+ if not isinstance(hooks, dict):
807
+ return None
808
+ names = (path, os.path.basename(path))
809
+ for entries in hooks.values():
810
+ if not isinstance(entries, list):
811
+ continue
812
+ for entry in entries:
813
+ for hook in _hooks_of(entry):
814
+ command = hook.get("command")
815
+ if isinstance(command, str) and any(name in command for name in names):
816
+ return command
817
+ return None
818
+
819
+
820
+ def _remove_command(entries: List[Any], command: str) -> bool:
821
+ """Drop every hook whose command is exactly ``command``; entries left empty go too. True when anything changed."""
822
+ changed = False
823
+ retained: List[Any] = []
824
+ for entry in entries:
825
+ hooks = _hooks_of(entry)
826
+ if not hooks:
827
+ retained.append(entry)
828
+ continue
829
+ kept = [hook for hook in entry["hooks"] if not (isinstance(hook, dict) and hook.get("command") == command)]
830
+ if len(kept) == len(entry["hooks"]):
831
+ retained.append(entry)
832
+ continue
833
+ changed = True
834
+ if kept:
835
+ updated = dict(entry)
836
+ updated["hooks"] = kept
837
+ retained.append(updated)
838
+ if changed:
839
+ entries[:] = retained
840
+ return changed
841
+
842
+
843
+ def _strip_flag(entries: Sequence[Any], prefix: Sequence[str], flag: str) -> Iterator[Tuple[str, Optional[str], str]]:
844
+ """Remove ``flag`` from every hook command that starts with ``prefix``; yields (before, after, reason).
845
+
846
+ The edit is made on the command text as written, so quoting and every other
847
+ byte survive; ``after`` is ``None``, with the reason, when the command is not
848
+ one simple command (an operator, redirection or substitution makes it compound)
849
+ or the flag is not a bare word in it. Such a command is left exactly as it is.
850
+ """
851
+ for entry in entries:
852
+ for hook in _hooks_of(entry):
853
+ command = hook.get("command")
854
+ if not isinstance(command, str):
855
+ continue
856
+ parsed = _tokens(command)
857
+ if parsed is None:
858
+ continue
859
+ argv, simple = parsed
860
+ if argv[: len(prefix)] != list(prefix) or flag not in argv:
861
+ continue
862
+ if not simple:
863
+ yield command, None, "not one simple command; edited by hand if the flag should go"
864
+ continue
865
+ rewritten = re.sub(rf"\s+{re.escape(flag)}(?!\S)", "", command)
866
+ try:
867
+ left = shlex.split(rewritten)
868
+ except ValueError:
869
+ left = None
870
+ if left != [arg for arg in argv if arg != flag]:
871
+ yield command, None, "the flag is not a bare word in it"
872
+ continue
873
+ hook["command"] = rewritten
874
+ yield command, rewritten, ""
875
+
876
+
877
+ def _tokens(command: str) -> Optional[Tuple[List[str], bool]]:
878
+ """``(tokens, simple)``: the shell's tokens of ``command``, and whether they make one simple command.
879
+
880
+ Operators, redirections and substitutions (``&&``, ``;``, ``|``, ``>``, ``$(``,
881
+ backticks) are read as the shell would, so a flag after a ``;`` is still seen
882
+ and the command is still known to be compound. A newline separates commands
883
+ too, and a lexer folds it into whitespace, so any multi-line text is compound
884
+ by rule. ``None`` on unbalanced quotes.
885
+ """
886
+ try:
887
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
888
+ lexer.whitespace_split = True
889
+ tokens = list(lexer)
890
+ words = shlex.split(command)
891
+ except ValueError:
892
+ return None
893
+ simple = (
894
+ tokens == words
895
+ and "`" not in command
896
+ and "\n" not in command
897
+ and "\r" not in command
898
+ and not any(token and set(token) <= _SHELL_OPERATOR_CHARS for token in tokens)
899
+ )
900
+ return tokens, simple
901
+
902
+
903
+ def _hooks_of(entry: Any) -> List[Dict[str, Any]]:
904
+ if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
905
+ return []
906
+ return [hook for hook in entry["hooks"] if isinstance(hook, dict)]
907
+
908
+
909
+ # --- files ------------------------------------------------------------------
910
+
911
+
912
+ def _linked_component(repo: Path, path: Path) -> Optional[Path]:
913
+ """The first symlink on the way from ``repo`` down to ``path`` (``path`` included), or ``None``."""
914
+ current = repo
915
+ for part in path.relative_to(repo).parts:
916
+ current = current / part
917
+ if current.is_symlink():
918
+ return current
919
+ return None
920
+
921
+
922
+ def _create_file(path: Path, data: bytes, mode: int) -> None:
923
+ try:
924
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
925
+ except FileExistsError as exc:
926
+ raise SetupError(f"{path} appeared while setup was running; run setup again") from exc
927
+ with os.fdopen(fd, "wb") as handle:
928
+ handle.write(data)
929
+ os.chmod(path, mode)
930
+
931
+
932
+ def _replace_file(path: Path, data: bytes, mode: int) -> None:
933
+ handle = tempfile.NamedTemporaryFile("wb", dir=str(path.parent), prefix=f".{path.name}.", delete=False)
934
+ with handle:
935
+ handle.write(data)
936
+ handle.flush()
937
+ os.fsync(handle.fileno())
938
+ temp = Path(handle.name)
939
+ try:
940
+ os.chmod(temp, mode)
941
+ os.replace(temp, path)
942
+ except OSError:
943
+ try:
944
+ temp.unlink()
945
+ except OSError:
946
+ pass
947
+ raise
948
+
949
+
950
+ def _chmod_file(path: Path, mode: int) -> None:
951
+ """Set ``mode`` on the regular file at ``path`` itself, never on whatever a link there points at."""
952
+ fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
953
+ try:
954
+ os.fchmod(fd, mode)
955
+ finally:
956
+ os.close(fd)
957
+
958
+
959
+ def _mode_of(path: Path, default: Optional[int]) -> Optional[int]:
960
+ try:
961
+ return os.stat(path).st_mode & 0o777
962
+ except OSError:
963
+ return default
964
+
965
+
966
+ def _digest_file(path: Path) -> Optional[str]:
967
+ try:
968
+ return _digest_bytes(path.read_bytes())
969
+ except OSError:
970
+ return None
971
+
972
+
973
+ def _digest_bytes(data: bytes) -> str:
974
+ return hashlib.sha256(data).hexdigest()
975
+
976
+
977
+ def _short(digest: Optional[str]) -> str:
978
+ return (digest or "?")[:12]
979
+
980
+
981
+ def _octal(mode: Optional[int]) -> str:
982
+ return f"{mode:04o}" if mode is not None else "unknown"
983
+
984
+
985
+ def _load_json(path: Path) -> Any:
986
+ try:
987
+ return json.loads(path.read_text(encoding="utf-8"))
988
+ except (OSError, ValueError):
989
+ return None
990
+
991
+
992
+ def _dumps(data: Any) -> str:
993
+ return json.dumps(data, indent=2, sort_keys=True) + "\n"
994
+
995
+
996
+ def _realpath(path: Path) -> str:
997
+ return os.path.realpath(path)
998
+
999
+
1000
+ def _rel(repo: Path, path: Path) -> str:
1001
+ try:
1002
+ return path.relative_to(repo).as_posix()
1003
+ except ValueError:
1004
+ return str(path)
1005
+
1006
+
1007
+ def _absolute(value: Path) -> Path:
1008
+ try:
1009
+ return Path(value).expanduser().absolute()
1010
+ except RuntimeError as exc:
1011
+ raise SetupError(f"cannot expand {value}: {exc}") from exc
1012
+
1013
+
1014
+ def _iso(moment: dt.datetime) -> str:
1015
+ return moment.astimezone(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")