@optave/codegraph 1.3.0 → 1.4.1

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/README.md CHANGED
@@ -366,6 +366,7 @@ See **[docs/recommended-practices.md](docs/recommended-practices.md)** for integ
366
366
  - **CI/CD** — PR impact comments, threshold gates, graph caching
367
367
  - **AI agents** — MCP server, CLAUDE.md templates, Claude Code hooks
368
368
  - **Developer workflow** — watch mode, explore-before-you-edit, semantic search
369
+ - **Secure credentials** — `apiKeyCommand` with 1Password, Bitwarden, Vault, macOS Keychain, `pass`
369
370
 
370
371
  ## 🔁 CI / GitHub Actions
371
372
 
@@ -395,6 +396,23 @@ Create a `.codegraphrc.json` in your project root to customize behavior:
395
396
  }
396
397
  ```
397
398
 
399
+ ### LLM credentials
400
+
401
+ Codegraph supports an `apiKeyCommand` field for secure credential management. Instead of storing API keys in config files or environment variables, you can shell out to a secret manager at runtime:
402
+
403
+ ```json
404
+ {
405
+ "llm": {
406
+ "provider": "openai",
407
+ "apiKeyCommand": "op read op://vault/openai/api-key"
408
+ }
409
+ }
410
+ ```
411
+
412
+ The command is split on whitespace and executed with `execFileSync` (no shell injection risk). Priority: **command output > `CODEGRAPH_LLM_API_KEY` env var > file config**. On failure, codegraph warns and falls back to the next source.
413
+
414
+ Works with any secret manager: 1Password CLI (`op`), Bitwarden (`bw`), `pass`, HashiCorp Vault, macOS Keychain (`security`), AWS Secrets Manager, etc.
415
+
398
416
  ## 📖 Programmatic API
399
417
 
400
418
  Codegraph also exports a full API for use in your own tools:
@@ -449,7 +467,7 @@ const { results: fused } = await multiSearchData(
449
467
  See **[ROADMAP.md](ROADMAP.md)** for the full development roadmap. Current plan:
450
468
 
451
469
  1. ~~**Rust Core**~~ — **Complete** (v1.3.0) — native tree-sitter parsing via napi-rs, parallel multi-core parsing, incremental re-parsing, import resolution & cycle detection in Rust
452
- 2. **Foundation Hardening** — ~~parser registry~~, complete MCP server, test coverage, enhanced config
470
+ 2. ~~**Foundation Hardening**~~ — **Complete** (v1.4.0) — parser registry, 11-tool MCP server, test coverage 62%→75%, `apiKeyCommand` secret resolution
453
471
  3. **Intelligent Embeddings** — LLM-generated descriptions, hybrid search
454
472
  4. **Natural Language Queries** — `codegraph ask` command, conversational sessions
455
473
  5. **Expanded Language Support** — 8 new languages (12 → 20)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optave/codegraph",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,10 +61,10 @@
61
61
  "optionalDependencies": {
62
62
  "@huggingface/transformers": "^3.8.1",
63
63
  "@modelcontextprotocol/sdk": "^1.0.0",
64
- "@optave/codegraph-darwin-arm64": "1.3.0",
65
- "@optave/codegraph-darwin-x64": "1.3.0",
66
- "@optave/codegraph-linux-x64-gnu": "1.3.0",
67
- "@optave/codegraph-win32-x64-msvc": "1.3.0"
64
+ "@optave/codegraph-darwin-arm64": "1.4.1",
65
+ "@optave/codegraph-darwin-x64": "1.4.1",
66
+ "@optave/codegraph-linux-x64-gnu": "1.4.1",
67
+ "@optave/codegraph-win32-x64-msvc": "1.4.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.4.4",
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
- import { debug } from './logger.js';
4
+ import { debug, warn } from './logger.js';
4
5
 
5
6
  export const CONFIG_FILES = ['.codegraphrc.json', '.codegraphrc', 'codegraph.config.json'];
6
7
 
@@ -18,6 +19,10 @@ export const DEFAULTS = {
18
19
  defaultDepth: 3,
19
20
  defaultLimit: 20,
20
21
  },
22
+ embeddings: { model: 'minilm', llmProvider: null },
23
+ llm: { provider: null, model: null, baseUrl: null, apiKey: null, apiKeyCommand: null },
24
+ search: { defaultMinScore: 0.2, rrfK: 60, topK: 15 },
25
+ ci: { failOnCycles: false, impactThreshold: null },
21
26
  };
22
27
 
23
28
  /**
@@ -33,13 +38,50 @@ export function loadConfig(cwd) {
33
38
  const raw = fs.readFileSync(filePath, 'utf-8');
34
39
  const config = JSON.parse(raw);
35
40
  debug(`Loaded config from ${filePath}`);
36
- return mergeConfig(DEFAULTS, config);
41
+ return resolveSecrets(applyEnvOverrides(mergeConfig(DEFAULTS, config)));
37
42
  } catch (err) {
38
43
  debug(`Failed to parse config ${filePath}: ${err.message}`);
39
44
  }
40
45
  }
41
46
  }
42
- return { ...DEFAULTS };
47
+ return resolveSecrets(applyEnvOverrides({ ...DEFAULTS }));
48
+ }
49
+
50
+ const ENV_LLM_MAP = {
51
+ CODEGRAPH_LLM_PROVIDER: 'provider',
52
+ CODEGRAPH_LLM_API_KEY: 'apiKey',
53
+ CODEGRAPH_LLM_MODEL: 'model',
54
+ };
55
+
56
+ export function applyEnvOverrides(config) {
57
+ for (const [envKey, field] of Object.entries(ENV_LLM_MAP)) {
58
+ if (process.env[envKey] !== undefined) {
59
+ config.llm[field] = process.env[envKey];
60
+ }
61
+ }
62
+ return config;
63
+ }
64
+
65
+ export function resolveSecrets(config) {
66
+ const cmd = config.llm.apiKeyCommand;
67
+ if (typeof cmd !== 'string' || cmd.trim() === '') return config;
68
+
69
+ const parts = cmd.trim().split(/\s+/);
70
+ const [executable, ...args] = parts;
71
+ try {
72
+ const result = execFileSync(executable, args, {
73
+ encoding: 'utf-8',
74
+ timeout: 10_000,
75
+ maxBuffer: 64 * 1024,
76
+ stdio: ['ignore', 'pipe', 'pipe'],
77
+ }).trim();
78
+ if (result) {
79
+ config.llm.apiKey = result;
80
+ }
81
+ } catch (err) {
82
+ warn(`apiKeyCommand failed: ${err.message}`);
83
+ }
84
+ return config;
43
85
  }
44
86
 
45
87
  function mergeConfig(defaults, overrides) {
package/src/mcp.js CHANGED
@@ -66,6 +66,93 @@ const TOOLS = [
66
66
  },
67
67
  },
68
68
  },
69
+ {
70
+ name: 'fn_deps',
71
+ description: 'Show function-level dependency chain: what a function calls and what calls it',
72
+ inputSchema: {
73
+ type: 'object',
74
+ properties: {
75
+ name: { type: 'string', description: 'Function/method/class name (partial match)' },
76
+ depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
77
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
78
+ },
79
+ required: ['name'],
80
+ },
81
+ },
82
+ {
83
+ name: 'fn_impact',
84
+ description:
85
+ 'Show function-level blast radius: all functions transitively affected by changes to a function',
86
+ inputSchema: {
87
+ type: 'object',
88
+ properties: {
89
+ name: { type: 'string', description: 'Function/method/class name (partial match)' },
90
+ depth: { type: 'number', description: 'Max traversal depth', default: 5 },
91
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
92
+ },
93
+ required: ['name'],
94
+ },
95
+ },
96
+ {
97
+ name: 'diff_impact',
98
+ description: 'Analyze git diff to find which functions changed and their transitive callers',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ staged: { type: 'boolean', description: 'Analyze staged changes only', default: false },
103
+ ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' },
104
+ depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
105
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
106
+ },
107
+ },
108
+ },
109
+ {
110
+ name: 'semantic_search',
111
+ description:
112
+ 'Search code symbols by meaning using embeddings (requires prior `codegraph embed`)',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ query: { type: 'string', description: 'Natural language search query' },
117
+ limit: { type: 'number', description: 'Max results to return', default: 15 },
118
+ min_score: { type: 'number', description: 'Minimum similarity score (0-1)', default: 0.2 },
119
+ },
120
+ required: ['query'],
121
+ },
122
+ },
123
+ {
124
+ name: 'export_graph',
125
+ description: 'Export the dependency graph in DOT (Graphviz), Mermaid, or JSON format',
126
+ inputSchema: {
127
+ type: 'object',
128
+ properties: {
129
+ format: {
130
+ type: 'string',
131
+ enum: ['dot', 'mermaid', 'json'],
132
+ description: 'Export format',
133
+ },
134
+ file_level: {
135
+ type: 'boolean',
136
+ description: 'File-level graph (true) or function-level (false)',
137
+ default: true,
138
+ },
139
+ },
140
+ required: ['format'],
141
+ },
142
+ },
143
+ {
144
+ name: 'list_functions',
145
+ description:
146
+ 'List functions, methods, and classes in the codebase, optionally filtered by file or name pattern',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ file: { type: 'string', description: 'Filter by file path (partial match)' },
151
+ pattern: { type: 'string', description: 'Filter by function name (partial match)' },
152
+ no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
153
+ },
154
+ },
155
+ },
69
156
  ];
70
157
 
71
158
  export { TOOLS };
@@ -90,9 +177,16 @@ export async function startMCPServer(customDbPath) {
90
177
  }
91
178
 
92
179
  // Lazy import query functions to avoid circular deps at module load
93
- const { queryNameData, impactAnalysisData, moduleMapData, fileDepsData } = await import(
94
- './queries.js'
95
- );
180
+ const {
181
+ queryNameData,
182
+ impactAnalysisData,
183
+ moduleMapData,
184
+ fileDepsData,
185
+ fnDepsData,
186
+ fnImpactData,
187
+ diffImpactData,
188
+ listFunctionsData,
189
+ } = await import('./queries.js');
96
190
 
97
191
  const require = createRequire(import.meta.url);
98
192
  const Database = require('better-sqlite3');
@@ -130,6 +224,78 @@ export async function startMCPServer(customDbPath) {
130
224
  case 'module_map':
131
225
  result = moduleMapData(dbPath, args.limit || 20);
132
226
  break;
227
+ case 'fn_deps':
228
+ result = fnDepsData(args.name, dbPath, {
229
+ depth: args.depth,
230
+ noTests: args.no_tests,
231
+ });
232
+ break;
233
+ case 'fn_impact':
234
+ result = fnImpactData(args.name, dbPath, {
235
+ depth: args.depth,
236
+ noTests: args.no_tests,
237
+ });
238
+ break;
239
+ case 'diff_impact':
240
+ result = diffImpactData(dbPath, {
241
+ staged: args.staged,
242
+ ref: args.ref,
243
+ depth: args.depth,
244
+ noTests: args.no_tests,
245
+ });
246
+ break;
247
+ case 'semantic_search': {
248
+ const { searchData } = await import('./embedder.js');
249
+ result = await searchData(args.query, dbPath, {
250
+ limit: args.limit,
251
+ minScore: args.min_score,
252
+ });
253
+ if (result === null) {
254
+ return {
255
+ content: [
256
+ { type: 'text', text: 'Semantic search unavailable. Run `codegraph embed` first.' },
257
+ ],
258
+ isError: true,
259
+ };
260
+ }
261
+ break;
262
+ }
263
+ case 'export_graph': {
264
+ const { exportDOT, exportMermaid, exportJSON } = await import('./export.js');
265
+ const db = new Database(findDbPath(dbPath), { readonly: true });
266
+ const fileLevel = args.file_level !== false;
267
+ switch (args.format) {
268
+ case 'dot':
269
+ result = exportDOT(db, { fileLevel });
270
+ break;
271
+ case 'mermaid':
272
+ result = exportMermaid(db, { fileLevel });
273
+ break;
274
+ case 'json':
275
+ result = exportJSON(db);
276
+ break;
277
+ default:
278
+ db.close();
279
+ return {
280
+ content: [
281
+ {
282
+ type: 'text',
283
+ text: `Unknown format: ${args.format}. Use dot, mermaid, or json.`,
284
+ },
285
+ ],
286
+ isError: true,
287
+ };
288
+ }
289
+ db.close();
290
+ break;
291
+ }
292
+ case 'list_functions':
293
+ result = listFunctionsData(dbPath, {
294
+ file: args.file,
295
+ pattern: args.pattern,
296
+ noTests: args.no_tests,
297
+ });
298
+ break;
133
299
  default:
134
300
  return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
135
301
  }
package/src/queries.js CHANGED
@@ -566,6 +566,36 @@ export function diffImpactData(customDbPath, opts = {}) {
566
566
  };
567
567
  }
568
568
 
569
+ export function listFunctionsData(customDbPath, opts = {}) {
570
+ const db = openReadonlyOrFail(customDbPath);
571
+ const noTests = opts.noTests || false;
572
+ const kinds = ['function', 'method', 'class'];
573
+ const placeholders = kinds.map(() => '?').join(', ');
574
+
575
+ const conditions = [`kind IN (${placeholders})`];
576
+ const params = [...kinds];
577
+
578
+ if (opts.file) {
579
+ conditions.push('file LIKE ?');
580
+ params.push(`%${opts.file}%`);
581
+ }
582
+ if (opts.pattern) {
583
+ conditions.push('name LIKE ?');
584
+ params.push(`%${opts.pattern}%`);
585
+ }
586
+
587
+ let rows = db
588
+ .prepare(
589
+ `SELECT name, kind, file, line FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
590
+ )
591
+ .all(...params);
592
+
593
+ if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
594
+
595
+ db.close();
596
+ return { count: rows.length, functions: rows };
597
+ }
598
+
569
599
  // ─── Human-readable output (original formatting) ───────────────────────
570
600
 
571
601
  export function queryName(name, customDbPath, opts = {}) {
package/src/resolve.js CHANGED
@@ -12,7 +12,7 @@ import { loadNative } from './native.js';
12
12
  export function convertAliasesForNative(aliases) {
13
13
  if (!aliases) return null;
14
14
  return {
15
- baseUrl: aliases.baseUrl || null,
15
+ baseUrl: aliases.baseUrl || '',
16
16
  paths: Object.entries(aliases.paths || {}).map(([pattern, targets]) => ({
17
17
  pattern,
18
18
  targets,