memhook 0.3.0 → 0.4.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/CHANGELOG.md +14 -0
- package/README.md +30 -7
- package/dist/bin/memhook.d.ts +0 -1
- package/dist/bin/memhook.js +52 -2
- package/dist/src/ansi.d.ts +0 -1
- package/dist/src/ansi.js +0 -1
- package/dist/src/backup.d.ts +9 -0
- package/dist/src/backup.js +16 -0
- package/dist/src/cache.d.ts +0 -1
- package/dist/src/cache.js +0 -1
- package/dist/src/catalog.d.ts +0 -1
- package/dist/src/catalog.js +0 -1
- package/dist/src/config.d.ts +11 -1
- package/dist/src/config.js +6 -1
- package/dist/src/configFile.d.ts +6 -1
- package/dist/src/configFile.js +0 -1
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +7 -1
- package/dist/src/init.d.ts +3 -3
- package/dist/src/init.js +54 -11
- package/dist/src/install.d.ts +0 -1
- package/dist/src/install.js +0 -1
- package/dist/src/preFilter.d.ts +0 -1
- package/dist/src/preFilter.js +0 -1
- package/dist/src/providers/anthropic.d.ts +0 -1
- package/dist/src/providers/anthropic.js +0 -1
- package/dist/src/providers/factory.d.ts +0 -1
- package/dist/src/providers/factory.js +0 -1
- package/dist/src/providers/http.d.ts +0 -1
- package/dist/src/providers/http.js +0 -1
- package/dist/src/providers/ollama.d.ts +0 -1
- package/dist/src/providers/ollama.js +0 -1
- package/dist/src/providers/openai.d.ts +0 -1
- package/dist/src/providers/openai.js +0 -1
- package/dist/src/providers/types.d.ts +0 -1
- package/dist/src/providers/types.js +0 -1
- package/dist/src/router.d.ts +22 -1
- package/dist/src/router.js +105 -12
- package/dist/src/skills.d.ts +67 -0
- package/dist/src/skills.js +72 -0
- package/dist/src/skillsCmd.d.ts +50 -0
- package/dist/src/skillsCmd.js +272 -0
- package/dist/src/tail.d.ts +0 -1
- package/dist/src/tail.js +7 -4
- package/dist/src/version.d.ts +1 -2
- package/dist/src/version.js +1 -2
- package/package.json +5 -2
- package/skills/curate/SKILL.md +181 -0
- package/skills/curate/reference.md +105 -0
- package/skills/relay/SKILL.md +162 -0
- package/skills/wrap/SKILL.md +173 -0
- package/dist/bin/memhook.d.ts.map +0 -1
- package/dist/bin/memhook.js.map +0 -1
- package/dist/src/ansi.d.ts.map +0 -1
- package/dist/src/ansi.js.map +0 -1
- package/dist/src/cache.d.ts.map +0 -1
- package/dist/src/cache.js.map +0 -1
- package/dist/src/catalog.d.ts.map +0 -1
- package/dist/src/catalog.js.map +0 -1
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js.map +0 -1
- package/dist/src/configFile.d.ts.map +0 -1
- package/dist/src/configFile.js.map +0 -1
- package/dist/src/index.d.ts.map +0 -1
- package/dist/src/index.js.map +0 -1
- package/dist/src/init.d.ts.map +0 -1
- package/dist/src/init.js.map +0 -1
- package/dist/src/install.d.ts.map +0 -1
- package/dist/src/install.js.map +0 -1
- package/dist/src/preFilter.d.ts.map +0 -1
- package/dist/src/preFilter.js.map +0 -1
- package/dist/src/providers/anthropic.d.ts.map +0 -1
- package/dist/src/providers/anthropic.js.map +0 -1
- package/dist/src/providers/factory.d.ts.map +0 -1
- package/dist/src/providers/factory.js.map +0 -1
- package/dist/src/providers/http.d.ts.map +0 -1
- package/dist/src/providers/http.js.map +0 -1
- package/dist/src/providers/ollama.d.ts.map +0 -1
- package/dist/src/providers/ollama.js.map +0 -1
- package/dist/src/providers/openai.d.ts.map +0 -1
- package/dist/src/providers/openai.js.map +0 -1
- package/dist/src/providers/types.d.ts.map +0 -1
- package/dist/src/providers/types.js.map +0 -1
- package/dist/src/router.d.ts.map +0 -1
- package/dist/src/router.js.map +0 -1
- package/dist/src/tail.d.ts.map +0 -1
- package/dist/src/tail.js.map +0 -1
- package/dist/src/version.d.ts.map +0 -1
- package/dist/src/version.js.map +0 -1
package/dist/src/router.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Fail-soft: every error path falls back to empty additionalContext.
|
|
19
19
|
* Never blocks Claude Code.
|
|
20
20
|
*/
|
|
21
|
-
import { existsSync, readFileSync, openSync, fstatSync, closeSync, appendFileSync, mkdirSync, readdirSync, } from "node:fs";
|
|
21
|
+
import { existsSync, readFileSync, writeFileSync, openSync, fstatSync, closeSync, appendFileSync, mkdirSync, readdirSync, } from "node:fs";
|
|
22
22
|
import { join, dirname, basename } from "node:path";
|
|
23
23
|
import { LocalCache } from "./cache.js";
|
|
24
24
|
import { loadConfig } from "./config.js";
|
|
@@ -192,12 +192,19 @@ export async function route(stdinJson, env = process.env) {
|
|
|
192
192
|
additionalSizeTokensEst: Math.floor(additional.length / 4),
|
|
193
193
|
status,
|
|
194
194
|
});
|
|
195
|
-
|
|
195
|
+
const output = {
|
|
196
196
|
hookSpecificOutput: {
|
|
197
197
|
hookEventName: "UserPromptSubmit",
|
|
198
198
|
additionalContext: additional,
|
|
199
199
|
},
|
|
200
200
|
};
|
|
201
|
+
// Proactive `/curate` nudge — best-effort, local-only, never affects the
|
|
202
|
+
// additionalContext contract or fail-soft. `catalogContent` is already in hand
|
|
203
|
+
// (read above), so the catalog-size signal is free.
|
|
204
|
+
const nudge = maybeCurateNudge(config, catalogContent, Date.now());
|
|
205
|
+
if (nudge)
|
|
206
|
+
output.systemMessage = nudge;
|
|
207
|
+
return output;
|
|
201
208
|
}
|
|
202
209
|
function buildSystemPrompt(catalog) {
|
|
203
210
|
return `Tu es un sélecteur de mémoire pour Claude Code. Tu identifies les feedbacks et règles pertinents pour le prompt utilisateur. Tu réponds UNIQUEMENT avec un JSON array de basenames .md, sans explication, sans markdown code fence.
|
|
@@ -239,18 +246,29 @@ function parseBasenames(raw) {
|
|
|
239
246
|
}
|
|
240
247
|
function extractJsonArray(text) {
|
|
241
248
|
const flat = text.replace(/\n/g, " ");
|
|
242
|
-
const
|
|
243
|
-
if (!
|
|
249
|
+
const matches = flat.match(/\[[^\]]*\]/g);
|
|
250
|
+
if (!matches)
|
|
244
251
|
return null;
|
|
245
|
-
|
|
246
|
-
|
|
252
|
+
// A compliant response is a single bare array, but a model may wrap it in
|
|
253
|
+
// prose containing a decoy `[...]`. Scan every bracketed candidate and prefer
|
|
254
|
+
// the LAST one that yields usable string basenames; keep an empty array only
|
|
255
|
+
// as a fallback when no non-empty array is found, else null.
|
|
256
|
+
let result = null;
|
|
257
|
+
for (const candidate of matches) {
|
|
258
|
+
let parsed;
|
|
259
|
+
try {
|
|
260
|
+
parsed = JSON.parse(candidate);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
247
265
|
if (!Array.isArray(parsed))
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return null;
|
|
266
|
+
continue;
|
|
267
|
+
const strings = parsed.filter((x) => typeof x === "string");
|
|
268
|
+
if (strings.length > 0 || result === null)
|
|
269
|
+
result = strings;
|
|
253
270
|
}
|
|
271
|
+
return result;
|
|
254
272
|
}
|
|
255
273
|
function readSelected(basenames, cwd, config) {
|
|
256
274
|
// Build search dirs: ~/.claude/projects/*/memory + global rules + cwd rules.
|
|
@@ -261,11 +279,16 @@ function readSelected(basenames, cwd, config) {
|
|
|
261
279
|
let additional = "";
|
|
262
280
|
let injected = 0;
|
|
263
281
|
const seen = [];
|
|
282
|
+
const seenNames = new Set();
|
|
264
283
|
for (const name of basenames) {
|
|
265
284
|
if (injected >= config.selection.maxFiles)
|
|
266
285
|
break;
|
|
267
286
|
if (!SAFE_BASENAME_RE.test(name))
|
|
268
287
|
continue;
|
|
288
|
+
// De-dup: a basename the model repeats is injected once and uses one slot.
|
|
289
|
+
if (seenNames.has(name))
|
|
290
|
+
continue;
|
|
291
|
+
seenNames.add(name);
|
|
269
292
|
seen.push(name);
|
|
270
293
|
for (const dir of dirs) {
|
|
271
294
|
const file = join(dir, name);
|
|
@@ -323,6 +346,77 @@ function evictStale(config) {
|
|
|
323
346
|
// silent — eviction is best-effort
|
|
324
347
|
}
|
|
325
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Proactive `/curate` nudge. Returns a one-line `systemMessage` when the memory
|
|
351
|
+
* catalog has grown past a threshold and the cooldown has elapsed, else
|
|
352
|
+
* undefined. Local-only: it reads the already-loaded catalog length, counts
|
|
353
|
+
* memory files, and stamps a local cooldown file — NO outbound call. The whole
|
|
354
|
+
* body is wrapped so any failure yields no nudge (fail-soft is never affected).
|
|
355
|
+
*
|
|
356
|
+
* Cost: within the cooldown it pays one tiny stamp read and returns; only once
|
|
357
|
+
* the cooldown elapses does it count files (a readdir per memory dir, the same
|
|
358
|
+
* order of I/O the router already does in `readSelected`).
|
|
359
|
+
*
|
|
360
|
+
* Exported for direct unit testing.
|
|
361
|
+
*/
|
|
362
|
+
export function maybeCurateNudge(config, catalogContent, now) {
|
|
363
|
+
try {
|
|
364
|
+
if (!config.curateNudge.enabled)
|
|
365
|
+
return undefined;
|
|
366
|
+
const stampFile = join(config.cache.dir, ".curate-nudge");
|
|
367
|
+
const last = readNudgeStamp(stampFile);
|
|
368
|
+
if (last !== null && now - last < config.curateNudge.cooldownDays * 86_400_000) {
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const tokensEst = Math.floor(catalogContent.length / 4);
|
|
372
|
+
const fileCount = countMemoryFiles(config.searchDirs[0]);
|
|
373
|
+
const over = tokensEst >= config.curateNudge.thresholdTokens ||
|
|
374
|
+
fileCount >= config.curateNudge.thresholdFiles;
|
|
375
|
+
if (!over)
|
|
376
|
+
return undefined;
|
|
377
|
+
writeNudgeStamp(stampFile, now);
|
|
378
|
+
const tokK = tokensEst >= 10_000 ? Math.round(tokensEst / 1000) : (tokensEst / 1000).toFixed(1);
|
|
379
|
+
return `📚 memhook: memory catalog is large (~${tokK}k tokens, ${fileCount} files). Run /curate to prune duplicate and stale entries.`;
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
return undefined; // a nudge must never break fail-soft
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/** Count top-level `*.md` memory files (excludes MEMORY.md and the journal/ subdir). */
|
|
386
|
+
function countMemoryFiles(projectsRoot) {
|
|
387
|
+
let total = 0;
|
|
388
|
+
for (const dir of listProjectsMemoryDirs(projectsRoot)) {
|
|
389
|
+
let entries = [];
|
|
390
|
+
try {
|
|
391
|
+
entries = readdirSync(dir);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
for (const e of entries) {
|
|
397
|
+
if (e.endsWith(".md") && e !== "MEMORY.md")
|
|
398
|
+
total++;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return total;
|
|
402
|
+
}
|
|
403
|
+
function readNudgeStamp(file) {
|
|
404
|
+
try {
|
|
405
|
+
const n = Number(readFileSync(file, "utf8").trim());
|
|
406
|
+
return Number.isFinite(n) ? n : null;
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
return null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function writeNudgeStamp(file, now) {
|
|
413
|
+
try {
|
|
414
|
+
writeFileSync(file, String(now), "utf8");
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// best-effort — a missed stamp just means the nudge may repeat
|
|
418
|
+
}
|
|
419
|
+
}
|
|
326
420
|
function baseLog(prompt, status) {
|
|
327
421
|
return {
|
|
328
422
|
ts: nowIso(),
|
|
@@ -366,4 +460,3 @@ function logEntry(config, entry) {
|
|
|
366
460
|
function nowIso() {
|
|
367
461
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
368
462
|
}
|
|
369
|
-
//# sourceMappingURL=router.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning core for `memhook skills install|uninstall|list`.
|
|
3
|
+
*
|
|
4
|
+
* Like src/install.ts, this module has NO file I/O. The orchestration layer
|
|
5
|
+
* (src/skillsCmd.ts) reads the bundled skill files and the files already on
|
|
6
|
+
* disk, calls these pure planners, and applies the result with backups.
|
|
7
|
+
* Keeping the plan pure means the idempotency + non-clobbering guarantees are
|
|
8
|
+
* unit-tested without touching anyone's real `~/.claude/skills`.
|
|
9
|
+
*
|
|
10
|
+
* memhook ships three STANDALONE companion skills in Claude Code's skill format
|
|
11
|
+
* (`~/.claude/skills/<name>/SKILL.md`, invoked as `/<name>`):
|
|
12
|
+
*
|
|
13
|
+
* /wrap — end-of-session wrap-up (capture lessons into memory + journal)
|
|
14
|
+
* /curate — memory hygiene (dedupe, index sync, rebuild the catalog)
|
|
15
|
+
* /relay — generate a handoff prompt for a fresh session
|
|
16
|
+
*
|
|
17
|
+
* Standalone — not a plugin — so the command names stay bare (`/wrap`, not
|
|
18
|
+
* `/memhook:wrap`). See docs/SPECIFICATION.md "Companion skills".
|
|
19
|
+
*/
|
|
20
|
+
export declare const COMPANION_SKILLS: readonly ["wrap", "curate", "relay"];
|
|
21
|
+
export type CompanionSkill = (typeof COMPANION_SKILLS)[number];
|
|
22
|
+
/**
|
|
23
|
+
* Files bundled for each skill, relative to the skill's own directory. The
|
|
24
|
+
* orchestration layer reads `<sourceDir>/<name>/<relPath>` and writes
|
|
25
|
+
* `~/.claude/skills/<name>/<relPath>`.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SKILL_FILES: Record<CompanionSkill, readonly string[]>;
|
|
28
|
+
export declare function isCompanionSkill(name: string): name is CompanionSkill;
|
|
29
|
+
/** Bundled content for one skill, keyed by relative path. */
|
|
30
|
+
export type SkillSources = Record<string, string>;
|
|
31
|
+
/** Installed content per relative path; `null` means the file is not on disk. */
|
|
32
|
+
export type InstalledFiles = Record<string, string | null>;
|
|
33
|
+
export type SkillStatus = "absent" | "identical" | "differs";
|
|
34
|
+
export type InstallAction = "install" | "skip" | "overwrite" | "blocked";
|
|
35
|
+
/**
|
|
36
|
+
* Compare a skill's bundled files against what's on disk.
|
|
37
|
+
* - `absent` — none of the skill's files are installed.
|
|
38
|
+
* - `identical` — every bundled file is installed with matching content.
|
|
39
|
+
* - `differs` — installed but a file is missing or its content changed
|
|
40
|
+
* (a user edit, or an older shipped version).
|
|
41
|
+
*/
|
|
42
|
+
export declare function diffSkill(source: SkillSources, installed: InstalledFiles): SkillStatus;
|
|
43
|
+
export interface SkillInstallPlan {
|
|
44
|
+
name: CompanionSkill;
|
|
45
|
+
status: SkillStatus;
|
|
46
|
+
action: InstallAction;
|
|
47
|
+
/** Relative paths that will be written for `install` / `overwrite`. */
|
|
48
|
+
writes: string[];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Plan one skill install. Idempotent: an `identical` skill is skipped. A skill
|
|
52
|
+
* that `differs` is NEVER clobbered without `force` (it's reported `blocked`),
|
|
53
|
+
* matching install.ts's "back up + never overwrite silently" stance. With
|
|
54
|
+
* `force`, a differing skill is `overwrite` (the caller backs up first).
|
|
55
|
+
*/
|
|
56
|
+
export declare function planInstall(name: CompanionSkill, source: SkillSources, installed: InstalledFiles, opts: {
|
|
57
|
+
force: boolean;
|
|
58
|
+
}): SkillInstallPlan;
|
|
59
|
+
export interface SkillUninstallPlan {
|
|
60
|
+
name: CompanionSkill;
|
|
61
|
+
present: boolean;
|
|
62
|
+
action: "remove" | "skip";
|
|
63
|
+
/** Relative paths that exist on disk and will be deleted. */
|
|
64
|
+
removes: string[];
|
|
65
|
+
}
|
|
66
|
+
/** Plan one skill uninstall: remove only the files memhook ships, if present. */
|
|
67
|
+
export declare function planUninstall(name: CompanionSkill, source: SkillSources, installed: InstalledFiles): SkillUninstallPlan;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning core for `memhook skills install|uninstall|list`.
|
|
3
|
+
*
|
|
4
|
+
* Like src/install.ts, this module has NO file I/O. The orchestration layer
|
|
5
|
+
* (src/skillsCmd.ts) reads the bundled skill files and the files already on
|
|
6
|
+
* disk, calls these pure planners, and applies the result with backups.
|
|
7
|
+
* Keeping the plan pure means the idempotency + non-clobbering guarantees are
|
|
8
|
+
* unit-tested without touching anyone's real `~/.claude/skills`.
|
|
9
|
+
*
|
|
10
|
+
* memhook ships three STANDALONE companion skills in Claude Code's skill format
|
|
11
|
+
* (`~/.claude/skills/<name>/SKILL.md`, invoked as `/<name>`):
|
|
12
|
+
*
|
|
13
|
+
* /wrap — end-of-session wrap-up (capture lessons into memory + journal)
|
|
14
|
+
* /curate — memory hygiene (dedupe, index sync, rebuild the catalog)
|
|
15
|
+
* /relay — generate a handoff prompt for a fresh session
|
|
16
|
+
*
|
|
17
|
+
* Standalone — not a plugin — so the command names stay bare (`/wrap`, not
|
|
18
|
+
* `/memhook:wrap`). See docs/SPECIFICATION.md "Companion skills".
|
|
19
|
+
*/
|
|
20
|
+
export const COMPANION_SKILLS = ["wrap", "curate", "relay"];
|
|
21
|
+
/**
|
|
22
|
+
* Files bundled for each skill, relative to the skill's own directory. The
|
|
23
|
+
* orchestration layer reads `<sourceDir>/<name>/<relPath>` and writes
|
|
24
|
+
* `~/.claude/skills/<name>/<relPath>`.
|
|
25
|
+
*/
|
|
26
|
+
export const SKILL_FILES = {
|
|
27
|
+
wrap: ["SKILL.md"],
|
|
28
|
+
curate: ["SKILL.md", "reference.md"],
|
|
29
|
+
relay: ["SKILL.md"],
|
|
30
|
+
};
|
|
31
|
+
export function isCompanionSkill(name) {
|
|
32
|
+
return COMPANION_SKILLS.includes(name);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Compare a skill's bundled files against what's on disk.
|
|
36
|
+
* - `absent` — none of the skill's files are installed.
|
|
37
|
+
* - `identical` — every bundled file is installed with matching content.
|
|
38
|
+
* - `differs` — installed but a file is missing or its content changed
|
|
39
|
+
* (a user edit, or an older shipped version).
|
|
40
|
+
*/
|
|
41
|
+
export function diffSkill(source, installed) {
|
|
42
|
+
const rels = Object.keys(source);
|
|
43
|
+
const anyPresent = rels.some((r) => installed[r] != null);
|
|
44
|
+
if (!anyPresent)
|
|
45
|
+
return "absent";
|
|
46
|
+
const allMatch = rels.every((r) => installed[r] != null && installed[r] === source[r]);
|
|
47
|
+
return allMatch ? "identical" : "differs";
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Plan one skill install. Idempotent: an `identical` skill is skipped. A skill
|
|
51
|
+
* that `differs` is NEVER clobbered without `force` (it's reported `blocked`),
|
|
52
|
+
* matching install.ts's "back up + never overwrite silently" stance. With
|
|
53
|
+
* `force`, a differing skill is `overwrite` (the caller backs up first).
|
|
54
|
+
*/
|
|
55
|
+
export function planInstall(name, source, installed, opts) {
|
|
56
|
+
const status = diffSkill(source, installed);
|
|
57
|
+
let action;
|
|
58
|
+
if (status === "absent")
|
|
59
|
+
action = "install";
|
|
60
|
+
else if (status === "identical")
|
|
61
|
+
action = "skip";
|
|
62
|
+
else
|
|
63
|
+
action = opts.force ? "overwrite" : "blocked";
|
|
64
|
+
const writes = action === "install" || action === "overwrite" ? Object.keys(source) : [];
|
|
65
|
+
return { name, status, action, writes };
|
|
66
|
+
}
|
|
67
|
+
/** Plan one skill uninstall: remove only the files memhook ships, if present. */
|
|
68
|
+
export function planUninstall(name, source, installed) {
|
|
69
|
+
const removes = Object.keys(source).filter((r) => installed[r] != null);
|
|
70
|
+
const present = removes.length > 0;
|
|
71
|
+
return { name, present, action: present ? "remove" : "skip", removes };
|
|
72
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memhook skills install|uninstall|list` — copy the bundled companion skills
|
|
3
|
+
* (/wrap, /curate, /relay) into `~/.claude/skills/<name>/`.
|
|
4
|
+
*
|
|
5
|
+
* This is the I/O shell around the pure planner in src/skills.ts (same split as
|
|
6
|
+
* init.ts ↔ install.ts). All reads/writes/backups live here; the plan logic is
|
|
7
|
+
* pure and unit-tested. These are INTERACTIVE, user-invoked commands — not the
|
|
8
|
+
* hook path — so they may use the TTY and exit non-zero on user error
|
|
9
|
+
* (docs/SPECIFICATION.md §9). The one safety rule: never clobber a skill the
|
|
10
|
+
* user has edited without `--force`, and always back up before overwriting.
|
|
11
|
+
*/
|
|
12
|
+
import { type CompanionSkill, type SkillInstallPlan } from "./skills.js";
|
|
13
|
+
/**
|
|
14
|
+
* Directory holding the bundled skill sources. The module lives at `src/` in
|
|
15
|
+
* dev/tests and `dist/src/` once published, so the relative depth to the
|
|
16
|
+
* package-root `skills/` differs — walk up from the module dir until we find a
|
|
17
|
+
* `skills/wrap/SKILL.md`, which is unambiguous in either layout.
|
|
18
|
+
*/
|
|
19
|
+
export declare function bundledSkillsDir(): string;
|
|
20
|
+
export interface InstallSkillsOptions {
|
|
21
|
+
home?: string | undefined;
|
|
22
|
+
sourceDir?: string | undefined;
|
|
23
|
+
names?: CompanionSkill[] | undefined;
|
|
24
|
+
force?: boolean | undefined;
|
|
25
|
+
dryRun?: boolean | undefined;
|
|
26
|
+
}
|
|
27
|
+
export interface SkillInstallResult {
|
|
28
|
+
plan: SkillInstallPlan;
|
|
29
|
+
applied: boolean;
|
|
30
|
+
backedUp: string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Install (copy) the requested skills, backing up any file an `--force`
|
|
34
|
+
* overwrite would replace. Pure-of-prompts: callers handle confirmation + I/O
|
|
35
|
+
* reporting. Returns one result per requested skill.
|
|
36
|
+
*/
|
|
37
|
+
export declare function installCompanionSkills(opts?: InstallSkillsOptions): SkillInstallResult[];
|
|
38
|
+
export type SkillsSubcommand = "install" | "uninstall" | "list";
|
|
39
|
+
export interface RunSkillsOptions {
|
|
40
|
+
subcommand: SkillsSubcommand;
|
|
41
|
+
names?: CompanionSkill[] | undefined;
|
|
42
|
+
yes: boolean;
|
|
43
|
+
dryRun: boolean;
|
|
44
|
+
force: boolean;
|
|
45
|
+
/** Test seams. */
|
|
46
|
+
home?: string | undefined;
|
|
47
|
+
sourceDir?: string | undefined;
|
|
48
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
49
|
+
}
|
|
50
|
+
export declare function runSkills(opts: RunSkillsOptions): Promise<number>;
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `memhook skills install|uninstall|list` — copy the bundled companion skills
|
|
3
|
+
* (/wrap, /curate, /relay) into `~/.claude/skills/<name>/`.
|
|
4
|
+
*
|
|
5
|
+
* This is the I/O shell around the pure planner in src/skills.ts (same split as
|
|
6
|
+
* init.ts ↔ install.ts). All reads/writes/backups live here; the plan logic is
|
|
7
|
+
* pure and unit-tested. These are INTERACTIVE, user-invoked commands — not the
|
|
8
|
+
* hook path — so they may use the TTY and exit non-zero on user error
|
|
9
|
+
* (docs/SPECIFICATION.md §9). The one safety rule: never clobber a skill the
|
|
10
|
+
* user has edited without `--force`, and always back up before overwriting.
|
|
11
|
+
*/
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { createInterface } from "node:readline/promises";
|
|
17
|
+
import { makeAnsi } from "./ansi.js";
|
|
18
|
+
import { backupPath, stampNow } from "./backup.js";
|
|
19
|
+
import { COMPANION_SKILLS, SKILL_FILES, diffSkill, planInstall, planUninstall, } from "./skills.js";
|
|
20
|
+
/**
|
|
21
|
+
* Directory holding the bundled skill sources. The module lives at `src/` in
|
|
22
|
+
* dev/tests and `dist/src/` once published, so the relative depth to the
|
|
23
|
+
* package-root `skills/` differs — walk up from the module dir until we find a
|
|
24
|
+
* `skills/wrap/SKILL.md`, which is unambiguous in either layout.
|
|
25
|
+
*/
|
|
26
|
+
export function bundledSkillsDir() {
|
|
27
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
for (let i = 0; i < 6; i++) {
|
|
29
|
+
const candidate = join(dir, "skills");
|
|
30
|
+
if (existsSync(join(candidate, "wrap", "SKILL.md")))
|
|
31
|
+
return candidate;
|
|
32
|
+
const parent = dirname(dir);
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
// Fallback: best guess relative to the published layout (dist/src → root).
|
|
38
|
+
return fileURLToPath(new URL("../../skills/", import.meta.url));
|
|
39
|
+
}
|
|
40
|
+
function skillDir(home, name) {
|
|
41
|
+
return join(home, ".claude", "skills", name);
|
|
42
|
+
}
|
|
43
|
+
/** Read a skill's bundled files. Throws if a shipped file is missing (a packaging bug). */
|
|
44
|
+
function readSources(sourceDir, name) {
|
|
45
|
+
const dir = join(sourceDir, name);
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const rel of SKILL_FILES[name])
|
|
48
|
+
out[rel] = readFileSync(join(dir, rel), "utf8");
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
/** Read what's installed for a skill; a missing/unreadable file maps to `null`. */
|
|
52
|
+
function readInstalled(home, name) {
|
|
53
|
+
const dir = skillDir(home, name);
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const rel of SKILL_FILES[name]) {
|
|
56
|
+
try {
|
|
57
|
+
out[rel] = readFileSync(join(dir, rel), "utf8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
out[rel] = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Install (copy) the requested skills, backing up any file an `--force`
|
|
67
|
+
* overwrite would replace. Pure-of-prompts: callers handle confirmation + I/O
|
|
68
|
+
* reporting. Returns one result per requested skill.
|
|
69
|
+
*/
|
|
70
|
+
export function installCompanionSkills(opts = {}) {
|
|
71
|
+
const home = opts.home ?? homedir();
|
|
72
|
+
const sourceDir = opts.sourceDir ?? bundledSkillsDir();
|
|
73
|
+
const names = opts.names ?? [...COMPANION_SKILLS];
|
|
74
|
+
const force = opts.force ?? false;
|
|
75
|
+
const dryRun = opts.dryRun ?? false;
|
|
76
|
+
const stamp = stampNow();
|
|
77
|
+
const results = [];
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
const source = readSources(sourceDir, name);
|
|
80
|
+
const installed = readInstalled(home, name);
|
|
81
|
+
const plan = planInstall(name, source, installed, { force });
|
|
82
|
+
const backedUp = [];
|
|
83
|
+
let applied = false;
|
|
84
|
+
if (!dryRun && (plan.action === "install" || plan.action === "overwrite")) {
|
|
85
|
+
const dir = skillDir(home, name);
|
|
86
|
+
for (const rel of plan.writes) {
|
|
87
|
+
const dest = join(dir, rel);
|
|
88
|
+
if (plan.action === "overwrite" && installed[rel] != null) {
|
|
89
|
+
const bak = backupPath(dest, stamp);
|
|
90
|
+
try {
|
|
91
|
+
copyFileSync(dest, bak);
|
|
92
|
+
backedUp.push(bak);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* best-effort backup; never abort the install on a backup miss */
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
99
|
+
writeFileSync(dest, source[rel], "utf8");
|
|
100
|
+
}
|
|
101
|
+
applied = true;
|
|
102
|
+
}
|
|
103
|
+
results.push({ plan, applied, backedUp });
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
function makeIo(env) {
|
|
108
|
+
const ansi = makeAnsi({ isTTY: Boolean(process.stdout.isTTY), env });
|
|
109
|
+
return { out: (s) => process.stdout.write(s + "\n"), ansi };
|
|
110
|
+
}
|
|
111
|
+
const STATUS_LABEL = {
|
|
112
|
+
absent: "not installed",
|
|
113
|
+
identical: "installed (up to date)",
|
|
114
|
+
differs: "installed (differs from shipped)",
|
|
115
|
+
};
|
|
116
|
+
export async function runSkills(opts) {
|
|
117
|
+
const env = opts.env ?? process.env;
|
|
118
|
+
const io = makeIo(env);
|
|
119
|
+
const { ansi } = io;
|
|
120
|
+
const home = opts.home ?? homedir();
|
|
121
|
+
const sourceDir = opts.sourceDir ?? bundledSkillsDir();
|
|
122
|
+
const names = opts.names && opts.names.length > 0 ? opts.names : [...COMPANION_SKILLS];
|
|
123
|
+
const interactive = !opts.yes && Boolean(process.stdin.isTTY) && !opts.dryRun;
|
|
124
|
+
if (opts.subcommand === "list") {
|
|
125
|
+
io.out(ansi.bold("memhook companion skills"));
|
|
126
|
+
for (const name of names) {
|
|
127
|
+
const status = diffSkill(readSources(sourceDir, name), readInstalled(home, name));
|
|
128
|
+
const dot = status === "identical"
|
|
129
|
+
? ansi.green("●")
|
|
130
|
+
: status === "differs"
|
|
131
|
+
? ansi.yellow("●")
|
|
132
|
+
: ansi.dim("○");
|
|
133
|
+
io.out(` ${dot} /${name} ${ansi.dim(`— ${STATUS_LABEL[status]}`)}`);
|
|
134
|
+
io.out(` ${ansi.dim(skillDir(home, name))}`);
|
|
135
|
+
}
|
|
136
|
+
io.out(ansi.dim("\nInstall with `memhook skills install`. Invoke as /wrap, /curate, /relay."));
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
if (opts.subcommand === "uninstall") {
|
|
140
|
+
const plans = names.map((name) => planUninstall(name, readSources(sourceDir, name), readInstalled(home, name)));
|
|
141
|
+
const toRemove = plans.filter((p) => p.present);
|
|
142
|
+
io.out(ansi.bold("memhook skills uninstall") + ansi.dim(" — remove bundled companion skills\n"));
|
|
143
|
+
if (toRemove.length === 0) {
|
|
144
|
+
io.out(ansi.dim("No memhook companion skills found. Nothing to do."));
|
|
145
|
+
return 0;
|
|
146
|
+
}
|
|
147
|
+
io.out(ansi.bold("Plan"));
|
|
148
|
+
for (const p of plans) {
|
|
149
|
+
if (p.present) {
|
|
150
|
+
io.out(` ${ansi.red("-")} /${p.name} ${ansi.dim(`(${p.removes.length} file(s))`)}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
io.out(` ${ansi.dim("·")} /${p.name} ${ansi.dim("(not installed — skip)")}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (opts.dryRun) {
|
|
157
|
+
io.out(ansi.dim("\n(dry run — nothing removed)"));
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
if (interactive && !(await confirm(ansi, "[y/N]", false))) {
|
|
161
|
+
io.out(ansi.dim("Aborted. Nothing removed."));
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
const stamp = stampNow();
|
|
165
|
+
const skillsRoot = join(home, ".claude", "skills");
|
|
166
|
+
let removed = 0;
|
|
167
|
+
for (const p of toRemove) {
|
|
168
|
+
const dir = skillDir(home, p.name);
|
|
169
|
+
const source = readSources(sourceDir, p.name);
|
|
170
|
+
for (const rel of p.removes) {
|
|
171
|
+
const target = join(dir, rel);
|
|
172
|
+
// Back up only a file the user edited (differs from shipped), and place
|
|
173
|
+
// the backup OUTSIDE the skill dir so the dir can be cleanly removed. A
|
|
174
|
+
// pristine shipped file is just deleted — it's recoverable by reinstall.
|
|
175
|
+
let content = null;
|
|
176
|
+
try {
|
|
177
|
+
content = readFileSync(target, "utf8");
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* already gone */
|
|
181
|
+
}
|
|
182
|
+
if (content !== null && content !== source[rel]) {
|
|
183
|
+
const bak = join(skillsRoot, `${p.name}.${rel.replace(/[\\/]/g, "_")}.bak-${stamp}`);
|
|
184
|
+
try {
|
|
185
|
+
copyFileSync(target, bak);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* best-effort backup */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(target);
|
|
193
|
+
removed++;
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* already gone */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
try {
|
|
200
|
+
rmdirSync(dir); // only succeeds if the dir is now empty — leaves user files alone
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
/* non-empty or missing — fine */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
io.out(`${ansi.green("✓")} removed ${removed} file(s) from ${toRemove.length} skill(s)`);
|
|
207
|
+
io.out(ansi.dim("Restart Claude Code to drop the skills from the menu."));
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
// install
|
|
211
|
+
io.out(ansi.bold("memhook skills install") + ansi.dim(" — copy /wrap, /curate, /relay\n"));
|
|
212
|
+
const preview = installCompanionSkills({
|
|
213
|
+
home,
|
|
214
|
+
sourceDir,
|
|
215
|
+
names,
|
|
216
|
+
force: opts.force,
|
|
217
|
+
dryRun: true,
|
|
218
|
+
});
|
|
219
|
+
const willWrite = preview.filter((r) => r.plan.action === "install" || r.plan.action === "overwrite");
|
|
220
|
+
const blocked = preview.filter((r) => r.plan.action === "blocked");
|
|
221
|
+
io.out(ansi.bold("Plan"));
|
|
222
|
+
for (const r of preview) {
|
|
223
|
+
const { name, action, status } = r.plan;
|
|
224
|
+
if (action === "install")
|
|
225
|
+
io.out(` ${ansi.green("+")} /${name} ${ansi.dim("(new)")}`);
|
|
226
|
+
else if (action === "overwrite")
|
|
227
|
+
io.out(` ${ansi.yellow("~")} /${name} ${ansi.dim("(overwrite — backup first)")}`);
|
|
228
|
+
else if (action === "skip")
|
|
229
|
+
io.out(` ${ansi.dim("·")} /${name} ${ansi.dim("(up to date — skip)")}`);
|
|
230
|
+
else
|
|
231
|
+
io.out(` ${ansi.yellow("!")} /${name} ${ansi.dim(`(${STATUS_LABEL[status]} — use --force to overwrite)`)}`);
|
|
232
|
+
}
|
|
233
|
+
if (willWrite.length === 0) {
|
|
234
|
+
if (blocked.length > 0) {
|
|
235
|
+
io.out(ansi.dim(`\n${blocked.length} skill(s) differ from shipped. Re-run with --force to overwrite (a backup is made first).`));
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
io.out(ansi.dim("\nAll skills are already up to date. Nothing to do."));
|
|
239
|
+
}
|
|
240
|
+
return 0;
|
|
241
|
+
}
|
|
242
|
+
if (opts.dryRun) {
|
|
243
|
+
io.out(ansi.dim("\n(dry run — nothing written)"));
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
if (interactive && !(await confirm(ansi, "[Y/n]", true))) {
|
|
247
|
+
io.out(ansi.dim("Aborted. Nothing written."));
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
const results = installCompanionSkills({ home, sourceDir, names, force: opts.force });
|
|
251
|
+
const applied = results.filter((r) => r.applied);
|
|
252
|
+
io.out(`${ansi.green("✓")} installed ${applied.length} skill(s) into ${skillDir(home, "<name>")}`);
|
|
253
|
+
if (blocked.length > 0) {
|
|
254
|
+
io.out(ansi.dim(` ${blocked.length} skill(s) left untouched (differ from shipped — use --force).`));
|
|
255
|
+
}
|
|
256
|
+
io.out(ansi.dim("Restart Claude Code, then use /wrap, /curate, /relay."));
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
async function confirm(ansi, hint, defaultYes) {
|
|
260
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
261
|
+
try {
|
|
262
|
+
const a = (await rl.question(`\n${ansi.bold("Proceed?")} ${ansi.dim(hint)} `))
|
|
263
|
+
.trim()
|
|
264
|
+
.toLowerCase();
|
|
265
|
+
if (a === "")
|
|
266
|
+
return defaultYes;
|
|
267
|
+
return a === "y" || a === "yes";
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
rl.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
package/dist/src/tail.d.ts
CHANGED
package/dist/src/tail.js
CHANGED
|
@@ -227,10 +227,15 @@ export async function runTail(opts, env = process.env) {
|
|
|
227
227
|
if (passesFilter(row, filter))
|
|
228
228
|
out(formatRow(row, ansi, columns));
|
|
229
229
|
};
|
|
230
|
-
// Initial tail of the existing log.
|
|
230
|
+
// Initial tail of the existing log. Capture the follow offset from the SAME
|
|
231
|
+
// buffer rendered here, so a line appended between this read and the offset
|
|
232
|
+
// snapshot can't be skipped in the live view.
|
|
233
|
+
let offset = 0;
|
|
231
234
|
if (existsSync(logPath)) {
|
|
232
|
-
|
|
235
|
+
const initial = readFileSync(logPath, "utf8");
|
|
236
|
+
for (const line of tailLines(initial, opts.lines))
|
|
233
237
|
render(line);
|
|
238
|
+
offset = Buffer.byteLength(initial, "utf8");
|
|
234
239
|
}
|
|
235
240
|
if (opts.noFollow) {
|
|
236
241
|
out(formatFooter(stats, ansi, columns));
|
|
@@ -246,7 +251,6 @@ export async function runTail(opts, env = process.env) {
|
|
|
246
251
|
});
|
|
247
252
|
if (!existsSync(logPath))
|
|
248
253
|
out(ansi.dim(" waiting for the first prompt…"));
|
|
249
|
-
let offset = existsSync(logPath) ? statSync(logPath).size : 0;
|
|
250
254
|
let buffer = "";
|
|
251
255
|
while (!stop) {
|
|
252
256
|
await Promise.race([sleep(POLL_MS), stopped]);
|
|
@@ -277,4 +281,3 @@ export async function runTail(opts, env = process.env) {
|
|
|
277
281
|
out("\n" + formatFooter(stats, ansi, columns));
|
|
278
282
|
return 0;
|
|
279
283
|
}
|
|
280
|
-
//# sourceMappingURL=tail.js.map
|
package/dist/src/version.d.ts
CHANGED