@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.
Files changed (39) hide show
  1. package/LICENSE.md +44 -0
  2. package/README.md +148 -0
  3. package/package.json +57 -0
  4. package/src/asset-install.mjs +109 -0
  5. package/src/claude-native-memory.mjs +507 -0
  6. package/src/file-perms.mjs +53 -0
  7. package/src/hooks/_transcript-cache.mjs +223 -0
  8. package/src/hooks/compact-mcp-output.mjs +87 -0
  9. package/src/hooks/compact-tool-output.mjs +177 -0
  10. package/src/hooks/ensure-otel-sink.mjs +51 -0
  11. package/src/hooks/guard-effort-gate.mjs +209 -0
  12. package/src/hooks/guard-native-memory-write.mjs +129 -0
  13. package/src/hooks/session-start-vault-context.mjs +200 -0
  14. package/src/hooks/stop-vault-close-reminder.mjs +150 -0
  15. package/src/index.js +1547 -0
  16. package/src/mcp-merge.mjs +279 -0
  17. package/src/memory-rules.mjs +205 -0
  18. package/src/obscura-setup.mjs +272 -0
  19. package/src/ollama-setup.mjs +126 -0
  20. package/src/rules-merge.mjs +106 -0
  21. package/src/settings-io.mjs +193 -0
  22. package/src/settings-writers.mjs +150 -0
  23. package/src/skills-install.mjs +96 -0
  24. package/src/telemetry.mjs +154 -0
  25. package/src/token-saver.mjs +248 -0
  26. package/templates/agents/vkm-implementer.md +23 -0
  27. package/templates/output-styles/vkm-terse.md +23 -0
  28. package/templates/skills/vkm-discipline/SKILL.md +77 -0
  29. package/templates/skills/vkm-discipline/domains/coding.md +39 -0
  30. package/templates/skills/vkm-discipline/domains/data.md +37 -0
  31. package/templates/skills/vkm-discipline/domains/debugging.md +41 -0
  32. package/templates/skills/vkm-discipline/domains/design-ui.md +41 -0
  33. package/templates/skills/vkm-discipline/domains/expertise.md +26 -0
  34. package/templates/skills/vkm-discipline/domains/infra.md +35 -0
  35. package/templates/skills/vkm-discipline/domains/llm-artifacts.md +30 -0
  36. package/templates/skills/vkm-discipline/domains/security.md +37 -0
  37. package/templates/skills/vkm-discipline/domains/web-search.md +43 -0
  38. package/templates/skills/vkm-discipline/domains/writing.md +32 -0
  39. package/templates/skills/vkm-spec/SKILL.md +33 -0
