dsh-context-mode 0.1.2 → 0.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 (60) hide show
  1. package/LICENSING.md +37 -0
  2. package/README.md +40 -14
  3. package/lib/types/cjk.d.ts +54 -0
  4. package/lib/types/cjk.d.ts.map +1 -0
  5. package/lib/types/cjk.js +64 -0
  6. package/lib/types/index.d.ts.map +1 -1
  7. package/lib/types/index.js +71 -22
  8. package/lib/types/output-containment.d.ts +35 -0
  9. package/lib/types/output-containment.d.ts.map +1 -0
  10. package/lib/types/output-containment.js +103 -0
  11. package/lib/types/routing.d.ts +3 -1
  12. package/lib/types/routing.d.ts.map +1 -1
  13. package/lib/types/routing.js +81 -6
  14. package/lib/types/session-memory.d.ts.map +1 -1
  15. package/lib/types/session-memory.js +14 -3
  16. package/package.json +9 -5
  17. package/skills/context-mode/SKILL.md +104 -11
  18. package/vendor/context-mode/LICENSE +94 -0
  19. package/vendor/context-mode/server.bundle.mjs +1126 -0
  20. package/vendor/context-mode/src/cli.ts +2040 -0
  21. package/vendor/context-mode/src/db-base.ts +617 -0
  22. package/vendor/context-mode/src/executor.ts +785 -0
  23. package/vendor/context-mode/src/exit-classify.ts +33 -0
  24. package/vendor/context-mode/src/fetch-cache.ts +15 -0
  25. package/vendor/context-mode/src/lifecycle.ts +305 -0
  26. package/vendor/context-mode/src/platform/client-map.ts +45 -0
  27. package/vendor/context-mode/src/platform/detect.ts +645 -0
  28. package/vendor/context-mode/src/platform/dsh.ts +206 -0
  29. package/vendor/context-mode/src/platform/types.ts +503 -0
  30. package/vendor/context-mode/src/runPool.ts +81 -0
  31. package/vendor/context-mode/src/runtime.ts +765 -0
  32. package/vendor/context-mode/src/search/auto-memory.ts +200 -0
  33. package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
  34. package/vendor/context-mode/src/search/flood-guard.ts +111 -0
  35. package/vendor/context-mode/src/search/unified.ts +176 -0
  36. package/vendor/context-mode/src/security.ts +889 -0
  37. package/vendor/context-mode/src/server.ts +4991 -0
  38. package/vendor/context-mode/src/session/analytics.ts +3085 -0
  39. package/vendor/context-mode/src/session/db.ts +1726 -0
  40. package/vendor/context-mode/src/session/error-classifier.ts +392 -0
  41. package/vendor/context-mode/src/session/event-emit.ts +132 -0
  42. package/vendor/context-mode/src/session/extract.ts +2958 -0
  43. package/vendor/context-mode/src/session/index.ts +130 -0
  44. package/vendor/context-mode/src/session/model-prices.json +429 -0
  45. package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
  46. package/vendor/context-mode/src/session/pricing.ts +191 -0
  47. package/vendor/context-mode/src/session/project-attribution.ts +309 -0
  48. package/vendor/context-mode/src/session/purge.ts +338 -0
  49. package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
  50. package/vendor/context-mode/src/session/snapshot.ts +577 -0
  51. package/vendor/context-mode/src/store-directory.ts +290 -0
  52. package/vendor/context-mode/src/store.ts +2071 -0
  53. package/vendor/context-mode/src/truncate.ts +154 -0
  54. package/vendor/context-mode/src/types.ts +147 -0
  55. package/vendor/context-mode/src/util/claude-config.ts +95 -0
  56. package/vendor/context-mode/src/util/hook-config.ts +78 -0
  57. package/vendor/context-mode/src/util/jsonc.ts +70 -0
  58. package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
  59. package/vendor/context-mode/src/util/project-dir.ts +347 -0
  60. package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
