@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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +31 -0
- package/.codex-plugin/hooks.json +65 -0
- package/.codex-plugin/mcp.json +13 -0
- package/.codex-plugin/plugin.json +31 -0
- package/.openclaw-plugin/index.ts +12 -0
- package/.openclaw-plugin/openclaw.plugin.json +38 -0
- package/.openclaw-plugin/package.json +28 -0
- package/LICENSE +94 -0
- package/README.md +1619 -0
- package/bin/statusline.mjs +378 -0
- package/build/adapters/antigravity/index.d.ts +51 -0
- package/build/adapters/antigravity/index.js +169 -0
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/base.d.ts +92 -0
- package/build/adapters/base.js +135 -0
- package/build/adapters/claude-code/config.d.ts +8 -0
- package/build/adapters/claude-code/config.js +8 -0
- package/build/adapters/claude-code/hooks.d.ts +106 -0
- package/build/adapters/claude-code/hooks.js +183 -0
- package/build/adapters/claude-code/index.d.ts +80 -0
- package/build/adapters/claude-code/index.js +569 -0
- package/build/adapters/claude-code-base.d.ts +49 -0
- package/build/adapters/claude-code-base.js +113 -0
- package/build/adapters/client-map.d.ts +10 -0
- package/build/adapters/client-map.js +42 -0
- package/build/adapters/codex/config.d.ts +8 -0
- package/build/adapters/codex/config.js +8 -0
- package/build/adapters/codex/hooks.d.ts +55 -0
- package/build/adapters/codex/hooks.js +64 -0
- package/build/adapters/codex/index.d.ts +87 -0
- package/build/adapters/codex/index.js +995 -0
- package/build/adapters/codex/paths.d.ts +1 -0
- package/build/adapters/codex/paths.js +12 -0
- package/build/adapters/codex/usage.d.ts +107 -0
- package/build/adapters/codex/usage.js +227 -0
- package/build/adapters/copilot-base.d.ts +94 -0
- package/build/adapters/copilot-base.js +297 -0
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/cursor/config.d.ts +4 -0
- package/build/adapters/cursor/config.js +4 -0
- package/build/adapters/cursor/hooks.d.ts +55 -0
- package/build/adapters/cursor/hooks.js +82 -0
- package/build/adapters/cursor/index.d.ts +64 -0
- package/build/adapters/cursor/index.js +517 -0
- package/build/adapters/detect.d.ts +136 -0
- package/build/adapters/detect.js +623 -0
- package/build/adapters/gemini-cli/config.d.ts +8 -0
- package/build/adapters/gemini-cli/config.js +8 -0
- package/build/adapters/gemini-cli/hooks.d.ts +83 -0
- package/build/adapters/gemini-cli/hooks.js +115 -0
- package/build/adapters/gemini-cli/index.d.ts +80 -0
- package/build/adapters/gemini-cli/index.js +483 -0
- package/build/adapters/jetbrains-copilot/config.d.ts +8 -0
- package/build/adapters/jetbrains-copilot/config.js +8 -0
- package/build/adapters/jetbrains-copilot/hooks.d.ts +59 -0
- package/build/adapters/jetbrains-copilot/hooks.js +87 -0
- package/build/adapters/jetbrains-copilot/index.d.ts +31 -0
- package/build/adapters/jetbrains-copilot/index.js +131 -0
- package/build/adapters/kimi/config.d.ts +8 -0
- package/build/adapters/kimi/config.js +8 -0
- package/build/adapters/kimi/hooks.d.ts +28 -0
- package/build/adapters/kimi/hooks.js +34 -0
- package/build/adapters/kimi/index.d.ts +66 -0
- package/build/adapters/kimi/index.js +537 -0
- package/build/adapters/kimi/paths.d.ts +21 -0
- package/build/adapters/kimi/paths.js +52 -0
- package/build/adapters/kimi/usage.d.ts +82 -0
- package/build/adapters/kimi/usage.js +217 -0
- package/build/adapters/kiro/hooks.d.ts +61 -0
- package/build/adapters/kiro/hooks.js +94 -0
- package/build/adapters/kiro/index.d.ts +61 -0
- package/build/adapters/kiro/index.js +301 -0
- package/build/adapters/omp/index.d.ts +65 -0
- package/build/adapters/omp/index.js +182 -0
- package/build/adapters/omp/plugin.d.ts +81 -0
- package/build/adapters/omp/plugin.js +331 -0
- package/build/adapters/omp/usage.d.ts +49 -0
- package/build/adapters/omp/usage.js +110 -0
- package/build/adapters/openclaw/config.d.ts +8 -0
- package/build/adapters/openclaw/config.js +8 -0
- package/build/adapters/openclaw/hooks.d.ts +50 -0
- package/build/adapters/openclaw/hooks.js +61 -0
- package/build/adapters/openclaw/index.d.ts +74 -0
- package/build/adapters/openclaw/index.js +439 -0
- package/build/adapters/openclaw/mcp-tools.d.ts +54 -0
- package/build/adapters/openclaw/mcp-tools.js +207 -0
- package/build/adapters/openclaw/plugin.d.ts +141 -0
- package/build/adapters/openclaw/plugin.js +818 -0
- package/build/adapters/openclaw/session-db.d.ts +55 -0
- package/build/adapters/openclaw/session-db.js +88 -0
- package/build/adapters/openclaw/usage.d.ts +34 -0
- package/build/adapters/openclaw/usage.js +52 -0
- package/build/adapters/openclaw/workspace-router.d.ts +29 -0
- package/build/adapters/openclaw/workspace-router.js +64 -0
- package/build/adapters/opencode/config.d.ts +8 -0
- package/build/adapters/opencode/config.js +8 -0
- package/build/adapters/opencode/hooks.d.ts +38 -0
- package/build/adapters/opencode/hooks.js +50 -0
- package/build/adapters/opencode/index.d.ts +144 -0
- package/build/adapters/opencode/index.js +638 -0
- package/build/adapters/opencode/plugin.d.ts +249 -0
- package/build/adapters/opencode/plugin.js +1536 -0
- package/build/adapters/opencode/v2.d.ts +117 -0
- package/build/adapters/opencode/v2.js +224 -0
- package/build/adapters/opencode/zod3tov4.d.ts +1 -0
- package/build/adapters/opencode/zod3tov4.js +132 -0
- package/build/adapters/pi/extension.d.ts +81 -0
- package/build/adapters/pi/extension.js +861 -0
- package/build/adapters/pi/index.d.ts +57 -0
- package/build/adapters/pi/index.js +173 -0
- package/build/adapters/pi/mcp-bridge.d.ts +268 -0
- package/build/adapters/pi/mcp-bridge.js +894 -0
- package/build/adapters/qwen-code/hooks.d.ts +26 -0
- package/build/adapters/qwen-code/hooks.js +29 -0
- package/build/adapters/qwen-code/index.d.ts +40 -0
- package/build/adapters/qwen-code/index.js +361 -0
- package/build/adapters/qwen-code/usage.d.ts +90 -0
- package/build/adapters/qwen-code/usage.js +222 -0
- package/build/adapters/types.d.ts +360 -0
- package/build/adapters/types.js +118 -0
- package/build/adapters/vscode-copilot/config.d.ts +8 -0
- package/build/adapters/vscode-copilot/config.js +8 -0
- package/build/adapters/vscode-copilot/hooks.d.ts +71 -0
- package/build/adapters/vscode-copilot/hooks.js +98 -0
- package/build/adapters/vscode-copilot/index.d.ts +32 -0
- package/build/adapters/vscode-copilot/index.js +222 -0
- package/build/adapters/zed/index.d.ts +40 -0
- package/build/adapters/zed/index.js +163 -0
- package/build/cli.d.ts +34 -0
- package/build/cli.js +1828 -0
- package/build/db-base.d.ts +250 -0
- package/build/db-base.js +756 -0
- package/build/executor.d.ts +72 -0
- package/build/executor.js +678 -0
- package/build/exit-classify.d.ts +19 -0
- package/build/exit-classify.js +12 -0
- package/build/fetch/blocks.d.ts +145 -0
- package/build/fetch/blocks.js +244 -0
- package/build/fetch/extract.d.ts +68 -0
- package/build/fetch/extract.js +123 -0
- package/build/fetch/page-store.d.ts +66 -0
- package/build/fetch/page-store.js +174 -0
- package/build/fetch-cache.d.ts +13 -0
- package/build/fetch-cache.js +15 -0
- package/build/lifecycle.d.ts +124 -0
- package/build/lifecycle.js +278 -0
- package/build/package-identity.d.ts +26 -0
- package/build/package-identity.js +28 -0
- package/build/runPool.d.ts +36 -0
- package/build/runPool.js +51 -0
- package/build/runtime.d.ts +71 -0
- package/build/runtime.js +659 -0
- package/build/search/auto-memory.d.ts +47 -0
- package/build/search/auto-memory.js +170 -0
- package/build/search/ctx-search-schema.d.ts +90 -0
- package/build/search/ctx-search-schema.js +135 -0
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/search/unified.d.ts +56 -0
- package/build/search/unified.js +104 -0
- package/build/security.d.ts +206 -0
- package/build/security.js +780 -0
- package/build/server.d.ts +240 -0
- package/build/server.js +5034 -0
- package/build/session/analytics.d.ts +773 -0
- package/build/session/analytics.js +2420 -0
- package/build/session/db.d.ts +468 -0
- package/build/session/db.js +1330 -0
- package/build/session/error-classifier.d.ts +87 -0
- package/build/session/error-classifier.js +303 -0
- package/build/session/event-emit.d.ts +48 -0
- package/build/session/event-emit.js +101 -0
- package/build/session/extract.d.ts +333 -0
- package/build/session/extract.js +2688 -0
- package/build/session/model-prices.json +429 -0
- package/build/session/persist-tool-calls.d.ts +54 -0
- package/build/session/persist-tool-calls.js +105 -0
- package/build/session/pricing.d.ts +64 -0
- package/build/session/pricing.js +151 -0
- package/build/session/project-attribution.d.ts +73 -0
- package/build/session/project-attribution.js +245 -0
- package/build/session/purge.d.ts +138 -0
- package/build/session/purge.js +240 -0
- package/build/session/retrieval-marker.d.ts +39 -0
- package/build/session/retrieval-marker.js +65 -0
- package/build/session/snapshot.d.ts +46 -0
- package/build/session/snapshot.js +532 -0
- package/build/store-directory.d.ts +56 -0
- package/build/store-directory.js +254 -0
- package/build/store.d.ts +147 -0
- package/build/store.js +1714 -0
- package/build/truncate.d.ts +57 -0
- package/build/truncate.js +142 -0
- package/build/types.d.ts +117 -0
- package/build/types.js +20 -0
- package/build/util/claude-config.d.ts +32 -0
- package/build/util/claude-config.js +84 -0
- package/build/util/hook-config.d.ts +27 -0
- package/build/util/hook-config.js +76 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/build/util/plugin-cache-integrity.d.ts +51 -0
- package/build/util/plugin-cache-integrity.js +146 -0
- package/build/util/project-dir.d.ts +149 -0
- package/build/util/project-dir.js +340 -0
- package/build/util/sibling-mcp.d.ts +79 -0
- package/build/util/sibling-mcp.js +181 -0
- package/cli.bundle.mjs +1495 -0
- package/configs/antigravity/GEMINI.md +68 -0
- package/configs/antigravity/mcp_config.json +7 -0
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +91 -0
- package/configs/codex/AGENTS.md +99 -0
- package/configs/codex/config.toml +8 -0
- package/configs/codex/hooks.json +47 -0
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/cursor/context-mode.mdc +76 -0
- package/configs/cursor/hooks.json +21 -0
- package/configs/cursor/mcp.json +7 -0
- package/configs/gemini-cli/GEMINI.md +88 -0
- package/configs/gemini-cli/mcp.json +7 -0
- package/configs/gemini-cli/settings.json +60 -0
- package/configs/jetbrains-copilot/copilot-instructions.md +80 -0
- package/configs/jetbrains-copilot/hooks.json +16 -0
- package/configs/jetbrains-copilot/mcp.json +7 -0
- package/configs/kilo/AGENTS.md +84 -0
- package/configs/kilo/kilo.json +6 -0
- package/configs/kimi/hooks.json +54 -0
- package/configs/kiro/KIRO.md +84 -0
- package/configs/kiro/agent.json +18 -0
- package/configs/kiro/mcp.json +7 -0
- package/configs/omp/SYSTEM.md +85 -0
- package/configs/omp/mcp.json +7 -0
- package/configs/openclaw/AGENTS.md +87 -0
- package/configs/openclaw/openclaw.json +13 -0
- package/configs/opencode/AGENTS.md +84 -0
- package/configs/opencode/opencode.json +6 -0
- package/configs/pi/AGENTS.md +3 -0
- package/configs/qwen-code/QWEN.md +91 -0
- package/configs/vscode-copilot/copilot-instructions.md +80 -0
- package/configs/vscode-copilot/hooks.json +16 -0
- package/configs/vscode-copilot/mcp.json +7 -0
- package/configs/zed/AGENTS.md +68 -0
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/auto-injection.mjs +102 -0
- package/hooks/cache-heal-utils.mjs +379 -0
- package/hooks/codex/platform.mjs +1 -0
- package/hooks/codex/posttooluse.mjs +66 -0
- package/hooks/codex/precompact.mjs +70 -0
- package/hooks/codex/pretooluse.mjs +42 -0
- package/hooks/codex/sessionstart.mjs +121 -0
- package/hooks/codex/stop.mjs +138 -0
- package/hooks/codex/userpromptsubmit.mjs +75 -0
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +356 -0
- package/hooks/core/mcp-ready.mjs +109 -0
- package/hooks/core/platform-detect.mjs +49 -0
- package/hooks/core/routing.mjs +1050 -0
- package/hooks/core/stdin.mjs +90 -0
- package/hooks/core/tool-naming.mjs +57 -0
- package/hooks/cursor/afteragentresponse.mjs +74 -0
- package/hooks/cursor/hooks.json +31 -0
- package/hooks/cursor/posttooluse.mjs +72 -0
- package/hooks/cursor/pretooluse.mjs +26 -0
- package/hooks/cursor/sessionstart.mjs +97 -0
- package/hooks/cursor/stop.mjs +48 -0
- package/hooks/ensure-deps.mjs +254 -0
- package/hooks/formatters/claude-code.mjs +113 -0
- package/hooks/formatters/cursor.mjs +37 -0
- package/hooks/formatters/gemini-cli.mjs +55 -0
- package/hooks/formatters/vscode-copilot.mjs +55 -0
- package/hooks/gemini-cli/aftermodel.mjs +70 -0
- package/hooks/gemini-cli/aftertool.mjs +61 -0
- package/hooks/gemini-cli/beforeagent.mjs +99 -0
- package/hooks/gemini-cli/beforetool.mjs +27 -0
- package/hooks/gemini-cli/precompress.mjs +55 -0
- package/hooks/gemini-cli/sessionstart.mjs +137 -0
- package/hooks/heal-partial-install.mjs +712 -0
- package/hooks/hooks.json +143 -0
- package/hooks/jetbrains-copilot/posttooluse.mjs +61 -0
- package/hooks/jetbrains-copilot/precompact.mjs +55 -0
- package/hooks/jetbrains-copilot/pretooluse.mjs +27 -0
- package/hooks/jetbrains-copilot/sessionstart.mjs +124 -0
- package/hooks/kimi/platform.mjs +1 -0
- package/hooks/kimi/posttooluse.mjs +72 -0
- package/hooks/kimi/precompact.mjs +80 -0
- package/hooks/kimi/pretooluse.mjs +42 -0
- package/hooks/kimi/sessionend.mjs +61 -0
- package/hooks/kimi/sessionstart.mjs +113 -0
- package/hooks/kimi/stop.mjs +132 -0
- package/hooks/kimi/userpromptsubmit.mjs +90 -0
- package/hooks/kiro/agentspawn.mjs +97 -0
- package/hooks/kiro/posttooluse.mjs +51 -0
- package/hooks/kiro/pretooluse.mjs +64 -0
- package/hooks/kiro/userpromptsubmit.mjs +88 -0
- package/hooks/normalize-hooks.mjs +323 -0
- package/hooks/platform-bridge.mjs +340 -0
- package/hooks/posttooluse.mjs +225 -0
- package/hooks/precompact.mjs +100 -0
- package/hooks/pretooluse.mjs +227 -0
- package/hooks/qwen-code/platform.mjs +1 -0
- package/hooks/qwen-code/stop.mjs +168 -0
- package/hooks/routing-block.mjs +105 -0
- package/hooks/run-hook.mjs +95 -0
- package/hooks/security.bundle.mjs +2 -0
- package/hooks/session-attribution.bundle.mjs +1 -0
- package/hooks/session-db.bundle.mjs +145 -0
- package/hooks/session-directive.mjs +520 -0
- package/hooks/session-extract.bundle.mjs +3 -0
- package/hooks/session-helpers.mjs +428 -0
- package/hooks/session-loaders.mjs +448 -0
- package/hooks/session-snapshot.bundle.mjs +32 -0
- package/hooks/sessionstart.mjs +464 -0
- package/hooks/stop.mjs +82 -0
- package/hooks/suppress-stderr.mjs +25 -0
- package/hooks/userpromptsubmit.mjs +108 -0
- package/hooks/vscode-copilot/posttooluse.mjs +61 -0
- package/hooks/vscode-copilot/precompact.mjs +55 -0
- package/hooks/vscode-copilot/pretooluse.mjs +27 -0
- package/hooks/vscode-copilot/sessionstart.mjs +129 -0
- package/openclaw.plugin.json +38 -0
- package/package.json +132 -0
- package/scripts/heal-better-sqlite3.mjs +430 -0
- package/scripts/heal-installed-plugins.mjs +635 -0
- package/scripts/plugin-cache-integrity.mjs +248 -0
- package/scripts/postinstall.mjs +397 -0
- package/server.bundle.mjs +1421 -0
- package/server.js +5 -0
- package/skills/.ignore +7 -0
- package/skills/context-mode/SKILL.md +300 -0
- package/skills/context-mode/references/anti-patterns.md +283 -0
- package/skills/context-mode/references/patterns-javascript.md +298 -0
- package/skills/context-mode/references/patterns-python.md +304 -0
- package/skills/context-mode/references/patterns-shell.md +277 -0
- package/skills/ctx-doctor/SKILL.md +22 -0
- package/skills/ctx-index/SKILL.md +46 -0
- package/skills/ctx-insight/SKILL.md +24 -0
- package/skills/ctx-purge/SKILL.md +49 -0
- package/skills/ctx-search/SKILL.md +35 -0
- package/skills/ctx-stats/SKILL.md +26 -0
- package/skills/ctx-upgrade/SKILL.md +31 -0
- 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
|
+
}
|