knodin 0.8.2 → 0.8.4

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.
@@ -0,0 +1,156 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const OWNED_COMMAND = "knodin agent-event";
5
+ function settingsPath(homeDir = os.homedir()) {
6
+ return path.join(homeDir, ".claude", "settings.json");
7
+ }
8
+ function assertNotSymlink(filePath) {
9
+ for (const candidate of [path.dirname(filePath), filePath]) {
10
+ try {
11
+ if (fs.lstatSync(candidate).isSymbolicLink())
12
+ throw new Error(`knodin agent-hooks: refusing symlinked path ${candidate}`);
13
+ }
14
+ catch (error) {
15
+ if (error.code !== "ENOENT")
16
+ throw error;
17
+ }
18
+ }
19
+ }
20
+ function readSettings(filePath) {
21
+ if (!fs.existsSync(filePath))
22
+ return {};
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
26
+ }
27
+ catch (error) {
28
+ throw new Error(`knodin agent-hooks: invalid Claude settings JSON: ${error.message}`);
29
+ }
30
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
31
+ throw new Error("knodin agent-hooks: Claude settings must contain a JSON object");
32
+ return parsed;
33
+ }
34
+ function atomicWrite(filePath, content) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
36
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
37
+ fs.writeFileSync(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
38
+ fs.renameSync(temporary, filePath);
39
+ }
40
+ function ownedEntry(value) {
41
+ return JSON.stringify(value).includes(OWNED_COMMAND);
42
+ }
43
+ function removeOwned(document) {
44
+ const hooks = document.hooks;
45
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
46
+ return;
47
+ const hookObject = hooks;
48
+ for (const [event, value] of Object.entries(hookObject)) {
49
+ if (!Array.isArray(value))
50
+ continue;
51
+ const retained = value.filter((entry) => !ownedEntry(entry));
52
+ if (retained.length === 0)
53
+ delete hookObject[event];
54
+ else
55
+ hookObject[event] = retained;
56
+ }
57
+ if (Object.keys(hookObject).length === 0)
58
+ delete document.hooks;
59
+ }
60
+ function hook(command, event, matcher) {
61
+ return {
62
+ ...(matcher ? { matcher } : {}),
63
+ hooks: [
64
+ {
65
+ type: "command",
66
+ command: `${command} agent-event ${event}`,
67
+ timeout: 5,
68
+ },
69
+ ],
70
+ };
71
+ }
72
+ function managedHooks(command) {
73
+ const observed = "mcp__knodin__knodin|Bash|Read|Glob|Grep|Edit|Write|NotebookEdit";
74
+ return {
75
+ SessionStart: [hook(command, "session-start", "startup|resume|clear|compact")],
76
+ UserPromptSubmit: [hook(command, "turn-start")],
77
+ PreToolUse: [hook(command, "pre-tool", observed)],
78
+ PostToolUse: [hook(command, "post-tool", observed)],
79
+ PostToolUseFailure: [hook(command, "tool-failure", observed)],
80
+ Stop: [hook(command, "turn-end")],
81
+ SessionEnd: [hook(command, "session-end")],
82
+ };
83
+ }
84
+ function mergedSettings(existing, command) {
85
+ const document = structuredClone(existing);
86
+ removeOwned(document);
87
+ const hooks = document.hooks && typeof document.hooks === "object" && !Array.isArray(document.hooks)
88
+ ? document.hooks
89
+ : {};
90
+ for (const [event, entries] of Object.entries(managedHooks(command))) {
91
+ const current = Array.isArray(hooks[event]) ? hooks[event] : [];
92
+ hooks[event] = [...current, ...entries];
93
+ }
94
+ document.hooks = hooks;
95
+ return document;
96
+ }
97
+ export function inspectClaudeAgentHooks(options = {}) {
98
+ const filePath = settingsPath(options.homeDir);
99
+ assertNotSymlink(filePath);
100
+ const document = readSettings(filePath);
101
+ const serialized = JSON.stringify(document);
102
+ const count = serialized.split(OWNED_COMMAND).length - 1;
103
+ return {
104
+ schemaVersion: 1,
105
+ client: "claude",
106
+ settingsPath: filePath,
107
+ installed: count > 0,
108
+ managedEntries: count,
109
+ };
110
+ }
111
+ export function installClaudeAgentHooks(options = {}) {
112
+ const filePath = settingsPath(options.homeDir);
113
+ assertNotSymlink(filePath);
114
+ const before = readSettings(filePath);
115
+ const after = mergedSettings(before, options.command ?? "knodin");
116
+ const beforeText = `${JSON.stringify(before, null, 2)}\n`;
117
+ const afterText = `${JSON.stringify(after, null, 2)}\n`;
118
+ const changed = beforeText !== afterText;
119
+ if (changed && !options.dryRun) {
120
+ if (fs.existsSync(filePath)) {
121
+ const backup = `${filePath}.knodin-backup`;
122
+ if (!fs.existsSync(backup))
123
+ fs.copyFileSync(filePath, backup, fs.constants.COPYFILE_EXCL);
124
+ }
125
+ atomicWrite(filePath, afterText);
126
+ }
127
+ return {
128
+ schemaVersion: 1,
129
+ client: "claude",
130
+ settingsPath: filePath,
131
+ changed,
132
+ dryRun: options.dryRun === true,
133
+ managedEntries: Object.keys(managedHooks(options.command ?? "knodin")).length,
134
+ };
135
+ }
136
+ export function uninstallClaudeAgentHooks(options = {}) {
137
+ const filePath = settingsPath(options.homeDir);
138
+ assertNotSymlink(filePath);
139
+ if (!fs.existsSync(filePath))
140
+ return { removed: false, settingsPath: filePath };
141
+ const document = readSettings(filePath);
142
+ const hadOwned = ownedEntry(document);
143
+ removeOwned(document);
144
+ if (hadOwned) {
145
+ if (Object.keys(document).length === 0)
146
+ fs.unlinkSync(filePath);
147
+ else
148
+ atomicWrite(filePath, `${JSON.stringify(document, null, 2)}\n`);
149
+ }
150
+ return {
151
+ schemaVersion: 1,
152
+ client: "claude",
153
+ removed: hadOwned,
154
+ settingsPath: filePath,
155
+ };
156
+ }
@@ -251,6 +251,14 @@ function createCliProgram(capture = () => { }) {
251
251
  leaf(program, "serve", "run the one-tool MCP gateway on stdio", capture);
252
252
  leaf(program, "version", "print the installed knodin version", capture);
253
253
  leaf(program, "hook-refresh <kind> [values...]", "internal Git lifecycle refresh", capture);
254
+ const agentHooks = program
255
+ .command("agent-hooks")
256
+ .description("manage optional user-global coding-agent lifecycle hooks");
257
+ for (const action of ["install", "status", "uninstall"])
258
+ leaf(agentHooks, action, `${action} optional coding-agent hooks`, capture)
259
+ .option("--client <client>", "coding-agent client; currently claude")
260
+ .option("--dry-run", "preview settings changes without writing");
261
+ leaf(program, "agent-event <event>", "internal coding-agent lifecycle event", capture);
254
262
  leaf(program, "refresh-artifacts [event]", "refresh external graph artifacts", capture);
255
263
  addRepositoryCommands(program, capture);
256
264
  addRemoteCommands(program, capture);
@@ -283,7 +291,14 @@ function createCliProgram(capture = () => { }) {
283
291
  .command("telemetry")
284
292
  .description("manage local opt-in ROI telemetry")
285
293
  .allowExcessArguments(false)
286
- .addArgument(new Argument("<action>").choices(["status", "report", "export", "clear"]));
294
+ .addArgument(new Argument("<action>").choices([
295
+ "enable",
296
+ "disable",
297
+ "status",
298
+ "report",
299
+ "export",
300
+ "clear",
301
+ ]));
287
302
  telemetry
288
303
  .option("--input <path>", "telemetry input path")
289
304
  .option("--output <path>", "dashboard or evidence-bundle output path")
@@ -27,6 +27,7 @@ The \`query\` operation lets you ask precise, structured questions about your co
27
27
  - **tests_for <target>**: Find test files that cover this symbol.
28
28
  - **file_summary <target>**: Lists all symbols defined in a file.
29
29
  - **shortest_path <from> <to>**: Find the call-graph chain connecting two symbols.
30
+ - **cross_substrate_path <from> <to>**: Prove one bounded static Salesforce Flow action to a uniquely resolved Apex \`@InvocableMethod\`. Returns exact XML and Apex evidence, stable endpoint identities, freshness, explicit omissions, and budget metadata; it does not resolve Terraform, dbt, or arbitrary substrate crossings.
30
31
  - **impact <symbol>**: Directional, depth-bounded symbol reach with stable selectors, relation/confidence/test filters, edge evidence, and heuristic summaries. Use \`impactMode: "file"\` explicitly for a distinctly labeled changed-file blast radius.
31
32
  - **dead_code**: Surface exported or unexported symbols with zero references.
32
33
  - **large_functions / large_files**: Spot complex "god" functions and files exceeding size thresholds.