@delorenj/pjangler 1.3.0 → 1.4.2
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.
- package/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +92 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +11307 -2809
- package/dist/mcp-server.js +9637 -2094
- package/dist/prompt.js +404 -0
- package/package.json +8 -5
- package/templates/commonproject/copier.yml +19 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- package/templates/hermes-agent/template/role.yaml.jinja +19 -19
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project the canonical Project Notebook hooks into Claude settings safely."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import contextlib
|
|
8
|
+
import copy
|
|
9
|
+
import fcntl
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import posixpath
|
|
14
|
+
import secrets
|
|
15
|
+
import shlex
|
|
16
|
+
import stat
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Iterator, Sequence
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
SKILL_ROOT = Path(__file__).resolve().parents[1]
|
|
24
|
+
DEFAULT_MASTER = SKILL_ROOT / "hooks" / "hooks.master.json"
|
|
25
|
+
DEFAULT_FRAGMENT = SKILL_ROOT / "hooks" / "claude.settings.json"
|
|
26
|
+
OWNER_PREFIX = "PJ_HOOK_OWNER=project-notebook.v1 "
|
|
27
|
+
EVENT_WRAPPERS = {
|
|
28
|
+
"SessionStart": "$HOME/.agents/skills/project-notebook/hooks/session-start.sh",
|
|
29
|
+
"SessionEnd": "$HOME/.agents/skills/project-notebook/hooks/session-end.sh",
|
|
30
|
+
}
|
|
31
|
+
EVENT_ORDER = tuple(EVENT_WRAPPERS)
|
|
32
|
+
MAX_JSON_BYTES = 8 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ProjectorError(RuntimeError):
|
|
36
|
+
"""A bounded validation or projection failure."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Generation:
|
|
41
|
+
exists: bool
|
|
42
|
+
device: int | None
|
|
43
|
+
inode: int | None
|
|
44
|
+
size: int
|
|
45
|
+
modified_ns: int | None
|
|
46
|
+
digest: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class LockedState:
|
|
51
|
+
hook_install_fd: int
|
|
52
|
+
snapshots_fd: int
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _canonical_command(event: str) -> str:
|
|
56
|
+
return f'{OWNER_PREFIX}"{EVENT_WRAPPERS[event]}"'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _absolute_path(raw: str | os.PathLike[str], label: str) -> Path:
|
|
60
|
+
path = Path(raw).expanduser()
|
|
61
|
+
if not path.is_absolute():
|
|
62
|
+
raise ProjectorError(f"{label} must be an absolute path")
|
|
63
|
+
if any(component in (".", "..") for component in path.parts[1:]):
|
|
64
|
+
raise ProjectorError(f"{label} must not contain dot path components")
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _directory_flags() -> int:
|
|
69
|
+
if not hasattr(os, "O_DIRECTORY") or not hasattr(os, "O_NOFOLLOW"):
|
|
70
|
+
raise ProjectorError("platform lacks required no-follow directory APIs")
|
|
71
|
+
return os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _regular_read_flags() -> int:
|
|
75
|
+
if not hasattr(os, "O_NOFOLLOW"):
|
|
76
|
+
raise ProjectorError("platform lacks required no-follow file APIs")
|
|
77
|
+
return os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _leaf_name(name: str, label: str) -> str:
|
|
81
|
+
if not name or name in (".", "..") or "/" in name or "\0" in name:
|
|
82
|
+
raise ProjectorError(f"{label} has an unsafe leaf name")
|
|
83
|
+
return name
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _open_absolute_directory(
|
|
87
|
+
path: Path,
|
|
88
|
+
label: str,
|
|
89
|
+
*,
|
|
90
|
+
create_missing: bool,
|
|
91
|
+
require_final_owner: bool = False,
|
|
92
|
+
) -> int | None:
|
|
93
|
+
if not path.is_absolute():
|
|
94
|
+
raise ProjectorError(f"{label} must be an absolute path")
|
|
95
|
+
components = path.parts[1:]
|
|
96
|
+
if any(component in ("", ".", "..") for component in components):
|
|
97
|
+
raise ProjectorError(f"{label} contains an unsafe path component")
|
|
98
|
+
try:
|
|
99
|
+
current_fd = os.open("/", _directory_flags())
|
|
100
|
+
except OSError as exc:
|
|
101
|
+
raise ProjectorError(f"cannot open filesystem root for {label}: {exc.strerror}") from exc
|
|
102
|
+
try:
|
|
103
|
+
for component in components:
|
|
104
|
+
created = False
|
|
105
|
+
try:
|
|
106
|
+
child_fd = os.open(component, _directory_flags(), dir_fd=current_fd)
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
if not create_missing:
|
|
109
|
+
os.close(current_fd)
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
os.mkdir(component, 0o700, dir_fd=current_fd)
|
|
113
|
+
created = True
|
|
114
|
+
except FileExistsError:
|
|
115
|
+
pass
|
|
116
|
+
except (OSError, TypeError) as exc:
|
|
117
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
118
|
+
raise ProjectorError(
|
|
119
|
+
f"cannot create descriptor-relative {label}: {detail}"
|
|
120
|
+
) from exc
|
|
121
|
+
try:
|
|
122
|
+
child_fd = os.open(component, _directory_flags(), dir_fd=current_fd)
|
|
123
|
+
except (OSError, TypeError) as exc:
|
|
124
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
125
|
+
raise ProjectorError(
|
|
126
|
+
f"cannot open descriptor-relative {label}: {detail}"
|
|
127
|
+
) from exc
|
|
128
|
+
except (OSError, TypeError) as exc:
|
|
129
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
130
|
+
raise ProjectorError(f"cannot open descriptor-relative {label}: {detail}") from exc
|
|
131
|
+
os.close(current_fd)
|
|
132
|
+
current_fd = child_fd
|
|
133
|
+
information = os.fstat(current_fd)
|
|
134
|
+
if not stat.S_ISDIR(information.st_mode):
|
|
135
|
+
raise ProjectorError(f"{label} must contain only real directories")
|
|
136
|
+
if created and (
|
|
137
|
+
information.st_uid != os.getuid() or stat.S_IMODE(information.st_mode) != 0o700
|
|
138
|
+
):
|
|
139
|
+
raise ProjectorError(f"new {label} directory must be current-user mode 0700")
|
|
140
|
+
information = os.fstat(current_fd)
|
|
141
|
+
if require_final_owner and information.st_uid != os.getuid():
|
|
142
|
+
raise ProjectorError(f"{label} must be owned by the current user")
|
|
143
|
+
return current_fd
|
|
144
|
+
except Exception:
|
|
145
|
+
os.close(current_fd)
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _open_private_directory_at(parent_fd: int, name: str, label: str) -> int:
|
|
150
|
+
name = _leaf_name(name, label)
|
|
151
|
+
try:
|
|
152
|
+
os.mkdir(name, 0o700, dir_fd=parent_fd)
|
|
153
|
+
except FileExistsError:
|
|
154
|
+
pass
|
|
155
|
+
except (OSError, TypeError) as exc:
|
|
156
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
157
|
+
raise ProjectorError(f"cannot create descriptor-relative {label}: {detail}") from exc
|
|
158
|
+
try:
|
|
159
|
+
descriptor = os.open(name, _directory_flags(), dir_fd=parent_fd)
|
|
160
|
+
except (OSError, TypeError) as exc:
|
|
161
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
162
|
+
raise ProjectorError(f"cannot open descriptor-relative {label}: {detail}") from exc
|
|
163
|
+
try:
|
|
164
|
+
information = os.fstat(descriptor)
|
|
165
|
+
if (
|
|
166
|
+
not stat.S_ISDIR(information.st_mode)
|
|
167
|
+
or information.st_uid != os.getuid()
|
|
168
|
+
or stat.S_IMODE(information.st_mode) != 0o700
|
|
169
|
+
):
|
|
170
|
+
raise ProjectorError(f"{label} must be a current-user mode 0700 directory")
|
|
171
|
+
return descriptor
|
|
172
|
+
except Exception:
|
|
173
|
+
os.close(descriptor)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _reject_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
178
|
+
result: dict[str, Any] = {}
|
|
179
|
+
for key, value in pairs:
|
|
180
|
+
if key in result:
|
|
181
|
+
raise ProjectorError(f"duplicate JSON key: {key}")
|
|
182
|
+
result[key] = value
|
|
183
|
+
return result
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_json_object(raw: bytes, label: str) -> dict[str, Any]:
|
|
187
|
+
if len(raw) > MAX_JSON_BYTES:
|
|
188
|
+
raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
|
|
189
|
+
try:
|
|
190
|
+
text = raw.decode("utf-8")
|
|
191
|
+
except UnicodeDecodeError as exc:
|
|
192
|
+
raise ProjectorError(f"{label} is not UTF-8") from exc
|
|
193
|
+
try:
|
|
194
|
+
value = json.loads(text, object_pairs_hook=_reject_duplicate_keys)
|
|
195
|
+
except ProjectorError:
|
|
196
|
+
raise
|
|
197
|
+
except json.JSONDecodeError as exc:
|
|
198
|
+
raise ProjectorError(
|
|
199
|
+
f"{label} is invalid JSON at line {exc.lineno}, column {exc.colno}"
|
|
200
|
+
) from exc
|
|
201
|
+
if not isinstance(value, dict):
|
|
202
|
+
raise ProjectorError(f"{label} must contain one JSON object")
|
|
203
|
+
return value
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _open_regular_at(parent_fd: int, name: str, label: str) -> tuple[int, os.stat_result]:
|
|
207
|
+
name = _leaf_name(name, label)
|
|
208
|
+
try:
|
|
209
|
+
descriptor = os.open(name, _regular_read_flags(), dir_fd=parent_fd)
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
raise
|
|
212
|
+
except (OSError, TypeError) as exc:
|
|
213
|
+
if isinstance(exc, TypeError):
|
|
214
|
+
raise ProjectorError(f"platform cannot safely open {label}") from exc
|
|
215
|
+
raise ProjectorError(f"cannot open {label}: {exc.strerror}") from exc
|
|
216
|
+
try:
|
|
217
|
+
info = os.fstat(descriptor)
|
|
218
|
+
if not stat.S_ISREG(info.st_mode):
|
|
219
|
+
raise ProjectorError(f"{label} must be a regular file")
|
|
220
|
+
if info.st_uid != os.getuid():
|
|
221
|
+
raise ProjectorError(f"{label} must be owned by the current user")
|
|
222
|
+
if info.st_size > MAX_JSON_BYTES:
|
|
223
|
+
raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
|
|
224
|
+
return descriptor, info
|
|
225
|
+
except Exception:
|
|
226
|
+
os.close(descriptor)
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _read_descriptor(descriptor: int, expected_size: int, label: str) -> bytes:
|
|
231
|
+
chunks: list[bytes] = []
|
|
232
|
+
total = 0
|
|
233
|
+
while True:
|
|
234
|
+
chunk = os.read(descriptor, min(65536, MAX_JSON_BYTES + 1 - total))
|
|
235
|
+
if not chunk:
|
|
236
|
+
break
|
|
237
|
+
chunks.append(chunk)
|
|
238
|
+
total += len(chunk)
|
|
239
|
+
if total > MAX_JSON_BYTES:
|
|
240
|
+
raise ProjectorError(f"{label} exceeds {MAX_JSON_BYTES} bytes")
|
|
241
|
+
raw = b"".join(chunks)
|
|
242
|
+
if len(raw) != expected_size:
|
|
243
|
+
raise ProjectorError(f"{label} changed while it was being read")
|
|
244
|
+
return raw
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_regular_at_bytes(parent_fd: int, name: str, label: str) -> bytes:
|
|
248
|
+
descriptor, information = _open_regular_at(parent_fd, name, label)
|
|
249
|
+
try:
|
|
250
|
+
return _read_descriptor(descriptor, information.st_size, label)
|
|
251
|
+
finally:
|
|
252
|
+
os.close(descriptor)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _read_regular_bytes(path: Path, label: str) -> bytes:
|
|
256
|
+
parent_fd = _open_absolute_directory(path.parent, f"{label} parent", create_missing=False)
|
|
257
|
+
if parent_fd is None:
|
|
258
|
+
raise ProjectorError(f"{label} does not exist")
|
|
259
|
+
try:
|
|
260
|
+
try:
|
|
261
|
+
return _read_regular_at_bytes(parent_fd, path.name, label)
|
|
262
|
+
except FileNotFoundError as exc:
|
|
263
|
+
raise ProjectorError(f"{label} does not exist") from exc
|
|
264
|
+
finally:
|
|
265
|
+
os.close(parent_fd)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _absent_target() -> tuple[bytes, dict[str, Any], Generation]:
|
|
269
|
+
return (
|
|
270
|
+
b"",
|
|
271
|
+
{},
|
|
272
|
+
Generation(False, None, None, 0, None, hashlib.sha256(b"").hexdigest()),
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _read_target_at(parent_fd: int, name: str) -> tuple[bytes, dict[str, Any], Generation]:
|
|
277
|
+
try:
|
|
278
|
+
descriptor, info = _open_regular_at(parent_fd, name, "Claude settings target")
|
|
279
|
+
except FileNotFoundError:
|
|
280
|
+
return _absent_target()
|
|
281
|
+
try:
|
|
282
|
+
raw = _read_descriptor(descriptor, info.st_size, "Claude settings target")
|
|
283
|
+
finally:
|
|
284
|
+
os.close(descriptor)
|
|
285
|
+
value = _parse_json_object(raw, "Claude settings target")
|
|
286
|
+
_validate_live_shape(value)
|
|
287
|
+
generation = Generation(
|
|
288
|
+
True,
|
|
289
|
+
info.st_dev,
|
|
290
|
+
info.st_ino,
|
|
291
|
+
info.st_size,
|
|
292
|
+
info.st_mtime_ns,
|
|
293
|
+
hashlib.sha256(raw).hexdigest(),
|
|
294
|
+
)
|
|
295
|
+
return raw, value, generation
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _read_target(path: Path) -> tuple[bytes, dict[str, Any], Generation]:
|
|
299
|
+
parent_fd = _open_absolute_directory(
|
|
300
|
+
path.parent,
|
|
301
|
+
"Claude settings parent",
|
|
302
|
+
create_missing=False,
|
|
303
|
+
require_final_owner=True,
|
|
304
|
+
)
|
|
305
|
+
if parent_fd is None:
|
|
306
|
+
return _absent_target()
|
|
307
|
+
try:
|
|
308
|
+
return _read_target_at(parent_fd, path.name)
|
|
309
|
+
finally:
|
|
310
|
+
os.close(parent_fd)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _validate_master(master: dict[str, Any]) -> None:
|
|
314
|
+
if list(master) != ["hooks"]:
|
|
315
|
+
raise ProjectorError("hook master must contain only the hooks key")
|
|
316
|
+
hooks = master.get("hooks")
|
|
317
|
+
if not isinstance(hooks, dict) or list(hooks) != list(EVENT_ORDER):
|
|
318
|
+
raise ProjectorError("hook master events must be SessionStart then SessionEnd")
|
|
319
|
+
if "Stop" in hooks:
|
|
320
|
+
raise ProjectorError("hook master must never contain Stop")
|
|
321
|
+
for event in EVENT_ORDER:
|
|
322
|
+
groups = hooks[event]
|
|
323
|
+
if not isinstance(groups, list) or len(groups) != 1:
|
|
324
|
+
raise ProjectorError(f"hook master {event} must contain one group")
|
|
325
|
+
group = groups[0]
|
|
326
|
+
if not isinstance(group, dict) or list(group) != ["hooks"]:
|
|
327
|
+
raise ProjectorError(f"hook master {event} group must contain only hooks")
|
|
328
|
+
entries = group["hooks"]
|
|
329
|
+
if not isinstance(entries, list) or len(entries) != 1:
|
|
330
|
+
raise ProjectorError(f"hook master {event} must contain one hook")
|
|
331
|
+
hook = entries[0]
|
|
332
|
+
if not isinstance(hook, dict) or list(hook) != ["type", "command", "timeout"]:
|
|
333
|
+
raise ProjectorError(f"hook master {event} hook keys must be type, command, timeout")
|
|
334
|
+
if hook["type"] != "command":
|
|
335
|
+
raise ProjectorError(f"hook master {event} type must be command")
|
|
336
|
+
if hook["command"] != _canonical_command(event):
|
|
337
|
+
raise ProjectorError(f"hook master {event} command is not canonical")
|
|
338
|
+
timeout = hook["timeout"]
|
|
339
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int) or not 1 <= timeout <= 60:
|
|
340
|
+
raise ProjectorError(f"hook master {event} timeout must be an integer 1..60")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _deterministic_bytes(value: dict[str, Any]) -> bytes:
|
|
344
|
+
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _load_master(path: Path) -> tuple[dict[str, Any], bytes]:
|
|
348
|
+
raw = _read_regular_bytes(path, "hook master")
|
|
349
|
+
master = _parse_json_object(raw, "hook master")
|
|
350
|
+
_validate_master(master)
|
|
351
|
+
return master, _deterministic_bytes(master)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _load_fragment(path: Path) -> tuple[dict[str, Any], bytes]:
|
|
355
|
+
raw = _read_regular_bytes(path, "Claude generated fragment")
|
|
356
|
+
fragment = _parse_json_object(raw, "Claude generated fragment")
|
|
357
|
+
return fragment, raw
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _load_assets(master_path: Path, fragment_path: Path) -> dict[str, Any]:
|
|
361
|
+
master, expected = _load_master(master_path)
|
|
362
|
+
fragment, raw = _load_fragment(fragment_path)
|
|
363
|
+
if fragment != master or raw != expected:
|
|
364
|
+
raise ProjectorError("Claude generated fragment is stale; run project-hooks.py render")
|
|
365
|
+
return master
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _validate_live_shape(value: dict[str, Any]) -> None:
|
|
369
|
+
if "hooks" not in value:
|
|
370
|
+
return
|
|
371
|
+
hooks = value["hooks"]
|
|
372
|
+
if not isinstance(hooks, dict):
|
|
373
|
+
raise ProjectorError("Claude settings hooks must be an object")
|
|
374
|
+
for event, groups in hooks.items():
|
|
375
|
+
if not isinstance(event, str) or not isinstance(groups, list):
|
|
376
|
+
raise ProjectorError("each Claude hook event must contain a group array")
|
|
377
|
+
for group in groups:
|
|
378
|
+
if not isinstance(group, dict):
|
|
379
|
+
raise ProjectorError(f"Claude hook group for {event} must be an object")
|
|
380
|
+
if "hooks" not in group:
|
|
381
|
+
continue
|
|
382
|
+
entries = group["hooks"]
|
|
383
|
+
if not isinstance(entries, list):
|
|
384
|
+
raise ProjectorError(f"Claude hook group hooks for {event} must be an array")
|
|
385
|
+
if not all(isinstance(entry, dict) for entry in entries):
|
|
386
|
+
raise ProjectorError(f"Claude hook entries for {event} must be objects")
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _normalized_wrapper(token: str) -> str | None:
|
|
390
|
+
if token.startswith("${HOME}/"):
|
|
391
|
+
token = "$HOME/" + token[len("${HOME}/") :]
|
|
392
|
+
elif token.startswith("~/"):
|
|
393
|
+
token = "$HOME/" + token[2:]
|
|
394
|
+
if not token.startswith("$HOME/"):
|
|
395
|
+
return None
|
|
396
|
+
suffix = posixpath.normpath(token[len("$HOME/") :])
|
|
397
|
+
if suffix == "." or suffix.startswith("../") or suffix == "..":
|
|
398
|
+
return None
|
|
399
|
+
return "$HOME/" + suffix
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _marked_wrapper(command: Any) -> tuple[bool, str | None]:
|
|
403
|
+
if not isinstance(command, str) or not command.startswith(OWNER_PREFIX):
|
|
404
|
+
return False, None
|
|
405
|
+
tail = command[len(OWNER_PREFIX) :]
|
|
406
|
+
try:
|
|
407
|
+
tokens = shlex.split(tail, posix=True)
|
|
408
|
+
except ValueError:
|
|
409
|
+
return True, None
|
|
410
|
+
if len(tokens) != 1:
|
|
411
|
+
return True, None
|
|
412
|
+
normalized = _normalized_wrapper(tokens[0])
|
|
413
|
+
if normalized is None:
|
|
414
|
+
return True, None
|
|
415
|
+
for event, wrapper in EVENT_WRAPPERS.items():
|
|
416
|
+
if normalized == wrapper:
|
|
417
|
+
return True, event
|
|
418
|
+
return True, None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _is_owned(hook: dict[str, Any], event: str) -> bool:
|
|
422
|
+
marked, wrapper_event = _marked_wrapper(hook.get("command"))
|
|
423
|
+
return marked and wrapper_event == event
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _dedicated_owned_group(group: dict[str, Any], event: str) -> bool:
|
|
427
|
+
entries = group.get("hooks")
|
|
428
|
+
return (
|
|
429
|
+
set(group) == {"hooks"}
|
|
430
|
+
and isinstance(entries, list)
|
|
431
|
+
and bool(entries)
|
|
432
|
+
and all(_is_owned(entry, event) for entry in entries)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _install_projection(live: dict[str, Any], fragment: dict[str, Any]) -> dict[str, Any]:
|
|
437
|
+
merged = copy.deepcopy(live)
|
|
438
|
+
hooks = merged.setdefault("hooks", {})
|
|
439
|
+
for event in EVENT_ORDER:
|
|
440
|
+
canonical_group = fragment["hooks"][event][0]
|
|
441
|
+
canonical_hook = canonical_group["hooks"][0]
|
|
442
|
+
groups = hooks.setdefault(event, [])
|
|
443
|
+
seen = False
|
|
444
|
+
next_groups: list[dict[str, Any]] = []
|
|
445
|
+
for group in groups:
|
|
446
|
+
entries = group.get("hooks")
|
|
447
|
+
if entries is None:
|
|
448
|
+
next_groups.append(group)
|
|
449
|
+
continue
|
|
450
|
+
was_dedicated = _dedicated_owned_group(group, event)
|
|
451
|
+
next_entries: list[dict[str, Any]] = []
|
|
452
|
+
removed = False
|
|
453
|
+
for hook in entries:
|
|
454
|
+
if not _is_owned(hook, event):
|
|
455
|
+
next_entries.append(hook)
|
|
456
|
+
continue
|
|
457
|
+
if not seen:
|
|
458
|
+
next_entries.append(copy.deepcopy(canonical_hook))
|
|
459
|
+
seen = True
|
|
460
|
+
else:
|
|
461
|
+
removed = True
|
|
462
|
+
group["hooks"] = next_entries
|
|
463
|
+
if removed and not next_entries and was_dedicated:
|
|
464
|
+
continue
|
|
465
|
+
next_groups.append(group)
|
|
466
|
+
if not seen:
|
|
467
|
+
next_groups.append(copy.deepcopy(canonical_group))
|
|
468
|
+
hooks[event] = next_groups
|
|
469
|
+
return merged
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _uninstall_projection(live: dict[str, Any]) -> dict[str, Any]:
|
|
473
|
+
merged = copy.deepcopy(live)
|
|
474
|
+
hooks = merged.get("hooks")
|
|
475
|
+
if not isinstance(hooks, dict):
|
|
476
|
+
return merged
|
|
477
|
+
for event in EVENT_ORDER:
|
|
478
|
+
groups = hooks.get(event)
|
|
479
|
+
if not isinstance(groups, list):
|
|
480
|
+
continue
|
|
481
|
+
event_removed = False
|
|
482
|
+
next_groups: list[dict[str, Any]] = []
|
|
483
|
+
for group in groups:
|
|
484
|
+
entries = group.get("hooks")
|
|
485
|
+
if entries is None:
|
|
486
|
+
next_groups.append(group)
|
|
487
|
+
continue
|
|
488
|
+
was_dedicated = _dedicated_owned_group(group, event)
|
|
489
|
+
next_entries = [entry for entry in entries if not _is_owned(entry, event)]
|
|
490
|
+
removed = len(next_entries) != len(entries)
|
|
491
|
+
event_removed = event_removed or removed
|
|
492
|
+
group["hooks"] = next_entries
|
|
493
|
+
if removed and not next_entries and was_dedicated:
|
|
494
|
+
continue
|
|
495
|
+
next_groups.append(group)
|
|
496
|
+
if event_removed and not next_groups:
|
|
497
|
+
del hooks[event]
|
|
498
|
+
else:
|
|
499
|
+
hooks[event] = next_groups
|
|
500
|
+
return merged
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _check_findings(live: dict[str, Any], fragment: dict[str, Any]) -> list[dict[str, Any]]:
|
|
504
|
+
findings: list[dict[str, Any]] = []
|
|
505
|
+
hooks = live.get("hooks")
|
|
506
|
+
if not isinstance(hooks, dict):
|
|
507
|
+
hooks = {}
|
|
508
|
+
|
|
509
|
+
owned: dict[str, list[dict[str, Any]]] = {event: [] for event in EVENT_ORDER}
|
|
510
|
+
for actual_event, groups in hooks.items():
|
|
511
|
+
for group_index, group in enumerate(groups):
|
|
512
|
+
for hook_index, hook in enumerate(group.get("hooks", [])):
|
|
513
|
+
marked, wrapper_event = _marked_wrapper(hook.get("command"))
|
|
514
|
+
if not marked:
|
|
515
|
+
continue
|
|
516
|
+
if wrapper_event != actual_event or actual_event not in EVENT_ORDER:
|
|
517
|
+
findings.append(
|
|
518
|
+
{
|
|
519
|
+
"kind": "foreign-conflict",
|
|
520
|
+
"event": actual_event,
|
|
521
|
+
"group": group_index,
|
|
522
|
+
"hook": hook_index,
|
|
523
|
+
"message": "owner marker has an unknown or event-mismatched wrapper",
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
continue
|
|
527
|
+
owned[actual_event].append(hook)
|
|
528
|
+
|
|
529
|
+
for event in EVENT_ORDER:
|
|
530
|
+
entries = owned[event]
|
|
531
|
+
if not entries:
|
|
532
|
+
findings.append(
|
|
533
|
+
{"kind": "missing", "event": event, "message": "canonical hook is absent"}
|
|
534
|
+
)
|
|
535
|
+
continue
|
|
536
|
+
if len(entries) > 1:
|
|
537
|
+
findings.append(
|
|
538
|
+
{
|
|
539
|
+
"kind": "duplicate",
|
|
540
|
+
"event": event,
|
|
541
|
+
"count": len(entries),
|
|
542
|
+
"message": "multiple owned hooks are installed",
|
|
543
|
+
}
|
|
544
|
+
)
|
|
545
|
+
canonical = fragment["hooks"][event][0]["hooks"][0]
|
|
546
|
+
if entries[0] != canonical:
|
|
547
|
+
findings.append(
|
|
548
|
+
{"kind": "stale", "event": event, "message": "owned hook differs from master"}
|
|
549
|
+
)
|
|
550
|
+
return findings
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _state_home(raw: str | None) -> Path:
|
|
554
|
+
if raw:
|
|
555
|
+
return _absolute_path(raw, "state home")
|
|
556
|
+
configured = os.environ.get("XDG_STATE_HOME")
|
|
557
|
+
if configured:
|
|
558
|
+
return _absolute_path(configured, "XDG_STATE_HOME")
|
|
559
|
+
home = os.environ.get("HOME")
|
|
560
|
+
if not home:
|
|
561
|
+
raise ProjectorError("HOME or XDG_STATE_HOME is required for mutation")
|
|
562
|
+
return _absolute_path(str(Path(home) / ".local" / "state"), "state home")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@contextlib.contextmanager
|
|
566
|
+
def _projector_lock(state_home: Path) -> Iterator[LockedState]:
|
|
567
|
+
state_fd = _open_absolute_directory(state_home, "state home", create_missing=True)
|
|
568
|
+
if state_fd is None:
|
|
569
|
+
raise AssertionError("create_missing directory traversal returned no descriptor")
|
|
570
|
+
current_fd = state_fd
|
|
571
|
+
hook_install_fd = -1
|
|
572
|
+
snapshots_fd = -1
|
|
573
|
+
lock_fd = -1
|
|
574
|
+
try:
|
|
575
|
+
for name in ("pjangler", "notebook", "v1", "hook-install"):
|
|
576
|
+
child_fd = _open_private_directory_at(current_fd, name, "state directory")
|
|
577
|
+
os.close(current_fd)
|
|
578
|
+
current_fd = child_fd
|
|
579
|
+
hook_install_fd = current_fd
|
|
580
|
+
current_fd = -1
|
|
581
|
+
snapshots_fd = _open_private_directory_at(
|
|
582
|
+
hook_install_fd, "snapshots", "snapshot directory"
|
|
583
|
+
)
|
|
584
|
+
flags = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
|
|
585
|
+
try:
|
|
586
|
+
lock_fd = os.open("lock", flags, 0o600, dir_fd=hook_install_fd)
|
|
587
|
+
except (OSError, TypeError) as exc:
|
|
588
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
589
|
+
raise ProjectorError(
|
|
590
|
+
f"cannot open descriptor-relative projector lock: {detail}"
|
|
591
|
+
) from exc
|
|
592
|
+
info = os.fstat(lock_fd)
|
|
593
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_nlink != 1:
|
|
594
|
+
raise ProjectorError("projector lock must be a current-user regular file")
|
|
595
|
+
os.fchmod(lock_fd, 0o600)
|
|
596
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
597
|
+
yield LockedState(hook_install_fd, snapshots_fd)
|
|
598
|
+
finally:
|
|
599
|
+
if lock_fd >= 0:
|
|
600
|
+
try:
|
|
601
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
602
|
+
finally:
|
|
603
|
+
os.close(lock_fd)
|
|
604
|
+
if snapshots_fd >= 0:
|
|
605
|
+
os.close(snapshots_fd)
|
|
606
|
+
if hook_install_fd >= 0:
|
|
607
|
+
os.close(hook_install_fd)
|
|
608
|
+
if current_fd >= 0:
|
|
609
|
+
os.close(current_fd)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _atomic_write_at(parent_fd: int, name: str, raw: bytes, mode: int) -> None:
|
|
613
|
+
name = _leaf_name(name, "atomic write target")
|
|
614
|
+
temporary_name = ""
|
|
615
|
+
descriptor = -1
|
|
616
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
|
|
617
|
+
for _ in range(16):
|
|
618
|
+
candidate = f".{name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
619
|
+
try:
|
|
620
|
+
descriptor = os.open(candidate, flags, mode, dir_fd=parent_fd)
|
|
621
|
+
except FileExistsError:
|
|
622
|
+
continue
|
|
623
|
+
except (OSError, TypeError) as exc:
|
|
624
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
625
|
+
raise ProjectorError(
|
|
626
|
+
f"cannot create descriptor-relative temporary file: {detail}"
|
|
627
|
+
) from exc
|
|
628
|
+
temporary_name = candidate
|
|
629
|
+
break
|
|
630
|
+
if descriptor < 0:
|
|
631
|
+
raise ProjectorError("cannot allocate a unique descriptor-relative temporary file")
|
|
632
|
+
try:
|
|
633
|
+
os.fchmod(descriptor, mode)
|
|
634
|
+
written = 0
|
|
635
|
+
while written < len(raw):
|
|
636
|
+
count = os.write(descriptor, raw[written:])
|
|
637
|
+
if count <= 0:
|
|
638
|
+
raise ProjectorError("short write to descriptor-relative temporary file")
|
|
639
|
+
written += count
|
|
640
|
+
os.fsync(descriptor)
|
|
641
|
+
os.close(descriptor)
|
|
642
|
+
descriptor = -1
|
|
643
|
+
try:
|
|
644
|
+
os.replace(
|
|
645
|
+
temporary_name,
|
|
646
|
+
name,
|
|
647
|
+
src_dir_fd=parent_fd,
|
|
648
|
+
dst_dir_fd=parent_fd,
|
|
649
|
+
)
|
|
650
|
+
except (OSError, TypeError) as exc:
|
|
651
|
+
detail = exc.strerror if isinstance(exc, OSError) else "unsupported API"
|
|
652
|
+
raise ProjectorError(f"descriptor-relative atomic replace failed: {detail}") from exc
|
|
653
|
+
temporary_name = ""
|
|
654
|
+
os.fsync(parent_fd)
|
|
655
|
+
finally:
|
|
656
|
+
if descriptor >= 0:
|
|
657
|
+
os.close(descriptor)
|
|
658
|
+
if temporary_name:
|
|
659
|
+
try:
|
|
660
|
+
os.unlink(temporary_name, dir_fd=parent_fd)
|
|
661
|
+
except FileNotFoundError:
|
|
662
|
+
pass
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _same_generation_at(parent_fd: int, name: str, expected: Generation) -> bool:
|
|
666
|
+
raw, _, actual = _read_target_at(parent_fd, name)
|
|
667
|
+
del raw
|
|
668
|
+
return actual == expected
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def _write_snapshot(snapshots_fd: int, raw: bytes) -> str:
|
|
672
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
673
|
+
name = f"{digest}.json"
|
|
674
|
+
try:
|
|
675
|
+
descriptor, information = _open_regular_at(snapshots_fd, name, "recovery snapshot")
|
|
676
|
+
except FileNotFoundError:
|
|
677
|
+
_atomic_write_at(snapshots_fd, name, raw, 0o600)
|
|
678
|
+
else:
|
|
679
|
+
try:
|
|
680
|
+
existing = _read_descriptor(descriptor, information.st_size, "recovery snapshot")
|
|
681
|
+
finally:
|
|
682
|
+
os.close(descriptor)
|
|
683
|
+
if existing != raw:
|
|
684
|
+
raise ProjectorError("content-addressed recovery snapshot collision")
|
|
685
|
+
if stat.S_IMODE(information.st_mode) != 0o600:
|
|
686
|
+
raise ProjectorError("recovery snapshot must be mode 0600")
|
|
687
|
+
return name
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _mutate(
|
|
691
|
+
operation: str,
|
|
692
|
+
master_path: Path,
|
|
693
|
+
fragment_path: Path,
|
|
694
|
+
target: Path,
|
|
695
|
+
state_home: Path,
|
|
696
|
+
) -> bool:
|
|
697
|
+
fragment = _load_assets(master_path, fragment_path)
|
|
698
|
+
_read_target(target) # Preflight JSON before creating lock or recovery state.
|
|
699
|
+
with _projector_lock(state_home) as locked:
|
|
700
|
+
target_parent_fd = _open_absolute_directory(
|
|
701
|
+
target.parent,
|
|
702
|
+
"Claude settings parent",
|
|
703
|
+
create_missing=True,
|
|
704
|
+
require_final_owner=True,
|
|
705
|
+
)
|
|
706
|
+
if target_parent_fd is None:
|
|
707
|
+
raise AssertionError("create_missing target traversal returned no descriptor")
|
|
708
|
+
try:
|
|
709
|
+
raw, live, generation = _read_target_at(
|
|
710
|
+
target_parent_fd, target.name
|
|
711
|
+
) # Required under-lock re-read.
|
|
712
|
+
if operation == "install":
|
|
713
|
+
desired = _install_projection(live, fragment)
|
|
714
|
+
elif operation == "uninstall":
|
|
715
|
+
desired = _uninstall_projection(live)
|
|
716
|
+
else:
|
|
717
|
+
raise AssertionError(operation)
|
|
718
|
+
if desired == live:
|
|
719
|
+
return False
|
|
720
|
+
if not _same_generation_at(target_parent_fd, target.name, generation):
|
|
721
|
+
raise ProjectorError("Claude settings changed concurrently; retry")
|
|
722
|
+
snapshot_raw = raw if generation.exists else b"{}\n"
|
|
723
|
+
_write_snapshot(locked.snapshots_fd, snapshot_raw)
|
|
724
|
+
if not _same_generation_at(target_parent_fd, target.name, generation):
|
|
725
|
+
raise ProjectorError("Claude settings changed concurrently; retry")
|
|
726
|
+
_atomic_write_at(
|
|
727
|
+
target_parent_fd,
|
|
728
|
+
target.name,
|
|
729
|
+
_deterministic_bytes(desired),
|
|
730
|
+
0o600,
|
|
731
|
+
)
|
|
732
|
+
return True
|
|
733
|
+
finally:
|
|
734
|
+
os.close(target_parent_fd)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _render(master_path: Path, fragment_path: Path) -> bool:
|
|
738
|
+
_, expected = _load_master(master_path)
|
|
739
|
+
parent_fd = _open_absolute_directory(
|
|
740
|
+
fragment_path.parent,
|
|
741
|
+
"generated fragment parent",
|
|
742
|
+
create_missing=False,
|
|
743
|
+
require_final_owner=True,
|
|
744
|
+
)
|
|
745
|
+
if parent_fd is None:
|
|
746
|
+
raise ProjectorError("generated fragment parent does not exist")
|
|
747
|
+
try:
|
|
748
|
+
try:
|
|
749
|
+
current = _read_regular_at_bytes(
|
|
750
|
+
parent_fd, fragment_path.name, "Claude generated fragment"
|
|
751
|
+
)
|
|
752
|
+
except FileNotFoundError:
|
|
753
|
+
current = None
|
|
754
|
+
if current is not None:
|
|
755
|
+
_parse_json_object(current, "Claude generated fragment")
|
|
756
|
+
if current == expected:
|
|
757
|
+
return False
|
|
758
|
+
_atomic_write_at(parent_fd, fragment_path.name, expected, 0o644)
|
|
759
|
+
return True
|
|
760
|
+
finally:
|
|
761
|
+
os.close(parent_fd)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _check(master_path: Path, fragment_path: Path, target: Path) -> list[dict[str, Any]]:
|
|
765
|
+
master, expected = _load_master(master_path)
|
|
766
|
+
fragment, fragment_raw = _load_fragment(fragment_path)
|
|
767
|
+
findings: list[dict[str, Any]] = []
|
|
768
|
+
if fragment != master or fragment_raw != expected:
|
|
769
|
+
findings.append(
|
|
770
|
+
{
|
|
771
|
+
"kind": "stale",
|
|
772
|
+
"event": "generated-fragment",
|
|
773
|
+
"message": "generated fragment differs from hook master",
|
|
774
|
+
}
|
|
775
|
+
)
|
|
776
|
+
_, live, _ = _read_target(target)
|
|
777
|
+
findings.extend(_check_findings(live, master))
|
|
778
|
+
return findings
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _add_asset_arguments(parser: argparse.ArgumentParser) -> None:
|
|
782
|
+
parser.add_argument("--master", default=str(DEFAULT_MASTER), help="absolute hook master")
|
|
783
|
+
parser.add_argument(
|
|
784
|
+
"--fragment", default=str(DEFAULT_FRAGMENT), help="absolute generated fragment"
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _add_target_argument(parser: argparse.ArgumentParser) -> None:
|
|
789
|
+
default = os.environ.get(
|
|
790
|
+
"PJ_PROJECT_NOTEBOOK_CLAUDE_SETTINGS", str(Path.home() / ".claude" / "settings.json")
|
|
791
|
+
)
|
|
792
|
+
parser.add_argument("--target", default=default, help="absolute Claude settings target")
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _parser() -> argparse.ArgumentParser:
|
|
796
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
797
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
798
|
+
|
|
799
|
+
render = commands.add_parser("render", help="render the deterministic Claude fragment")
|
|
800
|
+
_add_asset_arguments(render)
|
|
801
|
+
|
|
802
|
+
check = commands.add_parser("check", help="report hook projection drift without writes")
|
|
803
|
+
_add_asset_arguments(check)
|
|
804
|
+
_add_target_argument(check)
|
|
805
|
+
check.add_argument("--json", action="store_true", help="emit a machine-readable result")
|
|
806
|
+
|
|
807
|
+
for name in ("install", "uninstall"):
|
|
808
|
+
command = commands.add_parser(name, help=f"{name} only recognized Project Notebook hooks")
|
|
809
|
+
_add_asset_arguments(command)
|
|
810
|
+
_add_target_argument(command)
|
|
811
|
+
command.add_argument(
|
|
812
|
+
"--state-home",
|
|
813
|
+
help="absolute XDG state home override (tests and packaged installers)",
|
|
814
|
+
)
|
|
815
|
+
return parser
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
819
|
+
args = _parser().parse_args(argv)
|
|
820
|
+
try:
|
|
821
|
+
master_path = _absolute_path(args.master, "hook master")
|
|
822
|
+
fragment_path = _absolute_path(args.fragment, "generated fragment")
|
|
823
|
+
if master_path == fragment_path:
|
|
824
|
+
raise ProjectorError("hook master and generated fragment must be distinct")
|
|
825
|
+
|
|
826
|
+
if args.command == "render":
|
|
827
|
+
changed = _render(master_path, fragment_path)
|
|
828
|
+
print(f"project-hooks: render {'changed' if changed else 'up to date'}")
|
|
829
|
+
return 0
|
|
830
|
+
|
|
831
|
+
target = _absolute_path(args.target, "Claude settings target")
|
|
832
|
+
if target in (master_path, fragment_path):
|
|
833
|
+
raise ProjectorError("Claude settings target must be distinct from skill assets")
|
|
834
|
+
if args.command == "check":
|
|
835
|
+
findings = _check(master_path, fragment_path, target)
|
|
836
|
+
if args.json:
|
|
837
|
+
print(
|
|
838
|
+
json.dumps(
|
|
839
|
+
{"ok": not findings, "findings": findings},
|
|
840
|
+
separators=(",", ":"),
|
|
841
|
+
ensure_ascii=False,
|
|
842
|
+
)
|
|
843
|
+
)
|
|
844
|
+
elif findings:
|
|
845
|
+
for finding in findings:
|
|
846
|
+
print(
|
|
847
|
+
f"project-hooks: {finding['kind']}: {finding['event']}: "
|
|
848
|
+
f"{finding['message']}"
|
|
849
|
+
)
|
|
850
|
+
else:
|
|
851
|
+
print("project-hooks: check clean")
|
|
852
|
+
return 0 if not findings else 1
|
|
853
|
+
|
|
854
|
+
state_home = _state_home(args.state_home)
|
|
855
|
+
changed = _mutate(args.command, master_path, fragment_path, target, state_home)
|
|
856
|
+
print(f"project-hooks: {args.command} {'changed' if changed else 'up to date'}")
|
|
857
|
+
return 0
|
|
858
|
+
except (OSError, ProjectorError) as exc:
|
|
859
|
+
message = exc.strerror if isinstance(exc, OSError) and exc.strerror else str(exc)
|
|
860
|
+
print(f"project-hooks: error: {message}", file=sys.stderr)
|
|
861
|
+
return 2
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
if __name__ == "__main__":
|
|
865
|
+
raise SystemExit(main())
|