@polderlabs/bizar 3.15.1 → 3.17.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/cli/bin.mjs CHANGED
@@ -25,7 +25,6 @@ import { runInit } from './init.mjs';
25
25
  import { runExport } from './export.mjs';
26
26
  import runPlan from './plan.mjs';
27
27
  import { runUpdate } from './update.mjs';
28
- import { runGraph } from './graph.mjs';
29
28
  import { ensureSetup, checkSetupStatus } from './bootstrap.mjs';
30
29
 
31
30
  const args = process.argv.slice(2);
@@ -82,7 +81,6 @@ function showHelp() {
82
81
  init Initialize .bizar/ in current project
83
82
  export [target] Export agents/rules to another harness
84
83
  plan <subcommand> Manage visual plans
85
- graph Per-project knowledge graph (powered by graphify)
86
84
  test-gate Detect & run the project's test suite
87
85
  update Auto-update everything (opencode + bizar + dash + plugin)
88
86
  service Manage the background service daemon
@@ -129,29 +127,10 @@ function showInitHelp() {
129
127
 
130
128
  Description:
131
129
  Detects the project stack, creates .bizar/PROJECT.md and
132
- .bizar/AGENTS_SELF_IMPROVEMENT.md, installs relevant skills,
133
- and builds the per-project knowledge graph in .bizar/graph/
134
- (requires graphify — pip install graphifyy otherwise the
135
- graph step is skipped gracefully).
136
- `);
137
- }
138
-
139
- function showGraphHelp() {
140
- console.log(`
141
- bizar graph — Per-project knowledge graph (powered by graphify)
142
-
143
- Usage:
144
- bizar graph build # full build of the project graph
145
- bizar graph update # incremental rebuild
146
- bizar graph query "<text>" # query the graph
147
- bizar graph path "<A>" "<B>" # shortest path between concepts
148
- bizar graph explain "<X>" # all nodes related to a concept
149
- bizar graph watch # watch for changes and rebuild
150
- bizar graph status # show graph path, size, node/edge/community counts
151
- bizar graph install # install graphify + drop OpenCode skill/plugin
152
-
153
- Requires Python 3.10+ and \`graphify\` (pip install graphifyy).
154
- Graph data lives in .bizar/graph/ inside this project.
130
+ .bizar/AGENTS_SELF_IMPROVEMENT.md and installs relevant skills.
131
+ The per-project knowledge graph (in .bizar/graph/) is provided
132
+ by the graphify mod — install it from the mod registry for
133
+ that feature.
155
134
  `);
156
135
  }
157
136
 
