context-mode 1.0.112 → 1.0.114

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.
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.112",
6
+ "version": "1.0.114",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.112",
3
+ "version": "1.0.114",
4
4
  "type": "module",
5
5
  "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.",
6
6
  "author": "Mert Koseoğlu",
@@ -79,6 +79,7 @@
79
79
  "start.mjs",
80
80
  "scripts/postinstall.mjs",
81
81
  "scripts/heal-better-sqlite3.mjs",
82
+ "scripts/heal-installed-plugins.mjs",
82
83
  "README.md",
83
84
  "LICENSE"
84
85
  ],
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Self-heal `~/.claude/plugins/installed_plugins.json` (#46915 follow-up).
3
+ *
4
+ * v1.0.113's `/ctx-upgrade` poisoned this file in two ways:
5
+ * 1. Per-entry `version` drifted from the actual cache directory's
6
+ * `plugin.json` version.
7
+ * 2. The top-level `enabledPlugins[<key>]` was emptied (or never set)
8
+ * so Claude Code's plugin loader skipped context-mode → MCP died.
9
+ *
10
+ * Single source of truth shared by:
11
+ * - `start.mjs` HEAL 3+4 (every MCP boot)
12
+ * - `scripts/postinstall.mjs` (every `npm install -g context-mode`)
13
+ *
14
+ * Pure Node.js (built-ins only). Best-effort: never throws, always
15
+ * returns a plain result object so callers can log a one-liner.
16
+ *
17
+ * @see https://github.com/anthropics/claude-code/issues/46915
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { resolve, sep } from "node:path";
22
+
23
+ /**
24
+ * @typedef {Object} HealResult
25
+ * @property {string[]} healed - one of: "entry-version", "enabled-plugins"
26
+ * @property {string} [skipped] - reason if no work performed
27
+ * @property {string} [error] - error message if heal aborted
28
+ */
29
+
30
+ /**
31
+ * Heal a single plugin entry inside installed_plugins.json.
32
+ *
33
+ * @param {{
34
+ * registryPath: string,
35
+ * pluginCacheRoot: string,
36
+ * pluginKey: string,
37
+ * }} opts
38
+ * @returns {HealResult}
39
+ */
40
+ export function healInstalledPlugins({ registryPath, pluginCacheRoot, pluginKey }) {
41
+ if (!registryPath || !existsSync(registryPath)) {
42
+ return { healed: [], skipped: "no-registry" };
43
+ }
44
+
45
+ let raw;
46
+ try {
47
+ raw = readFileSync(registryPath, "utf-8");
48
+ } catch (err) {
49
+ return { healed: [], error: `read-failed: ${(err && err.message) || err}` };
50
+ }
51
+
52
+ let ip;
53
+ try {
54
+ ip = JSON.parse(raw);
55
+ } catch (err) {
56
+ return { healed: [], error: `parse-failed: ${(err && err.message) || err}` };
57
+ }
58
+ if (!ip || typeof ip !== "object") {
59
+ return { healed: [], error: "bad-shape" };
60
+ }
61
+
62
+ const entries = (ip.plugins && ip.plugins[pluginKey]) || [];
63
+ if (!Array.isArray(entries) || entries.length === 0) {
64
+ return { healed: [], skipped: "no-entry" };
65
+ }
66
+
67
+ /** @type {string[]} */
68
+ const healed = [];
69
+ let syncedVersion = null;
70
+
71
+ // ── HEAL 3: per-entry version <- cache plugin.json version ──
72
+ // We trust the cache directory because that's what start.mjs actually
73
+ // boots from; the registry is just a stale label.
74
+ for (const entry of entries) {
75
+ if (!entry || typeof entry !== "object") continue;
76
+ const installPath = entry.installPath;
77
+ if (!installPath || typeof installPath !== "string") continue;
78
+
79
+ // Path-traversal guard: only consult plugin.json files inside the
80
+ // declared plugin cache root.
81
+ const resolvedInstall = resolve(installPath);
82
+ const cacheRootWithSep = resolve(pluginCacheRoot) + sep;
83
+ if (!resolvedInstall.startsWith(cacheRootWithSep)) continue;
84
+
85
+ const cachePluginJson = resolve(installPath, ".claude-plugin", "plugin.json");
86
+ if (!existsSync(cachePluginJson)) continue;
87
+ let actualVersion = null;
88
+ try {
89
+ const pj = JSON.parse(readFileSync(cachePluginJson, "utf-8"));
90
+ if (pj && typeof pj.version === "string" && pj.version) {
91
+ actualVersion = pj.version;
92
+ }
93
+ } catch {
94
+ continue;
95
+ }
96
+ if (!actualVersion) continue;
97
+
98
+ syncedVersion = actualVersion;
99
+ if (entry.version !== actualVersion) {
100
+ entry.version = actualVersion;
101
+ if (!healed.includes("entry-version")) healed.push("entry-version");
102
+ }
103
+ }
104
+
105
+ // ── HEAL 4: top-level enabledPlugins[key] presence ──
106
+ // Claude Code's plugin loader checks enabledPlugins. When /ctx-upgrade
107
+ // emptied it, our plugin was silently disabled. Set it to `true` (the
108
+ // simplest enabled-flag form) when missing or falsy.
109
+ if (syncedVersion) {
110
+ if (!ip.enabledPlugins || typeof ip.enabledPlugins !== "object" || Array.isArray(ip.enabledPlugins)) {
111
+ ip.enabledPlugins = {};
112
+ }
113
+ const current = ip.enabledPlugins[pluginKey];
114
+ if (current === undefined || current === null || current === false || current === "") {
115
+ ip.enabledPlugins[pluginKey] = true;
116
+ healed.push("enabled-plugins");
117
+ }
118
+ }
119
+
120
+ if (healed.length > 0) {
121
+ try {
122
+ writeFileSync(registryPath, JSON.stringify(ip, null, 2) + "\n", "utf-8");
123
+ } catch (err) {
124
+ return { healed: [], error: `write-failed: ${(err && err.message) || err}` };
125
+ }
126
+ }
127
+
128
+ return { healed };
129
+ }
@@ -14,10 +14,33 @@ import { dirname, resolve, join, sep } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { homedir } from "node:os";
16
16
  import { healBetterSqlite3Binding } from "./heal-better-sqlite3.mjs";
17
+ import { healInstalledPlugins } from "./heal-installed-plugins.mjs";
17
18
 
18
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
19
20
  const pkgRoot = resolve(__dirname, "..");
20
21
 
22
+ /**
23
+ * True when running as a real `npm install -g context-mode`. We use this
24
+ * to keep contributors' local `npm install` runs from rewriting their HOME's
25
+ * Claude Code registry (would be very surprising during dev).
26
+ *
27
+ * Heuristic: npm sets `npm_config_global=true` for global installs AND the
28
+ * package directory has no nearby `.git` (a contributor's clone always
29
+ * does). Both signals must agree.
30
+ */
31
+ function isGlobalInstall() {
32
+ if (process.env.npm_config_global !== "true") return false;
33
+ // Walk up a few levels looking for .git — contributors always have one.
34
+ let dir = pkgRoot;
35
+ for (let i = 0; i < 4; i++) {
36
+ if (existsSync(join(dir, ".git"))) return false;
37
+ const parent = dirname(dir);
38
+ if (parent === dir) break;
39
+ dir = parent;
40
+ }
41
+ return true;
42
+ }
43
+
21
44
  /**
22
45
  * Validate that a path is safe to interpolate into a cmd.exe command.
23
46
  * Rejects characters that could enable command injection via cmd.exe.
@@ -26,6 +49,41 @@ function isSafeWindowsPath(p) {
26
49
  return !/[&|<>"^%\r\n]/.test(p);
27
50
  }
28
51
 
52
+ // ── -1. v1.0.114 hotfix — installed_plugins.json registry repair ─────
53
+ // /ctx-upgrade in v1.0.113 poisoned the registry (entry.version drifted
54
+ // + enabledPlugins emptied), making Claude Code's plugin loader skip
55
+ // context-mode entirely. start.mjs HEAL 3+4 fix this on every MCP boot,
56
+ // but already-broken users have no MCP to boot — they need the heal to
57
+ // run from npm postinstall. Shared module so both call sites stay in
58
+ // sync. Only runs in real `npm install -g` to avoid surprising
59
+ // contributors. Best effort, never blocks install. (#46915 follow-up.)
60
+ if (isGlobalInstall()) {
61
+ try {
62
+ const registryPath = resolve(homedir(), ".claude", "plugins", "installed_plugins.json");
63
+ const pluginCacheRoot = resolve(homedir(), ".claude", "plugins", "cache");
64
+ const result = healInstalledPlugins({
65
+ registryPath,
66
+ pluginCacheRoot,
67
+ pluginKey: "context-mode@context-mode",
68
+ });
69
+ if (result.skipped === "no-registry") {
70
+ // Standalone npm user (no Claude Code) — silent success.
71
+ process.stderr.write("context-mode: install OK, no Claude Code registry found\n");
72
+ } else if (result.error) {
73
+ process.stderr.write(`context-mode: install OK, registry heal skipped (${result.error})\n`);
74
+ } else if (result.healed && result.healed.length > 0) {
75
+ process.stderr.write(`context-mode: healed installed_plugins.json (${result.healed.join(", ")})\n`);
76
+ } else {
77
+ process.stderr.write("context-mode: install OK, no heal needed\n");
78
+ }
79
+ } catch (err) {
80
+ // Never block install on a heal failure.
81
+ try {
82
+ process.stderr.write(`context-mode: install OK, heal aborted (${(err && err.message) || err})\n`);
83
+ } catch { /* truly best effort */ }
84
+ }
85
+ }
86
+
29
87
  // ── 0. Self-heal Layer 3: Backward symlink for stale registry (anthropics/claude-code#46915) ──
30
88
  // When this install completes, installed_plugins.json may still point to an old
31
89
  // non-existent path. Create a symlink from that old path → our new directory.