@@ -0,0 +1,2040 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * context-mode CLI
4
+ *
5
+ * Usage:
6
+ * context-mode → Start MCP server (stdio)
7
+ * context-mode doctor → Diagnose runtime issues, hooks, FTS5, version
8
+ * context-mode upgrade → Fix hooks, permissions, and settings
9
+ * context-mode hook <platform> <event> → Dispatch a hook script (used by platform hook configs)
10
+ * CONTEXT_MODE_DIR=/abs/path context-mode → Override sessions/content storage root
11
+ * Empty/whitespace is ignored; non-empty values must be absolute.
12
+ *
13
+ * Platform auto-detection: CLI detects which platform is running
14
+ * (Claude Code, Gemini CLI, OpenCode, etc.) and uses the appropriate adapter.
15
+ */
16
+
17
+ import * as p from "@clack/prompts";
18
+ import color from "picocolors";
19
+ import { execFileSync, execSync, execFile as nodeExecFile, type ExecSyncOptions } from "node:child_process";
20
+ import { readFileSync, writeFileSync, cpSync, accessSync, existsSync, readdirSync, rmSync, closeSync, openSync, chmodSync, mkdirSync, lstatSync, realpathSync, statSync, constants } from "node:fs";
21
+ import { request as httpsRequest } from "node:https";
22
+ import { resolve, dirname, join, sep, basename, isAbsolute } from "node:path";
23
+ import { tmpdir, devNull, homedir } from "node:os";
24
+ import { fileURLToPath, pathToFileURL } from "node:url";
25
+ import {
26
+ detectRuntimes,
27
+ getRuntimeSummary,
28
+ hasBunRuntime,
29
+ getAvailableLanguages,
30
+ } from "./runtime.js";
31
+ import { getHookScriptPaths } from "./util/hook-config.js";
32
+ import { resolveClaudeConfigDir } from "./util/claude-config.js";
33
+ import {
34
+ ensureWritableStorageDir,
35
+ formatStorageDirectoryError,
36
+ resolveContentStorageDir,
37
+ resolveSessionStorageDir,
38
+ resolveStatsStorageDir,
39
+ StorageDirectoryError,
40
+ type ResolvedStorageDir,
41
+ } from "./session/db.js";
42
+ import { ContentStore } from "./store.js";
43
+ import { readToolDenyPatterns, evaluateFilePath } from "./security.js";
44
+ // v1.0.128 — Issue #559 sibling MCP kill helpers (see PR-559-560-FIX-DESIGN.md).
45
+ import { discoverSiblingMcpPids, killSiblingMcpServers } from "./util/sibling-mcp.js";
46
+ // v1.0.119 — Issue #523 Layer 5 heal: post-bump assertion on .claude-plugin/plugin.json
47
+ // mcpServers args. Single source of truth shared with start.mjs HEAL block + postinstall.
48
+ // @ts-expect-error — JS module, no TS declarations
49
+ import { healPluginJsonMcpServers, sweepStaleMcpJson } from "../scripts/heal-installed-plugins.mjs";
50
+ // @ts-expect-error — JS module, no TS declarations
51
+ import { detectWindowsVsYear } from "../scripts/heal-better-sqlite3.mjs";
52
+ // Private 16-LOC copy of browserOpenArgv. Canonical version lives in src/server.ts;
53
+ // duplicated here so the cli bundle does not pull server.ts top-level boot side effects.
54
+ // Keep in sync — pure data, no I/O.
55
+ function browserOpenArgv(
56
+ url: string,
57
+ platform: NodeJS.Platform,
58
+ ): readonly { cmd: string; args: readonly string[] }[] {
59
+ if (platform === "darwin") return [{ cmd: "open", args: [url] }];
60
+ if (platform === "win32") {
61
+ return [{ cmd: "cmd", args: ["/c", "start", "", url] }];
62
+ }
63
+ return [
64
+ { cmd: "xdg-open", args: [url] },
65
+ { cmd: "sensible-browser", args: [url] },
66
+ ];
67
+ }
68
+
69
+ // ── Adapter imports ──────────────────────────────────────
70
+ import { detectPlatform, getAdapter } from "./platform/detect.js";
71
+ import { isInProcessPluginPlatform } from "./platform/types.js";
72
+
73
+ /* -------------------------------------------------------
74
+ * Hook dispatcher — `context-mode hook <platform> <event>`
75
+ * ------------------------------------------------------- */
76
+
77
+ const HOOK_MAP: Record<string, Record<string, string>> = {
78
+ "claude-code": {
79
+ pretooluse: "hooks/pretooluse.mjs",
80
+ posttooluse: "hooks/posttooluse.mjs",
81
+ precompact: "hooks/precompact.mjs",
82
+ sessionstart: "hooks/sessionstart.mjs",
83
+ userpromptsubmit: "hooks/userpromptsubmit.mjs",
84
+ stop: "hooks/stop.mjs",
85
+ },
86
+ "gemini-cli": {
87
+ beforeagent: "hooks/gemini-cli/beforeagent.mjs",
88
+ beforetool: "hooks/gemini-cli/beforetool.mjs",
89
+ aftertool: "hooks/gemini-cli/aftertool.mjs",
90
+ precompress: "hooks/gemini-cli/precompress.mjs",
91
+ sessionstart: "hooks/gemini-cli/sessionstart.mjs",
92
+ },
93
+ "vscode-copilot": {
94
+ pretooluse: "hooks/vscode-copilot/pretooluse.mjs",
95
+ posttooluse: "hooks/vscode-copilot/posttooluse.mjs",
96
+ precompact: "hooks/vscode-copilot/precompact.mjs",
97
+ sessionstart: "hooks/vscode-copilot/sessionstart.mjs",
98
+ },
99
+ "cursor": {
100
+ pretooluse: "hooks/cursor/pretooluse.mjs",
101
+ posttooluse: "hooks/cursor/posttooluse.mjs",
102
+ sessionstart: "hooks/cursor/sessionstart.mjs",
103
+ stop: "hooks/cursor/stop.mjs",
104
+ afteragentresponse: "hooks/cursor/afteragentresponse.mjs",
105
+ },
106
+ "codex": {
107
+ pretooluse: "hooks/codex/pretooluse.mjs",
108
+ posttooluse: "hooks/codex/posttooluse.mjs",
109
+ precompact: "hooks/codex/precompact.mjs",
110
+ sessionstart: "hooks/codex/sessionstart.mjs",
111
+ userpromptsubmit: "hooks/codex/userpromptsubmit.mjs",
112
+ stop: "hooks/codex/stop.mjs",
113
+ },
114
+ "kiro": {
115
+ pretooluse: "hooks/kiro/pretooluse.mjs",
116
+ posttooluse: "hooks/kiro/posttooluse.mjs",
117
+ },
118
+ "jetbrains-copilot": {
119
+ pretooluse: "hooks/jetbrains-copilot/pretooluse.mjs",
120
+ posttooluse: "hooks/jetbrains-copilot/posttooluse.mjs",
121
+ precompact: "hooks/jetbrains-copilot/precompact.mjs",
122
+ sessionstart: "hooks/jetbrains-copilot/sessionstart.mjs",
123
+ },
124
+ "copilot-cli": {
125
+ pretooluse: "hooks/copilot-cli/pretooluse.mjs",
126
+ posttooluse: "hooks/copilot-cli/posttooluse.mjs",
127
+ precompact: "hooks/copilot-cli/precompact.mjs",
128
+ sessionstart: "hooks/copilot-cli/sessionstart.mjs",
129
+ userpromptsubmit: "hooks/copilot-cli/userpromptsubmit.mjs",
130
+ stop: "hooks/copilot-cli/stop.mjs",
131
+ },
132
+ // Antigravity CLI (`agy`) — bounded PreToolUse enforcement plus capture-only
133
+ // PostToolUse/Stop hooks. Configured via an installed agy plugin's
134
+ // hooks/hooks.json or ~/.gemini/config/hooks.json.
135
+ "antigravity-cli": {
136
+ pretooluse: "hooks/antigravity-cli/pretooluse.mjs",
137
+ posttooluse: "hooks/antigravity-cli/posttooluse.mjs",
138
+ stop: "hooks/antigravity-cli/stop.mjs",
139
+ },
140
+ "kimi": {
141
+ pretooluse: "hooks/kimi/pretooluse.mjs",
142
+ posttooluse: "hooks/kimi/posttooluse.mjs",
143
+ precompact: "hooks/kimi/precompact.mjs",
144
+ sessionstart: "hooks/kimi/sessionstart.mjs",
145
+ sessionend: "hooks/kimi/sessionend.mjs",
146
+ userpromptsubmit: "hooks/kimi/userpromptsubmit.mjs",
147
+ stop: "hooks/kimi/stop.mjs",
148
+ },
149
+ "qwen-code": {
150
+ pretooluse: "hooks/pretooluse.mjs",
151
+ posttooluse: "hooks/posttooluse.mjs",
152
+ precompact: "hooks/precompact.mjs",
153
+ sessionstart: "hooks/sessionstart.mjs",
154
+ userpromptsubmit: "hooks/userpromptsubmit.mjs",
155
+ },
156
+ };
157
+
158
+ async function hookDispatch(platform: string, event: string): Promise<void> {
159
+ // Suppress stderr at OS fd level — native C++ modules (better-sqlite3) write
160
+ // directly to fd 2 during initialization, bypassing Node.js process.stderr.
161
+ // Platforms like Claude Code interpret ANY stderr output as hook failure.
162
+ // Cross-platform: os.devNull → /dev/null (Unix) or \\.\NUL (Windows). See: #68
163
+ try {
164
+ closeSync(2);
165
+ openSync(devNull, "w"); // Acquires fd 2 (lowest available)
166
+ } catch {
167
+ process.stderr.write = (() => true) as typeof process.stderr.write;
168
+ }
169
+
170
+ const scriptPath = HOOK_MAP[platform]?.[event];
171
+ if (!scriptPath) {
172
+ // Fail OPEN. context-mode has no hook for this platform/event — most often
173
+ // because a newer adapter's hook command (`context-mode hook copilot-cli …`)
174
+ // is running against an OLDER global binary that predates that adapter
175
+ // (version skew). Exit 0 (no decision) so the host ALLOWS the tool. Exiting
176
+ // non-zero here makes some hosts treat it as a hook ERROR and DENY the tool:
177
+ // verified against GitHub Copilot CLI 1.0.59, where an exit-1 + empty-stdout
178
+ // PreToolUse hook blocks EVERY tool ("Denied by preToolUse hook (hook
179
+ // errored)") — bricking the agent during a skew instead of just disabling
180
+ // context-mode's instrumentation.
181
+ process.exit(0);
182
+ }
183
+ const pluginRoot = getPluginRoot();
184
+ await import(pathToFileURL(join(pluginRoot, scriptPath)).href);
185
+ }
186
+
187
+ /* -------------------------------------------------------
188
+ * Entry point
189
+ * ------------------------------------------------------- */
190
+
191
+ const args = process.argv.slice(2);
192
+
193
+ function printHelp(): void {
194
+ console.log([
195
+ "Usage:",
196
+ " context-mode Start MCP server (stdio)",
197
+ " context-mode index <path> Index a file or directory into the FTS5 knowledge base",
198
+ " context-mode search <query...> Search the current project's FTS5 knowledge base",
199
+ " context-mode doctor Diagnose runtime issues, hooks, FTS5, version",
200
+ " context-mode upgrade Fix hooks, permissions, and settings",
201
+ " context-mode hook <platform> <event> Dispatch a configured hook script",
202
+ " context-mode statusline Print Claude Code status line",
203
+ "",
204
+ "Index options:",
205
+ " --source <label> Source label (default: project:<directory-name> or path)",
206
+ " --project <path> Project identity for the content DB (default: indexed dir or cwd)",
207
+ " --max-depth <n> Directory recursion depth (default: 5)",
208
+ " --max-files <n> Directory file cap (default: 200)",
209
+ " --ext <.ts,.md> Comma-separated extension allowlist",
210
+ " --include <glob> Directory include pattern (repeatable)",
211
+ " --exclude <glob> Directory exclude pattern (repeatable)",
212
+ " --no-gitignore Do not apply .gitignore during directory walks",
213
+ " --follow-symlinks Follow directory symlinks inside the root",
214
+ "",
215
+ "Search options:",
216
+ " --project <path> Project identity for the content DB (default: cwd)",
217
+ " --source <label> Filter to a source label (partial match)",
218
+ " --limit <n> Results to show (default: 3)",
219
+ " --type <code|prose> Filter by content type",
220
+ "",
221
+ "Environment:",
222
+ " CONTEXT_MODE_DIR=/absolute/path Override sessions/content storage root; empty is ignored, non-empty must be absolute",
223
+ ].join("\n"));
224
+ }
225
+
226
+ if (args[0] === "--help" || args[0] === "-h" || args[0] === "help") {
227
+ printHelp();
228
+ } else if (args[0] === "index") {
229
+ indexCommand(args.slice(1)).then((code) => process.exit(code));
230
+ } else if (args[0] === "search") {
231
+ searchCommand(args.slice(1)).then((code) => process.exit(code));
232
+ } else if (args[0] === "doctor") {
233
+ doctor().then((code) => process.exit(code));
234
+ } else if (args[0] === "upgrade") {
235
+ // Issue #542 — accept --platform <id> from the ctx_upgrade MCP handler,
236
+ // which forwards the live MCP clientInfo's resolved PlatformId. The flag
237
+ // wins over upgrade()'s own detectPlatform() heuristic chain so an
238
+ // ambiguous config-dir collision (e.g. ~/.cursor + ~/.pi both present)
239
+ // can never misroute the upgrade.
240
+ const platformFlagIdx = args.indexOf("--platform");
241
+ const platformArg =
242
+ platformFlagIdx >= 0 && args[platformFlagIdx + 1]
243
+ ? args[platformFlagIdx + 1]
244
+ : undefined;
245
+ upgrade(platformArg ? { platform: platformArg } : undefined).catch((err: unknown) => {
246
+ const message = err instanceof Error ? err.message : String(err);
247
+ p.log.error(color.red(message));
248
+ process.exit(1);
249
+ });
250
+ } else if (args[0] === "hook") {
251
+ hookDispatch(args[1], args[2]);
252
+ } else if (args[0] === "insight") {
253
+ insight();
254
+ } else if (args[0] === "statusline") {
255
+ // Status line implementation lives in bin/statusline.mjs to keep it
256
+ // dependency-free and fast. Forward stdin and exit with its result.
257
+ statuslineForward();
258
+ } else {
259
+ // Default: start MCP server
260
+ import("./server.js");
261
+ }
262
+
263
+ /* -------------------------------------------------------
264
+ * Shared helpers
265
+ * ------------------------------------------------------- */
266
+
267
+ /** Normalize Windows backslash paths to forward slashes for Bash (MSYS2) compatibility. */
268
+ export function toUnixPath(p: string): string {
269
+ return p.replace(/\\/g, "/");
270
+ }
271
+
272
+ /**
273
+ * Windows-safe npm execution. On Windows:
274
+ * - "npm" → "npm.cmd" (Node won't resolve via PATHEXT in execFile)
275
+ * - shell: true required (Node v20+ CVE-2024-27980 mitigation)
276
+ * See: https://github.com/mksglu/context-mode/issues/344
277
+ */
278
+ const isWin = process.platform === "win32";
279
+
280
+ export function npmExecFile(args: string[], opts: Record<string, unknown> = {}): void {
281
+ execFileSync(isWin ? "npm.cmd" : "npm", args, {
282
+ ...opts,
283
+ ...(isWin ? { shell: true } : {}),
284
+ });
285
+ }
286
+
287
+ export function npmExec(command: string, opts: Record<string, unknown> = {}): void {
288
+ // Issue #511: use top-level static import (line 17) — never inline `require("node:...")`
289
+ // in ESM-bundled sources. esbuild rewrites them to a `__require` shim that throws
290
+ // `Dynamic require of "node:child_process" is not supported` under Node ESM/Bun.
291
+ // Cast preserves the prior `require()`-as-`any` shape; `shell: true` is the documented
292
+ // Node behavior even though @types/node typed `shell` as `string | undefined`.
293
+ const execOpts = {
294
+ ...opts,
295
+ ...(isWin ? { shell: true } : {}),
296
+ } as unknown as ExecSyncOptions;
297
+ execSync(isWin ? command.replace(/^npm /, "npm.cmd ") : command, execOpts);
298
+ }
299
+
300
+ /**
301
+ * Open a URL in the user's default browser without invoking a shell.
302
+ *
303
+ * Uses `execFile` with an arg array so the URL cannot be interpreted as
304
+ * shell metacharacters. Original code used `execSync(`open "${url}"`)`
305
+ * which would shell-interpolate the URL — fragile if the URL ever
306
+ * becomes attacker-controlled (remote, weak port-validation, etc).
307
+ *
308
+ * Best-effort: if the OS opener is missing the function logs a copyable
309
+ * URL hint and returns; it never throws. `runner` is injectable for
310
+ * tests; default is `child_process.execFile` (callback form, fire-and-
311
+ * forget).
312
+ */
313
+ export type ExecFileFn = (
314
+ file: string,
315
+ args: readonly string[],
316
+ opts?: Record<string, unknown>,
317
+ ) => unknown;
318
+
319
+ export function openInBrowser(
320
+ url: string,
321
+ platform: NodeJS.Platform = process.platform,
322
+ runner: ExecFileFn = nodeExecFile as unknown as ExecFileFn,
323
+ ): void {
324
+ const opts = { stdio: "ignore" as const };
325
+ const hint = () =>
326
+ console.error(`\nCould not auto-open browser. Open manually: ${url}`);
327
+
328
+ // Platform→argv mapping is canonical in src/server.ts; mirrored privately
329
+ // above to avoid pulling server boot side effects into the cli bundle.
330
+ const attempts = browserOpenArgv(url, platform);
331
+ let opened = false;
332
+ for (const { cmd, args } of attempts) {
333
+ try {
334
+ runner(cmd, args as string[], opts);
335
+ opened = true;
336
+ break;
337
+ } catch { /* try next fallback */ }
338
+ }
339
+ if (!opened) hint();
340
+ }
341
+
342
+ function defaultPluginRoot(): string {
343
+ const __filename = fileURLToPath(import.meta.url);
344
+ const __dirname = dirname(__filename);
345
+ // build/cli.js or src/cli.ts → go up one level; cli.bundle.mjs at project root → stay here
346
+ if (__dirname.endsWith("/build") || __dirname.endsWith("\\build") ||
347
+ __dirname.endsWith("/src") || __dirname.endsWith("\\src")) {
348
+ return resolve(__dirname, "..");
349
+ }
350
+ return __dirname;
351
+ }
352
+
353
+ // Opencode/Kilocode install plugins from npm into a per-package cache folder.
354
+ // Layout (changed silently in late 2024 — see PR #376 / KiloCode#9503):
355
+ // POSIX : ~/.cache/<platform>/packages/context-mode@latest/node_modules/context-mode
356
+ // Windows: %LOCALAPPDATA%\<platform>\packages\context-mode@latest\node_modules\context-mode
357
+ function cachePluginRoot(platform: string): string {
358
+ const subPath = ["packages", "context-mode@latest", "node_modules", "context-mode"];
359
+ if (process.platform === "win32") {
360
+ const localApp = process.env.LOCALAPPDATA;
361
+ if (localApp) return resolve(localApp, platform, ...subPath);
362
+ return resolve(homedir(), "AppData", "Local", platform, ...subPath);
363
+ }
364
+ return resolve(homedir(), ".cache", platform, ...subPath);
365
+ }
366
+
367
+ function getPluginRoot(): string {
368
+ const platform = detectPlatform().platform;
369
+ if (isInProcessPluginPlatform(platform)) {
370
+ return cachePluginRoot(platform);
371
+ }
372
+ return defaultPluginRoot();
373
+ }
374
+
375
+ function getLocalVersion(): string {
376
+ try {
377
+ const pkg = JSON.parse(readFileSync(resolve(getPluginRoot(), "package.json"), "utf-8"));
378
+ return pkg.version ?? "unknown";
379
+ } catch {
380
+ return "unknown";
381
+ }
382
+ }
383
+
384
+ async function fetchLatestVersion(): Promise<string> {
385
+ // Use node:https instead of global fetch to avoid a Windows libuv assertion
386
+ // (UV_HANDLE_CLOSING) caused by undici's connection-pool background threads
387
+ // racing with process.exit() teardown on Node.js v24+.
388
+ return new Promise((resolve) => {
389
+ const req = httpsRequest(
390
+ "https://registry.npmjs.org/context-mode/latest",
391
+ { headers: { Connection: "close" } },
392
+ (res) => {
393
+ let raw = "";
394
+ res.on("data", (chunk: Buffer) => { raw += chunk; });
395
+ res.on("end", () => {
396
+ try {
397
+ const data = JSON.parse(raw) as { version?: string };
398
+ resolve(data.version ?? "unknown");
399
+ } catch {
400
+ resolve("unknown");
401
+ }
402
+ });
403
+ },
404
+ );
405
+ req.on("error", () => resolve("unknown"));
406
+ req.setTimeout(5000, () => { req.destroy(); resolve("unknown"); });
407
+ req.end();
408
+ });
409
+ }
410
+
411
+ /* -------------------------------------------------------
412
+ * Doctor — adapter-aware diagnostics
413
+ * ------------------------------------------------------- */
414
+
415
+ function describeStorageSource(dir: ResolvedStorageDir): string {
416
+ return dir.envVar ? dir.envVar : "adapter default";
417
+ }
418
+
419
+ interface ParsedFlags {
420
+ positional: string[];
421
+ flags: Record<string, string | boolean | string[]>;
422
+ }
423
+
424
+ function parseFlags(argv: string[]): ParsedFlags {
425
+ const positional: string[] = [];
426
+ const flags: Record<string, string | boolean | string[]> = {};
427
+
428
+ for (let i = 0; i < argv.length; i++) {
429
+ const arg = argv[i]!;
430
+ if (!arg.startsWith("--") || arg === "--") {
431
+ positional.push(arg);
432
+ continue;
433
+ }
434
+
435
+ const raw = arg.slice(2);
436
+ const eq = raw.indexOf("=");
437
+ const key = eq >= 0 ? raw.slice(0, eq) : raw;
438
+ const inlineValue = eq >= 0 ? raw.slice(eq + 1) : undefined;
439
+ const next = argv[i + 1];
440
+ const value =
441
+ inlineValue !== undefined
442
+ ? inlineValue
443
+ : next && !next.startsWith("--")
444
+ ? (i++, next)
445
+ : true;
446
+
447
+ if (key === "include" || key === "exclude") {
448
+ const prev = flags[key];
449
+ flags[key] = Array.isArray(prev) ? [...prev, String(value)] : [String(value)];
450
+ } else {
451
+ flags[key] = value;
452
+ }
453
+ }
454
+
455
+ return { positional, flags };
456
+ }
457
+
458
+ function stringFlag(flags: ParsedFlags["flags"], key: string): string | undefined {
459
+ const v = flags[key];
460
+ if (typeof v === "string" && v.length > 0) return v;
461
+ return undefined;
462
+ }
463
+
464
+ function boolFlag(flags: ParsedFlags["flags"], key: string): boolean {
465
+ return flags[key] === true || flags[key] === "true";
466
+ }
467
+
468
+ function stringListFlag(flags: ParsedFlags["flags"], key: string): string[] | undefined {
469
+ const v = flags[key];
470
+ if (Array.isArray(v)) return v.filter(Boolean);
471
+ if (typeof v === "string" && v.length > 0) return [v];
472
+ return undefined;
473
+ }
474
+
475
+ function numberFlag(flags: ParsedFlags["flags"], key: string, opts: { min?: number } = {}): number | undefined {
476
+ const raw = stringFlag(flags, key);
477
+ if (!raw) return undefined;
478
+ const n = Number(raw);
479
+ const min = opts.min ?? 1;
480
+ if (!Number.isInteger(n) || n < min) throw new Error(`--${key} must be an integer >= ${min}`);
481
+ return n;
482
+ }
483
+
484
+ function extFlag(flags: ParsedFlags["flags"]): string[] | undefined {
485
+ const raw = stringFlag(flags, "ext") ?? stringFlag(flags, "extensions");
486
+ if (!raw) return undefined;
487
+ const exts = raw
488
+ .split(",")
489
+ .map((x) => x.trim())
490
+ .filter(Boolean)
491
+ .map((x) => (x.startsWith(".") ? x : `.${x}`));
492
+ return exts.length > 0 ? exts : undefined;
493
+ }
494
+
495
+ function resolveCliProjectDir(projectFlag: string | undefined, fallback: string): string {
496
+ if (projectFlag) return resolve(projectFlag);
497
+ return resolve(fallback);
498
+ }
499
+
500
+ async function openCliContentStore(projectDir: string): Promise<{ store: ContentStore; dbPath: string; contentDir: string }> {
501
+ const adapter = await getAdapter(detectPlatform().platform);
502
+ const contentStorage = resolveContentStorageDir(() => adapter.getSessionDir());
503
+ const contentDir = ensureWritableStorageDir(contentStorage);
504
+ const { resolveContentStorePath } = await import("./session/db.js");
505
+ const dbPath = resolveContentStorePath({ projectDir, contentDir });
506
+ return { store: new ContentStore(dbPath), dbPath, contentDir };
507
+ }
508
+
509
+ function defaultSourceForPath(absPath: string): string {
510
+ try {
511
+ if (statSync(absPath).isDirectory()) return `project:${basename(absPath) || absPath}`;
512
+ } catch { /* path errors are reported by the index command */ }
513
+ return absPath;
514
+ }
515
+
516
+ function assertReadAllowed(path: string, projectDir: string): void {
517
+ const denyGlobs = readToolDenyPatterns("Read", projectDir);
518
+ const denied = evaluateFilePath(path, denyGlobs, process.platform === "win32", projectDir);
519
+ if (denied.denied) {
520
+ throw new Error(`Read denied by policy: ${path}`);
521
+ }
522
+ }
523
+
524
+ async function indexCommand(argv: string[]): Promise<number> {
525
+ try {
526
+ const parsed = parseFlags(argv);
527
+ const target = parsed.positional[0];
528
+ if (!target || target === "-h" || target === "--help") {
529
+ console.log("Usage: context-mode index <path> [--source label] [--project path] [--max-files n] [--max-depth n] [--ext .ts,.md]");
530
+ return target ? 0 : 1;
531
+ }
532
+
533
+ const absPath = isAbsolute(target) ? resolve(target) : resolve(process.cwd(), target);
534
+ if (!existsSync(absPath)) throw new Error(`Path does not exist: ${absPath}`);
535
+
536
+ const st = statSync(absPath);
537
+ const projectDir = resolveCliProjectDir(
538
+ stringFlag(parsed.flags, "project"),
539
+ st.isDirectory() ? absPath : dirname(absPath),
540
+ );
541
+ const source = stringFlag(parsed.flags, "source") ?? defaultSourceForPath(absPath);
542
+ const { store, dbPath } = await openCliContentStore(projectDir);
543
+
544
+ try {
545
+ assertReadAllowed(absPath, projectDir);
546
+ if (st.isDirectory()) {
547
+ const denyGlobs = readToolDenyPatterns("Read", projectDir);
548
+ const result = store.indexDirectory({
549
+ path: absPath,
550
+ source,
551
+ include: stringListFlag(parsed.flags, "include"),
552
+ exclude: stringListFlag(parsed.flags, "exclude"),
553
+ maxDepth: numberFlag(parsed.flags, "max-depth", { min: 0 }),
554
+ maxFiles: numberFlag(parsed.flags, "max-files"),
555
+ extensions: extFlag(parsed.flags),
556
+ respectGitignore: !boolFlag(parsed.flags, "no-gitignore"),
557
+ followSymlinks: boolFlag(parsed.flags, "follow-symlinks"),
558
+ perFileDeny: (filePath) => {
559
+ try {
560
+ return evaluateFilePath(filePath, denyGlobs, process.platform === "win32", projectDir).denied;
561
+ } catch {
562
+ return false;
563
+ }
564
+ },
565
+ });
566
+ const cap = result.capped ? ` (cap reached at ${result.filesIndexed} files)` : "";
567
+ const denied = result.denied > 0 ? `; ${result.denied} denied` : "";
568
+ const failed = result.failed > 0 ? `; ${result.failed} failed` : "";
569
+ console.log(`Indexed ${result.filesIndexed} files (${result.totalChunks} sections) from ${absPath}${cap}${denied}${failed}`);
570
+ } else {
571
+ const result = store.index({ path: absPath, source });
572
+ console.log(`Indexed ${result.totalChunks} sections (${result.codeChunks} with code) from ${absPath}`);
573
+ }
574
+ console.log(`Source: ${source}`);
575
+ console.log(`Project: ${projectDir}`);
576
+ console.log(`DB: ${dbPath}`);
577
+ return 0;
578
+ } finally {
579
+ store.close();
580
+ }
581
+ } catch (err) {
582
+ const message = err instanceof Error ? err.message : String(err);
583
+ console.error(`context-mode index: ${message}`);
584
+ return 1;
585
+ }
586
+ }
587
+
588
+ async function searchCommand(argv: string[]): Promise<number> {
589
+ try {
590
+ const parsed = parseFlags(argv);
591
+ const query = parsed.positional.join(" ").trim();
592
+ if (!query || query === "-h" || query === "--help") {
593
+ console.log("Usage: context-mode search <query...> [--source label] [--project path] [--limit n] [--type code|prose]");
594
+ return query ? 0 : 1;
595
+ }
596
+
597
+ const projectDir = resolveCliProjectDir(stringFlag(parsed.flags, "project"), process.cwd());
598
+ const { store, dbPath } = await openCliContentStore(projectDir);
599
+ try {
600
+ const limit = numberFlag(parsed.flags, "limit") ?? 3;
601
+ const type = stringFlag(parsed.flags, "type");
602
+ if (type && type !== "code" && type !== "prose") throw new Error("--type must be code or prose");
603
+
604
+ const results = store.searchWithFallback(
605
+ query,
606
+ limit,
607
+ stringFlag(parsed.flags, "source"),
608
+ type as "code" | "prose" | undefined,
609
+ );
610
+ if (results.length === 0) {
611
+ console.log(`No matches for: ${query}`);
612
+ console.log(`Project: ${projectDir}`);
613
+ console.log(`DB: ${dbPath}`);
614
+ return 0;
615
+ }
616
+ for (const [i, r] of results.entries()) {
617
+ const content = r.content.replace(/\s+/g, " ").trim();
618
+ const snippet = content.length > 500 ? `${content.slice(0, 500)}...` : content;
619
+ console.log(`## ${i + 1}. ${r.title}`);
620
+ console.log(`Source: ${r.source}`);
621
+ console.log(`Type: ${r.contentType}`);
622
+ console.log(snippet);
623
+ console.log("");
624
+ }
625
+ return 0;
626
+ } finally {
627
+ store.close();
628
+ }
629
+ } catch (err) {
630
+ const message = err instanceof Error ? err.message : String(err);
631
+ console.error(`context-mode search: ${message}`);
632
+ return 1;
633
+ }
634
+ }
635
+
636
+ function logStorageDir(dir: ResolvedStorageDir): number {
637
+ try {
638
+ ensureWritableStorageDir(dir);
639
+ p.log.success(
640
+ color.green(`Storage ${dir.kind}: PASS`) +
641
+ color.dim(` — ${dir.path} (${describeStorageSource(dir)})`),
642
+ );
643
+ return 0;
644
+ } catch (err) {
645
+ if (err instanceof StorageDirectoryError) {
646
+ p.log.error(
647
+ color.red(`Storage ${dir.kind}: FAIL`) +
648
+ color.dim(` — ${formatStorageDirectoryError(err)}`),
649
+ );
650
+ return 1;
651
+ }
652
+ throw err;
653
+ }
654
+ }
655
+
656
+ async function doctor(): Promise<number> {
657
+ if (process.stdout.isTTY) console.clear();
658
+
659
+ // Detect platform
660
+ const detection = detectPlatform();
661
+ const adapter = await getAdapter(detection.platform);
662
+
663
+ p.intro(color.bgMagenta(color.white(" context-mode doctor ")));
664
+ p.log.info(
665
+ `Platform: ${color.cyan(adapter.name)}` +
666
+ color.dim(` (${detection.confidence} confidence — ${detection.reason})`),
667
+ );
668
+
669
+ let criticalFails = 0;
670
+
671
+ try {
672
+ const sessionDir = resolveSessionStorageDir(() => adapter.getSessionDir());
673
+ const contentDir = resolveContentStorageDir(() => sessionDir.path);
674
+ const statsDir = resolveStatsStorageDir(() => sessionDir.path);
675
+
676
+ p.note(
677
+ [
678
+ `sessions: ${sessionDir.path} (${describeStorageSource(sessionDir)})`,
679
+ `content: ${contentDir.path} (${describeStorageSource(contentDir)})`,
680
+ `stats: ${statsDir.path} (${describeStorageSource(statsDir)})`,
681
+ ].join("\n"),
682
+ "Storage paths",
683
+ );
684
+ criticalFails += logStorageDir(sessionDir);
685
+ criticalFails += logStorageDir(contentDir);
686
+ criticalFails += logStorageDir(statsDir);
687
+ } catch (err) {
688
+ if (err instanceof StorageDirectoryError) {
689
+ criticalFails++;
690
+ p.log.error(
691
+ color.red(`Storage ${err.kind}: FAIL`) +
692
+ color.dim(` — ${formatStorageDirectoryError(err)}`),
693
+ );
694
+ } else {
695
+ throw err;
696
+ }
697
+ }
698
+
699
+ const s = p.spinner();
700
+ s.start("Running diagnostics");
701
+
702
+ let runtimes: ReturnType<typeof detectRuntimes>;
703
+ let available: string[];
704
+ try {
705
+ runtimes = detectRuntimes();
706
+ available = getAvailableLanguages(runtimes);
707
+ } catch {
708
+ s.stop("Diagnostics partial");
709
+ p.log.warn(color.yellow("Could not detect runtimes") + color.dim(" — module may be missing, restart session after upgrade"));
710
+ p.outro(color.yellow("Doctor could not fully run — try again after restarting"));
711
+ return 1;
712
+ }
713
+
714
+ s.stop("Diagnostics complete");
715
+
716
+ // Runtime check
717
+ p.note(getRuntimeSummary(runtimes), "Runtimes");
718
+
719
+ // ── Issue #564 — Linux + Node < 22.5 + no Bun is unsafe ────────────
720
+ // V8's madvise(MADV_DONTNEED) can corrupt better-sqlite3's native addon
721
+ // `.got.plt` on Linux, causing sporadic SIGSEGV (1-4/hour). The 22.5
722
+ // gate (`hasModernSqlite()` in src/db-base.ts:226-244) is the contract:
723
+ // at or above it we use node:sqlite (built-in, no native addon, no
724
+ // .got.plt to corrupt); below it we fall through to better-sqlite3
725
+ // which WILL crash. engines.node + a hard-fail postinstall guard this
726
+ // at install time, but doctor() surfaces it for already-installed users
727
+ // (and for adapters whose MCP host swallows stderr during install).
728
+ // Refs:
729
+ // - https://github.com/nodejs/node/issues/62515
730
+ // - https://github.com/mksglu/context-mode/issues/564
731
+ {
732
+ const { hasModernSqlite } = await import("./db-base.js");
733
+ if (
734
+ process.platform === "linux" &&
735
+ !hasModernSqlite() &&
736
+ !hasBunRuntime()
737
+ ) {
738
+ criticalFails++;
739
+ p.log.error(
740
+ color.red("Node version: FAIL") +
741
+ ` — Linux + Node ${process.versions.node} is unsafe (SIGSEGV)` +
742
+ color.dim(
743
+ "\n context-mode requires Node.js >= 22.5 (or Bun) on Linux to avoid the" +
744
+ "\n V8 madvise(MADV_DONTNEED) SIGSEGV in better-sqlite3 (1-4/hour)." +
745
+ "\n Refs: https://github.com/nodejs/node/issues/62515" +
746
+ "\n https://github.com/mksglu/context-mode/issues/564" +
747
+ "\n Fix: nvm install 22.5 && nvm use 22.5 && npm install -g context-mode" +
748
+ "\n Or: curl -fsSL https://bun.sh/install | bash && bun add -g context-mode",
749
+ ),
750
+ );
751
+ }
752
+ }
753
+
754
+ // Speed tier
755
+ if (hasBunRuntime()) {
756
+ p.log.success(
757
+ color.green("Performance: FAST") +
758
+ " — Bun detected for JS/TS execution",
759
+ );
760
+ } else {
761
+ p.log.warn(
762
+ color.yellow("Performance: NORMAL") +
763
+ " — Using Node.js (install Bun for 3-5x speed boost)",
764
+ );
765
+ }
766
+
767
+ // Language coverage
768
+ const total = 11;
769
+ const pct = ((available.length / total) * 100).toFixed(0);
770
+ if (available.length < 2) {
771
+ criticalFails++;
772
+ p.log.error(
773
+ color.red(`Language coverage: ${available.length}/${total} (${pct}%)`) +
774
+ " — too few runtimes detected" +
775
+ color.dim(` — ${available.join(", ") || "none"}`),
776
+ );
777
+ } else {
778
+ p.log.info(
779
+ `Language coverage: ${available.length}/${total} (${pct}%)` +
780
+ color.dim(` — ${available.join(", ")}`),
781
+ );
782
+ }
783
+
784
+ // Server test
785
+ p.log.step("Testing server initialization...");
786
+ try {
787
+ const { PolyglotExecutor } = await import("./executor.js");
788
+ const executor = new PolyglotExecutor({ runtimes });
789
+ const result = await executor.execute({
790
+ language: "javascript",
791
+ code: 'console.log("ok");',
792
+ timeout: 5000,
793
+ });
794
+ if (result.exitCode === 0 && result.stdout.trim() === "ok") {
795
+ p.log.success(color.green("Server test: PASS"));
796
+ } else {
797
+ criticalFails++;
798
+ const detail = result.stderr?.trim() ? ` (${result.stderr.trim().slice(0, 200)})` : "";
799
+ p.log.error(
800
+ color.red("Server test: FAIL") + ` — exit ${result.exitCode}${detail}`,
801
+ );
802
+ }
803
+ } catch (err: unknown) {
804
+ const message = err instanceof Error ? err.message : String(err);
805
+ if (message.includes("Cannot find module") || message.includes("MODULE_NOT_FOUND")) {
806
+ p.log.warn(color.yellow("Server test: SKIP") + color.dim(" — module not available (restart session after upgrade)"));
807
+ } else {
808
+ criticalFails++;
809
+ p.log.error(color.red("Server test: FAIL") + ` — ${message}`);
810
+ }
811
+ }
812
+
813
+ // Hooks — adapter-aware validation
814
+ p.log.step(`Checking ${adapter.name} hooks configuration...`);
815
+ const pluginRoot = getPluginRoot();
816
+ const hookResults = adapter.validateHooks(pluginRoot);
817
+
818
+ for (const result of hookResults) {
819
+ if (result.status === "pass") {
820
+ p.log.success(color.green(`${result.check}: PASS`) + ` — ${result.message}`);
821
+ } else if (result.status === "warn") {
822
+ p.log.warn(
823
+ color.yellow(`${result.check}: WARN`) +
824
+ ` — ${result.message}` +
825
+ (result.fix ? color.dim(`\n Run: ${result.fix}`) : ""),
826
+ );
827
+ } else {
828
+ p.log.error(
829
+ color.red(`${result.check}: FAIL`) +
830
+ ` — ${result.message}` +
831
+ (result.fix ? color.dim(`\n Run: ${result.fix}`) : ""),
832
+ );
833
+ }
834
+ }
835
+
836
+ // Hook scripts exist — Algo-D1 protocol path takes precedence.
837
+ // Adapters that override `getHealthChecks` (claude-code today) get a
838
+ // direct `existsSync(join(pluginRoot, "hooks", scriptName))` per
839
+ // HOOK_SCRIPTS entry — no regex round-trip on a hook command, so the
840
+ // #548 doubled-path FAIL class can't surface. Adapters that don't
841
+ // override fall through to the legacy `getHookScriptPaths` flow which
842
+ // generates the hook config and parses each command via
843
+ // `extractHookScriptPath`. Post-D3 every adapter emits buildNodeCommand-
844
+ // shape, so the legacy flow is also safe — but the direct existsSync
845
+ // path is strictly preferable when the adapter offers it.
846
+ p.log.step("Checking hook scripts...");
847
+ const adapterHealthChecks = adapter.getHealthChecks?.(pluginRoot) ?? [];
848
+ if (adapterHealthChecks.length > 0) {
849
+ for (const hc of adapterHealthChecks) {
850
+ const result = hc.check();
851
+ if (result.status === "OK") {
852
+ p.log.success(
853
+ color.green(`${hc.name}: PASS`) +
854
+ (result.detail ? color.dim(` — ${result.detail}`) : ""),
855
+ );
856
+ } else {
857
+ p.log.error(
858
+ color.red(`${hc.name}: FAIL`) +
859
+ (result.detail ? color.dim(` — ${result.detail}`) : ""),
860
+ );
861
+ }
862
+ }
863
+ } else {
864
+ const hookScriptPaths = getHookScriptPaths(adapter, pluginRoot);
865
+ if (hookScriptPaths.length === 0) {
866
+ p.log.success(color.green("Hook scripts: PASS") + color.dim(" — no direct .mjs script paths to verify"));
867
+ } else {
868
+ for (const scriptPath of hookScriptPaths) {
869
+ const absolutePath = resolve(pluginRoot, scriptPath);
870
+ try {
871
+ accessSync(absolutePath, constants.R_OK);
872
+ p.log.success(color.green("Hook script exists: PASS") + color.dim(` — ${absolutePath}`));
873
+ } catch {
874
+ p.log.error(
875
+ color.red("Hook script exists: FAIL") +
876
+ color.dim(` — not found at ${absolutePath}`),
877
+ );
878
+ }
879
+ }
880
+ }
881
+ }
882
+
883
+ // Plugin registration — adapter-aware
884
+ p.log.step(`Checking ${adapter.name} plugin registration...`);
885
+ const pluginCheck = adapter.checkPluginRegistration();
886
+ if (pluginCheck.status === "pass") {
887
+ p.log.success(color.green("Plugin enabled: PASS") + color.dim(` — ${pluginCheck.message}`));
888
+ } else {
889
+ p.log.warn(
890
+ color.yellow("Plugin enabled: WARN") +
891
+ ` — ${pluginCheck.message}`,
892
+ );
893
+ }
894
+
895
+ // ── Issue #613 — proactive Tier C absolute-path detection ───────────
896
+ // PR #620 fixed `buildHookCommand` for vscode-copilot + jetbrains-copilot
897
+ // so future writes are CLI-dispatcher-shape. But users who ran
898
+ // /ctx-upgrade on v1.0.136 or earlier are still carrying poisoned
899
+ // committable files in their workspace:
900
+ // - `.github/hooks/context-mode.json` (vscode-copilot, team-shared)
901
+ // - `.jetbrains/copilot/hooks.json` (jetbrains-copilot, team-shared)
902
+ // - `.cursor/hooks.json` (cursor, team-shared)
903
+ // Per ISSUE-613-VERDICT §6.1 these are Tier C — workspace-committed
904
+ // cross-machine config. Doctor scans them for absolute paths and
905
+ // fnm_multishells shims; if found, FAIL with `ctx_upgrade` remediation.
906
+ // Per ISSUE-604-VERDICT §11 ("silent-green doctor while hooks are dead
907
+ // is itself a P0 trust bug") — surface poison BEFORE the user hits a
908
+ // runtime failure.
909
+ p.log.step("Checking team-shared hook configs in your workspace...");
910
+ {
911
+ const projectDir = process.cwd();
912
+ const tierCFiles = [
913
+ ".github/hooks/context-mode.json",
914
+ ".cursor/hooks.json",
915
+ ".jetbrains/copilot/hooks.json",
916
+ ];
917
+ let tierCFails = 0;
918
+ let tierCChecked = 0;
919
+
920
+ // Detect absolute-path patterns that should never appear in a
921
+ // workspace-committed config. Per Mert's standing Windows-safety rule:
922
+ // handle both `/` and `\\` separators.
923
+ function isAbsoluteOrShimPath(s: string): boolean {
924
+ // unix absolute
925
+ if (s.startsWith("/")) return true;
926
+ // Windows drive-letter absolute (e.g. C:/, C:\)
927
+ if (/^[A-Za-z]:[/\\]/.test(s)) return true;
928
+ // Windows UNC or escaped-backslash absolute fragments
929
+ if (s.includes("\\\\")) return true;
930
+ // fnm shim hint — issue #613 reporter's exact stderr shape
931
+ if (s.includes("fnm_multishells")) return true;
932
+ // process.execPath literal baked into JSON
933
+ if (s.includes("process.execPath")) return true;
934
+ return false;
935
+ }
936
+
937
+ function recurseStrings(node: unknown, hit: (s: string) => void): void {
938
+ if (typeof node === "string") {
939
+ hit(node);
940
+ } else if (Array.isArray(node)) {
941
+ for (const item of node) recurseStrings(item, hit);
942
+ } else if (node && typeof node === "object") {
943
+ for (const v of Object.values(node)) recurseStrings(v, hit);
944
+ }
945
+ }
946
+
947
+ for (const rel of tierCFiles) {
948
+ const abs = resolve(projectDir, rel);
949
+ if (!existsSync(abs)) continue; // missing config → SKIP, no false fail
950
+ tierCChecked++;
951
+ try {
952
+ const parsed = JSON.parse(readFileSync(abs, "utf-8"));
953
+ const offenders: string[] = [];
954
+ recurseStrings(parsed, (s) => {
955
+ if (isAbsoluteOrShimPath(s)) offenders.push(s);
956
+ });
957
+ if (offenders.length > 0) {
958
+ criticalFails++;
959
+ tierCFails++;
960
+ // Truncate to one example to keep output readable; show count.
961
+ const example = offenders[0].length > 100
962
+ ? offenders[0].slice(0, 97) + "..."
963
+ : offenders[0];
964
+ p.log.error(
965
+ color.red(`Hook config: FAIL`) +
966
+ ` — ${rel} has your machine's local paths baked in` +
967
+ color.dim(
968
+ "\n This file is committed to git, so teammates and CI will get your path and the hooks will break for them." +
969
+ `\n Found ${offenders.length} hard-coded path(s), e.g.: ${example}` +
970
+ "\n Fix: run /context-mode:ctx-upgrade — it rewrites the file to a portable form that works on every machine." +
971
+ "\n Details: https://github.com/mksglu/context-mode/issues/613",
972
+ ),
973
+ );
974
+ } else {
975
+ p.log.success(
976
+ color.green("Hook config: PASS") +
977
+ color.dim(` — ${rel} is portable (no hard-coded paths)`),
978
+ );
979
+ }
980
+ } catch (err: unknown) {
981
+ // Malformed JSON should not crash doctor; warn and move on.
982
+ const msg = err instanceof Error ? err.message : String(err);
983
+ p.log.warn(
984
+ color.yellow(`Hook config: WARN`) +
985
+ ` — ${rel} is not valid JSON` +
986
+ color.dim(
987
+ "\n Doctor cannot scan it for portability issues until the file parses." +
988
+ "\n Fix: open the file and check it in a JSON validator, or delete it and run /context-mode:ctx-upgrade to regenerate." +
989
+ `\n Parser said: ${msg.slice(0, 160)}`,
990
+ ),
991
+ );
992
+ }
993
+ }
994
+ if (tierCChecked === 0) {
995
+ p.log.info(
996
+ color.dim("Hook config: SKIP — no team-shared hook configs found in this workspace"),
997
+ );
998
+ } else if (tierCFails === 0) {
999
+ // already individual PASS messages above; no need for a summary
1000
+ }
1001
+ }
1002
+
1003
+ // ── Issue #609 — proactive stale `.mcp.json` detection ──────────────
1004
+ // PR #620 deleted the per-version cache `.mcp.json` write from cli.ts
1005
+ // and shipped `sweepStaleMcpJson` to clean up any pre-existing copies.
1006
+ // But users on the field may still have stale `.mcp.json` files left
1007
+ // by /ctx-upgrade flows that ran before PR #620 (or by Claude Code's
1008
+ // native auto-update copying a poisoned file forward). Surface those
1009
+ // as WARN (recoverable — next ctx_upgrade sweeps them) so the user
1010
+ // knows what to do instead of being told everything is green while
1011
+ // the file lingers on disk.
1012
+ // Per ISSUE-604-VERDICT §11 same trust contract as Tier C check above.
1013
+ p.log.step("Checking for leftover .mcp.json files from older versions...");
1014
+ {
1015
+ const cacheRoot = join(
1016
+ homedir(),
1017
+ ".claude",
1018
+ "plugins",
1019
+ "cache",
1020
+ "context-mode",
1021
+ "context-mode",
1022
+ );
1023
+ if (!existsSync(cacheRoot)) {
1024
+ p.log.info(
1025
+ color.dim("Leftover .mcp.json check: SKIP — no plugin cache exists yet (Claude Code has not installed context-mode here)"),
1026
+ );
1027
+ } else {
1028
+ let staleCount = 0;
1029
+ const staleVersions: string[] = [];
1030
+ try {
1031
+ const versionDirs = readdirSync(cacheRoot);
1032
+ for (const v of versionDirs) {
1033
+ const candidate = join(cacheRoot, v, ".mcp.json");
1034
+ if (existsSync(candidate)) {
1035
+ staleCount++;
1036
+ if (staleVersions.length < 5) staleVersions.push(v);
1037
+ }
1038
+ }
1039
+ } catch (err: unknown) {
1040
+ const msg = err instanceof Error ? err.message : String(err);
1041
+ p.log.warn(
1042
+ color.yellow("Leftover .mcp.json check: WARN") +
1043
+ ` — could not read the plugin cache directory` +
1044
+ color.dim(
1045
+ `\n Path: ${cacheRoot}` +
1046
+ `\n Reason: ${msg.slice(0, 160)}` +
1047
+ "\n Fix: check that the directory is readable, then re-run doctor. If the issue persists, run /context-mode:ctx-upgrade.",
1048
+ ),
1049
+ );
1050
+ staleCount = 0;
1051
+ }
1052
+ if (staleCount === 0) {
1053
+ p.log.success(
1054
+ color.green("Leftover .mcp.json check: PASS") +
1055
+ color.dim(" — no old .mcp.json files in the plugin cache"),
1056
+ );
1057
+ } else {
1058
+ // WARN, not FAIL — per architect spec this is recoverable.
1059
+ p.log.warn(
1060
+ color.yellow("Leftover .mcp.json check: WARN") +
1061
+ ` — found ${staleCount} old .mcp.json file(s) left over from previous context-mode versions` +
1062
+ color.dim(
1063
+ "\n These are harmless but should be cleaned up so they cannot confuse Claude Code after an auto-update." +
1064
+ `\n Versions affected: ${staleVersions.join(", ")}${staleCount > staleVersions.length ? ", ..." : ""}` +
1065
+ "\n Fix: run /context-mode:ctx-upgrade — it sweeps these files automatically on the next run." +
1066
+ "\n Details: https://github.com/mksglu/context-mode/issues/609",
1067
+ ),
1068
+ );
1069
+ }
1070
+ }
1071
+ }
1072
+
1073
+ // FTS5 / SQLite
1074
+ p.log.step("Checking FTS5 / SQLite...");
1075
+ try {
1076
+ const Database = (await import("./db-base.js")).loadDatabase();
1077
+ const db = new Database(":memory:");
1078
+ db.exec("CREATE VIRTUAL TABLE fts_test USING fts5(content)");
1079
+ db.exec("INSERT INTO fts_test(content) VALUES ('hello world')");
1080
+ const row = db.prepare("SELECT * FROM fts_test WHERE fts_test MATCH 'hello'").get() as { content: string } | undefined;
1081
+ db.close();
1082
+ if (row && row.content === "hello world") {
1083
+ p.log.success(color.green("FTS5 / SQLite: PASS") + " — native module works");
1084
+ } else {
1085
+ criticalFails++;
1086
+ p.log.error(color.red("FTS5 / SQLite: FAIL") + " — query returned unexpected result");
1087
+ }
1088
+ } catch (err: unknown) {
1089
+ const message = err instanceof Error ? err.message : String(err);
1090
+ // Distinguish package-missing from binding-missing (#514). Both
1091
+ // throw with similar shapes from `import("better-sqlite3")` but the
1092
+ // recovery commands differ:
1093
+ // - package-missing → `npm install better-sqlite3 --no-optional`
1094
+ // (npm@7+ silently drops optionalDependencies on engine
1095
+ // mismatch, e.g. Node 26 vs better-sqlite3@12.x — we name the
1096
+ // package explicitly + flip the optional filter to recover)
1097
+ // - binding-missing → `npm rebuild better-sqlite3` (#408 flow,
1098
+ // Windows + missing prebuild-install shim)
1099
+ const pluginRootForDoctor = getPluginRoot();
1100
+ const bsqPackageDir = resolve(pluginRootForDoctor, "node_modules", "better-sqlite3");
1101
+ const packageMissing = !existsSync(bsqPackageDir);
1102
+
1103
+ if (packageMissing) {
1104
+ criticalFails++;
1105
+ p.log.error(
1106
+ color.red("FTS5 / better-sqlite3: FAIL") +
1107
+ color.dim(" — package-missing") +
1108
+ color.dim(
1109
+ `\n Path: ${bsqPackageDir}` +
1110
+ "\n Root cause: npm silently skipped better-sqlite3 because the package's `engines` field excluded the running Node (issue #514, e.g. Node 26 vs better-sqlite3@12.x)." +
1111
+ `\n Try (primary): cd "${pluginRootForDoctor}" && npm install better-sqlite3 --no-optional` +
1112
+ "\n Try (fallback): /context-mode:ctx-upgrade",
1113
+ ),
1114
+ );
1115
+ } else if (message.includes("Cannot find module") || message.includes("MODULE_NOT_FOUND")) {
1116
+ p.log.warn(color.yellow("FTS5 / better-sqlite3: SKIP") + color.dim(" — module not available (restart session after upgrade)"));
1117
+ } else {
1118
+ criticalFails++;
1119
+ // Detect better-sqlite3 native bindings-missing pattern (issue #408).
1120
+ // The `bindings` package throws "Could not locate the bindings file"
1121
+ // when better_sqlite3.node failed to install — typical on Windows
1122
+ // when prebuild-install was not on PATH so install fell through to
1123
+ // node-gyp without an MSVC toolchain.
1124
+ const isBindingsMissing =
1125
+ /Could not locate the bindings file/i.test(message) ||
1126
+ /bindings\.node/i.test(message) ||
1127
+ /\bbindings\b/i.test(message);
1128
+ if (isBindingsMissing && process.platform === "win32") {
1129
+ p.log.error(
1130
+ color.red("FTS5 / better-sqlite3: FAIL") +
1131
+ ` — ${message}` +
1132
+ color.dim(
1133
+ "\n Root cause: prebuild-install was likely not on PATH, so install fell through to node-gyp without an MSVC toolchain (Windows)." +
1134
+ "\n Try (primary): npm install better-sqlite3 # re-resolves the dep tree and re-links the prebuild-install bin shim to fetch a prebuilt binary" +
1135
+ "\n Try (fallback): npm rebuild better-sqlite3",
1136
+ ),
1137
+ );
1138
+ } else {
1139
+ p.log.error(
1140
+ color.red("FTS5 / better-sqlite3: FAIL") +
1141
+ ` — ${message}` +
1142
+ color.dim("\n Try: npm rebuild better-sqlite3"),
1143
+ );
1144
+ }
1145
+ }
1146
+ }
1147
+
1148
+ // Version check — adapter-aware
1149
+ p.log.step("Checking versions...");
1150
+ const localVersion = getLocalVersion();
1151
+ const latestVersion = await fetchLatestVersion();
1152
+ const installedVersion = adapter.getInstalledVersion();
1153
+
1154
+ if (latestVersion === "unknown") {
1155
+ p.log.warn(
1156
+ color.yellow("npm (MCP): WARN") +
1157
+ ` — local v${localVersion}, could not reach npm registry`,
1158
+ );
1159
+ } else if (localVersion === latestVersion) {
1160
+ p.log.success(
1161
+ color.green("npm (MCP): PASS") +
1162
+ ` — v${localVersion}`,
1163
+ );
1164
+ } else {
1165
+ p.log.warn(
1166
+ color.yellow("npm (MCP): WARN") +
1167
+ ` — local v${localVersion}, latest v${latestVersion}` +
1168
+ color.dim("\n Run: /context-mode:ctx-upgrade"),
1169
+ );
1170
+ }
1171
+
1172
+ if (installedVersion === "standalone") {
1173
+ p.log.info(
1174
+ color.dim(`${adapter.name}: standalone MCP mode`) +
1175
+ " — no platform plugin version to compare",
1176
+ );
1177
+ } else if (installedVersion === "not installed") {
1178
+ p.log.info(
1179
+ color.dim(`${adapter.name}: not installed`) +
1180
+ " — using standalone MCP mode",
1181
+ );
1182
+ } else if (latestVersion !== "unknown" && installedVersion === latestVersion) {
1183
+ p.log.success(
1184
+ color.green(`${adapter.name}: PASS`) +
1185
+ ` — v${installedVersion}`,
1186
+ );
1187
+ } else if (latestVersion !== "unknown") {
1188
+ p.log.warn(
1189
+ color.yellow(`${adapter.name}: WARN`) +
1190
+ ` — v${installedVersion}, latest v${latestVersion}` +
1191
+ color.dim("\n Run: /context-mode:ctx-upgrade"),
1192
+ );
1193
+ } else {
1194
+ p.log.info(
1195
+ `${adapter.name}: v${installedVersion}` +
1196
+ color.dim(" — could not verify against npm registry"),
1197
+ );
1198
+ }
1199
+
1200
+ // Summary
1201
+ if (criticalFails > 0) {
1202
+ p.outro(
1203
+ color.red(`Diagnostics failed — ${criticalFails} critical issue(s) found`),
1204
+ );
1205
+ return 1;
1206
+ }
1207
+
1208
+ p.outro(
1209
+ available.length >= 4
1210
+ ? color.green("Diagnostics complete!")
1211
+ : color.yellow("Some checks need attention — see above for details"),
1212
+ );
1213
+ return 0;
1214
+ }
1215
+
1216
+ /* -------------------------------------------------------
1217
+ * Insight — hosted analytics dashboard
1218
+ * ------------------------------------------------------- */
1219
+
1220
+ // Insight pivoted from a locally-built dashboard to the hosted product at
1221
+ // context-mode.com/insight (the landing page is the single source of truth).
1222
+ // The command now just opens that URL in the default browser.
1223
+ async function insight() {
1224
+ const url = "https://context-mode.com/insight";
1225
+ console.log(`\n context-mode Insight\n ${url}\n`);
1226
+ // Open browser — execFile with arg array, no shell interpolation.
1227
+ openInBrowser(url);
1228
+ }
1229
+
1230
+ /* -------------------------------------------------------
1231
+ * Upgrade — adapter-aware hook configuration
1232
+ * ------------------------------------------------------- */
1233
+
1234
+ async function upgrade(opts?: { platform?: string }) {
1235
+ if (process.stdout.isTTY) console.clear();
1236
+
1237
+ // Issue #542 — when the MCP ctx_upgrade handler threads through an
1238
+ // explicit --platform <id> (resolved from live clientInfo), trust it
1239
+ // over the local heuristic chain. detectPlatform() with no args cannot
1240
+ // see the MCP handshake and falls through to the config-dir tier,
1241
+ // which misdetects Pi/OMP installs as Cursor on systems where both
1242
+ // ~/.cursor/ and ~/.pi/ exist.
1243
+ const detection = opts?.platform
1244
+ ? { platform: opts.platform as Parameters<typeof getAdapter>[0], confidence: "high" as const, reason: `--platform ${opts.platform} from ctx_upgrade handler` }
1245
+ : detectPlatform();
1246
+ const adapter = await getAdapter(detection.platform);
1247
+
1248
+ p.intro(color.bgCyan(color.black(" context-mode upgrade ")));
1249
+ p.log.info(
1250
+ `Platform: ${color.cyan(adapter.name)}` +
1251
+ color.dim(` (${detection.confidence} confidence)`),
1252
+ );
1253
+
1254
+ let pluginRoot = getPluginRoot();
1255
+ const changes: string[] = [];
1256
+ const s = p.spinner();
1257
+
1258
+ // Step 0: Sync the marketplace clone (#418).
1259
+ // Claude Code reads plugin metadata from ~/.claude/plugins/marketplaces/context-mode/.
1260
+ // Without a git pull there, the marketplace stays pinned at the install-time
1261
+ // commit and CC keeps reporting the old version even after our cache dir is
1262
+ // updated — users then see "ctx-upgrade succeeded" but nothing actually
1263
+ // changed at the plugin-system level.
1264
+ // Issue #460 round-3: route through resolveClaudeConfigDir so users who
1265
+ // relocate their CC config root keep the marketplace clone in the same tree.
1266
+ const marketplaceDir = resolve(resolveClaudeConfigDir(), "plugins", "marketplaces", "context-mode");
1267
+ if (existsSync(join(marketplaceDir, ".git"))) {
1268
+ s.start("Syncing marketplace clone");
1269
+ try {
1270
+ // Preserve user dev edits (Mert-class users symlink the clone to a worktree).
1271
+ const statusOut = execFileSync(
1272
+ "git", ["-C", marketplaceDir, "status", "--porcelain"],
1273
+ { stdio: "pipe", encoding: "utf-8", timeout: 5000 },
1274
+ );
1275
+ if (statusOut.trim()) {
1276
+ s.stop(color.yellow("Marketplace clone has local edits — skipping git pull"));
1277
+ p.log.info(
1278
+ color.dim(` Run manually: git -C "${marketplaceDir}" stash && git pull --ff-only`),
1279
+ );
1280
+ } else {
1281
+ execFileSync(
1282
+ "git", ["-C", marketplaceDir, "fetch", "--tags", "origin"],
1283
+ { stdio: "pipe", timeout: 30000 },
1284
+ );
1285
+ execFileSync(
1286
+ "git", ["-C", marketplaceDir, "reset", "--hard", "origin/HEAD"],
1287
+ { stdio: "pipe", timeout: 10000 },
1288
+ );
1289
+ s.stop(color.green("Marketplace clone synced"));
1290
+ changes.push("Marketplace clone updated to upstream");
1291
+ }
1292
+ } catch (err: unknown) {
1293
+ const message = err instanceof Error ? err.message : String(err);
1294
+ s.stop(color.yellow("Marketplace sync skipped"));
1295
+ p.log.warn(color.yellow("git refresh on marketplace failed") + ` — ${message}`);
1296
+ p.log.info(color.dim(" Continuing — cache dir update will still happen."));
1297
+ }
1298
+ }
1299
+
1300
+ // Step 1: Pull latest from GitHub
1301
+ p.log.step("Pulling latest from GitHub...");
1302
+ const localVersion = getLocalVersion();
1303
+ const tmpDir = join(tmpdir(), `context-mode-upgrade-${Date.now()}`);
1304
+
1305
+ s.start("Cloning mksglu/context-mode");
1306
+ try {
1307
+ execFileSync(
1308
+ "git", ["clone", "--depth", "1", "https://github.com/mksglu/context-mode.git", tmpDir],
1309
+ { stdio: "pipe", timeout: 30000 },
1310
+ );
1311
+ s.stop("Downloaded");
1312
+
1313
+ const srcDir = tmpDir;
1314
+ const newPkg = JSON.parse(
1315
+ readFileSync(resolve(srcDir, "package.json"), "utf-8"),
1316
+ );
1317
+ const newVersion = newPkg.version ?? "unknown";
1318
+
1319
+ if (newVersion === localVersion) {
1320
+ p.log.success(color.green("Already on latest") + ` — v${localVersion}`);
1321
+ rmSync(tmpDir, { recursive: true, force: true });
1322
+ } else {
1323
+ p.log.info(
1324
+ `Update available: ${color.yellow("v" + localVersion)} → ${color.green("v" + newVersion)}`,
1325
+ );
1326
+
1327
+ // v1.0.128 — Issue #559: terminate sibling MCP servers BEFORE installing
1328
+ // new files. Historically /ctx-upgrade rsynced new code over the old
1329
+ // tree but never signalled the running MCP server, so the previous
1330
+ // version stayed alive holding stdio + DB handles. Across enough
1331
+ // upgrades users observed 5+ context-mode start.mjs processes pinned
1332
+ // to RAM. Discovery + kill must happen before npm install to avoid
1333
+ // racing against the EXCLUSIVE lock the new server claims on first
1334
+ // ctx_search (see #560 fix). Wrapped in try/catch so a missing pgrep
1335
+ // (stripped Linux distro) or unavailable PowerShell (weird Windows)
1336
+ // can never block the upgrade itself.
1337
+ try {
1338
+ const siblingPids = discoverSiblingMcpPids({
1339
+ ownPid: process.pid,
1340
+ ownPpid: process.ppid,
1341
+ });
1342
+ if (siblingPids.length > 0) {
1343
+ const killReport = await killSiblingMcpServers({ pids: siblingPids });
1344
+ if (killReport.totalKilled > 0) {
1345
+ // Concise summary only — no PIDs in the user-facing log to keep
1346
+ // the line readable. Plural-aware so "1 sibling MCP server" reads
1347
+ // naturally alongside "3 sibling MCP servers".
1348
+ const noun = killReport.totalKilled === 1
1349
+ ? "sibling MCP server"
1350
+ : "sibling MCP servers";
1351
+ p.log.info(
1352
+ color.dim(
1353
+ `Stopped ${killReport.totalKilled} ${noun} (SIGTERM: ${killReport.terminatedBySigterm}, SIGKILL: ${killReport.terminatedBySigkill})`,
1354
+ ),
1355
+ );
1356
+ }
1357
+ }
1358
+ } catch { /* never block upgrade on discovery/kill failure */ }
1359
+
1360
+ // Step 2: Install dependencies + build
1361
+ s.start("Installing dependencies & building");
1362
+ const vsYear = detectWindowsVsYear();
1363
+ npmExecFile(["install", "--no-audit", "--no-fund"], {
1364
+ cwd: srcDir,
1365
+ stdio: "pipe",
1366
+ timeout: 120000,
1367
+ ...(vsYear ? { env: { ...process.env, npm_config_msvs_version: vsYear } } : {}),
1368
+ });
1369
+ npmExecFile(["run", "build"], {
1370
+ cwd: srcDir,
1371
+ stdio: "pipe",
1372
+ timeout: 60000,
1373
+ });
1374
+ s.stop("Built successfully");
1375
+
1376
+ // Step 3: Update in-place
1377
+ s.start("Updating files in-place");
1378
+
1379
+ // Old version dirs are cleaned lazily by sessionstart.mjs (age-gated >1h)
1380
+ // to avoid breaking active sessions that still reference them (#181).
1381
+
1382
+ // Read files list from cloned repo's package.json so new directories
1383
+ // (like insight/) are automatically included without chicken-and-egg issues
1384
+ // where the old CLI doesn't know about new directories.
1385
+ const clonedPkg = JSON.parse(readFileSync(resolve(srcDir, "package.json"), "utf-8"));
1386
+ const items = [
1387
+ ...(clonedPkg.files || []),
1388
+ "src", "package.json",
1389
+ ];
1390
+ // Supply-chain containment on items[]. A compromised upstream tag
1391
+ // shipping files: ["../../.ssh/authorized_keys"] or an absolute
1392
+ // path would, without a guard, hand rmSync+cpSync an arbitrary
1393
+ // destination under the user's UID. resolve(P, "/abs") discards P,
1394
+ // so the absolute-path variant escapes too. Reject items whose
1395
+ // resolved path escapes either srcDir or pluginRoot. Mirrors the
1396
+ // pattern hooks/heal-partial-install.mjs already uses for its own
1397
+ // files[] expansion (PR #699).
1398
+ //
1399
+ // Also refuse to copy any symlink encountered anywhere under a
1400
+ // source item. cpSync's default is to preserve source symlinks as
1401
+ // destination symlinks; a compromised upstream tag committing a
1402
+ // symlink to /etc inside src/ would plant that link in pluginRoot,
1403
+ // and the next Claude Code session that loads pluginRoot/src/*
1404
+ // would dereference through to the attacker target. Filtering at
1405
+ // copy time keeps pluginRoot symlink-free regardless of what the
1406
+ // clone shipped.
1407
+ const pluginRootWithSep = resolve(pluginRoot) + sep;
1408
+ const srcDirWithSep = resolve(srcDir) + sep;
1409
+ const refuseSymlinks = (src: string): boolean => {
1410
+ try { return !lstatSync(src).isSymbolicLink(); } catch { return false; }
1411
+ };
1412
+ for (const item of items) {
1413
+ const from = resolve(srcDir, item);
1414
+ const to = resolve(pluginRoot, item);
1415
+ if (!(to + sep).startsWith(pluginRootWithSep)) continue;
1416
+ if (!(from + sep).startsWith(srcDirWithSep)) continue;
1417
+ if (!refuseSymlinks(from)) continue;
1418
+ // Existence-check the source BEFORE the rm so a `files[]` entry that
1419
+ // doesn't exist in srcDir can never delete-without-replace at
1420
+ // pluginRoot. The catch-all below swallows cpSync failures too, and
1421
+ // a swallowed cp after a successful rm is exactly how a partial
1422
+ // install lands silently. Mirrors the safe pattern in
1423
+ // server.ts's inline-fallback upgrade path (PR #699).
1424
+ if (!existsSync(from)) continue;
1425
+ try {
1426
+ rmSync(to, { recursive: true, force: true });
1427
+ cpSync(from, to, { recursive: true, filter: refuseSymlinks });
1428
+ } catch { /* best effort, next /ctx-upgrade retries */ }
1429
+ }
1430
+
1431
+ // Issue #609 — DO NOT write `.mcp.json` into the plugin cache dir.
1432
+ //
1433
+ // Historical context: #411 fixed an absolute-path bake by writing the
1434
+ // ${CLAUDE_PLUGIN_ROOT} placeholder form here. #531 (commit 9261377)
1435
+ // removed `.mcp.json` from `package.json files[]` so the npm tarball
1436
+ // stopped shipping it. But the cli-side write persisted, so every
1437
+ // /ctx-upgrade re-baked one. When Claude Code's native plugin manager
1438
+ // auto-update later carries a previous version's `.mcp.json` forward
1439
+ // into a fresh version dir, the stale start.mjs absolute path goes
1440
+ // with it → MODULE_NOT_FOUND on every MCP boot.
1441
+ //
1442
+ // Architectural fix: Claude Code reads `.claude-plugin/plugin.json`
1443
+ // .mcpServers as the canonical source (upstream:
1444
+ // refs/platforms/claude-code/src/utils/plugins/mcpPluginIntegration.ts:131-212).
1445
+ // `.mcp.json` is a redundant per-version artifact whose only role
1446
+ // historically was to be a write-time poison vector. Don't write it.
1447
+ // The post-bump cache-sweep below removes any pre-existing copies so
1448
+ // the previous-version-carry vector cannot replay.
1449
+
1450
+ // Issue #711 + #414 split: normalize hooks.json (only) here.
1451
+ //
1452
+ // - plugin.json must NOT be normalized during /ctx-upgrade — Claude
1453
+ // Code carries it forward into new versioned cache dirs on
1454
+ // auto-update, so baked absolute paths go stale (#711).
1455
+ // - hooks/hooks.json MUST be normalized during /ctx-upgrade on
1456
+ // Windows + Git Bash — Claude Code fires SessionStart / PreToolUse
1457
+ // BEFORE the MCP server boots, so the unresolved
1458
+ // `${CLAUDE_PLUGIN_ROOT}` placeholder yields MODULE_NOT_FOUND for
1459
+ // the first hook fire after upgrade (#414, originally wired in
1460
+ // 13d1342 / #528).
1461
+ //
1462
+ // The narrow `normalizeHooksJsonOnly` helper preserves both invariants.
1463
+ // start.mjs continues to call the full `normalizeHooksOnStartup` at the
1464
+ // next MCP boot to re-heal plugin.json against the live __dirname.
1465
+ try {
1466
+ // #738: pass the resolved Bun ≥1.0 path so /ctx-upgrade's hooks.json
1467
+ // rewrite gains the same cold-start win as the boot-time rewrite.
1468
+ // Probe failures fall through to nodePath default.
1469
+ let jsRuntimePath: string | undefined;
1470
+ try {
1471
+ const { resolveHookRuntime } = await import("./runtime.js");
1472
+ const r = resolveHookRuntime();
1473
+ if (r.isBun) jsRuntimePath = r.path;
1474
+ } catch { /* best effort */ }
1475
+ const mod: { normalizeHooksJsonOnly: (opts: { pluginRoot: string; nodePath: string; jsRuntimePath?: string; platform: string }) => void } =
1476
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1477
+ (await import("../hooks/normalize-hooks.mjs" as any)) as any;
1478
+ mod.normalizeHooksJsonOnly({
1479
+ pluginRoot,
1480
+ nodePath: process.execPath,
1481
+ jsRuntimePath,
1482
+ platform: process.platform,
1483
+ });
1484
+ } catch { /* best effort — never block upgrade */ }
1485
+
1486
+ // Issue #710 — Layer 1: rewrite stale shell-snapshot PATH entries.
1487
+ //
1488
+ // Claude Code's per-session shell snapshot
1489
+ // (~/.claude/shell-snapshots/snapshot-*.sh, baked at session boot —
1490
+ // refs/platforms/claude-code/src/utils/bash/ShellSnapshot.ts:269-336)
1491
+ // is `source`d before every Bash tool call. It contains an
1492
+ // `export PATH='…'` line including the context-mode `bin/` for the
1493
+ // version active at session start. /ctx-upgrade deletes the old
1494
+ // cache dir mid-session — the snapshot still points at it, so every
1495
+ // Bash call fails with "Plugin directory does not exist" until the
1496
+ // session restarts. Layer 1 fixes the active session immediately;
1497
+ // Layer 2 (sessionstart.mjs) heals any session that started before
1498
+ // /ctx-upgrade ran.
1499
+ //
1500
+ // claude-code only — no other adapter uses shell-snapshots. Skip
1501
+ // when running under a non-claude-code adapter (Codex/Cursor/Gemini
1502
+ // etc. spawn Bash differently and have no `~/.claude/shell-snapshots`
1503
+ // tree). Best-effort, idempotent, never throws.
1504
+ try {
1505
+ if (detection.platform === "claude-code") {
1506
+ const { rewriteShellSnapshots } = await import(
1507
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1508
+ "../hooks/cache-heal-utils.mjs" as any
1509
+ ) as { rewriteShellSnapshots: (opts: { snapshotsDir: string; currentVersion: string }) => { rewritten: string[] } };
1510
+ const snapshotsDir = resolve(resolveClaudeConfigDir(), "shell-snapshots");
1511
+ const result = rewriteShellSnapshots({
1512
+ snapshotsDir,
1513
+ currentVersion: newVersion,
1514
+ });
1515
+ if (result.rewritten.length > 0) {
1516
+ p.log.info(color.dim(` Healed ${result.rewritten.length} stale shell snapshot(s) — Bash tool calls in the active session will pick up v${newVersion} immediately`));
1517
+ }
1518
+ }
1519
+ } catch { /* best effort — never block upgrade */ }
1520
+
1521
+ s.stop(color.green(`Updated in-place to v${newVersion}`));
1522
+
1523
+ // v1.0.114 hotfix — pre-flight: verify the in-place copy actually
1524
+ // wrote a plugin.json carrying newVersion BEFORE we tell the
1525
+ // registry that's the install path. If the manifest still reports
1526
+ // the old version (rsync race, partial write, files-array drift),
1527
+ // updating the registry would create the silent v1.0.113-class
1528
+ // drift Mert hit. Bail out — the next /ctx-upgrade gets to retry.
1529
+ const pluginManifest = resolve(pluginRoot, ".claude-plugin", "plugin.json");
1530
+ let onDiskVersion: string | null = null;
1531
+ try {
1532
+ const pj = JSON.parse(readFileSync(pluginManifest, "utf-8"));
1533
+ if (pj && typeof pj.version === "string") onDiskVersion = pj.version;
1534
+ } catch { /* parse error → onDiskVersion stays null */ }
1535
+ if (onDiskVersion !== newVersion) {
1536
+ throw new Error(
1537
+ `pluginRoot manifest version mismatch — disk says "${onDiskVersion ?? "<missing>"}" but newVersion is "${newVersion}". Refusing to bump registry.`,
1538
+ );
1539
+ }
1540
+
1541
+ // Fix registry — adapter-aware
1542
+ adapter.updatePluginRegistry(pluginRoot, newVersion);
1543
+ p.log.info(color.dim(" Registry synced to " + pluginRoot));
1544
+
1545
+ // v1.0.114 hotfix — post-write assertion: re-read installed_plugins.json
1546
+ // and verify installPath/.claude-plugin/plugin.json's version matches
1547
+ // the registry entry. Throws on mismatch — fails loudly so a future
1548
+ // adapter regression surfaces here, not weeks later in user reports.
1549
+ try {
1550
+ const ipPath = resolve(resolveClaudeConfigDir(), "plugins", "installed_plugins.json");
1551
+ if (existsSync(ipPath)) {
1552
+ const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
1553
+ const entries = ip?.plugins?.["context-mode@context-mode"];
1554
+ if (Array.isArray(entries)) {
1555
+ for (const entry of entries) {
1556
+ const ip2 = entry?.installPath;
1557
+ if (typeof ip2 !== "string" || !ip2) continue;
1558
+ if (!existsSync(ip2)) {
1559
+ throw new Error(`installPath does not exist on disk: ${ip2}`);
1560
+ }
1561
+ const pjPath = resolve(ip2, ".claude-plugin", "plugin.json");
1562
+ if (!existsSync(pjPath)) {
1563
+ throw new Error(`missing plugin.json manifest at ${pjPath}`);
1564
+ }
1565
+ const pj = JSON.parse(readFileSync(pjPath, "utf-8"));
1566
+ if (pj?.version !== entry.version) {
1567
+ throw new Error(
1568
+ `version mismatch — registry says "${entry.version}" but ${pjPath} says "${pj?.version}"`,
1569
+ );
1570
+ }
1571
+ }
1572
+ }
1573
+ }
1574
+ } catch (err: unknown) {
1575
+ const message = err instanceof Error ? err.message : String(err);
1576
+ throw new Error(`Registry consistency check failed: ${message}`);
1577
+ }
1578
+
1579
+ // v1.0.119 — Issue #523 — Layer 5 heal: assert .claude-plugin/plugin.json's
1580
+ // mcpServers["context-mode"].args[0] is the literal ${CLAUDE_PLUGIN_ROOT}/start.mjs
1581
+ // placeholder, not a tmpdir-prefixed absolute path. cli.ts already wrote .mcp.json
1582
+ // with the placeholder (#411 fix), but plugin.json was never touched here — and
1583
+ // start.mjs's normalize-hooks (Windows + #378) can bake in absolute paths that
1584
+ // become stale across upgrades. We call the shared heal twice: first call cleans
1585
+ // any drift; second call MUST return healed:[] or we throw. Single source of
1586
+ // truth shared with start.mjs HEAL block + postinstall.
1587
+ try {
1588
+ const pluginCacheRoot = resolve(resolveClaudeConfigDir(), "plugins", "cache");
1589
+ const pluginKey = "context-mode@context-mode";
1590
+ const firstPass = healPluginJsonMcpServers({ pluginRoot, pluginCacheRoot, pluginKey });
1591
+ if (firstPass && firstPass.error) {
1592
+ throw new Error(firstPass.error);
1593
+ }
1594
+ const secondPass = healPluginJsonMcpServers({ pluginRoot, pluginCacheRoot, pluginKey });
1595
+ if (secondPass && Array.isArray(secondPass.healed) && secondPass.healed.length > 0) {
1596
+ throw new Error(
1597
+ `Plugin manifest drift: plugin.json mcpServers.args still poisoned after first heal pass (healed=${secondPass.healed.join(",")})`,
1598
+ );
1599
+ }
1600
+ } catch (err: unknown) {
1601
+ const message = err instanceof Error ? err.message : String(err);
1602
+ throw new Error(`plugin.json drift check failed: ${message}`);
1603
+ }
1604
+
1605
+ // Issue #609 — Layer 6 replacement: sweep stale `.mcp.json` files from
1606
+ // every per-version cache dir. Supersedes the previous healMcpJsonArgs
1607
+ // drift-check block (v1.0.122) — that block existed because cli.ts
1608
+ // itself wrote `.mcp.json`. With the write gone (above), the only
1609
+ // remaining `.mcp.json` files are stale carry-forwards from earlier
1610
+ // versions. Sweep them so Claude Code's auto-update can't replay them
1611
+ // into a fresh version dir.
1612
+ //
1613
+ // Belt-and-braces: a second sweep call MUST report removed:[] or we
1614
+ // throw — same architectural-lock pattern as the plugin.json drift
1615
+ // check above. Single source of truth shared with start.mjs HEAL
1616
+ // block + postinstall.
1617
+ try {
1618
+ const pluginCacheRoot = resolve(resolveClaudeConfigDir(), "plugins", "cache");
1619
+ const pluginKey = "context-mode@context-mode";
1620
+ const firstSweep = sweepStaleMcpJson({ pluginCacheRoot, pluginKey });
1621
+ if (firstSweep && firstSweep.removed && firstSweep.removed.length > 0) {
1622
+ p.log.info(color.dim(` Swept ${firstSweep.removed.length} stale .mcp.json file(s) from cache`));
1623
+ }
1624
+ const secondSweep = sweepStaleMcpJson({ pluginCacheRoot, pluginKey });
1625
+ if (secondSweep && Array.isArray(secondSweep.removed) && secondSweep.removed.length > 0) {
1626
+ throw new Error(
1627
+ `.mcp.json sweep drift: ${secondSweep.removed.length} file(s) still present after first pass`,
1628
+ );
1629
+ }
1630
+ } catch (err: unknown) {
1631
+ const message = err instanceof Error ? err.message : String(err);
1632
+ throw new Error(`.mcp.json sweep check failed: ${message}`);
1633
+ }
1634
+
1635
+ // v1.0.X — Layer 7 heal: update user-level ~/.claude.json MCP server
1636
+ // registrations that point to old context-mode version dirs.
1637
+ // (anthropics/claude-code#59310 workaround — see heal-installed-plugins.mjs)
1638
+ try {
1639
+ // @ts-expect-error — JS module, no TS declarations
1640
+ const { healClaudeJsonMcpArgs } = await import("../scripts/heal-installed-plugins.mjs");
1641
+ const dotClaudeJson = resolve(homedir(), ".claude.json");
1642
+ const pluginCacheParent = resolve(resolveClaudeConfigDir(), "plugins", "cache", "context-mode", "context-mode");
1643
+ const result = healClaudeJsonMcpArgs({ dotClaudeJsonPath: dotClaudeJson, pluginCacheParent, newPluginRoot: pluginRoot });
1644
+ if (result.healed && result.healed.length > 0) {
1645
+ p.log.info(color.dim(" ~/.claude.json user MCP registrations updated → " + newVersion));
1646
+ }
1647
+ } catch {
1648
+ /* best effort — never block upgrade */
1649
+ }
1650
+
1651
+ // v1.0.114 hotfix — marketplace post-pull assertion: clone (if
1652
+ // present) MUST be on newVersion. Mert's case showed marketplace
1653
+ // stuck at v1.0.89 — the sync block above swallowed that silently.
1654
+ // Warn (don't throw) — npm-only users have no marketplace clone.
1655
+ try {
1656
+ const marketplaceManifest = resolve(marketplaceDir, ".claude-plugin", "plugin.json");
1657
+ if (existsSync(marketplaceManifest)) {
1658
+ const mpj = JSON.parse(readFileSync(marketplaceManifest, "utf-8"));
1659
+ if (mpj?.version !== newVersion) {
1660
+ p.log.warn(
1661
+ color.yellow("Marketplace clone version mismatch") +
1662
+ ` — ${marketplaceDir} reports "${mpj?.version}" but expected "${newVersion}"`,
1663
+ );
1664
+ p.log.info(
1665
+ color.dim(` Run manually: git -C "${marketplaceDir}" fetch --tags origin && git -C "${marketplaceDir}" reset --hard origin/HEAD`),
1666
+ );
1667
+ }
1668
+ }
1669
+ } catch { /* best effort */ }
1670
+
1671
+ // Install production deps
1672
+ s.start("Installing production dependencies");
1673
+ npmExecFile(["install", "--production", "--no-audit", "--no-fund"], {
1674
+ cwd: pluginRoot,
1675
+ stdio: "pipe",
1676
+ timeout: 60000,
1677
+ });
1678
+ s.stop("Dependencies ready");
1679
+
1680
+ if (!isInProcessPluginPlatform(detection.platform)) {
1681
+ // Verify native addons through the same bootstrap start.mjs imports.
1682
+ // On modern Node, the ABI-specific cache file is the compatibility marker;
1683
+ // the active binding alone may be stale from a previous Node ABI.
1684
+ s.start("Verifying native addon ABI");
1685
+ const bsqAbiCachePath = resolve(
1686
+ pluginRoot,
1687
+ "node_modules",
1688
+ "better-sqlite3",
1689
+ "build",
1690
+ "Release",
1691
+ `better_sqlite3.abi${process.versions.modules}.node`,
1692
+ );
1693
+ try {
1694
+ const ensureDepsPath = resolve(pluginRoot, "hooks", "ensure-deps.mjs");
1695
+ if (!existsSync(ensureDepsPath)) {
1696
+ throw new Error(`missing ${ensureDepsPath}`);
1697
+ }
1698
+ await import(`${pathToFileURL(ensureDepsPath).href}?upgrade=${Date.now()}`);
1699
+ if (existsSync(bsqAbiCachePath)) {
1700
+ s.stop(color.green("Native addons OK") + color.dim(" — ABI cache present"));
1701
+ changes.push(`better-sqlite3 ABI ${process.versions.modules} cache ready`);
1702
+ } else {
1703
+ s.stop(color.yellow("Native addon ABI cache missing"));
1704
+ p.log.warn(
1705
+ color.dim(` Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`),
1706
+ );
1707
+ }
1708
+ } catch (err: unknown) {
1709
+ const message = err instanceof Error ? err.message : String(err);
1710
+ s.stop(color.yellow("Native addon ABI bootstrap unavailable"));
1711
+ p.log.warn(
1712
+ color.yellow("better-sqlite3 ABI repair did not run") +
1713
+ ` — ${message}` +
1714
+ color.dim(`\n Try manually: cd "${pluginRoot}" && npm rebuild better-sqlite3`),
1715
+ );
1716
+ }
1717
+
1718
+ // ── Post-install binding verifier (#514) ────────────────────
1719
+ // npm@7+ silently drops optionalDependencies whose engines
1720
+ // field excludes the running Node (e.g. Node 26 vs
1721
+ // better-sqlite3@12.x). On a silent skip the package directory
1722
+ // is missing entirely and ensure-deps cannot recover. Fail
1723
+ // loud so /ctx-upgrade no longer reports success while the
1724
+ // knowledge base is unusable.
1725
+ const bsqBindingPath = resolve(
1726
+ pluginRoot,
1727
+ "node_modules",
1728
+ "better-sqlite3",
1729
+ "build",
1730
+ "Release",
1731
+ "better_sqlite3.node",
1732
+ );
1733
+ if (!existsSync(bsqBindingPath)) {
1734
+ // Try one last self-heal — explicit, named install bypasses
1735
+ // the optionalDependency silent-skip path even if the dep
1736
+ // somehow regressed back to optional.
1737
+ try {
1738
+ const healPath = resolve(pluginRoot, "scripts", "heal-better-sqlite3.mjs");
1739
+ if (existsSync(healPath)) {
1740
+ const mod = await import(
1741
+ `${pathToFileURL(healPath).href}?upgrade=${Date.now()}`
1742
+ );
1743
+ if (typeof mod.healBetterSqlite3Binding === "function") {
1744
+ mod.healBetterSqlite3Binding(pluginRoot);
1745
+ }
1746
+ }
1747
+ } catch { /* best effort — verifier below will fail loud */ }
1748
+ }
1749
+ if (!existsSync(bsqBindingPath)) {
1750
+ // Mark the upgrade process for a non-zero exit at completion.
1751
+ // Stays in scope only for the rest of upgrade(); the actual
1752
+ // exit-code wiring sits below the top-level changes report.
1753
+ process.exitCode = 1;
1754
+ p.log.error(
1755
+ color.red("better-sqlite3 native binding: MISSING") +
1756
+ color.dim(`\n Path: ${bsqBindingPath}`) +
1757
+ color.dim("\n Cause: npm silently skipped the package (Node engine mismatch, issue #514)") +
1758
+ color.dim(`\n Try (primary): cd "${pluginRoot}" && npm install better-sqlite3 --no-optional`) +
1759
+ color.dim("\n Try (fallback): /context-mode:ctx-doctor"),
1760
+ );
1761
+ }
1762
+
1763
+ // Update global npm
1764
+ s.start("Updating npm global package");
1765
+ try {
1766
+ npmExecFile(["install", "-g", pluginRoot, "--no-audit", "--no-fund"], {
1767
+ stdio: "pipe",
1768
+ timeout: 30000,
1769
+ });
1770
+ s.stop(color.green("npm global updated"));
1771
+ changes.push("Updated npm global package");
1772
+ } catch {
1773
+ s.stop(color.yellow("npm global update skipped"));
1774
+ p.log.info(color.dim(" Could not update global npm — may need sudo or standalone install"));
1775
+ }
1776
+ }
1777
+
1778
+ // Cleanup
1779
+ rmSync(tmpDir, { recursive: true, force: true });
1780
+
1781
+ // Sync skills to the active install path from installed_plugins.json (#228).
1782
+ // Only targets the ACTUAL directory Claude Code reads from — not spraying everywhere.
1783
+ // Issue #460 round-3: honor $CLAUDE_CONFIG_DIR so the registry lookup
1784
+ // tracks relocated CC config trees.
1785
+ try {
1786
+ const claudeRoot = resolveClaudeConfigDir();
1787
+ const registryPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
1788
+ if (existsSync(registryPath)) {
1789
+ // The registry's installPath fields are written by Claude Code under
1790
+ // <claudeRoot>/plugins/cache/<marketplace>/<plugin>/<version>. Any other
1791
+ // shape means the registry has been tampered with by a co-resident
1792
+ // plugin, a malicious postinstall script, or another local actor.
1793
+ // Without containment, cpSync would happily recursive-write the in-repo
1794
+ // skills/ tree to /etc/skills, ~/.ssh/skills, or wherever the attacker
1795
+ // pointed. server.ts:790 (healCacheMidSession) already gates the same
1796
+ // field this way; the symmetric guard belongs here too.
1797
+ //
1798
+ // The lexical resolve+startsWith check rejects ".."-escapes and
1799
+ // absolute paths outside cacheRoot, but path.resolve doesn't
1800
+ // dereference symlinks. A same-uid actor who can plant a symlink
1801
+ // AT <cacheRoot>/<owner>/<plugin>/<version> targeting an attacker
1802
+ // dir gets past the lexical guard, then cpSync follows the link at
1803
+ // FS-write time. Re-check via realpathSync so a planted symlink
1804
+ // anchor fails the gate.
1805
+ const cacheRoot = resolve(claudeRoot, "plugins", "cache");
1806
+ let cacheRootCanon: string;
1807
+ try { cacheRootCanon = realpathSync(cacheRoot); }
1808
+ catch { cacheRootCanon = cacheRoot; }
1809
+ const cacheRootWithSep = cacheRootCanon + sep;
1810
+ const registry = JSON.parse(readFileSync(registryPath, "utf-8"));
1811
+ const entries = registry?.plugins?.["context-mode@context-mode"];
1812
+ if (Array.isArray(entries)) {
1813
+ for (const entry of entries) {
1814
+ const installPath = entry?.installPath;
1815
+ if (typeof installPath !== "string" || !installPath) continue;
1816
+ if (installPath === pluginRoot) continue;
1817
+ const resolvedInstallPath = resolve(installPath);
1818
+ if (!(resolvedInstallPath + sep).startsWith(cacheRootWithSep)) continue;
1819
+ if (!existsSync(resolvedInstallPath)) continue;
1820
+ let realInstallPath: string;
1821
+ try { realInstallPath = realpathSync(resolvedInstallPath); }
1822
+ catch { continue; }
1823
+ if (!(realInstallPath + sep).startsWith(cacheRootWithSep)) continue;
1824
+ const srcSkills = resolve(srcDir, "skills");
1825
+ if (existsSync(srcSkills)) {
1826
+ cpSync(srcSkills, resolve(realInstallPath, "skills"), { recursive: true });
1827
+ changes.push(`Synced skills to active install path`);
1828
+ }
1829
+ }
1830
+ }
1831
+ }
1832
+ } catch { /* best effort — registry may not exist or be malformed */ }
1833
+
1834
+ changes.push(`Updated v${localVersion} → v${newVersion}`);
1835
+ p.log.success(
1836
+ color.green("Plugin reinstalled from GitHub!") +
1837
+ color.dim(` — v${newVersion}`),
1838
+ );
1839
+ }
1840
+ } catch (err: unknown) {
1841
+ const message = err instanceof Error ? err.message : String(err);
1842
+ s.stop(color.red("Update failed"));
1843
+ p.log.error(color.red("GitHub pull failed") + ` — ${message}`);
1844
+
1845
+ // Issue #628 — Windows `spawnSync cmd.exe ETIMEDOUT` (and any
1846
+ // other Step 1/2 throw — network, npm, manifest mismatch) used
1847
+ // to fall through to Steps 3-7 (backup, hooks, perms, doctor),
1848
+ // all of which succeed against the OLD on-disk install. The
1849
+ // process then exited 0 and the upgrade-checklist renderer
1850
+ // marked `[x] Built and installed vNEW` while in-place files,
1851
+ // installed_plugins.json registry, and per-version cache dirs
1852
+ // stayed at vOLD. Worse: the marketplace clone synced earlier
1853
+ // in this same run is now AHEAD of cache+registry — Claude
1854
+ // Code's plugin manager keeps offering the same upgrade
1855
+ // forever (drift trap; reporter had to hand-edit
1856
+ // installed_plugins.json to escape).
1857
+ //
1858
+ // Algo defense: mark the process for non-zero exit and surface
1859
+ // an actionable recovery hint. Steps 3-7 still run because the
1860
+ // user's hooks may be broken regardless — but the overall
1861
+ // upgrade no longer reports success.
1862
+ process.exitCode = 1;
1863
+ p.log.warn(
1864
+ color.yellow("In-place files were NOT updated") +
1865
+ color.dim(" — old version is still on disk; hooks/settings will still be refreshed."),
1866
+ );
1867
+ p.log.info(
1868
+ color.dim(" Recovery: re-run /ctx-upgrade once network is stable, or run /context-mode:ctx-doctor for a full health check."),
1869
+ );
1870
+
1871
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
1872
+ }
1873
+
1874
+ // Step 3: Backup settings — adapter-aware
1875
+ p.log.step(`Backing up ${adapter.name} settings...`);
1876
+ const backupPath = adapter.backupSettings();
1877
+ if (backupPath?.endsWith(".bak")) {
1878
+ p.log.success(color.green("Backup created") + color.dim(" -> " + backupPath));
1879
+ changes.push("Backed up settings");
1880
+ } else if (backupPath) {
1881
+ p.log.success(color.green("Backup skipped") + color.dim(" — no changes needed"));
1882
+ } else {
1883
+ p.log.warn(
1884
+ color.yellow("No existing settings to backup") +
1885
+ " — a new one will be created",
1886
+ );
1887
+ }
1888
+
1889
+ // Step 4: Configure hooks — adapter-aware
1890
+ p.log.step(`Configuring ${adapter.name} hooks...`);
1891
+ try {
1892
+ const hookChanges = adapter.configureAllHooks(pluginRoot);
1893
+ for (const change of hookChanges) {
1894
+ p.log.info(color.dim(` ${change}`));
1895
+ changes.push(change);
1896
+ }
1897
+ p.log.success(color.green("Hooks configured") + color.dim(` — ${adapter.name}`));
1898
+ } catch (err: unknown) {
1899
+ const message = err instanceof Error ? err.message : String(err);
1900
+ throw new Error(`Hook configuration failed: ${message}`);
1901
+ }
1902
+
1903
+ // Step 5: Set hook script permissions — adapter-aware
1904
+ p.log.step("Setting hook script permissions...");
1905
+ const permSet = adapter.setHookPermissions(pluginRoot);
1906
+ // Also ensure CLI binaries are executable (tsc doesn't set +x)
1907
+ // chmod is POSIX-only — skip on Windows where execute bits are irrelevant
1908
+ if (process.platform !== "win32") {
1909
+ for (const bin of ["build/cli.js", "cli.bundle.mjs"]) {
1910
+ const binPath = resolve(pluginRoot, bin);
1911
+ try {
1912
+ accessSync(binPath, constants.F_OK);
1913
+ chmodSync(binPath, 0o755);
1914
+ permSet.push(binPath);
1915
+ } catch { /* not found — skip */ }
1916
+ }
1917
+ }
1918
+ if (permSet.length > 0) {
1919
+ p.log.success(color.green("Permissions set") + color.dim(` — ${permSet.length} hook script(s)`));
1920
+ changes.push(`Set ${permSet.length} hook scripts as executable`);
1921
+ } else {
1922
+ p.log.error(
1923
+ color.red("No hook scripts found") +
1924
+ color.dim(" — expected in " + resolve(pluginRoot, "hooks")),
1925
+ );
1926
+ }
1927
+
1928
+ // Step 6: Report
1929
+ if (changes.length > 0) {
1930
+ p.note(
1931
+ changes.map((c) => color.green(" + ") + c).join("\n"),
1932
+ "Changes Applied",
1933
+ );
1934
+ } else {
1935
+ p.log.info(color.dim("No changes were needed."));
1936
+ }
1937
+
1938
+ // Restart notice — new MCP tools require MCP server restart
1939
+ const restartHint = adapter.name === "Claude Code"
1940
+ ? "/reload-plugins, new terminal, or restart session"
1941
+ : "new terminal or restart session";
1942
+ p.log.warn(
1943
+ color.yellow("Restart for new MCP tools to take effect.") +
1944
+ color.dim(` (${restartHint})`),
1945
+ );
1946
+
1947
+ // Step 7: Run doctor
1948
+ p.log.step("Running doctor to verify...");
1949
+ console.log();
1950
+
1951
+ try {
1952
+ const cliBundlePath = resolve(pluginRoot, "cli.bundle.mjs");
1953
+ const cliBuildPath = resolve(pluginRoot, "build", "cli.js");
1954
+ const cliPath = existsSync(cliBundlePath) ? cliBundlePath : cliBuildPath;
1955
+ execFileSync("node", [cliPath, "doctor"], {
1956
+ stdio: "inherit",
1957
+ timeout: 30000,
1958
+ cwd: pluginRoot,
1959
+ env: { ...process.env, CONTEXT_MODE_PLATFORM: detection.platform },
1960
+ });
1961
+ } catch {
1962
+ p.log.warn(
1963
+ color.yellow("Doctor had warnings") +
1964
+ color.dim(` — restart your ${adapter.name} session to pick up the new version`),
1965
+ );
1966
+ }
1967
+ }
1968
+
1969
+ /* -------------------------------------------------------
1970
+ * statusline — forward to bin/statusline.mjs
1971
+ * ------------------------------------------------------- */
1972
+
1973
+ function statuslineForward(): void {
1974
+ // Try multiple plugin-root candidates in priority order. After ctx-upgrade,
1975
+ // getPluginRoot() can resolve to a cache dir that sessionstart.mjs (#181)
1976
+ // already cleaned, leaving bin/statusline.mjs missing. Falling back to the
1977
+ // marketplace clone (#418-synced, stable across upgrades) and to the path
1978
+ // Claude Code itself loads from (installed_plugins.json) keeps the bar
1979
+ // alive instead of silently going blank.
1980
+ // Issue #460 round-3: marketplace + registry paths must follow
1981
+ // $CLAUDE_CONFIG_DIR so relocated CC trees still find the statusline binary.
1982
+ const claudeRoot = resolveClaudeConfigDir();
1983
+ const candidates: string[] = [
1984
+ resolve(getPluginRoot(), "bin", "statusline.mjs"),
1985
+ resolve(claudeRoot, "plugins", "marketplaces", "context-mode", "bin", "statusline.mjs"),
1986
+ ];
1987
+
1988
+ // installed_plugins.json may list one or more install paths CC actually
1989
+ // loads from. Prefer those if they exist.
1990
+ try {
1991
+ const registryPath = resolve(claudeRoot, "plugins", "installed_plugins.json");
1992
+ if (existsSync(registryPath)) {
1993
+ // Same trust boundary as the cpSync site in upgrade() and as
1994
+ // server.ts:790's healCacheMidSession: only honor installPath values
1995
+ // that resolve under <claudeRoot>/plugins/cache. A stray /etc or
1996
+ // ~/.ssh entry written by another local actor must not become the
1997
+ // script the statusline forwarder imports, since statusline re-fires
1998
+ // several times per second and would hand the attacker durable RCE
1999
+ // on the user's behalf.
2000
+ //
2001
+ // path.resolve is purely lexical, so a same-uid actor who can plant
2002
+ // a symlink at <cacheRoot>/<owner>/<plugin>/<version> targeting an
2003
+ // attacker dir would pass the lexical gate. Re-check via
2004
+ // realpathSync so the dynamic-import target's actual on-disk
2005
+ // location also stays under cacheRoot.
2006
+ const cacheRoot = resolve(claudeRoot, "plugins", "cache");
2007
+ let cacheRootCanon: string;
2008
+ try { cacheRootCanon = realpathSync(cacheRoot); }
2009
+ catch { cacheRootCanon = cacheRoot; }
2010
+ const cacheRootWithSep = cacheRootCanon + sep;
2011
+ const registry = JSON.parse(readFileSync(registryPath, "utf-8"));
2012
+ const entries = registry?.plugins?.["context-mode@context-mode"];
2013
+ if (Array.isArray(entries)) {
2014
+ for (const entry of entries) {
2015
+ const installPath = entry?.installPath;
2016
+ if (typeof installPath !== "string" || !installPath) continue;
2017
+ const resolvedInstallPath = resolve(installPath);
2018
+ if (!(resolvedInstallPath + sep).startsWith(cacheRootWithSep)) continue;
2019
+ let realInstallPath: string;
2020
+ try { realInstallPath = realpathSync(resolvedInstallPath); }
2021
+ catch { continue; }
2022
+ if (!(realInstallPath + sep).startsWith(cacheRootWithSep)) continue;
2023
+ candidates.push(resolve(realInstallPath, "bin", "statusline.mjs"));
2024
+ }
2025
+ }
2026
+ }
2027
+ } catch { /* registry malformed — fall through to other candidates */ }
2028
+
2029
+ const scriptPath = candidates.find((c) => existsSync(c));
2030
+ if (!scriptPath) {
2031
+ // Statusline output is the user-facing status bar; stderr surfaces visibly
2032
+ // in some terminals. Exit silently — the bar simply stays empty until the
2033
+ // next /ctx-upgrade or restart resolves the path.
2034
+ process.exit(0);
2035
+ }
2036
+ // Re-exec via dynamic import so stdin/stdout are inherited cleanly.
2037
+ import(pathToFileURL(scriptPath).href).catch(() => {
2038
+ process.exit(0);
2039
+ });
2040
+ }