diffninja 0.1.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 (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/dist/calltree.d.ts +47 -0
  4. package/dist/calltree.js +296 -0
  5. package/dist/cli.d.ts +57 -0
  6. package/dist/cli.js +340 -0
  7. package/dist/diff.d.ts +7 -0
  8. package/dist/diff.js +114 -0
  9. package/dist/extract.d.ts +26 -0
  10. package/dist/extract.js +152 -0
  11. package/dist/git.d.ts +40 -0
  12. package/dist/git.js +288 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +8 -0
  15. package/dist/infer.d.ts +21 -0
  16. package/dist/infer.js +189 -0
  17. package/dist/languages/bash.d.ts +2 -0
  18. package/dist/languages/bash.js +208 -0
  19. package/dist/languages/c.d.ts +2 -0
  20. package/dist/languages/c.js +218 -0
  21. package/dist/languages/call-syntax.d.ts +125 -0
  22. package/dist/languages/call-syntax.js +997 -0
  23. package/dist/languages/cpp.d.ts +2 -0
  24. package/dist/languages/cpp.js +321 -0
  25. package/dist/languages/csharp.d.ts +2 -0
  26. package/dist/languages/csharp.js +324 -0
  27. package/dist/languages/elixir.d.ts +2 -0
  28. package/dist/languages/elixir.js +331 -0
  29. package/dist/languages/go.d.ts +2 -0
  30. package/dist/languages/go.js +299 -0
  31. package/dist/languages/grammars.d.ts +50 -0
  32. package/dist/languages/grammars.js +351 -0
  33. package/dist/languages/haskell.d.ts +2 -0
  34. package/dist/languages/haskell.js +250 -0
  35. package/dist/languages/java.d.ts +2 -0
  36. package/dist/languages/java.js +351 -0
  37. package/dist/languages/javascript.d.ts +4 -0
  38. package/dist/languages/javascript.js +648 -0
  39. package/dist/languages/kotlin.d.ts +2 -0
  40. package/dist/languages/kotlin.js +368 -0
  41. package/dist/languages/lua.d.ts +2 -0
  42. package/dist/languages/lua.js +212 -0
  43. package/dist/languages/ocaml.d.ts +2 -0
  44. package/dist/languages/ocaml.js +291 -0
  45. package/dist/languages/perl.d.ts +2 -0
  46. package/dist/languages/perl.js +418 -0
  47. package/dist/languages/php.d.ts +2 -0
  48. package/dist/languages/php.js +397 -0
  49. package/dist/languages/python.d.ts +2 -0
  50. package/dist/languages/python.js +376 -0
  51. package/dist/languages/registry.d.ts +7 -0
  52. package/dist/languages/registry.js +69 -0
  53. package/dist/languages/ruby.d.ts +2 -0
  54. package/dist/languages/ruby.js +391 -0
  55. package/dist/languages/rust.d.ts +2 -0
  56. package/dist/languages/rust.js +261 -0
  57. package/dist/languages/scala.d.ts +2 -0
  58. package/dist/languages/scala.js +307 -0
  59. package/dist/languages/solidity.d.ts +2 -0
  60. package/dist/languages/solidity.js +240 -0
  61. package/dist/languages/swift.d.ts +2 -0
  62. package/dist/languages/swift.js +268 -0
  63. package/dist/languages/types.d.ts +36 -0
  64. package/dist/languages/types.js +74 -0
  65. package/dist/languages/typescript-contracts.d.ts +57 -0
  66. package/dist/languages/typescript-contracts.js +528 -0
  67. package/dist/languages/typescript-dispatch.d.ts +68 -0
  68. package/dist/languages/typescript-dispatch.js +710 -0
  69. package/dist/languages/typescript.d.ts +4 -0
  70. package/dist/languages/typescript.js +722 -0
  71. package/dist/languages/zig.d.ts +2 -0
  72. package/dist/languages/zig.js +243 -0
  73. package/dist/loc.d.ts +17 -0
  74. package/dist/loc.js +34 -0
  75. package/dist/reach.d.ts +17 -0
  76. package/dist/reach.js +65 -0
  77. package/dist/render.d.ts +18 -0
  78. package/dist/render.js +83 -0
  79. package/dist/review/brand.d.ts +8 -0
  80. package/dist/review/brand.js +25 -0
  81. package/dist/review/call-context.d.ts +27 -0
  82. package/dist/review/call-context.js +446 -0
  83. package/dist/review/call-flow-html.d.ts +32 -0
  84. package/dist/review/call-flow-html.js +1870 -0
  85. package/dist/review/call-flow-nav.d.ts +151 -0
  86. package/dist/review/call-flow-nav.js +317 -0
  87. package/dist/review/call-flow.d.ts +47 -0
  88. package/dist/review/call-flow.js +229 -0
  89. package/dist/review/change-facts.d.ts +69 -0
  90. package/dist/review/change-facts.js +729 -0
  91. package/dist/review/cli.d.ts +2 -0
  92. package/dist/review/cli.js +50 -0
  93. package/dist/review/connected-analysis.d.ts +100 -0
  94. package/dist/review/connected-analysis.js +163 -0
  95. package/dist/review/connected-html.d.ts +17 -0
  96. package/dist/review/connected-html.js +2853 -0
  97. package/dist/review/connected.d.ts +23 -0
  98. package/dist/review/connected.js +141 -0
  99. package/dist/review/escape-html.d.ts +2 -0
  100. package/dist/review/escape-html.js +9 -0
  101. package/dist/review/evidence-html.d.ts +21 -0
  102. package/dist/review/evidence-html.js +521 -0
  103. package/dist/review/evidence-syntax.d.ts +132 -0
  104. package/dist/review/evidence-syntax.js +478 -0
  105. package/dist/review/evidence-types.d.ts +62 -0
  106. package/dist/review/evidence-types.js +1 -0
  107. package/dist/review/evidence.d.ts +31 -0
  108. package/dist/review/evidence.js +1603 -0
  109. package/dist/review/file-role.d.ts +9 -0
  110. package/dist/review/file-role.js +29 -0
  111. package/dist/review/github.d.ts +204 -0
  112. package/dist/review/github.js +1245 -0
  113. package/dist/review/history.d.ts +101 -0
  114. package/dist/review/history.js +412 -0
  115. package/dist/review/html.d.ts +34 -0
  116. package/dist/review/html.js +1104 -0
  117. package/dist/review/input.d.ts +10 -0
  118. package/dist/review/input.js +113 -0
  119. package/dist/review/intent.d.ts +4 -0
  120. package/dist/review/intent.js +75 -0
  121. package/dist/review/mcp-cli.d.ts +2 -0
  122. package/dist/review/mcp-cli.js +25 -0
  123. package/dist/review/mcp.d.ts +12 -0
  124. package/dist/review/mcp.js +414 -0
  125. package/dist/review/module-resolution.d.ts +2 -0
  126. package/dist/review/module-resolution.js +86 -0
  127. package/dist/review/palette.d.ts +7 -0
  128. package/dist/review/palette.js +104 -0
  129. package/dist/review/pipeline.d.ts +77 -0
  130. package/dist/review/pipeline.js +227 -0
  131. package/dist/review/pr-input.d.ts +19 -0
  132. package/dist/review/pr-input.js +130 -0
  133. package/dist/review/questions.d.ts +201 -0
  134. package/dist/review/questions.js +174 -0
  135. package/dist/review/reference-check.d.ts +7 -0
  136. package/dist/review/reference-check.js +733 -0
  137. package/dist/review/report-pages.d.ts +109 -0
  138. package/dist/review/report-pages.js +328 -0
  139. package/dist/review/service.d.ts +23 -0
  140. package/dist/review/service.js +198 -0
  141. package/dist/review/setup.d.ts +112 -0
  142. package/dist/review/setup.js +549 -0
  143. package/dist/review/source.d.ts +26 -0
  144. package/dist/review/source.js +276 -0
  145. package/dist/review/toml.d.ts +38 -0
  146. package/dist/review/toml.js +565 -0
  147. package/dist/review/types.d.ts +179 -0
  148. package/dist/review/types.js +1 -0
  149. package/dist/run.d.ts +49 -0
  150. package/dist/run.js +311 -0
  151. package/dist/types.d.ts +366 -0
  152. package/dist/types.js +83 -0
  153. package/package.json +88 -0
  154. package/scripts/ensure-native-grammar.mjs +188 -0
@@ -0,0 +1,112 @@
1
+ /**
2
+ * `diffninja setup`: register the diffninja MCP server on every detected
3
+ * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
+ *
5
+ * The setup installs the package globally first so the registration points
6
+ * at a permanent binary instead of the npx cache. When the global install is
7
+ * unavailable it falls back to an npx-based entry and says so. The server
8
+ * needs no key or environment: reviews are local, and connected reviews reuse
9
+ * the `gh` session.
10
+ */
11
+ import { type Stats } from "node:fs";
12
+ export declare const setupHelp = "diffninja setup. Register the diffninja MCP server on every detected agent CLI.\n\n npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]\n diffninja setup --uninstall [--cli codex]\n\nDetects Claude Code, Codex, OMP, and pi from their config files or binaries\nand registers the diffninja MCP server in each user config, pointing at the\nglobally installed package. Installs the package globally first\n(`npm install -g diffninja`) so the registration keeps working; when that\ninstall fails it registers an npx-based entry instead and says so.\n\nOptions:\n --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.\n --uninstall Remove the diffninja server from every detected CLI.\n --dry-run Show what would change, without installing or writing.\n --no-install Skip the global install and register npx-based entries.\n --help Show this help.\n\nThe server needs no API key: static reviews run locally, and pull request\nreviews reuse your authenticated gh session.\n";
13
+ export declare const CLI_NAMES: readonly ["claude", "codex", "omp", "pi"];
14
+ export type CliName = (typeof CLI_NAMES)[number];
15
+ export interface McpEntry {
16
+ command: string;
17
+ args: string[];
18
+ }
19
+ export interface SetupOptions {
20
+ clis?: readonly string[];
21
+ uninstall?: boolean;
22
+ dryRun?: boolean;
23
+ noInstall?: boolean;
24
+ quiet?: boolean;
25
+ homeDir?: string;
26
+ pathDirs?: string[];
27
+ /** Environment to read `CODEX_HOME` from. */
28
+ env?: NodeJS.ProcessEnv;
29
+ }
30
+ export interface Npm {
31
+ rootG(): Promise<string>;
32
+ installG(): Promise<boolean>;
33
+ }
34
+ export interface CliReport {
35
+ cli: CliName;
36
+ detected: boolean;
37
+ action: "configured" | "already-configured" | "removed" | "not-detected" | "dry-run";
38
+ path?: string;
39
+ }
40
+ export interface SetupReport {
41
+ entry: McpEntry;
42
+ viaNpx: boolean;
43
+ clis: CliReport[];
44
+ }
45
+ export interface SetupDeps {
46
+ npm?: Npm;
47
+ }
48
+ /** Entry that runs the published package on demand, without an install. */
49
+ export declare function npxEntry(platform?: NodeJS.Platform): McpEntry;
50
+ /**
51
+ * Entry for a command that Windows ships only as a `.cmd` shim, which a client
52
+ * that spawns without a shell cannot launch. Running npm's JS entry point with
53
+ * Node keeps every path a literal argv value, quotes and spaces included; the
54
+ * installed npm CLI is the fallback when Node has no npm beside it.
55
+ *
56
+ * `cliPath` maps a shim name to npm's JS entry point (`npx` →
57
+ * `npx-cli.js`); production searches the npm install, tests pass a stub.
58
+ */
59
+ export declare function windowsCliEntry(name: string, args: readonly string[], cliPath?: (entryName: string) => string | undefined): McpEntry;
60
+ /** Entry that runs the globally installed package. `globalRoot` is `npm root -g`. */
61
+ export declare function globalEntry(globalRoot: string): McpEntry;
62
+ export declare function globalEntryExists(globalRoot: string): boolean;
63
+ /**
64
+ * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
65
+ * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
66
+ */
67
+ export declare function createNpm(overrides?: {
68
+ platform?: NodeJS.Platform;
69
+ env?: NodeJS.ProcessEnv;
70
+ }): Npm;
71
+ /**
72
+ * Packages whose install scripts the global install needs: tree-sitter's
73
+ * native builds and diffninja's own grammar repair. npm 12 blocks dependency
74
+ * install scripts unless named; earlier npm accepts the flag and ignores it.
75
+ */
76
+ export declare const INSTALL_SCRIPT_PACKAGES: readonly ["diffninja", "tree-sitter", "tree-sitter-javascript", "tree-sitter-typescript"];
77
+ /** A rewritten file and whether it differs from what was read. */
78
+ export interface FileEdit {
79
+ text: string;
80
+ changed: boolean;
81
+ }
82
+ interface TemporaryFile {
83
+ writeFile(text: string): Promise<void>;
84
+ chmod(mode: number): Promise<void>;
85
+ close(): Promise<void>;
86
+ }
87
+ /** Filesystem operations used to stage and commit an atomic update. */
88
+ export interface UpdateFileIO {
89
+ open(path: string, flags: string, mode: number): Promise<TemporaryFile>;
90
+ stat(path: string): Promise<Stats>;
91
+ rename(from: string, to: string): Promise<void>;
92
+ }
93
+ /**
94
+ * Read `path`, apply `merge` to its contents, and commit the result. Updates
95
+ * in this process are serialized per file, so two of them cannot drop each
96
+ * other's edits. A writer in another process is caught by re-reading the
97
+ * contents and state immediately before the rename, which re-runs `merge`
98
+ * against what that writer left behind; after `attempts` the write is
99
+ * abandoned with the file untouched. That re-read narrows the window between
100
+ * the read and the rename but cannot close it: only another in-process update
101
+ * is excluded by construction.
102
+ */
103
+ export declare function updateFile(path: string, merge: (current: string | undefined) => FileEdit, attempts?: number, io?: UpdateFileIO): Promise<FileEdit & {
104
+ path: string;
105
+ }>;
106
+ /**
107
+ * Codex reads `$CODEX_HOME/config.toml`, defaulting to `~/.codex/config.toml`.
108
+ * Detection, install, and uninstall all use this one path.
109
+ */
110
+ export declare function codexConfigPath(homeDir: string, env?: NodeJS.ProcessEnv): string;
111
+ export declare function runSetup(options?: SetupOptions, deps?: SetupDeps): Promise<SetupReport>;
112
+ export {};
@@ -0,0 +1,549 @@
1
+ /**
2
+ * `diffninja setup`: register the diffninja MCP server on every detected
3
+ * agent CLI (Claude Code, Codex, OMP, pi) with one command.
4
+ *
5
+ * The setup installs the package globally first so the registration points
6
+ * at a permanent binary instead of the npx cache. When the global install is
7
+ * unavailable it falls back to an npx-based entry and says so. The server
8
+ * needs no key or environment: reviews are local, and connected reviews reuse
9
+ * the `gh` session.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ import { randomBytes } from "node:crypto";
13
+ import { once } from "node:events";
14
+ import { existsSync } from "node:fs";
15
+ import { lstat, mkdir, open, readFile, readlink, realpath, rename, rm, stat } from "node:fs/promises";
16
+ import { homedir } from "node:os";
17
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
18
+ import { z } from "zod";
19
+ import { npmCliPath, npmSpawnSpec } from "../languages/grammars.js";
20
+ import { removeTomlTable, upsertTomlTable } from "./toml.js";
21
+ export const setupHelp = `diffninja setup. Register the diffninja MCP server on every detected agent CLI.
22
+
23
+ npx -y diffninja setup [--cli claude,codex,omp,pi] [--dry-run]
24
+ diffninja setup --uninstall [--cli codex]
25
+
26
+ Detects Claude Code, Codex, OMP, and pi from their config files or binaries
27
+ and registers the diffninja MCP server in each user config, pointing at the
28
+ globally installed package. Installs the package globally first
29
+ (\`npm install -g diffninja\`) so the registration keeps working; when that
30
+ install fails it registers an npx-based entry instead and says so.
31
+
32
+ Options:
33
+ --cli NAMES Only these CLIs, comma-separated: claude,codex,omp,pi.
34
+ --uninstall Remove the diffninja server from every detected CLI.
35
+ --dry-run Show what would change, without installing or writing.
36
+ --no-install Skip the global install and register npx-based entries.
37
+ --help Show this help.
38
+
39
+ The server needs no API key: static reviews run locally, and pull request
40
+ reviews reuse your authenticated gh session.
41
+ `;
42
+ export const CLI_NAMES = ["claude", "codex", "omp", "pi"];
43
+ const jsonValueSchema = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(jsonValueSchema), z.record(z.string(), jsonValueSchema)]));
44
+ const jsonObjectSchema = z.object({}).catchall(jsonValueSchema);
45
+ /** Entry that runs the published package on demand, without an install. */
46
+ export function npxEntry(platform = process.platform) {
47
+ const args = ["-y", "-p", "diffninja", "diffninja-mcp"];
48
+ return platform === "win32" ? windowsCliEntry("npx", args) : { command: "npx", args };
49
+ }
50
+ /**
51
+ * Entry for a command that Windows ships only as a `.cmd` shim, which a client
52
+ * that spawns without a shell cannot launch. Running npm's JS entry point with
53
+ * Node keeps every path a literal argv value, quotes and spaces included; the
54
+ * installed npm CLI is the fallback when Node has no npm beside it.
55
+ *
56
+ * `cliPath` maps a shim name to npm's JS entry point (`npx` →
57
+ * `npx-cli.js`); production searches the npm install, tests pass a stub.
58
+ */
59
+ export function windowsCliEntry(name, args, cliPath = npmCliPath) {
60
+ const npmCli = cliPath(`${name}-cli.js`);
61
+ if (npmCli === undefined) {
62
+ return { command: process.env["ComSpec"] ?? "cmd.exe", args: ["/d", "/s", "/c", name, ...args] };
63
+ }
64
+ return { command: process.execPath, args: [npmCli, ...args] };
65
+ }
66
+ /** Entry that runs the globally installed package. `globalRoot` is `npm root -g`. */
67
+ export function globalEntry(globalRoot) {
68
+ return { command: "node", args: [join(globalRoot, "diffninja", "dist", "review", "mcp-cli.js")] };
69
+ }
70
+ export function globalEntryExists(globalRoot) {
71
+ return existsSync(join(globalRoot, "diffninja", "dist", "review", "mcp-cli.js"));
72
+ }
73
+ /**
74
+ * npm runner. Shell-free by design: on Windows npm is a `.cmd` shim, so runs
75
+ * go through `npmSpawnSpec`, which resolves npm's JS entry point instead.
76
+ */
77
+ export function createNpm(overrides = {}) {
78
+ const platform = overrides.platform ?? process.platform;
79
+ const env = overrides.env;
80
+ const run = (args, inherit) => {
81
+ const spec = npmSpawnSpec(args, platform);
82
+ return spawn(spec.file, spec.args, inherit ? { stdio: "inherit", env } : { env });
83
+ };
84
+ return {
85
+ async rootG() {
86
+ const child = run(["root", "-g"], false);
87
+ let out = "";
88
+ child.stdout?.on("data", (chunk) => {
89
+ out += String(chunk);
90
+ });
91
+ return (await exitCode(child)) === 0 ? out.trim() : "";
92
+ },
93
+ async installG() {
94
+ return (await exitCode(run(["install", "-g", `--allow-scripts=${INSTALL_SCRIPT_PACKAGES.join(",")}`, "diffninja"], true))) === 0;
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Packages whose install scripts the global install needs: tree-sitter's
100
+ * native builds and diffninja's own grammar repair. npm 12 blocks dependency
101
+ * install scripts unless named; earlier npm accepts the flag and ignores it.
102
+ */
103
+ export const INSTALL_SCRIPT_PACKAGES = ["diffninja", "tree-sitter", "tree-sitter-javascript", "tree-sitter-typescript"];
104
+ /** Wait for a child's exit code; a child that never starts counts as failure. */
105
+ async function exitCode(child) {
106
+ try {
107
+ // SAFETY: the `close` handler receives [code, signal]; both may be null.
108
+ const [code] = (await once(child, "close"));
109
+ return code ?? -1;
110
+ }
111
+ catch {
112
+ return -1;
113
+ }
114
+ }
115
+ const realNpm = createNpm();
116
+ /** `npm root -g`, or undefined when npm is unavailable or fails. */
117
+ async function globalRoot(npm) {
118
+ try {
119
+ const root = (await npm.rootG()).trim();
120
+ return root === "" ? undefined : root;
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ }
126
+ /**
127
+ * Resolve the registration entry. `--dry-run` never installs: it reports the
128
+ * install it would have run and the entry that install would have produced.
129
+ */
130
+ async function resolveEntry(noInstall, npm, quiet, dryRun) {
131
+ const root = await globalRoot(npm);
132
+ if (root !== undefined && globalEntryExists(root))
133
+ return { entry: globalEntry(root), viaNpx: false };
134
+ if (dryRun) {
135
+ if (!noInstall && root !== undefined) {
136
+ if (!quiet)
137
+ console.log("diffninja: dry run: would install the package globally (npm install -g diffninja).");
138
+ return { entry: globalEntry(root), viaNpx: false };
139
+ }
140
+ if (!quiet)
141
+ console.error("diffninja: dry run: global install unavailable; would register npx-based entries instead.");
142
+ return { entry: npxEntry(), viaNpx: true };
143
+ }
144
+ if (!noInstall) {
145
+ if (!quiet)
146
+ console.log("diffninja: installing the package globally (npm install -g diffninja)...");
147
+ let installed = false;
148
+ try {
149
+ installed = await npm.installG();
150
+ }
151
+ catch {
152
+ installed = false;
153
+ }
154
+ if (installed) {
155
+ const installedRoot = await globalRoot(npm);
156
+ if (installedRoot !== undefined && globalEntryExists(installedRoot)) {
157
+ return { entry: globalEntry(installedRoot), viaNpx: false };
158
+ }
159
+ }
160
+ }
161
+ if (!quiet)
162
+ console.error("diffninja: global install unavailable; registering npx-based entries instead.");
163
+ return { entry: npxEntry(), viaNpx: true };
164
+ }
165
+ function findOnPath(name, pathDirs) {
166
+ const candidates = process.platform === "win32" ? [name, `${name}.cmd`, `${name}.exe`] : [name];
167
+ for (const dir of pathDirs) {
168
+ for (const candidate of candidates) {
169
+ const full = join(dir, candidate);
170
+ if (existsSync(full))
171
+ return full;
172
+ }
173
+ }
174
+ return undefined;
175
+ }
176
+ function isDetected(cli, homeDir, pathDirs, codexConfig) {
177
+ switch (cli) {
178
+ case "claude":
179
+ return existsSync(join(homeDir, ".claude.json")) || findOnPath("claude", pathDirs) !== undefined;
180
+ case "codex":
181
+ return existsSync(codexConfig) || findOnPath("codex", pathDirs) !== undefined;
182
+ case "omp":
183
+ return existsSync(join(homeDir, ".omp", "agent")) || findOnPath("omp", pathDirs) !== undefined;
184
+ case "pi":
185
+ return existsSync(join(homeDir, ".pi", "agent")) || findOnPath("pi", pathDirs) !== undefined;
186
+ }
187
+ }
188
+ function toCliName(name) {
189
+ for (const cli of CLI_NAMES) {
190
+ if (cli === name)
191
+ return cli;
192
+ }
193
+ return undefined;
194
+ }
195
+ const fileIO = { open, stat, rename };
196
+ const missingFileError = z.object({ code: z.literal("ENOENT") });
197
+ async function fileState(path, io) {
198
+ try {
199
+ const info = await io.stat(path);
200
+ return { mode: info.mode & 0o777, size: info.size, mtimeMs: info.mtimeMs, ino: info.ino };
201
+ }
202
+ catch (error) {
203
+ if (!missingFileError.safeParse(error).success)
204
+ throw error;
205
+ return undefined;
206
+ }
207
+ }
208
+ /** A concurrent chmod is as much a change as a concurrent write. */
209
+ function sameFile(left, right) {
210
+ if (left === undefined || right === undefined)
211
+ return left === right;
212
+ return left.mode === right.mode && left.size === right.size && left.mtimeMs === right.mtimeMs && left.ino === right.ino;
213
+ }
214
+ async function sameContents(path, text) {
215
+ try {
216
+ return (await readFile(path, "utf8")) === text;
217
+ }
218
+ catch (error) {
219
+ if (!missingFileError.safeParse(error).success)
220
+ throw error;
221
+ return text === undefined;
222
+ }
223
+ }
224
+ /** Names one process has already proposed, so no two commits ask for the same temporary. */
225
+ let tempAttempts = 0;
226
+ /** Rounds of link chasing allowed before a chain is called too long. */
227
+ const MAX_LINK_HOPS = 40;
228
+ /**
229
+ * Stage `text` beside `path` under a name no other writer can hold: `wx`
230
+ * creates the file or fails, and a name already taken is never opened, so a
231
+ * file belonging to someone else is never truncated. Once the name is ours a
232
+ * failure mid-write is cleaned up here; a failure to create it means the name
233
+ * was never ours and there is nothing of ours to remove.
234
+ */
235
+ async function createTemp(path, text, mode, io) {
236
+ tempAttempts += 1;
237
+ // The pid and the counter place the name; the random tag keeps a recycled
238
+ // pid from inheriting one a previous process left behind.
239
+ const tag = randomBytes(6).toString("hex");
240
+ const temporary = join(dirname(path), `.${basename(path)}.diffninja-${process.pid}-${tempAttempts}-${tag}.tmp`);
241
+ const handle = await io.open(temporary, "wx", mode);
242
+ try {
243
+ await handle.writeFile(text);
244
+ await handle.chmod(mode);
245
+ await handle.close();
246
+ }
247
+ catch (error) {
248
+ // The name is ours, so the partial file is ours to remove: left behind it
249
+ // would sit beside the user's config for good.
250
+ await handle.close().catch(() => undefined);
251
+ await rm(temporary, { force: true }).catch(() => undefined);
252
+ throw error;
253
+ }
254
+ return temporary;
255
+ }
256
+ /**
257
+ * Write `text` through a temporary file beside `path` and rename it into
258
+ * place, so a reader (or a crash) never sees a half-written config. The
259
+ * temporary inherits the mode of the file it replaces.
260
+ *
261
+ * The file is checked immediately before the rename — contents first, then
262
+ * state, so the last look before the rename covers both — because a write
263
+ * landing between the merge and the rename would otherwise be dropped by the
264
+ * rename without anyone noticing. A check that fails removes the temporary and
265
+ * reports the file as changed; only the temporary this call staged is removed.
266
+ */
267
+ async function commitFile(path, text, current, expected, io) {
268
+ const temporary = await createTemp(path, text, expected?.mode ?? 0o600, io);
269
+ let renamed = false;
270
+ try {
271
+ if (!(await sameContents(path, current)) || !sameFile(expected, await fileState(path, io)))
272
+ return false;
273
+ await io.rename(temporary, path);
274
+ renamed = true;
275
+ return true;
276
+ }
277
+ finally {
278
+ if (!renamed)
279
+ await rm(temporary, { force: true }).catch(() => undefined);
280
+ }
281
+ }
282
+ /**
283
+ * The file a write should land in: a config that is symlinked (dotfile
284
+ * managers do this) keeps its link, so the link's destination is what gets
285
+ * replaced.
286
+ *
287
+ * A path that exists is canonicalized with `realpath`, which is how two
288
+ * aliases of one file resolve together, a link reached through a symlinked
289
+ * directory included. A destination that does not exist yet — what a dangling
290
+ * link points at — has no canonical path to ask for, so the chain is followed
291
+ * by hand: each link is read and its destination composed against the link's
292
+ * own directory, and the missing name is resolved against the nearest existing
293
+ * ancestor, so the destination is created rather than the link swapped for a
294
+ * file. Only a missing entry counts as absent; a permission or loop error is
295
+ * reported, never read as "nothing there".
296
+ */
297
+ async function resolveTarget(path) {
298
+ let current = resolve(path);
299
+ const tail = [];
300
+ const seen = new Set();
301
+ let hops = 0;
302
+ for (;;) {
303
+ const info = await lstat(current).catch((error) => {
304
+ if (missingFileError.safeParse(error).success)
305
+ return undefined;
306
+ throw error;
307
+ });
308
+ if (info === undefined) {
309
+ // Nothing here yet: keep the name and canonicalize the parent instead.
310
+ tail.unshift(basename(current));
311
+ const parent = dirname(current);
312
+ if (parent === current)
313
+ return join(current, ...tail);
314
+ current = parent;
315
+ continue;
316
+ }
317
+ if (!info.isSymbolicLink()) {
318
+ const canonical = await realpath(current);
319
+ return tail.length === 0 ? canonical : join(canonical, ...tail);
320
+ }
321
+ if (hops >= MAX_LINK_HOPS)
322
+ throw new Error(`Cannot update ${path}: too many levels of symbolic links.`);
323
+ if (seen.has(current))
324
+ throw new Error(`Cannot update ${path}: its symlinks form a cycle.`);
325
+ seen.add(current);
326
+ hops += 1;
327
+ current = resolve(dirname(current), await readlink(current));
328
+ }
329
+ }
330
+ /**
331
+ * Updates in flight in this process, one chain per resolved file. Two of them
332
+ * would otherwise both merge the contents they read, and the later rename
333
+ * would drop the earlier edit with both writers reporting success.
334
+ */
335
+ const inFlight = new Map();
336
+ async function withFileLock(key, run) {
337
+ const previous = inFlight.get(key) ?? Promise.resolve();
338
+ const result = previous.then(run);
339
+ const chain = result.then(() => undefined, () => undefined);
340
+ inFlight.set(key, chain);
341
+ try {
342
+ return await result;
343
+ }
344
+ finally {
345
+ if (inFlight.get(key) === chain)
346
+ inFlight.delete(key);
347
+ }
348
+ }
349
+ /**
350
+ * Read `path`, apply `merge` to its contents, and commit the result. Updates
351
+ * in this process are serialized per file, so two of them cannot drop each
352
+ * other's edits. A writer in another process is caught by re-reading the
353
+ * contents and state immediately before the rename, which re-runs `merge`
354
+ * against what that writer left behind; after `attempts` the write is
355
+ * abandoned with the file untouched. That re-read narrows the window between
356
+ * the read and the rename but cannot close it: only another in-process update
357
+ * is excluded by construction.
358
+ */
359
+ export async function updateFile(path, merge, attempts = 3, io = fileIO) {
360
+ const target = await resolveTarget(path);
361
+ return withFileLock(target, async () => {
362
+ for (let attempt = 1;; attempt++) {
363
+ const before = await fileState(target, io);
364
+ const current = before === undefined ? undefined : await readFile(target, "utf8");
365
+ const edit = merge(current);
366
+ if (!edit.changed)
367
+ return { ...edit, path };
368
+ await mkdir(dirname(target), { recursive: true });
369
+ if (await commitFile(target, edit.text, current, before, io))
370
+ return { ...edit, path };
371
+ if (attempt >= attempts) {
372
+ throw new Error(`Cannot update ${path}: it changed while diffninja was writing. Re-run the command.`);
373
+ }
374
+ }
375
+ });
376
+ }
377
+ /** Apply an edit, or preview it under `--dry-run`, where nothing is written. */
378
+ async function editConfig(path, merge, dryRun) {
379
+ if (dryRun) {
380
+ const current = existsSync(path) ? await readFile(path, "utf8") : undefined;
381
+ return merge(current).changed;
382
+ }
383
+ return (await updateFile(path, merge)).changed;
384
+ }
385
+ function parseJsonObject(path, text) {
386
+ let parsed;
387
+ try {
388
+ parsed = JSON.parse(text);
389
+ }
390
+ catch {
391
+ throw new Error(`Cannot use ${path}: not valid JSON.`);
392
+ }
393
+ const result = jsonObjectSchema.safeParse(parsed);
394
+ if (!result.success)
395
+ throw new Error(`Cannot use ${path}: expected a JSON object at the top level.`);
396
+ return result.data;
397
+ }
398
+ function sameJson(a, b) {
399
+ return a !== undefined && JSON.stringify(a) === JSON.stringify(b);
400
+ }
401
+ /**
402
+ * Insert, replace, or remove the diffninja server in a JSON MCP config file.
403
+ * `entry` undefined removes it.
404
+ */
405
+ function mergeJsonServer(path, build, entry) {
406
+ return (current) => {
407
+ const root = current === undefined ? {} : parseJsonObject(path, current);
408
+ const tableResult = jsonObjectSchema.safeParse(root["mcpServers"] ?? {});
409
+ if (!tableResult.success)
410
+ throw new Error(`Cannot use ${path}: "mcpServers" is not an object.`);
411
+ const table = tableResult.data;
412
+ let changed;
413
+ if (entry === undefined) {
414
+ changed = Object.prototype.hasOwnProperty.call(table, "diffninja");
415
+ if (changed)
416
+ delete table["diffninja"];
417
+ }
418
+ else {
419
+ const want = build(entry);
420
+ changed = !sameJson(table["diffninja"], want);
421
+ if (changed)
422
+ table["diffninja"] = want;
423
+ }
424
+ if (!changed)
425
+ return { text: current ?? "", changed: false };
426
+ root["mcpServers"] = table;
427
+ return { text: `${JSON.stringify(root, null, 2)}\n`, changed: true };
428
+ };
429
+ }
430
+ function claudeServer(entry) {
431
+ return { type: "stdio", command: entry.command, args: entry.args };
432
+ }
433
+ function ompServer(entry) {
434
+ return { command: entry.command, args: entry.args };
435
+ }
436
+ function piServer(entry) {
437
+ return { transport: "stdio", command: entry.command, args: entry.args, lifecycle: "lazy" };
438
+ }
439
+ /** Key path of the Codex MCP server table for diffninja. */
440
+ const CODEX_SERVER_PATH = ["mcp_servers", "diffninja"];
441
+ function codexValues(entry) {
442
+ return { command: entry.command, args: entry.args };
443
+ }
444
+ /**
445
+ * Codex reads `$CODEX_HOME/config.toml`, defaulting to `~/.codex/config.toml`.
446
+ * Detection, install, and uninstall all use this one path.
447
+ */
448
+ export function codexConfigPath(homeDir, env = process.env) {
449
+ const configured = env["CODEX_HOME"]?.trim();
450
+ const directory = configured !== undefined && configured !== "" ? configured : join(homeDir, ".codex");
451
+ return join(directory, "config.toml");
452
+ }
453
+ /**
454
+ * Insert, replace, or remove the diffninja server in a Codex TOML config.
455
+ * `entry` undefined removes it, nested tables included.
456
+ */
457
+ function mergeCodexServer(path, entry) {
458
+ return (current) => {
459
+ const text = current ?? "";
460
+ return entry === undefined
461
+ ? removeTomlTable(text, path, CODEX_SERVER_PATH)
462
+ : upsertTomlTable(text, path, CODEX_SERVER_PATH, codexValues(entry));
463
+ };
464
+ }
465
+ function prettyPath(homeDir, path) {
466
+ return path.startsWith(homeDir) ? `~${path.slice(homeDir.length)}` : path;
467
+ }
468
+ function describeEntry(entry) {
469
+ return [entry.command, ...entry.args].join(" ");
470
+ }
471
+ export async function runSetup(options = {}, deps = {}) {
472
+ const homeDir = options.homeDir ?? homedir();
473
+ const env = options.env ?? process.env;
474
+ const pathDirs = options.pathDirs ?? (env["PATH"] ?? "").split(delimiter);
475
+ const codexConfig = codexConfigPath(homeDir, env);
476
+ const requested = options.clis === undefined ? [...CLI_NAMES] : [...new Set(options.clis.map((name) => name.trim()))];
477
+ if (requested.length === 0)
478
+ throw new Error("--cli needs at least one name: claude, codex, omp, pi.");
479
+ const clis = [];
480
+ for (const name of requested) {
481
+ const cli = toCliName(name);
482
+ if (cli === undefined)
483
+ throw new Error(`Unknown CLI "${name}". Choose from: ${CLI_NAMES.join(", ")}.`);
484
+ clis.push(cli);
485
+ }
486
+ const quiet = options.quiet === true;
487
+ const uninstall = options.uninstall === true;
488
+ const dryRun = options.dryRun === true;
489
+ let entry;
490
+ let viaNpx = false;
491
+ if (!uninstall) {
492
+ const resolved = await resolveEntry(options.noInstall === true, deps.npm ?? realNpm, quiet, dryRun);
493
+ entry = resolved.entry;
494
+ viaNpx = resolved.viaNpx;
495
+ }
496
+ const reports = [];
497
+ for (const cli of clis) {
498
+ if (!isDetected(cli, homeDir, pathDirs, codexConfig)) {
499
+ reports.push({ cli, detected: false, action: "not-detected" });
500
+ continue;
501
+ }
502
+ let changed = false;
503
+ let path = "";
504
+ if (cli === "claude") {
505
+ path = join(homeDir, ".claude.json");
506
+ changed = await editConfig(path, mergeJsonServer(path, claudeServer, entry), dryRun);
507
+ }
508
+ else if (cli === "codex") {
509
+ path = codexConfig;
510
+ changed = await editConfig(path, mergeCodexServer(path, entry), dryRun);
511
+ }
512
+ else if (cli === "omp") {
513
+ path = join(homeDir, ".omp", "agent", "mcp.json");
514
+ changed = await editConfig(path, mergeJsonServer(path, ompServer, entry), dryRun);
515
+ }
516
+ else {
517
+ path = join(homeDir, ".pi", "agent", "mcp.json");
518
+ changed = await editConfig(path, mergeJsonServer(path, piServer, entry), dryRun);
519
+ }
520
+ const action = dryRun ? "dry-run" : uninstall ? (changed ? "removed" : "already-configured") : changed ? "configured" : "already-configured";
521
+ reports.push({ cli, detected: true, action, path: prettyPath(homeDir, path) });
522
+ }
523
+ if (!quiet) {
524
+ if (!uninstall)
525
+ console.log(`diffninja setup: MCP server -> ${describeEntry(entry)}${viaNpx ? " (npx, no global install)" : ""}`);
526
+ for (const report of reports) {
527
+ const label = `[${report.cli}]`;
528
+ if (report.action === "not-detected") {
529
+ console.log(`${label} not detected, skipped`);
530
+ }
531
+ else if (report.action === "dry-run") {
532
+ console.log(`${label} would update ${report.path}`);
533
+ }
534
+ else if (report.action === "configured") {
535
+ console.log(`${label} configured ${report.path}`);
536
+ }
537
+ else if (report.action === "removed") {
538
+ console.log(`${label} removed from ${report.path}`);
539
+ }
540
+ else {
541
+ console.log(`${label} already configured`);
542
+ }
543
+ }
544
+ if (reports.some((report) => report.cli === "pi" && report.detected && report.action !== "not-detected")) {
545
+ console.log("note: pi needs the pi-mcp-extension for MCP support (pi install npm:pi-mcp-extension)");
546
+ }
547
+ }
548
+ return { entry: entry ?? npxEntry(), viaNpx, clis: reports };
549
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Definition source for report call-flow nodes, read from the snapshot the
3
+ * definition lived in. Nothing here is generated: a definition whose file or
4
+ * span cannot be read honestly yields no detail rather than an approximation,
5
+ * and the description is the definition's own attached comment or docstring,
6
+ * copied verbatim.
7
+ *
8
+ * The whole span `line`..`endLine` is carried: a trimmed preview would leave
9
+ * the rest of the function readable only online, which is exactly what an
10
+ * offline report cannot do. Only what git cannot confirm is withheld.
11
+ */
12
+ import type { Snapshot, SourceLoc } from "../types.js";
13
+ import type { CallFlowSource } from "./types.js";
14
+ /** Definition source and attached prose for one node; either field may be absent. */
15
+ export interface DefinitionDetail {
16
+ source?: CallFlowSource;
17
+ description?: string;
18
+ }
19
+ /** Reads one definition's detail; a Snapshot identifies the immutable revision. */
20
+ export type DefinitionReader = (definition: SourceLoc, snapshot: Snapshot) => DefinitionDetail;
21
+ /**
22
+ * Definition source reader with one snapshot-file read per revision and path.
23
+ * The cache holds parsed lines, so a repeated call site of the same definition
24
+ * costs a slice rather than a `git show`.
25
+ */
26
+ export declare function definitionReader(cwd: string): DefinitionReader;