@@ -0,0 +1,193 @@
1
+ // Shared primitives for safely reading, merging and writing `~/.claude/settings.json`
2
+ // (and any other user-owned JSON config this kit manages).
3
+ //
4
+ // Extracted from `claude-native-memory.mjs` (Phase 0 groundwork, ADR-0042 train) so new
5
+ // installer modules (token-saver, telemetry, assets) can reuse the exact same battle-tested
6
+ // idiom instead of copying it: read → BOM-strip → parse (backup invalid) → pure merge on a
7
+ // copy → JSON.parse sanity → backup prior → atomic tmp + restrict + rename → symmetric
8
+ // reconcile keyed on ownership markers/stems. Behavior is identical to the pre-extraction
9
+ // code; the existing test suite is the refactor guard.
10
+ //
11
+ // Everything here is either PURE (the merge/remove helpers — never mutate their input) or
12
+ // best-effort I/O with crash-safe semantics (the read/write helpers). Nothing in this module
13
+ // logs; user-facing messages stay in the orchestrators that own the flow.
14
+ import path from "node:path";
15
+ import fse from "fs-extra";
16
+ import { restrictFileToOwner } from "./file-perms.mjs";
17
+
18
+ /** Literal string every hook/asset file this kit ships carries in its header comment. Used
19
+ * as a cheap-but-reliable ownership marker before `--uninstall` deletes a file by that name —
20
+ * a user's own unrelated same-named script is exceedingly unlikely to contain this exact
21
+ * npm package name in its source. */
22
+ export const KIT_FILE_MARKER = "vkm-kit";
23
+
24
+ /** Prior markers this kit shipped under other names. Ownership checks accept these too, so
25
+ * files installed by an older release are still recognized as ours after a rename. Filled at
26
+ * the 4.0.0 rename (ADR-0041); kept exported so reconcile paths can match both directions. */
27
+ export const LEGACY_FILE_MARKERS = ["create-obsidian-memory"];
28
+
29
+ /** Strip UTF-8 BOM so JSON.parse succeeds (common when a file was saved from PowerShell). */
30
+ export function stripLeadingUtf8Bom(text) {
31
+ return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
32
+ }
33
+
34
+ /**
35
+ * True if a raw `hooks[event][].hooks[]` entry belongs to this kit's `stem`. Recognizes
36
+ * BOTH the current exec form (`args` array — the stem shows up in an arg, since args[0] is
37
+ * always the hook's own file path) AND the legacy single-string shell form a prior version
38
+ * of this kit (or the even older `.ps1` variant) may have written — the stem is a substring
39
+ * of that command string too. Never matches an unrelated hook the user added themselves,
40
+ * since it requires the EXACT filename stem, not just the event/matcher.
41
+ * @param {unknown} h
42
+ * @param {string} stem
43
+ */
44
+ export function hookEntryMatchesStem(h, stem) {
45
+ if (!h || typeof h !== "object") return false;
46
+ if (typeof h.command === "string" && h.command.includes(stem)) return true;
47
+ if (Array.isArray(h.args) && h.args.some((a) => typeof a === "string" && a.includes(stem))) {
48
+ return true;
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Merge ONE managed hook entry into `hooks[eventName]`, replacing any entry whose command
55
+ * matches `stem` (so re-runs, and a legacy variant of the same hook, never duplicate) and
56
+ * preserving every unrelated entry for that event.
57
+ * @param {Record<string, unknown>} hooks - the settings' `hooks` object (not mutated)
58
+ * @param {string} eventName - e.g. "SessionStart", "PreToolUse", "Stop"
59
+ * @param {string} matcher - hook matcher (e.g. "*" or a tool-name regex)
60
+ * @param {{ command: string, args: string[] }} hookEntry - exec-form hook command
61
+ * @param {string} stem - filename stem identifying our managed entry, for dedup
62
+ * @returns {Record<string, unknown>} a NEW hooks object
63
+ */
64
+ export function mergeManagedHook(hooks, eventName, matcher, hookEntry, stem) {
65
+ const prior = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
66
+ const kept = prior.filter((entry) => {
67
+ const inner = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
68
+ return !inner.some((h) => hookEntryMatchesStem(h, stem));
69
+ });
70
+ kept.push({ matcher, hooks: [{ type: "command", ...hookEntry }] });
71
+ return { ...hooks, [eventName]: kept };
72
+ }
73
+
74
+ /**
75
+ * The removal counterpart to {@link mergeManagedHook}: strip any entry matching `stem` from
76
+ * `hooks[eventName]`, leaving every unrelated entry (ours or the user's own) untouched. If
77
+ * removal empties the array, the event key is deleted entirely rather than left as `[]`, so
78
+ * an uninstalled kit leaves no trace. A no-op (returns `hooks` structurally unchanged, modulo
79
+ * a shallow copy) when nothing matches — safe to call unconditionally.
80
+ * @param {Record<string, unknown>} hooks
81
+ * @param {string} eventName
82
+ * @param {string} stem
83
+ * @returns {Record<string, unknown>}
84
+ */
85
+ export function removeManagedHook(hooks, eventName, stem) {
86
+ const prior = Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
87
+ const kept = prior.filter((entry) => {
88
+ const inner = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
89
+ return !inner.some((h) => hookEntryMatchesStem(h, stem));
90
+ });
91
+ const next = { ...hooks };
92
+ if (kept.length) next[eventName] = kept;
93
+ else delete next[eventName];
94
+ return next;
95
+ }
96
+
97
+ /** Assign `settings.hooks = next`, unless `next` is empty AND `settings` never had a
98
+ * `hooks` key to begin with — avoids a removal function introducing a gratuitous
99
+ * `"hooks": {}` into settings that had no hooks at all before. */
100
+ export function setOrDeleteHooks(settings, hadHooksBefore, next) {
101
+ if (Object.keys(next).length || hadHooksBefore) {
102
+ settings.hooks = next;
103
+ }
104
+ return settings;
105
+ }
106
+
107
+ /** True if `hooks[eventName]` currently contains a managed entry for `stem`. Used to decide
108
+ * whether a removal is a real action worth announcing (dry-run) or a pure no-op. */
109
+ export function hasManagedHook(hooks, eventName, stem) {
110
+ const prior = hooks && Array.isArray(hooks[eventName]) ? hooks[eventName] : [];
111
+ return prior.some((entry) => {
112
+ const inner = entry && Array.isArray(entry.hooks) ? entry.hooks : [];
113
+ return inner.some((h) => hookEntryMatchesStem(h, stem));
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Read a user-owned JSON settings file without ever throwing: missing file, empty file and
119
+ * invalid JSON are all first-class outcomes the caller reconciles against, not errors.
120
+ * The raw prior bytes are returned so the caller can (a) back them up before overwriting and
121
+ * (b) cheaply detect kit-owned entries by stem on the raw text even when the JSON is broken.
122
+ * @param {string} settingsFp
123
+ * @returns {Promise<{ existing: Record<string, unknown>, priorBytes: Buffer | null,
124
+ * invalidJson: boolean, rawText: string }>}
125
+ */
126
+ export async function readSettingsSafe(settingsFp) {
127
+ let existing = {};
128
+ let priorBytes = null;
129
+ let invalidJson = false;
130
+ if (await fse.pathExists(settingsFp)) {
131
+ priorBytes = await fse.readFile(settingsFp);
132
+ const raw = stripLeadingUtf8Bom(priorBytes.toString("utf8")).trim();
133
+ if (raw) {
134
+ try {
135
+ existing = JSON.parse(raw);
136
+ } catch {
137
+ invalidJson = true;
138
+ }
139
+ }
140
+ }
141
+ const rawText = priorBytes ? priorBytes.toString("utf8") : "";
142
+ return { existing, priorBytes, invalidJson, rawText };
143
+ }
144
+
145
+ /**
146
+ * Write `bytes` to a timestamped `<fp>.bak.<ts>` sibling, restricted to the current user
147
+ * (settings files can carry secrets in keys this kit doesn't own, e.g. a user's own `env`
148
+ * block). Returns the backup path so the caller can log it. Backups are kept indefinitely;
149
+ * clean up manually — agent-driven installs that go wrong should be 1 `mv` away from
150
+ * recovery.
151
+ * @param {string} fp
152
+ * @param {Buffer | string} bytes
153
+ * @returns {Promise<string>}
154
+ */
155
+ export async function backupRestricted(fp, bytes) {
156
+ const bak = `${fp}.bak.${Date.now()}`;
157
+ await fse.writeFile(bak, bytes);
158
+ await restrictFileToOwner(bak);
159
+ return bak;
160
+ }
161
+
162
+ /**
163
+ * Write JSON to `fp` atomically: stage at `<fp>.tmp.<pid>.<ts>`, restrict to owner, rename.
164
+ * Crash mid-write leaves the original `fp` intact rather than truncating it. The payload is
165
+ * serialized with 2-space indent + trailing newline and parsed back once before the write —
166
+ * never write something that won't parse.
167
+ * @param {string} fp - target path
168
+ * @param {unknown} data - JSON-serializable payload
169
+ */
170
+ export async function atomicWriteJson(fp, data) {
171
+ const out = `${JSON.stringify(data, null, 2)}\n`;
172
+ JSON.parse(out); // sanity: never write something that won't parse back
173
+ await fse.ensureDir(path.dirname(fp));
174
+ const tmp = `${fp}.tmp.${process.pid}.${Date.now()}`;
175
+ await fse.writeFile(tmp, out, "utf8");
176
+ await restrictFileToOwner(tmp);
177
+ await fse.rename(tmp, fp);
178
+ }
179
+
180
+ /** True if `fp` contains one of this kit's ownership markers, meaning it's safe to assume
181
+ * WE wrote it (as opposed to a user's own unrelated same-named file). Read failures
182
+ * (missing file, etc.) are treated as "not ours" — never delete on doubt.
183
+ * @param {string} fp
184
+ * @param {string[]} [markers]
185
+ */
186
+ export async function isKitOwnedFile(fp, markers = [KIT_FILE_MARKER, ...LEGACY_FILE_MARKERS]) {
187
+ try {
188
+ const text = await fse.readFile(fp, "utf8");
189
+ return markers.some((marker) => text.includes(marker));
190
+ } catch {
191
+ return false;
192
+ }
193
+ }
@@ -0,0 +1,150 @@
1
+ // Pure merge/remove writers for the settings.json sections the token-saver and telemetry
2
+ // modules manage: `env`, `permissions.{deny,allow}` and `outputStyle`.
3
+ //
4
+ // Same contract as the hook mergers in `settings-io.mjs`: never mutate the input, preserve
5
+ // every unrelated key/entry, be idempotent under re-runs, and on removal only reverse what
6
+ // we can PROVE is ours — a value the user has since changed is left alone (the same
7
+ // principle `removeSessionStartHook` applies to `autoMemoryEnabled`). Emptied sections are
8
+ // deleted entirely so an uninstalled kit leaves no trace.
9
+ //
10
+ // Nothing here does I/O or logs; orchestrators own the read→merge→write flow via
11
+ // `readSettingsSafe`/`atomicWriteJson` from `settings-io.mjs`.
12
+
13
+ /** Shallow-copy `existing` when it's a plain object; otherwise start fresh (same guard the
14
+ * hook mergers use — a corrupt/array/null settings value must never crash a merge). */
15
+ function asSettingsObject(existing) {
16
+ return existing && typeof existing === "object" && !Array.isArray(existing)
17
+ ? { ...existing }
18
+ : {};
19
+ }
20
+
21
+ /** Plain-object guard for a nested section (`env`, `permissions`). */
22
+ function asSection(value) {
23
+ return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
24
+ }
25
+
26
+ /**
27
+ * Set every `envMap` key in `settings.env`, preserving unrelated env vars. Last write wins
28
+ * for our own keys (re-runs converge). `envMap` values are coerced to strings — settings
29
+ * env blocks are string-valued.
30
+ * @param {unknown} existing - parsed `settings.json` (or anything; non-objects are ignored)
31
+ * @param {Record<string, string | number | boolean>} envMap
32
+ * @returns {Record<string, unknown>}
33
+ */
34
+ export function mergeManagedEnv(existing, envMap) {
35
+ const settings = asSettingsObject(existing);
36
+ const env = asSection(settings.env);
37
+ for (const [key, value] of Object.entries(envMap ?? {})) {
38
+ env[key] = String(value);
39
+ }
40
+ settings.env = env;
41
+ return settings;
42
+ }
43
+
44
+ /**
45
+ * The removal counterpart to {@link mergeManagedEnv}: delete each `envMap` key from
46
+ * `settings.env` ONLY when its current value is still exactly what this kit would have set
47
+ * — a user-tuned value (e.g. a custom OTLP endpoint) is never silently reverted. Deletes
48
+ * the `env` section entirely when removal empties it.
49
+ * @param {unknown} existing
50
+ * @param {Record<string, string | number | boolean>} envMap - keys + the values we set
51
+ * @returns {Record<string, unknown>}
52
+ */
53
+ export function removeManagedEnv(existing, envMap) {
54
+ const settings = asSettingsObject(existing);
55
+ if (!settings.env || typeof settings.env !== "object" || Array.isArray(settings.env)) {
56
+ return settings;
57
+ }
58
+ const env = { ...settings.env };
59
+ for (const [key, value] of Object.entries(envMap ?? {})) {
60
+ if (env[key] === String(value)) delete env[key];
61
+ }
62
+ if (Object.keys(env).length) settings.env = env;
63
+ else delete settings.env;
64
+ return settings;
65
+ }
66
+
67
+ /** Append `rules` to `list` preserving the user's order and entries; ours land at the end,
68
+ * deduped by exact string so re-runs never duplicate. */
69
+ function mergeRuleList(list, rules) {
70
+ const prior = Array.isArray(list) ? [...list] : [];
71
+ for (const rule of rules) {
72
+ if (!prior.includes(rule)) prior.push(rule);
73
+ }
74
+ return prior;
75
+ }
76
+
77
+ /**
78
+ * Merge managed permission rules into `settings.permissions.{deny,allow}` (exact-string
79
+ * dedup; user rules and their order preserved; ours appended).
80
+ * @param {unknown} existing
81
+ * @param {{ deny?: string[], allow?: string[] }} rules
82
+ * @returns {Record<string, unknown>}
83
+ */
84
+ export function mergeManagedPermissions(existing, { deny = [], allow = [] } = {}) {
85
+ const settings = asSettingsObject(existing);
86
+ const permissions = asSection(settings.permissions);
87
+ if (deny.length) permissions.deny = mergeRuleList(permissions.deny, deny);
88
+ if (allow.length) permissions.allow = mergeRuleList(permissions.allow, allow);
89
+ settings.permissions = permissions;
90
+ return settings;
91
+ }
92
+
93
+ /**
94
+ * The removal counterpart to {@link mergeManagedPermissions}: strip exactly the given rule
95
+ * strings, leaving every other rule (the user's own) untouched. Emptied arrays and an
96
+ * emptied `permissions` section are deleted entirely.
97
+ * @param {unknown} existing
98
+ * @param {{ deny?: string[], allow?: string[] }} rules
99
+ * @returns {Record<string, unknown>}
100
+ */
101
+ export function removeManagedPermissions(existing, { deny = [], allow = [] } = {}) {
102
+ const settings = asSettingsObject(existing);
103
+ if (
104
+ !settings.permissions ||
105
+ typeof settings.permissions !== "object" ||
106
+ Array.isArray(settings.permissions)
107
+ ) {
108
+ return settings;
109
+ }
110
+ const permissions = { ...settings.permissions };
111
+ for (const [key, ours] of [
112
+ ["deny", deny],
113
+ ["allow", allow]
114
+ ]) {
115
+ if (!Array.isArray(permissions[key])) continue;
116
+ const kept = permissions[key].filter((rule) => !ours.includes(rule));
117
+ if (kept.length) permissions[key] = kept;
118
+ else delete permissions[key];
119
+ }
120
+ if (Object.keys(permissions).length) settings.permissions = permissions;
121
+ else delete settings.permissions;
122
+ return settings;
123
+ }
124
+
125
+ /**
126
+ * Set `settings.outputStyle` to the kit-shipped style. Overwrites a prior value — the
127
+ * install flow only calls this when the user opted in (default ON under `--full`), and the
128
+ * removal below refuses to touch anything that isn't exactly ours.
129
+ * @param {unknown} existing
130
+ * @param {string} name - output style name (e.g. "vkm-terse")
131
+ * @returns {Record<string, unknown>}
132
+ */
133
+ export function setManagedOutputStyle(existing, name) {
134
+ const settings = asSettingsObject(existing);
135
+ settings.outputStyle = name;
136
+ return settings;
137
+ }
138
+
139
+ /**
140
+ * The removal counterpart to {@link setManagedOutputStyle}: delete `outputStyle` ONLY when
141
+ * it still points at our style — a user who switched styles afterwards keeps their choice.
142
+ * @param {unknown} existing
143
+ * @param {string} name
144
+ * @returns {Record<string, unknown>}
145
+ */
146
+ export function clearManagedOutputStyle(existing, name) {
147
+ const settings = asSettingsObject(existing);
148
+ if (settings.outputStyle === name) delete settings.outputStyle;
149
+ return settings;
150
+ }
@@ -0,0 +1,96 @@
1
+ // vkm-kit skills + subagent installer (ADR-0049): copies the discipline/spec skills into
2
+ // `~/.claude/skills/` and the implementer subagent template into `~/.claude/agents/`.
3
+ // Pure file assets — no settings.json changes — tracked by content hash through
4
+ // `asset-install.mjs`, so uninstall never deletes a file the user customized. Skills load
5
+ // their body only when invoked (Claude Code progressive disclosure); the always-in-context
6
+ // cost is just each skill's frontmatter description.
7
+ import path from "node:path";
8
+ import { readdirSync, statSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import pc from "picocolors";
11
+ import {
12
+ ASSETS_SIDECAR_BASENAME,
13
+ installManagedAssets,
14
+ removeManagedAssets
15
+ } from "./asset-install.mjs";
16
+
17
+ export const SKILL_NAMES = ["vkm-discipline", "vkm-spec"];
18
+ export const AGENT_BASENAMES = ["vkm-implementer.md"];
19
+
20
+ function templatesDir() {
21
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "templates");
22
+ }
23
+
24
+ /** The {src,dest} pairs for each asset group under a given `home`. */
25
+ /** Every file under a skill's template dir (SKILL.md + any domain/reference files), as {src,dest}. */
26
+ function skillDirFiles(name, claudeDir) {
27
+ const root = path.join(templatesDir(), "skills", name);
28
+ const out = [];
29
+ for (const rel of readdirSync(root, { recursive: true })) {
30
+ const src = path.join(root, String(rel));
31
+ if (!statSync(src).isFile()) continue;
32
+ out.push({ src, dest: path.join(claudeDir, "skills", name, String(rel)) });
33
+ }
34
+ return out;
35
+ }
36
+
37
+ export function skillAssetFiles(home, { skills = true, agents = true } = {}) {
38
+ const claudeDir = path.join(home, ".claude");
39
+ const files = [];
40
+ if (skills) {
41
+ for (const name of SKILL_NAMES) files.push(...skillDirFiles(name, claudeDir));
42
+ }
43
+ if (agents) {
44
+ for (const basename of AGENT_BASENAMES) {
45
+ files.push({
46
+ src: path.join(templatesDir(), "agents", basename),
47
+ dest: path.join(claudeDir, "agents", basename)
48
+ });
49
+ }
50
+ }
51
+ return files;
52
+ }
53
+
54
+ /**
55
+ * Install / reconcile / remove the skill + agent assets. Symmetric like the sibling
56
+ * modules: a piece that is NOT wanted is actively removed (hash-guarded).
57
+ * @param {string} home
58
+ * @param {boolean} dryRun
59
+ * @param {{ skills?: boolean, agents?: boolean }} [opts]
60
+ */
61
+ export async function configureSkillAssets(home, dryRun, { skills = true, agents = true } = {}) {
62
+ const sidecarFp = path.join(home, ".claude", ASSETS_SIDECAR_BASENAME);
63
+ try {
64
+ const wanted = skillAssetFiles(home, { skills, agents });
65
+ const unwanted = skillAssetFiles(home, { skills: !skills, agents: !agents });
66
+ if (dryRun) {
67
+ for (const { dest } of wanted) console.log(pc.cyan("[dry-run] would install"), pc.dim(dest));
68
+ for (const { dest } of unwanted) {
69
+ console.log(pc.cyan("[dry-run] would remove (if unmodified)"), pc.dim(dest));
70
+ }
71
+ return;
72
+ }
73
+ if (wanted.length) {
74
+ const { installed } = await installManagedAssets({ files: wanted, sidecarFp });
75
+ if (skills) console.log(pc.green("Skills installed:"), pc.dim(SKILL_NAMES.join(", ")));
76
+ if (agents) {
77
+ console.log(pc.green("Subagent template installed:"), pc.dim(AGENT_BASENAMES.join(", ")));
78
+ }
79
+ void installed;
80
+ }
81
+ if (unwanted.length) {
82
+ const { skipped } = await removeManagedAssets({
83
+ sidecarFp,
84
+ dests: unwanted.map((f) => f.dest)
85
+ });
86
+ for (const fp of skipped) console.warn(pc.yellow("Kept (modified by user):"), fp);
87
+ }
88
+ } catch (e) {
89
+ console.warn(pc.yellow("Could not configure vkm skills/agents (skipped):"), e?.message || e);
90
+ }
91
+ }
92
+
93
+ /** Full teardown of both groups (hash-guarded). */
94
+ export async function uninstallSkillAssets(home, dryRun) {
95
+ await configureSkillAssets(home, dryRun, { skills: false, agents: false });
96
+ }
@@ -0,0 +1,154 @@
1
+ // vkm-kit local telemetry installer (ADR-0044): wires Claude Code's OTLP metrics export to
2
+ // the local vkm-otel-sink so `vkm-doctor` has real usage data. LOCAL ONLY by construction —
3
+ // the endpoint is 127.0.0.1 and the sink persists to `~/.vkm/telemetry/`; nothing leaves
4
+ // the machine. Two managed pieces, reconciled symmetrically like every other module:
5
+ // (1) the four OTEL env vars in settings.json (`env` block, removal only reverts values
6
+ // still exactly ours), (2) a SessionStart hook that spawns the sink when it isn't running.
7
+ // The sink script itself lives in the kit checkout (`packages/vkm-doctor/`), same
8
+ // wire-by-absolute-path model as the hybrid MCP server — so telemetry needs a kit clone
9
+ // (callers pass `kitRoot`; without one this module is skipped with a hint).
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import fse from "fs-extra";
13
+ import pc from "picocolors";
14
+ import {
15
+ atomicWriteJson,
16
+ backupRestricted,
17
+ isKitOwnedFile,
18
+ mergeManagedHook,
19
+ readSettingsSafe,
20
+ removeManagedHook,
21
+ setOrDeleteHooks
22
+ } from "./settings-io.mjs";
23
+ import { mergeManagedEnv, removeManagedEnv } from "./settings-writers.mjs";
24
+
25
+ export const ENSURE_SINK_HOOK_STEM = "ensure-otel-sink";
26
+ export const ENSURE_SINK_HOOK_BASENAME = `${ENSURE_SINK_HOOK_STEM}.mjs`;
27
+
28
+ /** The managed env block (values must stay in sync with vkm-doctor's DEFAULT_PORT). */
29
+ export const TELEMETRY_ENV = {
30
+ CLAUDE_CODE_ENABLE_TELEMETRY: "1",
31
+ OTEL_METRICS_EXPORTER: "otlp",
32
+ OTEL_EXPORTER_OTLP_PROTOCOL: "http/json",
33
+ OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4319"
34
+ };
35
+
36
+ function packagedHookPath(basename) {
37
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "hooks", basename);
38
+ }
39
+
40
+ /** `<kitRoot>/packages/vkm-doctor/src/otel-sink.mjs` (the script the hook spawns). */
41
+ export function sinkScriptFromKitRoot(kitRoot) {
42
+ return path.join(kitRoot, "packages", "vkm-doctor", "src", "otel-sink.mjs");
43
+ }
44
+
45
+ /**
46
+ * Install / reconcile / remove local telemetry. Same symmetric contract as the sibling
47
+ * modules; `enable:false` strips a prior install. Needs `kitRoot` only when enabling.
48
+ * @param {string} home
49
+ * @param {boolean} dryRun
50
+ * @param {{ enable?: boolean, kitRoot?: string | null }} [opts]
51
+ */
52
+ export async function configureTelemetry(home, dryRun, { enable = true, kitRoot = null } = {}) {
53
+ const claudeDir = path.join(home, ".claude");
54
+ const hooksDir = path.join(claudeDir, "hooks");
55
+ const settingsFp = path.join(claudeDir, "settings.json");
56
+ const hookDest = path.join(hooksDir, ENSURE_SINK_HOOK_BASENAME);
57
+
58
+ try {
59
+ if (enable && !kitRoot) {
60
+ console.log(
61
+ pc.dim("Telemetry skipped: needs a kit clone (--repo-root) to host the OTLP sink.")
62
+ );
63
+ return;
64
+ }
65
+ let { existing, priorBytes, invalidJson, rawText } = await readSettingsSafe(settingsFp);
66
+ const oursPresent =
67
+ rawText.includes(ENSURE_SINK_HOOK_STEM) || rawText.includes("CLAUDE_CODE_ENABLE_TELEMETRY");
68
+ if (!enable && !oursPresent) return;
69
+
70
+ if (dryRun) {
71
+ console.log(
72
+ pc.cyan(
73
+ enable
74
+ ? "[dry-run] would enable LOCAL telemetry (OTEL env → 127.0.0.1:4319 + SessionStart sink hook)"
75
+ : "[dry-run] would remove the telemetry env block + sink hook"
76
+ )
77
+ );
78
+ return;
79
+ }
80
+
81
+ const sinkScript = enable ? sinkScriptFromKitRoot(kitRoot) : null;
82
+ if (enable) {
83
+ await fse.ensureDir(hooksDir);
84
+ await fse.copy(packagedHookPath(ENSURE_SINK_HOOK_BASENAME), hookDest, { overwrite: true });
85
+ }
86
+
87
+ if (invalidJson) {
88
+ const bak = await backupRestricted(settingsFp, priorBytes);
89
+ console.warn(pc.yellow("Invalid JSON in ~/.claude/settings.json; backed up to"), bak);
90
+ existing = {};
91
+ }
92
+
93
+ let merged = enable
94
+ ? mergeManagedEnv(existing, TELEMETRY_ENV)
95
+ : removeManagedEnv(existing, TELEMETRY_ENV);
96
+ const hadHooks =
97
+ merged.hooks && typeof merged.hooks === "object" && !Array.isArray(merged.hooks);
98
+ let hookMap = hadHooks ? merged.hooks : {};
99
+ hookMap = enable
100
+ ? mergeManagedHook(
101
+ hookMap,
102
+ "SessionStart",
103
+ "*",
104
+ { command: "node", args: [hookDest, sinkScript] },
105
+ ENSURE_SINK_HOOK_STEM
106
+ )
107
+ : removeManagedHook(hookMap, "SessionStart", ENSURE_SINK_HOOK_STEM);
108
+ merged = setOrDeleteHooks(merged, Boolean(hadHooks), hookMap);
109
+
110
+ if (priorBytes && !invalidJson) {
111
+ const bak = await backupRestricted(settingsFp, priorBytes);
112
+ console.log(pc.dim("Backed up previous settings.json to"), bak);
113
+ }
114
+ await atomicWriteJson(settingsFp, merged);
115
+
116
+ if (enable) {
117
+ console.log(
118
+ pc.green("Local telemetry:"),
119
+ pc.dim("OTEL → 127.0.0.1:4319 → ~/.vkm/telemetry/ (run `vkm-doctor` for the report)")
120
+ );
121
+ } else if (oursPresent) {
122
+ console.log(pc.green("Local telemetry removed:"), settingsFp);
123
+ }
124
+ } catch (e) {
125
+ console.warn(pc.yellow("Could not configure telemetry (skipped):"), e?.message || e);
126
+ }
127
+ }
128
+
129
+ /** Full teardown: settings pieces + the hook script file (marker-checked). */
130
+ export async function uninstallTelemetry(home, dryRun) {
131
+ await configureTelemetry(home, dryRun, { enable: false });
132
+ const fp = path.join(home, ".claude", "hooks", ENSURE_SINK_HOOK_BASENAME);
133
+ if (!(await fse.pathExists(fp))) return;
134
+ const owned = await isKitOwnedFile(fp);
135
+ if (dryRun) {
136
+ console.log(
137
+ owned
138
+ ? pc.cyan("[dry-run] would remove")
139
+ : pc.yellow("[dry-run] would SKIP (not recognized as this kit's file)"),
140
+ pc.dim(fp)
141
+ );
142
+ return;
143
+ }
144
+ if (!owned) {
145
+ console.warn(pc.yellow("Skipped (not recognized as this kit's file):"), fp);
146
+ return;
147
+ }
148
+ try {
149
+ await fse.remove(fp);
150
+ console.log(pc.green("Removed"), pc.dim(fp));
151
+ } catch (e) {
152
+ console.warn(pc.yellow("Could not remove"), fp, e?.message || e);
153
+ }
154
+ }