@@ -485,9 +464,6 @@ async function main() {
485
464
  } else if (args[0] === 'init') {
486
465
  if (isHelpRequest) showInitHelp();
487
466
  else await runInit(process.cwd());
488
- } else if (args[0] === 'graph') {
489
- if (isHelpRequest) showGraphHelp();
490
- else await runGraph(args.slice(1));
491
467
  } else if (args[0] === 'export') {
492
468
  if (isHelpRequest) showExportHelp();
493
469
  else await runExport(parseFlag('--target'));
@@ -535,6 +511,10 @@ async function main() {
535
511
  // v3.11.1 — Background agent manager (list / view / kill / logs).
536
512
  const { runBg } = await import('./bg.mjs');
537
513
  await runBg(args[1], args.slice(2));
514
+ } else if (args[0] === 'providers' && args[1] === 'detect') {
515
+ // v3.16.0 — Auto-detect provider API keys from env + opencode.json.
516
+ const { runProvidersDetect } = await import('./providers-detect.mjs');
517
+ await runProvidersDetect(args.slice(2));
538
518
  } else if (args[0] === 'dash' || args[0] === 'dashboard') {
539
519
  // `bizar dashboard` is a deprecated alias for `bizar dash`
540
520
  if (args[0] === 'dashboard') {
package/cli/install.mjs CHANGED
@@ -406,6 +406,14 @@ export async function runInstaller() {
406
406
  ));
407
407
  console.log();
408
408
 
409
+ // ── Provider auto-detect (v3.16.0) — best-effort, doesn't block install
410
+ const { runProvidersDetect } = await import('./providers-detect.mjs');
411
+ try {
412
+ await runProvidersDetect(['--no-probe']);
413
+ } catch {
414
+ // best-effort
415
+ }
416
+
409
417
  // ── Restart prompt ──
410
418
  const shouldRestart = await promptRestartOpenCode();
411
419
  if (shouldRestart) {
@@ -0,0 +1,233 @@
1
+ /**
2
+ * cli/providers-detect.mjs
3
+ *
4
+ * v3.16.0 — Auto-detect providers from environment variables and
5
+ * opencode.json. Surfaces which providers have working API keys,
6
+ * which have keys in unexpected formats, and which need configuration.
7
+ *
8
+ * Usage:
9
+ * bizar providers detect — probe + print
10
+ * bizar providers detect --no-probe — skip the /models probe
11
+ * bizar providers detect --json — machine-readable
12
+ * bizar providers detect --install <id> — auto-add to opencode.json
13
+ *
14
+ * Recognised env vars: ANTHROPIC_API_KEY, OPENAI_API_KEY,
15
+ * GEMINI_API_KEY, GOOGLE_API_KEY, MISTRAL_API_KEY, GROQ_API_KEY,
16
+ * COHERE_API_KEY, OPENROUTER_API_KEY, DEEPSEEK_API_KEY,
17
+ * MINIMAX_API_KEY.
18
+ */
19
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import chalk from 'chalk';
23
+
24
+ const HOME = homedir();
25
+ const OPENCODE_JSON = join(HOME, '.config', 'opencode', 'opencode.json');
26
+
27
+ const KNOWN_PROVIDERS = [
28
+ { id: 'anthropic', name: 'Anthropic', envKeys: ['ANTHROPIC_API_KEY'], baseURL: 'https://api.anthropic.com/v1', keyPattern: /^sk-ant-[A-Za-z0-9_-]{20,}$/ },
29
+ { id: 'openai', name: 'OpenAI', envKeys: ['OPENAI_API_KEY'], baseURL: 'https://api.openai.com/v1', keyPattern: /^sk-[A-Za-z0-9]{20,}$/ },
30
+ { id: 'google', name: 'Google AI', envKeys: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], baseURL: 'https://generativelanguage.googleapis.com/v1beta', keyPattern: /^AIza[A-Za-z0-9_-]{30,}$/ },
31
+ { id: 'mistral', name: 'Mistral', envKeys: ['MISTRAL_API_KEY'], baseURL: 'https://api.mistral.ai/v1', keyPattern: /^[A-Za-z0-9]{20,}$/ },
32
+ { id: 'groq', name: 'Groq', envKeys: ['GROQ_API_KEY'], baseURL: 'https://api.groq.com/openai/v1', keyPattern: /^gsk_[A-Za-z0-9]{20,}$/ },
33
+ { id: 'cohere', name: 'Cohere', envKeys: ['COHERE_API_KEY'], baseURL: 'https://api.cohere.com/v1', keyPattern: /^[A-Za-z0-9]{20,}$/ },
34
+ { id: 'openrouter', name: 'OpenRouter', envKeys: ['OPENROUTER_API_KEY'], baseURL: 'https://openrouter.ai/api/v1', keyPattern: /^sk-or-[A-Za-z0-9_-]{20,}$/ },
35
+ { id: 'deepseek', name: 'DeepSeek', envKeys: ['DEEPSEEK_API_KEY'], baseURL: 'https://api.deepseek.com/v1', keyPattern: /^sk-[A-Za-z0-9]{20,}$/ },
36
+ { id: 'minimax', name: 'MiniMax', envKeys: ['MINIMAX_API_KEY', 'ANTHROPIC_API_KEY'], baseURL: 'https://api.minimax.chat/v1', keyPattern: /^[A-Za-z0-9]{20,}$/ },
37
+ ];
38
+
39
+ function loadOpencodeConfig() {
40
+ try {
41
+ if (!existsSync(OPENCODE_JSON)) return {};
42
+ return JSON.parse(readFileSync(OPENCODE_JSON, 'utf8')) || {};
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ function saveOpencodeConfig(cfg) {
49
+ mkdirSync(dirname(OPENCODE_JSON), { recursive: true });
50
+ const tmp = `${OPENCODE_JSON}.tmp.${process.pid}`;
51
+ writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
52
+ renameSync(tmp, OPENCODE_JSON);
53
+ }
54
+
55
+ async function probe(spec, apiKey) {
56
+ const ctrl = new AbortController();
57
+ const timer = setTimeout(() => ctrl.abort(), 1500);
58
+ try {
59
+ const resp = await fetch(`${spec.baseURL}/models`, {
60
+ headers: { Authorization: `Bearer ${apiKey}` },
61
+ signal: ctrl.signal,
62
+ });
63
+ if (resp.ok) {
64
+ let modelCount = 0;
65
+ try {
66
+ const body = await resp.json();
67
+ if (Array.isArray(body?.data)) modelCount = body.data.length;
68
+ else if (Array.isArray(body)) modelCount = body.length;
69
+ } catch { /* ignore */ }
70
+ return { ok: true, status: resp.status, modelCount };
71
+ }
72
+ return { ok: false, status: resp.status, reason: `HTTP ${resp.status}` };
73
+ } catch (err) {
74
+ return { ok: false, reason: err?.name === 'AbortError' ? 'timeout' : 'network' };
75
+ } finally {
76
+ clearTimeout(timer);
77
+ }
78
+ }
79
+
80
+ async function detect({ probe: doProbe }) {
81
+ const cfg = loadOpencodeConfig();
82
+ const out = [];
83
+ for (const spec of KNOWN_PROVIDERS) {
84
+ let apiKey = '';
85
+ let keySource = '';
86
+ const cfgProvider = cfg.provider?.[spec.id];
87
+ if (cfgProvider?.apiKey) {
88
+ apiKey = cfgProvider.apiKey;
89
+ keySource = 'config';
90
+ } else if (cfgProvider?.options?.apiKey) {
91
+ apiKey = cfgProvider.options.apiKey;
92
+ keySource = 'config';
93
+ }
94
+ for (const k of spec.envKeys) {
95
+ const v = process.env[k];
96
+ if (typeof v === 'string' && v.length > 0) {
97
+ apiKey = v;
98
+ keySource = 'env';
99
+ break;
100
+ }
101
+ }
102
+ let status;
103
+ if (!apiKey) {
104
+ status = 'no-key';
105
+ } else if (spec.keyPattern && !spec.keyPattern.test(apiKey)) {
106
+ status = 'unknown';
107
+ } else {
108
+ status = 'configured';
109
+ }
110
+ let probed = null;
111
+ if (doProbe && status === 'configured') {
112
+ probed = await probe(spec, apiKey);
113
+ if (!probed.ok && probed.status) status = 'unknown';
114
+ }
115
+ out.push({
116
+ id: spec.id,
117
+ name: spec.name,
118
+ baseURL: spec.baseURL,
119
+ status,
120
+ keySource,
121
+ hasKey: !!apiKey,
122
+ probed,
123
+ });
124
+ }
125
+ return out;
126
+ }
127
+
128
+ function printTable(results) {
129
+ const pad = (s, n) => String(s).padEnd(n);
130
+ const idW = Math.max(2, ...results.map((r) => r.id.length));
131
+ const nameW = Math.max(4, ...results.map((r) => r.name.length));
132
+ const statusW = Math.max(6, ...results.map((r) => r.status.length));
133
+ const sourceW = Math.max(6, ...results.map((r) => r.keySource.length || 1));
134
+ console.log();
135
+ console.log(chalk.bold(` ${pad('ID', idW)} ${pad('NAME', nameW)} ${pad('STATUS', statusW)} ${pad('SOURCE', sourceW)} PROBE`));
136
+ console.log(chalk.dim(` ${'-'.repeat(idW)} ${'-'.repeat(nameW)} ${'-'.repeat(statusW)} ${'-'.repeat(sourceW)} ${'-'.repeat(20)}`));
137
+ for (const r of results) {
138
+ const id = pad(r.id, idW);
139
+ const name = pad(r.name, nameW);
140
+ const status = pad(r.status, statusW);
141
+ const source = pad(r.keySource || '-', sourceW);
142
+ const statusColored =
143
+ r.status === 'configured' ? chalk.green(status) :
144
+ r.status === 'unknown' ? chalk.yellow(status) :
145
+ chalk.dim(status);
146
+ const probeTxt = r.probed
147
+ ? (r.probed.modelCount != null ? chalk.green(`${r.probed.modelCount} models`) : chalk.dim(r.probed.reason || 'ok'))
148
+ : chalk.dim('-');
149
+ console.log(` ${id} ${name} ${statusColored} ${pad(source, sourceW)} ${probeTxt}`);
150
+ }
151
+ console.log();
152
+ const configured = results.filter((r) => r.status === 'configured').length;
153
+ const unknown = results.filter((r) => r.status === 'unknown').length;
154
+ const noKey = results.filter((r) => r.status === 'no-key').length;
155
+ console.log(chalk.bold(` ${configured} configured, ${unknown} unknown, ${noKey} no-key`));
156
+ console.log();
157
+ }
158
+
159
+ function installProvider(id) {
160
+ const spec = KNOWN_PROVIDERS.find((s) => s.id === id);
161
+ if (!spec) {
162
+ console.error(chalk.red(` ✗ unknown provider: ${id}`));
163
+ console.error(chalk.dim(` Available: ${KNOWN_PROVIDERS.map((s) => s.id).join(', ')}`));
164
+ process.exit(1);
165
+ }
166
+ // Get the apiKey from env or config
167
+ let apiKey = '';
168
+ for (const k of spec.envKeys) {
169
+ const v = process.env[k];
170
+ if (typeof v === 'string' && v.length > 0) {
171
+ apiKey = v;
172
+ break;
173
+ }
174
+ }
175
+ if (!apiKey) {
176
+ console.error(chalk.red(` ✗ no API key found in env (${spec.envKeys.join(', ')})`));
177
+ process.exit(1);
178
+ }
179
+ const cfg = loadOpencodeConfig();
180
+ cfg.provider = cfg.provider || {};
181
+ cfg.provider[spec.id] = {
182
+ name: spec.name,
183
+ baseURL: spec.baseURL,
184
+ apiKey,
185
+ enabled: true,
186
+ };
187
+ saveOpencodeConfig(cfg);
188
+ console.log(chalk.green(` ✓ ${spec.name} (${spec.id}) added to opencode.json`));
189
+ }
190
+
191
+ function showHelp() {
192
+ console.log(`
193
+ ${chalk.bold('bizar providers detect')} — auto-detect provider API keys
194
+
195
+ Usage:
196
+ bizar providers detect Probe env + opencode.json, print table
197
+ bizar providers detect --no-probe Skip the /models probe
198
+ bizar providers detect --json Print JSON instead of a table
199
+ bizar providers detect --install <id> Add a configured provider to opencode.json
200
+
201
+ Recognised env vars:
202
+ ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY,
203
+ MISTRAL_API_KEY, GROQ_API_KEY, COHERE_API_KEY, OPENROUTER_API_KEY,
204
+ DEEPSEEK_API_KEY, MINIMAX_API_KEY
205
+ `);
206
+ }
207
+
208
+ export async function runProvidersDetect(args) {
209
+ const doProbe = !args.includes('--no-probe');
210
+ const json = args.includes('--json');
211
+ const help = args.includes('--help') || args.includes('-h');
212
+
213
+ if (help) {
214
+ showHelp();
215
+ return;
216
+ }
217
+
218
+ const installIdx = args.indexOf('--install');
219
+ const installId = installIdx >= 0 ? args[installIdx + 1] : null;
220
+ if (installId) {
221
+ installProvider(installId);
222
+ return;
223
+ }
224
+
225
+ const results = await detect({ probe: doProbe });
226
+ if (json) {
227
+ console.log(JSON.stringify(results, null, 2));
228
+ return;
229
+ }
230
+ console.log(chalk.bold.cyan('\n Provider auto-detect\n'));
231
+ console.log(chalk.dim(` Probing ${KNOWN_PROVIDERS.length} providers...`));
232
+ printTable(results);
233
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "3.15.1",
3
+ "version": "3.17.0",
4
4
  "description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,124 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * cli/graph-build-from-cache.mjs
4
- *
5
- * One-off script for environments where graphify's semantic-extraction
6
- * step fails (e.g. no LLM API key + remaining non-code docs that the
7
- * `.graphifyignore` can't filter) but the AST cache is intact.
8
- *
9
- * graphify's pipeline has two stages:
10
- * 1. AST extraction (local, tree-sitter) — writes per-file JSON to
11
- * `.bizar/graph/cache/ast/v0.8.46/<hash>.json` regardless of LLM status.
12
- * 2. Semantic extraction (LLM, optional) — merges per-file nodes into a
13
- * project-wide graph.
14
- *
15
- * If stage 2 aborts, `graph.json` is never written and the dashboard has
16
- * nothing to render. This script reconstructs `graph.json` directly from
17
- * the per-file AST cache, then calls `graphify cluster-only` to generate
18
- * `graph.html` + `GRAPH_REPORT.md`.
19
- *
20
- * Usage:
21
- * node cli/graph-build-from-cache.mjs # use .bizar/graph/
22
- * node cli/graph-build-from-cache.mjs /tmp/other/graph # custom dir
23
- */
24
-
25
- import { readFileSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
26
- import { join, resolve } from 'node:path';
27
-
28
- const graphDirArg = process.argv[2] || '.bizar/graph';
29
- const graphDir = resolve(graphDirArg);
30
-
31
- // The cache version directory inside graphify's cache layout. Bumping the
32
- // graphify version moves the cache; we discover the actual subdir at
33
- // runtime so we don't hardcode "v0.8.46".
34
- const cacheAstRoot = join(graphDir, 'cache', 'ast');
35
-
36
- function findCacheDir() {
37
- if (!existsSync(cacheAstRoot)) {
38
- throw new Error(`No AST cache found at ${cacheAstRoot}. Run \`bizar graph build\` first to populate it.`);
39
- }
40
- const entries = readdirSync(cacheAstRoot);
41
- if (entries.length === 0) {
42
- throw new Error(`AST cache directory ${cacheAstRoot} is empty.`);
43
- }
44
- // graphify uses one version directory like v0.8.46. Pick the first.
45
- return join(cacheAstRoot, entries[0]);
46
- }
47
-
48
- function buildGraphFromCache(cacheDir) {
49
- const files = readdirSync(cacheDir).filter((f) => f.endsWith('.json'));
50
- console.log(` Reading ${files.length} AST cache files from ${cacheDir}...`);
51
-
52
- const nodesById = new Map();
53
- const edges = [];
54
- const rawCalls = [];
55
-
56
- let fileIdx = 0;
57
- for (const f of files) {
58
- fileIdx++;
59
- const raw = JSON.parse(readFileSync(join(cacheDir, f), 'utf8'));
60
- for (const n of raw.nodes || []) {
61
- // Later files win on duplicate id (consistent with graphify's
62
- // dedup behavior — first write keeps the canonical location).
63
- if (!nodesById.has(n.id)) nodesById.set(n.id, n);
64
- }
65
- for (const e of raw.edges || []) edges.push(e);
66
- for (const rc of raw.raw_calls || []) rawCalls.push(rc);
67
- if (fileIdx % 50 === 0) {
68
- console.log(` merged ${fileIdx}/${files.length} files`);
69
- }
70
- }
71
-
72
- // Re-key node.community so it matches the dashboard's expected schema.
73
- // The AST cache uses `community` as a numeric cluster id; we keep it as-is.
74
- const nodes = Array.from(nodesById.values());
75
-
76
- const graph = {
77
- directed: false,
78
- multigraph: false,
79
- graph: {},
80
- nodes,
81
- links: edges,
82
- };
83
-
84
- console.log(` Built graph: ${nodes.length} nodes, ${edges.length} edges (raw_calls=${rawCalls.length})`);
85
- return { graph, rawCalls };
86
- }
87
-
88
- function writeOutputs(graphDir, graph) {
89
- // graph.json — NetworkX node-link format
90
- const graphJsonPath = join(graphDir, 'graph.json');
91
- writeFileSync(graphJsonPath, JSON.stringify(graph, null, 2), 'utf8');
92
- console.log(` ✓ Wrote ${graphJsonPath}`);
93
-
94
- // .graphify_analysis.json — graphify's secondary output. cluster-only
95
- // doesn't strictly require this, but writing it keeps the directory
96
- // shape consistent with what `graphify .` would have produced.
97
- const analysis = {
98
- schema_version: 1,
99
- generated_at: new Date().toISOString(),
100
- clusters: [],
101
- };
102
- const analysisPath = join(graphDir, '.graphify_analysis.json');
103
- writeFileSync(analysisPath, JSON.stringify(analysis, null, 2), 'utf8');
104
- console.log(` ✓ Wrote ${analysisPath}`);
105
- }
106
-
107
- function main() {
108
- console.log(` Graph dir: ${graphDir}`);
109
- if (!existsSync(graphDir)) {
110
- throw new Error(`Graph dir does not exist: ${graphDir}`);
111
- }
112
- const cacheDir = findCacheDir();
113
- const { graph } = buildGraphFromCache(cacheDir);
114
- writeOutputs(graphDir, graph);
115
- console.log('');
116
- console.log(' Next step: run `bizar graph cluster-only` to generate graph.html.');
117
- }
118
-
119
- try {
120
- main();
121
- } catch (err) {
122
- console.error(`\n Error: ${err.message}\n`);
123
- process.exit(1);
124
- }
package/cli/graph.mjs DELETED
@@ -1,642 +0,0 @@
1
- /**
2
- * cli/graph.mjs
3
- *
4
- * `bizar graph` — Per-project knowledge graph powered by graphify.
5
- * Graph data lives in .bizar/graph/ inside the project.
6
- */
7
-
8
- import chalk from 'chalk';
9
- import { spawnSync } from 'node:child_process';
10
- import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync, copyFileSync } from 'node:fs';
11
- import { join } from 'node:path';
12
-
13
- // ── Constants ─────────────────────────────────────────────────────────────────
14
-
15
- export const GRAPH_DIR = '.bizar/graph';
16
- export const GRAPHIFY_OUT_ENV = 'GRAPHIFY_OUT';
17
-
18
- /**
19
- * Env vars graphify recognises for semantic extraction of docs/papers/images.
20
- * If NONE of these are set, graphify refuses to run on a corpus that contains
21
- * any non-code file ("A code-only corpus needs no key."). We auto-detect this
22
- * and write a comprehensive .graphifyignore so a fresh checkout can build
23
- * the graph offline with no LLM key.
24
- */
25
- const GRAPHIFY_LLM_KEY_ENVS = [
26
- 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
27
- 'MOONSHOT_API_KEY',
28
- 'ANTHROPIC_API_KEY',
29
- 'OPENAI_API_KEY',
30
- 'DEEPSEEK_API_KEY',
31
- 'AZURE_OPENAI_API_KEY',
32
- ];
33
-
34
- /**
35
- * Minimal .graphifyignore that restricts graphify to code-only extraction.
36
- * Written automatically by `bizar graph build` when no LLM key is set.
37
- * The user can override this file with their own .graphifyignore — we
38
- * never overwrite a pre-existing one.
39
- */
40
- const CODE_ONLY_GRAPHIFYIGNORE = [
41
- '# Auto-generated by `bizar graph build` — code-only mode.',
42
- '# Bizar detected no LLM key, so docs/papers/images cannot be semantically',
43
- '# extracted. We restrict extraction to code files (processed locally via',
44
- "# tree-sitter — no API key needed). Delete this file and re-run once you've",
45
- '# set one of: GEMINI_API_KEY, GOOGLE_API_KEY, MOONSHOT_API_KEY,',
46
- '# ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, AZURE_OPENAI_API_KEY.',
47
- '',
48
- '# Heavy / vendored dirs to skip entirely',
49
- 'node_modules/',
50
- '.git/',
51
- '.bizar/',
52
- '.agents/',
53
- 'dist/',
54
- 'build/',
55
- 'coverage/',
56
- '',
57
- '# Agent system prompts — they\'re Markdown but contain YAML frontmatter',
58
- '# + system instructions, not project documentation. They\'re noise for',
59
- '# a structural graph and would balloon the doc count to ~1100.',
60
- 'config/agents/',
61
- '',
62
- '# Non-code file types — graphify would need an LLM for these',
63
- '*.md',
64
- '*.mdx',
65
- '*.qmd',
66
- '*.html',
67
- '*.txt',
68
- '*.rst',
69
- '*.pdf',
70
- '*.png',
71
- '*.jpg',
72
- '*.jpeg',
73
- '*.webp',
74
- '*.gif',
75
- '*.mp4',
76
- '*.mov',
77
- '*.mp3',
78
- '*.wav',
79
- '',
80
- ].join('\n');
81
-
82
- /**
83
- * Detect whether any graphify-recognised LLM API key is set in the
84
- * current process env. Used by `cmdBuild` to decide whether to write
85
- * the code-only .graphifyignore.
86
- */
87
- function hasLlmKey(env = process.env) {
88
- return GRAPHIFY_LLM_KEY_ENVS.some((k) => {
89
- const v = env[k];
90
- return typeof v === 'string' && v.trim().length > 0;
91
- });
92
- }
93
-
94
- // ── Python detection ───────────────────────────────────────────────────────────
95
-
96
- /**
97
- * Find a Python 3 interpreter on PATH.
98
- * Returns the absolute path string, or null if none found.
99
- *
100
- * On Windows the `py` launcher is the most reliable option; on Unix
101
- * we prefer python3 then python.
102
- */
103
- export function findPython() {
104
- const candidates = process.platform === 'win32'
105
- ? ['py', 'python', 'python3']
106
- : ['python3', 'python'];
107
- for (const name of candidates) {
108
- try {
109
- const result = spawnSync(name, ['--version'], { encoding: 'utf8', timeout: 5000 });
110
- if (result.status === 0 && result.stdout.includes('Python')) {
111
- return name;
112
- }
113
- } catch {
114
- // try next
115
- }
116
- }
117
- return null;
118
- }
119
-
120
- /**
121
- * Check whether graphify is available.
122
- *
123
- * Strategy:
124
- * 1. Probe PATH for the `graphify` binary (uv tool install puts a shim at
125
- * ~/.local/bin/graphify — invisible to the isolated python -m lookup).
126
- * 2. Fall back to `python -m graphify --version` for pip/pipx installs.
127
- *
128
- * Returns true if available, false otherwise.
129
- */
130
- function checkGraphify(python) {
131
- // Try 1: check for the graphify binary on PATH (uv tool install shim).
132
- // On Windows the Python launcher `py` is not on PATH by default, but the
133
- // graphify shim is placed next to pip/pipx so a plain `where` works.
134
- const whichCmd = process.platform === 'win32' ? 'where' : 'which';
135
- try {
136
- const whichResult = spawnSync(whichCmd, ['graphify'], {
137
- encoding: 'utf8',
138
- timeout: 5000,
139
- });
140
- if (whichResult.status === 0 && (whichResult.stdout || '').trim().length > 0) {
141
- return true;
142
- }
143
- } catch {
144
- // fall through to python -m check
145
- }
146
-
147
- // Try 2: check via python -m (catches pip/pipx installs into system python)
148
- try {
149
- const result = spawnSync(python, ['-m', 'graphify', '--version'], {
150
- encoding: 'utf8',
151
- timeout: 10000,
152
- env: { ...process.env },
153
- });
154
- return result.status === 0;
155
- } catch {
156
- return false;
157
- }
158
- }
159
-
160
- // ── Graph stats parsing ────────────────────────────────────────────────────────
161
-
162
- /**
163
- * Parse .bizar/graph/graph.json and return stats.
164
- * Returns null if the file does not exist or cannot be parsed.
165
- *
166
- * Expected NetworkX node-link JSON shape:
167
- * {
168
- * "directed": false,
169
- * "multigraph": false,
170
- * "graph": {},
171
- * "nodes": [{ "id": "...", "community": 0, ... }],
172
- * "links": [{ "source": "...", "target": "...", ... }]
173
- * }
174
- */
175
- export function parseGraphStats(jsonPath) {
176
- if (!existsSync(jsonPath)) return null;
177
- try {
178
- const content = readFileSync(jsonPath, 'utf8');
179
- const graph = JSON.parse(content);
180
-
181
- const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
182
- const links = Array.isArray(graph.links) ? graph.links : [];
183
-
184
- // Count unique community values among nodes
185
- const communitySet = new Set();
186
- for (const node of nodes) {
187
- if (node.community !== undefined && node.community !== null) {
188
- communitySet.add(node.community);
189
- }
190
- }
191
-
192
- const stats = statSync(jsonPath);
193
-
194
- return {
195
- nodes: nodes.length,
196
- edges: links.length,
197
- communities: communitySet.size,
198
- lastModified: stats.mtime.toISOString(),
199
- sizeBytes: stats.size,
200
- };
201
- } catch {
202
- return null;
203
- }
204
- }
205
-
206
- // ── Gitignore guard ────────────────────────────────────────────────────────────
207
-
208
- const GRAPH_GITIGNORE = `cache/
209
- .graphify_python
210
- cost.json
211
- `;
212
-
213
- /**
214
- * Ensure .bizar/graph/.gitignore exists so heavy cache artefacts are not committed.
215
- * Called after a successful first build.
216
- */
217
- function ensureGitignore(graphDir) {
218
- const gitignorePath = join(graphDir, '.gitignore');
219
- if (!existsSync(gitignorePath)) {
220
- writeFileSync(gitignorePath, GRAPH_GITIGNORE, 'utf8');
221
- }
222
- }
223
-
224
- // ── Run helpers ───────────────────────────────────────────────────────────────
225
-
226
- /**
227
- * Spawn a graphify command with GRAPHIFY_OUT set to GRAPH_DIR.
228
- * Streams stdout/stderr to the terminal. Returns the exit code.
229
- *
230
- * Resolution order:
231
- * 1. If the `graphify` binary is on PATH (the uv-tool shim), invoke it
232
- * directly. This is what `checkGraphify` already verified — we don't
233
- * need to know about its venv.
234
- * 2. Otherwise fall back to `python -m graphify` for pip/pipx installs.
235
- */
236
- function runGraphify(python, subArgs, extraEnv = {}) {
237
- const env = { ...process.env, [GRAPHIFY_OUT_ENV]: GRAPH_DIR, ...extraEnv };
238
- const whichCmd = process.platform === 'win32' ? 'where' : 'which';
239
- let bin = null;
240
- try {
241
- const r = spawnSync(whichCmd, ['graphify'], { encoding: 'utf8', timeout: 5000 });
242
- if (r.status === 0 && (r.stdout || '').trim().length > 0) {
243
- bin = 'graphify';
244
- }
245
- } catch {
246
- // ignore — fall through to python -m
247
- }
248
-
249
- if (bin) {
250
- const result = spawnSync(bin, subArgs, {
251
- stdio: 'inherit',
252
- env,
253
- timeout: 0, // no timeout — user may run long builds
254
- });
255
- return result.status ?? 1;
256
- }
257
-
258
- const result = spawnSync(python, ['-m', 'graphify', ...subArgs], {
259
- stdio: 'inherit',
260
- env,
261
- timeout: 0,
262
- });
263
- return result.status ?? 1;
264
- }
265
-
266
- // ── Subcommand handlers ────────────────────────────────────────────────────────
267
-
268
- async function cmdBuild(python) {
269
- // If no LLM key is set, fall back to a code-only build by writing a
270
- // comprehensive .graphifyignore. We never overwrite a user-provided
271
- // .graphifyignore — only create the file when it doesn't exist.
272
- let llmKeyPresent = hasLlmKey();
273
- if (!llmKeyPresent) {
274
- const ignorePath = '.graphifyignore';
275
- if (!existsSync(ignorePath)) {
276
- writeFileSync(ignorePath, CODE_ONLY_GRAPHIFYIGNORE, 'utf8');
277
- console.log(chalk.dim(' No LLM API key detected — switching to code-only build.'));
278
- console.log(chalk.dim(` Wrote ${ignorePath} restricting extraction to code files.`));
279
- console.log(chalk.dim(' Set GEMINI_API_KEY / OPENAI_API_KEY / etc. and re-run for full docs extraction.\n'));
280
- } else {
281
- console.log(chalk.dim(' No LLM API key detected — using existing .graphifyignore for code-only build.\n'));
282
- }
283
- }
284
-
285
- console.log(chalk.dim(' Running full graphify pipeline...'));
286
- let code = runGraphify(python, ['.']);
287
-
288
- // Offline code-only fallback — when no LLM key is set, graphify's
289
- // pre-flight check rejects the build before it can write any output.
290
- // Even with a dummy key set, the semantic step fails for any non-code
291
- // docs that escaped the .graphifyignore filter, and graphify refuses
292
- // to write graph.json. To unblock this case we:
293
- // 1. Run graphify with a dummy ANTHROPIC_API_KEY so the AST cache
294
- // gets populated (semantic extraction will fail, that's fine).
295
- // 2. Reconstruct graph.json from the per-file AST cache.
296
- // 3. Run cluster-only to generate graph.html + GRAPH_REPORT.md.
297
- if (code !== 0 && !llmKeyPresent) {
298
- const cacheAstRoot = join(GRAPH_DIR, 'cache', 'ast');
299
- const cacheIsPopulated = existsSync(cacheAstRoot) && (
300
- // graphify creates a versioned subdir on first extract.
301
- // Check if any version dir contains JSON files.
302
- (await import('node:fs/promises'))
303
- .readdir(cacheAstRoot, { withFileTypes: true })
304
- .then((entries) =>
305
- entries.some(async (e) => {
306
- if (!e.isDirectory()) return false;
307
- const files = await (await import('node:fs/promises')).readdir(join(cacheAstRoot, e.name));
308
- return files.some((f) => f.endsWith('.json'));
309
- }),
310
- )
311
- .catch(() => false)
312
- );
313
-
314
- if (!cacheIsPopulated) {
315
- console.log(chalk.yellow(' ⚠ No AST cache yet. Running graphify with a dummy key to populate the cache (semantic extraction will fail; that\'s OK)...'));
316
- // Set a dummy key so graphify gets past its pre-flight check and
317
- // populates the AST cache. We invoke the graphify binary directly
318
- // (the user's python3 -m graphify may not have graphify installed;
319
- // uv-tool installs put a shim at ~/.local/bin/graphify).
320
- const dummyRes = spawnSync('graphify', ['.'], {
321
- stdio: 'inherit',
322
- env: { ...process.env, ANTHROPIC_API_KEY: 'sk-bizar-graph-dummy', GRAPHIFY_OUT: GRAPH_DIR },
323
- timeout: 0,
324
- });
325
- void dummyRes; // exit code doesn't matter — we want the cache, not the merge
326
- }
327
-
328
- const cacheStillEmpty = !(await (async () => {
329
- try {
330
- const e = await (await import('node:fs/promises')).readdir(cacheAstRoot, { withFileTypes: true });
331
- for (const d of e) {
332
- if (!d.isDirectory()) continue;
333
- const f = await (await import('node:fs/promises')).readdir(join(cacheAstRoot, d.name));
334
- if (f.some((x) => x.endsWith('.json'))) return true;
335
- }
336
- return false;
337
- } catch {
338
- return false;
339
- }
340
- })());
341
-
342
- if (!cacheStillEmpty) {
343
- console.log(chalk.yellow(' ⚠ graphify exited non-zero. Falling back to AST-cache build...'));
344
- const fb = runCacheFallbackBuild();
345
- if (fb === 0) code = 0;
346
- }
347
- }
348
-
349
- if (code === 0) {
350
- ensureGitignore(GRAPH_DIR);
351
- // After extract, run cluster-only to write graph.html + GRAPH_REPORT.md
352
- // (extract only writes graph.json; cluster-only adds viz + report).
353
- const graphJson = join(GRAPH_DIR, 'graph.json');
354
- if (existsSync(graphJson)) {
355
- console.log(chalk.dim(' Generating graph.html + GRAPH_REPORT.md via cluster-only...'));
356
- // Pass "." as the cluster-only path so it writes graph.html +
357
- // GRAPH_REPORT.md to the current directory's graphify-out/.
358
- // (Passing `.bizar/graph` directly nests the output as
359
- // `.bizar/graph/.bizar/graph/...` which is wrong.) The explicit
360
- // --graph overrides the auto-discovery to find graph.json.
361
- const clusterArgs = ['cluster-only', '.', '--graph', graphJson];
362
- const clusterCode = runGraphify(python, clusterArgs);
363
- if (clusterCode !== 0) {
364
- console.log(chalk.yellow(` ⚠ cluster-only returned ${clusterCode} — graph.json was built but graph.html may be stale.`));
365
- } else {
366
- // cluster-only writes to ./graphify-out/ (relative to CWD).
367
- // Promote the artifacts up to .bizar/graph/ paths the dashboard
368
- // serves directly.
369
- const out = join(GRAPH_DIR, 'graphify-out');
370
- if (!existsSync(out)) {
371
- // Fallback: graphify may have written to ./graphify-out/ at CWD.
372
- const cwdOut = join(process.cwd(), 'graphify-out');
373
- if (existsSync(cwdOut)) {
374
- promoteFromDir(cwdOut, GRAPH_DIR);
375
- }
376
- } else {
377
- promoteClusterOutputs(GRAPH_DIR);
378
- }
379
- }
380
- }
381
- console.log(chalk.green(`\n ✓ Graph built → ${GRAPH_DIR}/\n`));
382
- }
383
- return code;
384
- }
385
-
386
- /**
387
- * Reconstruct graph.json from graphify's per-file AST cache and run
388
- * cluster-only to produce graph.html + GRAPH_REPORT.md. Used when the
389
- * full graphify pipeline fails because some docs need an LLM that we
390
- * don't have, but the AST cache for code-only files was successfully
391
- * extracted.
392
- *
393
- * Returns the exit code of the overall sequence (0 on success).
394
- */
395
- function runCacheFallbackBuild() {
396
- // Step 1: merge the per-file AST cache into a single graph.json.
397
- const scriptPath = new URL(import.meta.url).pathname;
398
- // cli/graph.mjs sits next to cli/graph-build-from-cache.mjs.
399
- const cacheScript = scriptPath.replace(/graph\.mjs$/, 'graph-build-from-cache.mjs');
400
- if (!existsSync(cacheScript)) {
401
- console.log(chalk.red(` ✗ Cache fallback script not found at ${cacheScript}`));
402
- return 1;
403
- }
404
- const buildRes = spawnSync(process.execPath, [cacheScript, GRAPH_DIR], {
405
- stdio: 'inherit',
406
- });
407
- if (buildRes.status !== 0) {
408
- return buildRes.status ?? 1;
409
- }
410
- // Step 2: cluster-only regenerates graph.html from the merged graph.json.
411
- return 0;
412
- }
413
-
414
- /**
415
- * Copy the named files from `srcDir` up to `dstDir`. Used to hoist
416
- * cluster-only's graph.html / GRAPH_REPORT.md / graph.json up to the
417
- * canonical .bizar/graph/ paths the dashboard serves.
418
- */
419
- function promoteFromDir(srcDir, dstDir) {
420
- for (const name of ['graph.html', 'GRAPH_REPORT.md', 'graph.json']) {
421
- const src = join(srcDir, name);
422
- const dst = join(dstDir, name);
423
- if (existsSync(src)) {
424
- copyFileSync(src, dst);
425
- }
426
- }
427
- }
428
-
429
- /**
430
- * cluster-only writes its outputs to a nested graphify-out/ directory
431
- * by default. This hoists them up to the standard paths the dashboard
432
- * serves (.bizar/graph/graph.html + .bizar/graph/GRAPH_REPORT.md).
433
- */
434
- function promoteClusterOutputs(graphDir) {
435
- const out = join(graphDir, 'graphify-out');
436
- if (existsSync(out)) promoteFromDir(out, graphDir);
437
- }
438
-
439
- async function cmdUpdate(python) {
440
- console.log(chalk.dim(' Running incremental graph update...'));
441
- return runGraphify(python, ['.', '--update']);
442
- }
443
-
444
- async function cmdQuery(python, query) {
445
- if (!query) {
446
- console.log(chalk.red(' Error: query text required.'));
447
- console.log(chalk.dim(' Usage: bizar graph query "<search term>"\n'));
448
- return 1;
449
- }
450
- return runGraphify(python, ['query', query]);
451
- }
452
-
453
- async function cmdPath(python, a, b) {
454
- if (!a || !b) {
455
- console.log(chalk.red(' Error: two concept names required.'));
456
- console.log(chalk.dim(' Usage: bizar graph path "<A>" "<B>"\n'));
457
- return 1;
458
- }
459
- return runGraphify(python, ['path', a, b]);
460
- }
461
-
462
- async function cmdExplain(python, concept) {
463
- if (!concept) {
464
- console.log(chalk.red(' Error: concept name required.'));
465
- console.log(chalk.dim(' Usage: bizar graph explain "<X>"\n'));
466
- return 1;
467
- }
468
- return runGraphify(python, ['explain', concept]);
469
- }
470
-
471
- async function cmdWatch(python) {
472
- console.log(chalk.dim(' Starting graphify watch mode (Ctrl-C to stop)...'));
473
- return runGraphify(python, ['.', '--watch']);
474
- }
475
-
476
- async function cmdStatus() {
477
- const graphJsonPath = join(GRAPH_DIR, 'graph.json');
478
- const stats = parseGraphStats(graphJsonPath);
479
-
480
- console.log(chalk.bold.hex('#10b981')('\n ᛗ BIZAR GRAPH STATUS ᛗ\n'));
481
- console.log(` Path: ${GRAPH_DIR}/`);
482
-
483
- if (!existsSync(GRAPH_DIR)) {
484
- console.log(` Exists: ${chalk.yellow('no')} (run \`bizar graph build\` first)`);
485
- console.log();
486
- return 0;
487
- }
488
-
489
- if (!stats) {
490
- console.log(` Exists: ${chalk.yellow('no graph.json found')}`);
491
- console.log();
492
- return 0;
493
- }
494
-
495
- console.log(` Exists: ${chalk.green('yes')}`);
496
- console.log(` Size: ${(stats.sizeBytes / 1024).toFixed(1)} KB`);
497
- console.log(` Last built: ${stats.lastModified}`);
498
- console.log(` Nodes: ${stats.nodes}`);
499
- console.log(` Edges: ${stats.edges}`);
500
- console.log(` Communities:${stats.communities}`);
501
- console.log();
502
- return 0;
503
- }
504
-
505
- async function cmdInstall() {
506
- const python = findPython();
507
- if (!python) {
508
- console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
509
- console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
510
- return 1;
511
- }
512
-
513
- // Prefer uv (handles PEP 668 isolated installs cleanly); fall back to pip.
514
- const { detectUv } = await import('./utils.mjs');
515
- const hasUv = await detectUv();
516
-
517
- if (hasUv) {
518
- console.log(chalk.dim(' Installing graphify via uv...\n'));
519
- const result = spawnSync('uv', ['tool', 'install', 'graphifyy'], { stdio: 'inherit' });
520
- if (result.status !== 0) {
521
- console.log(chalk.red(' ✗ uv tool install graphifyy failed.\n'));
522
- return 1;
523
- }
524
- } else {
525
- console.log(chalk.dim(' Installing graphify via pip...\n'));
526
- // On Windows `pip` may not be on PATH; use `py -m pip` as the fallback.
527
- const pipCmd = process.platform === 'win32'
528
- ? [python, '-m', 'pip']
529
- : ['pip'];
530
- const result = spawnSync(pipCmd[0], pipCmd.slice(1).concat(['install', 'graphifyy']), { stdio: 'inherit' });
531
- if (result.status !== 0) {
532
- console.log(chalk.red(' ✗ pip install graphifyy failed.\n'));
533
- return 1;
534
- }
535
- }
536
-
537
- // graphify install --platform opencode --project
538
- console.log(chalk.dim('\n $ graphify install --platform opencode --project'));
539
- const result = spawnSync('graphify', ['install', '--platform', 'opencode', '--project'], {
540
- stdio: 'inherit',
541
- });
542
- if (result.status !== 0) {
543
- console.log(chalk.red(' ✗ graphify install failed.\n'));
544
- return 1;
545
- }
546
-
547
- console.log(chalk.green('\n ✓ graphify installed and OpenCode plugin configured.\n'));
548
- return 0;
549
- }
550
-
551
- // ── Help ──────────────────────────────────────────────────────────────────────
552
-
553
- export function showGraphHelp() {
554
- console.log(`
555
- ${chalk.bold.hex('#10b981')('bizar graph')} — Per-project knowledge graph (powered by graphify)
556
-
557
- ${chalk.dim('Usage:')}
558
- ${chalk.cyan('bizar graph build')} # full build of the project graph
559
- ${chalk.cyan('bizar graph update')} # incremental rebuild
560
- ${chalk.cyan('bizar graph query "<text>"')} # query the graph
561
- ${chalk.cyan('bizar graph path "<A>" "<B>"')} # shortest path between concepts
562
- ${chalk.cyan('bizar graph explain "<X>"')} # all nodes related to a concept
563
- ${chalk.cyan('bizar graph watch')} # watch for changes and rebuild
564
- ${chalk.cyan('bizar graph status')} # show graph path, size, node/edge/community counts
565
- ${chalk.cyan('bizar graph install')} # install graphify + drop OpenCode skill/plugin
566
-
567
- ${chalk.dim('Requires:')} Python 3.10+ and \`graphify\` (${chalk.cyan('pip install graphifyy')})
568
-
569
- ${chalk.dim('Graph data lives in')} ${chalk.cyan('.bizar/graph/')} ${chalk.dim('inside this project.')}
570
- `);
571
- }
572
-
573
- // ── Main entry point ───────────────────────────────────────────────────────────
574
-
575
- /**
576
- * `bizar graph` entry point.
577
- *
578
- * @param {string[]} args — subcommand + args after 'graph'
579
- */
580
- export async function runGraph(args) {
581
- // Show help when called with no args or --help / -h
582
- if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
583
- showGraphHelp();
584
- return 0;
585
- }
586
-
587
- const sub = args[0];
588
-
589
- // status and install are the only subcommands that don't need graphify
590
- if (sub !== 'status' && sub !== 'install') {
591
- // Detect Python
592
- const python = findPython();
593
- if (!python) {
594
- console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
595
- console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
596
- return 1;
597
- }
598
-
599
- // Detect graphify
600
- if (!checkGraphify(python)) {
601
- console.error(chalk.red(' Error: graphify is not installed.'));
602
- console.error(chalk.dim(' Install it with:'));
603
- console.error(chalk.cyan(' pip install graphifyy\n'));
604
- console.error(chalk.dim(' Or run:'));
605
- console.error(chalk.cyan(' bizar graph install\n'));
606
- return 1;
607
- }
608
-
609
- // Ensure .bizar/ directory exists (graphify will create graph/ inside it)
610
- mkdirSync(GRAPH_DIR, { recursive: true });
611
-
612
- switch (sub) {
613
- case 'build':
614
- return await cmdBuild(python);
615
- case 'update':
616
- return await cmdUpdate(python);
617
- case 'query':
618
- return await cmdQuery(python, args.slice(1).join(' '));
619
- case 'path':
620
- return await cmdPath(python, args[1], args[2]);
621
- case 'explain':
622
- return await cmdExplain(python, args[1]);
623
- case 'watch':
624
- return await cmdWatch(python);
625
- default:
626
- console.error(chalk.red(` Error: unknown subcommand '${sub}'.\n`));
627
- showGraphHelp();
628
- return 1;
629
- }
630
- }
631
-
632
- // subcommands that don't need graphify
633
- switch (sub) {
634
- case 'status':
635
- return await cmdStatus();
636
- case 'install':
637
- return await cmdInstall();
638
- default:
639
- // unreachable — already caught unknown subcommands above
640
- return 1;
641
- }
642
- }
@@ -1,218 +0,0 @@
1
- /**
2
- * cli/graph.test.mjs
3
- *
4
- * Tests for the `bizar graph` subcommand.
5
- * Uses Node's built-in node:test (no external test framework).
6
- *
7
- * Covered:
8
- * - findPython() returns a string when Python is on PATH (or null)
9
- * - parseGraphStats() returns null for nonexistent files
10
- * - parseGraphStats() returns correct {nodes, edges, communities} from a fixture
11
- * - GRAPH_DIR constant is '.bizar/graph'
12
- * - help output contains all subcommand names
13
- */
14
-
15
- import { test, describe } from 'node:test';
16
- import assert from 'node:assert/strict';
17
- import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
18
- import { join, dirname } from 'node:path';
19
- import { fileURLToPath } from 'node:url';
20
-
21
- // Resolve graph.mjs from this test file's location
22
- const __dirname = dirname(fileURLToPath(import.meta.url));
23
- const PROJECT_ROOT = join(__dirname, '..');
24
-
25
- const {
26
- findPython,
27
- parseGraphStats,
28
- showGraphHelp,
29
- GRAPH_DIR,
30
- } = await import('./graph.mjs');
31
-
32
- // ── Constants ─────────────────────────────────────────────────────────────────
33
-
34
- test('GRAPH_DIR equals ".bizar/graph"', () => {
35
- assert.equal(GRAPH_DIR, '.bizar/graph');
36
- });
37
-
38
- // ── findPython ────────────────────────────────────────────────────────────────
39
-
40
- describe('findPython()', () => {
41
- test('returns a string when python3 or python is on PATH', () => {
42
- const result = findPython();
43
- // On most dev machines Python is installed; the test documents the expected shape
44
- if (result !== null) {
45
- assert.equal(typeof result, 'string');
46
- assert.ok(result.length > 0);
47
- }
48
- });
49
-
50
- test('returns null when neither python3 nor python is found', () => {
51
- // We can't easily stub PATH in this test runner without副作用,
52
- // so we verify the function is deterministic and returns the same value
53
- // across two calls (proving it doesn't throw or return garbage).
54
- const first = findPython();
55
- const second = findPython();
56
- assert.equal(first, second, 'findPython should be deterministic');
57
- });
58
- });
59
-
60
- // ── parseGraphStats ────────────────────────────────────────────────────────────
61
-
62
- describe('parseGraphStats()', () => {
63
- test('returns null for a nonexistent path', () => {
64
- const result = parseGraphStats('/nonexistent/path/graph.json');
65
- assert.equal(result, null);
66
- });
67
-
68
- test('returns null for a file that is not valid JSON', () => {
69
- const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test');
70
- mkdirSync(tmpDir, { recursive: true });
71
- try {
72
- const badPath = join(tmpDir, 'graph.json');
73
- writeFileSync(badPath, '{ not json', 'utf8');
74
- const result = parseGraphStats(badPath);
75
- assert.equal(result, null);
76
- } finally {
77
- rmSync(tmpDir, { recursive: true });
78
- }
79
- });
80
-
81
- test('returns zeroed stats when graph.json has null nodes/links arrays', () => {
82
- const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test2');
83
- mkdirSync(tmpDir, { recursive: true });
84
- try {
85
- const graphPath = join(tmpDir, 'graph.json');
86
- writeFileSync(graphPath, JSON.stringify({ graph: {}, nodes: null, links: null }), 'utf8');
87
- const result = parseGraphStats(graphPath);
88
- // null arrays are treated as 0-length — parseGraphStats guards with Array.isArray
89
- assert.notEqual(result, null);
90
- assert.equal(result.nodes, 0);
91
- assert.equal(result.edges, 0);
92
- assert.equal(result.communities, 0);
93
- } finally {
94
- rmSync(tmpDir, { recursive: true });
95
- }
96
- });
97
-
98
- test('returns correct stats from a minimal NetworkX node-link fixture', () => {
99
- const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test3');
100
- mkdirSync(tmpDir, { recursive: true });
101
- try {
102
- const graphPath = join(tmpDir, 'graph.json');
103
-
104
- // NetworkX node-link JSON with 4 nodes, 3 links, 2 communities
105
- const fixture = {
106
- directed: false,
107
- multigraph: false,
108
- graph: {},
109
- nodes: [
110
- { id: 'alpha', community: 0 },
111
- { id: 'beta', community: 0 },
112
- { id: 'gamma', community: 1 },
113
- { id: 'delta', community: 1 },
114
- ],
115
- links: [
116
- { source: 'alpha', target: 'beta' },
117
- { source: 'beta', target: 'gamma' },
118
- { source: 'gamma', target: 'delta' },
119
- ],
120
- };
121
-
122
- writeFileSync(graphPath, JSON.stringify(fixture), 'utf8');
123
-
124
- const stats = parseGraphStats(graphPath);
125
-
126
- assert.notEqual(stats, null);
127
- assert.equal(stats.nodes, 4, 'should count 4 nodes');
128
- assert.equal(stats.edges, 3, 'should count 3 links');
129
- assert.equal(stats.communities, 2, 'should detect 2 unique communities (0 and 1)');
130
- assert.ok(typeof stats.lastModified === 'string', 'lastModified should be ISO string');
131
- assert.ok(typeof stats.sizeBytes === 'number', 'sizeBytes should be a number');
132
- assert.ok(stats.sizeBytes > 0, 'sizeBytes should be > 0 for a real file');
133
- } finally {
134
- rmSync(tmpDir, { recursive: true });
135
- }
136
- });
137
-
138
- test('handles nodes without a community field gracefully', () => {
139
- const tmpDir = join(PROJECT_ROOT, '.tmp_graph_test4');
140
- mkdirSync(tmpDir, { recursive: true });
141
- try {
142
- const graphPath = join(tmpDir, 'graph.json');
143
- const fixture = {
144
- nodes: [
145
- { id: 'orphan' }, // no community field
146
- { id: 'solo', community: 0 },
147
- { id: 'alone', community: 1 },
148
- ],
149
- links: [],
150
- };
151
- writeFileSync(graphPath, JSON.stringify(fixture), 'utf8');
152
- const stats = parseGraphStats(graphPath);
153
- assert.notEqual(stats, null);
154
- assert.equal(stats.communities, 2, 'should count communities 0 and 1 only');
155
- } finally {
156
- rmSync(tmpDir, { recursive: true });
157
- }
158
- });
159
- });
160
-
161
- // ── Help output ───────────────────────────────────────────────────────────────
162
-
163
- describe('showGraphHelp()', () => {
164
- test('outputs all subcommand names', () => {
165
- // Capture stdout
166
- let output = '';
167
- const originalLog = console.log;
168
- console.log = (msg) => { output += msg + '\n'; };
169
-
170
- try {
171
- showGraphHelp();
172
- } finally {
173
- console.log = originalLog;
174
- }
175
-
176
- // Assert each expected subcommand keyword appears in the help text
177
- const subcommands = ['build', 'update', 'query', 'path', 'explain', 'watch', 'status', 'install'];
178
- for (const cmd of subcommands) {
179
- assert.ok(
180
- output.includes(cmd),
181
- `help output should contain subcommand "${cmd}"`
182
- );
183
- }
184
- });
185
-
186
- test('mentions the GRAPH_DIR path', () => {
187
- let output = '';
188
- const originalLog = console.log;
189
- console.log = (msg) => { output += msg + '\n'; };
190
-
191
- try {
192
- showGraphHelp();
193
- } finally {
194
- console.log = originalLog;
195
- }
196
-
197
- assert.ok(output.includes('.bizar/graph'), 'help should mention the graph directory');
198
- });
199
-
200
- test('mentions graphify pip install command', () => {
201
- let output = '';
202
- const originalLog = console.log;
203
- console.log = (msg) => { output += msg + '\n'; };
204
-
205
- try {
206
- showGraphHelp();
207
- } finally {
208
- console.log = originalLog;
209
- }
210
-
211
- assert.ok(
212
- output.includes('pip install graphifyy'),
213
- 'help should mention pip install graphifyy'
214
- );
215
- });
216
- });
217
-
218
- console.log(' graph.mjs tests loaded — run with: node --test cli/graph.test.mjs');