vibe-code-explainer 0.3.4 → 0.3.5

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 (35) hide show
  1. package/dist/chunk-GEAH6PTG.js +37 -0
  2. package/dist/chunk-GEAH6PTG.js.map +1 -0
  3. package/dist/chunk-GU4Y5ZWY.js +140 -0
  4. package/dist/chunk-GU4Y5ZWY.js.map +1 -0
  5. package/dist/{chunk-2PUO5G3C.js → chunk-KK76JK7S.js} +32 -92
  6. package/dist/chunk-KK76JK7S.js.map +1 -0
  7. package/dist/{chunk-XW3S5GNV.js → chunk-VJN7Y4SI.js} +114 -33
  8. package/dist/chunk-VJN7Y4SI.js.map +1 -0
  9. package/dist/{chunk-ABPTVWQ3.js → chunk-ZZY3IDL2.js} +86 -85
  10. package/dist/chunk-ZZY3IDL2.js.map +1 -0
  11. package/dist/cli/index.js +37 -9
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/{config-AHHWBME7.js → config-YLMDBCIR.js} +116 -6
  14. package/dist/config-YLMDBCIR.js.map +1 -0
  15. package/dist/hooks/post-tool.js +143 -162
  16. package/dist/hooks/post-tool.js.map +1 -1
  17. package/dist/{init-XXK6SGF2.js → init-UDODKO25.js} +12 -16
  18. package/dist/init-UDODKO25.js.map +1 -0
  19. package/dist/ollama-YSRRK7LL.js +12 -0
  20. package/dist/{schema-YEJIXFMK.js → schema-R3THK35H.js} +8 -4
  21. package/dist/{tracker-Z5EEYUUZ.js → tracker-Y2G5DW6Y.js} +2 -2
  22. package/dist/{uninstall-AIH4HVPZ.js → uninstall-5RVTDKTA.js} +3 -3
  23. package/package.json +3 -2
  24. package/dist/chunk-2PUO5G3C.js.map +0 -1
  25. package/dist/chunk-ABPTVWQ3.js.map +0 -1
  26. package/dist/chunk-RK7ZFN4W.js +0 -97
  27. package/dist/chunk-RK7ZFN4W.js.map +0 -1
  28. package/dist/chunk-XW3S5GNV.js.map +0 -1
  29. package/dist/config-AHHWBME7.js.map +0 -1
  30. package/dist/init-XXK6SGF2.js.map +0 -1
  31. package/dist/ollama-2WHLTTDD.js +0 -14
  32. /package/dist/{ollama-2WHLTTDD.js.map → ollama-YSRRK7LL.js.map} +0 -0
  33. /package/dist/{schema-YEJIXFMK.js.map → schema-R3THK35H.js.map} +0 -0
  34. /package/dist/{tracker-Z5EEYUUZ.js.map → tracker-Y2G5DW6Y.js.map} +0 -0
  35. /package/dist/{uninstall-AIH4HVPZ.js.map → uninstall-5RVTDKTA.js.map} +0 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/flags.ts
