hypomnema 1.5.1 → 1.6.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 +2 -1
- package/README.md +2 -1
- package/commands/capture.md +40 -0
- package/docs/CONTRIBUTING.md +18 -0
- package/package.json +1 -1
- package/scripts/capture.mjs +695 -0
- package/scripts/crystallize.mjs +245 -128
- package/scripts/doctor.mjs +71 -4
- package/scripts/init.mjs +16 -5
- package/scripts/lib/core-hooks.mjs +156 -0
- package/scripts/lib/extensions.mjs +317 -9
- package/scripts/uninstall.mjs +84 -42
- package/templates/hypo-config.md +1 -1
package/scripts/uninstall.mjs
CHANGED
|
@@ -38,6 +38,8 @@ import {
|
|
|
38
38
|
EXT_TYPES,
|
|
39
39
|
CODEX_TYPES,
|
|
40
40
|
readExtensionPkgStateNoMutate,
|
|
41
|
+
parseExtKey,
|
|
42
|
+
buildHookCommand,
|
|
41
43
|
} from './lib/extensions.mjs';
|
|
42
44
|
|
|
43
45
|
const HOME = homedir();
|
|
@@ -138,13 +140,23 @@ function stripExtensionsFromPkg(pkgPath, target, removedKeys, apply) {
|
|
|
138
140
|
return changed;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
// Remove
|
|
142
|
-
//
|
|
143
|
-
// hypo-pkg.json#extensions[target]
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
143
|
+
// Remove Hypomnema-installed extension hard-copies for one target (claude |
|
|
144
|
+
// codex). Ownership is decided by the per-target recorded SHA map in
|
|
145
|
+
// hypo-pkg.json#extensions[target]: we iterate the RECORDED KEYS (not a
|
|
146
|
+
// filesystem+prefix scan) because the reverse-capture design decoupled the install filename from
|
|
147
|
+
// the wiki `hypo-ext-*` storage name — a reverse-captured command installs as
|
|
148
|
+
// `commands/mycmd.md`, which a prefix scan would never reach. The SHA map key
|
|
149
|
+
// IS the install path relative to the target root, so it names every file we
|
|
150
|
+
// own regardless of its filename.
|
|
151
|
+
//
|
|
152
|
+
// Safety (capture design §8): a recorded key comes from an on-disk JSON we do not
|
|
153
|
+
// fully control, so each key is validated by `parseExtKey` to a single covered
|
|
154
|
+
// `<type>/<safe-basename>` segment (no separators / traversal) before any
|
|
155
|
+
// join/rm — a corrupt or malicious key can never delete outside the extension
|
|
156
|
+
// directory. "Never delete unowned" holds via SHA MATCHING, not the prefix: a
|
|
157
|
+
// file whose on-disk SHA differs from the recorded one is user-modified and
|
|
158
|
+
// preserved unless --force-extensions; a symlink/non-regular target is always
|
|
159
|
+
// preserved (force does not follow them, matching install/upgrade E3's guard).
|
|
148
160
|
function removeExtensions(target, apply, force) {
|
|
149
161
|
const targetRoot = target === 'codex' ? join(HOME, '.codex') : join(HOME, '.claude');
|
|
150
162
|
const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
|
|
@@ -158,37 +170,27 @@ function removeExtensions(target, apply, force) {
|
|
|
158
170
|
const skippedUserModified = [];
|
|
159
171
|
const skippedNonRegular = [];
|
|
160
172
|
|
|
161
|
-
for (const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
for (const [key, recordedSHA] of Object.entries(recorded)) {
|
|
174
|
+
if (!recordedSHA) continue; // no ownership baseline → nothing to remove
|
|
175
|
+
const parsed = parseExtKey(key, types);
|
|
176
|
+
if (!parsed) continue; // untrusted / cross-target key → never join+rm
|
|
177
|
+
const fullPath = join(targetRoot, parsed.type, parsed.installFile);
|
|
178
|
+
if (!existsSync(fullPath)) continue; // already gone
|
|
179
|
+
|
|
180
|
+
if (!isRegularFile(fullPath)) {
|
|
181
|
+
// Refuse to follow symlinks/sockets even under --force-extensions.
|
|
182
|
+
skippedNonRegular.push(fullPath);
|
|
168
183
|
continue;
|
|
169
184
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
skippedNonRegular.push(fullPath);
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
const buf = readFileIfRegular(fullPath);
|
|
183
|
-
const sha = buf ? sha256(buf) : null;
|
|
184
|
-
|
|
185
|
-
if (sha === recordedSHA || force) {
|
|
186
|
-
if (apply) rmSync(fullPath);
|
|
187
|
-
removed.push(fullPath);
|
|
188
|
-
removedKeys.push(key);
|
|
189
|
-
} else {
|
|
190
|
-
skippedUserModified.push(fullPath);
|
|
191
|
-
}
|
|
185
|
+
const buf = readFileIfRegular(fullPath);
|
|
186
|
+
const sha = buf ? sha256(buf) : null;
|
|
187
|
+
|
|
188
|
+
if (sha === recordedSHA || force) {
|
|
189
|
+
if (apply) rmSync(fullPath);
|
|
190
|
+
removed.push(fullPath);
|
|
191
|
+
removedKeys.push(key);
|
|
192
|
+
} else {
|
|
193
|
+
skippedUserModified.push(fullPath);
|
|
192
194
|
}
|
|
193
195
|
}
|
|
194
196
|
return { target, removed, removedKeys, skippedUserModified, skippedNonRegular };
|
|
@@ -226,12 +228,39 @@ function unprocessedExtensionTargetRemains(pkgPath, processedTargets) {
|
|
|
226
228
|
return false;
|
|
227
229
|
}
|
|
228
230
|
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
+
// Build the set of settings.json commands owned by reverse-captured hooks that
|
|
232
|
+
// install under their ORIGINAL name (not the hypo-ext-* storage name). The prefix
|
|
233
|
+
// scan below only reaches hypo-ext-* commands; a captured hook registers as
|
|
234
|
+
// `node $HOME/.claude/hooks/foo.mjs`, which no prefix would ever match. The
|
|
235
|
+
// recorded per-target SHA map names every file we own, so its hooks `.mjs` keys
|
|
236
|
+
// reconstruct exactly the commands we registered. Read WITHOUT mutation and while
|
|
237
|
+
// the map is still whole. stripExtensionsFromPkg (which clears it) runs later.
|
|
238
|
+
// Only `.mjs` keys are commands; the paired `.manifest.json` sidecar keys (which
|
|
239
|
+
// parseExtKey also admits for hooks) are dropped by the extension check.
|
|
240
|
+
function ownedHookCommands(pkgPath, target, hooksDir) {
|
|
241
|
+
const types = target === 'codex' ? CODEX_TYPES : EXT_TYPES;
|
|
242
|
+
const recorded = readExtensionPkgStateNoMutate(pkgPath, target);
|
|
243
|
+
const commands = new Set();
|
|
244
|
+
for (const key of Object.keys(recorded)) {
|
|
245
|
+
const parsed = parseExtKey(key, types);
|
|
246
|
+
if (!parsed || parsed.type !== 'hooks') continue;
|
|
247
|
+
if (!parsed.installFile.endsWith('.mjs')) continue;
|
|
248
|
+
commands.add(buildHookCommand(hooksDir, parsed.installFile));
|
|
249
|
+
}
|
|
250
|
+
return commands;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// settings.json: strip groups whose command is one of ours under the target's
|
|
254
|
+
// hooks dir. Ownership is the UNION of two path-based signals (plan §0 D1, P2):
|
|
255
|
+
// the hypo-ext-* command prefix (catches wiki-authored hooks and hypo-ext-*
|
|
256
|
+
// leftovers whose recorded key was already cleared by a partial uninstall) and
|
|
257
|
+
// the recorded owned-command set (`ownedCommands`, catches reverse-captured hooks
|
|
258
|
+
// registered under their original name, which carry no hypo-ext-* marker). Both
|
|
259
|
+
// branches are needed, so this is a union, not a replacement. No hookMap needed
|
|
231
260
|
// because ext hooks are never enumerated there. Mixed groups (foreign hook +
|
|
232
261
|
// ours) keep the foreign hook; ours-only groups are dropped entirely. Mirrors
|
|
233
262
|
// stripSettingsJson's flatMap pattern so other-plugin invariants (§7.3) hold.
|
|
234
|
-
function stripExtensionSettings(settingsPath, hooksDir, apply) {
|
|
263
|
+
function stripExtensionSettings(settingsPath, hooksDir, apply, ownedCommands = new Set()) {
|
|
235
264
|
if (!existsSync(settingsPath)) return { stripped: [] };
|
|
236
265
|
let settings;
|
|
237
266
|
try {
|
|
@@ -243,7 +272,10 @@ function stripExtensionSettings(settingsPath, hooksDir, apply) {
|
|
|
243
272
|
|
|
244
273
|
const cmdPrefix = `node ${hooksDir.replace(HOME, '$HOME')}/${EXT_PREFIX}`;
|
|
245
274
|
const isExtHook = (h) =>
|
|
246
|
-
h &&
|
|
275
|
+
h &&
|
|
276
|
+
h.type === 'command' &&
|
|
277
|
+
typeof h.command === 'string' &&
|
|
278
|
+
(h.command.startsWith(cmdPrefix) || ownedCommands.has(h.command));
|
|
247
279
|
|
|
248
280
|
const stripped = [];
|
|
249
281
|
let changed = false;
|
|
@@ -432,7 +464,12 @@ const commandResult = removeCommands(args.apply, args.forceCommands);
|
|
|
432
464
|
// hook copy was deleted by hand (force-commands legacy state).
|
|
433
465
|
const pkgJsonPath = join(HOME, '.claude', 'hypo-pkg.json');
|
|
434
466
|
const claudeExtResult = removeExtensions('claude', args.apply, args.forceExtensions);
|
|
435
|
-
const claudeExtSettings = stripExtensionSettings(
|
|
467
|
+
const claudeExtSettings = stripExtensionSettings(
|
|
468
|
+
claudeSettings,
|
|
469
|
+
claudeHooksDir,
|
|
470
|
+
args.apply,
|
|
471
|
+
ownedHookCommands(pkgJsonPath, 'claude', claudeHooksDir),
|
|
472
|
+
);
|
|
436
473
|
|
|
437
474
|
let codexHookResult = { removed: [], missing: [] };
|
|
438
475
|
let codexSettingsResult = { stripped: [] };
|
|
@@ -452,7 +489,12 @@ if (args.codex) {
|
|
|
452
489
|
codexHookResult = removeHookFiles(codexHooksDir, hookFiles, args.apply);
|
|
453
490
|
codexSettingsResult = stripSettingsJson(codexSettings, codexHooksDir, hookMap, args.apply);
|
|
454
491
|
codexExtResult = removeExtensions('codex', args.apply, args.forceExtensions);
|
|
455
|
-
codexExtSettings = stripExtensionSettings(
|
|
492
|
+
codexExtSettings = stripExtensionSettings(
|
|
493
|
+
codexSettings,
|
|
494
|
+
codexHooksDir,
|
|
495
|
+
args.apply,
|
|
496
|
+
ownedHookCommands(pkgJsonPath, 'codex', codexHooksDir),
|
|
497
|
+
);
|
|
456
498
|
}
|
|
457
499
|
|
|
458
500
|
// Surgical per-target ext SHA strip — only for files we actually removed.
|