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
|
@@ -27,8 +27,10 @@ import {
|
|
|
27
27
|
renameSync,
|
|
28
28
|
unlinkSync,
|
|
29
29
|
mkdirSync,
|
|
30
|
+
lstatSync,
|
|
31
|
+
rmdirSync,
|
|
30
32
|
} from 'fs';
|
|
31
|
-
import { join } from 'path';
|
|
33
|
+
import { join, dirname, relative, resolve, posix, sep } from 'path';
|
|
32
34
|
import { homedir } from 'os';
|
|
33
35
|
import { sha256, isRegularFile, readFileIfRegular, writePkgJsonAtomic } from './pkg-json.mjs';
|
|
34
36
|
import { loadHypoIgnore, isIgnored } from './hypo-ignore.mjs';
|
|
@@ -148,6 +150,46 @@ export function resolveInstallFile(ext) {
|
|
|
148
150
|
return { installFile: m.installName + TYPE_FILE_EXT[ext.type] };
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Resolve the install DIRECTORY segment for a directory skill. Deliberately NOT
|
|
155
|
+
* resolveInstallFile: that one appends TYPE_FILE_EXT (`foo.md`), which would both
|
|
156
|
+
* miss the directory key and regress the flat hypo-ext-*.md skills.
|
|
157
|
+
*
|
|
158
|
+
* Default: strip the `hypo-ext-` wiki storage prefix, so a skill keeps the name it
|
|
159
|
+
* is actually invoked by (`skills/gstack/`, not `skills/hypo-ext-gstack/`). Unlike
|
|
160
|
+
* commands/agents there is no shipped directory-skill behavior to stay compatible
|
|
161
|
+
* with — none were ever discovered — so the clean name is free to be the default.
|
|
162
|
+
* A sidecar manifest `installName` (with a matching `type`) overrides it.
|
|
163
|
+
*
|
|
164
|
+
* The resolved segment becomes a directory we create and delete under, so it gets
|
|
165
|
+
* the same injection defenses as a flat installName plus a trailing-dot rejection.
|
|
166
|
+
*/
|
|
167
|
+
export function resolveSkillInstallDir(ext) {
|
|
168
|
+
if (ext.manifestPath) {
|
|
169
|
+
const parsed = parseManifest(ext.manifestPath);
|
|
170
|
+
if (parsed.ok) {
|
|
171
|
+
const m = parsed.manifest;
|
|
172
|
+
if (m && m.type === TYPE_SINGULAR.skills && m.installName !== undefined) {
|
|
173
|
+
if (!isValidSkillDirSegment(m.installName)) {
|
|
174
|
+
return {
|
|
175
|
+
skip: true,
|
|
176
|
+
warn: `skills/${ext.file}: invalid installName "${m.installName}" — skill skipped`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { installDir: m.installName };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const stem = ext.file.startsWith(EXT_PREFIX) ? ext.file.slice(EXT_PREFIX.length) : ext.file;
|
|
184
|
+
if (!isValidSkillDirSegment(stem)) {
|
|
185
|
+
return {
|
|
186
|
+
skip: true,
|
|
187
|
+
warn: `skills/${ext.file} skipped (unsafe install directory name "${stem}")`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return { installDir: stem };
|
|
191
|
+
}
|
|
192
|
+
|
|
151
193
|
/**
|
|
152
194
|
* Parse + validate a recorded pkg-json extension key `${type}/${installFile}`
|
|
153
195
|
* for destructive use (capture design §8 — uninstall traverses recorded keys, which
|
|
@@ -183,6 +225,179 @@ export function parseExtKey(key, coveredTypes) {
|
|
|
183
225
|
return { type, installFile };
|
|
184
226
|
}
|
|
185
227
|
|
|
228
|
+
// ── directory skills (skills-dir design) ─────────────────────────────────────
|
|
229
|
+
//
|
|
230
|
+
// A real Claude Code skill is a DIRECTORY (`skills/<name>/SKILL.md` + an
|
|
231
|
+
// arbitrary subtree), not the flat single `.md` the rest of this module assumes.
|
|
232
|
+
// The SHA map keeps its single-segment top-level key (`skills/<name>`) so
|
|
233
|
+
// parseExtKey's no-slash rule — the capture design §8 traversal fix — stays shut;
|
|
234
|
+
// only the VALUE widens to a per-relpath map. Everything below validates that
|
|
235
|
+
// widened shape: a corrupt hypo-pkg.json can now express many paths under one key.
|
|
236
|
+
|
|
237
|
+
// The file that makes a directory a skill. Absent (or not a regular file) → not a skill.
|
|
238
|
+
export const SKILL_ROOT_FILE = 'SKILL.md';
|
|
239
|
+
|
|
240
|
+
// Depth/length ceilings. A pathological subtree is skipped with a warning rather
|
|
241
|
+
// than crashing the sync.
|
|
242
|
+
const SKILL_MAX_DEPTH = 16;
|
|
243
|
+
const SKILL_MAX_RELPATH_LEN = 400;
|
|
244
|
+
|
|
245
|
+
const HEX_SHA = /^[0-9a-f]{64}$/;
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* True iff `seg` is safe to use as an install DIRECTORY segment for a skill.
|
|
249
|
+
* Reuses the flat installName defenses (charset, `..`, reserved `hypo`
|
|
250
|
+
* namespace, Windows device names) and adds a trailing-dot rejection: Windows
|
|
251
|
+
* strips trailing dots from directory names, so `foo.` and `foo` alias.
|
|
252
|
+
*/
|
|
253
|
+
export function isValidSkillDirSegment(seg) {
|
|
254
|
+
if (!isValidInstallStem(seg)) return false;
|
|
255
|
+
if (seg.endsWith('.')) return false;
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Parse a recorded pkg-json key for a DIRECTORY skill. parseExtKey rejects
|
|
261
|
+
* `skills/foo` outright (it demands the type's file extension), so a skill key
|
|
262
|
+
* must never be routed through it — uninstall validates every recorded key and
|
|
263
|
+
* would silently drop the record. Returns `{ type, installDir }` or null.
|
|
264
|
+
*/
|
|
265
|
+
export function parseSkillKey(key) {
|
|
266
|
+
if (typeof key !== 'string') return null;
|
|
267
|
+
const slash = key.indexOf('/');
|
|
268
|
+
if (slash === -1) return null;
|
|
269
|
+
if (key.slice(0, slash) !== 'skills') return null;
|
|
270
|
+
const installDir = key.slice(slash + 1);
|
|
271
|
+
if (installDir.includes('/') || installDir.includes('\\')) return null;
|
|
272
|
+
if (!isValidSkillDirSegment(installDir)) return null;
|
|
273
|
+
return { type: 'skills', installDir };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate one subtree-relative path (`SKILL.md`, `references/x.md`). Returns the
|
|
278
|
+
* canonical posix relpath, or null when unsafe. Rejects traversal, absolute and
|
|
279
|
+
* Windows-absolute forms, backslash separators, `.` segments, NUL, and anything
|
|
280
|
+
* past the depth/length ceiling. The canonical return value is what callers must
|
|
281
|
+
* key on: `references/./x.md` and `references/x.md` are the same destination, and
|
|
282
|
+
* treating them as distinct would manufacture a phantom orphan.
|
|
283
|
+
*/
|
|
284
|
+
export function normalizeSkillRelPath(rel) {
|
|
285
|
+
if (typeof rel !== 'string' || rel.length === 0) return null;
|
|
286
|
+
if (rel.length > SKILL_MAX_RELPATH_LEN) return null;
|
|
287
|
+
if (rel.includes('\0')) return null;
|
|
288
|
+
// Backslash is a separator on Windows; treat it as unsafe everywhere rather
|
|
289
|
+
// than letting `a\..\b` mean different things per platform.
|
|
290
|
+
if (rel.includes('\\')) return null;
|
|
291
|
+
if (rel.startsWith('/')) return null;
|
|
292
|
+
// Windows drive (`C:x`, `C:/x`) and UNC (`//host/share`) forms.
|
|
293
|
+
if (/^[A-Za-z]:/.test(rel)) return null;
|
|
294
|
+
if (rel.startsWith('//')) return null;
|
|
295
|
+
const segs = rel.split('/');
|
|
296
|
+
if (segs.length > SKILL_MAX_DEPTH) return null;
|
|
297
|
+
for (const s of segs) {
|
|
298
|
+
if (s === '' || s === '.' || s === '..') return null;
|
|
299
|
+
if (s.endsWith('.')) return null; // Windows directory-name aliasing
|
|
300
|
+
}
|
|
301
|
+
const norm = posix.normalize(rel);
|
|
302
|
+
// normalize() cannot escape given the segment checks above, but re-assert:
|
|
303
|
+
// a caller-supplied relpath must never resolve outside its own subtree.
|
|
304
|
+
if (norm.startsWith('..') || norm.startsWith('/')) return null;
|
|
305
|
+
return norm;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* True iff `target` resolves strictly inside `root`. Boundary-aware: a raw
|
|
310
|
+
* `startsWith` would accept `/a/bc` as living under `/a/b`.
|
|
311
|
+
*/
|
|
312
|
+
export function isContainedUnder(root, target) {
|
|
313
|
+
const rel = relative(resolve(root), resolve(target));
|
|
314
|
+
if (rel === '' || rel.startsWith('..')) return false;
|
|
315
|
+
return !rel.split(sep).includes('..');
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* True iff `root` itself, or any path segment from `root` down to `target`
|
|
320
|
+
* (inclusive), is a symlink. Lexical containment alone cannot see this: a hostile
|
|
321
|
+
* `references -> /outside` directory symlink lets `references/x.md` pass every
|
|
322
|
+
* string check while the write or unlink lands outside the skill entirely.
|
|
323
|
+
* copyOne's leaf check (`isRegularFile`) only guards the FINAL component.
|
|
324
|
+
*
|
|
325
|
+
* `root` is checked too, not just what is below it. Skipping it left the whole
|
|
326
|
+
* guard bypassable by symlinking the boundary directory itself (`~/.claude/skills`),
|
|
327
|
+
* which is the one ancestor every install path shares (codex pre-commit BLOCKER).
|
|
328
|
+
*
|
|
329
|
+
* Callers must re-run this immediately before EVERY mutation (mkdir, copy,
|
|
330
|
+
* unlink, rmdir). The residual TOCTOU window between this check and the syscall
|
|
331
|
+
* is accepted (skills-dir design §7); re-checking per mutation keeps it from widening.
|
|
332
|
+
*/
|
|
333
|
+
export function hasSymlinkAncestor(root, target) {
|
|
334
|
+
const rootAbs = resolve(root);
|
|
335
|
+
const targetAbs = resolve(target);
|
|
336
|
+
if (!isContainedUnder(rootAbs, targetAbs)) return true; // treat as unsafe
|
|
337
|
+
try {
|
|
338
|
+
if (lstatSync(rootAbs).isSymbolicLink()) return true;
|
|
339
|
+
} catch {
|
|
340
|
+
// The boundary dir does not exist yet: nothing to escape through.
|
|
341
|
+
}
|
|
342
|
+
const rel = relative(rootAbs, targetAbs);
|
|
343
|
+
const segs = rel.split(sep).filter(Boolean);
|
|
344
|
+
let cur = rootAbs;
|
|
345
|
+
// Walk every intermediate segment AND the leaf: a symlinked leaf must not be
|
|
346
|
+
// written through or unlinked either.
|
|
347
|
+
for (const s of segs) {
|
|
348
|
+
cur = join(cur, s);
|
|
349
|
+
let st;
|
|
350
|
+
try {
|
|
351
|
+
st = lstatSync(cur);
|
|
352
|
+
} catch {
|
|
353
|
+
continue; // does not exist yet (fresh install) — nothing to escape through
|
|
354
|
+
}
|
|
355
|
+
if (st.isSymbolicLink()) return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* True iff `value` is a plain hex SHA — the only shape a FLAT extension key may
|
|
362
|
+
* carry. A corrupt pkg-json that parks an object (or any other type) under a flat
|
|
363
|
+
* key must not be treated as ownership: under --force-extensions that would remove
|
|
364
|
+
* a file on the strength of a value we never wrote (codex pre-commit CONCERN).
|
|
365
|
+
*/
|
|
366
|
+
export function isFlatShaValue(value) {
|
|
367
|
+
return typeof value === 'string' && HEX_SHA.test(value);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* A nested SHA map keyed by relpath. Null-prototype on purpose: relpaths come from
|
|
372
|
+
* the filesystem, so a file named `__proto__` would otherwise assign through the
|
|
373
|
+
* prototype setter instead of creating an own key (installed but never recorded,
|
|
374
|
+
* hence unowned and unreachable by uninstall), and a file named `constructor` would
|
|
375
|
+
* read back a truthy inherited value as if it were a recorded SHA. Codex found the
|
|
376
|
+
* `__proto__` case with a working repro.
|
|
377
|
+
*/
|
|
378
|
+
export function emptyShaMap() {
|
|
379
|
+
return Object.create(null);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Validate a recorded skill SHA value: a plain object mapping canonical relpaths
|
|
384
|
+
* to hex SHAs. Returns a clean map containing only the entries that pass; a
|
|
385
|
+
* corrupt or malicious entry is dropped rather than trusted. Returns null when
|
|
386
|
+
* the value is not a plain object at all (shape mismatch — e.g. a flat string
|
|
387
|
+
* SHA parked under a skill key).
|
|
388
|
+
*/
|
|
389
|
+
export function parseSkillShaValue(value) {
|
|
390
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
391
|
+
const out = emptyShaMap();
|
|
392
|
+
for (const [rel, sha] of Object.entries(value)) {
|
|
393
|
+
if (typeof sha !== 'string' || !HEX_SHA.test(sha)) continue;
|
|
394
|
+
const norm = normalizeSkillRelPath(rel);
|
|
395
|
+
if (norm === null || norm !== rel) continue; // only canonical keys are trusted
|
|
396
|
+
out[norm] = sha;
|
|
397
|
+
}
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
|
|
186
401
|
// Claude Code hook events (D3 allowlist). A manifest with an event outside this
|
|
187
402
|
// set is malformed → the whole extension is skipped (no copy, no registration).
|
|
188
403
|
export const HOOK_EVENT_ALLOWLIST = new Set([
|
|
@@ -212,11 +427,183 @@ function pkgRootDir(target) {
|
|
|
212
427
|
|
|
213
428
|
// ── read-only helpers ─────────────────────────────────────────────────────────
|
|
214
429
|
|
|
430
|
+
/**
|
|
431
|
+
* True iff `p` is a directory and NOT a symlink to one. A symlinked directory in
|
|
432
|
+
* the wiki subtree (`references -> /etc`) would otherwise let the walker copy
|
|
433
|
+
* files from outside the vault into the install target.
|
|
434
|
+
*/
|
|
435
|
+
export function isRealDir(p) {
|
|
436
|
+
try {
|
|
437
|
+
return lstatSync(p).isDirectory();
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Directory names that must never be captured into the wiki: the wiki is itself a
|
|
444
|
+
// git repo, so a `.git` under a skill would nest one repo inside another. Strict
|
|
445
|
+
// (capture) mode only.
|
|
446
|
+
const VCS_DIRS = new Set(['.git', '.hg', '.svn']);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Walk a skill directory and collect its owned files. lstat-based: symlinked
|
|
450
|
+
* files and symlinked directories are skipped with a warning, never followed.
|
|
451
|
+
*
|
|
452
|
+
* Returns `{ files: [{ rel, srcPath, size }], warnings, skip }`. `skip` is set when
|
|
453
|
+
* the whole skill must be dropped: no regular SKILL.md at the root, or two source
|
|
454
|
+
* paths that canonicalize to the same destination (case-fold aliasing), which
|
|
455
|
+
* would make ownership order-dependent.
|
|
456
|
+
*
|
|
457
|
+
* `opts` is empty for forward-sync, which keeps its tolerant behavior verbatim.
|
|
458
|
+
* Reverse capture passes:
|
|
459
|
+
* - `strict`: any per-file skip reason poisons the WHOLE skill instead of
|
|
460
|
+
* dropping that one file. Capture must never write a lossy copy into the
|
|
461
|
+
* wiki, because what lands there is what the far machine installs. It also
|
|
462
|
+
* turns on the checks that only matter for content coming INTO the wiki:
|
|
463
|
+
* empty directories (git cannot carry them), VCS control dirs, and hardlinks
|
|
464
|
+
* (link identity does not round-trip).
|
|
465
|
+
* - `maxFiles` / `maxBytes`: cumulative ceilings, checked during the walk so a
|
|
466
|
+
* vendored skill (gstack: 14k files / 1.1GB) aborts early instead of being
|
|
467
|
+
* enumerated in full on every candidate listing. Sizes come from lstat, so
|
|
468
|
+
* the verdict lands before anything is read.
|
|
469
|
+
*/
|
|
470
|
+
export function walkSkillSubtree(skillDir, label, hypoDir, hypoignorePatterns, opts = {}) {
|
|
471
|
+
const { strict = false, maxFiles = Infinity, maxBytes = Infinity } = opts;
|
|
472
|
+
const files = [];
|
|
473
|
+
const warnings = [];
|
|
474
|
+
const seen = new Map(); // case-folded canonical rel -> first rel that claimed it
|
|
475
|
+
let bytes = 0;
|
|
476
|
+
// Set once the whole skill is doomed; unwinds the recursion immediately.
|
|
477
|
+
let fatal = null;
|
|
478
|
+
const poison = (reason) => {
|
|
479
|
+
fatal = reason;
|
|
480
|
+
warnings.push(`${label} skipped (${reason})`);
|
|
481
|
+
files.length = 0;
|
|
482
|
+
return { fatal: true };
|
|
483
|
+
};
|
|
484
|
+
// A per-file problem: tolerated (skip the file) by forward-sync, fatal in strict.
|
|
485
|
+
const reject = (rel, reason) => {
|
|
486
|
+
if (strict) return poison(`${rel}: ${reason}`);
|
|
487
|
+
warnings.push(`${label}/${rel} skipped (${reason})`);
|
|
488
|
+
return null;
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const rootFile = join(skillDir, SKILL_ROOT_FILE);
|
|
492
|
+
if (!isRegularFile(rootFile)) {
|
|
493
|
+
return {
|
|
494
|
+
files: [],
|
|
495
|
+
warnings: [`${label} skipped (no regular ${SKILL_ROOT_FILE} at the skill root)`],
|
|
496
|
+
skip: true,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const walk = (dir, prefix, depth) => {
|
|
501
|
+
if (depth > SKILL_MAX_DEPTH) {
|
|
502
|
+
return reject(prefix, `subtree deeper than ${SKILL_MAX_DEPTH}`);
|
|
503
|
+
}
|
|
504
|
+
let entries;
|
|
505
|
+
try {
|
|
506
|
+
entries = readdirSync(dir);
|
|
507
|
+
} catch {
|
|
508
|
+
return reject(prefix, 'unreadable directory');
|
|
509
|
+
}
|
|
510
|
+
// git cannot carry an empty directory, so a skill that needs one cannot be
|
|
511
|
+
// reproduced through the wiki. Refuse rather than capture a subset.
|
|
512
|
+
if (strict && entries.length === 0 && depth > 1) {
|
|
513
|
+
return poison(`${prefix}: empty directory (add a .gitkeep to carry it)`);
|
|
514
|
+
}
|
|
515
|
+
for (const name of entries) {
|
|
516
|
+
const full = join(dir, name);
|
|
517
|
+
const rel = prefix ? `${prefix}/${name}` : name;
|
|
518
|
+
if (isIgnored(full, hypoDir, hypoignorePatterns)) continue;
|
|
519
|
+
if (isRealDir(full)) {
|
|
520
|
+
if (strict && VCS_DIRS.has(name)) {
|
|
521
|
+
return poison(`${rel}: VCS control directory cannot be nested in the wiki`);
|
|
522
|
+
}
|
|
523
|
+
// Propagate a collision found further down: ignoring this return value let a
|
|
524
|
+
// deep `references/A.md` vs `references/a.md` clash through while only the
|
|
525
|
+
// root level was poisoned (codex pre-commit NIT).
|
|
526
|
+
const sub = walk(full, rel, depth + 1);
|
|
527
|
+
if (sub && (sub.collision || sub.fatal)) return sub;
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (!isRegularFile(full)) {
|
|
531
|
+
// Symlink, socket, or a symlinked dir: never followed, never owned.
|
|
532
|
+
const r = reject(rel, 'not a regular file');
|
|
533
|
+
if (r) return r;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// A sidecar manifest lives NEXT TO the skill dir, not inside it. Reserve the
|
|
537
|
+
// name inside the subtree so skill content can never be confused for install
|
|
538
|
+
// metadata later.
|
|
539
|
+
if (name.endsWith('.manifest.json')) {
|
|
540
|
+
const r = reject(rel, 'reserved manifest name inside a skill');
|
|
541
|
+
if (r) return r;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const norm = normalizeSkillRelPath(rel);
|
|
545
|
+
if (norm === null) {
|
|
546
|
+
const r = reject(rel, 'unsafe path');
|
|
547
|
+
if (r) return r;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
// NFD and NFC spellings of the same name are one file on macOS, so collapse
|
|
551
|
+
// the unicode form before case-folding or the alias check misses them.
|
|
552
|
+
const fold = norm.normalize('NFC').toLowerCase();
|
|
553
|
+
if (seen.has(fold)) {
|
|
554
|
+
// Poison the whole skill: which file wins would depend on readdir order. `fatal`
|
|
555
|
+
// carries the reason out so a caller (capture) reports THIS, not the fallback
|
|
556
|
+
// "no SKILL.md" (codex pre-commit CONCERN).
|
|
557
|
+
fatal = `${norm} and ${seen.get(fold)} collide on a case-insensitive filesystem`;
|
|
558
|
+
warnings.push(`${label} skipped (${fatal})`);
|
|
559
|
+
files.length = 0;
|
|
560
|
+
return { collision: true };
|
|
561
|
+
}
|
|
562
|
+
let st = null;
|
|
563
|
+
if (strict || maxBytes !== Infinity) {
|
|
564
|
+
try {
|
|
565
|
+
st = lstatSync(full);
|
|
566
|
+
} catch {
|
|
567
|
+
const r = reject(rel, 'unreadable file');
|
|
568
|
+
if (r) return r;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
// A hardlink is a regular file, so nothing above catches it, and copying it
|
|
572
|
+
// silently flattens a link the far machine cannot reconstruct.
|
|
573
|
+
if (strict && st.nlink !== 1) {
|
|
574
|
+
return poison(`${rel}: hardlink (nlink=${st.nlink}) does not round-trip`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
seen.set(fold, norm);
|
|
578
|
+
files.push({ rel: norm, srcPath: full, size: st ? st.size : undefined });
|
|
579
|
+
if (files.length > maxFiles) {
|
|
580
|
+
return poison(`over ${maxFiles} files (aborted while walking)`);
|
|
581
|
+
}
|
|
582
|
+
if (st) {
|
|
583
|
+
bytes += st.size;
|
|
584
|
+
if (bytes > maxBytes) {
|
|
585
|
+
const mib = (n) => `${Math.round(n / (1024 * 1024))} MiB`;
|
|
586
|
+
return poison(`over ${mib(maxBytes)} (${mib(bytes)}+ so far, aborted while walking)`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
const res = walk(skillDir, '', 1);
|
|
594
|
+
if (res && (res.collision || res.fatal)) return { files: [], warnings, skip: true, fatal };
|
|
595
|
+
return { files, warnings, skip: false, bytes };
|
|
596
|
+
}
|
|
597
|
+
|
|
215
598
|
/**
|
|
216
599
|
* Discover sync-eligible extensions under `extDir`. Returns a per-type map plus a
|
|
217
600
|
* `warnings` array. Applies the `.hypoignore` filter, the basename
|
|
218
601
|
* whitelist (plan §5 #9), and pairs each file with its optional `<name>.manifest.json`.
|
|
219
602
|
* No-ops gracefully when extDir is absent (e.g. --from-remote clones, plan §5 #8).
|
|
603
|
+
*
|
|
604
|
+
* Skills come in two shapes. A directory (`skills/hypo-ext-<name>/SKILL.md` + a
|
|
605
|
+
* subtree) is the real Claude Code layout and carries a `files[]` list; a flat
|
|
606
|
+
* `hypo-ext-<name>.md` keeps working exactly as before (backward compatible).
|
|
220
607
|
*/
|
|
221
608
|
export function discoverExtensions(extDir, hypoignorePatterns, hypoDir) {
|
|
222
609
|
const result = { hooks: [], commands: [], skills: [], agents: [], warnings: [] };
|
|
@@ -238,6 +625,39 @@ export function discoverExtensions(extDir, hypoignorePatterns, hypoDir) {
|
|
|
238
625
|
if (fname.endsWith('.manifest.json')) continue;
|
|
239
626
|
const srcPath = join(typeDir, fname);
|
|
240
627
|
if (isIgnored(srcPath, hypoDir, hypoignorePatterns)) continue;
|
|
628
|
+
|
|
629
|
+
// A directory under skills/ is the real skill layout. Everything else falls
|
|
630
|
+
// through to the flat single-file path below.
|
|
631
|
+
if (type === 'skills' && isRealDir(srcPath)) {
|
|
632
|
+
if (!SAFE_EXT_STEM.test(fname)) {
|
|
633
|
+
result.warnings.push(
|
|
634
|
+
`skills/${fname} skipped (extensions must use a 'hypo-ext-<name>' directory name)`,
|
|
635
|
+
);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const label = `skills/${fname}`;
|
|
639
|
+
const walked = walkSkillSubtree(srcPath, label, hypoDir, hypoignorePatterns);
|
|
640
|
+
result.warnings.push(...walked.warnings);
|
|
641
|
+
if (walked.skip) continue;
|
|
642
|
+
const manifestName = `${fname}.manifest.json`;
|
|
643
|
+
const manifestPath = join(typeDir, manifestName);
|
|
644
|
+
const hasSkillManifest =
|
|
645
|
+
existsSync(manifestPath) &&
|
|
646
|
+
isRegularFile(manifestPath) &&
|
|
647
|
+
!isIgnored(manifestPath, hypoDir, hypoignorePatterns);
|
|
648
|
+
result.skills.push({
|
|
649
|
+
type,
|
|
650
|
+
name: fname,
|
|
651
|
+
file: fname,
|
|
652
|
+
srcPath,
|
|
653
|
+
isDir: true,
|
|
654
|
+
files: walked.files,
|
|
655
|
+
manifestName,
|
|
656
|
+
manifestPath: hasSkillManifest ? manifestPath : null,
|
|
657
|
+
});
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
241
661
|
if (!isRegularFile(srcPath)) continue;
|
|
242
662
|
if (!fname.endsWith(fileExt)) continue;
|
|
243
663
|
const stem = fname.slice(0, -fileExt.length);
|
|
@@ -563,6 +983,190 @@ function recordCopyOutcome(result, name, key, action, apply) {
|
|
|
563
983
|
}
|
|
564
984
|
}
|
|
565
985
|
|
|
986
|
+
/** Join a canonical posix relpath onto a native base path. */
|
|
987
|
+
function joinRel(base, rel) {
|
|
988
|
+
return join(base, ...rel.split('/'));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Sync one DIRECTORY skill: mirror its wiki subtree into the install directory and
|
|
993
|
+
* remove the files we own that the wiki no longer has.
|
|
994
|
+
*
|
|
995
|
+
* This is the only place forward-sync deletes anything. The gate is the same
|
|
996
|
+
* ownership rule the rest of the module uses for overwrite: a file is removed only
|
|
997
|
+
* when its on-disk SHA still matches the SHA we recorded when we installed it. A
|
|
998
|
+
* user-modified copy, an unowned file the user dropped in, and anything that is not
|
|
999
|
+
* a regular file are all preserved. The recorded SHA of a PRESERVED orphan is kept,
|
|
1000
|
+
* not dropped — without it the file becomes unowned and --force-extensions could
|
|
1001
|
+
* never clean it up.
|
|
1002
|
+
*
|
|
1003
|
+
* Every mutation (mkdir, copy, unlink, rmdir) re-runs the symlink-ancestor walk
|
|
1004
|
+
* first: lexical containment alone cannot see a `references -> /outside` directory
|
|
1005
|
+
* symlink, and both the write and the unlink would land outside the skill.
|
|
1006
|
+
*/
|
|
1007
|
+
function syncOneSkill({
|
|
1008
|
+
ext,
|
|
1009
|
+
installDir,
|
|
1010
|
+
typeDir,
|
|
1011
|
+
recorded,
|
|
1012
|
+
newSHAs,
|
|
1013
|
+
result,
|
|
1014
|
+
target,
|
|
1015
|
+
apply,
|
|
1016
|
+
force,
|
|
1017
|
+
}) {
|
|
1018
|
+
const key = `skills/${installDir}`;
|
|
1019
|
+
const skillRoot = join(typeDir, installDir);
|
|
1020
|
+
// A recorded value of the wrong shape (e.g. a flat string SHA parked under a
|
|
1021
|
+
// skill key by a corrupt pkg-json) is not trusted as ownership of anything.
|
|
1022
|
+
const recordedNested = parseSkillShaValue(recorded[key]) ?? emptyShaMap();
|
|
1023
|
+
const newNested = emptyShaMap();
|
|
1024
|
+
|
|
1025
|
+
const unsafe = (destPath) =>
|
|
1026
|
+
!isContainedUnder(skillRoot, destPath) || hasSymlinkAncestor(typeDir, destPath);
|
|
1027
|
+
|
|
1028
|
+
// (1) install / update every file the wiki subtree currently has.
|
|
1029
|
+
const desired = new Set();
|
|
1030
|
+
// A path we refuse to touch must KEEP whatever ownership it already had. Dropping
|
|
1031
|
+
// the record here would silently disown a file we installed on an earlier run,
|
|
1032
|
+
// putting it beyond doctor and uninstall (codex pre-commit CONCERN).
|
|
1033
|
+
const refuse = (fileKey, rel) => {
|
|
1034
|
+
result.conflicts.push({ name: ext.name, file: fileKey, action: 'skip-unsafe-path' });
|
|
1035
|
+
result.warnings.push(`${fileKey} (skip-unsafe-path) — left untouched`);
|
|
1036
|
+
if (recordedNested[rel] !== undefined) newNested[rel] = recordedNested[rel];
|
|
1037
|
+
};
|
|
1038
|
+
for (const f of ext.files) {
|
|
1039
|
+
desired.add(f.rel);
|
|
1040
|
+
const destPath = joinRel(skillRoot, f.rel);
|
|
1041
|
+
const fileKey = `${key}/${f.rel}`; // display only — never a pkg-json key
|
|
1042
|
+
if (unsafe(destPath)) {
|
|
1043
|
+
refuse(fileKey, f.rel);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (apply) {
|
|
1047
|
+
const parent = dirname(destPath);
|
|
1048
|
+
if (hasSymlinkAncestor(typeDir, parent)) {
|
|
1049
|
+
refuse(fileKey, f.rel);
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
mkdirSync(parent, { recursive: true });
|
|
1053
|
+
}
|
|
1054
|
+
const res = copyOne({
|
|
1055
|
+
srcPath: f.srcPath,
|
|
1056
|
+
destPath,
|
|
1057
|
+
key: fileKey,
|
|
1058
|
+
recordedSHA: recordedNested[f.rel],
|
|
1059
|
+
apply,
|
|
1060
|
+
force,
|
|
1061
|
+
});
|
|
1062
|
+
if (res.sha != null) newNested[f.rel] = res.sha;
|
|
1063
|
+
result.actions.push({ target, file: fileKey, action: res.action });
|
|
1064
|
+
recordCopyOutcome(result, ext.name, fileKey, res.action, apply);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// (2) orphans: recorded, but the wiki subtree no longer carries them.
|
|
1068
|
+
for (const [rel, sha] of Object.entries(recordedNested)) {
|
|
1069
|
+
if (desired.has(rel)) continue;
|
|
1070
|
+
const destPath = joinRel(skillRoot, rel);
|
|
1071
|
+
const fileKey = `${key}/${rel}`;
|
|
1072
|
+
const keep = (reason) => {
|
|
1073
|
+
// Preserving the record is the point: drop it and the file goes unowned,
|
|
1074
|
+
// which puts it beyond --force-extensions forever.
|
|
1075
|
+
newNested[rel] = sha;
|
|
1076
|
+
result.warnings.push(`${fileKey} (${reason}) — left untouched`);
|
|
1077
|
+
};
|
|
1078
|
+
if (unsafe(destPath)) {
|
|
1079
|
+
keep('skip-unsafe-path');
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (!existsSync(destPath)) continue; // already gone: the record can go too
|
|
1083
|
+
if (!isRegularFile(destPath)) {
|
|
1084
|
+
keep('skip-non-regular');
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const onDisk = readFileIfRegular(destPath);
|
|
1088
|
+
if (onDisk === null) {
|
|
1089
|
+
keep('skip-unreadable');
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
const onDiskSHA = sha256(onDisk);
|
|
1093
|
+
if (onDiskSHA !== sha && !force) {
|
|
1094
|
+
// Owned once, edited since. Same call as an overwrite drift: warn, preserve.
|
|
1095
|
+
result.drifts.push({ name: ext.name, file: fileKey });
|
|
1096
|
+
result.needsWork = true;
|
|
1097
|
+
newNested[rel] = sha;
|
|
1098
|
+
result.warnings.push(`${fileKey} (drift — user-modified orphan) — left untouched`);
|
|
1099
|
+
continue;
|
|
1100
|
+
}
|
|
1101
|
+
if (!apply) {
|
|
1102
|
+
// Check mode: the deletion is pending work, and nothing is written yet.
|
|
1103
|
+
newNested[rel] = sha;
|
|
1104
|
+
result.needsWork = true;
|
|
1105
|
+
result.actions.push({ target, file: fileKey, action: 'delete' });
|
|
1106
|
+
continue;
|
|
1107
|
+
}
|
|
1108
|
+
// CAS: re-read immediately before unlink. A save between the hash above and
|
|
1109
|
+
// this syscall must not lose the user's bytes.
|
|
1110
|
+
const verify = readFileIfRegular(destPath);
|
|
1111
|
+
if (verify === null || (sha256(verify) !== sha && !force)) {
|
|
1112
|
+
keep('skip-changed');
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (hasSymlinkAncestor(typeDir, destPath)) {
|
|
1116
|
+
keep('skip-unsafe-path');
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (force && sha256(verify) !== sha) writeFreshAtomic(`${destPath}.bak`, verify);
|
|
1120
|
+
try {
|
|
1121
|
+
unlinkSync(destPath);
|
|
1122
|
+
} catch {
|
|
1123
|
+
keep('skip-unlink-failed');
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
result.actions.push({ target, file: fileKey, action: 'delete' });
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// (3) prune directories the orphan removal emptied. The skill root itself stays:
|
|
1130
|
+
// removing it is uninstall's job, not sync's.
|
|
1131
|
+
if (apply) pruneEmptyDirs(skillRoot, typeDir, result);
|
|
1132
|
+
|
|
1133
|
+
if (Object.keys(newNested).length > 0) newSHAs[key] = newNested;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Remove now-empty directories under `skillRoot` (exclusive of the root itself),
|
|
1138
|
+
* deepest first. Skips anything reachable through a symlinked ancestor.
|
|
1139
|
+
*/
|
|
1140
|
+
function pruneEmptyDirs(skillRoot, typeDir, result) {
|
|
1141
|
+
const dirs = [];
|
|
1142
|
+
const collect = (dir, depth) => {
|
|
1143
|
+
if (depth > SKILL_MAX_DEPTH) return;
|
|
1144
|
+
let entries;
|
|
1145
|
+
try {
|
|
1146
|
+
entries = readdirSync(dir);
|
|
1147
|
+
} catch {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
for (const name of entries) {
|
|
1151
|
+
const full = join(dir, name);
|
|
1152
|
+
if (!isRealDir(full)) continue;
|
|
1153
|
+
collect(full, depth + 1);
|
|
1154
|
+
dirs.push(full);
|
|
1155
|
+
}
|
|
1156
|
+
};
|
|
1157
|
+
if (!isRealDir(skillRoot)) return;
|
|
1158
|
+
collect(skillRoot, 1);
|
|
1159
|
+
// Deepest first, so a parent emptied by its children is caught in the same pass.
|
|
1160
|
+
for (const dir of dirs.reverse()) {
|
|
1161
|
+
if (!isContainedUnder(skillRoot, dir) || hasSymlinkAncestor(typeDir, dir)) continue;
|
|
1162
|
+
try {
|
|
1163
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
1164
|
+
} catch {
|
|
1165
|
+
result.warnings.push(`skills: could not prune empty directory ${dir}`);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
566
1170
|
/**
|
|
567
1171
|
* Sync all discovered extensions for one target ('claude' | 'codex').
|
|
568
1172
|
*
|
|
@@ -638,10 +1242,33 @@ export function syncExtensions({
|
|
|
638
1242
|
// WHOLE group — otherwise file traversal order would decide ownership and
|
|
639
1243
|
// overwrite/skip unpredictably. Keyed by ext object identity.
|
|
640
1244
|
const installFileByExt = new Map();
|
|
1245
|
+
// Directory skills resolve to an install DIRECTORY segment, not a filename.
|
|
1246
|
+
const installDirByExt = new Map();
|
|
641
1247
|
const dupSkip = new Set();
|
|
642
1248
|
for (const type of types) {
|
|
643
1249
|
const seen = new Map(); // case-folded `${type}/${installFile}` → first ext
|
|
644
1250
|
for (const ext of discovered[type]) {
|
|
1251
|
+
if (ext.isDir) {
|
|
1252
|
+
const dirRes = resolveSkillInstallDir(ext);
|
|
1253
|
+
if (dirRes.skip) {
|
|
1254
|
+
dupSkip.add(ext);
|
|
1255
|
+
result.warnings.push(dirRes.warn);
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
installDirByExt.set(ext, dirRes.installDir);
|
|
1259
|
+
const dirNorm = `skills/${dirRes.installDir.toLowerCase()}`;
|
|
1260
|
+
const firstDir = seen.get(dirNorm);
|
|
1261
|
+
if (firstDir !== undefined) {
|
|
1262
|
+
dupSkip.add(ext);
|
|
1263
|
+
dupSkip.add(firstDir);
|
|
1264
|
+
result.warnings.push(
|
|
1265
|
+
`skills/${dirRes.installDir} install target claimed by multiple extensions — all skipped (rename installName)`,
|
|
1266
|
+
);
|
|
1267
|
+
} else {
|
|
1268
|
+
seen.set(dirNorm, ext);
|
|
1269
|
+
}
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
645
1272
|
const res = resolveInstallFile(ext);
|
|
646
1273
|
if (res.skip) {
|
|
647
1274
|
dupSkip.add(ext);
|
|
@@ -669,6 +1296,15 @@ export function syncExtensions({
|
|
|
669
1296
|
// previously-owned installed copy — it would linger on disk, untracked by doctor
|
|
670
1297
|
// and unreachable by uninstall (codex pre-commit BLOCKER).
|
|
671
1298
|
for (const ext of dupSkip) {
|
|
1299
|
+
if (ext.isDir) {
|
|
1300
|
+
// Same reasoning for a directory skill: keep its whole nested record so the
|
|
1301
|
+
// installed subtree stays owned (doctor sees it, uninstall can remove it).
|
|
1302
|
+
const dir = installDirByExt.get(ext);
|
|
1303
|
+
if (!dir) continue; // invalid installName: never owned
|
|
1304
|
+
const key = `skills/${dir}`;
|
|
1305
|
+
if (recorded[key] !== undefined && newSHAs[key] === undefined) newSHAs[key] = recorded[key];
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
672
1308
|
const inst = installFileByExt.get(ext);
|
|
673
1309
|
if (!inst) continue; // invalid-installName skip: never owned
|
|
674
1310
|
// Preserve BOTH ownership keys a hook records: the main `.mjs` AND its paired
|
|
@@ -689,6 +1325,25 @@ export function syncExtensions({
|
|
|
689
1325
|
const typeDir = join(targetRoot, type);
|
|
690
1326
|
for (const ext of discovered[type]) {
|
|
691
1327
|
if (dupSkip.has(ext)) continue;
|
|
1328
|
+
|
|
1329
|
+
// A directory skill mirrors a subtree (and prunes what the wiki dropped)
|
|
1330
|
+
// rather than copying one file.
|
|
1331
|
+
if (ext.isDir) {
|
|
1332
|
+
if (apply) mkdirSync(typeDir, { recursive: true });
|
|
1333
|
+
syncOneSkill({
|
|
1334
|
+
ext,
|
|
1335
|
+
installDir: installDirByExt.get(ext),
|
|
1336
|
+
typeDir,
|
|
1337
|
+
recorded,
|
|
1338
|
+
newSHAs,
|
|
1339
|
+
result,
|
|
1340
|
+
target,
|
|
1341
|
+
apply,
|
|
1342
|
+
force,
|
|
1343
|
+
});
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
692
1347
|
// Install under the manifest-declared installName (capture design §3) or, by
|
|
693
1348
|
// default, the wiki storage name (backward compatible).
|
|
694
1349
|
const installFile = installFileByExt.get(ext) ?? ext.file;
|