hypomnema 1.6.2 → 1.7.0
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 +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- package/scripts/pre-commit-format.mjs +0 -198
package/scripts/capture.mjs
CHANGED
|
@@ -8,9 +8,16 @@
|
|
|
8
8
|
* so they propagate to other machines via the existing forward-sync ("register on
|
|
9
9
|
* A → sync on B").
|
|
10
10
|
*
|
|
11
|
-
* Scope: commands + agents (pure file copy, no settings.json)
|
|
12
|
-
* reverse-capture, lossless round-trip only)
|
|
13
|
-
*
|
|
11
|
+
* Scope: commands + agents (pure file copy, no settings.json), hooks (settings
|
|
12
|
+
* reverse-capture, lossless round-trip only), and skills (a DIRECTORY: SKILL.md
|
|
13
|
+
* plus an arbitrary subtree, stored as `extensions/skills/hypo-ext-<name>/`).
|
|
14
|
+
*
|
|
15
|
+
* Skills (skills-capture design): the same subtree walker forward-sync uses is run
|
|
16
|
+
* in `strict` mode here, so anything that cannot round-trip through the wiki
|
|
17
|
+
* (symlink, hardlink, empty directory, VCS control dir, reserved manifest name)
|
|
18
|
+
* refuses the WHOLE skill rather than capturing a lossy subset — what lands in the
|
|
19
|
+
* wiki is exactly what the far machine installs. Cumulative ceilings abort the walk
|
|
20
|
+
* early so a vendored skill (14k files, 1.1GB) never gets enumerated in full.
|
|
14
21
|
*
|
|
15
22
|
* Naming (capture design §3): the wiki STORAGE name is `hypo-ext-<name>.<ext>` (keeps
|
|
16
23
|
* the discovery whitelist), and a sidecar `hypo-ext-<name>.manifest.json` records
|
|
@@ -31,11 +38,16 @@ import {
|
|
|
31
38
|
readdirSync,
|
|
32
39
|
writeFileSync,
|
|
33
40
|
mkdirSync,
|
|
41
|
+
rmdirSync,
|
|
34
42
|
unlinkSync,
|
|
35
43
|
renameSync,
|
|
36
44
|
realpathSync,
|
|
45
|
+
lstatSync,
|
|
46
|
+
openSync,
|
|
47
|
+
closeSync,
|
|
37
48
|
} from 'fs';
|
|
38
|
-
import {
|
|
49
|
+
import { randomBytes } from 'crypto';
|
|
50
|
+
import { join, dirname, relative, sep } from 'path';
|
|
39
51
|
import { homedir } from 'os';
|
|
40
52
|
import { pathToFileURL, fileURLToPath } from 'url';
|
|
41
53
|
import {
|
|
@@ -46,13 +58,23 @@ import {
|
|
|
46
58
|
writePkgJsonAtomic,
|
|
47
59
|
} from './lib/pkg-json.mjs';
|
|
48
60
|
import { resolveHypoRoot } from './lib/hypo-root.mjs';
|
|
61
|
+
import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
|
|
49
62
|
import {
|
|
50
63
|
syncExtensions,
|
|
51
64
|
readExtensionPkgStateNoMutate,
|
|
52
65
|
isValidInstallStem,
|
|
66
|
+
isValidSkillDirSegment,
|
|
67
|
+
parseSkillKey,
|
|
68
|
+
parseSkillShaValue,
|
|
69
|
+
walkSkillSubtree,
|
|
70
|
+
emptyShaMap,
|
|
71
|
+
isRealDir,
|
|
72
|
+
isContainedUnder,
|
|
73
|
+
hasSymlinkAncestor,
|
|
53
74
|
scanSettingsHooks,
|
|
54
75
|
parseCapturableHookCommand,
|
|
55
76
|
HOOK_EVENT_ALLOWLIST,
|
|
77
|
+
SKILL_ROOT_FILE,
|
|
56
78
|
EXT_PREFIX,
|
|
57
79
|
} from './lib/extensions.mjs';
|
|
58
80
|
import { readCoreHooksConfig, deriveCoreHookBasenames } from './lib/core-hooks.mjs';
|
|
@@ -66,11 +88,27 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
66
88
|
|
|
67
89
|
// Captured types and their singular manifest `type` value. commands/agents install
|
|
68
90
|
// 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
|
-
|
|
91
|
+
// settings.json entry (reverse of forward-sync); skills install as a DIRECTORY.
|
|
92
|
+
const CAPTURE_TYPES = ['commands', 'agents', 'hooks', 'skills'];
|
|
93
|
+
// The flat, single-file types. `TYPE_EXT` has no `skills` entry on purpose: a skill
|
|
94
|
+
// has no install extension, and letting it through a flat helper would silently
|
|
95
|
+
// build paths with an `undefined` suffix. Every flat helper keys off this list.
|
|
96
|
+
const FLAT_TYPES = ['commands', 'agents', 'hooks'];
|
|
97
|
+
// The flat types enumerated by readdir. hooks are enumerated from the settings.json
|
|
98
|
+
// registration instead, and skills have their own directory scanner.
|
|
99
|
+
const READDIR_TYPES = ['commands', 'agents'];
|
|
100
|
+
const TYPE_SINGULAR = { commands: 'command', agents: 'agent', hooks: 'hook', skills: 'skill' };
|
|
72
101
|
const TYPE_EXT = { commands: '.md', agents: '.md', hooks: '.mjs' };
|
|
73
102
|
|
|
103
|
+
// Ceilings for a captured skill subtree (skills-capture design §3). A hand-authored
|
|
104
|
+
// skill is a handful of files; a vendored one (gstack: 14,311 files / 1.1GB, mostly
|
|
105
|
+
// node_modules) must be refused, not copied into a git-tracked vault. Enforced as
|
|
106
|
+
// cumulative counters DURING the walk, from lstat sizes, so the verdict lands before
|
|
107
|
+
// anything is read and the candidate listing never enumerates a vendored tree.
|
|
108
|
+
const SKILL_MAX_FILES = 500;
|
|
109
|
+
const SKILL_MAX_BYTES = 5 * 1024 * 1024;
|
|
110
|
+
const SKILL_LIMITS = { strict: true, maxFiles: SKILL_MAX_FILES, maxBytes: SKILL_MAX_BYTES };
|
|
111
|
+
|
|
74
112
|
function pkgJsonPath() {
|
|
75
113
|
return join(HOME, '.claude', 'hypo-pkg.json');
|
|
76
114
|
}
|
|
@@ -168,6 +206,90 @@ export function planCapture({ wantManifest, srcSha, existingFileSha, existingMan
|
|
|
168
206
|
return { status: 'ready', manifest: wantManifest };
|
|
169
207
|
}
|
|
170
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The set of install directories the wiki VALIDLY owns as directory skills.
|
|
211
|
+
*
|
|
212
|
+
* Ownership must be read through the same schema the rest of the module trusts, not
|
|
213
|
+
* raw property truthiness (skills-capture design §8). A corrupt hypo-pkg.json that
|
|
214
|
+
* parks a string under `skills/mine` would otherwise read as "already managed" and
|
|
215
|
+
* lock that skill out of capture forever, and it would also poison the "keys this
|
|
216
|
+
* run newly recorded" bookkeeping the failed-adopt purge depends on.
|
|
217
|
+
*/
|
|
218
|
+
export function ownedSkillDirs(recorded) {
|
|
219
|
+
const out = new Set();
|
|
220
|
+
for (const [key, value] of Object.entries(recorded)) {
|
|
221
|
+
const parsed = parseSkillKey(key);
|
|
222
|
+
if (!parsed) continue;
|
|
223
|
+
const nested = parseSkillShaValue(value);
|
|
224
|
+
if (nested === null || Object.keys(nested).length === 0) continue;
|
|
225
|
+
out.add(parsed.installDir);
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Plan the capture of one directory skill. Same three outcomes as the flat planner,
|
|
232
|
+
* but `ready` is deliberately narrow: it means the wiki skill directory is WHOLLY
|
|
233
|
+
* ABSENT (skills-capture design §4). That narrowness is what makes the rollback safe
|
|
234
|
+
* — every path the rollback removes is a path this run created.
|
|
235
|
+
*
|
|
236
|
+
* `wikiShas` is null when the wiki directory does not exist, and a rel→sha map when
|
|
237
|
+
* it does. A wiki path that exists but cannot be read as a clean skill (a file, a
|
|
238
|
+
* symlink, a crash-truncated directory with no SKILL.md) arrives here as
|
|
239
|
+
* `wikiPresent: true, wikiShas: null` and is a conflict — never a crash, never a
|
|
240
|
+
* silent overwrite.
|
|
241
|
+
*/
|
|
242
|
+
export function planSkillCapture({
|
|
243
|
+
wantManifest,
|
|
244
|
+
srcShas,
|
|
245
|
+
wikiPresent,
|
|
246
|
+
wikiShas,
|
|
247
|
+
existingManifestRaw,
|
|
248
|
+
}) {
|
|
249
|
+
if (!isValidSkillDirSegment(wantManifest.installName)) {
|
|
250
|
+
return { status: 'invalid', reason: `invalid installName "${wantManifest.installName}"` };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (wikiPresent) {
|
|
254
|
+
if (wikiShas === null) {
|
|
255
|
+
return {
|
|
256
|
+
status: 'conflict',
|
|
257
|
+
reason: 'wiki target exists but is not a readable skill directory',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (!deepEqual(wikiShas, srcShas)) {
|
|
261
|
+
return { status: 'conflict', reason: 'wiki skill directory exists with different content' };
|
|
262
|
+
}
|
|
263
|
+
if (existingManifestRaw == null) {
|
|
264
|
+
return { status: 'conflict', reason: 'wiki skill exists without its installName manifest' };
|
|
265
|
+
}
|
|
266
|
+
let parsed;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(existingManifestRaw);
|
|
269
|
+
} catch {
|
|
270
|
+
return { status: 'conflict', reason: 'wiki manifest is not valid JSON' };
|
|
271
|
+
}
|
|
272
|
+
if (!deepEqual(parsed, wantManifest)) {
|
|
273
|
+
return { status: 'conflict', reason: 'wiki manifest declares a different mapping' };
|
|
274
|
+
}
|
|
275
|
+
return { status: 'already', manifest: wantManifest };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// No wiki directory yet. A stray sidecar with a different mapping is still a conflict.
|
|
279
|
+
if (existingManifestRaw != null) {
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = JSON.parse(existingManifestRaw);
|
|
283
|
+
} catch {
|
|
284
|
+
return { status: 'conflict', reason: 'stray wiki manifest is not valid JSON' };
|
|
285
|
+
}
|
|
286
|
+
if (!deepEqual(parsed, wantManifest)) {
|
|
287
|
+
return { status: 'conflict', reason: 'stray wiki manifest declares a different mapping' };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return { status: 'ready', manifest: wantManifest };
|
|
291
|
+
}
|
|
292
|
+
|
|
171
293
|
/**
|
|
172
294
|
* Reverse-generate the sidecar manifest for a capture candidate. commands/agents
|
|
173
295
|
* carry only `{ type, installName }`; hooks additionally carry the settings
|
|
@@ -188,9 +310,91 @@ function buildWantManifest(c) {
|
|
|
188
310
|
|
|
189
311
|
// ── filesystem orchestration ─────────────────────────────────────────────────
|
|
190
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Enumerate capturable DIRECTORY skills under `~/.claude/skills/`.
|
|
315
|
+
*
|
|
316
|
+
* Every rejected entry gets a visible reason (never a silent drop): the strict walker
|
|
317
|
+
* refuses the whole skill for anything that cannot round-trip, and the ceilings abort
|
|
318
|
+
* the walk as soon as they are crossed. Returns `{ candidates, skipped }`.
|
|
319
|
+
*
|
|
320
|
+
* A flat `.md` under `skills/` is not a candidate: the directory is the real Claude
|
|
321
|
+
* Code layout, and the flat form only exists as a backward-compatible wiki storage
|
|
322
|
+
* shape. A symlinked directory is not a candidate either (never followed).
|
|
323
|
+
*
|
|
324
|
+
* `vault` is `{ hypoDir, patterns }` — the vault's `.hypoignore`. Capture must judge a
|
|
325
|
+
* source file by the rule that will apply to it AFTER it lands in the wiki: forward-sync
|
|
326
|
+
* discovers the wiki subtree through that filter, so a file the vault ignores (a `.pdf`
|
|
327
|
+
* reference, a `.cache/` directory) would be written here and then dropped from
|
|
328
|
+
* discovery, leaving the adopt check short one relpath and failing with no reason a user
|
|
329
|
+
* could act on. Refuse it up front instead, with the pattern named.
|
|
330
|
+
*/
|
|
331
|
+
export function scanSkillCandidates(claudeHome, ownedDirs, vault = null) {
|
|
332
|
+
const candidates = [];
|
|
333
|
+
const skipped = [];
|
|
334
|
+
const dir = join(claudeHome, 'skills');
|
|
335
|
+
if (!existsSync(dir)) return { candidates, skipped };
|
|
336
|
+
let entries;
|
|
337
|
+
try {
|
|
338
|
+
entries = readdirSync(dir);
|
|
339
|
+
} catch {
|
|
340
|
+
return { candidates, skipped };
|
|
341
|
+
}
|
|
342
|
+
for (const name of entries) {
|
|
343
|
+
const srcPath = join(dir, name);
|
|
344
|
+
if (!isRealDir(srcPath)) continue; // flat .md, symlinked dir, or junk: not a skill dir
|
|
345
|
+
const lower = name.toLowerCase();
|
|
346
|
+
if (lower === 'hypo' || lower.startsWith('hypo-')) continue; // reserved namespace
|
|
347
|
+
if (ownedDirs.has(name)) continue; // already managed by the wiki
|
|
348
|
+
const label = `skills/${name}`;
|
|
349
|
+
if (!isValidSkillDirSegment(name)) {
|
|
350
|
+
skipped.push({ type: 'skills', file: name, reason: `unsafe skill directory name "${name}"` });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// The SOURCE walk applies no ignore filter (the patterns are the vault's, and the
|
|
354
|
+
// source lives under ~/.claude). The dir argument still has to be a string: isIgnored()
|
|
355
|
+
// calls relative() on it before it ever looks at the (empty) patterns.
|
|
356
|
+
const walked = walkSkillSubtree(srcPath, label, dir, [], SKILL_LIMITS);
|
|
357
|
+
if (walked.skip) {
|
|
358
|
+
skipped.push({
|
|
359
|
+
type: 'skills',
|
|
360
|
+
file: name,
|
|
361
|
+
reason: walked.fatal || `no regular ${SKILL_ROOT_FILE} at the skill root`,
|
|
362
|
+
});
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
// Would any of these files be invisible to forward-sync once they are in the wiki?
|
|
366
|
+
if (vault && vault.patterns.length > 0) {
|
|
367
|
+
const wikiRootForSkill = join(vault.hypoDir, 'extensions', 'skills', `${EXT_PREFIX}${name}`);
|
|
368
|
+
const ignored = walked.files.find((f) =>
|
|
369
|
+
isIgnored(join(wikiRootForSkill, ...f.rel.split('/')), vault.hypoDir, vault.patterns),
|
|
370
|
+
);
|
|
371
|
+
if (ignored) {
|
|
372
|
+
skipped.push({
|
|
373
|
+
type: 'skills',
|
|
374
|
+
file: name,
|
|
375
|
+
reason: `${ignored.rel} matches the vault .hypoignore and would not sync back`,
|
|
376
|
+
});
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
candidates.push({
|
|
381
|
+
type: 'skills',
|
|
382
|
+
file: name,
|
|
383
|
+
stem: name,
|
|
384
|
+
srcPath,
|
|
385
|
+
isDir: true,
|
|
386
|
+
files: walked.files,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return { candidates, skipped };
|
|
390
|
+
}
|
|
391
|
+
|
|
191
392
|
function scanCandidates(claudeHome, recorded, types) {
|
|
192
393
|
const out = [];
|
|
193
394
|
for (const type of types) {
|
|
395
|
+
// hooks come from the settings registration, skills from their own directory
|
|
396
|
+
// scanner. Letting either through here would double-enumerate them.
|
|
397
|
+
if (!READDIR_TYPES.includes(type)) continue;
|
|
194
398
|
const dir = join(claudeHome, type);
|
|
195
399
|
if (!existsSync(dir)) continue;
|
|
196
400
|
let entries;
|
|
@@ -373,9 +577,19 @@ function log(msg) {
|
|
|
373
577
|
|
|
374
578
|
// Write via a sibling temp + rename so the final replace is atomic and, crucially,
|
|
375
579
|
// never follows a symlink already sitting at `dest` (rename swaps the link itself).
|
|
580
|
+
//
|
|
581
|
+
// The temp path is randomized and opened with `wx` (O_CREAT|O_EXCL). The old
|
|
582
|
+
// `${dest}.tmp.${pid}` name was predictable, and writeFileSync on a path someone had
|
|
583
|
+
// already planted a symlink at would follow it straight out of the wiki. O_EXCL fails
|
|
584
|
+
// on an existing path of any kind, symlink included.
|
|
376
585
|
function writeAtomic(dest, buf) {
|
|
377
|
-
const tmp = `${dest}.tmp.${process.pid}`;
|
|
378
|
-
|
|
586
|
+
const tmp = `${dest}.tmp.${process.pid}.${randomBytes(6).toString('hex')}`;
|
|
587
|
+
const fd = openSync(tmp, 'wx');
|
|
588
|
+
try {
|
|
589
|
+
writeFileSync(fd, buf);
|
|
590
|
+
} finally {
|
|
591
|
+
closeSync(fd);
|
|
592
|
+
}
|
|
379
593
|
try {
|
|
380
594
|
renameSync(tmp, dest);
|
|
381
595
|
} catch (err) {
|
|
@@ -386,11 +600,41 @@ function writeAtomic(dest, buf) {
|
|
|
386
600
|
}
|
|
387
601
|
}
|
|
388
602
|
|
|
389
|
-
// Undo one capture's wiki writes: drop the storage file
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
603
|
+
// Undo one capture's wiki writes: drop the storage file(s), and either restore the
|
|
604
|
+
// manifest bytes we overwrote (a pre-existing same-mapping sidecar) or remove the
|
|
605
|
+
// manifest we created. Used on a failed/aborted adopt so a capture never half-lands.
|
|
606
|
+
//
|
|
607
|
+
// A skill's rollback walks the ledger of paths this run actually created, newest
|
|
608
|
+
// first: files, then the directories that held them. It never removes a tree
|
|
609
|
+
// wholesale. The plan only says `ready` when the wiki directory was absent, and the
|
|
610
|
+
// root is claimed with an exclusive mkdir, so everything in the ledger is ours — but
|
|
611
|
+
// deleting only what we recorded is what makes that true by construction rather than
|
|
612
|
+
// by trusting a check made earlier in time.
|
|
393
613
|
function rollbackRec(rec) {
|
|
614
|
+
// A skill ledger is AUTHORITATIVE: only paths this run actually created are removed,
|
|
615
|
+
// and the manifest is touched only if we actually wrote it. The flat branch below
|
|
616
|
+
// unlinks `manifestPath` unconditionally, which is wrong for a skill whose write
|
|
617
|
+
// failed BEFORE the manifest was written — a dangling sidecar symlink is invisible to
|
|
618
|
+
// existsSync, so the guard refuses the write and the rollback would then delete a
|
|
619
|
+
// path the run never touched (codex pre-commit BLOCKER, with a repro).
|
|
620
|
+
if (rec.createdFiles) {
|
|
621
|
+
for (const p of [...rec.createdFiles].reverse()) {
|
|
622
|
+
try {
|
|
623
|
+
unlinkSync(p);
|
|
624
|
+
} catch {}
|
|
625
|
+
}
|
|
626
|
+
for (const d of [...(rec.createdDirs || [])].reverse()) {
|
|
627
|
+
try {
|
|
628
|
+
rmdirSync(d); // fails on a non-empty dir, which is the point: never a tree wipe
|
|
629
|
+
} catch {}
|
|
630
|
+
}
|
|
631
|
+
if (rec.manifestOverwritten && rec.manifestPrevBuf != null) {
|
|
632
|
+
try {
|
|
633
|
+
writeFileSync(rec.manifestPath, rec.manifestPrevBuf);
|
|
634
|
+
} catch {}
|
|
635
|
+
}
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
394
638
|
try {
|
|
395
639
|
unlinkSync(rec.filePath);
|
|
396
640
|
} catch {}
|
|
@@ -405,6 +649,81 @@ function rollbackRec(rec) {
|
|
|
405
649
|
}
|
|
406
650
|
}
|
|
407
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Write one directory skill into the wiki, recording every path created so a failed
|
|
654
|
+
* adopt can be undone exactly (skills-capture design §5/§7).
|
|
655
|
+
*
|
|
656
|
+
* Order: the sidecar manifest first, then the subtree with SKILL.md LAST. A crash at
|
|
657
|
+
* any point therefore leaves a directory with no SKILL.md, which discovery skips —
|
|
658
|
+
* never a half-skill that the far machine would partially install.
|
|
659
|
+
*
|
|
660
|
+
* `guard` re-checks the destination against the wiki boundary immediately before each
|
|
661
|
+
* mutation. Lexical containment cannot see a symlinked ancestor, and the boundary has
|
|
662
|
+
* to be the wiki root itself: rooting the walk at `extensions/skills` would lstat
|
|
663
|
+
* straight through a symlinked `extensions/`.
|
|
664
|
+
*/
|
|
665
|
+
function writeSkill({ rec, skillRoot, manifestPath, manifest, files, guard, wikiRoot }) {
|
|
666
|
+
const prev = readFileIfRegular(manifestPath);
|
|
667
|
+
rec.manifestExisted = prev != null;
|
|
668
|
+
rec.manifestPrevBuf = prev;
|
|
669
|
+
|
|
670
|
+
if (!guard(manifestPath) || !guard(skillRoot)) {
|
|
671
|
+
throw new Error('wiki path is not inside the vault or crosses a symlinked directory');
|
|
672
|
+
}
|
|
673
|
+
// Build the chain down to the type directory segment by segment, so a directory this
|
|
674
|
+
// run creates (a fresh `extensions/skills/`) lands in the ledger and can be undone.
|
|
675
|
+
// A recursive mkdir would create them silently and leak them on rollback.
|
|
676
|
+
const typeDir = dirname(skillRoot);
|
|
677
|
+
const chain = relative(wikiRoot, typeDir).split(sep).filter(Boolean);
|
|
678
|
+
let cur = wikiRoot;
|
|
679
|
+
for (const seg of chain) {
|
|
680
|
+
cur = join(cur, seg);
|
|
681
|
+
if (existsSync(cur)) continue;
|
|
682
|
+
if (!guard(cur)) throw new Error('wiki path crosses a symlinked directory');
|
|
683
|
+
mkdirSync(cur);
|
|
684
|
+
rec.createdDirs.push(cur);
|
|
685
|
+
}
|
|
686
|
+
// Exclusive: EEXIST here means the "absent" verdict the plan made is already stale,
|
|
687
|
+
// and the rollback must not be handed a directory it did not create.
|
|
688
|
+
mkdirSync(skillRoot);
|
|
689
|
+
rec.createdDirs.push(skillRoot);
|
|
690
|
+
|
|
691
|
+
writeAtomic(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
692
|
+
// Record what the write actually did, not what we predicted it would do: the rollback
|
|
693
|
+
// must never remove or restore a manifest it did not touch.
|
|
694
|
+
if (rec.manifestExisted) rec.manifestOverwritten = true;
|
|
695
|
+
else rec.createdFiles.push(manifestPath);
|
|
696
|
+
|
|
697
|
+
// SKILL.md last: it is the file that makes the directory discoverable.
|
|
698
|
+
const ordered = [
|
|
699
|
+
...files.filter((f) => f.rel !== SKILL_ROOT_FILE),
|
|
700
|
+
...files.filter((f) => f.rel === SKILL_ROOT_FILE),
|
|
701
|
+
];
|
|
702
|
+
const madeDirs = new Set([skillRoot]);
|
|
703
|
+
for (const f of ordered) {
|
|
704
|
+
const destPath = join(skillRoot, ...f.rel.split('/'));
|
|
705
|
+
if (!guard(destPath)) {
|
|
706
|
+
throw new Error(`unsafe wiki destination for ${f.rel}`);
|
|
707
|
+
}
|
|
708
|
+
const parent = dirname(destPath);
|
|
709
|
+
if (!madeDirs.has(parent)) {
|
|
710
|
+
// Build the chain segment by segment so every new directory lands in the ledger.
|
|
711
|
+
const segs = f.rel.split('/').slice(0, -1);
|
|
712
|
+
let cur = skillRoot;
|
|
713
|
+
for (const s of segs) {
|
|
714
|
+
cur = join(cur, s);
|
|
715
|
+
if (madeDirs.has(cur)) continue;
|
|
716
|
+
if (!guard(cur)) throw new Error(`unsafe wiki destination for ${f.rel}`);
|
|
717
|
+
mkdirSync(cur);
|
|
718
|
+
rec.createdDirs.push(cur);
|
|
719
|
+
madeDirs.add(cur);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
writeAtomic(destPath, readFileSync(f.srcPath));
|
|
723
|
+
rec.createdFiles.push(destPath);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
408
727
|
// Remove specific per-target ownership keys from hypo-pkg.json (codex pre-commit
|
|
409
728
|
// CONCERN). A partial adopt can leave forward-sync having recorded SOME of a
|
|
410
729
|
// capture's install keys (e.g. the byte-identical `.mjs` was owned but its sidecar
|
|
@@ -429,19 +748,155 @@ function purgeOwnedKeys(pkgPath, target, keys) {
|
|
|
429
748
|
if (changed) writePkgJsonAtomic(pkgPath, pkg);
|
|
430
749
|
}
|
|
431
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Plan + write one directory skill. Pushes into the caller's captured/skipped/created
|
|
753
|
+
* lists so the shared adopt + rollback machinery downstream treats it like any other
|
|
754
|
+
* capture.
|
|
755
|
+
*
|
|
756
|
+
* The wiki side is read with the SAME strict walker as the source, so a wiki directory
|
|
757
|
+
* that is unreadable, symlink-poisoned, or crash-truncated (no SKILL.md) surfaces as
|
|
758
|
+
* `wikiShas: null` and lands on the conflict branch instead of being silently treated
|
|
759
|
+
* as absent and then blown away by a rollback.
|
|
760
|
+
*/
|
|
761
|
+
function captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created }) {
|
|
762
|
+
const wikiStem = `${EXT_PREFIX}${c.stem}`;
|
|
763
|
+
const typeDir = join(extDir, 'skills');
|
|
764
|
+
const skillRoot = join(typeDir, wikiStem);
|
|
765
|
+
const manifestPath = join(typeDir, `${wikiStem}.manifest.json`);
|
|
766
|
+
const label = `skills/${c.file}`;
|
|
767
|
+
|
|
768
|
+
// Null-prototype: a skill file legitimately named `__proto__` would otherwise assign
|
|
769
|
+
// through the prototype setter instead of creating an own key, so it would vanish from
|
|
770
|
+
// the comparison and from the adopt check (forward-sync keeps its SHA maps this way for
|
|
771
|
+
// the same reason). codex pre-commit CONCERN.
|
|
772
|
+
const srcShas = emptyShaMap();
|
|
773
|
+
for (const f of c.files) srcShas[f.rel] = sha256(readFileSync(f.srcPath));
|
|
774
|
+
|
|
775
|
+
const wikiPresent = existsSync(skillRoot);
|
|
776
|
+
let wikiShas = null;
|
|
777
|
+
if (wikiPresent && isRealDir(skillRoot)) {
|
|
778
|
+
const walked = walkSkillSubtree(skillRoot, label, args.hypoDir, [], SKILL_LIMITS);
|
|
779
|
+
if (!walked.skip) {
|
|
780
|
+
wikiShas = emptyShaMap();
|
|
781
|
+
for (const f of walked.files) wikiShas[f.rel] = sha256(readFileSync(f.srcPath));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (existsSync(manifestPath) && !isRegularFile(manifestPath)) {
|
|
785
|
+
const reason = 'wiki manifest exists but is not a regular file';
|
|
786
|
+
log(`⊘ ${label}: ${reason} — skipped`);
|
|
787
|
+
skipped.push({ ...c, reason, status: 'conflict' });
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const existingManifestBuf = readFileIfRegular(manifestPath);
|
|
791
|
+
const existingManifestRaw = existingManifestBuf ? existingManifestBuf.toString('utf-8') : null;
|
|
792
|
+
|
|
793
|
+
const wantManifest = { type: TYPE_SINGULAR.skills, installName: c.stem };
|
|
794
|
+
const plan = planSkillCapture({
|
|
795
|
+
wantManifest,
|
|
796
|
+
srcShas,
|
|
797
|
+
wikiPresent,
|
|
798
|
+
wikiShas,
|
|
799
|
+
existingManifestRaw,
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
// The install key is the whole directory; forward-sync records one nested map under
|
|
803
|
+
// it, one entry per relpath (skills-dir design).
|
|
804
|
+
const rec = { ...c, installKey: `skills/${c.stem}`, srcShas };
|
|
805
|
+
|
|
806
|
+
if (plan.status === 'invalid' || plan.status === 'conflict') {
|
|
807
|
+
log(`⊘ ${label}: ${plan.reason} — skipped`);
|
|
808
|
+
skipped.push({ ...rec, reason: plan.reason, status: plan.status });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (plan.status === 'already') {
|
|
812
|
+
log(`= ${label}: already captured`);
|
|
813
|
+
captured.push({ ...rec, status: 'already' });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Content round-trips; the executable bit does not (forward-sync writes with the
|
|
818
|
+
// default mode). Say so rather than let a captured `scripts/run.sh` arrive
|
|
819
|
+
// non-executable on the far machine without a word.
|
|
820
|
+
const execFiles = c.files.filter((f) => {
|
|
821
|
+
try {
|
|
822
|
+
return (lstatSync(f.srcPath).mode & 0o111) !== 0;
|
|
823
|
+
} catch {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
if (execFiles.length > 0) {
|
|
828
|
+
log(
|
|
829
|
+
`! ${label}: ${execFiles.length} executable file(s) — content is captured, but the executable bit is not carried by sync`,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (!args.dryRun) {
|
|
834
|
+
// The ledger is owned by the caller so a throw MID-write is still recoverable: the
|
|
835
|
+
// paths created before the failure are already recorded in it.
|
|
836
|
+
const ledger = {
|
|
837
|
+
type: 'skills',
|
|
838
|
+
stem: c.stem,
|
|
839
|
+
manifestPath,
|
|
840
|
+
manifestExisted: false,
|
|
841
|
+
manifestOverwritten: false,
|
|
842
|
+
manifestPrevBuf: null,
|
|
843
|
+
createdFiles: [],
|
|
844
|
+
createdDirs: [],
|
|
845
|
+
};
|
|
846
|
+
try {
|
|
847
|
+
writeSkill({
|
|
848
|
+
rec: ledger,
|
|
849
|
+
skillRoot,
|
|
850
|
+
manifestPath,
|
|
851
|
+
manifest: plan.manifest,
|
|
852
|
+
files: c.files,
|
|
853
|
+
guard,
|
|
854
|
+
wikiRoot,
|
|
855
|
+
});
|
|
856
|
+
} catch (err) {
|
|
857
|
+
rollbackRec(ledger);
|
|
858
|
+
const reason = `wiki write failed (${err.message})`;
|
|
859
|
+
log(`⊘ ${label}: ${reason} — skipped`);
|
|
860
|
+
skipped.push({ ...rec, reason, status: 'conflict' });
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
created.push(ledger);
|
|
864
|
+
}
|
|
865
|
+
captured.push({ ...rec, status: 'ready' });
|
|
866
|
+
}
|
|
867
|
+
|
|
432
868
|
function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
433
|
-
|
|
869
|
+
// Resolve the vault root through realpath. Every wiki mutation below is boundary-
|
|
870
|
+
// checked against it, and a symlinked ancestor INSIDE the vault must be caught while
|
|
871
|
+
// a vault root that is itself a symlink (a perfectly normal setup) still works. Doing
|
|
872
|
+
// it here means extDir and the sync call see the same real root.
|
|
873
|
+
let wikiRoot = args.hypoDir;
|
|
874
|
+
try {
|
|
875
|
+
wikiRoot = realpathSync(args.hypoDir);
|
|
876
|
+
} catch {
|
|
877
|
+
// Not created yet: fall back to the lexical path; the guard below still applies.
|
|
878
|
+
}
|
|
879
|
+
const extDir = join(wikiRoot, 'extensions');
|
|
434
880
|
const settingsPath = join(claudeHome, 'settings.json');
|
|
435
881
|
const recorded = readExtensionPkgStateNoMutate(pkgJsonPath(), 'claude');
|
|
436
|
-
// commands/agents enumerate by readdir; hooks enumerate from settings.json
|
|
437
|
-
|
|
438
|
-
const candidates = scanCandidates(claudeHome, recorded,
|
|
882
|
+
// commands/agents enumerate by readdir; hooks enumerate from settings.json; skills
|
|
883
|
+
// enumerate as directories under ~/.claude/skills.
|
|
884
|
+
const candidates = scanCandidates(claudeHome, recorded, args.types);
|
|
885
|
+
|
|
886
|
+
const scanSkipped = [];
|
|
887
|
+
if (args.types.includes('skills')) {
|
|
888
|
+
const skillScan = scanSkillCandidates(claudeHome, ownedSkillDirs(recorded), {
|
|
889
|
+
hypoDir: wikiRoot,
|
|
890
|
+
patterns: loadHypoIgnore(wikiRoot),
|
|
891
|
+
});
|
|
892
|
+
candidates.push(...skillScan.candidates);
|
|
893
|
+
scanSkipped.push(...skillScan.skipped);
|
|
894
|
+
}
|
|
439
895
|
|
|
440
896
|
// Hooks are captured from the settings registration. Reserve the core hook
|
|
441
897
|
// basenames deterministically from hooks.json; if that load is not ok (read,
|
|
442
898
|
// parse, or shape failure) skip the whole hooks type rather than risk capturing
|
|
443
899
|
// a core hook (T1 fail-closed contract: gate on ok, not on cfg presence).
|
|
444
|
-
const scanSkipped = [];
|
|
445
900
|
if (args.types.includes('hooks')) {
|
|
446
901
|
const core = readCoreHooksConfig(PKG_ROOT);
|
|
447
902
|
if (!core.ok) {
|
|
@@ -457,7 +912,10 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
457
912
|
// Surface every scan-time skip with its reason (never a silent drop, F4). Printed
|
|
458
913
|
// before any early return so observability holds even when nothing is capturable.
|
|
459
914
|
for (const s of scanSkipped) {
|
|
460
|
-
|
|
915
|
+
let label;
|
|
916
|
+
if (s.type === 'skills') label = `skills/${s.file}`;
|
|
917
|
+
else if (s.basename) label = `hooks/${s.basename}`;
|
|
918
|
+
else label = `hooks (${s.command})`;
|
|
461
919
|
log(`⊘ ${label}: ${s.reason} — skipped`);
|
|
462
920
|
}
|
|
463
921
|
|
|
@@ -481,22 +939,37 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
481
939
|
};
|
|
482
940
|
}
|
|
483
941
|
|
|
484
|
-
// Resolve the selection.
|
|
942
|
+
// Resolve the selection. A bare name can now be ambiguous (`commands/mine.md` and
|
|
943
|
+
// `skills/mine/` can both exist), and the old map silently let the last candidate
|
|
944
|
+
// win. Collect every match per name and refuse when there is more than one: the
|
|
945
|
+
// caller must say which by qualifying with the type.
|
|
485
946
|
let selected;
|
|
486
947
|
if (args.all) {
|
|
487
948
|
selected = candidates;
|
|
488
949
|
} else {
|
|
489
950
|
const byName = new Map();
|
|
951
|
+
const add = (key, c) => {
|
|
952
|
+
if (!byName.has(key)) byName.set(key, []);
|
|
953
|
+
byName.get(key).push(c);
|
|
954
|
+
};
|
|
490
955
|
for (const c of candidates) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
956
|
+
add(c.file, c);
|
|
957
|
+
if (c.stem !== c.file) add(c.stem, c);
|
|
958
|
+
add(`${c.type}/${c.file}`, c);
|
|
494
959
|
}
|
|
495
960
|
selected = [];
|
|
496
961
|
for (const name of args.names) {
|
|
497
|
-
const
|
|
498
|
-
if (
|
|
499
|
-
|
|
962
|
+
const matches = byName.get(name) || [];
|
|
963
|
+
if (matches.length === 0) {
|
|
964
|
+
log(`⊘ ${name}: no capturable candidate by that name — skipped`);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (matches.length > 1) {
|
|
968
|
+
const qualified = matches.map((c) => `${c.type}/${c.file}`).join(', ');
|
|
969
|
+
log(`⊘ ${name}: ambiguous (matches ${qualified}) — qualify with the type — skipped`);
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
selected.push(matches[0]);
|
|
500
973
|
}
|
|
501
974
|
}
|
|
502
975
|
|
|
@@ -505,7 +978,15 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
505
978
|
const failed = [];
|
|
506
979
|
const created = []; // files written THIS run, for rollback on adopt failure
|
|
507
980
|
|
|
981
|
+
// Every wiki mutation is checked against the vault root, not the type directory: a
|
|
982
|
+
// symlinked `extensions/` would otherwise be walked straight through.
|
|
983
|
+
const guard = (dest) => isContainedUnder(wikiRoot, dest) && !hasSymlinkAncestor(wikiRoot, dest);
|
|
984
|
+
|
|
508
985
|
for (const c of selected) {
|
|
986
|
+
if (c.type === 'skills') {
|
|
987
|
+
captureOneSkill({ c, extDir, guard, wikiRoot, args, captured, skipped, created });
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
509
990
|
const fileExt = TYPE_EXT[c.type];
|
|
510
991
|
const wikiStem = `${EXT_PREFIX}${c.stem}`;
|
|
511
992
|
const typeDir = join(extDir, c.type);
|
|
@@ -594,7 +1075,7 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
594
1075
|
try {
|
|
595
1076
|
sync = syncExtensions({
|
|
596
1077
|
extDir,
|
|
597
|
-
hypoDir:
|
|
1078
|
+
hypoDir: wikiRoot,
|
|
598
1079
|
target: 'claude',
|
|
599
1080
|
settingsPath: join(claudeHome, 'settings.json'),
|
|
600
1081
|
pkgPath: pkgJsonPath(),
|
|
@@ -623,14 +1104,17 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
623
1104
|
// this run is preserved (never touched here).
|
|
624
1105
|
const strayOwnedKeys = new Set();
|
|
625
1106
|
for (const c of toAdopt) {
|
|
626
|
-
|
|
1107
|
+
const ok =
|
|
1108
|
+
c.type === 'skills' ? skillAdopted(c, sync) : c.requiredKeys.every((k) => newSHAs[k]);
|
|
1109
|
+
if (ok) {
|
|
627
1110
|
c.status = 'captured';
|
|
628
1111
|
} else {
|
|
629
1112
|
c.status = 'failed';
|
|
630
1113
|
failed.push(c);
|
|
631
1114
|
const rec = created.find((r) => r.type === c.type && r.stem === c.stem);
|
|
632
1115
|
if (rec) rollbackRec(rec);
|
|
633
|
-
|
|
1116
|
+
const keys = c.type === 'skills' ? [c.installKey] : c.requiredKeys;
|
|
1117
|
+
for (const k of keys) {
|
|
634
1118
|
if (recorded[k] === undefined && newSHAs[k] !== undefined) strayOwnedKeys.add(k);
|
|
635
1119
|
}
|
|
636
1120
|
}
|
|
@@ -639,7 +1123,8 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
639
1123
|
// stems make this impossible in practice, but the guard keeps the rollback
|
|
640
1124
|
// strictly scoped to failed captures).
|
|
641
1125
|
for (const c of toAdopt) {
|
|
642
|
-
if (c.status
|
|
1126
|
+
if (c.status !== 'captured') continue;
|
|
1127
|
+
for (const k of c.type === 'skills' ? [c.installKey] : c.requiredKeys) strayOwnedKeys.delete(k);
|
|
643
1128
|
}
|
|
644
1129
|
purgeOwnedKeys(pkgJsonPath(), 'claude', strayOwnedKeys);
|
|
645
1130
|
const okCaptured = captured.filter((c) => c.status === 'captured' || c.status === 'already');
|
|
@@ -648,12 +1133,43 @@ function run(args, { claudeHome = join(HOME, '.claude') } = {}) {
|
|
|
648
1133
|
return { selected, captured, skipped, failed, sync };
|
|
649
1134
|
}
|
|
650
1135
|
|
|
1136
|
+
/**
|
|
1137
|
+
* Did forward-sync actually adopt this skill, file for file?
|
|
1138
|
+
*
|
|
1139
|
+
* Presence of the key is not enough. A skill's recorded value is a NESTED map, so the
|
|
1140
|
+
* `newSHAs[key]` truthiness test the flat types use passes as soon as a single relpath
|
|
1141
|
+
* lands — a skill missing half its files would count as adopted and the wiki would own
|
|
1142
|
+
* a lossy install. Worse, copyOne hands back the previously recorded SHA on its drift
|
|
1143
|
+
* and skip paths, so even a per-relpath presence check can be satisfied by a file sync
|
|
1144
|
+
* refused to touch.
|
|
1145
|
+
*
|
|
1146
|
+
* So: every captured relpath must be recorded with EXACTLY the SHA we captured, the
|
|
1147
|
+
* recorded map must hold nothing else, and sync must not have reported drift or a
|
|
1148
|
+
* conflict against this skill. A first capture is source == install target, so every
|
|
1149
|
+
* file is expected to land on the up-to-date branch.
|
|
1150
|
+
*/
|
|
1151
|
+
function skillAdopted(c, sync) {
|
|
1152
|
+
const nested = (sync.newSHAs || {})[c.installKey];
|
|
1153
|
+
if (!nested || typeof nested !== 'object' || Array.isArray(nested)) return false;
|
|
1154
|
+
const wikiName = `${EXT_PREFIX}${c.stem}`;
|
|
1155
|
+
const touched = (list) => (list || []).some((e) => e.name === wikiName);
|
|
1156
|
+
if (touched(sync.drifts) || touched(sync.conflicts)) return false;
|
|
1157
|
+
const relpaths = Object.keys(c.srcShas);
|
|
1158
|
+
if (Object.keys(nested).length !== relpaths.length) return false;
|
|
1159
|
+
return relpaths.every((rel) => nested[rel] === c.srcShas[rel]);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
651
1162
|
function report(captured, skipped, failed, dryRun) {
|
|
652
1163
|
log('');
|
|
653
1164
|
if (captured.length > 0) {
|
|
654
1165
|
log(`${dryRun ? 'Would capture' : 'Captured'} ${captured.length} extension(s):`);
|
|
655
|
-
for (const c of captured)
|
|
656
|
-
|
|
1166
|
+
for (const c of captured) {
|
|
1167
|
+
const dest =
|
|
1168
|
+
c.type === 'skills'
|
|
1169
|
+
? `extensions/skills/${EXT_PREFIX}${c.stem}/`
|
|
1170
|
+
: `extensions/${c.type}/${EXT_PREFIX}${c.stem}${TYPE_EXT[c.type]}`;
|
|
1171
|
+
log(` ${c.type}/${c.file} → ${dest}`);
|
|
1172
|
+
}
|
|
657
1173
|
}
|
|
658
1174
|
if (skipped.length > 0) log(`Skipped ${skipped.length} (conflict/invalid — see above).`);
|
|
659
1175
|
if (failed.length > 0)
|
|
@@ -667,11 +1183,14 @@ function report(captured, skipped, failed, dryRun) {
|
|
|
667
1183
|
function main() {
|
|
668
1184
|
const args = parseArgs(process.argv);
|
|
669
1185
|
if (args.help) {
|
|
670
|
-
log(
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
log('
|
|
674
|
-
log('
|
|
1186
|
+
log(
|
|
1187
|
+
'Usage: hypomnema capture [names…] [--all] [--type=commands,agents,hooks,skills] [--dry-run]',
|
|
1188
|
+
);
|
|
1189
|
+
log(' Pull ~/.claude/{commands,agents,skills} extensions and canonical');
|
|
1190
|
+
log(' settings.json hooks into the wiki for cross-machine sync. Hooks and skills');
|
|
1191
|
+
log(' are captured only when they round-trip losslessly; others are skipped with');
|
|
1192
|
+
log(' a reason. A skill is a directory (SKILL.md + subtree); one holding symlinks,');
|
|
1193
|
+
log(` hardlinks, empty dirs, or more than ${SKILL_MAX_FILES} files is refused.`);
|
|
675
1194
|
return 0;
|
|
676
1195
|
}
|
|
677
1196
|
const res = run(args);
|