4
+ function parseFlags(args) {
5
+ const flags = {};
6
+ const positional = [];
7
+ let i = 0;
8
+ while (i < args.length) {
9
+ const arg = args[i];
10
+ if (arg.startsWith("--")) {
11
+ const eqIdx = arg.indexOf("=");
12
+ if (eqIdx !== -1) {
13
+ const name = arg.slice(2, eqIdx);
14
+ const value = arg.slice(eqIdx + 1);
15
+ flags[name] = value;
16
+ } else {
17
+ flags[arg.slice(2)] = true;
18
+ }
19
+ } else if (arg.startsWith("-") && arg.length === 2) {
20
+ const name = arg.slice(1);
21
+ flags[name] = true;
22
+ } else {
23
+ positional.push(arg);
24
+ }
25
+ i++;
26
+ }
27
+ return { flags, positional };
28
+ }
29
+ function flagBool(flags, ...names) {
30
+ return names.some((n) => flags[n] === true || flags[n] !== void 0);
31
+ }
32
+
33
+ export {
34
+ parseFlags,
35
+ flagBool
36
+ };
37
+ //# sourceMappingURL=chunk-GEAH6PTG.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/flags.ts"],"sourcesContent":["/**\n * Hand-rolled CLI flag parser — no library dependency.\n *\n * Supports:\n * --flag boolean flag (value: true)\n * --flag=value string flag\n * --flag value string flag (next positional arg is consumed as value)\n *\n * Returns:\n * flags — Record of flag name → string | true\n * positional — remaining args that are not flags or flag values\n */\nexport interface ParsedFlags {\n flags: Record<string, string | true>;\n positional: string[];\n}\n\nexport function parseFlags(args: string[]): ParsedFlags {\n const flags: Record<string, string | true> = {};\n const positional: string[] = [];\n\n let i = 0;\n while (i < args.length) {\n const arg = args[i];\n\n if (arg.startsWith(\"--\")) {\n const eqIdx = arg.indexOf(\"=\");\n if (eqIdx !== -1) {\n // --flag=value (only this form captures a string value)\n const name = arg.slice(2, eqIdx);\n const value = arg.slice(eqIdx + 1);\n flags[name] = value;\n } else {\n // --flag (always boolean; no peek-consume to avoid positional ambiguity)\n flags[arg.slice(2)] = true;\n }\n } else if (arg.startsWith(\"-\") && arg.length === 2) {\n // Short flags: -j, -y\n const name = arg.slice(1);\n flags[name] = true;\n } else {\n positional.push(arg);\n }\n\n i++;\n }\n\n return { flags, positional };\n}\n\nexport function flagBool(flags: Record<string, string | true>, ...names: string[]): boolean {\n return names.some((n) => flags[n] === true || flags[n] !== undefined);\n}\n\nexport function flagString(\n flags: Record<string, string | true>,\n name: string\n): string | undefined {\n const v = flags[name];\n return typeof v === \"string\" ? v : undefined;\n}\n"],"mappings":";;;AAiBO,SAAS,WAAW,MAA6B;AACtD,QAAM,QAAuC,CAAC;AAC9C,QAAM,aAAuB,CAAC;AAE9B,MAAI,IAAI;AACR,SAAO,IAAI,KAAK,QAAQ;AACtB,UAAM,MAAM,KAAK,CAAC;AAElB,QAAI,IAAI,WAAW,IAAI,GAAG;AACxB,YAAM,QAAQ,IAAI,QAAQ,GAAG;AAC7B,UAAI,UAAU,IAAI;AAEhB,cAAM,OAAO,IAAI,MAAM,GAAG,KAAK;AAC/B,cAAM,QAAQ,IAAI,MAAM,QAAQ,CAAC;AACjC,cAAM,IAAI,IAAI;AAAA,MAChB,OAAO;AAEL,cAAM,IAAI,MAAM,CAAC,CAAC,IAAI;AAAA,MACxB;AAAA,IACF,WAAW,IAAI,WAAW,GAAG,KAAK,IAAI,WAAW,GAAG;AAElD,YAAM,OAAO,IAAI,MAAM,CAAC;AACxB,YAAM,IAAI,IAAI;AAAA,IAChB,OAAO;AACL,iBAAW,KAAK,GAAG;AAAA,IACrB;AAEA;AAAA,EACF;AAEA,SAAO,EAAE,OAAO,WAAW;AAC7B;AAEO,SAAS,SAAS,UAAyC,OAA0B;AAC1F,SAAO,MAAM,KAAK,CAAC,MAAM,MAAM,CAAC,MAAM,QAAQ,MAAM,CAAC,MAAM,MAAS;AACtE;","names":[]}
@@ -0,0 +1,140 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config/schema.ts
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ import { z } from "zod";
8
+ var LANGUAGE_NAMES = {
9
+ en: "English",
10
+ pt: "Portuguese",
11
+ es: "Spanish",
12
+ fr: "French",
13
+ de: "German",
14
+ it: "Italian",
15
+ zh: "Chinese",
16
+ ja: "Japanese",
17
+ ko: "Korean"
18
+ };
19
+ var LEARNER_LEVEL_NAMES = {
20
+ none: "Never programmed",
21
+ beginner: "Just starting out",
22
+ intermediate: "Read code with difficulty",
23
+ regular: "Code regularly"
24
+ };
25
+ var CONFIG_FILENAME = "code-explainer.config.json";
26
+ function getGlobalConfigPath() {
27
+ return join(homedir(), ".code-explainer.config.json");
28
+ }
29
+ var DEFAULT_CONFIG = {
30
+ engine: "ollama",
31
+ ollamaModel: "qwen3.5:4b",
32
+ ollamaUrl: "http://localhost:11434",
33
+ detailLevel: "standard",
34
+ language: "en",
35
+ learnerLevel: "intermediate",
36
+ hooks: {
37
+ edit: true,
38
+ write: true,
39
+ bash: true
40
+ },
41
+ exclude: ["*.lock", "dist/**", "node_modules/**"],
42
+ skipIfSlowMs: 3e4,
43
+ bashFilter: {
44
+ capturePatterns: [
45
+ "rm",
46
+ "mv",
47
+ "cp",
48
+ "mkdir",
49
+ "npm install",
50
+ "pip install",
51
+ "yarn add",
52
+ "pnpm add",
53
+ "chmod",
54
+ "chown",
55
+ "git checkout",
56
+ "git reset",
57
+ "git revert",
58
+ "sed -i"
59
+ ]
60
+ }
61
+ };
62
+ var EngineSchema = z.enum(["ollama", "claude"]);
63
+ var DetailLevelSchema = z.enum(["minimal", "standard", "verbose"]);
64
+ var LanguageSchema = z.enum(["en", "pt", "es", "fr", "de", "it", "zh", "ja", "ko"]);
65
+ var LearnerLevelSchema = z.enum(["none", "beginner", "intermediate", "regular"]);
66
+ var HooksConfigSchema = z.object({
67
+ edit: z.boolean().default(true),
68
+ write: z.boolean().default(true),
69
+ bash: z.boolean().default(true)
70
+ }).default({});
71
+ var BashFilterConfigSchema = z.object({
72
+ capturePatterns: z.array(z.string()).default([])
73
+ }).default({});
74
+ var ConfigSchema = z.object({
75
+ engine: EngineSchema.default("ollama"),
76
+ ollamaModel: z.string().min(1).default("qwen3.5:4b"),
77
+ ollamaUrl: z.string().url().default("http://localhost:11434"),
78
+ detailLevel: DetailLevelSchema.default("standard"),
79
+ language: LanguageSchema.default("en"),
80
+ learnerLevel: LearnerLevelSchema.default("intermediate"),
81
+ hooks: HooksConfigSchema,
82
+ exclude: z.array(z.string()).default(["*.lock", "dist/**", "node_modules/**"]),
83
+ skipIfSlowMs: z.coerce.number().int().min(0).default(3e4),
84
+ bashFilter: BashFilterConfigSchema
85
+ });
86
+ function validateConfig(raw) {
87
+ const result = ConfigSchema.safeParse(raw);
88
+ if (result.success) return result.data;
89
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
90
+ throw new Error(`[code-explainer] Invalid config:
91
+ ${issues}`);
92
+ }
93
+ function mergeConfig(base, overlay) {
94
+ return {
95
+ ...base,
96
+ ...overlay,
97
+ hooks: { ...base.hooks, ...overlay.hooks ?? {} },
98
+ bashFilter: {
99
+ ...base.bashFilter,
100
+ ...overlay.bashFilter ?? {}
101
+ }
102
+ };
103
+ }
104
+ function tryReadJson(path) {
105
+ if (!existsSync(path)) return null;
106
+ let raw;
107
+ try {
108
+ raw = JSON.parse(readFileSync(path, "utf-8"));
109
+ } catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ throw new Error(`[code-explainer] Config file ${path} is not valid JSON: ${msg}`);
112
+ }
113
+ const partial = ConfigSchema.partial().safeParse(raw);
114
+ if (!partial.success) {
115
+ const issues = partial.error.issues.map((i) => ` ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
116
+ throw new Error(`[code-explainer] Invalid config in ${path}:
117
+ ${issues}`);
118
+ }
119
+ return partial.data;
120
+ }
121
+ function loadConfig(configPath) {
122
+ const globalConfig = tryReadJson(getGlobalConfigPath());
123
+ const projectConfig = tryReadJson(configPath);
124
+ let result = DEFAULT_CONFIG;
125
+ if (globalConfig) result = mergeConfig(result, globalConfig);
126
+ if (projectConfig) result = mergeConfig(result, projectConfig);
127
+ return result;
128
+ }
129
+
130
+ export {
131
+ LANGUAGE_NAMES,
132
+ LEARNER_LEVEL_NAMES,
133
+ CONFIG_FILENAME,
134
+ getGlobalConfigPath,
135
+ DEFAULT_CONFIG,
136
+ ConfigSchema,
137
+ validateConfig,
138
+ loadConfig
139
+ };
140
+ //# sourceMappingURL=chunk-GU4Y5ZWY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config/schema.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join } from \"node:path\";\nimport { z } from \"zod\";\n\nexport type Engine = \"ollama\" | \"claude\";\nexport type DetailLevel = \"minimal\" | \"standard\" | \"verbose\";\nexport type RiskLevel = \"none\" | \"low\" | \"medium\" | \"high\";\n\nexport type Language =\n | \"en\"\n | \"pt\"\n | \"es\"\n | \"fr\"\n | \"de\"\n | \"it\"\n | \"zh\"\n | \"ja\"\n | \"ko\";\n\nexport const LANGUAGE_NAMES: Record<Language, string> = {\n en: \"English\",\n pt: \"Portuguese\",\n es: \"Spanish\",\n fr: \"French\",\n de: \"German\",\n it: \"Italian\",\n zh: \"Chinese\",\n ja: \"Japanese\",\n ko: \"Korean\",\n};\n\nexport type LearnerLevel = \"none\" | \"beginner\" | \"intermediate\" | \"regular\";\n\nexport const LEARNER_LEVEL_NAMES: Record<LearnerLevel, string> = {\n none: \"Never programmed\",\n beginner: \"Just starting out\",\n intermediate: \"Read code with difficulty\",\n regular: \"Code regularly\",\n};\n\nexport interface HooksConfig {\n edit: boolean;\n write: boolean;\n bash: boolean;\n}\n\nexport interface BashFilterConfig {\n capturePatterns: string[];\n}\n\nexport interface Config {\n engine: Engine;\n ollamaModel: string;\n ollamaUrl: string;\n detailLevel: DetailLevel;\n language: Language;\n learnerLevel: LearnerLevel;\n hooks: HooksConfig;\n exclude: string[];\n skipIfSlowMs: number;\n bashFilter: BashFilterConfig;\n}\n\nexport interface DeepDiveItem {\n term: string;\n explanation: string;\n}\n\nexport interface ExplanationResult {\n impact: string;\n howItWorks: string;\n why: string;\n deepDive: DeepDiveItem[];\n isSamePattern: boolean;\n samePatternNote: string;\n risk: RiskLevel;\n riskReason: string;\n}\n\nexport interface HookPayload {\n session_id: string;\n transcript_path: string;\n cwd: string;\n permission_mode: string;\n hook_event_name: string;\n tool_name: string;\n tool_input: Record<string, unknown>;\n // Claude Code sends this as an object for Edit/Write/MultiEdit and a string\n // for Bash; type it as unknown so consumers validate before use.\n tool_response: unknown;\n}\n\nexport const CONFIG_FILENAME = \"code-explainer.config.json\";\n\nexport function getGlobalConfigPath(): string {\n return join(homedir(), \".code-explainer.config.json\");\n}\n\nexport const DEFAULT_CONFIG: Config = {\n engine: \"ollama\",\n ollamaModel: \"qwen3.5:4b\",\n ollamaUrl: \"http://localhost:11434\",\n detailLevel: \"standard\",\n language: \"en\",\n learnerLevel: \"intermediate\",\n hooks: {\n edit: true,\n write: true,\n bash: true,\n },\n exclude: [\"*.lock\", \"dist/**\", \"node_modules/**\"],\n skipIfSlowMs: 30000,\n bashFilter: {\n capturePatterns: [\n \"rm\",\n \"mv\",\n \"cp\",\n \"mkdir\",\n \"npm install\",\n \"pip install\",\n \"yarn add\",\n \"pnpm add\",\n \"chmod\",\n \"chown\",\n \"git checkout\",\n \"git reset\",\n \"git revert\",\n \"sed -i\",\n ],\n },\n};\n\n// ---------------------------------------------------------------------------\n// Zod schema — used to validate and parse config files at load time.\n// Using z.coerce where sensible so that older config files with slightly\n// different types (e.g., skipIfSlowMs stored as a string) still work.\n// ---------------------------------------------------------------------------\n\nconst EngineSchema = z.enum([\"ollama\", \"claude\"]);\nconst DetailLevelSchema = z.enum([\"minimal\", \"standard\", \"verbose\"]);\nconst LanguageSchema = z.enum([\"en\", \"pt\", \"es\", \"fr\", \"de\", \"it\", \"zh\", \"ja\", \"ko\"]);\nconst LearnerLevelSchema = z.enum([\"none\", \"beginner\", \"intermediate\", \"regular\"]);\n\nconst HooksConfigSchema = z.object({\n edit: z.boolean().default(true),\n write: z.boolean().default(true),\n bash: z.boolean().default(true),\n}).default({});\n\nconst BashFilterConfigSchema = z.object({\n capturePatterns: z.array(z.string()).default([]),\n}).default({});\n\nexport const ConfigSchema = z.object({\n engine: EngineSchema.default(\"ollama\"),\n ollamaModel: z.string().min(1).default(\"qwen3.5:4b\"),\n ollamaUrl: z.string().url().default(\"http://localhost:11434\"),\n detailLevel: DetailLevelSchema.default(\"standard\"),\n language: LanguageSchema.default(\"en\"),\n learnerLevel: LearnerLevelSchema.default(\"intermediate\"),\n hooks: HooksConfigSchema,\n exclude: z.array(z.string()).default([\"*.lock\", \"dist/**\", \"node_modules/**\"]),\n skipIfSlowMs: z.coerce.number().int().min(0).default(30000),\n bashFilter: BashFilterConfigSchema,\n});\n\nexport type ConfigInput = z.input<typeof ConfigSchema>;\n\n/**\n * Validate a raw JSON object against the config schema.\n * Returns a typed Config on success, or throws a ZodError-derived Error with\n * a human-readable message listing all invalid fields.\n */\nexport function validateConfig(raw: unknown): Config {\n const result = ConfigSchema.safeParse(raw);\n if (result.success) return result.data;\n\n const issues = result.error.issues\n .map((i) => ` ${i.path.join(\".\") || \"<root>\"}: ${i.message}`)\n .join(\"\\n\");\n throw new Error(`[code-explainer] Invalid config:\\n${issues}`);\n}\n\nfunction mergeConfig(base: Config, overlay: Partial<Config>): Config {\n return {\n ...base,\n ...overlay,\n hooks: { ...base.hooks, ...(overlay.hooks ?? {}) },\n bashFilter: {\n ...base.bashFilter,\n ...(overlay.bashFilter ?? {}),\n },\n };\n}\n\n/**\n * Read and parse a config file. Returns the partial config or null if the\n * file is missing. Throws if the JSON is malformed or fails schema validation\n * (so callers surface useful errors rather than silently using defaults).\n */\nfunction tryReadJson(path: string): Partial<Config> | null {\n if (!existsSync(path)) return null;\n let raw: unknown;\n try {\n raw = JSON.parse(readFileSync(path, \"utf-8\"));\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[code-explainer] Config file ${path} is not valid JSON: ${msg}`);\n }\n // Partial validation: only validate keys that are present. Unknown keys are\n // ignored (forward-compat). We re-use ConfigSchema with .partial() so that\n // missing keys fall through to the DEFAULT_CONFIG merger rather than errors.\n const partial = ConfigSchema.partial().safeParse(raw);\n if (!partial.success) {\n const issues = partial.error.issues\n .map((i) => ` ${i.path.join(\".\") || \"<root>\"}: ${i.message}`)\n .join(\"\\n\");\n throw new Error(`[code-explainer] Invalid config in ${path}:\\n${issues}`);\n }\n return partial.data as Partial<Config>;\n}\n\n/**\n * Load config with three-level resolution, most specific first:\n * 1. Project config (passed as configPath) — overrides everything\n * 2. Global user config (~/.code-explainer.config.json)\n * 3. Built-in defaults\n *\n * A project config that lacks a field falls through to the global; a global\n * that lacks a field falls through to defaults. This lets a global install\n * set everyone's defaults while still allowing per-project overrides.\n */\nexport function loadConfig(configPath: string): Config {\n const globalConfig = tryReadJson(getGlobalConfigPath());\n const projectConfig = tryReadJson(configPath);\n\n let result = DEFAULT_CONFIG;\n if (globalConfig) result = mergeConfig(result, globalConfig);\n if (projectConfig) result = mergeConfig(result, projectConfig);\n return result;\n}\n"],"mappings":"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","names":[]}
@@ -26,10 +26,7 @@ function buildCodeExplainerEntries(hookScriptPath) {
26
26
  function isCodeExplainerHook(cmd) {
27
27
  return cmd.includes(HOOK_MARKER) && cmd.includes("post-tool");
28
28
  }
29
- function mergeHooksIntoSettings(projectRoot, hookScriptPath, { useLocal = true } = {}) {
30
- const claudeDir = join(projectRoot, ".claude");
31
- const filename = useLocal ? "settings.local.json" : "settings.json";
32
- const settingsPath = join(claudeDir, filename);
29
+ function mergeHooksAtPath(settingsPath, hookScriptPath) {
33
30
  let settings = {};
34
31
  let created = false;
35
32
  if (existsSync(settingsPath)) {
@@ -49,8 +46,9 @@ function mergeHooksIntoSettings(projectRoot, hookScriptPath, { useLocal = true }
49
46
  }
50
47
  } else {
51
48
  created = true;
52
- if (!existsSync(claudeDir)) {
53
- mkdirSync(claudeDir, { recursive: true });
49
+ const parent = dirname(settingsPath);
50
+ if (!existsSync(parent)) {
51
+ mkdirSync(parent, { recursive: true });
54
52
  }
55
53
  }
56
54
  if (!settings.hooks) settings.hooks = {};
@@ -64,90 +62,7 @@ function mergeHooksIntoSettings(projectRoot, hookScriptPath, { useLocal = true }
64
62
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
65
63
  return { created, path: settingsPath };
66
64
  }
67
- function removeHooksFromSettings(projectRoot, { useLocal = true } = {}) {
68
- const candidates = useLocal ? [".claude/settings.local.json", ".claude/settings.json"] : [".claude/settings.json"];
69
- let removedAny = false;
70
- let lastPath = null;
71
- for (const rel of candidates) {
72
- const path = join(projectRoot, rel);
73
- if (!existsSync(path)) continue;
74
- let settings;
75
- try {
76
- settings = JSON.parse(readFileSync(path, "utf-8"));
77
- } catch {
78
- continue;
79
- }
80
- if (!settings.hooks?.PostToolUse) continue;
81
- const before = JSON.stringify(settings.hooks.PostToolUse);
82
- settings.hooks.PostToolUse = settings.hooks.PostToolUse.map((entry) => ({
83
- ...entry,
84
- hooks: entry.hooks.filter((h) => !isCodeExplainerHook(h.command))
85
- })).filter((entry) => entry.hooks.length > 0);
86
- const after = JSON.stringify(settings.hooks.PostToolUse);
87
- if (before !== after) {
88
- if (settings.hooks.PostToolUse.length === 0) {
89
- delete settings.hooks.PostToolUse;
90
- }
91
- if (Object.keys(settings.hooks).length === 0) {
92
- delete settings.hooks;
93
- }
94
- writeFileSync(path, JSON.stringify(settings, null, 2) + "\n");
95
- removedAny = true;
96
- lastPath = path;
97
- }
98
- }
99
- return { removed: removedAny, path: lastPath };
100
- }
101
- function mergeHooksIntoUserSettings(hookScriptPath) {
102
- const userClaudeDir = join(homedir(), ".claude");
103
- const settingsPath = join(userClaudeDir, "settings.json");
104
- let settings = {};
105
- let created = false;
106
- if (existsSync(settingsPath)) {
107
- const raw = readFileSync(settingsPath, "utf-8");
108
- try {
109
- settings = JSON.parse(raw);
110
- } catch (err) {
111
- const msg = err instanceof Error ? err.message : String(err);
112
- throw new Error(
113
- `[code-explainer] Cannot merge hooks into ${settingsPath}. The file is not valid JSON. Fix: repair the JSON manually or delete the file to regenerate. Original error: ${msg}`
114
- );
115
- }
116
- if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
117
- throw new Error(
118
- `[code-explainer] Cannot merge hooks into ${settingsPath}. The file does not contain a JSON object at the top level.`
119
- );
120
- }
121
- } else {
122
- created = true;
123
- if (!existsSync(userClaudeDir)) {
124
- mkdirSync(userClaudeDir, { recursive: true });
125
- }
126
- }
127
- if (!settings.hooks) settings.hooks = {};
128
- const ourEntries = {
129
- PostToolUse: [
130
- {
131
- matcher: "Edit|Write|MultiEdit",
132
- hooks: [{ type: "command", command: `node "${hookScriptPath}"` }]
133
- },
134
- {
135
- matcher: "Bash",
136
- hooks: [{ type: "command", command: `node "${hookScriptPath}"` }]
137
- }
138
- ]
139
- };
140
- const existingPostTool = settings.hooks.PostToolUse ?? [];
141
- const cleaned = existingPostTool.map((entry) => ({
142
- ...entry,
143
- hooks: entry.hooks.filter((h) => !isCodeExplainerHook(h.command))
144
- })).filter((entry) => entry.hooks.length > 0);
145
- settings.hooks.PostToolUse = [...cleaned, ...ourEntries.PostToolUse];
146
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
147
- return { created, path: settingsPath };
148
- }
149
- function removeHooksFromUserSettings() {
150
- const settingsPath = join(homedir(), ".claude", "settings.json");
65
+ function removeHooksAtPath(settingsPath) {
151
66
  if (!existsSync(settingsPath)) return { removed: false, path: null };
152
67
  let settings;
153
68
  try {
@@ -168,11 +83,36 @@ function removeHooksFromUserSettings() {
168
83
  writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
169
84
  return { removed: true, path: settingsPath };
170
85
  }
86
+ function mergeHooksIntoSettings(projectRoot, hookScriptPath, { useLocal = true } = {}) {
87
+ const filename = useLocal ? "settings.local.json" : "settings.json";
88
+ const settingsPath = join(projectRoot, ".claude", filename);
89
+ return mergeHooksAtPath(settingsPath, hookScriptPath);
90
+ }
91
+ function mergeHooksIntoUserSettings(hookScriptPath) {
92
+ const settingsPath = join(homedir(), ".claude", "settings.json");
93
+ return mergeHooksAtPath(settingsPath, hookScriptPath);
94
+ }
95
+ function removeHooksFromSettings(projectRoot, { useLocal = true } = {}) {
96
+ const candidates = useLocal ? [".claude/settings.local.json", ".claude/settings.json"] : [".claude/settings.json"];
97
+ let removedAny = false;
98
+ let lastPath = null;
99
+ for (const rel of candidates) {
100
+ const r = removeHooksAtPath(join(projectRoot, rel));
101
+ if (r.removed) {
102
+ removedAny = true;
103
+ lastPath = r.path;
104
+ }
105
+ }
106
+ return { removed: removedAny, path: lastPath };
107
+ }
108
+ function removeHooksFromUserSettings() {
109
+ return removeHooksAtPath(join(homedir(), ".claude", "settings.json"));
110
+ }
171
111
 
172
112
  export {
173
113
  mergeHooksIntoSettings,
174
- removeHooksFromSettings,
175
114
  mergeHooksIntoUserSettings,
115
+ removeHooksFromSettings,
176
116
  removeHooksFromUserSettings
177
117
  };
178
- //# sourceMappingURL=chunk-2PUO5G3C.js.map
118
+ //# sourceMappingURL=chunk-KK76JK7S.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config/merge.ts"],"sourcesContent":["import { existsSync, readFileSync, writeFileSync, mkdirSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\n\nexport const HOOK_MARKER = \"code-explainer\";\n\ninterface HookMatcherEntry {\n matcher: string;\n hooks: Array<{\n type: \"command\";\n command: string;\n }>;\n}\n\ninterface ClaudeSettings {\n hooks?: Record<string, HookMatcherEntry[]>;\n [key: string]: unknown;\n}\n\nfunction buildHookCommand(hookScriptPath: string): string {\n return `node \"${hookScriptPath}\"`;\n}\n\nfunction buildCodeExplainerEntries(hookScriptPath: string): Record<string, HookMatcherEntry[]> {\n const command = buildHookCommand(hookScriptPath);\n return {\n PostToolUse: [\n {\n matcher: \"Edit|Write|MultiEdit\",\n hooks: [{ type: \"command\", command }],\n },\n {\n matcher: \"Bash\",\n hooks: [{ type: \"command\", command }],\n },\n ],\n };\n}\n\nfunction isCodeExplainerHook(cmd: string): boolean {\n return cmd.includes(HOOK_MARKER) && cmd.includes(\"post-tool\");\n}\n\nexport interface MergeResult {\n created: boolean;\n path: string;\n}\n\n/**\n * Core merge: read/parse/merge-and-write at a specific settings path.\n * Creates the parent dir if needed. Preserves all existing hooks and other\n * top-level keys. Idempotent — removes previous code-explainer entries\n * before adding the new ones so re-running does not duplicate.\n *\n * Throws if the existing file is malformed JSON or not a top-level object.\n */\nfunction mergeHooksAtPath(settingsPath: string, hookScriptPath: string): MergeResult {\n let settings: ClaudeSettings = {};\n let created = false;\n\n if (existsSync(settingsPath)) {\n const raw = readFileSync(settingsPath, \"utf-8\");\n try {\n settings = JSON.parse(raw);\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(\n `[code-explainer] Cannot merge hooks into ${settingsPath}. The file is not valid JSON. Fix: repair the JSON manually (check for trailing commas, unquoted keys) or delete the file to regenerate. Original error: ${msg}`\n );\n }\n if (typeof settings !== \"object\" || settings === null || Array.isArray(settings)) {\n throw new Error(\n `[code-explainer] Cannot merge hooks into ${settingsPath}. The file does not contain a JSON object at the top level. Fix: ensure the file starts with { and ends with }.`\n );\n }\n } else {\n created = true;\n const parent = dirname(settingsPath);\n if (!existsSync(parent)) {\n mkdirSync(parent, { recursive: true });\n }\n }\n\n if (!settings.hooks) settings.hooks = {};\n\n const ourEntries = buildCodeExplainerEntries(hookScriptPath);\n const existingPostTool = settings.hooks.PostToolUse ?? [];\n\n // Remove any previous code-explainer entries to keep idempotency.\n const cleaned = existingPostTool\n .map((entry) => ({\n ...entry,\n hooks: entry.hooks.filter((h) => !isCodeExplainerHook(h.command)),\n }))\n .filter((entry) => entry.hooks.length > 0);\n\n settings.hooks.PostToolUse = [...cleaned, ...ourEntries.PostToolUse];\n\n writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + \"\\n\");\n\n return { created, path: settingsPath };\n}\n\n/**\n * Core remove: read/filter/write at a specific settings path.\n * Silently skips missing or malformed files (we don't want to corrupt\n * unrelated user config during an uninstall).\n */\nfunction removeHooksAtPath(settingsPath: string): { removed: boolean; path: string | null } {\n if (!existsSync(settingsPath)) return { removed: false, path: null };\n\n let settings: ClaudeSettings;\n try {\n settings = JSON.parse(readFileSync(settingsPath, \"utf-8\"));\n } catch {\n return { removed: false, path: null };\n }\n\n if (!settings.hooks?.PostToolUse) return { removed: false, path: null };\n\n const before = JSON.stringify(settings.hooks.PostToolUse);\n settings.hooks.PostToolUse = settings.hooks.PostToolUse\n .map((entry) => ({\n ...entry,\n hooks: entry.hooks.filter((h) => !isCodeExplainerHook(h.command)),\n }))\n .filter((entry) => entry.hooks.length > 0);\n const after = JSON.stringify(settings.hooks.PostToolUse);\n\n if (before === after) return { removed: false, path: null };\n\n if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;\n if (Object.keys(settings.hooks).length === 0) delete settings.hooks;\n\n writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + \"\\n\");\n return { removed: true, path: settingsPath };\n}\n\n/**\n * Merge code-explainer hooks into a project's .claude/settings[.local].json.\n */\nexport function mergeHooksIntoSettings(\n projectRoot: string,\n hookScriptPath: string,\n { useLocal = true }: { useLocal?: boolean } = {}\n): MergeResult {\n const filename = useLocal ? \"settings.local.json\" : \"settings.json\";\n const settingsPath = join(projectRoot, \".claude\", filename);\n return mergeHooksAtPath(settingsPath, hookScriptPath);\n}\n\n/**\n * Merge code-explainer hooks into the user-level ~/.claude/settings.json,\n * so hooks fire in every project. Used by the global install path.\n */\nexport function mergeHooksIntoUserSettings(hookScriptPath: string): MergeResult {\n const settingsPath = join(homedir(), \".claude\", \"settings.json\");\n return mergeHooksAtPath(settingsPath, hookScriptPath);\n}\n\n/**\n * Remove all code-explainer hook entries from a project's settings files,\n * preserving other hooks and config.\n */\nexport function removeHooksFromSettings(\n projectRoot: string,\n { useLocal = true }: { useLocal?: boolean } = {}\n): { removed: boolean; path: string | null } {\n const candidates = useLocal\n ? [\".claude/settings.local.json\", \".claude/settings.json\"]\n : [\".claude/settings.json\"];\n\n let removedAny = false;\n let lastPath: string | null = null;\n\n for (const rel of candidates) {\n const r = removeHooksAtPath(join(projectRoot, rel));\n if (r.removed) {\n removedAny = true;\n lastPath = r.path;\n }\n }\n\n return { removed: removedAny, path: lastPath };\n}\n\n/**\n * Remove code-explainer hook entries from ~/.claude/settings.json.\n */\nexport function removeHooksFromUserSettings(): { removed: boolean; path: string | null } {\n return removeHooksAtPath(join(homedir(), \".claude\", \"settings.json\"));\n}\n"],"mappings":"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","names":[]}
@@ -4,31 +4,79 @@ import {
4
4
  } from "./chunk-7OCVIDC7.js";
5
5
 
6
6
  // src/session/tracker.ts
7
- import { existsSync as existsSync2, readFileSync as readFileSync2, appendFileSync as appendFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync2, readdirSync, statSync } from "fs";
8
- import { tmpdir as tmpdir2 } from "os";
9
- import { join as join2 } from "path";
7
+ import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2, appendFileSync as appendFileSync2, unlinkSync as unlinkSync2, readdirSync, statSync } from "fs";
8
+ import { join as join3 } from "path";
10
9
 
11
10
  // src/cache/explanation-cache.ts
12
11
  import { createHash } from "crypto";
13
- import { existsSync, readFileSync, appendFileSync, unlinkSync, mkdirSync } from "fs";
14
- import { tmpdir } from "os";
12
+ import { existsSync as existsSync2, readFileSync, appendFileSync, writeFileSync, renameSync, unlinkSync } from "fs";
13
+ import { join as join2 } from "path";
14
+
15
+ // src/session/session-id.ts
16
+ var SAFE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
17
+ function isSafeSessionId(id) {
18
+ return typeof id === "string" && SAFE_ID_PATTERN.test(id);
19
+ }
20
+ function assertSafeSessionId(id) {
21
+ if (!isSafeSessionId(id)) {
22
+ throw new Error(`unsafe session id: ${JSON.stringify(id)}`);
23
+ }
24
+ }
25
+
26
+ // src/session/tmpdir.ts
27
+ import { existsSync, mkdirSync } from "fs";
28
+ import { tmpdir, userInfo } from "os";
15
29
  import { join } from "path";
16
30
  function getUserTmpDir() {
17
- const dir = join(tmpdir(), `code-explainer-${process.getuid?.() ?? "user"}`);
31
+ let suffix;
32
+ try {
33
+ const info = userInfo();
34
+ suffix = typeof info.username === "string" && info.username ? info.username : "user";
35
+ } catch {
36
+ suffix = "user";
37
+ }
38
+ suffix = suffix.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || "user";
39
+ const dir = join(tmpdir(), `code-explainer-${suffix}`);
18
40
  if (!existsSync(dir)) {
19
41
  mkdirSync(dir, { recursive: true, mode: 448 });
20
42
  }
21
43
  return dir;
22
44
  }
45
+
46
+ // src/cache/explanation-cache.ts
47
+ var CACHE_ROTATE_THRESHOLD = 500;
48
+ var CACHE_COMPACT_TARGET = 250;
23
49
  function getCacheFilePath(sessionId) {
24
- return join(getUserTmpDir(), `cache-${sessionId}.jsonl`);
50
+ assertSafeSessionId(sessionId);
51
+ return join2(getUserTmpDir(), `cache-${sessionId}.jsonl`);
25
52
  }
26
53
  function hashDiff(diff) {
27
54
  return createHash("sha256").update(diff, "utf-8").digest("hex");
28
55
  }
56
+ function rotateCacheIfNeeded(path) {
57
+ try {
58
+ const content = readFileSync(path, "utf-8");
59
+ const lines = content.split("\n").filter((l) => l.trim());
60
+ if (lines.length <= CACHE_ROTATE_THRESHOLD) return;
61
+ const seen = /* @__PURE__ */ new Map();
62
+ for (const line2 of lines) {
63
+ try {
64
+ const entry = JSON.parse(line2);
65
+ seen.set(entry.hash, entry);
66
+ } catch {
67
+ }
68
+ }
69
+ const unique = Array.from(seen.values());
70
+ const compacted = unique.slice(-CACHE_COMPACT_TARGET);
71
+ const tmp = path + ".tmp";
72
+ writeFileSync(tmp, compacted.map((e) => JSON.stringify(e)).join("\n") + "\n", { mode: 384 });
73
+ renameSync(tmp, path);
74
+ } catch {
75
+ }
76
+ }
29
77
  function getCached(sessionId, diff) {
30
78
  const path = getCacheFilePath(sessionId);
31
- if (!existsSync(path)) return void 0;
79
+ if (!existsSync2(path)) return void 0;
32
80
  const hash = hashDiff(diff);
33
81
  try {
34
82
  const content = readFileSync(path, "utf-8");
@@ -52,12 +100,13 @@ function setCached(sessionId, diff, result) {
52
100
  const entry = { hash: hashDiff(diff), result };
53
101
  try {
54
102
  appendFileSync(path, JSON.stringify(entry) + "\n", { mode: 384 });
103
+ rotateCacheIfNeeded(path);
55
104
  } catch {
56
105
  }
57
106
  }
58
107
  function clearCache(sessionId) {
59
108
  const path = getCacheFilePath(sessionId);
60
- if (existsSync(path)) {
109
+ if (existsSync2(path)) {
61
110
  try {
62
111
  unlinkSync(path);
63
112
  } catch {
@@ -487,15 +536,10 @@ function stripAnsi(s) {
487
536
 
488
537
  // src/session/tracker.ts
489
538
  var TWO_HOURS_MS = 2 * 60 * 60 * 1e3;
490
- function getUserTmpDir2() {
491
- const dir = join2(tmpdir2(), `code-explainer-${process.getuid?.() ?? "user"}`);
492
- if (!existsSync2(dir)) {
493
- mkdirSync2(dir, { recursive: true, mode: 448 });
494
- }
495
- return dir;
496
- }
539
+ var CLEANUP_THROTTLE_MS = 60 * 1e3;
497
540
  function getSessionFilePath(sessionId) {
498
- return join2(getUserTmpDir2(), `session-${sessionId}.jsonl`);
541
+ assertSafeSessionId(sessionId);
542
+ return join3(getUserTmpDir(), `session-${sessionId}.jsonl`);
499
543
  }
500
544
  function recordEntry(sessionId, entry) {
501
545
  const path = getSessionFilePath(sessionId);
@@ -506,7 +550,7 @@ function recordEntry(sessionId, entry) {
506
550
  }
507
551
  function readSession(sessionId) {
508
552
  const path = getSessionFilePath(sessionId);
509
- if (!existsSync2(path)) return [];
553
+ if (!existsSync3(path)) return [];
510
554
  try {
511
555
  const content = readFileSync2(path, "utf-8");
512
556
  return content.split("\n").filter((l) => l.trim()).map((line2) => {
@@ -520,19 +564,34 @@ function readSession(sessionId) {
520
564
  return [];
521
565
  }
522
566
  }
523
- function getRecentSummaries(sessionId, n) {
524
- const entries = readSession(sessionId);
525
- if (entries.length === 0) return [];
526
- return entries.slice(-n).map((e) => `${e.file}: ${e.summary}`);
567
+ function getRecentSummaries(sessionId, n, entries) {
568
+ const all = entries ?? readSession(sessionId);
569
+ if (all.length === 0) return [];
570
+ return all.slice(-n).map((e) => `${e.file}: ${e.summary}`);
571
+ }
572
+ function getCleanupTimestampPath() {
573
+ return join3(getUserTmpDir(), ".last-cleanup");
527
574
  }
528
575
  function cleanStaleSessionFiles() {
529
576
  try {
530
- const dir = getUserTmpDir2();
577
+ const tsPath = getCleanupTimestampPath();
531
578
  const now = Date.now();
579
+ if (existsSync3(tsPath)) {
580
+ try {
581
+ const ts = parseInt(readFileSync2(tsPath, "utf-8").trim(), 10);
582
+ if (!isNaN(ts) && now - ts < CLEANUP_THROTTLE_MS) return;
583
+ } catch {
584
+ }
585
+ }
586
+ try {
587
+ writeFileSync2(tsPath, String(now), { mode: 384 });
588
+ } catch {
589
+ }
590
+ const dir = getUserTmpDir();
532
591
  const entries = readdirSync(dir);
533
592
  for (const name of entries) {
534
593
  if (!name.startsWith("session-") && !name.startsWith("cache-")) continue;
535
- const filePath = join2(dir, name);
594
+ const filePath = join3(dir, name);
536
595
  try {
537
596
  const stat = statSync(filePath);
538
597
  if (now - stat.mtimeMs > TWO_HOURS_MS) {
@@ -549,33 +608,56 @@ function getSessionIdFromEnv() {
549
608
  }
550
609
  function findLatestSession() {
551
610
  try {
552
- const dir = getUserTmpDir2();
611
+ const dir = getUserTmpDir();
553
612
  const entries = readdirSync(dir).filter((n) => n.startsWith("session-") && n.endsWith(".jsonl")).map((n) => ({
554
613
  name: n,
555
614
  id: n.slice("session-".length, -".jsonl".length),
556
- mtime: statSync(join2(dir, n)).mtimeMs
615
+ mtime: statSync(join3(dir, n)).mtimeMs
557
616
  })).sort((a, b) => b.mtime - a.mtime);
558
617
  return entries[0]?.id;
559
618
  } catch {
560
619
  return void 0;
561
620
  }
562
621
  }
563
- async function printSummary() {
622
+ async function printSummary({ json = false } = {}) {
564
623
  const sessionId = getSessionIdFromEnv() ?? findLatestSession();
565
624
  if (!sessionId) {
566
- process.stderr.write("[code-explainer] No active session found. Session data is created when Claude Code makes changes.\n");
625
+ if (json) {
626
+ process.stdout.write(JSON.stringify({ error: "No active session found" }) + "\n");
627
+ } else {
628
+ process.stderr.write("[code-explainer] No active session found. Session data is created when Claude Code makes changes.\n");
629
+ }
567
630
  return;
568
631
  }
569
632
  const entries = readSession(sessionId);
570
633
  if (entries.length === 0) {
571
- process.stderr.write(`[code-explainer] Session '${sessionId}' has no recorded changes yet.
634
+ if (json) {
635
+ process.stdout.write(JSON.stringify({ sessionId, totalChanges: 0, files: [], risks: { none: 0, low: 0, medium: 0, high: 0 } }) + "\n");
636
+ } else {
637
+ process.stderr.write(`[code-explainer] Session '${sessionId}' has no recorded changes yet.
572
638
  `);
639
+ }
573
640
  return;
574
641
  }
575
642
  const related = entries.filter((e) => !e.unrelated);
576
643
  const unrelated = entries.filter((e) => e.unrelated);
577
644
  const uniqueFiles = Array.from(new Set(entries.map((e) => e.file)));
578
645
  const unrelatedFiles = Array.from(new Set(unrelated.map((e) => e.file)));
646
+ const risks = { none: 0, low: 0, medium: 0, high: 0 };
647
+ for (const e of entries) risks[e.risk]++;
648
+ if (json) {
649
+ process.stdout.write(JSON.stringify({
650
+ sessionId,
651
+ totalChanges: entries.length,
652
+ filesCount: uniqueFiles.length,
653
+ relatedChanges: related.length,
654
+ unrelatedChanges: unrelated.length,
655
+ unrelatedFiles,
656
+ risks,
657
+ entries: entries.map((e) => ({ file: e.file, risk: e.risk, summary: e.summary, unrelated: !!e.unrelated }))
658
+ }, null, 2) + "\n");
659
+ return;
660
+ }
579
661
  const alert = formatDriftAlert(uniqueFiles.length, unrelatedFiles);
580
662
  printToStderr(alert);
581
663
  process.stderr.write(`
@@ -587,8 +669,6 @@ Total changes: ${entries.length}
587
669
  `);
588
670
  process.stderr.write(`Unrelated/risky: ${unrelated.length}
589
671
  `);
590
- const risks = { none: 0, low: 0, medium: 0, high: 0 };
591
- for (const e of entries) risks[e.risk]++;
592
672
  process.stderr.write(`
593
673
  Risk breakdown:
594
674
  `);
@@ -608,7 +688,7 @@ async function endSession() {
608
688
  return;
609
689
  }
610
690
  const sessionPath = getSessionFilePath(sessionId);
611
- if (existsSync2(sessionPath)) {
691
+ if (existsSync3(sessionPath)) {
612
692
  try {
613
693
  unlinkSync2(sessionPath);
614
694
  } catch {
@@ -620,6 +700,7 @@ async function endSession() {
620
700
  }
621
701
 
622
702
  export {
703
+ isSafeSessionId,
623
704
  getCached,
624
705
  setCached,
625
706
  formatExplanationBox,
@@ -634,4 +715,4 @@ export {
634
715
  printSummary,
635
716
  endSession
636
717
  };
637
- //# sourceMappingURL=chunk-XW3S5GNV.js.map
718
+ //# sourceMappingURL=chunk-VJN7Y4SI.js.map