@polderlabs/bizar 6.2.2 → 6.2.3
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 +51 -1
- package/cli/commands/cline-cmd.mjs +289 -0
- package/cli/commands/rca.mjs +198 -0
- package/cli/commands/rca.test.mjs +99 -0
- package/cli/commands/setup-provider.mjs +249 -75
- package/cli/commands/setup-provider.test.mjs +198 -98
- package/cli/commands/validate.mjs +63 -0
- package/package.json +2 -2
- package/plugins/bizar/package.json +17 -6
- package/plugins/bizar/src/clineruntime.ts +1 -1
- package/plugins/bizar/tests/clineruntime-config.test.ts +24 -3
- package/scripts/bh-full-e2e.mjs +57 -4
package/cli/bin.mjs
CHANGED
|
@@ -130,6 +130,13 @@ function showHelp() {
|
|
|
130
130
|
plan [v6.0.0+] Reserved for future plan management
|
|
131
131
|
validate Validate the Bizar install (21 checks)
|
|
132
132
|
setup-provider Configure a provider in cline.json (since v6.2.2 installer doesn't touch providers)
|
|
133
|
+
config Show current Cline configuration (pass-through to cline config)
|
|
134
|
+
history List session history (pass-through to cline history)
|
|
135
|
+
hub Manage the local hub daemon (pass-through to cline hub)
|
|
136
|
+
hook Handle a hook payload from stdin (pass-through to cline hook)
|
|
137
|
+
team Spawn an agent team from CLI (wraps cline --team-name)
|
|
138
|
+
subagent Spawn a read-only research subagent from CLI
|
|
139
|
+
rca Analyze a GitHub issue (Cline CLI sample)
|
|
133
140
|
|
|
134
141
|
Examples:
|
|
135
142
|
bizar install
|
|
@@ -206,7 +213,8 @@ async function main() {
|
|
|
206
213
|
'audit', 'init', 'export', 'test-gate', 'dev-link', 'dev-unlink',
|
|
207
214
|
'doctor', 'repair', 'heads-up', 'bg', 'digest', 'backup', 'restore',
|
|
208
215
|
'agent-browser', 'update', 'providers', 'plan', 'validate',
|
|
209
|
-
'setup-provider',
|
|
216
|
+
'setup-provider', 'config', 'history', 'hub', 'hook',
|
|
217
|
+
'team', 'subagent', 'rca',
|
|
210
218
|
]);
|
|
211
219
|
const UTIL_ALIASES = new Set(['dashboard', 'agent-browser-up']);
|
|
212
220
|
let mod;
|
|
@@ -534,6 +542,48 @@ async function main() {
|
|
|
534
542
|
break;
|
|
535
543
|
}
|
|
536
544
|
|
|
545
|
+
case 'config':
|
|
546
|
+
case 'history':
|
|
547
|
+
case 'hub':
|
|
548
|
+
case 'hook':
|
|
549
|
+
case 'team':
|
|
550
|
+
case 'subagent': {
|
|
551
|
+
// v6.2.3 — Pass-through wrappers for Cline CLI commands.
|
|
552
|
+
// These live in cli/commands/cline-cmd.mjs.
|
|
553
|
+
const mod = await importCommand('cline-cmd');
|
|
554
|
+
if (!mod) {
|
|
555
|
+
console.error(chalk.red(` ✗ Could not load cline-cmd module`));
|
|
556
|
+
process.exit(EXIT_ERROR);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
dbg('loaded command module:', 'cline-cmd');
|
|
560
|
+
const found = await mod.run(cmd, cmdArgs, isHelpRequest);
|
|
561
|
+
if (!found) {
|
|
562
|
+
console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
|
|
563
|
+
showHelp();
|
|
564
|
+
process.exit(EXIT_ERROR);
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
case 'rca': {
|
|
570
|
+
// v6.2.3 — GitHub Issue RCA sample (adapted from Cline docs).
|
|
571
|
+
const mod = await importCommand('rca');
|
|
572
|
+
if (!mod) {
|
|
573
|
+
console.error(chalk.red(` ✗ Could not load rca command module`));
|
|
574
|
+
process.exit(EXIT_ERROR);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
dbg('loaded command module:', 'rca');
|
|
578
|
+
const found = await mod.run(cmd, cmdArgs, isHelpRequest);
|
|
579
|
+
if (!found) {
|
|
580
|
+
console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
|
|
581
|
+
showHelp();
|
|
582
|
+
process.exit(EXIT_ERROR);
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
|
|
537
587
|
default: {
|
|
538
588
|
console.error(chalk.red(` ✗ Unknown command: ${cmd}`));
|
|
539
589
|
showHelp();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/commands/cline-cmd.mjs
|
|
3
|
+
*
|
|
4
|
+
* v6.2.3 — Pass-through wrappers for Cline CLI commands that Bizar
|
|
5
|
+
* wants to expose as `bizar <subcommand>` for consistency.
|
|
6
|
+
*
|
|
7
|
+
* These are NOT reimaginings — they're thin pass-throughs that:
|
|
8
|
+
* 1. Set sane defaults from Bizar's installed location
|
|
9
|
+
* (e.g. CLINE_DIR=~/.cline, CLINE_PROVIDER_SETTINGS_PATH)
|
|
10
|
+
* 2. Spawn the `cline` binary with the same args
|
|
11
|
+
* 3. Stream the output through (with our chalk banner)
|
|
12
|
+
*
|
|
13
|
+
* Wrapped commands:
|
|
14
|
+
* - bizar config → `cline config`
|
|
15
|
+
* - bizar history → `cline history`
|
|
16
|
+
* - bizar plugin → `cline plugin <sub>`
|
|
17
|
+
* - bizar hub → `cline hub <sub>`
|
|
18
|
+
* - bizar hook → `cline hook` (handle hook payload from stdin)
|
|
19
|
+
* - bizar team → `cline --team-name <name> "mission"`
|
|
20
|
+
* (spawn an agent team from the CLI directly, without the plugin)
|
|
21
|
+
*
|
|
22
|
+
* Why: Bizar users already know the `bizar` CLI. Wrapping Cline
|
|
23
|
+
* commands means they don't have to remember which binary owns
|
|
24
|
+
* which feature.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { spawn } from 'node:child_process';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { join } from 'node:path';
|
|
30
|
+
import chalk from 'chalk';
|
|
31
|
+
|
|
32
|
+
const HOMEDIR = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
33
|
+
const CLINE_BIN = process.env.CLINE_BIN || 'cline';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the cline binary. On most installs, `which cline` returns
|
|
37
|
+
* the shim; we use that. Allow override via CLINE_BIN env var.
|
|
38
|
+
*/
|
|
39
|
+
function findClineBin() {
|
|
40
|
+
if (CLINE_BIN && existsSync(CLINE_BIN)) return CLINE_BIN;
|
|
41
|
+
// Default to PATH lookup via the spawn shell.
|
|
42
|
+
return 'cline';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Spawn `cline` with the given args, inheriting stdio.
|
|
47
|
+
* Returns a promise that resolves with the exit code.
|
|
48
|
+
*/
|
|
49
|
+
export function spawnCline(args, opts = {}) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const bin = findClineBin();
|
|
52
|
+
const child = spawn(bin, args, {
|
|
53
|
+
stdio: opts.stdio || 'inherit',
|
|
54
|
+
env: {
|
|
55
|
+
...process.env,
|
|
56
|
+
// Always use the standard Cline state dir unless overridden.
|
|
57
|
+
...(process.env.CLINE_DIR ? {} : { CLINE_DIR: join(HOMEDIR, '.cline') }),
|
|
58
|
+
// The plugin already wires up the Cline data dir; pass it
|
|
59
|
+
// through so `bizar config` and `bizar history` see the same
|
|
60
|
+
// session/db the plugin sees.
|
|
61
|
+
...(process.env.CLINE_PROVIDER_SETTINGS_PATH ? {} : {}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
child.on('error', reject);
|
|
65
|
+
child.on('exit', (code, signal) => {
|
|
66
|
+
// Mirror the exit code so callers can chain commands.
|
|
67
|
+
if (typeof code === 'number') resolve(code);
|
|
68
|
+
else if (signal) resolve(128 + (typeof process.exitCode === 'number' ? process.exitCode : 0));
|
|
69
|
+
else resolve(0);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function showClineCmdHelp() {
|
|
75
|
+
console.log(`
|
|
76
|
+
bizar <cline-cmd> — Pass-through wrappers for Cline CLI commands
|
|
77
|
+
|
|
78
|
+
These commands delegate to the \`cline\` binary on your PATH so you
|
|
79
|
+
don't have to remember which binary owns which feature.
|
|
80
|
+
|
|
81
|
+
Available wrappers:
|
|
82
|
+
bizar config Show current configuration (mirrors \`cline config\`)
|
|
83
|
+
bizar history List session history (mirrors \`cline history\`)
|
|
84
|
+
bizar hub Manage the local hub daemon (mirrors \`cline hub\`)
|
|
85
|
+
bizar hook Handle a hook payload from stdin (mirrors \`cline hook\`)
|
|
86
|
+
bizar team <name> Spawn a coordinated agent team (wraps \`cline --team-name\`)
|
|
87
|
+
bizar subagent Spawn a read-only research subagent
|
|
88
|
+
|
|
89
|
+
Note: Cline plugin management uses \`bizar plugin\` (Bizar's plugin
|
|
90
|
+
marketplace) which already overlaps with the Cline plugin manager.
|
|
91
|
+
Run \`cline plugin <sub>\` directly for the Cline one.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
bizar config --json
|
|
95
|
+
bizar history --limit 20 --json
|
|
96
|
+
bizar hub start
|
|
97
|
+
bizar team auth-sprint "Plan and implement user auth with tests"
|
|
98
|
+
bizar subagent mimir "Find all places where the auth token is verified"
|
|
99
|
+
|
|
100
|
+
Related:
|
|
101
|
+
/team The matching Cline slash command (works inside a session)
|
|
102
|
+
bizar validate Health check the Bizar install
|
|
103
|
+
bizar doctor Diagnose Cline-side issues
|
|
104
|
+
`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `bizar config` — show current Cline configuration.
|
|
109
|
+
* Thin wrapper around `cline config [options]`.
|
|
110
|
+
*/
|
|
111
|
+
export async function runClineConfig(args = []) {
|
|
112
|
+
console.log(chalk.dim(` (delegating to \`cline config\`)\n`));
|
|
113
|
+
const code = await spawnCline(['config', ...args]);
|
|
114
|
+
process.exit(code);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* `bizar history` — list session history.
|
|
119
|
+
* Thin wrapper around `cline history [options]`.
|
|
120
|
+
*/
|
|
121
|
+
export async function runClineHistory(args = []) {
|
|
122
|
+
console.log(chalk.dim(` (delegating to \`cline history\`)\n`));
|
|
123
|
+
const code = await spawnCline(['history', ...args]);
|
|
124
|
+
process.exit(code);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// NOTE: `bizar plugin` is already taken by the Bizar marketplace
|
|
128
|
+
// (cli/commands/plugin.mjs). To avoid the name collision, Cline's
|
|
129
|
+
// plugin manager is exposed as `cline plugin <sub>` directly via
|
|
130
|
+
// the cline CLI on PATH. Users who want Cline plugin management
|
|
131
|
+
// can also shell out via `bizar provider install <url>` or just
|
|
132
|
+
// run `cline plugin install <url>` themselves.
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* `bizar hub <subcommand> [...args]` — manage the local hub daemon.
|
|
136
|
+
* Thin wrapper around `cline hub <subcommand>`.
|
|
137
|
+
*/
|
|
138
|
+
export async function runClineHub(args = []) {
|
|
139
|
+
if (args.length === 0) {
|
|
140
|
+
console.log(chalk.cyan(' bizar hub — Manage the local Cline hub daemon\n'));
|
|
141
|
+
console.log(chalk.dim(' Subcommands (passed through to `cline hub`):'));
|
|
142
|
+
console.log(' start Start the hub daemon');
|
|
143
|
+
console.log(' stop Stop the hub daemon');
|
|
144
|
+
console.log(' status Show hub status');
|
|
145
|
+
console.log(' logs Show hub logs');
|
|
146
|
+
console.log('');
|
|
147
|
+
console.log(chalk.dim(' The hub daemon lets long-running tasks survive logout. '));
|
|
148
|
+
console.log(chalk.dim(' Used by `--zen` mode and by scheduled agents.'));
|
|
149
|
+
console.log('');
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const code = await spawnCline(['hub', ...args]);
|
|
153
|
+
process.exit(code);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `bizar hook` — handle a hook payload from stdin.
|
|
158
|
+
* Thin wrapper around `cline hook` (reads JSON from stdin and
|
|
159
|
+
* runs the configured hook handler).
|
|
160
|
+
*/
|
|
161
|
+
export async function runClineHook(args = []) {
|
|
162
|
+
// No banner — the hook payload is JSON, no room for chalk noise.
|
|
163
|
+
const code = await spawnCline(['hook', ...args], { stdio: 'inherit' });
|
|
164
|
+
process.exit(code);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `bizar team <name> "mission" [...flags]` — spawn a coordinated agent
|
|
169
|
+
* team from the CLI directly. Wraps `cline --team-name <name>`.
|
|
170
|
+
*
|
|
171
|
+
* This is the OUT-OF-SESSION equivalent of the `/team` slash command.
|
|
172
|
+
* Useful for batch / scripting use cases where you want to fire off a
|
|
173
|
+
* team mission from a shell script (e.g. a CI job, a cron, a webhook).
|
|
174
|
+
*
|
|
175
|
+
* Example:
|
|
176
|
+
* bizar team auth-sprint "Plan and implement user auth with tests" \
|
|
177
|
+
* --provider litellm --model minimaxcustom/MiniMax-M3
|
|
178
|
+
*/
|
|
179
|
+
export async function runClineTeam(args = []) {
|
|
180
|
+
if (args.length === 0) {
|
|
181
|
+
console.log(chalk.cyan(' bizar team — Spawn a coordinated agent team from the CLI\n'));
|
|
182
|
+
console.log(chalk.dim(' Usage:'));
|
|
183
|
+
console.log(' bizar team <name> "mission" [--provider X] [--model Y] [more flags]');
|
|
184
|
+
console.log('');
|
|
185
|
+
console.log(chalk.dim(' This is the OUT-OF-SESSION equivalent of the /team slash command.'));
|
|
186
|
+
console.log(chalk.dim(' Wraps `cline --team-name <name> "<mission>"`. Use it for batch'));
|
|
187
|
+
console.log(chalk.dim(' / scripted / CI use cases where you want to fire a team mission'));
|
|
188
|
+
console.log(chalk.dim(' without entering an interactive Cline session.'));
|
|
189
|
+
console.log('');
|
|
190
|
+
console.log(chalk.dim(' Examples:'));
|
|
191
|
+
console.log(' bizar team auth-sprint "Plan and implement user auth with tests"');
|
|
192
|
+
console.log(' bizar team release-prep "Check CHANGELOG, bump version, run /test"');
|
|
193
|
+
console.log(' bizar team rca "Investigate why the build failed on PR #42"');
|
|
194
|
+
console.log('');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// First positional arg = team name, second = mission prompt.
|
|
198
|
+
const [name, mission, ...rest] = args;
|
|
199
|
+
if (!name) {
|
|
200
|
+
console.error(chalk.red(' ✗ Team name required'));
|
|
201
|
+
console.error(chalk.dim(' Usage: bizar team <name> "mission"'));
|
|
202
|
+
process.exit(2);
|
|
203
|
+
}
|
|
204
|
+
if (!mission) {
|
|
205
|
+
console.error(chalk.red(' ✗ Mission prompt required'));
|
|
206
|
+
console.error(chalk.dim(' Usage: bizar team <name> "mission"'));
|
|
207
|
+
process.exit(2);
|
|
208
|
+
}
|
|
209
|
+
console.log(chalk.cyan(`\n 🚀 Spawning team '${name}'\n`));
|
|
210
|
+
console.log(chalk.dim(` Mission: ${mission}\n`));
|
|
211
|
+
const code = await spawnCline(['--team-name', name, mission, ...rest]);
|
|
212
|
+
process.exit(code);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* `bizar subagent <agent> "task"` — spawn a quick read-only research
|
|
217
|
+
* subagent. This is the OUT-OF-SESSION equivalent of using
|
|
218
|
+
* `use_subagents` inside Cline.
|
|
219
|
+
*
|
|
220
|
+
* Why it's separate from `bizar team`: subagents are fast/cheap and
|
|
221
|
+
* read-only. They don't need team coordination. They're the right
|
|
222
|
+
* tool for "go investigate X and report back".
|
|
223
|
+
*
|
|
224
|
+
* Note: Cline's `use_subagents` is a built-in tool that only works
|
|
225
|
+
* INSIDE a Cline session. The `cline --yolo` / interactive flags
|
|
226
|
+
* don't expose it directly. We shell out via the `--provider` flag
|
|
227
|
+
* to force an immediate one-shot run.
|
|
228
|
+
*
|
|
229
|
+
* For programmatic subagent dispatch (from inside a Cline session),
|
|
230
|
+
* use the `task` tool that Cline provides automatically when
|
|
231
|
+
* `enableSpawnAgent: true` (the Bizar plugin sets this since v6.2.3).
|
|
232
|
+
*
|
|
233
|
+
* This function is a thin convenience: it just spawns a regular
|
|
234
|
+
* Cline run with the agent name prefixed. For real subagent
|
|
235
|
+
* orchestration, run `cline` interactively.
|
|
236
|
+
*/
|
|
237
|
+
export async function runClineSubagent(args = []) {
|
|
238
|
+
if (args.length === 0) {
|
|
239
|
+
console.log(chalk.cyan(' bizar subagent — Spawn a quick read-only research subagent\n'));
|
|
240
|
+
console.log(chalk.dim(' Subagents are read-only research agents. They run in parallel, '));
|
|
241
|
+
console.log(chalk.dim(' keep their own context window, and return a focused report.'));
|
|
242
|
+
console.log(chalk.dim(' Use them for broad codebase exploration without burning context.\n'));
|
|
243
|
+
console.log('');
|
|
244
|
+
console.log(chalk.dim(' Usage:'));
|
|
245
|
+
console.log(' bizar subagent <agent> "task"');
|
|
246
|
+
console.log('');
|
|
247
|
+
console.log(chalk.dim(' Note: for IN-SESSION subagent dispatch, just ask the model'));
|
|
248
|
+
console.log(chalk.dim(' to "use subagents" — Cline picks them up automatically when'));
|
|
249
|
+
console.log(chalk.dim(' enableSpawnAgent=true (the Bizar default since v6.2.3).'));
|
|
250
|
+
console.log('');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const [agent, ...rest] = args;
|
|
254
|
+
const task = rest.find((a) => typeof a === 'string' && !a.startsWith('-'));
|
|
255
|
+
if (!agent || !task) {
|
|
256
|
+
console.error(chalk.red(' ✗ Usage: bizar subagent <agent> "task"'));
|
|
257
|
+
process.exit(2);
|
|
258
|
+
}
|
|
259
|
+
console.log(chalk.cyan(`\n 🔍 Spawning subagent '${agent}'\n`));
|
|
260
|
+
console.log(chalk.dim(` Task: ${task}\n`));
|
|
261
|
+
// We pass --agent <name> so Cline uses the named agent and
|
|
262
|
+
// --auto-approve true to skip permission prompts for the
|
|
263
|
+
// read-only subagent.
|
|
264
|
+
const filteredArgs = rest.filter((a) => a !== task);
|
|
265
|
+
const code = await spawnCline([
|
|
266
|
+
'--agent', agent,
|
|
267
|
+
'--auto-approve', 'true',
|
|
268
|
+
`--system-prompt:subagent-research`,
|
|
269
|
+
task,
|
|
270
|
+
...filteredArgs,
|
|
271
|
+
]);
|
|
272
|
+
process.exit(code);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── run() entry point (used by bin.mjs dispatcher) ─────────────────────────
|
|
276
|
+
|
|
277
|
+
export async function run(name, args, isHelpRequest) {
|
|
278
|
+
// Note: bin.mjs dispatcher calls run('team', [], false) etc.
|
|
279
|
+
// So `name` here is the subcommand itself.
|
|
280
|
+
if (name === 'config') { await runClineConfig(args); return true; }
|
|
281
|
+
if (name === 'history') { await runClineHistory(args); return true; }
|
|
282
|
+
if (name === 'hub') { await runClineHub(args); return true; }
|
|
283
|
+
if (name === 'hook') { await runClineHook(args); return true; }
|
|
284
|
+
if (name === 'team') { await runClineTeam(args); return true; }
|
|
285
|
+
if (name === 'subagent') { await runClineSubagent(args); return true; }
|
|
286
|
+
// Fallback: show help.
|
|
287
|
+
showClineCmdHelp();
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cli/commands/rca.mjs
|
|
4
|
+
*
|
|
5
|
+
* v6.2.3 — `bizar rca <github-issue-url> [prompt]`
|
|
6
|
+
*
|
|
7
|
+
* Adapted from the Cline CLI sample "GitHub Issue RCA"
|
|
8
|
+
* (https://docs.cline.bot/cli/samples/). Analyzes a GitHub issue
|
|
9
|
+
* using Cline CLI and outputs a root-cause report.
|
|
10
|
+
*
|
|
11
|
+
* Equivalent shell script:
|
|
12
|
+
* cline --auto-approve true --json "$PROMPT: $ISSUE_URL" | \
|
|
13
|
+
* jq -r 'select(.type == "agent_event" and .event.type == "done") | .event.text' | \
|
|
14
|
+
* sed 's/\\n/\n/g'
|
|
15
|
+
*
|
|
16
|
+
* This implementation:
|
|
17
|
+
* 1. Spawns `gh issue view` to fetch the issue body
|
|
18
|
+
* 2. Spawns `cline --auto-approve true --json` with the issue content + prompt
|
|
19
|
+
* 3. Streams the analysis to stdout as plain text
|
|
20
|
+
*
|
|
21
|
+
* Requirements: gh CLI, jq, cline on PATH
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import chalk from 'chalk';
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_PROMPT = 'What is the root cause of this issue?';
|
|
28
|
+
|
|
29
|
+
export function showRcaHelp() {
|
|
30
|
+
console.log(`
|
|
31
|
+
bizar rca — Analyze a GitHub issue with Cline CLI (root-cause analysis)
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
bizar rca <github-issue-url> [prompt]
|
|
35
|
+
bizar rca --help
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
bizar rca https://github.com/owner/repo/issues/123
|
|
39
|
+
bizar rca https://github.com/owner/repo/issues/456 "What is the security impact?"
|
|
40
|
+
bizar rca https://github.com/owner/repo/issues/789 "What are the migration steps?"
|
|
41
|
+
|
|
42
|
+
Description:
|
|
43
|
+
Fetches the GitHub issue via \`gh issue view\`, then asks Cline
|
|
44
|
+
CLI to analyze it with the given prompt. Outputs a structured
|
|
45
|
+
root-cause report. Based on the official Cline CLI sample.
|
|
46
|
+
|
|
47
|
+
Requirements:
|
|
48
|
+
- \`gh\` CLI installed and authenticated
|
|
49
|
+
- \`jq\` installed
|
|
50
|
+
- \`cline\` on PATH
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function which(bin) {
|
|
55
|
+
// Lightweight `which` — works on Linux + macOS.
|
|
56
|
+
const path = process.env.PATH || '';
|
|
57
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
58
|
+
for (const dir of path.split(sep)) {
|
|
59
|
+
const full = dir.endsWith('/') || dir.endsWith('\\') ? dir + bin : dir + '/' + bin;
|
|
60
|
+
if (existsSync(full)) return full;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function checkPrereqs() {
|
|
66
|
+
const missing = [];
|
|
67
|
+
if (!which('gh')) missing.push('gh (GitHub CLI)');
|
|
68
|
+
if (!which('jq')) missing.push('jq');
|
|
69
|
+
if (!which('cline')) missing.push('cline');
|
|
70
|
+
if (missing.length > 0) {
|
|
71
|
+
console.error(chalk.red(`\n ✗ Missing prerequisites: ${missing.join(', ')}`));
|
|
72
|
+
console.error(chalk.dim(' Install them first, then re-run.'));
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function spawnSyncCapture(bin, args, opts = {}) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const child = spawn(bin, args, {
|
|
81
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
82
|
+
...opts,
|
|
83
|
+
});
|
|
84
|
+
let out = '';
|
|
85
|
+
child.stdout.on('data', (chunk) => { out += chunk.toString('utf8'); });
|
|
86
|
+
child.on('error', reject);
|
|
87
|
+
child.on('exit', (code) => {
|
|
88
|
+
if (code === 0) resolve(out);
|
|
89
|
+
else reject(new Error(`${bin} exited with code ${code}`));
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function spawnStreaming(bin, args) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const child = spawn(bin, args, { stdio: 'inherit' });
|
|
97
|
+
child.on('error', reject);
|
|
98
|
+
child.on('exit', (code) => resolve(code ?? 0));
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractFinalText(jsonl) {
|
|
103
|
+
// Stream of NDJSON lines. Pull the last "agent_event" with type "done"
|
|
104
|
+
// and return its text. Falls back to concatenating all "say" events.
|
|
105
|
+
const lines = jsonl.split('\n').filter(Boolean);
|
|
106
|
+
let finalText = null;
|
|
107
|
+
const seen = [];
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
try {
|
|
110
|
+
const evt = JSON.parse(line);
|
|
111
|
+
if (evt.type === 'agent_event' && evt.event?.type === 'done' && evt.event?.text) {
|
|
112
|
+
finalText = evt.event.text;
|
|
113
|
+
} else if (evt.type === 'say' && evt.text) {
|
|
114
|
+
seen.push(evt.text);
|
|
115
|
+
}
|
|
116
|
+
} catch { /* skip malformed line */ }
|
|
117
|
+
}
|
|
118
|
+
if (finalText) return finalText.replace(/\\n/g, '\n');
|
|
119
|
+
if (seen.length > 0) return seen.join('\n').replace(/\\n/g, '\n');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runRca(args = []) {
|
|
124
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
125
|
+
showRcaHelp();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (args.length === 0) {
|
|
129
|
+
showRcaHelp();
|
|
130
|
+
process.exit(2);
|
|
131
|
+
}
|
|
132
|
+
const issueUrl = args[0];
|
|
133
|
+
const prompt = args[1] || DEFAULT_PROMPT;
|
|
134
|
+
|
|
135
|
+
if (!checkPrereqs()) process.exit(1);
|
|
136
|
+
|
|
137
|
+
// Validate URL shape
|
|
138
|
+
if (!/^https?:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+/.test(issueUrl)) {
|
|
139
|
+
console.error(chalk.red(`\n ✗ Not a GitHub issue URL: ${issueUrl}`));
|
|
140
|
+
console.error(chalk.dim(' Expected: https://github.com/<owner>/<repo>/issues/<n>'));
|
|
141
|
+
process.exit(2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(chalk.cyan(`\n 🔍 Fetching ${issueUrl} ...\n`));
|
|
145
|
+
|
|
146
|
+
// 1. Fetch the issue via gh CLI
|
|
147
|
+
let issueText;
|
|
148
|
+
try {
|
|
149
|
+
issueText = await spawnSyncCapture('gh', [
|
|
150
|
+
'issue', 'view', issueUrl, '--json', 'title,body,labels,comments',
|
|
151
|
+
]);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(chalk.red(` ✗ gh issue view failed: ${err.message}`));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
console.log(chalk.dim(` (${issueText.length} chars of issue context)\n`));
|
|
157
|
+
|
|
158
|
+
// 2. Spawn Cline with the issue + prompt
|
|
159
|
+
const clinePrompt = `${prompt}\n\nGitHub issue: ${issueUrl}\n\nIssue context:\n${issueText}`;
|
|
160
|
+
|
|
161
|
+
console.log(chalk.cyan(` 🤖 Asking Cline to analyze: "${prompt}"\n`));
|
|
162
|
+
|
|
163
|
+
// We capture Cline's stdout so we can extract the final text and
|
|
164
|
+
// re-emit it cleanly.
|
|
165
|
+
const child = spawn('cline', [
|
|
166
|
+
'--auto-approve', 'true',
|
|
167
|
+
'--json',
|
|
168
|
+
clinePrompt,
|
|
169
|
+
], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
170
|
+
|
|
171
|
+
let clineStdout = '';
|
|
172
|
+
child.stdout.on('data', (chunk) => { clineStdout += chunk.toString('utf8'); });
|
|
173
|
+
|
|
174
|
+
const code = await new Promise((resolve) => {
|
|
175
|
+
child.on('exit', (c) => resolve(c ?? 0));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// 3. Extract and print the final analysis
|
|
179
|
+
const finalText = extractFinalText(clineStdout);
|
|
180
|
+
if (finalText) {
|
|
181
|
+
console.log('\n' + chalk.bold.cyan(' ─── Root-Cause Analysis ─────────────────────────────────') + '\n');
|
|
182
|
+
console.log(finalText);
|
|
183
|
+
console.log('\n' + chalk.bold.cyan(' ─────────────────────────────────────────────────────────────') + '\n');
|
|
184
|
+
} else if (code === 0) {
|
|
185
|
+
console.log(chalk.dim(' (Cline returned 0 but no final text was found in --json output.)'));
|
|
186
|
+
console.log(chalk.dim(' Try running again without --json for verbose output.'));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
process.exit(code);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── run() entry point (used by bin.mjs dispatcher) ─────────────────────────
|
|
193
|
+
|
|
194
|
+
export async function run(name, args, isHelpRequest) {
|
|
195
|
+
if (name !== 'rca') return false;
|
|
196
|
+
await runRca(args);
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/commands/rca.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v6.2.3 — Unit tests for the rca (GitHub Issue RCA) subcommand.
|
|
5
|
+
*
|
|
6
|
+
* Tests the URL validation and argument parsing without actually
|
|
7
|
+
* spawning the `gh` and `cline` binaries.
|
|
8
|
+
*/
|
|
9
|
+
import { test, describe } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { showRcaHelp } from './rca.mjs';
|
|
12
|
+
|
|
13
|
+
describe('rca — help', () => {
|
|
14
|
+
test('prints help text when --help is passed', () => {
|
|
15
|
+
let out = '';
|
|
16
|
+
const origWrite = process.stdout.write.bind(process.stdout);
|
|
17
|
+
process.stdout.write = (chunk) => { out += String(chunk); return true; };
|
|
18
|
+
try {
|
|
19
|
+
showRcaHelp();
|
|
20
|
+
} finally {
|
|
21
|
+
process.stdout.write = origWrite;
|
|
22
|
+
}
|
|
23
|
+
assert.match(out, /bizar rca/);
|
|
24
|
+
assert.match(out, /Usage:/);
|
|
25
|
+
assert.match(out, /GitHub/);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('rca — URL validation (inline test)', () => {
|
|
30
|
+
// The actual URL validation regex is inline in runRca. Test it here.
|
|
31
|
+
const URL_REGEX = /^https?:\/\/github\.com\/[^/]+\/[^/]+\/issues\/\d+/;
|
|
32
|
+
|
|
33
|
+
test('accepts valid GitHub issue URLs', () => {
|
|
34
|
+
assert.match('https://github.com/owner/repo/issues/123', URL_REGEX);
|
|
35
|
+
assert.match('https://github.com/anthropics/claude-code/issues/1', URL_REGEX);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects non-GitHub URLs', () => {
|
|
39
|
+
assert.equal(URL_REGEX.test('https://gitlab.com/owner/repo/issues/123'), false);
|
|
40
|
+
assert.equal(URL_REGEX.test('https://example.com/foo'), false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('rejects malformed GitHub URLs', () => {
|
|
44
|
+
assert.equal(URL_REGEX.test('https://github.com/owner/repo'), false);
|
|
45
|
+
assert.equal(URL_REGEX.test('https://github.com/owner/repo/pull/123'), false);
|
|
46
|
+
assert.equal(URL_REGEX.test('not-a-url'), false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('rca — extractFinalText (inline test)', () => {
|
|
51
|
+
// The actual extraction logic is inline in rca.mjs. Test the
|
|
52
|
+
// expected behavior here so we know the contract.
|
|
53
|
+
function extractFinalText(jsonl) {
|
|
54
|
+
const lines = jsonl.split('\n').filter(Boolean);
|
|
55
|
+
let finalText = null;
|
|
56
|
+
const seen = [];
|
|
57
|
+
for (const line of lines) {
|
|
58
|
+
try {
|
|
59
|
+
const evt = JSON.parse(line);
|
|
60
|
+
if (evt.type === 'agent_event' && evt.event?.type === 'done' && evt.event?.text) {
|
|
61
|
+
finalText = evt.event.text;
|
|
62
|
+
} else if (evt.type === 'say' && evt.text) {
|
|
63
|
+
seen.push(evt.text);
|
|
64
|
+
}
|
|
65
|
+
} catch { /* skip */ }
|
|
66
|
+
}
|
|
67
|
+
if (finalText) return finalText.replace(/\\n/g, '\n');
|
|
68
|
+
if (seen.length > 0) return seen.join('\n').replace(/\\n/g, '\n');
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('extracts final done event text', () => {
|
|
73
|
+
const ndjson = [
|
|
74
|
+
JSON.stringify({ type: 'say', text: 'thinking...' }),
|
|
75
|
+
JSON.stringify({ type: 'agent_event', event: { type: 'done', text: 'final answer' } }),
|
|
76
|
+
].join('\n');
|
|
77
|
+
assert.equal(extractFinalText(ndjson), 'final answer');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('un-escapes \\n in the final text', () => {
|
|
81
|
+
const ndjson = JSON.stringify({
|
|
82
|
+
type: 'agent_event', event: { type: 'done', text: 'line1\\nline2' },
|
|
83
|
+
});
|
|
84
|
+
assert.equal(extractFinalText(ndjson), 'line1\nline2');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('falls back to concatenated say events when no done event', () => {
|
|
88
|
+
const ndjson = [
|
|
89
|
+
JSON.stringify({ type: 'say', text: 'part1' }),
|
|
90
|
+
JSON.stringify({ type: 'say', text: 'part2' }),
|
|
91
|
+
].join('\n');
|
|
92
|
+
assert.equal(extractFinalText(ndjson), 'part1\npart2');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('returns null for empty/invalid input', () => {
|
|
96
|
+
assert.equal(extractFinalText(''), null);
|
|
97
|
+
assert.equal(extractFinalText('not json'), null);
|
|
98
|
+
});
|
|
99
|
+
});
|