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/__init__.py +9 -0
- agent_memory/__main__.py +9 -0
- agent_memory/archive.py +359 -0
- agent_memory/cli.py +437 -0
- agent_memory/debrief.py +336 -0
- agent_memory/doctor.py +710 -0
- agent_memory/git_runner.py +68 -0
- agent_memory/home.py +246 -0
- agent_memory/layout.py +198 -0
- agent_memory/org.py +218 -0
- agent_memory/publication.py +433 -0
- agent_memory/setup/__init__.py +53 -0
- agent_memory/setup/claude.py +34 -0
- agent_memory/setup/codex.py +39 -0
- agent_memory/setup/common.py +1015 -0
- agent_memory/setup/legacy.py +67 -0
- agent_memory/startup.py +254 -0
- agent_memory/status.py +137 -0
- agent_memory/sync.py +649 -0
- agent_memory/templates/org-memory/decisions.md +5 -0
- agent_memory/templates/org-memory/recent.md +16 -0
- agent_memory/templates/org-memory/rules.md +5 -0
- agent_memory/templates/project-memory/decision_log.md +3 -0
- agent_memory/templates/project-memory/known_debt.md +8 -0
- agent_memory/templates/project-memory/open_threads.md +3 -0
- agent_memory/templates/project-memory/project_facts.md +4 -0
- agent_memory/templates/workflow/SKILL.md +42 -0
- agent_memory/workflow.py +287 -0
- agent_memory_cli-0.1.0.dist-info/METADATA +261 -0
- agent_memory_cli-0.1.0.dist-info/RECORD +33 -0
- agent_memory_cli-0.1.0.dist-info/WHEEL +4 -0
- agent_memory_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_memory_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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")
|