hypomnema 1.5.1 → 1.6.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -1
- package/README.md +2 -1
- package/commands/capture.md +40 -0
- package/docs/CONTRIBUTING.md +18 -0
- package/hooks/hypo-auto-minimal-crystallize.mjs +69 -2
- package/hooks/hypo-cwd-change.mjs +21 -3
- package/hooks/hypo-shared.mjs +303 -9
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +245 -128
- package/scripts/doctor.mjs +144 -5
- package/scripts/init.mjs +16 -5
- package/scripts/lib/core-hooks.mjs +156 -0
- package/scripts/lib/extensions.mjs +317 -9
- package/scripts/lint.mjs +32 -0
- package/scripts/uninstall.mjs +84 -42
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/capture.mjs — Reverse extension capture (MVP capture design).
|
|
4
|
+
*
|
|
5
|
+
* Pulls extensions authored the "normal way" under `~/.claude/{commands,agents}/`
|
|
6
|
+
* (readdir enumeration) and canonical hooks registered in `~/.claude/settings.json`
|
|
7
|
+
* (settings enumeration) into the wiki `~/hypomnema/extensions/{commands,agents,hooks}/`
|
|
8
|
+
* so they propagate to other machines via the existing forward-sync ("register on
|
|
9
|
+
* A → sync on B").
|
|
10
|
+
*
|
|
11
|
+
* Scope: commands + agents (pure file copy, no settings.json) and hooks (settings
|
|
12
|
+
* reverse-capture, lossless round-trip only). skills (directory support) are
|
|
13
|
+
* deferred to a separate PR.
|
|
14
|
+
*
|
|
15
|
+
* Naming (capture design §3): the wiki STORAGE name is `hypo-ext-<name>.<ext>` (keeps
|
|
16
|
+
* the discovery whitelist), and a sidecar `hypo-ext-<name>.manifest.json` records
|
|
17
|
+
* `{ type, installName, ... }` so forward-sync installs the file back under the
|
|
18
|
+
* user's ORIGINAL name (`~/.claude/<type>/<name>.<ext>`), not the wiki storage name.
|
|
19
|
+
* For hooks the manifest also carries `{ event, matcher?, timeout? }` so the original
|
|
20
|
+
* settings.json registration is restored on the far machine.
|
|
21
|
+
*
|
|
22
|
+
* Adopt (capture design §4): capture copies the source verbatim into the wiki, then
|
|
23
|
+
* runs forward-sync. The install target already holds byte-identical content, so
|
|
24
|
+
* `copyOne` hits its `up-to-date` branch and records the SHA — ownership is
|
|
25
|
+
* acquired through the normal sync path, never by injecting a SHA directly.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
readdirSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
unlinkSync,
|
|
35
|
+
renameSync,
|
|
36
|
+
realpathSync,
|
|
37
|
+
} from 'fs';
|
|
38
|
+
import { join, dirname } from 'path';
|
|
39
|
+
import { homedir } from 'os';
|
|
40
|
+
import { pathToFileURL, fileURLToPath } from 'url';
|
|
41
|
+
import {
|
|
42
|
+
sha256,
|
|
43
|
+
isRegularFile,
|
|
44
|
+
readFileIfRegular,
|
|
45
|
+
readPkgJson,
|
|
46
|
+
writePkgJsonAtomic,
|
|
47
|
+
} from './lib/pkg-json.mjs';
|
|
48
|
+
import { resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
49
|
+
import {
|
|
50
|
+
syncExtensions,
|
|
51
|
+
readExtensionPkgStateNoMutate,
|
|
52
|
+
isValidInstallStem,
|
|
53
|
+
scanSettingsHooks,
|
|
54
|
+
parseCapturableHookCommand,
|
|
55
|
+
HOOK_EVENT_ALLOWLIST,
|
|
56
|
+
EXT_PREFIX,
|
|
57
|
+
} from './lib/extensions.mjs';
|
|
58
|
+
import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
|
|
59
|
+
|
|
60
|
+
const HOME = homedir();
|
|
61
|
+
|
|
62
|
+
// The package root (contains hooks/hooks.json) so reverse-capture can reserve the
|
|
63
|
+
// core hook basenames without importing init.mjs (which has exit-on-error side
|
|
64
|
+
// effects). capture.mjs lives in scripts/, so the root is one level up.
|
|
65
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
66
|
+
|
|
67
|
+
// Captured types and their singular manifest `type` value. commands/agents install
|
|
68
|
+
// as top-level `.md` files; hooks install as `.mjs` and additionally register a
|
|
69
|
+
// settings.json entry (reverse of forward-sync).
|
|
70
|
+
const CAPTURE_TYPES = ['commands', 'agents', 'hooks'];
|
|
71
|
+
const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook' };
|
|
72
|
+
const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
|
|
73
|
+
|
|
74
|
+
function pkgJsonPath() {
|
|
75
|
+
return join(HOME, '.claude', 'hypo-pkg.json');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── pure helpers (exported for tests) ────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide whether a top-level `~/.claude/<type>/<file>` is a capture candidate.
|
|
82
|
+
* Excludes (capture design §6): the reserved `hypo` namespace (case-insensitive — core
|
|
83
|
+
* hooks/commands + already-synced hypo-ext-* copies), anything already owned
|
|
84
|
+
* (a recorded SHA for its install path means the wiki already manages it), and
|
|
85
|
+
* non-.md files. Symlink/non-regular is checked by the caller (needs an fs stat).
|
|
86
|
+
*/
|
|
87
|
+
export function isCaptureCandidate(type, file, recorded) {
|
|
88
|
+
if (!file.endsWith(TYPE_EXT[type])) return { ok: false, reason: `not a ${TYPE_EXT[type]} file` };
|
|
89
|
+
const lower = file.toLowerCase();
|
|
90
|
+
if (lower === 'hypo' || lower.startsWith('hypo-')) {
|
|
91
|
+
return { ok: false, reason: 'reserved hypo namespace' };
|
|
92
|
+
}
|
|
93
|
+
if (recorded[`${type}/${file}`]) {
|
|
94
|
+
return { ok: false, reason: 'already managed by the wiki' };
|
|
95
|
+
}
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Order-independent deep equality for parsed JSON values (objects/arrays/scalars).
|
|
100
|
+
* Manifests are compared by content, not key order, so a hand-reordered but
|
|
101
|
+
* equivalent sidecar is still recognized as `already` rather than a false conflict. */
|
|
102
|
+
function deepEqual(a, b) {
|
|
103
|
+
if (a === b) return true;
|
|
104
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object') return false;
|
|
105
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
106
|
+
const ka = Object.keys(a);
|
|
107
|
+
const kb = Object.keys(b);
|
|
108
|
+
if (ka.length !== kb.length) return false;
|
|
109
|
+
for (const k of ka) {
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(b, k) || !deepEqual(a[k], b[k])) return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Plan the capture of one candidate against the current wiki state. Generalized
|
|
117
|
+
* over extension type: the caller assembles the desired `wantManifest` (commands
|
|
118
|
+
* and agents get `{ type, installName }`; hooks additionally carry
|
|
119
|
+
* `{ event, matcher?, timeout? }`) and this decides the outcome by comparing the
|
|
120
|
+
* source SHA and doing a deep-equality check against any existing sidecar. Pure
|
|
121
|
+
* w.r.t. decisions; the caller supplies the already-read source + wiki contents so
|
|
122
|
+
* this stays testable.
|
|
123
|
+
*
|
|
124
|
+
* status:
|
|
125
|
+
* 'invalid' — the installName stem is unsafe/reserved (skip).
|
|
126
|
+
* 'conflict' — a wiki storage file or sidecar manifest already exists and
|
|
127
|
+
* disagrees (refuse; never silently overwrite, capture design §7).
|
|
128
|
+
* 'already' — wiki file + manifest already match this capture (no-op).
|
|
129
|
+
* 'ready' — safe to write.
|
|
130
|
+
*/
|
|
131
|
+
export function planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw }) {
|
|
132
|
+
if (!isValidInstallStem(wantManifest.installName)) {
|
|
133
|
+
return { status: 'invalid', reason: `invalid installName "${wantManifest.installName}"` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (existingFileSha !== null && existingFileSha !== undefined) {
|
|
137
|
+
if (existingFileSha !== srcSha) {
|
|
138
|
+
return { status: 'conflict', reason: 'wiki storage file exists with different content' };
|
|
139
|
+
}
|
|
140
|
+
// The storage file matches — the manifest must also match, else install
|
|
141
|
+
// semantics (installName/event/matcher/timeout) would differ.
|
|
142
|
+
if (existingManifestRaw == null) {
|
|
143
|
+
return { status: 'conflict', reason: 'wiki file exists without its installName manifest' };
|
|
144
|
+
}
|
|
145
|
+
let parsed;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(existingManifestRaw);
|
|
148
|
+
} catch {
|
|
149
|
+
return { status: 'conflict', reason: 'wiki manifest is not valid JSON' };
|
|
150
|
+
}
|
|
151
|
+
if (!deepEqual(parsed, wantManifest)) {
|
|
152
|
+
return { status: 'conflict', reason: 'wiki manifest declares a different mapping' };
|
|
153
|
+
}
|
|
154
|
+
return { status: 'already', manifest: wantManifest };
|
|
155
|
+
}
|
|
156
|
+
// No wiki storage file yet. A stray manifest with a different mapping is still a conflict.
|
|
157
|
+
if (existingManifestRaw != null) {
|
|
158
|
+
let parsed;
|
|
159
|
+
try {
|
|
160
|
+
parsed = JSON.parse(existingManifestRaw);
|
|
161
|
+
} catch {
|
|
162
|
+
return { status: 'conflict', reason: 'stray wiki manifest is not valid JSON' };
|
|
163
|
+
}
|
|
164
|
+
if (!deepEqual(parsed, wantManifest)) {
|
|
165
|
+
return { status: 'conflict', reason: 'stray wiki manifest declares a different mapping' };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { status: 'ready', manifest: wantManifest };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Reverse-generate the sidecar manifest for a capture candidate. commands/agents
|
|
173
|
+
* carry only `{ type, installName }`; hooks additionally carry the settings
|
|
174
|
+
* registration fields `{ event, matcher?, timeout? }`. matcher/timeout are omitted
|
|
175
|
+
* when absent (an empty-string matcher is already normalized to undefined by the
|
|
176
|
+
* scanner) via conditional spread, so the in-memory object never carries an
|
|
177
|
+
* `undefined`-valued key that would break the planCapture deep-equality check.
|
|
178
|
+
*/
|
|
179
|
+
function buildWantManifest(c) {
|
|
180
|
+
const manifest = { type: TYPE_SINGULAR[c.type], installName: c.stem };
|
|
181
|
+
if (c.type === 'hooks') {
|
|
182
|
+
manifest.event = c.event;
|
|
183
|
+
if (c.matcher !== undefined && c.matcher !== '') manifest.matcher = c.matcher;
|
|
184
|
+
if (c.timeout !== undefined) manifest.timeout = c.timeout;
|
|
185
|
+
}
|
|
186
|
+
return manifest;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── filesystem orchestration ─────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
function scanCandidates(claudeHome, recorded, types) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const type of types) {
|
|
194
|
+
const dir = join(claudeHome, type);
|
|
195
|
+
if (!existsSync(dir)) continue;
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = readdirSync(dir);
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
for (const file of entries) {
|
|
203
|
+
const verdict = isCaptureCandidate(type, file, recorded);
|
|
204
|
+
if (!verdict.ok) continue;
|
|
205
|
+
const srcPath = join(dir, file);
|
|
206
|
+
if (!isRegularFile(srcPath)) continue; // never follow symlinks/sockets
|
|
207
|
+
out.push({ type, file, stem: file.slice(0, -TYPE_EXT[type].length), srcPath });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// The preservable shape a capturable hook must have (F3). Anything outside these
|
|
214
|
+
// key sets, or a non-positive-integer timeout, means a lossless round-trip cannot
|
|
215
|
+
// be guaranteed → skip rather than drop a field on the far machine.
|
|
216
|
+
const ALLOWED_HOOK_KEYS = new Set(['type', 'command', 'timeout']);
|
|
217
|
+
const ALLOWED_GROUP_KEYS = new Set(['matcher', 'hooks']);
|
|
218
|
+
|
|
219
|
+
function subsetOf(keys, allowed) {
|
|
220
|
+
for (const k of keys) if (!allowed.has(k)) return false;
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Enumerate capturable hooks from `~/.claude/settings.json`. Unlike commands/agents
|
|
226
|
+
* (readdir), hooks are discovered by walking the settings registration and keeping
|
|
227
|
+
* only entries that round-trip losslessly to the canonical form forward-sync emits.
|
|
228
|
+
* Returns `{ candidates, skipped }`; every rejected entry gets a visible skip reason
|
|
229
|
+
* (never a silent drop, capture design F4) so a user can see why a hook was passed
|
|
230
|
+
* over — including absolute-path / tilde registrations that are legal but not
|
|
231
|
+
* canonical.
|
|
232
|
+
*
|
|
233
|
+
* A candidate must satisfy ALL of: strict command grammar
|
|
234
|
+
* (`parseCapturableHookCommand`) resolving under `~/.claude/hooks/`; the resolved
|
|
235
|
+
* `.mjs` is a real regular file (no symlink / non-regular); `type === 'command'`;
|
|
236
|
+
* the event is in `HOOK_EVENT_ALLOWLIST`; a preservable shape (F3); the case-folded
|
|
237
|
+
* basename appears exactly once across all settings hooks (F6); and the stem is not
|
|
238
|
+
* a core hook, not the reserved hypo namespace, and not already wiki-owned.
|
|
239
|
+
*/
|
|
240
|
+
export function scanHookCandidates(claudeHome, settingsPath, recorded, coreBasenames) {
|
|
241
|
+
const candidates = [];
|
|
242
|
+
const skipped = [];
|
|
243
|
+
let settings;
|
|
244
|
+
try {
|
|
245
|
+
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
|
246
|
+
} catch {
|
|
247
|
+
// Absent or invalid settings.json → nothing to capture (not an error).
|
|
248
|
+
return { candidates, skipped };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const records = scanSettingsHooks(settings);
|
|
252
|
+
|
|
253
|
+
// First pass: count case-folded basenames across every record whose command
|
|
254
|
+
// parses to a canonical basename. A basename seen 2+ times (multiple events, or
|
|
255
|
+
// the same event with different matchers) cannot be reverse-generated to a single
|
|
256
|
+
// manifest, so ALL its registrations are skipped (F6).
|
|
257
|
+
const parsedByRecord = new Map();
|
|
258
|
+
const basenameCounts = new Map();
|
|
259
|
+
for (const rec of records) {
|
|
260
|
+
const parsed = parseCapturableHookCommand(rec.command);
|
|
261
|
+
parsedByRecord.set(rec, parsed);
|
|
262
|
+
if (parsed.ok) {
|
|
263
|
+
const key = parsed.basename.toLowerCase();
|
|
264
|
+
basenameCounts.set(key, (basenameCounts.get(key) || 0) + 1);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Second pass: classify each record.
|
|
269
|
+
for (const rec of records) {
|
|
270
|
+
const parsed = parsedByRecord.get(rec);
|
|
271
|
+
if (!parsed.ok) {
|
|
272
|
+
skipped.push({ type: 'hooks', command: rec.command, reason: parsed.reason });
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const { stem, basename } = parsed;
|
|
276
|
+
const label = `hooks/${basename}`;
|
|
277
|
+
|
|
278
|
+
// Duplicate first: a basename registered 2+ times is uniformly skipped so both
|
|
279
|
+
// sides report the same reason.
|
|
280
|
+
if (basenameCounts.get(basename.toLowerCase()) >= 2) {
|
|
281
|
+
skipped.push({
|
|
282
|
+
type: 'hooks',
|
|
283
|
+
command: rec.command,
|
|
284
|
+
basename,
|
|
285
|
+
reason: 'duplicate-registration',
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (rec.type !== 'command') {
|
|
290
|
+
skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'non-command-type' });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (!HOOK_EVENT_ALLOWLIST.has(rec.event)) {
|
|
294
|
+
skipped.push({
|
|
295
|
+
type: 'hooks',
|
|
296
|
+
command: rec.command,
|
|
297
|
+
basename,
|
|
298
|
+
reason: 'event-not-allowlisted',
|
|
299
|
+
});
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (
|
|
303
|
+
!subsetOf(rec.hookKeys, ALLOWED_HOOK_KEYS) ||
|
|
304
|
+
!subsetOf(rec.groupKeys, ALLOWED_GROUP_KEYS) ||
|
|
305
|
+
(rec.timeout !== undefined && !(Number.isInteger(rec.timeout) && rec.timeout > 0))
|
|
306
|
+
) {
|
|
307
|
+
skipped.push({
|
|
308
|
+
type: 'hooks',
|
|
309
|
+
command: rec.command,
|
|
310
|
+
basename,
|
|
311
|
+
reason: 'unpreservable-shape',
|
|
312
|
+
});
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (coreBasenames.has(basename.toLowerCase())) {
|
|
316
|
+
skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'core-hook' });
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (recorded[label]) {
|
|
320
|
+
skipped.push({
|
|
321
|
+
type: 'hooks',
|
|
322
|
+
command: rec.command,
|
|
323
|
+
basename,
|
|
324
|
+
reason: 'already-managed by the wiki',
|
|
325
|
+
});
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// The command resolved under $HOME/.claude/hooks; the source must be a real
|
|
329
|
+
// regular file there (a symlink or non-regular is refused, capture design F4).
|
|
330
|
+
const srcPath = join(claudeHome, 'hooks', basename);
|
|
331
|
+
if (!existsSync(srcPath) || !isRegularFile(srcPath)) {
|
|
332
|
+
skipped.push({ type: 'hooks', command: rec.command, basename, reason: 'unresolved-source' });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
candidates.push({
|
|
336
|
+
type: 'hooks',
|
|
337
|
+
file: basename,
|
|
338
|
+
stem,
|
|
339
|
+
srcPath,
|
|
340
|
+
event: rec.event,
|
|
341
|
+
matcher: rec.matcher,
|
|
342
|
+
timeout: rec.timeout,
|
|
343
|
+
label,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
return { candidates, skipped };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseArgs(argv) {
|
|
350
|
+
const args = { names: [], all: false, types: CAPTURE_TYPES.slice(), dryRun: false, help: false };
|
|
351
|
+
let hypoDir = null;
|
|
352
|
+
for (const a of argv.slice(2)) {
|
|
353
|
+
if (a === '--all') args.all = true;
|
|
354
|
+
else if (a === '--dry-run') args.dryRun = true;
|
|
355
|
+
else if (a === '--help' || a === '-h') args.help = true;
|
|
356
|
+
else if (a.startsWith('--type=')) {
|
|
357
|
+
args.types = a
|
|
358
|
+
.slice(7)
|
|
359
|
+
.split(',')
|
|
360
|
+
.map((s) => s.trim())
|
|
361
|
+
.filter((t) => CAPTURE_TYPES.includes(t));
|
|
362
|
+
} else if (a.startsWith('--hypo-dir=')) hypoDir = a.slice(11);
|
|
363
|
+
else if (!a.startsWith('-')) args.names.push(a);
|
|
364
|
+
}
|
|
365
|
+
args.hypoDir = hypoDir || resolveHypoRoot();
|
|
366
|
+
if (args.types.length === 0) args.types = CAPTURE_TYPES.slice();
|
|
367
|
+
return args;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function log(msg) {
|
|
371
|
+
process.stdout.write(`${msg}\n`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Write via a sibling temp + rename so the final replace is atomic and, crucially,
|
|
375
|
+
// never follows a symlink already sitting at `dest` (rename swaps the link itself).
|
|
376
|
+
function writeAtomic(dest, buf) {
|
|
377
|
+
const tmp = `${dest}.tmp.${process.pid}`;
|
|
378
|
+
writeFileSync(tmp, buf);
|
|
379
|
+
try {
|
|
380
|
+
renameSync(tmp, dest);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
try {
|
|
383
|
+
unlinkSync(tmp);
|
|
384
|
+
} catch {}
|
|
385
|
+
throw err;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Undo one capture's wiki writes: drop the storage file (.md or .mjs), and either
|
|
390
|
+
// restore the manifest bytes we overwrote (a pre-existing same-mapping sidecar) or
|
|
391
|
+
// remove the manifest we created. Used on a failed/aborted adopt so a capture never
|
|
392
|
+
// half-lands.
|
|
393
|
+
function rollbackRec(rec) {
|
|
394
|
+
try {
|
|
395
|
+
unlinkSync(rec.filePath);
|
|
396
|
+
} catch {}
|
|
397
|
+
if (rec.manifestExisted && rec.manifestPrevBuf != null) {
|
|
398
|
+
try {
|
|
399
|
+
writeFileSync(rec.manifestPath, rec.manifestPrevBuf);
|
|
400
|
+
} catch {}
|
|
401
|
+
} else {
|
|
402
|
+
try {
|
|
403
|
+
unlinkSync(rec.manifestPath);
|
|
404
|
+
} catch {}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Remove specific per-target ownership keys from hypo-pkg.json (codex pre-commit
|
|
409
|
+
// CONCERN). A partial adopt can leave forward-sync having recorded SOME of a
|
|
410
|
+
// capture's install keys (e.g. the byte-identical `.mjs` was owned but its sidecar
|
|
411
|
+
// manifest could not be), and the wiki-file rollback alone would leave that
|
|
412
|
+
// ownership stranded: a later capture would skip the hook as already-managed and
|
|
413
|
+
// uninstall would trust the recorded SHA and delete the user's ORIGINAL hook.
|
|
414
|
+
// Reads the freshly-written pkg, drops only the given keys, and rewrites atomically.
|
|
415
|
+
function purgeOwnedKeys(pkgPath, target, keys) {
|
|
416
|
+
if (keys.size === 0) return;
|
|
417
|
+
const pkg = readPkgJson(pkgPath);
|
|
418
|
+
const ext = pkg.extensions;
|
|
419
|
+
if (!ext || typeof ext !== 'object' || Array.isArray(ext)) return;
|
|
420
|
+
const perTarget = ext[target];
|
|
421
|
+
if (!perTarget || typeof perTarget !== 'object' || Array.isArray(perTarget)) return;
|
|
422
|
+
let changed = false;
|
|
423
|
+
for (const k of keys) {
|
|
424
|
+
if (perTarget[k] !== undefined) {
|
|
425
|
+
delete perTarget[k];
|
|
426
|
+
changed = true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (changed) writePkgJsonAtomic(pkgPath, pkg);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
433
|
+
const extDir = join(args.hypoDir, 'extensions');
|
|
434
|
+
const settingsPath = join(claudeHome, 'settings.json');
|
|
435
|
+
const recorded = readExtensionPkgStateNoMutate(pkgJsonPath(), 'claude');
|
|
436
|
+
// commands/agents enumerate by readdir; hooks enumerate from settings.json.
|
|
437
|
+
const dirTypes = args.types.filter((t) => t !== 'hooks');
|
|
438
|
+
const candidates = scanCandidates(claudeHome, recorded, dirTypes);
|
|
439
|
+
|
|
440
|
+
// Hooks are captured from the settings registration. Reserve the core hook
|
|
441
|
+
// basenames deterministically from hooks.json; if that load is not ok (read,
|
|
442
|
+
// parse, or shape failure) skip the whole hooks type rather than risk capturing
|
|
443
|
+
// a core hook (T1 fail-closed contract: gate on ok, not on cfg presence).
|
|
444
|
+
const scanSkipped = [];
|
|
445
|
+
if (args.types.includes('hooks')) {
|
|
446
|
+
const core = readCoreHooksConfig(PKG_ROOT);
|
|
447
|
+
if (!core.ok) {
|
|
448
|
+
log(`⊘ hooks: core hook reservations unavailable (${core.error}) — hooks capture skipped`);
|
|
449
|
+
} else {
|
|
450
|
+
const coreBasenames = deriveCoreHookBasenames(core.cfg);
|
|
451
|
+
const hookScan = scanHookCandidates(claudeHome, settingsPath, recorded, coreBasenames);
|
|
452
|
+
candidates.push(...hookScan.candidates);
|
|
453
|
+
scanSkipped.push(...hookScan.skipped);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Surface every scan-time skip with its reason (never a silent drop, F4). Printed
|
|
458
|
+
// before any early return so observability holds even when nothing is capturable.
|
|
459
|
+
for (const s of scanSkipped) {
|
|
460
|
+
const label = s.basename ? `hooks/${s.basename}` : `hooks (${s.command})`;
|
|
461
|
+
log(`⊘ ${label}: ${s.reason} — skipped`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// No explicit selection → list candidates and stop (capture design §6).
|
|
465
|
+
if (!args.all && args.names.length === 0) {
|
|
466
|
+
if (candidates.length === 0) {
|
|
467
|
+
log('No capturable extensions found under ~/.claude/{' + args.types.join(',') + '}.');
|
|
468
|
+
return { selected: [], captured: [], skipped: scanSkipped, failed: [], listedOnly: true };
|
|
469
|
+
}
|
|
470
|
+
log('Capturable extensions (pass names to capture, or --all):');
|
|
471
|
+
for (const c of candidates) log(` ${c.type}/${c.file}`);
|
|
472
|
+
log('');
|
|
473
|
+
log('Note: --all captures every unowned candidate here — not a provenance check.');
|
|
474
|
+
log('Explicit selection is the trust boundary.');
|
|
475
|
+
return {
|
|
476
|
+
selected: candidates,
|
|
477
|
+
captured: [],
|
|
478
|
+
skipped: scanSkipped,
|
|
479
|
+
failed: [],
|
|
480
|
+
listedOnly: true,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Resolve the selection.
|
|
485
|
+
let selected;
|
|
486
|
+
if (args.all) {
|
|
487
|
+
selected = candidates;
|
|
488
|
+
} else {
|
|
489
|
+
const byName = new Map();
|
|
490
|
+
for (const c of candidates) {
|
|
491
|
+
byName.set(c.file, c);
|
|
492
|
+
byName.set(c.stem, c);
|
|
493
|
+
byName.set(`${c.type}/${c.file}`, c);
|
|
494
|
+
}
|
|
495
|
+
selected = [];
|
|
496
|
+
for (const name of args.names) {
|
|
497
|
+
const c = byName.get(name);
|
|
498
|
+
if (c) selected.push(c);
|
|
499
|
+
else log(`⊘ ${name}: no capturable candidate by that name — skipped`);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const captured = [];
|
|
504
|
+
const skipped = [...scanSkipped];
|
|
505
|
+
const failed = [];
|
|
506
|
+
const created = []; // files written THIS run, for rollback on adopt failure
|
|
507
|
+
|
|
508
|
+
for (const c of selected) {
|
|
509
|
+
const fileExt = TYPE_EXT[c.type];
|
|
510
|
+
const wikiStem = `${EXT_PREFIX}${c.stem}`;
|
|
511
|
+
const typeDir = join(extDir, c.type);
|
|
512
|
+
const filePath = join(typeDir, `${wikiStem}${fileExt}`);
|
|
513
|
+
const manifestPath = join(typeDir, `${wikiStem}.manifest.json`);
|
|
514
|
+
|
|
515
|
+
// A pre-existing NON-regular wiki target (symlink / dir / socket) is a hard
|
|
516
|
+
// conflict: readFileIfRegular reports it as absent, so without this guard a
|
|
517
|
+
// naive write would follow a symlink out of the wiki (codex pre-commit
|
|
518
|
+
// BLOCKER). Refuse before reading or planning.
|
|
519
|
+
if (
|
|
520
|
+
(existsSync(filePath) && !isRegularFile(filePath)) ||
|
|
521
|
+
(existsSync(manifestPath) && !isRegularFile(manifestPath))
|
|
522
|
+
) {
|
|
523
|
+
const reason = 'wiki target exists but is not a regular file';
|
|
524
|
+
log(`⊘ ${c.type}/${c.file}: ${reason} — skipped`);
|
|
525
|
+
skipped.push({ ...c, reason, status: 'conflict' });
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const srcBuf = readFileSync(c.srcPath); // verbatim bytes (capture design §4)
|
|
530
|
+
const srcSha = sha256(srcBuf);
|
|
531
|
+
const existingFileBuf = readFileIfRegular(filePath);
|
|
532
|
+
const existingFileSha = existingFileBuf ? sha256(existingFileBuf) : null;
|
|
533
|
+
const existingManifestBuf = readFileIfRegular(manifestPath);
|
|
534
|
+
const existingManifestRaw = existingManifestBuf ? existingManifestBuf.toString('utf-8') : null;
|
|
535
|
+
|
|
536
|
+
const wantManifest = buildWantManifest(c);
|
|
537
|
+
const plan = planCapture({ wantManifest, srcSha, existingFileSha, existingManifestRaw });
|
|
538
|
+
|
|
539
|
+
// installFile is the ORIGINAL name the far machine installs under (installName
|
|
540
|
+
// adoption); for hooks that is `<stem>.mjs`, for commands/agents `<stem>.md`.
|
|
541
|
+
const installFile = `${c.stem}${fileExt}`;
|
|
542
|
+
// Ownership keys sync must record for the adoption to count. Hooks track BOTH
|
|
543
|
+
// the `.mjs` and its `.manifest.json` sidecar under the install path (success
|
|
544
|
+
// criterion d); commands/agents track only the single install file.
|
|
545
|
+
const requiredKeys =
|
|
546
|
+
c.type === 'hooks'
|
|
547
|
+
? [`hooks/${installFile}`, `hooks/${c.stem}.manifest.json`]
|
|
548
|
+
: [`${c.type}/${installFile}`];
|
|
549
|
+
|
|
550
|
+
if (plan.status === 'invalid' || plan.status === 'conflict') {
|
|
551
|
+
log(`⊘ ${c.type}/${c.file}: ${plan.reason} — skipped`);
|
|
552
|
+
skipped.push({ ...c, reason: plan.reason, status: plan.status });
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (plan.status === 'already') {
|
|
556
|
+
log(`= ${c.type}/${c.file}: already captured`);
|
|
557
|
+
captured.push({ ...c, installFile, requiredKeys, status: 'already' });
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// status === 'ready'. Write the manifest FIRST, then the storage file (capture
|
|
562
|
+
// design §4): a crash between the two leaves only a manifest (no sibling →
|
|
563
|
+
// discovery skips it), never a lone file that forward-sync would install under
|
|
564
|
+
// the wiki storage name. Both writes are atomic (temp + rename) so a symlink at
|
|
565
|
+
// the target is replaced, not followed.
|
|
566
|
+
if (!args.dryRun) {
|
|
567
|
+
mkdirSync(typeDir, { recursive: true });
|
|
568
|
+
const rec = {
|
|
569
|
+
type: c.type,
|
|
570
|
+
stem: c.stem,
|
|
571
|
+
filePath,
|
|
572
|
+
manifestPath,
|
|
573
|
+
manifestExisted: existingManifestBuf != null,
|
|
574
|
+
manifestPrevBuf: existingManifestBuf,
|
|
575
|
+
};
|
|
576
|
+
writeAtomic(manifestPath, JSON.stringify(plan.manifest, null, 2) + '\n');
|
|
577
|
+
writeAtomic(filePath, srcBuf);
|
|
578
|
+
created.push(rec);
|
|
579
|
+
}
|
|
580
|
+
captured.push({ ...c, installFile, requiredKeys, status: 'ready' });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const toAdopt = captured.filter((c) => c.status === 'ready');
|
|
584
|
+
if (toAdopt.length === 0 || args.dryRun) {
|
|
585
|
+
report(captured, skipped, failed, args.dryRun);
|
|
586
|
+
return { selected, captured, skipped, failed };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Adopt: run forward-sync so the install targets (byte-identical) hit the
|
|
590
|
+
// up-to-date branch and record ownership. If sync THROWS, roll back every file
|
|
591
|
+
// written this run so a hard failure never leaves a half-captured extension
|
|
592
|
+
// behind (codex pre-commit CONCERN).
|
|
593
|
+
let sync;
|
|
594
|
+
try {
|
|
595
|
+
sync = syncExtensions({
|
|
596
|
+
extDir,
|
|
597
|
+
hypoDir: args.hypoDir,
|
|
598
|
+
target: 'claude',
|
|
599
|
+
settingsPath: join(claudeHome, 'settings.json'),
|
|
600
|
+
pkgPath: pkgJsonPath(),
|
|
601
|
+
apply: true,
|
|
602
|
+
force: false,
|
|
603
|
+
});
|
|
604
|
+
} catch (err) {
|
|
605
|
+
for (const rec of created) rollbackRec(rec);
|
|
606
|
+
log(`capture aborted: forward-sync failed (${err.message}); wiki files rolled back.`);
|
|
607
|
+
for (const c of toAdopt) {
|
|
608
|
+
c.status = 'failed';
|
|
609
|
+
failed.push(c);
|
|
610
|
+
}
|
|
611
|
+
report([], skipped, failed, false);
|
|
612
|
+
return { selected, captured, skipped, failed, sync: null };
|
|
613
|
+
}
|
|
614
|
+
const newSHAs = sync.newSHAs || {};
|
|
615
|
+
|
|
616
|
+
// VERIFY every expected key is owned; roll back any capture whose adoption did
|
|
617
|
+
// not take (e.g. the install target changed between copy and sync → skip-conflict
|
|
618
|
+
// with a null SHA) so the wiki does not keep an un-adopted file. Hooks require
|
|
619
|
+
// BOTH the `.mjs` and the sidecar `.manifest.json` keys (success criterion d).
|
|
620
|
+
// Ownership keys that this run's sync newly recorded for a capture whose adoption
|
|
621
|
+
// then failed. `recorded` is the pre-sync owned map, so a key absent there but
|
|
622
|
+
// present in newSHAs is new THIS run: safe to strip. A key already owned before
|
|
623
|
+
// this run is preserved (never touched here).
|
|
624
|
+
const strayOwnedKeys = new Set();
|
|
625
|
+
for (const c of toAdopt) {
|
|
626
|
+
if (c.requiredKeys.every((k) => newSHAs[k])) {
|
|
627
|
+
c.status = 'captured';
|
|
628
|
+
} else {
|
|
629
|
+
c.status = 'failed';
|
|
630
|
+
failed.push(c);
|
|
631
|
+
const rec = created.find((r) => r.type === c.type && r.stem === c.stem);
|
|
632
|
+
if (rec) rollbackRec(rec);
|
|
633
|
+
for (const k of c.requiredKeys) {
|
|
634
|
+
if (recorded[k] === undefined && newSHAs[k] !== undefined) strayOwnedKeys.add(k);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Never strip a key a successful capture still depends on (distinct install
|
|
639
|
+
// stems make this impossible in practice, but the guard keeps the rollback
|
|
640
|
+
// strictly scoped to failed captures).
|
|
641
|
+
for (const c of toAdopt) {
|
|
642
|
+
if (c.status === 'captured') for (const k of c.requiredKeys) strayOwnedKeys.delete(k);
|
|
643
|
+
}
|
|
644
|
+
purgeOwnedKeys(pkgJsonPath(), 'claude', strayOwnedKeys);
|
|
645
|
+
const okCaptured = captured.filter((c) => c.status === 'captured' || c.status === 'already');
|
|
646
|
+
for (const w of sync.warnings || []) log(` sync: ${w}`);
|
|
647
|
+
report(okCaptured, skipped, failed, args.dryRun);
|
|
648
|
+
return { selected, captured, skipped, failed, sync };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function report(captured, skipped, failed, dryRun) {
|
|
652
|
+
log('');
|
|
653
|
+
if (captured.length > 0) {
|
|
654
|
+
log(`${dryRun ? 'Would capture' : 'Captured'} ${captured.length} extension(s):`);
|
|
655
|
+
for (const c of captured)
|
|
656
|
+
log(` ${c.type}/${c.file} → extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`);
|
|
657
|
+
}
|
|
658
|
+
if (skipped.length > 0) log(`Skipped ${skipped.length} (conflict/invalid — see above).`);
|
|
659
|
+
if (failed.length > 0)
|
|
660
|
+
log(`Failed to adopt ${failed.length} (see sync warnings; wiki files rolled back).`);
|
|
661
|
+
if (!dryRun && captured.some((c) => c.status === 'captured')) {
|
|
662
|
+
log('');
|
|
663
|
+
log('Next: commit + push the wiki, then run `hypomnema upgrade --apply` on another machine.');
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function main() {
|
|
668
|
+
const args = parseArgs(process.argv);
|
|
669
|
+
if (args.help) {
|
|
670
|
+
log('Usage: hypomnema capture [names…] [--all] [--type=commands,agents,hooks] [--dry-run]');
|
|
671
|
+
log(' Pull ~/.claude/{commands,agents} extensions and canonical settings.json');
|
|
672
|
+
log(' hooks into the wiki for cross-machine sync. Hooks are captured only when');
|
|
673
|
+
log(' they round-trip losslessly to the canonical form; others are skipped with');
|
|
674
|
+
log(' a reason.');
|
|
675
|
+
return 0;
|
|
676
|
+
}
|
|
677
|
+
const res = run(args);
|
|
678
|
+
return res.failed.length > 0 ? 1 : 0;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isMain() {
|
|
682
|
+
try {
|
|
683
|
+
if (!process.argv[1]) return false;
|
|
684
|
+
return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
|
|
685
|
+
} catch {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (isMain()) {
|
|
691
|
+
process.exit(main());
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export { parseArgs, scanCandidates, run };
|
|
695
|
+
// scanHookCandidates is exported at its definition above.
|