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