@polderlabs/bizar 3.7.3 → 3.9.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/README.md +8 -10
- package/cli/audit.mjs +2 -2
- package/cli/bin.mjs +28 -1
- package/cli/graph.mjs +337 -0
- package/cli/graph.test.mjs +218 -0
- package/cli/init.mjs +35 -0
- package/cli/install.mjs +149 -0
- package/config/AGENTS.md +59 -6
- package/config/agents/baldr.md +25 -1
- package/config/agents/forseti.md +10 -1
- package/config/agents/heimdall.md +24 -0
- package/config/agents/hermod.md +21 -1
- package/config/agents/mimir.md +24 -0
- package/config/agents/odin.md +53 -8
- package/config/agents/quick.md +1 -1
- package/config/agents/thor.md +26 -2
- package/config/agents/tyr.md +26 -2
- package/config/agents/vidarr.md +24 -0
- package/config/commands/init.md +22 -1
- package/config/opencode.json.template +26 -13
- package/config/skills/bizar/SKILL.md +13 -13
- package/package.json +1 -1
- package/config/opencode.json +0 -338
package/README.md
CHANGED
|
@@ -65,18 +65,18 @@
|
|
|
65
65
|
### Agents
|
|
66
66
|
|
|
67
67
|
| Agent | Rune | Model | Cost | Role |
|
|
68
|
-
|
|
69
|
-
| **Odin** | ᛟ |
|
|
68
|
+
|---|---|---|---|---|
|
|
69
|
+
| **Odin** | ᛟ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Primary router — never executes, only delegates |
|
|
70
70
|
| **Frigg** | ᚠ | DeepSeek V4 Flash | **Free** | Read-only Q&A — ask questions, get answers, never modifies |
|
|
71
71
|
| **Vör** | ᛢ | DeepSeek V4 Flash | **Free** | Clarifies ambiguous requests — asks questions until task is well-defined |
|
|
72
72
|
| **Mimir** | ᛗ | DeepSeek V4 Flash | **Free** | Research & codebase exploration (Semble-first) |
|
|
73
73
|
| **Heimdall** | ᚹ | DeepSeek V4 Flash | **Free** | Simple tasks, quick edits, file operations |
|
|
74
|
-
| **Hermod** | ᚱ |
|
|
75
|
-
| **Thor** | ᚦ |
|
|
76
|
-
| **Baldr** | ᛒ |
|
|
77
|
-
| **Tyr** | ᛏ |
|
|
74
|
+
| **Hermod** | ᚱ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | Git & GitHub operations (commit, PR, merge, rebase) |
|
|
75
|
+
| **Thor** | ᚦ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | Moderate implementation, debugging, refactoring |
|
|
76
|
+
| **Baldr** | ᛒ | OpenRouter minimax-m2.7 | $0.30/M · $1.20/M out | UI/UX design system (DESIGN.md plans, visual audits) |
|
|
77
|
+
| **Tyr** | ᛏ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Complex implementation, architecture, deep debugging |
|
|
78
78
|
| **Vidarr** | ᛉ | GPT-5.5 | Subscription | Last resort — GPT-5.5 |
|
|
79
|
-
| **Forseti** | ᚨ |
|
|
79
|
+
| **Forseti** | ᚨ | OpenRouter minimax-m3 | $0.30/M · $1.20/M out | Adversarial plan reviewer (edit: deny, audit-only) |
|
|
80
80
|
|
|
81
81
|
---
|
|
82
82
|
|
|
@@ -257,13 +257,11 @@ After installation, run `/connect` in opencode to add API keys:
|
|
|
257
257
|
| Provider | Models | Auth |
|
|
258
258
|
|---|---|---|
|
|
259
259
|
| **OpenCode Zen** | `opencode/deepseek-v4-flash-free` | Free API key from [opencode.ai](https://opencode.ai) — create account, get key, no charges |
|
|
260
|
-
| **
|
|
260
|
+
| **OpenRouter** | `openrouter/minimax-m2.7`, `openrouter/minimax-m3` | API key from [openrouter.ai](https://openrouter.ai) |
|
|
261
261
|
| **OpenAI** | `openai/gpt-5.5` | ChatGPT subscription (OAuth) |
|
|
262
262
|
|
|
263
263
|
Then run `/models` to verify connectivity.
|
|
264
264
|
|
|
265
|
-
> **Note:** Do not set `baseURL` on the minimax provider. The ai-sdk minimax provider uses its own default URL internally. Setting one causes 404 errors.
|
|
266
|
-
|
|
267
265
|
---
|
|
268
266
|
|
|
269
267
|
## 🧭 Routing
|
package/cli/audit.mjs
CHANGED
|
@@ -78,8 +78,8 @@ export async function runAudit() {
|
|
|
78
78
|
const model = modelMatch[1].trim();
|
|
79
79
|
const validModels = [
|
|
80
80
|
'opencode/deepseek-v4-flash-free',
|
|
81
|
-
'minimax
|
|
82
|
-
'minimax
|
|
81
|
+
'openrouter/minimax-m2.7',
|
|
82
|
+
'openrouter/minimax-m3',
|
|
83
83
|
'openai/gpt-5.5',
|
|
84
84
|
];
|
|
85
85
|
if (!validModels.includes(model)) {
|
package/cli/bin.mjs
CHANGED
|
@@ -28,6 +28,7 @@ import { runInit } from './init.mjs';
|
|
|
28
28
|
import { runExport } from './export.mjs';
|
|
29
29
|
import runPlan from './plan.mjs';
|
|
30
30
|
import { runUpdate } from './update.mjs';
|
|
31
|
+
import { runGraph } from './graph.mjs';
|
|
31
32
|
import { ensureSetup, checkSetupStatus } from './bootstrap.mjs';
|
|
32
33
|
|
|
33
34
|
const args = process.argv.slice(2);
|
|
@@ -86,6 +87,7 @@ function showHelp() {
|
|
|
86
87
|
bizar init Initialize .bizar/ in current project
|
|
87
88
|
bizar export [target] Export agents/rules to another harness
|
|
88
89
|
bizar plan <subcommand> Manage visual plans
|
|
90
|
+
bizar graph Per-project knowledge graph (powered by graphify)
|
|
89
91
|
bizar test-gate Detect & run the project's test suite
|
|
90
92
|
bizar update Update opencode, bizar, and/or bizar-plugin
|
|
91
93
|
bizar service Manage the background service daemon
|
|
@@ -126,7 +128,29 @@ function showInitHelp() {
|
|
|
126
128
|
|
|
127
129
|
Description:
|
|
128
130
|
Detects the project stack, creates .bizar/PROJECT.md and
|
|
129
|
-
.bizar/AGENTS_SELF_IMPROVEMENT.md,
|
|
131
|
+
.bizar/AGENTS_SELF_IMPROVEMENT.md, installs relevant skills,
|
|
132
|
+
and builds the per-project knowledge graph in .bizar/graph/
|
|
133
|
+
(requires graphify — pip install graphifyy — otherwise the
|
|
134
|
+
graph step is skipped gracefully).
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function showGraphHelp() {
|
|
139
|
+
console.log(`
|
|
140
|
+
bizar graph — Per-project knowledge graph (powered by graphify)
|
|
141
|
+
|
|
142
|
+
Usage:
|
|
143
|
+
bizar graph build # full build of the project graph
|
|
144
|
+
bizar graph update # incremental rebuild
|
|
145
|
+
bizar graph query "<text>" # query the graph
|
|
146
|
+
bizar graph path "<A>" "<B>" # shortest path between concepts
|
|
147
|
+
bizar graph explain "<X>" # all nodes related to a concept
|
|
148
|
+
bizar graph watch # watch for changes and rebuild
|
|
149
|
+
bizar graph status # show graph path, size, node/edge/community counts
|
|
150
|
+
bizar graph install # install graphify + drop OpenCode skill/plugin
|
|
151
|
+
|
|
152
|
+
Requires Python 3.10+ and \`graphify\` (pip install graphifyy).
|
|
153
|
+
Graph data lives in .bizar/graph/ inside this project.
|
|
130
154
|
`);
|
|
131
155
|
}
|
|
132
156
|
|
|
@@ -376,6 +400,9 @@ async function main() {
|
|
|
376
400
|
} else if (args[0] === 'init') {
|
|
377
401
|
if (isHelpRequest) showInitHelp();
|
|
378
402
|
else await runInit(process.cwd());
|
|
403
|
+
} else if (args[0] === 'graph') {
|
|
404
|
+
if (isHelpRequest) showGraphHelp();
|
|
405
|
+
else await runGraph(args.slice(1));
|
|
379
406
|
} else if (args[0] === 'export') {
|
|
380
407
|
if (isHelpRequest) showExportHelp();
|
|
381
408
|
else await runExport(parseFlag('--target'));
|
package/cli/graph.mjs
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
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 } 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
|
+
// ── Python detection ───────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Find a Python 3 interpreter on PATH.
|
|
22
|
+
* Returns the absolute path string, or null if none found.
|
|
23
|
+
*/
|
|
24
|
+
export function findPython() {
|
|
25
|
+
// Prefer python3, fall back to python
|
|
26
|
+
for (const name of ['python3', 'python']) {
|
|
27
|
+
try {
|
|
28
|
+
const result = spawnSync(name, ['--version'], { encoding: 'utf8', timeout: 5000 });
|
|
29
|
+
if (result.status === 0 && result.stdout.includes('Python 3')) {
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
// try next
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check whether graphify is available via `python3 -m graphify --version`.
|
|
41
|
+
* Returns true if available, false otherwise.
|
|
42
|
+
*/
|
|
43
|
+
function checkGraphify(python) {
|
|
44
|
+
try {
|
|
45
|
+
const result = spawnSync(python, ['-m', 'graphify', '--version'], {
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
timeout: 10000,
|
|
48
|
+
env: { ...process.env },
|
|
49
|
+
});
|
|
50
|
+
return result.status === 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Graph stats parsing ────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse .bizar/graph/graph.json and return stats.
|
|
60
|
+
* Returns null if the file does not exist or cannot be parsed.
|
|
61
|
+
*
|
|
62
|
+
* Expected NetworkX node-link JSON shape:
|
|
63
|
+
* {
|
|
64
|
+
* "directed": false,
|
|
65
|
+
* "multigraph": false,
|
|
66
|
+
* "graph": {},
|
|
67
|
+
* "nodes": [{ "id": "...", "community": 0, ... }],
|
|
68
|
+
* "links": [{ "source": "...", "target": "...", ... }]
|
|
69
|
+
* }
|
|
70
|
+
*/
|
|
71
|
+
export function parseGraphStats(jsonPath) {
|
|
72
|
+
if (!existsSync(jsonPath)) return null;
|
|
73
|
+
try {
|
|
74
|
+
const content = readFileSync(jsonPath, 'utf8');
|
|
75
|
+
const graph = JSON.parse(content);
|
|
76
|
+
|
|
77
|
+
const nodes = Array.isArray(graph.nodes) ? graph.nodes : [];
|
|
78
|
+
const links = Array.isArray(graph.links) ? graph.links : [];
|
|
79
|
+
|
|
80
|
+
// Count unique community values among nodes
|
|
81
|
+
const communitySet = new Set();
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
if (node.community !== undefined && node.community !== null) {
|
|
84
|
+
communitySet.add(node.community);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stats = statSync(jsonPath);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
nodes: nodes.length,
|
|
92
|
+
edges: links.length,
|
|
93
|
+
communities: communitySet.size,
|
|
94
|
+
lastModified: stats.mtime.toISOString(),
|
|
95
|
+
sizeBytes: stats.size,
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Gitignore guard ────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const GRAPH_GITIGNORE = `cache/
|
|
105
|
+
.graphify_python
|
|
106
|
+
cost.json
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Ensure .bizar/graph/.gitignore exists so heavy cache artefacts are not committed.
|
|
111
|
+
* Called after a successful first build.
|
|
112
|
+
*/
|
|
113
|
+
function ensureGitignore(graphDir) {
|
|
114
|
+
const gitignorePath = join(graphDir, '.gitignore');
|
|
115
|
+
if (!existsSync(gitignorePath)) {
|
|
116
|
+
writeFileSync(gitignorePath, GRAPH_GITIGNORE, 'utf8');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Run helpers ───────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Spawn a graphify command with GRAPHIFY_OUT set to GRAPH_DIR.
|
|
124
|
+
* Streams stdout/stderr to the terminal. Returns the exit code.
|
|
125
|
+
*/
|
|
126
|
+
function runGraphify(python, subArgs, extraEnv = {}) {
|
|
127
|
+
const env = { ...process.env, [GRAPHIFY_OUT_ENV]: GRAPH_DIR };
|
|
128
|
+
const result = spawnSync(python, ['-m', 'graphify', ...subArgs], {
|
|
129
|
+
stdio: 'inherit',
|
|
130
|
+
env,
|
|
131
|
+
timeout: 0, // no timeout — user may run long builds
|
|
132
|
+
});
|
|
133
|
+
return result.status ?? 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Subcommand handlers ────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
async function cmdBuild(python) {
|
|
139
|
+
console.log(chalk.dim(' Running full graphify pipeline...'));
|
|
140
|
+
const code = runGraphify(python, ['.']);
|
|
141
|
+
if (code === 0) {
|
|
142
|
+
ensureGitignore(GRAPH_DIR);
|
|
143
|
+
console.log(chalk.green(`\n ✓ Graph built → ${GRAPH_DIR}/\n`));
|
|
144
|
+
}
|
|
145
|
+
return code;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function cmdUpdate(python) {
|
|
149
|
+
console.log(chalk.dim(' Running incremental graph update...'));
|
|
150
|
+
return runGraphify(python, ['.', '--update']);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function cmdQuery(python, query) {
|
|
154
|
+
if (!query) {
|
|
155
|
+
console.log(chalk.red(' Error: query text required.'));
|
|
156
|
+
console.log(chalk.dim(' Usage: bizar graph query "<search term>"\n'));
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
return runGraphify(python, ['query', query]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function cmdPath(python, a, b) {
|
|
163
|
+
if (!a || !b) {
|
|
164
|
+
console.log(chalk.red(' Error: two concept names required.'));
|
|
165
|
+
console.log(chalk.dim(' Usage: bizar graph path "<A>" "<B>"\n'));
|
|
166
|
+
return 1;
|
|
167
|
+
}
|
|
168
|
+
return runGraphify(python, ['path', a, b]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdExplain(python, concept) {
|
|
172
|
+
if (!concept) {
|
|
173
|
+
console.log(chalk.red(' Error: concept name required.'));
|
|
174
|
+
console.log(chalk.dim(' Usage: bizar graph explain "<X>"\n'));
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
return runGraphify(python, ['explain', concept]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function cmdWatch(python) {
|
|
181
|
+
console.log(chalk.dim(' Starting graphify watch mode (Ctrl-C to stop)...'));
|
|
182
|
+
return runGraphify(python, ['.', '--watch']);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function cmdStatus() {
|
|
186
|
+
const graphJsonPath = join(GRAPH_DIR, 'graph.json');
|
|
187
|
+
const stats = parseGraphStats(graphJsonPath);
|
|
188
|
+
|
|
189
|
+
console.log(chalk.bold.hex('#10b981')('\n ᛗ BIZAR GRAPH STATUS ᛗ\n'));
|
|
190
|
+
console.log(` Path: ${GRAPH_DIR}/`);
|
|
191
|
+
|
|
192
|
+
if (!existsSync(GRAPH_DIR)) {
|
|
193
|
+
console.log(` Exists: ${chalk.yellow('no')} (run \`bizar graph build\` first)`);
|
|
194
|
+
console.log();
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!stats) {
|
|
199
|
+
console.log(` Exists: ${chalk.yellow('no graph.json found')}`);
|
|
200
|
+
console.log();
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
console.log(` Exists: ${chalk.green('yes')}`);
|
|
205
|
+
console.log(` Size: ${(stats.sizeBytes / 1024).toFixed(1)} KB`);
|
|
206
|
+
console.log(` Last built: ${stats.lastModified}`);
|
|
207
|
+
console.log(` Nodes: ${stats.nodes}`);
|
|
208
|
+
console.log(` Edges: ${stats.edges}`);
|
|
209
|
+
console.log(` Communities:${stats.communities}`);
|
|
210
|
+
console.log();
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function cmdInstall() {
|
|
215
|
+
const python = findPython();
|
|
216
|
+
if (!python) {
|
|
217
|
+
console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
|
|
218
|
+
console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
|
|
219
|
+
return 1;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(chalk.dim(' Installing graphify via pip...\n'));
|
|
223
|
+
|
|
224
|
+
// pip install graphifyy
|
|
225
|
+
console.log(chalk.dim(' $ pip install graphifyy'));
|
|
226
|
+
let result = spawnSync('pip', ['install', 'graphifyy'], { stdio: 'inherit' });
|
|
227
|
+
if (result.status !== 0) {
|
|
228
|
+
console.log(chalk.red(' ✗ pip install graphifyy failed.\n'));
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// graphify install --platform opencode --project
|
|
233
|
+
console.log(chalk.dim('\n $ graphify install --platform opencode --project'));
|
|
234
|
+
result = spawnSync('graphify', ['install', '--platform', 'opencode', '--project'], {
|
|
235
|
+
stdio: 'inherit',
|
|
236
|
+
});
|
|
237
|
+
if (result.status !== 0) {
|
|
238
|
+
console.log(chalk.red(' ✗ graphify install failed.\n'));
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
console.log(chalk.green('\n ✓ graphify installed and OpenCode plugin configured.\n'));
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Help ──────────────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
export function showGraphHelp() {
|
|
249
|
+
console.log(`
|
|
250
|
+
${chalk.bold.hex('#10b981')('bizar graph')} — Per-project knowledge graph (powered by graphify)
|
|
251
|
+
|
|
252
|
+
${chalk.dim('Usage:')}
|
|
253
|
+
${chalk.cyan('bizar graph build')} # full build of the project graph
|
|
254
|
+
${chalk.cyan('bizar graph update')} # incremental rebuild
|
|
255
|
+
${chalk.cyan('bizar graph query "<text>"')} # query the graph
|
|
256
|
+
${chalk.cyan('bizar graph path "<A>" "<B>"')} # shortest path between concepts
|
|
257
|
+
${chalk.cyan('bizar graph explain "<X>"')} # all nodes related to a concept
|
|
258
|
+
${chalk.cyan('bizar graph watch')} # watch for changes and rebuild
|
|
259
|
+
${chalk.cyan('bizar graph status')} # show graph path, size, node/edge/community counts
|
|
260
|
+
${chalk.cyan('bizar graph install')} # install graphify + drop OpenCode skill/plugin
|
|
261
|
+
|
|
262
|
+
${chalk.dim('Requires:')} Python 3.10+ and \`graphify\` (${chalk.cyan('pip install graphifyy')})
|
|
263
|
+
|
|
264
|
+
${chalk.dim('Graph data lives in')} ${chalk.cyan('.bizar/graph/')} ${chalk.dim('inside this project.')}
|
|
265
|
+
`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Main entry point ───────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* `bizar graph` entry point.
|
|
272
|
+
*
|
|
273
|
+
* @param {string[]} args — subcommand + args after 'graph'
|
|
274
|
+
*/
|
|
275
|
+
export async function runGraph(args) {
|
|
276
|
+
// Show help when called with no args or --help / -h
|
|
277
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
278
|
+
showGraphHelp();
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const sub = args[0];
|
|
283
|
+
|
|
284
|
+
// status and install are the only subcommands that don't need graphify
|
|
285
|
+
if (sub !== 'status' && sub !== 'install') {
|
|
286
|
+
// Detect Python
|
|
287
|
+
const python = findPython();
|
|
288
|
+
if (!python) {
|
|
289
|
+
console.error(chalk.red(' Error: graphify requires Python 3.10+.'));
|
|
290
|
+
console.error(chalk.dim(' Install Python from https://python.org or via your package manager, then re-run.\n'));
|
|
291
|
+
return 1;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Detect graphify
|
|
295
|
+
if (!checkGraphify(python)) {
|
|
296
|
+
console.error(chalk.red(' Error: graphify is not installed.'));
|
|
297
|
+
console.error(chalk.dim(' Install it with:'));
|
|
298
|
+
console.error(chalk.cyan(' pip install graphifyy\n'));
|
|
299
|
+
console.error(chalk.dim(' Or run:'));
|
|
300
|
+
console.error(chalk.cyan(' bizar graph install\n'));
|
|
301
|
+
return 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Ensure .bizar/ directory exists (graphify will create graph/ inside it)
|
|
305
|
+
mkdirSync(GRAPH_DIR, { recursive: true });
|
|
306
|
+
|
|
307
|
+
switch (sub) {
|
|
308
|
+
case 'build':
|
|
309
|
+
return await cmdBuild(python);
|
|
310
|
+
case 'update':
|
|
311
|
+
return await cmdUpdate(python);
|
|
312
|
+
case 'query':
|
|
313
|
+
return await cmdQuery(python, args.slice(1).join(' '));
|
|
314
|
+
case 'path':
|
|
315
|
+
return await cmdPath(python, args[1], args[2]);
|
|
316
|
+
case 'explain':
|
|
317
|
+
return await cmdExplain(python, args[1]);
|
|
318
|
+
case 'watch':
|
|
319
|
+
return await cmdWatch(python);
|
|
320
|
+
default:
|
|
321
|
+
console.error(chalk.red(` Error: unknown subcommand '${sub}'.\n`));
|
|
322
|
+
showGraphHelp();
|
|
323
|
+
return 1;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// subcommands that don't need graphify
|
|
328
|
+
switch (sub) {
|
|
329
|
+
case 'status':
|
|
330
|
+
return await cmdStatus();
|
|
331
|
+
case 'install':
|
|
332
|
+
return await cmdInstall();
|
|
333
|
+
default:
|
|
334
|
+
// unreachable — already caught unknown subcommands above
|
|
335
|
+
return 1;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
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');
|