@sdsrs/code-graph 0.101.0 → 0.103.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/LICENSE +21 -0
- package/bin/cli.js +31 -8
- package/claude-plugin/.claude-plugin/plugin.json +1 -1
- package/claude-plugin/scripts/adopt.js +46 -3
- package/claude-plugin/scripts/auto-update.js +143 -24
- package/claude-plugin/scripts/doctor.js +32 -0
- package/claude-plugin/scripts/find-binary.js +114 -53
- package/claude-plugin/scripts/install-lock.js +48 -0
- package/claude-plugin/scripts/launcher-install.js +146 -0
- package/claude-plugin/scripts/lifecycle.js +189 -28
- package/claude-plugin/scripts/mcp-launcher.js +75 -62
- package/claude-plugin/scripts/mcp-stub.js +25 -3
- package/claude-plugin/scripts/npm-exec.js +15 -0
- package/claude-plugin/scripts/session-init.js +15 -6
- package/claude-plugin/scripts/statusline.js +29 -5
- package/claude-plugin/scripts/version-utils.js +42 -3
- package/package.json +6 -6
|
@@ -18,6 +18,13 @@ const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
|
|
|
18
18
|
const PLUGIN_ROOT = path.resolve(__dirname, '..');
|
|
19
19
|
const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
|
|
20
20
|
const REGISTRY_FILE = path.join(CACHE_DIR, 'statusline-registry.json');
|
|
21
|
+
// Written by the launcher's background install when ITS `npm install -g` step
|
|
22
|
+
// introduced the global shell + platform packages. Uninstall only removes
|
|
23
|
+
// global packages it can prove the plugin installed (marker present) or when
|
|
24
|
+
// the user passes --purge-global — a deliberate user install is never yanked.
|
|
25
|
+
const GLOBAL_INSTALL_MARKER = path.join(CACHE_DIR, 'global-install-marker.json');
|
|
26
|
+
const INSTALL_LOCK_FILE = path.join(CACHE_DIR, 'install.lock');
|
|
27
|
+
const SHELL_PKG = '@sdsrs/code-graph';
|
|
21
28
|
|
|
22
29
|
// Lazy resolvers — Claude Code's config dir can be overridden by CLAUDE_CONFIG_DIR
|
|
23
30
|
// (multi-account isolation). Re-read every call so test subprocesses with a
|
|
@@ -190,25 +197,41 @@ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
|
|
|
190
197
|
return !hasInstalledPluginRecord();
|
|
191
198
|
}
|
|
192
199
|
|
|
193
|
-
function detachStatuslineIntegration(settings) {
|
|
200
|
+
function detachStatuslineIntegration(settings, { compositeDoomed = true } = {}) {
|
|
194
201
|
let settingsChanged = false;
|
|
195
202
|
|
|
196
203
|
unregisterStatuslineProvider('code-graph');
|
|
197
|
-
const
|
|
204
|
+
const registry = readRegistry();
|
|
205
|
+
const previous = registry.find(p => p.id === '_previous' && p.command);
|
|
206
|
+
// Third-party providers registered through our registry (e.g. gsd). They
|
|
207
|
+
// must not be silently orphaned: with the composite gone from settings
|
|
208
|
+
// their segments stop rendering while the registry entries dangle.
|
|
209
|
+
const thirdParty = registry.filter(p => p.id !== '_previous' && p.id !== 'code-graph' && p.command);
|
|
198
210
|
|
|
199
211
|
// If our composite is still configured while the plugin is disabled/uninstalled,
|
|
200
|
-
//
|
|
201
|
-
// truly stops affecting Claude Code.
|
|
212
|
+
// stop affecting Claude Code — but keep surviving third parties rendering.
|
|
202
213
|
if (isOurComposite(settings)) {
|
|
203
|
-
if (
|
|
214
|
+
if (thirdParty.length > 0 && !compositeDoomed) {
|
|
215
|
+
// Temporary disable: the composite script survives on disk and keeps
|
|
216
|
+
// rendering the remaining providers — only our segment was unregistered.
|
|
217
|
+
} else if (thirdParty.length > 0) {
|
|
218
|
+
// Genuine uninstall: our composite runner dies with the plugin cache.
|
|
219
|
+
// Hand the slot to the first surviving third-party provider; the rest
|
|
220
|
+
// stay listed in the registry backup for manual re-wiring.
|
|
221
|
+
settings.statusLine = { type: 'command', command: thirdParty[0].command };
|
|
222
|
+
settingsChanged = true;
|
|
223
|
+
} else if (previous) {
|
|
204
224
|
settings.statusLine = { type: 'command', command: previous.command };
|
|
225
|
+
settingsChanged = true;
|
|
205
226
|
} else {
|
|
206
227
|
delete settings.statusLine;
|
|
228
|
+
settingsChanged = true;
|
|
207
229
|
}
|
|
208
|
-
settingsChanged = true;
|
|
209
230
|
}
|
|
210
231
|
|
|
211
|
-
|
|
232
|
+
// _previous only becomes removable once no third party still relies on the
|
|
233
|
+
// registry file (writeRegistry unlinks primary+backup when emptied).
|
|
234
|
+
if (thirdParty.length === 0) unregisterStatuslineProvider('_previous');
|
|
212
235
|
return settingsChanged;
|
|
213
236
|
}
|
|
214
237
|
|
|
@@ -218,13 +241,25 @@ function cleanupDisabledStatusline() {
|
|
|
218
241
|
return { cleaned: false, settingsChanged: false };
|
|
219
242
|
}
|
|
220
243
|
|
|
221
|
-
|
|
244
|
+
// Decide BEFORE mutating: isPluginUninstalled reads the same composite/
|
|
245
|
+
// registry markers detachStatuslineIntegration is about to remove.
|
|
246
|
+
const uninstalled = isPluginUninstalled(settings);
|
|
247
|
+
|
|
248
|
+
let settingsChanged = detachStatuslineIntegration(settings, { compositeDoomed: uninstalled });
|
|
222
249
|
if (removeHooksFromSettings(settings)) settingsChanged = true;
|
|
223
250
|
if (settingsChanged) {
|
|
224
251
|
writeJsonAtomic(settingsPath(), settings);
|
|
225
252
|
}
|
|
226
253
|
|
|
227
|
-
|
|
254
|
+
// Genuine uninstall (not a temporary disable): reclaim ~/.cache/code-graph
|
|
255
|
+
// too. This statusline-render path is the ONLY plugin code guaranteed to
|
|
256
|
+
// still run after `/plugin uninstall` — Claude Code stops loading the
|
|
257
|
+
// plugin's hooks.json, so the SessionStart teardown in session-init.js never
|
|
258
|
+
// fires post-uninstall. Without this, the ~40MB cached binary leaked forever.
|
|
259
|
+
let cacheRemoved = false;
|
|
260
|
+
if (uninstalled) cacheRemoved = removeCacheResidue();
|
|
261
|
+
|
|
262
|
+
return { cleaned: true, settingsChanged, cacheRemoved };
|
|
228
263
|
}
|
|
229
264
|
|
|
230
265
|
// --- Scope Conflict Detection ---
|
|
@@ -374,11 +409,20 @@ function removeHooksFromSettings(settings) {
|
|
|
374
409
|
|
|
375
410
|
function buildSettingsHookEntries() {
|
|
376
411
|
const root = PLUGIN_ROOT;
|
|
377
|
-
const scriptCmd = (name, timeout) =>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
412
|
+
const scriptCmd = (name, timeout) => {
|
|
413
|
+
const script = path.join(root, 'scripts', name);
|
|
414
|
+
// POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
|
|
415
|
+
// plugin-cache dir before our statusline teardown gets to strip these
|
|
416
|
+
// entries — in that window every Edit/Bash/Read/prompt errored on a dead
|
|
417
|
+
// path. The `if` form preserves node's own exit code (PreToolUse deny =
|
|
418
|
+
// exit 2); `&& … || exit 0` would swallow it. Windows keeps the bare
|
|
419
|
+
// command — the hook shell there is not reliably cmd, so `if exist`
|
|
420
|
+
// can't be assumed.
|
|
421
|
+
const command = process.platform === 'win32'
|
|
422
|
+
? `node "${script}"`
|
|
423
|
+
: `if [ -f "${script}" ]; then node "${script}"; fi`;
|
|
424
|
+
return { type: 'command', command, timeout };
|
|
425
|
+
};
|
|
382
426
|
|
|
383
427
|
return {
|
|
384
428
|
PreToolUse: [
|
|
@@ -585,7 +629,7 @@ function verifyHooksFire({ hooks, env, timeoutMs = 4000, tmpBase } = {}) {
|
|
|
585
629
|
|
|
586
630
|
// --- Install (idempotent) ---
|
|
587
631
|
|
|
588
|
-
function install() {
|
|
632
|
+
function install({ reclaimStatusline = false } = {}) {
|
|
589
633
|
const version = getPluginVersion();
|
|
590
634
|
const manifest = readManifest();
|
|
591
635
|
const settings = readJson(settingsPath()) || {};
|
|
@@ -601,14 +645,40 @@ function install() {
|
|
|
601
645
|
// b. Register code-graph as a provider
|
|
602
646
|
// c. Set statusLine to composite script
|
|
603
647
|
if (!isOurComposite(settings)) {
|
|
604
|
-
//
|
|
605
|
-
|
|
606
|
-
|
|
648
|
+
// Displacement tracking: we held the slot before (manifest.config.statusLine)
|
|
649
|
+
// but a foreign command sits there now — either another slot-claiming plugin
|
|
650
|
+
// (whose own self-heal re-takes it just like ours would → statusline
|
|
651
|
+
// ping-pong every session) or the user's deliberate choice. Either way,
|
|
652
|
+
// silently re-claiming forever is wrong: after >2 observed displacements
|
|
653
|
+
// stand down — stay registered as a provider, leave the slot alone.
|
|
654
|
+
// Explicit `lifecycle.js install` (or CODE_GRAPH_FORCE_STATUSLINE=1)
|
|
655
|
+
// resets the counter and re-claims.
|
|
656
|
+
const currentCmd = settings.statusLine && settings.statusLine.command;
|
|
657
|
+
if (reclaimStatusline || process.env.CODE_GRAPH_FORCE_STATUSLINE === '1') {
|
|
658
|
+
manifest.config.statuslineDisplaced = 0;
|
|
659
|
+
} else if (manifest.config.statusLine === true && currentCmd) {
|
|
660
|
+
manifest.config.statuslineDisplaced = (manifest.config.statuslineDisplaced || 0) + 1;
|
|
661
|
+
}
|
|
662
|
+
if ((manifest.config.statuslineDisplaced || 0) > 2) {
|
|
663
|
+
if (manifest.config.statusLine === true) {
|
|
664
|
+
// Transition into stand-down exactly once: release claimed ownership
|
|
665
|
+
// (stops the counter) and leave a breadcrumb.
|
|
666
|
+
manifest.config.statusLine = false;
|
|
667
|
+
process.stderr.write(
|
|
668
|
+
'[code-graph] statusLine slot keeps being re-claimed by another provider — standing down.\n' +
|
|
669
|
+
' Re-claim: CODE_GRAPH_FORCE_STATUSLINE=1 or `node lifecycle.js install`\n'
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
// Preserve existing statusline as first provider
|
|
674
|
+
if (currentCmd) {
|
|
675
|
+
registerStatuslineProvider('_previous', currentCmd, true);
|
|
676
|
+
}
|
|
677
|
+
// Set composite as the statusLine
|
|
678
|
+
settings.statusLine = { type: 'command', command: compositeCommand() };
|
|
679
|
+
settingsChanged = true;
|
|
680
|
+
manifest.config.statusLine = true;
|
|
607
681
|
}
|
|
608
|
-
// Set composite as the statusLine
|
|
609
|
-
settings.statusLine = { type: 'command', command: compositeCommand() };
|
|
610
|
-
settingsChanged = true;
|
|
611
|
-
manifest.config.statusLine = true;
|
|
612
682
|
} else {
|
|
613
683
|
// Composite exists — ensure path is correct (may have been polluted by env leak)
|
|
614
684
|
const cmd = compositeCommand();
|
|
@@ -616,6 +686,9 @@ function install() {
|
|
|
616
686
|
settings.statusLine.command = cmd;
|
|
617
687
|
settingsChanged = true;
|
|
618
688
|
}
|
|
689
|
+
// We hold the slot — any displacement episode is over.
|
|
690
|
+
if (manifest.config.statuslineDisplaced) manifest.config.statuslineDisplaced = 0;
|
|
691
|
+
manifest.config.statusLine = true;
|
|
619
692
|
}
|
|
620
693
|
|
|
621
694
|
// Register code-graph provider
|
|
@@ -648,7 +721,30 @@ function install() {
|
|
|
648
721
|
|
|
649
722
|
// --- Uninstall (clean all config) ---
|
|
650
723
|
|
|
651
|
-
|
|
724
|
+
/** Which of our npm packages exist at a global top level right now. */
|
|
725
|
+
function installedGlobalPkgs() {
|
|
726
|
+
const { globalNodeModulesCandidates, PLATFORM_PKG } = require('./find-binary');
|
|
727
|
+
const found = [];
|
|
728
|
+
for (const name of [SHELL_PKG, PLATFORM_PKG]) {
|
|
729
|
+
for (const root of globalNodeModulesCandidates()) {
|
|
730
|
+
if (fs.existsSync(path.join(root, name, 'package.json'))) { found.push(name); break; }
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return found;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function defaultRunNpm(args) {
|
|
737
|
+
const { spawnSync } = require('child_process');
|
|
738
|
+
const { npmSpawnOpts } = require('./npm-exec');
|
|
739
|
+
try {
|
|
740
|
+
const r = spawnSync('npm', args, npmSpawnOpts({
|
|
741
|
+
timeout: 120000, stdio: 'pipe', encoding: 'utf8',
|
|
742
|
+
}));
|
|
743
|
+
return !r.error && r.status === 0;
|
|
744
|
+
} catch { return false; }
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
|
|
652
748
|
const settings = readJson(settingsPath());
|
|
653
749
|
let settingsChanged = false;
|
|
654
750
|
|
|
@@ -692,6 +788,44 @@ function uninstall() {
|
|
|
692
788
|
if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
|
|
693
789
|
}
|
|
694
790
|
|
|
791
|
+
// 5.5. Global npm packages + adoption inventory — read BEFORE step 6 wipes
|
|
792
|
+
// CACHE_DIR (both the install marker and the adopted-projects registry live
|
|
793
|
+
// there). The launcher's background install runs `npm install -g` on the
|
|
794
|
+
// user's behalf; nothing on the Claude Code uninstall path ever removes those
|
|
795
|
+
// packages (~40MB platform binary + CLI shim left on PATH forever).
|
|
796
|
+
const pluginInstalledGlobals = !!readJson(GLOBAL_INSTALL_MARKER);
|
|
797
|
+
let adoptedProjects = [];
|
|
798
|
+
try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* POSIX-only helper — ok */ }
|
|
799
|
+
|
|
800
|
+
// 5.4. --unadopt-all: sweep every registered project's managed CLAUDE.md
|
|
801
|
+
// block + generated detail file (unadopt is marker-guarded, so user files
|
|
802
|
+
// are never touched; the .code-graph/ index dir is project DATA and stays —
|
|
803
|
+
// its removal is listed in the guidance instead of automated).
|
|
804
|
+
const unadopted = [];
|
|
805
|
+
if (unadoptAll && adoptedProjects.length) {
|
|
806
|
+
let unadoptFn = null;
|
|
807
|
+
try { unadoptFn = require('./adopt').unadopt; } catch { /* POSIX-only — skip */ }
|
|
808
|
+
if (unadoptFn) {
|
|
809
|
+
for (const project of adoptedProjects) {
|
|
810
|
+
try {
|
|
811
|
+
const r = unadoptFn({ cwd: project });
|
|
812
|
+
unadopted.push({ project, ok: !!(r && r.ok), cleaned: !!(r && (r.blockPruned || r.fileRemoved || r.claudeMdRemoved)) });
|
|
813
|
+
} catch (e) {
|
|
814
|
+
unadopted.push({ project, ok: false, error: (e && e.message) || String(e) });
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
try { adoptedProjects = require('./adopt').readAdoptedProjects(); } catch { /* ok */ }
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
let globalPkgsRemoved = [];
|
|
821
|
+
let globalPkgsRemaining = scanGlobalPkgs();
|
|
822
|
+
if (globalPkgsRemaining.length && (pluginInstalledGlobals || purgeGlobal)) {
|
|
823
|
+
if (runNpm(['uninstall', '-g', ...globalPkgsRemaining])) {
|
|
824
|
+
globalPkgsRemoved = globalPkgsRemaining;
|
|
825
|
+
globalPkgsRemaining = scanGlobalPkgs(); // re-scan: report only what actually survived
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
695
829
|
// 6. Remove cache directory
|
|
696
830
|
try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
|
|
697
831
|
|
|
@@ -706,7 +840,7 @@ function uninstall() {
|
|
|
706
840
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
|
|
707
841
|
}
|
|
708
842
|
|
|
709
|
-
return { settingsChanged };
|
|
843
|
+
return { settingsChanged, pluginInstalledGlobals, globalPkgsRemoved, globalPkgsRemaining, adoptedProjects, unadopted };
|
|
710
844
|
}
|
|
711
845
|
|
|
712
846
|
// --- Update (refresh config points) ---
|
|
@@ -952,6 +1086,7 @@ module.exports = {
|
|
|
952
1086
|
SETTINGS_HOOK_DESC, OUR_HOOK_SCRIPTS, OUR_DESCRIPTIONS, // v0.32.0 — for tests
|
|
953
1087
|
PLUGIN_ROOT, // v0.32.1 — for tests / consumers
|
|
954
1088
|
registerStatuslineProvider, unregisterStatuslineProvider,
|
|
1089
|
+
installedGlobalPkgs, GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE, SHELL_PKG, // uninstall residue
|
|
955
1090
|
PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
|
|
956
1091
|
settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
|
|
957
1092
|
};
|
|
@@ -960,13 +1095,39 @@ module.exports = {
|
|
|
960
1095
|
if (require.main === module) {
|
|
961
1096
|
const cmd = process.argv[2];
|
|
962
1097
|
if (cmd === 'install') {
|
|
963
|
-
|
|
1098
|
+
// Explicit CLI install = user intent: reset any statusline stand-down and re-claim.
|
|
1099
|
+
const r = install({ reclaimStatusline: true });
|
|
964
1100
|
console.log(`Installed v${r.version} | settings=${r.settingsChanged} | statusLine=${r.statusLineClaimed}`);
|
|
965
1101
|
} else if (cmd === 'uninstall') {
|
|
966
|
-
const r = uninstall(
|
|
1102
|
+
const r = uninstall({
|
|
1103
|
+
purgeGlobal: process.argv.includes('--purge-global'),
|
|
1104
|
+
unadoptAll: process.argv.includes('--unadopt-all'),
|
|
1105
|
+
});
|
|
967
1106
|
console.log(`Uninstalled | settings cleaned=${r.settingsChanged}`);
|
|
968
|
-
|
|
969
|
-
|
|
1107
|
+
if (r.unadopted.length) {
|
|
1108
|
+
const cleaned = r.unadopted.filter((u) => u.cleaned).length;
|
|
1109
|
+
console.log(` Unadopted ${cleaned}/${r.unadopted.length} registered project(s):`);
|
|
1110
|
+
for (const u of r.unadopted) {
|
|
1111
|
+
console.log(` ${u.ok ? (u.cleaned ? 'cleaned' : 'nothing-to-clean') : `FAILED (${u.error || 'unknown'})`} ${u.project}`);
|
|
1112
|
+
}
|
|
1113
|
+
console.log(' Their .code-graph/ index dirs are project data — remove per project with `rm -rf .code-graph` if unwanted.');
|
|
1114
|
+
}
|
|
1115
|
+
if (r.globalPkgsRemoved.length) {
|
|
1116
|
+
console.log(` Removed global npm package(s): ${r.globalPkgsRemoved.join(', ')}`);
|
|
1117
|
+
}
|
|
1118
|
+
if (r.globalPkgsRemaining.length) {
|
|
1119
|
+
console.log(` Global npm package(s) still installed: ${r.globalPkgsRemaining.join(', ')}`);
|
|
1120
|
+
console.log(` Remove with: npm uninstall -g ${r.globalPkgsRemaining.join(' ')}`);
|
|
1121
|
+
if (!r.pluginInstalledGlobals) {
|
|
1122
|
+
console.log(' (left in place: no plugin-install marker, so they may be your own install; --purge-global forces removal)');
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
if (r.adoptedProjects.length) {
|
|
1126
|
+
console.log(' Adopted project(s) still carrying a managed CLAUDE.md block + .code-graph/ index:');
|
|
1127
|
+
for (const p of r.adoptedProjects) console.log(` ${p}`);
|
|
1128
|
+
console.log(' Clean all at once: re-run with --unadopt-all, or per project `code-graph-mcp unadopt` + `rm -rf .code-graph`.');
|
|
1129
|
+
}
|
|
1130
|
+
console.log(' Note: also run `/plugin uninstall code-graph-mcp` inside Claude Code to sync its UI state.');
|
|
970
1131
|
} else if (cmd === 'update') {
|
|
971
1132
|
const r = update();
|
|
972
1133
|
console.log(`Updated ${r.oldVersion} → ${r.version} | settings=${r.settingsChanged}`);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* Used by .mcp.json so the plugin controls binary discovery instead of
|
|
8
8
|
* relying on the binary being in PATH.
|
|
9
9
|
*/
|
|
10
|
-
const { spawn
|
|
10
|
+
const { spawn } = require('child_process');
|
|
11
11
|
const path = require('path');
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const { isNonProjectCwd } = require('./project-detect');
|
|
@@ -86,66 +86,15 @@ if (process.env.CODE_GRAPH_FORCE_PLUGIN_MCP !== '1' && isNonProjectCwd(process.c
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
const { findBinary, clearCache, unsupportedPlatformHint } = require('./find-binary');
|
|
89
|
+
const { installBinaryInBackground } = require('./launcher-install');
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
const binary = findBinary();
|
|
91
92
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
|
|
98
|
-
} catch { /* use latest */ }
|
|
99
|
-
|
|
100
|
-
process.stderr.write(`[code-graph] Binary not found, installing @sdsrs/code-graph@${version}...\n`);
|
|
101
|
-
const npmResult = spawnSync('npm', ['install', '-g', `@sdsrs/code-graph@${version}`], {
|
|
102
|
-
timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8',
|
|
103
|
-
});
|
|
104
|
-
if (npmResult.error || npmResult.status !== 0) {
|
|
105
|
-
process.stderr.write('[code-graph] npm install failed.\n');
|
|
106
|
-
if (npmResult.stderr) {
|
|
107
|
-
process.stderr.write(npmResult.stderr.trim().split('\n').map(l => `[code-graph][npm] ${l}\n`).join(''));
|
|
108
|
-
}
|
|
109
|
-
} else {
|
|
110
|
-
clearCache();
|
|
111
|
-
binary = findBinary();
|
|
112
|
-
if (binary) {
|
|
113
|
-
process.stderr.write(`[code-graph] Installed at ${binary}\n`);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Fallback: npm install may have succeeded but optionalDependencies for the
|
|
119
|
-
// platform binary can fail silently (npm tolerates OS-mismatch + flaky
|
|
120
|
-
// registry). Pull the platform binary directly from the GitHub release.
|
|
121
|
-
//
|
|
122
|
-
// --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
|
|
123
|
-
// marketplace ships the full repo (including Cargo.toml at the workspace root),
|
|
124
|
-
// so dev-mode heuristics that look for Cargo.toml were misclassifying every
|
|
125
|
-
// marketplace install as dev mode and skipping this fallback (issue #12).
|
|
126
|
-
if (!binary) {
|
|
127
|
-
process.stderr.write('[code-graph] Falling back to GitHub release download...\n');
|
|
128
|
-
const result = spawnSync(
|
|
129
|
-
process.execPath,
|
|
130
|
-
[path.join(__dirname, 'auto-update.js'), '--silent', '--install-missing'],
|
|
131
|
-
{ timeout: 90000, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }
|
|
132
|
-
);
|
|
133
|
-
if (result.stderr && result.stderr.trim()) {
|
|
134
|
-
process.stderr.write(result.stderr.trim().split('\n').map(l => `[code-graph][auto-update] ${l}\n`).join(''));
|
|
135
|
-
}
|
|
136
|
-
if (result.error) {
|
|
137
|
-
process.stderr.write(`[code-graph] auto-update spawn failed: ${result.error.message}\n`);
|
|
138
|
-
} else if (result.status !== 0) {
|
|
139
|
-
process.stderr.write(`[code-graph] auto-update exited with status ${result.status}\n`);
|
|
140
|
-
}
|
|
141
|
-
clearCache();
|
|
142
|
-
binary = findBinary();
|
|
143
|
-
if (binary) {
|
|
144
|
-
process.stderr.write(`[code-graph] Installed at ${binary}\n`);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (!binary) {
|
|
93
|
+
// Manual-install guidance, printed when the background install chain exhausts
|
|
94
|
+
// both steps without producing a binary. Unlike the old sync path this does NOT
|
|
95
|
+
// exit: the upgradeable stub stays connected (0 tools), so a manual
|
|
96
|
+
// `npm install -g` mid-session still upgrades the live connection.
|
|
97
|
+
function printManualInstallHints() {
|
|
149
98
|
const installedViaMarketplace = fs.existsSync(
|
|
150
99
|
path.join(__dirname, '..', '.claude-plugin', 'plugin.json')
|
|
151
100
|
);
|
|
@@ -155,9 +104,9 @@ if (!binary) {
|
|
|
155
104
|
// npm package does not exist, so the generic "npm install @sdsrs/code-graph-<plat>-<arch>"
|
|
156
105
|
// suggestion below would point at a nonexistent package. Show the source/emulation hint.
|
|
157
106
|
process.stderr.write('[code-graph] Binary not found.\n' + platformHint + '\n');
|
|
158
|
-
|
|
107
|
+
return;
|
|
159
108
|
}
|
|
160
|
-
process.stderr.write('[code-graph] Binary
|
|
109
|
+
process.stderr.write('[code-graph] Binary install failed. Install manually:\n');
|
|
161
110
|
if (installedViaMarketplace) {
|
|
162
111
|
process.stderr.write(
|
|
163
112
|
' # Re-install the plugin via Claude Code marketplace:\n' +
|
|
@@ -171,7 +120,71 @@ if (!binary) {
|
|
|
171
120
|
' npm install -g @sdsrs/code-graph\n' +
|
|
172
121
|
' npm install -g @sdsrs/code-graph-' + process.platform + '-' + process.arch + '\n'
|
|
173
122
|
);
|
|
174
|
-
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// --- Missing binary: answer the handshake NOW, install in the background ----
|
|
126
|
+
// The old chain ran `npm install -g` (60s timeout) and the GitHub-release
|
|
127
|
+
// fallback (90s) SYNCHRONOUSLY before answering any MCP JSON-RPC. Claude
|
|
128
|
+
// Code's connect timeout is 30s, so a cold install always presented as
|
|
129
|
+
// "MCP server connection timed out after 30000ms" and the tools only appeared
|
|
130
|
+
// on a later reconnect. Serve the upgradeable 0-tool stub first (initialize is
|
|
131
|
+
// answered instantly), run the same install chain in the background, and hand
|
|
132
|
+
// the live connection to the real binary via the same upgrade mechanism the
|
|
133
|
+
// non-project gate uses — no reconnect, no restart.
|
|
134
|
+
//
|
|
135
|
+
// --install-missing bypasses auto-update.js's isDevMode() short-circuit. The
|
|
136
|
+
// marketplace ships the full repo (including Cargo.toml at the workspace root),
|
|
137
|
+
// so dev-mode heuristics that look for Cargo.toml were misclassifying every
|
|
138
|
+
// marketplace install as dev mode and skipping this fallback (issue #12).
|
|
139
|
+
if (!binary) {
|
|
140
|
+
let version = 'latest';
|
|
141
|
+
try {
|
|
142
|
+
const pj = path.join(__dirname, '..', '.claude-plugin', 'plugin.json');
|
|
143
|
+
version = JSON.parse(fs.readFileSync(pj, 'utf8')).version || 'latest';
|
|
144
|
+
} catch { /* use latest */ }
|
|
145
|
+
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`[code-graph] Binary not found — serving 0-tool stub while installing ` +
|
|
148
|
+
`@sdsrs/code-graph@${version} in the background (tools appear when it lands)...\n`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
const stub = serveEmptyMcpStub({
|
|
152
|
+
upgrade: {
|
|
153
|
+
// Each probe is a full discovery walk (incl. `npm root -g`, up to 2s);
|
|
154
|
+
// offline the binary never appears, so back the poll off toward 60s.
|
|
155
|
+
// The install chain's onInstalled nudge below still upgrades instantly.
|
|
156
|
+
backoff: true,
|
|
157
|
+
shouldUpgrade: () => !!findBinary(),
|
|
158
|
+
spawnReal: () => {
|
|
159
|
+
const bin = findBinary();
|
|
160
|
+
if (!bin) return null;
|
|
161
|
+
process.stderr.write(`[code-graph] binary ready at ${bin} — upgrading plugin MCP to real tools (restart Claude Code for full tool steering)\n`);
|
|
162
|
+
return spawn(bin, ['serve'], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const { GLOBAL_INSTALL_MARKER, INSTALL_LOCK_FILE } = require('./lifecycle');
|
|
168
|
+
installBinaryInBackground({
|
|
169
|
+
version,
|
|
170
|
+
findBinary,
|
|
171
|
+
clearCache,
|
|
172
|
+
// Nudge the handover immediately instead of waiting for the stub's next poll.
|
|
173
|
+
onInstalled: () => stub.attemptUpgrade(),
|
|
174
|
+
onFailed: () => printManualInstallHints(),
|
|
175
|
+
// Marker: this npm install was OURS, so lifecycle.js uninstall knows it
|
|
176
|
+
// owns removing the global packages (never yanks a user's own install).
|
|
177
|
+
recordGlobalInstall: () => {
|
|
178
|
+
fs.mkdirSync(path.dirname(GLOBAL_INSTALL_MARKER), { recursive: true });
|
|
179
|
+
fs.writeFileSync(GLOBAL_INSTALL_MARKER, JSON.stringify({
|
|
180
|
+
installedBy: 'code-graph-mcp launcher', version, at: new Date().toISOString(),
|
|
181
|
+
}, null, 2) + '\n');
|
|
182
|
+
},
|
|
183
|
+
// Serialize against other cold sessions + auto-update (parallel global npm
|
|
184
|
+
// installs corrupt the shared prefix).
|
|
185
|
+
lockPath: INSTALL_LOCK_FILE,
|
|
186
|
+
});
|
|
187
|
+
return; // top-level function scope of mcp-launcher.js
|
|
175
188
|
}
|
|
176
189
|
|
|
177
190
|
// Pre-spawn: verify binary is executable (catches macOS quarantine, permission issues)
|
|
@@ -52,6 +52,8 @@ function serveEmptyMcpStub(opts = {}) {
|
|
|
52
52
|
const queuedForChild = []; // client lines seen after spawn, before child is ready
|
|
53
53
|
let poller = null;
|
|
54
54
|
let upgradeFailures = 0; // consecutive failed upgrade attempts (see noteUpgradeFailure)
|
|
55
|
+
let backoffTicks = 0; // poll ticks to skip before the next probe (upgrade.backoff)
|
|
56
|
+
let backoffNext = 1;
|
|
55
57
|
|
|
56
58
|
function writeCc(obj) { output.write(JSON.stringify(obj) + '\n'); }
|
|
57
59
|
|
|
@@ -133,7 +135,20 @@ function serveEmptyMcpStub(opts = {}) {
|
|
|
133
135
|
|
|
134
136
|
function attemptUpgrade() {
|
|
135
137
|
if (child || !upgrade) return;
|
|
136
|
-
if (!upgrade.shouldUpgrade())
|
|
138
|
+
if (!upgrade.shouldUpgrade()) {
|
|
139
|
+
// Not upgradable yet — not a failure. But when the probe itself is
|
|
140
|
+
// expensive (missing-binary: full discovery walk incl. `npm root -g`,
|
|
141
|
+
// up to 2s), a flat 4s cadence for a whole offline session is pure
|
|
142
|
+
// subprocess churn. With { backoff:true } skip a doubling number of
|
|
143
|
+
// ticks between probes, capped near 60s; a manual attemptUpgrade()
|
|
144
|
+
// nudge (install chain's onInstalled) still probes immediately.
|
|
145
|
+
if (upgrade.backoff) {
|
|
146
|
+
const pollMs = upgrade.pollMs || DEFAULT_POLL_MS;
|
|
147
|
+
backoffTicks = backoffNext;
|
|
148
|
+
backoffNext = Math.min(backoffNext * 2, Math.max(1, Math.floor(60000 / pollMs) - 1));
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
137
152
|
const spawned = upgrade.spawnReal();
|
|
138
153
|
if (!spawned) { noteUpgradeFailure('binary-unresolved'); return; }
|
|
139
154
|
if (poller) { clearIv(poller); poller = null; }
|
|
@@ -141,6 +156,13 @@ function serveEmptyMcpStub(opts = {}) {
|
|
|
141
156
|
beginProxy();
|
|
142
157
|
}
|
|
143
158
|
|
|
159
|
+
// Poll-timer tick: honors the backoff skip counter; the exported
|
|
160
|
+
// attemptUpgrade stays direct so external nudges are never delayed.
|
|
161
|
+
function pollTick() {
|
|
162
|
+
if (backoffTicks > 0) { backoffTicks--; return; }
|
|
163
|
+
attemptUpgrade();
|
|
164
|
+
}
|
|
165
|
+
|
|
144
166
|
function fallBackToStub(reason) {
|
|
145
167
|
// Child spawned but died/errored before it was ready: answer anything the
|
|
146
168
|
// client queued (so it doesn't hang on those ids), resume polling, and count
|
|
@@ -153,7 +175,7 @@ function serveEmptyMcpStub(opts = {}) {
|
|
|
153
175
|
try { const req = JSON.parse(line); if (req && typeof req.method === 'string') answerAsStub(req); }
|
|
154
176
|
catch { /* ignore */ }
|
|
155
177
|
}
|
|
156
|
-
if (upgrade && !poller) poller = setIv(
|
|
178
|
+
if (upgrade && !poller) poller = setIv(pollTick, upgrade.pollMs || DEFAULT_POLL_MS);
|
|
157
179
|
noteUpgradeFailure(reason);
|
|
158
180
|
}
|
|
159
181
|
|
|
@@ -206,7 +228,7 @@ function serveEmptyMcpStub(opts = {}) {
|
|
|
206
228
|
});
|
|
207
229
|
}
|
|
208
230
|
|
|
209
|
-
if (upgrade) poller = setIv(
|
|
231
|
+
if (upgrade) poller = setIv(pollTick, upgrade.pollMs || DEFAULT_POLL_MS);
|
|
210
232
|
|
|
211
233
|
return { attemptUpgrade, _state: () => ({ hasChild: !!child, childReady }) };
|
|
212
234
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// npm is `npm.cmd` on Windows: child_process spawn/execFileSync cannot exec a
|
|
3
|
+
// .cmd without a shell (and Node >= 18.20 throws EINVAL spawning .cmd directly
|
|
4
|
+
// as a CVE-2024-27980 mitigation). Every bare `spawn('npm', ...)` in the
|
|
5
|
+
// install/update flow therefore silently ENOENT'd on Windows while
|
|
6
|
+
// commandExists('npm') (via `where`) said npm was present. All args routed
|
|
7
|
+
// through here are fixed flags / package specs — shell-quoting-safe.
|
|
8
|
+
const NPM_NEEDS_SHELL = process.platform === 'win32';
|
|
9
|
+
|
|
10
|
+
/** Merge shell:true into spawn/exec options when the platform needs it. */
|
|
11
|
+
function npmSpawnOpts(opts = {}) {
|
|
12
|
+
return NPM_NEEDS_SHELL ? { ...opts, shell: true } : opts;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { npmSpawnOpts, NPM_NEEDS_SHELL };
|
|
@@ -480,10 +480,13 @@ function runSessionInit({ source } = {}) {
|
|
|
480
480
|
cleanupDisabledStatusline();
|
|
481
481
|
// Genuine uninstall (not a temporary disable) leaves residue the settings-only
|
|
482
482
|
// self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
|
|
483
|
-
// current project's CLAUDE.md adoption block. CC fires no uninstall hook,
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
483
|
+
// current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
|
|
484
|
+
// stops loading this plugin's hooks.json the moment the install record is gone —
|
|
485
|
+
// so after a real `/plugin uninstall` this SessionStart usually never runs again.
|
|
486
|
+
// The reachable teardown is cleanupDisabledStatusline() via the composite
|
|
487
|
+
// statusline (still wired in settings.json); it removes the cache residue too.
|
|
488
|
+
// This branch remains for the disable→uninstall-while-running edge and as the
|
|
489
|
+
// only place project unadoption can happen automatically.
|
|
487
490
|
let teardown = null;
|
|
488
491
|
if (uninstalled) {
|
|
489
492
|
const cacheRemoved = removeCacheResidue();
|
|
@@ -550,9 +553,15 @@ function runSessionInit({ source } = {}) {
|
|
|
550
553
|
}
|
|
551
554
|
if (autoAdopt.attempted && autoAdopt.result && autoAdopt.result.ok) {
|
|
552
555
|
if (autoAdopt.reason === 'refreshed') {
|
|
556
|
+
// Name BOTH refreshed surfaces: the drift-refresh fully overwrites the
|
|
557
|
+
// generated detail doc, so a user who hand-edited it must learn why
|
|
558
|
+
// their edits vanished and how to lock the file.
|
|
559
|
+
const detailNote = autoAdopt.result.detailWritten
|
|
560
|
+
? ' + .claude/plugin_code_graph_mcp.md (manual edits to that generated file are overwritten)'
|
|
561
|
+
: '';
|
|
553
562
|
process.stderr.write(
|
|
554
|
-
|
|
555
|
-
' Lock
|
|
563
|
+
`[code-graph] Refreshed CLAUDE.md decision block to latest shipped version${detailNote}.\n` +
|
|
564
|
+
' Lock files: CODE_GRAPH_NO_TEMPLATE_REFRESH=1 in ~/.claude/settings.json env\n'
|
|
556
565
|
);
|
|
557
566
|
} else {
|
|
558
567
|
process.stderr.write(
|
|
@@ -57,13 +57,31 @@ const codeGraphDir = path.join(root, '.code-graph');
|
|
|
57
57
|
// Check for background indexing progress file first
|
|
58
58
|
const progressFile = path.join(codeGraphDir, 'indexing-status.json');
|
|
59
59
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// Staleness gate: the file is normally deleted by the server's IndexGuard, but
|
|
61
|
+
// a killed process (session exit, SIGKILL, the 30s MCP connect-timeout kill)
|
|
62
|
+
// skips Drop, and the orphan would pin "indexing N/M" here forever. A LIVE
|
|
63
|
+
// indexer heartbeats the file at least once per batch and per finalize phase,
|
|
64
|
+
// so an old mtime proves no indexer is writing it: ignore the file and fall
|
|
65
|
+
// through to the health check. (Mirrors INDEXING_STATUS_STALE_SECS in
|
|
66
|
+
// src/indexer/pipeline/mod.rs, which drives server/CLI-side stale cleanup.)
|
|
67
|
+
const INDEXING_STALE_MS = 120000;
|
|
68
|
+
const fresh = (Date.now() - fs.statSync(progressFile).mtimeMs) < INDEXING_STALE_MS;
|
|
69
|
+
const p = fresh ? JSON.parse(fs.readFileSync(progressFile, 'utf8')) : null;
|
|
70
|
+
if (p && p.s === 'indexing' && p.t > 0) {
|
|
71
|
+
// floor, not round: skipped files (parse errors, oversized) keep d below t
|
|
72
|
+
// even in the terminal progress write, and rounding displayed that state as
|
|
73
|
+
// a confusing stuck "100%".
|
|
74
|
+
const pct = Math.floor((p.d / p.t) * 100);
|
|
64
75
|
process.stdout.write(`code-graph: \u21BB indexing ${p.d}/${p.t} (${pct}%)`);
|
|
65
76
|
process.exit(0);
|
|
66
77
|
}
|
|
78
|
+
if (p && p.s === 'finalizing' && p.t > 0) {
|
|
79
|
+
// Post-batch full-graph phases (context strings, import bind/prune, ANALYZE):
|
|
80
|
+
// the file count no longer moves, so show an explicit phase label instead of
|
|
81
|
+
// a frozen-looking counter.
|
|
82
|
+
process.stdout.write(`code-graph: ↻ finalizing ${p.d}/${p.t}`);
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
67
85
|
} catch { /* no progress file or parse error — continue to health check */ }
|
|
68
86
|
|
|
69
87
|
// No indexing in progress — show normal health status
|
|
@@ -131,8 +149,14 @@ function statusUnavailable(errText) {
|
|
|
131
149
|
let report = null;
|
|
132
150
|
let errText = '';
|
|
133
151
|
try {
|
|
152
|
+
// 1500ms, NOT 3000ms: the composite wrapper kills this whole provider at
|
|
153
|
+
// 3000ms (statusline-composite.js runProvider), so an inner budget equal to
|
|
154
|
+
// the outer one guaranteed the OUTER timeout fired first on a slow
|
|
155
|
+
// health-check (e.g. CPU saturated by the embedding backfill) and the segment
|
|
156
|
+
// silently vanished. Keeping the inner budget well under the outer one turns
|
|
157
|
+
// "slow health-check" into a rendered "offline"/"updating" instead of a blank.
|
|
134
158
|
report = parseReport(execFileSync(bin, ['health-check', '--format', 'json'], {
|
|
135
|
-
timeout:
|
|
159
|
+
timeout: 1500,
|
|
136
160
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
137
161
|
// Run the binary FROM the resolved root so its own project-root resolution
|
|
138
162
|
// lands on the same DB the gate above picked (a subdir cwd would otherwise
|