@sdsrs/code-graph 0.29.0 → 0.31.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.29.0",
7
+ "version": "0.31.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -269,9 +269,14 @@ const TARGET_NAME = 'plugin_code_graph_mcp.md';
269
269
  // Claude Code slug convention: every non-alphanumeric-non-hyphen char → `-`.
270
270
  // `/mnt/data_ssd/dev/proj` → `-mnt-data-ssd-dev-proj`
271
271
  // `/home/sds/.claude/x` → `-home-sds--claude-x` (double-dash from `/.`)
272
+ //
273
+ // `home` is the OS home dir (default `os.homedir()`). When `CLAUDE_CONFIG_DIR`
274
+ // is set it overrides `home/.claude`, so multi-account users (personal vs work)
275
+ // land in the directory Claude Code itself is using for `projects/`.
272
276
  function memoryDir(cwd = process.cwd(), home = os.homedir()) {
273
277
  const slug = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
274
- return path.join(home, '.claude', 'projects', slug, 'memory');
278
+ const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
279
+ return path.join(claudeDir, 'projects', slug, 'memory');
275
280
  }
276
281
 
277
282
  function escapeRegex(s) {
@@ -421,9 +426,17 @@ function needsRefresh({ cwd, home, templatePath } = {}) {
421
426
  // 检测脚本是否从 Claude Code 插件 cache 运行。
422
427
  // 走 __dirname 而非 CLAUDE_PLUGIN_ROOT — 后者在多插件共存时会互相污染
423
428
  // (见 feedback_plugin_env_isolation.md)。
429
+ // 默认匹配 `.claude/plugins/` 路径;CLAUDE_CONFIG_DIR 自定义目录时
430
+ // 走 startsWith(CLAUDE_CONFIG_DIR/plugins/) 兜底。
424
431
  function isPluginModeInstall(scriptPath = __dirname) {
425
432
  const sep = path.sep;
426
- return scriptPath.includes(`${sep}.claude${sep}plugins${sep}`);
433
+ if (scriptPath.includes(`${sep}.claude${sep}plugins${sep}`)) return true;
434
+ const envDir = process.env.CLAUDE_CONFIG_DIR;
435
+ if (envDir) {
436
+ const marker = path.join(envDir, 'plugins') + sep;
437
+ if (scriptPath.startsWith(marker)) return true;
438
+ }
439
+ return false;
427
440
  }
428
441
 
429
442
  // C' 上下文感知默认(v0.9.0):插件模式下首次 SessionStart 静默 adopt。
@@ -278,6 +278,44 @@ test('isPluginModeInstall rejects npx cache paths', () => {
278
278
  assert.strictEqual(isPluginModeInstall(npxPath), false);
279
279
  });
280
280
 
281
+ test('memoryDir honors CLAUDE_CONFIG_DIR override (multi-account isolation)', () => {
282
+ const prev = process.env.CLAUDE_CONFIG_DIR;
283
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
284
+ try {
285
+ // home arg is irrelevant when env var is set — projects live under the
286
+ // configured claude dir, not home/.claude.
287
+ assert.strictEqual(
288
+ memoryDir('/home/alice/proj', '/home/alice'),
289
+ '/home/alice/work-claude/projects/-home-alice-proj/memory'
290
+ );
291
+ } finally {
292
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
293
+ else process.env.CLAUDE_CONFIG_DIR = prev;
294
+ }
295
+ });
296
+
297
+ test('isPluginModeInstall recognizes CLAUDE_CONFIG_DIR/plugins/... paths', () => {
298
+ const prev = process.env.CLAUDE_CONFIG_DIR;
299
+ process.env.CLAUDE_CONFIG_DIR = '/home/alice/work-claude';
300
+ try {
301
+ const pluginPath = '/home/alice/work-claude/plugins/cache/code-graph-mcp@0.31.0/scripts';
302
+ assert.strictEqual(isPluginModeInstall(pluginPath), true);
303
+ // Legacy ~/.claude/plugins/ path still works even with env var set.
304
+ assert.strictEqual(
305
+ isPluginModeInstall('/home/user/.claude/plugins/cache/code-graph-mcp/scripts'),
306
+ true
307
+ );
308
+ // Unrelated path under same prefix is still rejected.
309
+ assert.strictEqual(
310
+ isPluginModeInstall('/home/alice/work-claude/projects/foo/memory'),
311
+ false
312
+ );
313
+ } finally {
314
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
315
+ else process.env.CLAUDE_CONFIG_DIR = prev;
316
+ }
317
+ });
318
+
281
319
  test('maybeAutoAdopt skips when CODE_GRAPH_NO_AUTO_ADOPT=1', () => {
282
320
  const sb = makeSandbox();
283
321
  try {
@@ -5,7 +5,7 @@ const fs = require('fs');
5
5
  const https = require('https');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic } = require('./lifecycle');
8
+ const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
9
9
  const { clearCache: clearBinaryCache } = require('./find-binary');
10
10
  const { readBinaryVersion, isDevMode } = require('./version-utils');
11
11
 
@@ -272,7 +272,7 @@ async function downloadAndInstall(latest) {
272
272
 
273
273
  const pluginSrc = path.join(tmpDir, 'claude-plugin');
274
274
  const pluginDst = path.join(
275
- os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME, 'code-graph-mcp', latest.version
275
+ pluginsCacheDir(), MARKETPLACE_NAME, 'code-graph-mcp', latest.version
276
276
  );
277
277
 
278
278
  if (fs.existsSync(pluginSrc) && getExtractedPluginVersion(pluginSrc) === latest.version) {
@@ -282,7 +282,7 @@ async function downloadAndInstall(latest) {
282
282
  }
283
283
 
284
284
  // Update installed_plugins.json to point to new version
285
- const installedPath = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
285
+ const installedPath = installedPluginsPath();
286
286
  try {
287
287
  const installed = readJson(installedPath);
288
288
  if (installed && installed.plugins && installed.plugins[PLUGIN_ID]) {
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ // Resolve Claude Code's config directory. Honors CLAUDE_CONFIG_DIR — when set
6
+ // (commonly used to keep multiple accounts isolated, e.g. personal vs work),
7
+ // Claude Code reads `settings.json`, `plugins/`, `projects/`, etc. from there
8
+ // instead of `~/.claude/`. Our plugin must follow the same override so its
9
+ // hook registrations, statusline, adoption files, and cache cleanup land in
10
+ // the directory Claude Code is actually using.
11
+ //
12
+ // Read fresh on every call so per-process env mutation (tests, child procs
13
+ // spawned with a different env) takes effect immediately. Unlike
14
+ // CLAUDE_PLUGIN_ROOT (which leaks across plugins — see
15
+ // feedback_plugin_env_isolation.md), CLAUDE_CONFIG_DIR is user-set and
16
+ // process-wide, so reading it is safe.
17
+ function claudeHome() {
18
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
19
+ }
20
+
21
+ module.exports = { claudeHome };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+ const test = require('node:test');
3
+ const assert = require('node:assert');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { claudeHome } = require('./claude-config');
7
+
8
+ test('claudeHome defaults to ~/.claude when CLAUDE_CONFIG_DIR unset', () => {
9
+ const prev = process.env.CLAUDE_CONFIG_DIR;
10
+ delete process.env.CLAUDE_CONFIG_DIR;
11
+ try {
12
+ assert.strictEqual(claudeHome(), path.join(os.homedir(), '.claude'));
13
+ } finally {
14
+ if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
15
+ }
16
+ });
17
+
18
+ test('claudeHome honors CLAUDE_CONFIG_DIR when set', () => {
19
+ const prev = process.env.CLAUDE_CONFIG_DIR;
20
+ process.env.CLAUDE_CONFIG_DIR = '/tmp/work-claude';
21
+ try {
22
+ assert.strictEqual(claudeHome(), '/tmp/work-claude');
23
+ } finally {
24
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
25
+ else process.env.CLAUDE_CONFIG_DIR = prev;
26
+ }
27
+ });
28
+
29
+ test('claudeHome re-reads env on every call (not cached)', () => {
30
+ const prev = process.env.CLAUDE_CONFIG_DIR;
31
+ delete process.env.CLAUDE_CONFIG_DIR;
32
+ try {
33
+ const before = claudeHome();
34
+ process.env.CLAUDE_CONFIG_DIR = '/tmp/account-A';
35
+ const during = claudeHome();
36
+ delete process.env.CLAUDE_CONFIG_DIR;
37
+ const after = claudeHome();
38
+ assert.strictEqual(before, path.join(os.homedir(), '.claude'));
39
+ assert.strictEqual(during, '/tmp/account-A');
40
+ assert.strictEqual(after, path.join(os.homedir(), '.claude'));
41
+ } finally {
42
+ if (prev !== undefined) process.env.CLAUDE_CONFIG_DIR = prev;
43
+ }
44
+ });
45
+
46
+ test('claudeHome ignores empty CLAUDE_CONFIG_DIR (falls back to ~/.claude)', () => {
47
+ // Empty string is falsy in JS — sanity-check the `||` fallback path so an
48
+ // accidentally `CLAUDE_CONFIG_DIR=` (unset-style) shell line does not strand
49
+ // us writing to the literal repository root `/`.
50
+ const prev = process.env.CLAUDE_CONFIG_DIR;
51
+ process.env.CLAUDE_CONFIG_DIR = '';
52
+ try {
53
+ assert.strictEqual(claudeHome(), path.join(os.homedir(), '.claude'));
54
+ } finally {
55
+ if (prev === undefined) delete process.env.CLAUDE_CONFIG_DIR;
56
+ else process.env.CLAUDE_CONFIG_DIR = prev;
57
+ }
58
+ });
@@ -8,6 +8,7 @@ const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-util
8
8
  const {
9
9
  getPluginVersion, readJson, healthCheck, CACHE_DIR,
10
10
  removeHooksFromSettings, isOurHookEntry, writeJsonAtomic,
11
+ settingsPath,
11
12
  } = require('./lifecycle');
12
13
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
13
14
 
@@ -91,41 +92,61 @@ function runDiagnostics() {
91
92
  }).trim();
92
93
  const hc = JSON.parse(hcOutput);
93
94
 
94
- // Schema
95
- if (hc.issue && hc.issue.includes('schema')) {
96
- results.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
95
+ // No-index short-circuit — binary deliberately returns a structured
96
+ // JSON with reason='no_index' instead of bailing, so we can route to
97
+ // the index-empty fix without grepping stderr. Falls through to the
98
+ // rest of runDiagnostics so Auto-update / Hooks still report.
99
+ if (hc.reason === 'no_index') {
100
+ results.push({ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' });
101
+ results.push({ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' });
102
+ results.push({ name: 'Embeddings', status: 'skip', detail: 'no index' });
97
103
  } else {
98
- results.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
99
- }
104
+ // Schema
105
+ if (hc.issue && hc.issue.includes('schema')) {
106
+ results.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
107
+ } else {
108
+ results.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
109
+ }
100
110
 
101
- // Index
102
- if (hc.nodes === 0) {
103
- results.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
104
- } else {
105
- const age = hc.index_age ? ` (${hc.index_age})` : '';
106
- results.push({
107
- name: 'Index',
108
- status: 'ok',
109
- detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
110
- });
111
- }
111
+ // Index
112
+ if (hc.nodes === 0) {
113
+ results.push({ name: 'Index', status: 'warn', detail: 'empty', fixId: 'index-empty' });
114
+ } else {
115
+ const age = hc.index_age ? ` (${hc.index_age})` : '';
116
+ results.push({
117
+ name: 'Index',
118
+ status: 'ok',
119
+ detail: `${hc.nodes} nodes, ${hc.edges} edges, ${hc.files} files${age}`,
120
+ });
121
+ }
112
122
 
113
- // Embeddings
114
- const ep = hc.embedding_progress || '0/0';
115
- const [done, total] = ep.split('/').map(Number);
116
- if (total > 0 && done < total) {
117
- const pct = Math.round((done / total) * 100);
118
- results.push({ name: 'Embeddings', status: 'ok', detail: `${pct}% (${done}/${total})` });
119
- } else if (total === 0) {
120
- results.push({ name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' });
121
- } else {
122
- results.push({ name: 'Embeddings', status: 'ok', detail: `100% (${done}/${total})` });
123
+ // Embeddings
124
+ const ep = hc.embedding_progress || '0/0';
125
+ const [done, total] = ep.split('/').map(Number);
126
+ if (total > 0 && done < total) {
127
+ const pct = Math.round((done / total) * 100);
128
+ results.push({ name: 'Embeddings', status: 'ok', detail: `${pct}% (${done}/${total})` });
129
+ } else if (total === 0) {
130
+ results.push({ name: 'Embeddings', status: 'ok', detail: 'no embeddable nodes' });
131
+ } else {
132
+ results.push({ name: 'Embeddings', status: 'ok', detail: `100% (${done}/${total})` });
133
+ }
123
134
  }
124
135
  } catch (e) {
125
- const msg = e.stderr ? e.stderr.toString().trim().slice(0, 100) : e.message.slice(0, 100);
126
- results.push({ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' });
127
- results.push({ name: 'Index', status: 'skip', detail: 'health-check failed' });
128
- results.push({ name: 'Embeddings', status: 'skip', detail: 'health-check failed' });
136
+ const rawStderr = e.stderr ? e.stderr.toString() : '';
137
+ const msg = rawStderr ? rawStderr.trim().slice(0, 100) : e.message.slice(0, 100);
138
+ // "No index found" is a missing-index situation, not a broken binary —
139
+ // the index-empty fix path knows how to create one. Without this branch
140
+ // the fixId routes to nothing and the report shows "0/1 addressed".
141
+ if (rawStderr.includes('No index found')) {
142
+ results.push({ name: 'Schema', status: 'ok', detail: 'binary ok (no index yet)' });
143
+ results.push({ name: 'Index', status: 'warn', detail: 'missing — not indexed yet', fixId: 'index-empty' });
144
+ results.push({ name: 'Embeddings', status: 'skip', detail: 'no index' });
145
+ } else {
146
+ results.push({ name: 'Schema', status: 'error', detail: `health-check failed: ${msg}`, fixId: 'binary-broken' });
147
+ results.push({ name: 'Index', status: 'skip', detail: 'health-check failed' });
148
+ results.push({ name: 'Embeddings', status: 'skip', detail: 'health-check failed' });
149
+ }
129
150
  }
130
151
  } else {
131
152
  results.push({ name: 'Schema', status: 'skip', detail: 'binary not executable' });
@@ -170,8 +191,7 @@ function runDiagnostics() {
170
191
  // cache/<ver>/hooks/hooks.json is now authoritative. Duplicates cause
171
192
  // every hook to fire twice until settings.json is cleaned.
172
193
  try {
173
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
174
- const settings = readJson(SETTINGS_PATH) || {};
194
+ const settings = readJson(settingsPath()) || {};
175
195
  const legacyCount = countLegacyHookEntries(settings);
176
196
  if (legacyCount === 0) {
177
197
  results.push({ name: 'Legacy hooks', status: 'ok', detail: 'settings.json is clean' });
@@ -357,10 +377,10 @@ function runRepairs(results) {
357
377
 
358
378
  case 'legacy-hooks-in-settings': {
359
379
  console.log('\n Removing legacy code-graph hooks from settings.json...');
360
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
361
- const settings = readJson(SETTINGS_PATH) || {};
380
+ const settingsFile = settingsPath();
381
+ const settings = readJson(settingsFile) || {};
362
382
  if (removeHooksFromSettings(settings)) {
363
- writeJsonAtomic(SETTINGS_PATH, settings);
383
+ writeJsonAtomic(settingsFile, settings);
364
384
  console.log(' \u2705 settings.json cleaned — restart Claude Code to apply');
365
385
  fixed++;
366
386
  } else {
@@ -95,3 +95,49 @@ test('lifecycle CLI handles install, disable self-heal, re-enable, and uninstall
95
95
  assert.equal(fs.existsSync(cacheDir), false);
96
96
  });
97
97
 
98
+ test('lifecycle install writes to CLAUDE_CONFIG_DIR instead of ~/.claude when set', (t) => {
99
+ // Multi-account isolation: a user with CLAUDE_CONFIG_DIR=~/work-claude
100
+ // expects all plugin config (settings.json, installed_plugins.json,
101
+ // statusline-providers backup) to land under that directory, not the
102
+ // default ~/.claude. Default path must remain untouched.
103
+ const homeDir = mkHome(t);
104
+ const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-graph-cfgdir-'));
105
+ t.after(() => fs.rmSync(configDir, { recursive: true, force: true }));
106
+
107
+ const cfgSettings = path.join(configDir, 'settings.json');
108
+ const cfgInstalled = path.join(configDir, 'plugins', 'installed_plugins.json');
109
+ const cfgBackup = path.join(configDir, 'statusline-providers.json');
110
+ const defaultSettings = path.join(homeDir, '.claude', 'settings.json');
111
+
112
+ writeJson(cfgSettings, {
113
+ statusLine: { type: 'command', command: 'echo prior-work-status' },
114
+ enabledPlugins: { 'code-graph-mcp@code-graph-mcp': true },
115
+ });
116
+ writeJson(cfgInstalled, {
117
+ plugins: {
118
+ 'code-graph-mcp@code-graph-mcp': [{
119
+ installPath: pluginRoot,
120
+ version: currentVersion,
121
+ scope: 'user',
122
+ }],
123
+ },
124
+ });
125
+
126
+ // Run install with CLAUDE_CONFIG_DIR set; HOME points elsewhere.
127
+ const env = { ...process.env, HOME: homeDir, CLAUDE_CONFIG_DIR: configDir };
128
+ delete env.CLAUDE_PLUGIN_ROOT;
129
+ execFileSync(process.execPath, [lifecycleCli, 'install'], {
130
+ cwd: repoRoot, env, stdio: ['pipe', 'pipe', 'pipe'],
131
+ });
132
+
133
+ // Config landed in the override dir...
134
+ const settings = readJson(cfgSettings);
135
+ assert.match(settings.statusLine.command, /statusline-composite\.js/);
136
+ assert.equal(fs.existsSync(cfgBackup), true,
137
+ 'statusline-providers backup should land in CLAUDE_CONFIG_DIR');
138
+
139
+ // ...and default ~/.claude was never touched.
140
+ assert.equal(fs.existsSync(defaultSettings), false,
141
+ 'default ~/.claude/settings.json must not be written when override is set');
142
+ });
143
+
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
+ const { claudeHome } = require('./claude-config');
6
7
 
7
8
  const PLUGIN_ID = 'code-graph-mcp@code-graph-mcp';
8
9
  const OLD_PLUGIN_IDS = [
@@ -16,13 +17,18 @@ const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
16
17
  // to its own marketplace path, polluting all subsequent settings.json hook processes).
17
18
  const PLUGIN_ROOT = path.resolve(__dirname, '..');
18
19
  const MANIFEST_FILE = path.join(CACHE_DIR, 'install-manifest.json');
19
- const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
20
- const INSTALLED_PLUGINS_PATH = path.join(os.homedir(), '.claude', 'plugins', 'installed_plugins.json');
21
20
  const REGISTRY_FILE = path.join(CACHE_DIR, 'statusline-registry.json');
21
+
22
+ // Lazy resolvers — Claude Code's config dir can be overridden by CLAUDE_CONFIG_DIR
23
+ // (multi-account isolation). Re-read every call so test subprocesses with a
24
+ // different env see the right path.
25
+ function settingsPath() { return path.join(claudeHome(), 'settings.json'); }
26
+ function installedPluginsPath() { return path.join(claudeHome(), 'plugins', 'installed_plugins.json'); }
22
27
  // Durable mirror outside ~/.cache/ — survives cache cleanup. Captures the
23
28
  // `_previous` snapshot (pre-install statusline) and any third-party providers
24
29
  // (GSD, etc.). readRegistry() self-heals from this file when primary is missing.
25
- const PROVIDERS_BACKUP_FILE = path.join(os.homedir(), '.claude', 'statusline-providers.json');
30
+ function providersBackupFile() { return path.join(claudeHome(), 'statusline-providers.json'); }
31
+ function pluginsCacheDir() { return path.join(claudeHome(), 'plugins', 'cache'); }
26
32
 
27
33
  // --- Helpers ---
28
34
 
@@ -65,7 +71,7 @@ function hasOwn(obj, key) {
65
71
  }
66
72
 
67
73
  function hasInstalledPluginRecord() {
68
- const installed = readJson(INSTALLED_PLUGINS_PATH);
74
+ const installed = readJson(installedPluginsPath());
69
75
  return !!(installed && installed.plugins && Array.isArray(installed.plugins[PLUGIN_ID]) && installed.plugins[PLUGIN_ID].length > 0);
70
76
  }
71
77
 
@@ -83,7 +89,7 @@ function readRegistry() {
83
89
  if (primary && Array.isArray(primary) && primary.length > 0) return primary;
84
90
  // Self-heal: primary missing or empty (e.g. user cleaned ~/.cache/code-graph/).
85
91
  // Durable backup in ~/.claude/ retains `_previous` + third-party providers.
86
- const backup = readJson(PROVIDERS_BACKUP_FILE);
92
+ const backup = readJson(providersBackupFile());
87
93
  if (backup && Array.isArray(backup) && backup.length > 0) {
88
94
  try { writeJsonAtomic(REGISTRY_FILE, backup); } catch { /* ok */ }
89
95
  return backup;
@@ -94,13 +100,13 @@ function readRegistry() {
94
100
  function writeRegistry(registry) {
95
101
  if (!registry || registry.length === 0) {
96
102
  try { fs.unlinkSync(REGISTRY_FILE); } catch { /* ok */ }
97
- try { fs.unlinkSync(PROVIDERS_BACKUP_FILE); } catch { /* ok */ }
103
+ try { fs.unlinkSync(providersBackupFile()); } catch { /* ok */ }
98
104
  return;
99
105
  }
100
106
  writeJsonAtomic(REGISTRY_FILE, registry);
101
107
  // Mirror to durable location so cache cleanup doesn't strand `_previous`
102
108
  // or third-party provider entries.
103
- try { writeJsonAtomic(PROVIDERS_BACKUP_FILE, registry); } catch { /* ok */ }
109
+ try { writeJsonAtomic(providersBackupFile(), registry); } catch { /* ok */ }
104
110
  }
105
111
 
106
112
  function registerStatuslineProvider(id, command, needsStdin) {
@@ -126,18 +132,18 @@ function unregisterStatuslineProvider(id) {
126
132
  return true;
127
133
  }
128
134
 
129
- function isPluginExplicitlyDisabled(settings = readJson(SETTINGS_PATH) || {}) {
135
+ function isPluginExplicitlyDisabled(settings = readJson(settingsPath()) || {}) {
130
136
  return hasOwn(settings.enabledPlugins, PLUGIN_ID) && settings.enabledPlugins[PLUGIN_ID] === false;
131
137
  }
132
138
 
133
- function isPluginInactive(settings = readJson(SETTINGS_PATH) || {}) {
139
+ function isPluginInactive(settings = readJson(settingsPath()) || {}) {
134
140
  if (isPluginExplicitlyDisabled(settings)) return true;
135
141
 
136
142
  const hasComposite = isOurComposite(settings);
137
143
  const hasCodeGraphRegistry = readRegistry().some((provider) => provider.id === 'code-graph');
138
144
  if (!hasComposite && !hasCodeGraphRegistry) return false;
139
145
 
140
- const installed = readJson(INSTALLED_PLUGINS_PATH);
146
+ const installed = readJson(installedPluginsPath());
141
147
  if (!installed || !installed.plugins) return false;
142
148
  return !hasInstalledPluginRecord();
143
149
  }
@@ -165,7 +171,7 @@ function detachStatuslineIntegration(settings) {
165
171
  }
166
172
 
167
173
  function cleanupDisabledStatusline() {
168
- const settings = readJson(SETTINGS_PATH);
174
+ const settings = readJson(settingsPath());
169
175
  if (!settings || !isPluginInactive(settings)) {
170
176
  return { cleaned: false, settingsChanged: false };
171
177
  }
@@ -173,7 +179,7 @@ function cleanupDisabledStatusline() {
173
179
  let settingsChanged = detachStatuslineIntegration(settings);
174
180
  if (removeHooksFromSettings(settings)) settingsChanged = true;
175
181
  if (settingsChanged) {
176
- writeJsonAtomic(SETTINGS_PATH, settings);
182
+ writeJsonAtomic(settingsPath(), settings);
177
183
  }
178
184
 
179
185
  return { cleaned: true, settingsChanged };
@@ -182,7 +188,7 @@ function cleanupDisabledStatusline() {
182
188
  // --- Scope Conflict Detection ---
183
189
 
184
190
  function checkScopeConflict() {
185
- const installed = readJson(INSTALLED_PLUGINS_PATH);
191
+ const installed = readJson(installedPluginsPath());
186
192
  if (!installed || !installed.plugins) return null;
187
193
  for (const [key, entries] of Object.entries(installed.plugins)) {
188
194
  if (key === PLUGIN_ID) continue;
@@ -207,10 +213,10 @@ function migrateOldPluginIds(settings) {
207
213
  }
208
214
 
209
215
  // Clean old ID from installed_plugins.json
210
- const installed = readJson(INSTALLED_PLUGINS_PATH);
216
+ const installed = readJson(installedPluginsPath());
211
217
  if (installed && installed.plugins && oldId in installed.plugins) {
212
218
  delete installed.plugins[oldId];
213
- writeJsonAtomic(INSTALLED_PLUGINS_PATH, installed);
219
+ writeJsonAtomic(installedPluginsPath(), installed);
214
220
  }
215
221
  }
216
222
 
@@ -225,10 +231,11 @@ function migrateOldPluginIds(settings) {
225
231
  }
226
232
 
227
233
  // Clean old cache paths
234
+ const cacheRoot = pluginsCacheDir();
228
235
  const oldCacheDirs = [
229
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'code-graph'),
230
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph', 'code-graph'),
231
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph'),
236
+ path.join(cacheRoot, 'sdsrss', 'code-graph'),
237
+ path.join(cacheRoot, 'sdsrss-code-graph', 'code-graph'),
238
+ path.join(cacheRoot, 'sdsrss-code-graph'),
232
239
  ];
233
240
  for (const dir of oldCacheDirs) {
234
241
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -284,7 +291,7 @@ function removeHooksFromSettings(settings) {
284
291
  function install() {
285
292
  const version = getPluginVersion();
286
293
  const manifest = readManifest();
287
- const settings = readJson(SETTINGS_PATH) || {};
294
+ const settings = readJson(settingsPath()) || {};
288
295
  let settingsChanged = false;
289
296
 
290
297
  // 0. Migrate from old plugin IDs
@@ -329,7 +336,7 @@ function install() {
329
336
 
330
337
  // 3. Write settings atomically if changed
331
338
  if (settingsChanged) {
332
- writeJsonAtomic(SETTINGS_PATH, settings);
339
+ writeJsonAtomic(settingsPath(), settings);
333
340
  }
334
341
 
335
342
  // 4. Write manifest with version
@@ -344,7 +351,7 @@ function install() {
344
351
  // --- Uninstall (clean all config) ---
345
352
 
346
353
  function uninstall() {
347
- const settings = readJson(SETTINGS_PATH);
354
+ const settings = readJson(settingsPath());
348
355
  let settingsChanged = false;
349
356
 
350
357
  if (settings) {
@@ -370,12 +377,12 @@ function uninstall() {
370
377
 
371
378
  // 4. Write settings if changed
372
379
  if (settingsChanged) {
373
- writeJsonAtomic(SETTINGS_PATH, settings);
380
+ writeJsonAtomic(settingsPath(), settings);
374
381
  }
375
382
  }
376
383
 
377
384
  // 5. Remove all known IDs from installed_plugins.json
378
- const installedPlugins = readJson(INSTALLED_PLUGINS_PATH);
385
+ const installedPlugins = readJson(installedPluginsPath());
379
386
  if (installedPlugins && installedPlugins.plugins) {
380
387
  let ipChanged = false;
381
388
  for (const id of [PLUGIN_ID, ...OLD_PLUGIN_IDS]) {
@@ -384,17 +391,18 @@ function uninstall() {
384
391
  ipChanged = true;
385
392
  }
386
393
  }
387
- if (ipChanged) writeJsonAtomic(INSTALLED_PLUGINS_PATH, installedPlugins);
394
+ if (ipChanged) writeJsonAtomic(installedPluginsPath(), installedPlugins);
388
395
  }
389
396
 
390
397
  // 6. Remove cache directory
391
398
  try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
392
399
 
393
400
  // 7. Remove plugin files from cache (all known paths, including parent dirs)
401
+ const cacheRoot = pluginsCacheDir();
394
402
  const pluginCacheDirs = [
395
- path.join(os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME),
396
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss-code-graph'),
397
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'sdsrss', 'code-graph'),
403
+ path.join(cacheRoot, MARKETPLACE_NAME),
404
+ path.join(cacheRoot, 'sdsrss-code-graph'),
405
+ path.join(cacheRoot, 'sdsrss', 'code-graph'),
398
406
  ];
399
407
  for (const dir of pluginCacheDirs) {
400
408
  try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -409,7 +417,7 @@ function update() {
409
417
  const version = getPluginVersion();
410
418
  const manifest = readManifest();
411
419
  const oldVersion = manifest.version;
412
- const settings = readJson(SETTINGS_PATH) || {};
420
+ const settings = readJson(settingsPath()) || {};
413
421
  let settingsChanged = false;
414
422
 
415
423
  // 0. Migrate from old plugin IDs
@@ -438,7 +446,7 @@ function update() {
438
446
 
439
447
  // 4. Write settings if changed
440
448
  if (settingsChanged) {
441
- writeJsonAtomic(SETTINGS_PATH, settings);
449
+ writeJsonAtomic(settingsPath(), settings);
442
450
  }
443
451
 
444
452
  // 5. Clear update-check cache (force re-check after update)
@@ -463,7 +471,7 @@ function update() {
463
471
  * Cache layout: ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
464
472
  */
465
473
  function cleanupOldCacheVersions(keep = 3) {
466
- const cacheParent = path.join(os.homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_NAME);
474
+ const cacheParent = path.join(pluginsCacheDir(), MARKETPLACE_NAME);
467
475
  try {
468
476
  // List all subdirectories under the marketplace cache
469
477
  const entries = fs.readdirSync(cacheParent, { withFileTypes: true });
@@ -498,7 +506,7 @@ function cleanupOldCacheVersions(keep = 3) {
498
506
  // Returns { healthy, issues, repaired }.
499
507
 
500
508
  function healthCheck() {
501
- const settings = readJson(SETTINGS_PATH) || {};
509
+ const settings = readJson(settingsPath()) || {};
502
510
  const issues = [];
503
511
 
504
512
  // Check statusLine path
@@ -554,7 +562,7 @@ module.exports = {
554
562
  removeHooksFromSettings, isOurHookEntry,
555
563
  registerStatuslineProvider, unregisterStatuslineProvider,
556
564
  PLUGIN_ID, OLD_PLUGIN_IDS, MARKETPLACE_NAME, CACHE_DIR, REGISTRY_FILE,
557
- PROVIDERS_BACKUP_FILE,
565
+ settingsPath, installedPluginsPath, providersBackupFile, pluginsCacheDir,
558
566
  };
559
567
 
560
568
  // CLI: node lifecycle.js <install|uninstall|update|health>
@@ -2,11 +2,11 @@
2
2
  'use strict';
3
3
  const { spawn, execSync, execFileSync } = require('child_process');
4
4
  const path = require('path');
5
- const os = require('os');
6
5
  const fs = require('fs');
7
6
  const {
8
7
  install, update, readManifest, getPluginVersion, checkScopeConflict,
9
8
  cleanupDisabledStatusline, isPluginInactive, readJson, CACHE_DIR,
9
+ settingsPath,
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted } = require('./adopt');
@@ -58,7 +58,7 @@ function syncLifecycleConfig() {
58
58
  // Self-heal: version matches but statusLine may have been lost or path corrupted
59
59
  // (e.g. plugin removed and reinstalled, or CLAUDE_PLUGIN_ROOT leaked from another plugin).
60
60
  // install() is idempotent — isOurComposite guard prevents duplicate work.
61
- const settings = readJson(path.join(os.homedir(), '.claude', 'settings.json')) || {};
61
+ const settings = readJson(settingsPath()) || {};
62
62
  if (!settings.statusLine || !settings.statusLine.command ||
63
63
  !settings.statusLine.command.includes('statusline-composite')) {
64
64
  install();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.29.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.29.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.29.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.29.0",
42
- "@sdsrs/code-graph-win32-x64": "0.29.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.31.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.31.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.31.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.31.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.31.0"
43
43
  }
44
44
  }