context-mode 1.0.95 → 1.0.97

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/start.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, chmodSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
4
- import { dirname, resolve } from "node:path";
3
+ import { existsSync, chmodSync, readFileSync, writeFileSync, readdirSync, symlinkSync, mkdirSync, lstatSync, unlinkSync } from "node:fs";
4
+ import { dirname, resolve, join, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { homedir } from "node:os";
7
7
 
@@ -29,7 +29,9 @@ if (!process.env.CONTEXT_MODE_PROJECT_DIR) {
29
29
  // - Non-hook platforms: server.ts writeRoutingInstructions() on MCP connect
30
30
  // - Future: explicit `context-mode init` command
31
31
 
32
- // Self-heal: if a newer version dir exists, update registry so next session uses it
32
+ // ── Self-heal Layer 1: Fix registry symlink mismatches (anthropics/claude-code#46915) ──
33
+ // Claude Code auto-update can leave installed_plugins.json pointing to a non-existent
34
+ // directory. We detect this and create symlinks so hooks find the right path.
33
35
  const cacheMatch = __dirname.match(
34
36
  /^(.*[\/\\]plugins[\/\\]cache[\/\\][^\/\\]+[\/\\][^\/\\]+[\/\\])([^\/\\]+)$/,
35
37
  );
@@ -37,6 +39,9 @@ if (cacheMatch) {
37
39
  try {
38
40
  const cacheParent = cacheMatch[1];
39
41
  const myVersion = cacheMatch[2];
42
+ const ipPath = resolve(homedir(), ".claude", "plugins", "installed_plugins.json");
43
+
44
+ // Forward heal: if a newer version dir exists, update registry
40
45
  const dirs = readdirSync(cacheParent).filter((d) =>
41
46
  /^\d+\.\d+\.\d+/.test(d),
42
47
  );
@@ -52,26 +57,38 @@ if (cacheMatch) {
52
57
  });
53
58
  const newest = dirs[dirs.length - 1];
54
59
  if (newest && newest !== myVersion) {
55
- const ipPath = resolve(
56
- homedir(),
57
- ".claude",
58
- "plugins",
59
- "installed_plugins.json",
60
- );
61
60
  const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
62
61
  for (const [key, entries] of Object.entries(ip.plugins || {})) {
63
- if (!key.toLowerCase().includes("context-mode")) continue;
62
+ if (key !== "context-mode@context-mode") continue;
64
63
  for (const entry of entries) {
65
64
  entry.installPath = resolve(cacheParent, newest);
66
65
  entry.version = newest;
67
66
  entry.lastUpdated = new Date().toISOString();
68
67
  }
69
68
  }
70
- writeFileSync(
71
- ipPath,
72
- JSON.stringify(ip, null, 2) + "\n",
73
- "utf-8",
74
- );
69
+ writeFileSync(ipPath, JSON.stringify(ip, null, 2) + "\n", "utf-8");
70
+ }
71
+ }
72
+
73
+ // Reverse heal: if registry points to non-existent dir, create symlink to us
74
+ const cacheRoot = resolve(homedir(), ".claude", "plugins", "cache");
75
+ if (existsSync(ipPath)) {
76
+ const ip = JSON.parse(readFileSync(ipPath, "utf-8"));
77
+ for (const [key, entries] of Object.entries(ip.plugins || {})) {
78
+ if (key !== "context-mode@context-mode") continue;
79
+ for (const entry of entries) {
80
+ const rp = entry.installPath;
81
+ if (!rp || existsSync(rp) || rp === __dirname) continue;
82
+ // Path traversal guard: only allow paths inside plugin cache
83
+ if (!resolve(rp).startsWith(cacheRoot + sep)) continue;
84
+ try {
85
+ // Remove dangling symlink before creating new one
86
+ try { if (lstatSync(rp).isSymbolicLink()) unlinkSync(rp); } catch {}
87
+ const rpParent = dirname(rp);
88
+ if (!existsSync(rpParent)) mkdirSync(rpParent, { recursive: true });
89
+ symlinkSync(__dirname, rp, process.platform === "win32" ? "junction" : undefined);
90
+ } catch { /* best effort */ }
91
+ }
75
92
  }
76
93
  }
77
94
  } catch {
@@ -79,6 +96,72 @@ if (cacheMatch) {
79
96
  }
80
97
  }
81
98
 
99
+ // ── Self-heal Layer 4: Deploy global SessionStart hook + register in settings.json ──
100
+ // This hook lives outside the plugin directory (~/.claude/hooks/) so it works
101
+ // even when the plugin cache is completely broken. It creates symlinks for any
102
+ // missing plugin cache directories on every session start.
103
+ // Pure Node.js — no bash dependency. Works on Windows, macOS (SIP), Linux.
104
+ try {
105
+ const globalHooksDir = resolve(homedir(), ".claude", "hooks");
106
+ const healHookPath = resolve(globalHooksDir, "context-mode-cache-heal.mjs");
107
+ // Clean up old bash version if it exists
108
+ const oldBashHook = resolve(globalHooksDir, "context-mode-cache-heal.sh");
109
+ if (existsSync(oldBashHook)) {
110
+ try { unlinkSync(oldBashHook); } catch {}
111
+ }
112
+ if (!existsSync(healHookPath)) {
113
+ if (!existsSync(globalHooksDir)) mkdirSync(globalHooksDir, { recursive: true });
114
+ const healScript = `#!/usr/bin/env node
115
+ // context-mode plugin cache self-heal (auto-deployed)
116
+ // Fixes anthropics/claude-code#46915: auto-update breaks CLAUDE_PLUGIN_ROOT
117
+ // Pure Node.js — no bash/shell dependency.
118
+ import{existsSync,readdirSync,statSync,symlinkSync,lstatSync,unlinkSync,readFileSync}from"node:fs";
119
+ import{dirname,join,resolve,sep}from"node:path";
120
+ import{homedir}from"node:os";
121
+ try{
122
+ const f=resolve(homedir(),".claude","plugins","installed_plugins.json");
123
+ if(!existsSync(f))process.exit(0);
124
+ const cacheRoot=resolve(homedir(),".claude","plugins","cache");
125
+ const ip=JSON.parse(readFileSync(f,"utf-8"));
126
+ for(const[k,es]of Object.entries(ip.plugins||{})){
127
+ if(k!=="context-mode@context-mode")continue;
128
+ for(const e of es){
129
+ const p=e.installPath;
130
+ if(!p||existsSync(p))continue;
131
+ if(!resolve(p).startsWith(cacheRoot+sep))continue;
132
+ const parent=dirname(p);
133
+ if(!existsSync(parent))continue;
134
+ try{if(lstatSync(p).isSymbolicLink())unlinkSync(p)}catch{}
135
+ const dirs=readdirSync(parent).filter(d=>/^\\d+\\.\\d+/.test(d)&&statSync(join(parent,d)).isDirectory());
136
+ if(!dirs.length)continue;
137
+ dirs.sort((a,b)=>{const pa=a.split(".").map(Number),pb=b.split(".").map(Number);for(let i=0;i<3;i++){if((pa[i]||0)!==(pb[i]||0))return(pa[i]||0)-(pb[i]||0)}return 0});
138
+ try{symlinkSync(join(parent,dirs[dirs.length-1]),p,process.platform==="win32"?"junction":undefined)}catch{}
139
+ }
140
+ }
141
+ }catch{}
142
+ `;
143
+ writeFileSync(healHookPath, healScript, { mode: 0o755 });
144
+ }
145
+ // Register the hook in ~/.claude/settings.json (Claude Code doesn't auto-discover hook files)
146
+ const settingsPath = resolve(homedir(), ".claude", "settings.json");
147
+ if (existsSync(settingsPath)) {
148
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
149
+ const hooks = settings.hooks ?? {};
150
+ const sessionStart = hooks.SessionStart ?? [];
151
+ const alreadyRegistered = sessionStart.some((h) =>
152
+ h.hooks?.some((hh) => hh.command?.includes("context-mode-cache-heal")),
153
+ );
154
+ if (!alreadyRegistered) {
155
+ sessionStart.push({
156
+ hooks: [{ type: "command", command: `node ${healHookPath}` }],
157
+ });
158
+ hooks.SessionStart = sessionStart;
159
+ settings.hooks = hooks;
160
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
161
+ }
162
+ }
163
+ } catch { /* best effort */ }
164
+
82
165
  // Ensure native dependencies + ABI compatibility (shared with hooks via ensure-deps.mjs)
83
166
  // ensure-deps handles better-sqlite3 install + ABI cache/rebuild automatically (#148, #203)
84
167
  import "./hooks/ensure-deps.mjs";