@vkmikc/create-vkm-kit 4.2.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/LICENSE.md +44 -0
- package/README.md +148 -0
- package/package.json +57 -0
- package/src/asset-install.mjs +109 -0
- package/src/claude-native-memory.mjs +507 -0
- package/src/file-perms.mjs +53 -0
- package/src/hooks/_transcript-cache.mjs +223 -0
- package/src/hooks/compact-mcp-output.mjs +87 -0
- package/src/hooks/compact-tool-output.mjs +177 -0
- package/src/hooks/ensure-otel-sink.mjs +51 -0
- package/src/hooks/guard-effort-gate.mjs +209 -0
- package/src/hooks/guard-native-memory-write.mjs +129 -0
- package/src/hooks/session-start-vault-context.mjs +200 -0
- package/src/hooks/stop-vault-close-reminder.mjs +150 -0
- package/src/index.js +1547 -0
- package/src/mcp-merge.mjs +279 -0
- package/src/memory-rules.mjs +205 -0
- package/src/obscura-setup.mjs +272 -0
- package/src/ollama-setup.mjs +126 -0
- package/src/rules-merge.mjs +106 -0
- package/src/settings-io.mjs +193 -0
- package/src/settings-writers.mjs +150 -0
- package/src/skills-install.mjs +96 -0
- package/src/telemetry.mjs +154 -0
- package/src/token-saver.mjs +248 -0
- package/templates/agents/vkm-implementer.md +23 -0
- package/templates/output-styles/vkm-terse.md +23 -0
- package/templates/skills/vkm-discipline/SKILL.md +77 -0
- package/templates/skills/vkm-discipline/domains/coding.md +39 -0
- package/templates/skills/vkm-discipline/domains/data.md +37 -0
- package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
- package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
- package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
- package/templates/skills/vkm-discipline/domains/infra.md +35 -0
- package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
- package/templates/skills/vkm-discipline/domains/security.md +37 -0
- package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
- package/templates/skills/vkm-discipline/domains/writing.md +32 -0
- package/templates/skills/vkm-spec/SKILL.md +33 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
// Make the Obsidian vault Claude Code's ONLY memory: turn OFF the native per-project
|
|
2
|
+
// auto-memory (`autoMemoryEnabled:false`) and install a `SessionStart` hook that injects
|
|
3
|
+
// the vault map + the "vault is the source of truth" reminders.
|
|
4
|
+
//
|
|
5
|
+
// Why: Claude Code ships a native auto-memory (`~/.claude/projects/<enc>/memory/MEMORY.md`)
|
|
6
|
+
// that the harness auto-loads and the base system prompt tells the model to WRITE with the
|
|
7
|
+
// `Write` tool. It competes with the vault and the native side wins by default (it's in the
|
|
8
|
+
// base prompt with `Write` always available, while the `vault_*` MCP tools are often
|
|
9
|
+
// deferred). Disabling it + a reinforced SessionStart reminder makes the vault win out of
|
|
10
|
+
// the box. See ADR-0029.
|
|
11
|
+
//
|
|
12
|
+
// Idempotent: merges `~/.claude/settings.json` without clobbering other keys or hooks, and
|
|
13
|
+
// REPLACES (never duplicates) our managed `SessionStart` entry — re-runs and an older
|
|
14
|
+
// PowerShell variant of the hook are both recognized by the shared filename stem.
|
|
15
|
+
//
|
|
16
|
+
// ADR-0030 adds two more managed hooks (on by default, `enforce` opt-out) so the doctrine
|
|
17
|
+
// holds even when the driving model doesn't reliably honor a prose rule: a `PreToolUse`
|
|
18
|
+
// guard that DENIES Write/Edit/MultiEdit/NotebookEdit into the native auto-memory directory,
|
|
19
|
+
// and a `Stop` nudge that reminds the close ritual once per turn when the session did
|
|
20
|
+
// substantive file work but never touched the vault.
|
|
21
|
+
//
|
|
22
|
+
// ADR-0031 adds a third, independently-toggleable managed hook (on by default, `effortGate`
|
|
23
|
+
// opt-out): a `PreToolUse` gate that DENIES a session's 2nd+ substantive edit until the model
|
|
24
|
+
// proposed an effort level and got a real reply from the user — same "deterministic beats a
|
|
25
|
+
// prose rule" reasoning, aimed at a different failure mode (the model announcing a pause and
|
|
26
|
+
// not actually taking one).
|
|
27
|
+
//
|
|
28
|
+
// Install/remove is SYMMETRIC (audit finding, 2026-07): every call to
|
|
29
|
+
// `configureClaudeNativeMemory` fully reconciles the desired state — pieces that are wanted
|
|
30
|
+
// get merged in, pieces that are NOT wanted get actively stripped out (not just skipped),
|
|
31
|
+
// so a `--no-effort-gate` re-run after a prior install with the effort gate on actually
|
|
32
|
+
// removes that hook entry instead of leaving it orphaned. `uninstallClaudeNativeMemory`
|
|
33
|
+
// builds on the same removal path plus deletes the hook script files themselves, but only
|
|
34
|
+
// when a marker check proves this kit wrote them (never a user's own same-named file).
|
|
35
|
+
import path from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import fse from "fs-extra";
|
|
38
|
+
import pc from "picocolors";
|
|
39
|
+
import {
|
|
40
|
+
atomicWriteJson,
|
|
41
|
+
backupRestricted,
|
|
42
|
+
hasManagedHook,
|
|
43
|
+
isKitOwnedFile,
|
|
44
|
+
mergeManagedHook,
|
|
45
|
+
readSettingsSafe,
|
|
46
|
+
removeManagedHook,
|
|
47
|
+
setOrDeleteHooks
|
|
48
|
+
} from "./settings-io.mjs";
|
|
49
|
+
|
|
50
|
+
/** Shared stem so re-runs (and a legacy `.ps1` install) match our managed entry. */
|
|
51
|
+
export const HOOK_STEM = "session-start-vault-context";
|
|
52
|
+
export const HOOK_BASENAME = `${HOOK_STEM}.mjs`;
|
|
53
|
+
|
|
54
|
+
/** PreToolUse guard (ADR-0030): denies Write/Edit/MultiEdit/NotebookEdit into the native
|
|
55
|
+
* auto-memory directory, independent of whether the model honored the CLAUDE.md rule. */
|
|
56
|
+
export const GUARD_HOOK_STEM = "guard-native-memory-write";
|
|
57
|
+
export const GUARD_HOOK_BASENAME = `${GUARD_HOOK_STEM}.mjs`;
|
|
58
|
+
|
|
59
|
+
/** Stop nudge (ADR-0030): reminds the close ritual once per turn when the session did
|
|
60
|
+
* substantive file work but never touched the vault. */
|
|
61
|
+
export const STOP_HOOK_STEM = "stop-vault-close-reminder";
|
|
62
|
+
export const STOP_HOOK_BASENAME = `${STOP_HOOK_STEM}.mjs`;
|
|
63
|
+
|
|
64
|
+
/** Effort gate (ADR-0031): denies a session's 2nd+ substantive edit until the model
|
|
65
|
+
* proposed an effort level and got a real reply from the user. Independently toggleable
|
|
66
|
+
* from the `enforce` pair above — a user may want one without the other. */
|
|
67
|
+
export const EFFORT_GATE_HOOK_STEM = "guard-effort-gate";
|
|
68
|
+
export const EFFORT_GATE_HOOK_BASENAME = `${EFFORT_GATE_HOOK_STEM}.mjs`;
|
|
69
|
+
|
|
70
|
+
/** Shared sidecar-cache engine (item 3 / perf fix) the Stop nudge and effort-gate hooks both
|
|
71
|
+
* import as a sibling module — not a hook Claude Code invokes itself, but installed and
|
|
72
|
+
* removed alongside whichever of the two needs it (see `configureClaudeNativeMemory`). */
|
|
73
|
+
export const TRANSCRIPT_CACHE_BASENAME = "_transcript-cache.mjs";
|
|
74
|
+
|
|
75
|
+
/** All filename stems this kit manages in `~/.claude/settings.json` hooks. Used both to
|
|
76
|
+
* detect "is there anything of ours to remove" and by `--uninstall`'s file-marker check. */
|
|
77
|
+
const ALL_HOOK_STEMS = [HOOK_STEM, GUARD_HOOK_STEM, STOP_HOOK_STEM, EFFORT_GATE_HOOK_STEM];
|
|
78
|
+
|
|
79
|
+
/** Source hook shipped inside the published package (`src/hooks/`). */
|
|
80
|
+
function packagedHookPath(basename = HOOK_BASENAME) {
|
|
81
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "hooks", basename);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The `SessionStart` hook entry: exec form (`command` + `args`, no shell involved), so a
|
|
86
|
+
* vault path containing a quote, a backtick, or a trailing backslash is passed through as
|
|
87
|
+
* one literal argv element — nothing to escape, nothing to get wrong. (Prior versions built
|
|
88
|
+
* a single interpolated command STRING here — `node "<hook>" "<vault>" <lang>` — which broke
|
|
89
|
+
* on exactly those inputs, e.g. a trailing backslash before the closing quote swallowed it.
|
|
90
|
+
* Claude Code's hook schema supports this exec form for any `command` that resolves to a
|
|
91
|
+
* real executable, which `node` always does, cross-platform.)
|
|
92
|
+
* @param {string} hookPath
|
|
93
|
+
* @param {string} vaultAbs
|
|
94
|
+
* @param {string} [lang]
|
|
95
|
+
* @returns {{ command: string, args: string[] }}
|
|
96
|
+
*/
|
|
97
|
+
export function hookCommand(hookPath, vaultAbs, lang = "es") {
|
|
98
|
+
return { command: "node", args: [hookPath, vaultAbs, lang === "en" ? "en" : "es"] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The `PreToolUse` guard hook entry: `node "<hook>" "<claudeDir>" <lang>` (exec form). */
|
|
102
|
+
export function guardHookCommand(hookPath, claudeDir, lang = "es") {
|
|
103
|
+
return { command: "node", args: [hookPath, claudeDir, lang === "en" ? "en" : "es"] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `Stop` nudge hook entry (exec form; no path arg — it reads the transcript path
|
|
107
|
+
* Claude Code passes on stdin). */
|
|
108
|
+
export function stopHookCommand(hookPath, lang = "es") {
|
|
109
|
+
return { command: "node", args: [hookPath, lang === "en" ? "en" : "es"] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The effort-gate hook entry (exec form; same shape as the Stop nudge — it also reads
|
|
113
|
+
* `transcript_path` from stdin, not argv). */
|
|
114
|
+
export function effortGateHookCommand(hookPath, lang = "es") {
|
|
115
|
+
return { command: "node", args: [hookPath, lang === "en" ? "en" : "es"] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The shared merge/remove/read/write primitives (hookEntryMatchesStem, mergeManagedHook,
|
|
119
|
+
// removeManagedHook, setOrDeleteHooks, hasManagedHook, readSettingsSafe, backupRestricted,
|
|
120
|
+
// atomicWriteJson, isKitOwnedFile, KIT_FILE_MARKER) live in `settings-io.mjs` — extracted
|
|
121
|
+
// there (Phase 0 groundwork) so other installer modules reuse the same idiom.
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pure merge: return a NEW settings object with the native auto-memory disabled and our
|
|
125
|
+
* managed `SessionStart` hook present exactly once. Preserves every other key, every other
|
|
126
|
+
* hook event, and every unrelated `SessionStart` entry.
|
|
127
|
+
* @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
|
|
128
|
+
* @param {{ command: string, args: string[] }} hookEntry - from {@link hookCommand}
|
|
129
|
+
* @returns {Record<string, unknown>}
|
|
130
|
+
*/
|
|
131
|
+
export function mergeClaudeSettings(existing, hookEntry) {
|
|
132
|
+
const settings =
|
|
133
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
134
|
+
// The whole point of the override: force it off (documented key; default is true).
|
|
135
|
+
settings.autoMemoryEnabled = false;
|
|
136
|
+
|
|
137
|
+
const hooks =
|
|
138
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
139
|
+
? settings.hooks
|
|
140
|
+
: {};
|
|
141
|
+
settings.hooks = mergeManagedHook(hooks, "SessionStart", "*", hookEntry, HOOK_STEM);
|
|
142
|
+
return settings;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The removal counterpart to {@link mergeClaudeSettings}: drop the managed `SessionStart`
|
|
147
|
+
* entry, and drop the `autoMemoryEnabled` override too — but ONLY when it's still exactly
|
|
148
|
+
* `false`, i.e. the value this kit itself would have set. If the user (or something else)
|
|
149
|
+
* has since set it to `true`, that's left alone rather than silently re-toggled; we only
|
|
150
|
+
* ever reverse a value we can prove is ours.
|
|
151
|
+
* @param {unknown} existing
|
|
152
|
+
* @returns {Record<string, unknown>}
|
|
153
|
+
*/
|
|
154
|
+
export function removeSessionStartHook(existing) {
|
|
155
|
+
const settings =
|
|
156
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
157
|
+
if (settings.autoMemoryEnabled === false) {
|
|
158
|
+
delete settings.autoMemoryEnabled;
|
|
159
|
+
}
|
|
160
|
+
const hadHooks =
|
|
161
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
|
|
162
|
+
const hooks = hadHooks ? settings.hooks : {};
|
|
163
|
+
return setOrDeleteHooks(
|
|
164
|
+
settings,
|
|
165
|
+
Boolean(hadHooks),
|
|
166
|
+
removeManagedHook(hooks, "SessionStart", HOOK_STEM)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Pure merge (ADR-0030): add the deterministic enforcement hooks — a `PreToolUse` guard
|
|
172
|
+
* that denies writes into the native auto-memory directory, and a `Stop` nudge that
|
|
173
|
+
* reminds the close ritual once per turn. Either command may be omitted to install just
|
|
174
|
+
* one. Idempotent and dedup'd the same way as {@link mergeClaudeSettings}.
|
|
175
|
+
* @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
|
|
176
|
+
* @param {{ guardCommand?: {command:string,args:string[]}|null, stopCommand?: {command:string,args:string[]}|null }} commands
|
|
177
|
+
* @returns {Record<string, unknown>}
|
|
178
|
+
*/
|
|
179
|
+
export function mergeEnforcementHooks(existing, { guardCommand, stopCommand } = {}) {
|
|
180
|
+
const settings =
|
|
181
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
182
|
+
let hooks =
|
|
183
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
184
|
+
? settings.hooks
|
|
185
|
+
: {};
|
|
186
|
+
if (guardCommand) {
|
|
187
|
+
hooks = mergeManagedHook(
|
|
188
|
+
hooks,
|
|
189
|
+
"PreToolUse",
|
|
190
|
+
"Write|Edit|MultiEdit|NotebookEdit",
|
|
191
|
+
guardCommand,
|
|
192
|
+
GUARD_HOOK_STEM
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
if (stopCommand) {
|
|
196
|
+
hooks = mergeManagedHook(hooks, "Stop", "*", stopCommand, STOP_HOOK_STEM);
|
|
197
|
+
}
|
|
198
|
+
settings.hooks = hooks;
|
|
199
|
+
return settings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The removal counterpart to {@link mergeEnforcementHooks}: strip both the guard and stop
|
|
204
|
+
* entries unconditionally (a no-op for whichever, or both, aren't present).
|
|
205
|
+
* @param {unknown} existing
|
|
206
|
+
* @returns {Record<string, unknown>}
|
|
207
|
+
*/
|
|
208
|
+
export function removeEnforcementHooks(existing) {
|
|
209
|
+
const settings =
|
|
210
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
211
|
+
const hadHooks =
|
|
212
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
|
|
213
|
+
let hooks = hadHooks ? settings.hooks : {};
|
|
214
|
+
hooks = removeManagedHook(hooks, "PreToolUse", GUARD_HOOK_STEM);
|
|
215
|
+
hooks = removeManagedHook(hooks, "Stop", STOP_HOOK_STEM);
|
|
216
|
+
return setOrDeleteHooks(settings, Boolean(hadHooks), hooks);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Pure merge (ADR-0031): add the effort-gate `PreToolUse` hook. Kept as its OWN function
|
|
221
|
+
* (not folded into {@link mergeEnforcementHooks}) so it's independently toggleable — same
|
|
222
|
+
* reasoning ADR-0030 already used to keep the native-memory override separate from its own
|
|
223
|
+
* hooks. Registers under the same matcher as the native-memory guard; the two coexist as
|
|
224
|
+
* separate `hooks.PreToolUse` entries (deduped by stem, not by matcher) and either may deny
|
|
225
|
+
* independently. Idempotent and dedup'd the same way as {@link mergeClaudeSettings}.
|
|
226
|
+
* @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
|
|
227
|
+
* @param {{ effortGateCommand?: {command:string,args:string[]}|null }} commands
|
|
228
|
+
* @returns {Record<string, unknown>}
|
|
229
|
+
*/
|
|
230
|
+
export function mergeEffortGateHook(existing, { effortGateCommand } = {}) {
|
|
231
|
+
const settings =
|
|
232
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
233
|
+
let hooks =
|
|
234
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
|
|
235
|
+
? settings.hooks
|
|
236
|
+
: {};
|
|
237
|
+
if (effortGateCommand) {
|
|
238
|
+
hooks = mergeManagedHook(
|
|
239
|
+
hooks,
|
|
240
|
+
"PreToolUse",
|
|
241
|
+
"Write|Edit|MultiEdit|NotebookEdit",
|
|
242
|
+
effortGateCommand,
|
|
243
|
+
EFFORT_GATE_HOOK_STEM
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
settings.hooks = hooks;
|
|
247
|
+
return settings;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The removal counterpart to {@link mergeEffortGateHook}: strip the effort-gate entry (a
|
|
252
|
+
* no-op if it isn't present).
|
|
253
|
+
* @param {unknown} existing
|
|
254
|
+
* @returns {Record<string, unknown>}
|
|
255
|
+
*/
|
|
256
|
+
export function removeEffortGateHook(existing) {
|
|
257
|
+
const settings =
|
|
258
|
+
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing } : {};
|
|
259
|
+
const hadHooks =
|
|
260
|
+
settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks);
|
|
261
|
+
const hooks = hadHooks ? settings.hooks : {};
|
|
262
|
+
return setOrDeleteHooks(
|
|
263
|
+
settings,
|
|
264
|
+
Boolean(hadHooks),
|
|
265
|
+
removeManagedHook(hooks, "PreToolUse", EFFORT_GATE_HOOK_STEM)
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Install (or, when a piece isn't wanted, actively REMOVE) the Claude Code native-memory
|
|
271
|
+
* override: copy the hook(s) into `~/.claude/hooks/`, then reconcile `autoMemoryEnabled` +
|
|
272
|
+
* the hook registrations in `~/.claude/settings.json` to match exactly what's wanted.
|
|
273
|
+
*
|
|
274
|
+
* Symmetric by design: every call fully reconciles state, in both directions. `enable:false`
|
|
275
|
+
* doesn't just skip installing the SessionStart hook — if it was installed by a PRIOR call
|
|
276
|
+
* (e.g. a previous run without `--no-native-memory-override`), this strips it out, same for
|
|
277
|
+
* `enforce:false` / `effortGate:false` against their own pieces. That's what makes `--no-X`
|
|
278
|
+
* flags actually reverse a prior `--X` install instead of only affecting fresh ones.
|
|
279
|
+
*
|
|
280
|
+
* As a side effect of being symmetric, a call that wants nothing installed AND finds nothing
|
|
281
|
+
* of ours already present is a true no-op — it never touches `~/.claude/settings.json` at
|
|
282
|
+
* all (not even to create it), so running with Claude Code off/unwanted never surprises a
|
|
283
|
+
* machine that never had this override.
|
|
284
|
+
*
|
|
285
|
+
* Best-effort and non-fatal; never throws out (a failure here must not abort the install).
|
|
286
|
+
* @param {string} home
|
|
287
|
+
* @param {string} vaultAbs
|
|
288
|
+
* @param {boolean} dryRun
|
|
289
|
+
* @param {{ lang?: "es"|"en", enable?: boolean, enforce?: boolean, effortGate?: boolean }} [opts] -
|
|
290
|
+
* `enable` (default true) is the top-level on/off for the whole override (SessionStart
|
|
291
|
+
* hook + `autoMemoryEnabled:false`); `enforce` (default true, only meaningful when
|
|
292
|
+
* `enable`) also installs the ADR-0030 deterministic hooks: a `PreToolUse` guard that
|
|
293
|
+
* denies writes into the native auto-memory directory, and a `Stop` nudge for the close
|
|
294
|
+
* ritual. `effortGate` (default true, only meaningful when `enable`) installs the
|
|
295
|
+
* ADR-0031 effort-gate hook, independently of `enforce`.
|
|
296
|
+
*/
|
|
297
|
+
export async function configureClaudeNativeMemory(
|
|
298
|
+
home,
|
|
299
|
+
vaultAbs,
|
|
300
|
+
dryRun,
|
|
301
|
+
{ lang = "es", enable = true, enforce = true, effortGate = true } = {}
|
|
302
|
+
) {
|
|
303
|
+
const claudeDir = path.join(home, ".claude");
|
|
304
|
+
const hooksDir = path.join(claudeDir, "hooks");
|
|
305
|
+
const hookDest = path.join(hooksDir, HOOK_BASENAME);
|
|
306
|
+
const guardDest = path.join(hooksDir, GUARD_HOOK_BASENAME);
|
|
307
|
+
const stopDest = path.join(hooksDir, STOP_HOOK_BASENAME);
|
|
308
|
+
const effortGateDest = path.join(hooksDir, EFFORT_GATE_HOOK_BASENAME);
|
|
309
|
+
const transcriptCacheDest = path.join(hooksDir, TRANSCRIPT_CACHE_BASENAME);
|
|
310
|
+
const settingsFp = path.join(claudeDir, "settings.json");
|
|
311
|
+
|
|
312
|
+
const wantEnforce = enable && enforce;
|
|
313
|
+
const wantEffortGate = enable && effortGate;
|
|
314
|
+
|
|
315
|
+
const hookEntry = enable ? hookCommand(hookDest, vaultAbs, lang) : null;
|
|
316
|
+
const guardCommand = wantEnforce ? guardHookCommand(guardDest, claudeDir, lang) : null;
|
|
317
|
+
const stopCommand = wantEnforce ? stopHookCommand(stopDest, lang) : null;
|
|
318
|
+
const effortGateCommand = wantEffortGate ? effortGateHookCommand(effortGateDest, lang) : null;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
// Read existing settings up front — needed to reconcile installs AND removals, and to
|
|
322
|
+
// cheaply detect (via our filename stems, on the raw text) whether there's anything of
|
|
323
|
+
// ours to remove at all, without depending on the JSON being valid.
|
|
324
|
+
let { existing, priorBytes, invalidJson, rawText } = await readSettingsSafe(settingsFp);
|
|
325
|
+
const oursPresent = ALL_HOOK_STEMS.some((stem) => rawText.includes(stem));
|
|
326
|
+
|
|
327
|
+
// True no-op: nothing wanted, nothing of ours present — leave the file (even a
|
|
328
|
+
// nonexistent one) completely untouched. Prevents a bare `--ide claude
|
|
329
|
+
// --no-native-memory-override` from conjuring a settings.json out of thin air.
|
|
330
|
+
if (!enable && !oursPresent) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (dryRun) {
|
|
335
|
+
if (enable) {
|
|
336
|
+
console.log(
|
|
337
|
+
pc.cyan("[dry-run] would set"),
|
|
338
|
+
"autoMemoryEnabled:false",
|
|
339
|
+
pc.dim(`in ${settingsFp}`)
|
|
340
|
+
);
|
|
341
|
+
console.log(pc.cyan("[dry-run] would install SessionStart hook"), pc.dim(hookDest));
|
|
342
|
+
} else if (
|
|
343
|
+
hasManagedHook(existing.hooks, "SessionStart", HOOK_STEM) ||
|
|
344
|
+
existing.autoMemoryEnabled === false
|
|
345
|
+
) {
|
|
346
|
+
console.log(
|
|
347
|
+
pc.cyan("[dry-run] would remove autoMemoryEnabled override + SessionStart hook"),
|
|
348
|
+
pc.dim(`from ${settingsFp}`)
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (wantEnforce) {
|
|
352
|
+
console.log(
|
|
353
|
+
pc.cyan("[dry-run] would install PreToolUse native-memory guard hook"),
|
|
354
|
+
pc.dim(guardDest)
|
|
355
|
+
);
|
|
356
|
+
console.log(
|
|
357
|
+
pc.cyan("[dry-run] would install Stop close-ritual reminder hook"),
|
|
358
|
+
pc.dim(stopDest)
|
|
359
|
+
);
|
|
360
|
+
} else if (
|
|
361
|
+
hasManagedHook(existing.hooks, "PreToolUse", GUARD_HOOK_STEM) ||
|
|
362
|
+
hasManagedHook(existing.hooks, "Stop", STOP_HOOK_STEM)
|
|
363
|
+
) {
|
|
364
|
+
console.log(
|
|
365
|
+
pc.cyan(
|
|
366
|
+
"[dry-run] would remove PreToolUse native-memory guard + Stop close-ritual reminder hooks"
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
if (wantEffortGate) {
|
|
371
|
+
console.log(
|
|
372
|
+
pc.cyan("[dry-run] would install PreToolUse effort-gate hook"),
|
|
373
|
+
pc.dim(effortGateDest)
|
|
374
|
+
);
|
|
375
|
+
} else if (hasManagedHook(existing.hooks, "PreToolUse", EFFORT_GATE_HOOK_STEM)) {
|
|
376
|
+
console.log(pc.cyan("[dry-run] would remove PreToolUse effort-gate hook"));
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// 1. Copy (only the pieces being installed) / never delete script files here — that's
|
|
382
|
+
// `--uninstall`'s job, guarded by a marker check. A per-run `--no-X` only touches the
|
|
383
|
+
// settings.json registration; an orphaned-but-uncalled script file is harmless.
|
|
384
|
+
await fse.ensureDir(hooksDir);
|
|
385
|
+
if (enable) {
|
|
386
|
+
await fse.copy(packagedHookPath(), hookDest, { overwrite: true });
|
|
387
|
+
}
|
|
388
|
+
if (wantEnforce) {
|
|
389
|
+
await fse.copy(packagedHookPath(GUARD_HOOK_BASENAME), guardDest, { overwrite: true });
|
|
390
|
+
await fse.copy(packagedHookPath(STOP_HOOK_BASENAME), stopDest, { overwrite: true });
|
|
391
|
+
}
|
|
392
|
+
if (wantEffortGate) {
|
|
393
|
+
await fse.copy(packagedHookPath(EFFORT_GATE_HOOK_BASENAME), effortGateDest, {
|
|
394
|
+
overwrite: true
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (wantEnforce || wantEffortGate) {
|
|
398
|
+
// The Stop nudge and the effort-gate hook both import this sidecar-cache module as a
|
|
399
|
+
// sibling file (item 3 perf fix) — copy it whenever either consumer is installed.
|
|
400
|
+
await fse.copy(packagedHookPath(TRANSCRIPT_CACHE_BASENAME), transcriptCacheDest, {
|
|
401
|
+
overwrite: true
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (invalidJson) {
|
|
406
|
+
const bak = await backupRestricted(settingsFp, priorBytes);
|
|
407
|
+
console.warn(pc.yellow("Invalid JSON in ~/.claude/settings.json; backed up to"), bak);
|
|
408
|
+
existing = {};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 2. Reconcile: merge what's wanted, remove what's not — same code path handles a
|
|
412
|
+
// fresh install, a no-op re-run, and a partial/full removal.
|
|
413
|
+
let merged = enable
|
|
414
|
+
? mergeClaudeSettings(existing, hookEntry)
|
|
415
|
+
: removeSessionStartHook(existing);
|
|
416
|
+
merged = wantEnforce
|
|
417
|
+
? mergeEnforcementHooks(merged, { guardCommand, stopCommand })
|
|
418
|
+
: removeEnforcementHooks(merged);
|
|
419
|
+
merged = wantEffortGate
|
|
420
|
+
? mergeEffortGateHook(merged, { effortGateCommand })
|
|
421
|
+
: removeEffortGateHook(merged);
|
|
422
|
+
// Keep a one-`mv`-away backup of the prior valid settings, like mcp.json does. Restricted
|
|
423
|
+
// to the current user, same as the final file below — settings.json can carry secrets in
|
|
424
|
+
// keys this kit doesn't own (e.g. a user's own `env` block), on either platform.
|
|
425
|
+
if (priorBytes && !invalidJson) {
|
|
426
|
+
const bak = await backupRestricted(settingsFp, priorBytes);
|
|
427
|
+
console.log(pc.dim("Backed up previous settings.json to"), bak);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Serializes + sanity-parses + writes atomically (tmp → restrict → rename).
|
|
431
|
+
await atomicWriteJson(settingsFp, merged);
|
|
432
|
+
|
|
433
|
+
if (enable) {
|
|
434
|
+
console.log(pc.green("Claude Code native-memory override:"), settingsFp);
|
|
435
|
+
console.log(pc.dim(" autoMemoryEnabled:false + SessionStart hook ->"), pc.dim(hookDest));
|
|
436
|
+
} else if (oursPresent) {
|
|
437
|
+
console.log(pc.green("Claude Code native-memory override removed:"), settingsFp);
|
|
438
|
+
}
|
|
439
|
+
if (wantEnforce) {
|
|
440
|
+
console.log(pc.dim(" + PreToolUse native-memory guard ->"), pc.dim(guardDest));
|
|
441
|
+
console.log(pc.dim(" + Stop close-ritual reminder ->"), pc.dim(stopDest));
|
|
442
|
+
} else if (oursPresent && enable) {
|
|
443
|
+
console.log(pc.dim(" (enforcement hooks removed or not installed)"));
|
|
444
|
+
}
|
|
445
|
+
if (wantEffortGate) {
|
|
446
|
+
console.log(pc.dim(" + PreToolUse effort-gate ->"), pc.dim(effortGateDest));
|
|
447
|
+
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
console.warn(
|
|
450
|
+
pc.yellow("Could not configure the Claude Code native-memory override (skipped):"),
|
|
451
|
+
e?.message || e
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Fully reverse everything this kit's Claude Code integration installed: all 4 managed
|
|
458
|
+
* hook entries in `~/.claude/settings.json`, the `autoMemoryEnabled` override (iff it's
|
|
459
|
+
* still exactly the value this kit would have set — see {@link removeSessionStartHook}),
|
|
460
|
+
* and — only for files a marker check proves this kit wrote — the hook script files
|
|
461
|
+
* themselves under `~/.claude/hooks/`. Never touches MCP server registrations, the vault,
|
|
462
|
+
* or rules blocks; those have their own manual/CLI removal paths (see docs/en/faq.md).
|
|
463
|
+
* Best-effort and non-fatal, matching {@link configureClaudeNativeMemory}.
|
|
464
|
+
* @param {string} home
|
|
465
|
+
* @param {boolean} dryRun
|
|
466
|
+
*/
|
|
467
|
+
export async function uninstallClaudeNativeMemory(home, dryRun) {
|
|
468
|
+
// Reuses the same reconcile path as a `--no-X` removal, just with every piece off.
|
|
469
|
+
await configureClaudeNativeMemory(home, "", dryRun, {
|
|
470
|
+
enable: false,
|
|
471
|
+
enforce: false,
|
|
472
|
+
effortGate: false
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const hooksDir = path.join(home, ".claude", "hooks");
|
|
476
|
+
const basenames = [
|
|
477
|
+
HOOK_BASENAME,
|
|
478
|
+
GUARD_HOOK_BASENAME,
|
|
479
|
+
STOP_HOOK_BASENAME,
|
|
480
|
+
EFFORT_GATE_HOOK_BASENAME,
|
|
481
|
+
TRANSCRIPT_CACHE_BASENAME
|
|
482
|
+
];
|
|
483
|
+
for (const basename of basenames) {
|
|
484
|
+
const fp = path.join(hooksDir, basename);
|
|
485
|
+
if (!(await fse.pathExists(fp))) continue;
|
|
486
|
+
const owned = await isKitOwnedFile(fp);
|
|
487
|
+
if (dryRun) {
|
|
488
|
+
console.log(
|
|
489
|
+
owned
|
|
490
|
+
? pc.cyan("[dry-run] would remove")
|
|
491
|
+
: pc.yellow("[dry-run] would SKIP (not recognized as this kit's file)"),
|
|
492
|
+
pc.dim(fp)
|
|
493
|
+
);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (!owned) {
|
|
497
|
+
console.warn(pc.yellow("Skipped (not recognized as this kit's file):"), fp);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
await fse.remove(fp);
|
|
502
|
+
console.log(pc.green("Removed"), pc.dim(fp));
|
|
503
|
+
} catch (e) {
|
|
504
|
+
console.warn(pc.yellow("Could not remove"), fp, e?.message || e);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Restrict a just-written config file (and its `.bak.*` copies) to the current user.
|
|
2
|
+
//
|
|
3
|
+
// `mcp.json` / `settings.json` may carry `env` blocks with API tokens (this kit's own
|
|
4
|
+
// writes don't add secrets, but we merge into and preserve whatever else the user already
|
|
5
|
+
// had in the file, which can). POSIX: chmod 0600. Windows has no chmod bits at all — the
|
|
6
|
+
// prior code just skipped the whole step there (`if (process.platform !== "win32")`), which
|
|
7
|
+
// on the platform this repo's own maintainer uses meant the protection never applied, and
|
|
8
|
+
// `.bak.*` copies accumulated with default (often broader) ACLs. This restricts the file to
|
|
9
|
+
// the current user via `icacls`, the Windows analogue of chmod 0600 (ADR-none; audit fix).
|
|
10
|
+
//
|
|
11
|
+
// Best-effort by design, on BOTH platforms: a permissions failure must never abort a write
|
|
12
|
+
// that otherwise succeeded (matches the existing chmod call sites' `catch { /* ignore */ }`
|
|
13
|
+
// precedent) — ACL manipulation in particular can be fragile across Windows configs (domain
|
|
14
|
+
// accounts, non-NTFS volumes, restricted environments), so failures here only warn.
|
|
15
|
+
import { execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import pc from "picocolors";
|
|
20
|
+
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Best-effort: restrict `fp` to the current user only (POSIX: chmod 0600; Windows: icacls
|
|
25
|
+
* ACL reset). Never throws — callers should NOT await-fail their write on this.
|
|
26
|
+
* @param {string} fp
|
|
27
|
+
*/
|
|
28
|
+
export async function restrictFileToOwner(fp) {
|
|
29
|
+
if (process.platform === "win32") {
|
|
30
|
+
try {
|
|
31
|
+
const username = process.env.USERNAME || os.userInfo().username;
|
|
32
|
+
if (!username) return;
|
|
33
|
+
// /inheritance:r drops inherited ACEs; /grant:r replaces the explicit grant list with
|
|
34
|
+
// just the current user (Full control) — together, the closest Windows analogue of
|
|
35
|
+
// POSIX chmod 0600. Passed as an argv array (no shell), so no quoting to get wrong.
|
|
36
|
+
await execFileAsync("icacls", [fp, "/inheritance:r", "/grant:r", `${username}:F`], {
|
|
37
|
+
windowsHide: true
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.warn(
|
|
41
|
+
pc.yellow("Could not restrict file permissions (icacls, best-effort):"),
|
|
42
|
+
fp,
|
|
43
|
+
e?.message || e
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await fs.chmod(fp, 0o600);
|
|
50
|
+
} catch {
|
|
51
|
+
/* best-effort: not all filesystems support chmod */
|
|
52
|
+
}
|
|
53
|
+
}
|