@backburner/cli 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -8
- package/dist/src/cli/dispatcher.js +7 -0
- package/dist/src/cli/init.js +2 -1
- package/dist/src/onboarding/prompt.js +104 -23
- package/dist/src/onboarding/services/demo-repo.js +33 -0
- package/dist/src/onboarding/services/provider-discovery.js +71 -6
- package/dist/src/onboarding/steps/configure-models.js +100 -39
- package/dist/src/onboarding/steps/discover-providers.js +9 -3
- package/dist/src/onboarding/steps/select-repos.js +112 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,12 +117,14 @@ Useful `init` flags:
|
|
|
117
117
|
--yes, -y Accept defaults without prompts
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
`backburner init` checks local requirements, confirms `gh auth status`, discovers repositories, detects provider CLIs, and writes these files into the management directory:
|
|
120
|
+
`backburner init` checks local requirements, confirms `gh auth status`, discovers repositories, detects provider CLIs, attempts model-list discovery when supported by those CLIs, and writes these files into the management directory:
|
|
121
121
|
|
|
122
122
|
- `repos.json`
|
|
123
123
|
- `agents.json`
|
|
124
124
|
|
|
125
|
-
If either file already exists, onboarding asks before overwriting it. With `--yes`, defaults are accepted non-interactively.
|
|
125
|
+
If either file already exists, onboarding asks before overwriting it. With `--yes`, defaults are accepted non-interactively, including selecting existing discovered repositories, enabling every discovered provider, and creating strong and cheap agents for each one. `--yes` does not create a private demo repository.
|
|
126
|
+
|
|
127
|
+
Repository setup first asks whether to select existing repositories, create a private Backburner demo repository, or skip repository setup for now. In an interactive terminal, repository and provider selection use a checkbox picker: arrow keys move, space toggles, and enter confirms. Non-TTY sessions fall back to text input and accept comma-separated numbers. If you are authenticated with GitHub but do not want to onboard an existing local clone yet, onboarding can explicitly create a private `backburner-demo` repository and clone it under the configured code root.
|
|
126
128
|
|
|
127
129
|
## Default Directories
|
|
128
130
|
|
|
@@ -180,7 +182,7 @@ Minimal `agents.json`:
|
|
|
180
182
|
"id": "codex-strong",
|
|
181
183
|
"provider": "codex",
|
|
182
184
|
"modelClass": "strong",
|
|
183
|
-
"model": "
|
|
185
|
+
"model": "sol",
|
|
184
186
|
"roles": [
|
|
185
187
|
"product",
|
|
186
188
|
"planning",
|
|
@@ -192,15 +194,15 @@ Minimal `agents.json`:
|
|
|
192
194
|
"enabled": true
|
|
193
195
|
},
|
|
194
196
|
{
|
|
195
|
-
"id": "
|
|
196
|
-
"provider": "
|
|
197
|
+
"id": "codex-cheap",
|
|
198
|
+
"provider": "codex",
|
|
197
199
|
"modelClass": "cheap",
|
|
198
|
-
"model": "
|
|
200
|
+
"model": "luna",
|
|
199
201
|
"roles": [
|
|
200
202
|
"implementation",
|
|
201
203
|
"discussion"
|
|
202
204
|
],
|
|
203
|
-
"command": "
|
|
205
|
+
"command": "codex",
|
|
204
206
|
"enabled": true
|
|
205
207
|
}
|
|
206
208
|
]
|
|
@@ -209,7 +211,7 @@ Minimal `agents.json`:
|
|
|
209
211
|
|
|
210
212
|
`allowedUsers` limits which GitHub users can feed issues, comments, reviews, and pull requests into task derivation. `localPath` must be relative to `--code-root`.
|
|
211
213
|
|
|
212
|
-
Agent selection filters enabled agents by required role and `modelClass`, then breaks ties by lowest agent `id`. Provider model names are passed through to the provider CLI and are not validated against vendor catalogs.
|
|
214
|
+
Agent selection filters enabled agents by required role and `modelClass`, then breaks ties by lowest agent `id`. Provider model names are passed through to the provider CLI and are not validated against vendor catalogs. During onboarding, model setup shows discovered model options when available, marks strong and cheap defaults, and pressing enter accepts the default. Backburner prefers discovered `sol` for strong, `terra` for balanced/default, and `luna` for cheap model names; Codex defaults are `sol` and `luna`. OpenCode may use discovered OpenRouter model IDs such as `openrouter/openai/gpt-5.6-sol` and `openrouter/openai/gpt-5.6-luna`.
|
|
213
215
|
|
|
214
216
|
## Label Control Plane
|
|
215
217
|
|
|
@@ -14,6 +14,10 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
14
14
|
process.stdout.write(`${usage}\n`);
|
|
15
15
|
return;
|
|
16
16
|
}
|
|
17
|
+
if (command !== undefined && hasHelpFlag(rest)) {
|
|
18
|
+
process.stdout.write(`${usage}\n`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
17
21
|
if (command === "run") {
|
|
18
22
|
if (shouldUseRuntimeStatusScreen(rest)) {
|
|
19
23
|
await runInteractiveTui([command, ...rest]);
|
|
@@ -49,3 +53,6 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
49
53
|
}
|
|
50
54
|
throw new CliUsageError(usage);
|
|
51
55
|
}
|
|
56
|
+
function hasHelpFlag(argv) {
|
|
57
|
+
return argv.includes("--help") || argv.includes("-h");
|
|
58
|
+
}
|
package/dist/src/cli/init.js
CHANGED
|
@@ -14,6 +14,7 @@ import { ConfigureModelsStep } from "../onboarding/steps/configure-models.js";
|
|
|
14
14
|
import { GenerateConfigStep } from "../onboarding/steps/generate-config.js";
|
|
15
15
|
import { ReadinessValidationStep } from "../onboarding/steps/readiness-validation.js";
|
|
16
16
|
import { ShowSummaryStep } from "../onboarding/steps/show-summary.js";
|
|
17
|
+
import { DemoRepoCreator } from "../onboarding/services/demo-repo.js";
|
|
17
18
|
export async function runInitCli(argv) {
|
|
18
19
|
const codeRoot = readStringFlag(argv, "--code-root") ?? defaultBackburnerCodeRoot();
|
|
19
20
|
const managementDir = readStringFlag(argv, "--management-dir");
|
|
@@ -56,7 +57,7 @@ export async function runInit(options) {
|
|
|
56
57
|
new CheckRequirementsStep(commandRunner),
|
|
57
58
|
new CheckGitHubAuthStep(commandRunner),
|
|
58
59
|
new DiscoverReposStep(commandRunner, ctx.codeRoot),
|
|
59
|
-
new SelectReposStep(),
|
|
60
|
+
new SelectReposStep(new DemoRepoCreator(commandRunner)),
|
|
60
61
|
new ConfigurePathsStep(),
|
|
61
62
|
new DiscoverProvidersStep(commandRunner),
|
|
62
63
|
new ConfigureModelsStep(),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emitKeypressEvents } from "node:readline";
|
|
1
2
|
import * as readline from "node:readline/promises";
|
|
2
3
|
/**
|
|
3
4
|
* Interactive prompter backed by Node.js readline/promises.
|
|
@@ -5,10 +6,14 @@ import * as readline from "node:readline/promises";
|
|
|
5
6
|
*/
|
|
6
7
|
export class NodeReadlinePrompter {
|
|
7
8
|
rl;
|
|
8
|
-
|
|
9
|
+
input;
|
|
10
|
+
output;
|
|
11
|
+
constructor(options = {}) {
|
|
12
|
+
this.input = options.input ?? process.stdin;
|
|
13
|
+
this.output = options.output ?? process.stdout;
|
|
9
14
|
this.rl = readline.createInterface({
|
|
10
|
-
input:
|
|
11
|
-
output:
|
|
15
|
+
input: this.input,
|
|
16
|
+
output: this.output
|
|
12
17
|
});
|
|
13
18
|
}
|
|
14
19
|
close() {
|
|
@@ -32,7 +37,7 @@ export class NodeReadlinePrompter {
|
|
|
32
37
|
const marker = i === defaultIndex ? "*" : " ";
|
|
33
38
|
lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
|
|
34
39
|
}
|
|
35
|
-
|
|
40
|
+
this.output.write(`${lines.join("\n")}\n`);
|
|
36
41
|
const defaultHint = defaultIndex !== undefined ? ` [${defaultIndex + 1}]` : "";
|
|
37
42
|
// eslint-disable-next-line no-constant-condition
|
|
38
43
|
while (true) {
|
|
@@ -44,10 +49,16 @@ export class NodeReadlinePrompter {
|
|
|
44
49
|
if (Number.isInteger(n) && n >= 1 && n <= options.length) {
|
|
45
50
|
return n - 1;
|
|
46
51
|
}
|
|
47
|
-
|
|
52
|
+
this.output.write(` Please enter a number between 1 and ${options.length}\n`);
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
async multiSelect(question, options, preSelected) {
|
|
56
|
+
if (canUseTerminalPicker(this.input, this.output)) {
|
|
57
|
+
return this.terminalMultiSelect(question, options, preSelected);
|
|
58
|
+
}
|
|
59
|
+
return this.textMultiSelect(question, options, preSelected);
|
|
60
|
+
}
|
|
61
|
+
async textMultiSelect(question, options, preSelected) {
|
|
51
62
|
const preSet = new Set(preSelected ?? []);
|
|
52
63
|
const lines = [
|
|
53
64
|
question,
|
|
@@ -57,30 +68,100 @@ export class NodeReadlinePrompter {
|
|
|
57
68
|
const marker = preSet.has(i) ? "[x]" : "[ ]";
|
|
58
69
|
lines.push(` ${marker} ${i + 1}. ${options[i] ?? ""}`);
|
|
59
70
|
}
|
|
60
|
-
|
|
71
|
+
this.output.write(`${lines.join("\n")}\n`);
|
|
61
72
|
// eslint-disable-next-line no-constant-condition
|
|
62
73
|
while (true) {
|
|
63
74
|
const answer = (await this.rl.question("Selection: ")).trim().toLowerCase();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const indices = [];
|
|
70
|
-
let valid = true;
|
|
71
|
-
for (const part of parts) {
|
|
72
|
-
const n = Number(part);
|
|
73
|
-
if (!Number.isInteger(n) || n < 1 || n > options.length) {
|
|
74
|
-
process.stdout.write(` Invalid selection "${part}". Enter numbers between 1 and ${options.length}\n`);
|
|
75
|
-
valid = false;
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
indices.push(n - 1);
|
|
75
|
+
const parsed = parseTextMultiSelectAnswer(answer, options.length, preSelected);
|
|
76
|
+
if (parsed.ok)
|
|
77
|
+
return parsed.indices;
|
|
78
|
+
if (parsed.invalidPart !== undefined) {
|
|
79
|
+
this.output.write(` Invalid selection "${parsed.invalidPart}". Enter numbers between 1 and ${options.length}\n`);
|
|
79
80
|
}
|
|
80
|
-
if (valid)
|
|
81
|
-
return [...new Set(indices)];
|
|
82
81
|
}
|
|
83
82
|
}
|
|
83
|
+
async terminalMultiSelect(question, options, preSelected) {
|
|
84
|
+
const selected = new Set(preSelected ?? []);
|
|
85
|
+
let cursor = preSelected?.[0] ?? 0;
|
|
86
|
+
if (cursor < 0 || cursor >= options.length)
|
|
87
|
+
cursor = 0;
|
|
88
|
+
this.rl.pause();
|
|
89
|
+
emitKeypressEvents(this.input);
|
|
90
|
+
this.input.setRawMode?.(true);
|
|
91
|
+
this.input.resume();
|
|
92
|
+
return await new Promise((resolve) => {
|
|
93
|
+
const render = () => {
|
|
94
|
+
const lines = [
|
|
95
|
+
question,
|
|
96
|
+
"Use ↑/↓ to move, space to toggle, enter to confirm.",
|
|
97
|
+
...options.map((option, i) => {
|
|
98
|
+
const pointer = i === cursor ? ">" : " ";
|
|
99
|
+
const marker = selected.has(i) ? "[x]" : "[ ]";
|
|
100
|
+
return `${pointer} ${marker} ${option}`;
|
|
101
|
+
})
|
|
102
|
+
];
|
|
103
|
+
this.output.write(`\x1b[?25l\x1b[2K\r${lines.join("\n")}`);
|
|
104
|
+
this.output.write(`\x1b[${lines.length - 1}A`);
|
|
105
|
+
};
|
|
106
|
+
const finish = () => {
|
|
107
|
+
this.input.off("keypress", onKeypress);
|
|
108
|
+
this.input.setRawMode?.(false);
|
|
109
|
+
this.output.write(`\x1b[?25h\x1b[${options.length + 1}B\n`);
|
|
110
|
+
this.rl.resume();
|
|
111
|
+
resolve([...selected].sort((a, b) => a - b));
|
|
112
|
+
};
|
|
113
|
+
const onKeypress = (_chunk, key) => {
|
|
114
|
+
if (key.ctrl === true && key.name === "c") {
|
|
115
|
+
finish();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
switch (key.name) {
|
|
119
|
+
case "up":
|
|
120
|
+
cursor = cursor <= 0 ? options.length - 1 : cursor - 1;
|
|
121
|
+
render();
|
|
122
|
+
break;
|
|
123
|
+
case "down":
|
|
124
|
+
cursor = cursor >= options.length - 1 ? 0 : cursor + 1;
|
|
125
|
+
render();
|
|
126
|
+
break;
|
|
127
|
+
case "space":
|
|
128
|
+
if (selected.has(cursor)) {
|
|
129
|
+
selected.delete(cursor);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
selected.add(cursor);
|
|
133
|
+
}
|
|
134
|
+
render();
|
|
135
|
+
break;
|
|
136
|
+
case "return":
|
|
137
|
+
case "enter":
|
|
138
|
+
finish();
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
this.input.on("keypress", onKeypress);
|
|
143
|
+
render();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function canUseTerminalPicker(input, output) {
|
|
148
|
+
return input.isTTY === true && output.isTTY === true && typeof input.setRawMode === "function";
|
|
149
|
+
}
|
|
150
|
+
export function parseTextMultiSelectAnswer(answer, optionCount, preSelected) {
|
|
151
|
+
if (answer === "all")
|
|
152
|
+
return { ok: true, indices: Array.from({ length: optionCount }, (_, i) => i) };
|
|
153
|
+
if (answer === "" || answer === "none")
|
|
154
|
+
return { ok: true, indices: preSelected ?? [] };
|
|
155
|
+
const parts = answer.split(",").map((p) => p.trim());
|
|
156
|
+
const indices = [];
|
|
157
|
+
for (const part of parts) {
|
|
158
|
+
const n = Number(part);
|
|
159
|
+
if (!Number.isInteger(n) || n < 1 || n > optionCount) {
|
|
160
|
+
return { ok: false, invalidPart: part };
|
|
161
|
+
}
|
|
162
|
+
indices.push(n - 1);
|
|
163
|
+
}
|
|
164
|
+
return { ok: true, indices: [...new Set(indices)] };
|
|
84
165
|
}
|
|
85
166
|
/**
|
|
86
167
|
* Non-interactive prompter that accepts all defaults.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export class DemoRepoCreator {
|
|
4
|
+
commandRunner;
|
|
5
|
+
constructor(commandRunner) {
|
|
6
|
+
this.commandRunner = commandRunner;
|
|
7
|
+
}
|
|
8
|
+
async createPrivateDemoRepo(options) {
|
|
9
|
+
await fs.mkdir(options.codeRoot, { recursive: true });
|
|
10
|
+
await this.commandRunner.run("gh", [
|
|
11
|
+
"repo",
|
|
12
|
+
"create",
|
|
13
|
+
`${options.owner}/${options.name}`,
|
|
14
|
+
"--private",
|
|
15
|
+
"--clone",
|
|
16
|
+
"--add-readme",
|
|
17
|
+
"--description",
|
|
18
|
+
"Private Backburner onboarding demo repository"
|
|
19
|
+
], { cwd: options.codeRoot, timeoutMs: 60_000 });
|
|
20
|
+
return {
|
|
21
|
+
owner: options.owner,
|
|
22
|
+
name: options.name,
|
|
23
|
+
localPath: path.join(options.codeRoot, options.name),
|
|
24
|
+
defaultBranch: "main",
|
|
25
|
+
isLocalClone: true,
|
|
26
|
+
canFetch: true,
|
|
27
|
+
canPush: true
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function defaultDemoRepoName() {
|
|
32
|
+
return "backburner-demo";
|
|
33
|
+
}
|
|
@@ -3,31 +3,36 @@ const PROVIDER_SPECS = [
|
|
|
3
3
|
name: "claude",
|
|
4
4
|
command: "claude",
|
|
5
5
|
versionArgs: ["--version"],
|
|
6
|
-
versionExtract: (out) => out.trim().split("\n")[0] ?? out.trim()
|
|
6
|
+
versionExtract: (out) => out.trim().split("\n")[0] ?? out.trim(),
|
|
7
|
+
modelListCommands: [["models"], ["models", "list"], ["model", "list"]]
|
|
7
8
|
},
|
|
8
9
|
{
|
|
9
10
|
name: "codex",
|
|
10
11
|
command: "codex",
|
|
11
12
|
versionArgs: ["--version"],
|
|
12
|
-
versionExtract: (out) => out.trim()
|
|
13
|
+
versionExtract: (out) => out.trim(),
|
|
14
|
+
modelListCommands: [["models"], ["models", "list"], ["model", "list"]]
|
|
13
15
|
},
|
|
14
16
|
{
|
|
15
17
|
name: "gemini",
|
|
16
18
|
command: "gemini",
|
|
17
19
|
versionArgs: ["--version"],
|
|
18
|
-
versionExtract: (out) => out.trim()
|
|
20
|
+
versionExtract: (out) => out.trim(),
|
|
21
|
+
modelListCommands: [["models"], ["models", "list"], ["model", "list"]]
|
|
19
22
|
},
|
|
20
23
|
{
|
|
21
24
|
name: "opencode",
|
|
22
25
|
command: "opencode",
|
|
23
26
|
versionArgs: ["--version"],
|
|
24
|
-
versionExtract: (out) => out.trim()
|
|
27
|
+
versionExtract: (out) => out.trim(),
|
|
28
|
+
modelListCommands: [["models"], ["models", "list"], ["model", "list"]]
|
|
25
29
|
},
|
|
26
30
|
{
|
|
27
31
|
name: "antigravity",
|
|
28
32
|
command: "agy",
|
|
29
33
|
versionArgs: ["--version"],
|
|
30
|
-
versionExtract: (out) => out.trim()
|
|
34
|
+
versionExtract: (out) => out.trim(),
|
|
35
|
+
modelListCommands: [["models"], ["models", "list"], ["model", "list"]]
|
|
31
36
|
}
|
|
32
37
|
];
|
|
33
38
|
export class ProviderDiscoveryService {
|
|
@@ -44,15 +49,75 @@ export class ProviderDiscoveryService {
|
|
|
44
49
|
timeoutMs: 5_000
|
|
45
50
|
});
|
|
46
51
|
const version = spec.versionExtract?.(result.stdout) ?? result.stdout.trim();
|
|
52
|
+
const models = await this.discoverModels(spec);
|
|
47
53
|
return {
|
|
48
54
|
name: spec.name,
|
|
49
55
|
command: spec.command,
|
|
50
56
|
available: true,
|
|
51
|
-
...(version.length > 0 ? { version } : {})
|
|
57
|
+
...(version.length > 0 ? { version } : {}),
|
|
58
|
+
...(models.length > 0 ? { models } : {})
|
|
52
59
|
};
|
|
53
60
|
}
|
|
54
61
|
catch {
|
|
55
62
|
return { name: spec.name, command: spec.command, available: false };
|
|
56
63
|
}
|
|
57
64
|
}
|
|
65
|
+
async discoverModels(spec) {
|
|
66
|
+
for (const args of spec.modelListCommands ?? []) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await this.commandRunner.run(spec.command, args, {
|
|
69
|
+
timeoutMs: 5_000
|
|
70
|
+
});
|
|
71
|
+
const models = parseModelListOutput(result.stdout);
|
|
72
|
+
if (models.length > 0) {
|
|
73
|
+
return models;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Model listing is optional. Keep provider discovery available and fall back to defaults.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function parseModelListOutput(stdout) {
|
|
84
|
+
const models = [];
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
87
|
+
const model = normalizeModelListLine(rawLine);
|
|
88
|
+
if (model.length === 0 || seen.has(model)) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
models.push(model);
|
|
92
|
+
seen.add(model);
|
|
93
|
+
}
|
|
94
|
+
return models;
|
|
95
|
+
}
|
|
96
|
+
function normalizeModelListLine(line) {
|
|
97
|
+
const withoutBullet = line
|
|
98
|
+
.trim()
|
|
99
|
+
.replace(/^[*\-]\s*/, "")
|
|
100
|
+
.replace(/^\[[ xX]\]\s*/, "")
|
|
101
|
+
.replace(/^\d+[.)]\s*/, "")
|
|
102
|
+
.trim();
|
|
103
|
+
if (withoutBullet.length === 0 || withoutBullet.startsWith("#")) {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
const jsonName = /"(?<key>id|name|model)"\s*:\s*"(?<value>[^"]+)"/.exec(withoutBullet);
|
|
107
|
+
if (jsonName?.groups?.value !== undefined) {
|
|
108
|
+
return jsonName.groups.value.trim();
|
|
109
|
+
}
|
|
110
|
+
const tableCells = withoutBullet.split("|").map((cell) => cell.trim()).filter(Boolean);
|
|
111
|
+
if (tableCells.length > 0 && tableCells.every((cell) => /^-+$/.test(cell))) {
|
|
112
|
+
return "";
|
|
113
|
+
}
|
|
114
|
+
if (tableCells.length > 1) {
|
|
115
|
+
return stripAnnotation(tableCells[0] ?? "");
|
|
116
|
+
}
|
|
117
|
+
return stripAnnotation(withoutBullet);
|
|
118
|
+
}
|
|
119
|
+
function stripAnnotation(value) {
|
|
120
|
+
return value
|
|
121
|
+
.replace(/\s+\((default|recommended|latest|beta|preview)\)$/i, "")
|
|
122
|
+
.trim();
|
|
58
123
|
}
|
|
@@ -7,17 +7,19 @@ const STRONG_ROLES = [
|
|
|
7
7
|
"retrospective"
|
|
8
8
|
];
|
|
9
9
|
const CHEAP_ROLES = ["implementation", "discussion"];
|
|
10
|
-
/**
|
|
10
|
+
/** Provider-specific fallbacks used when the CLI cannot list Backburner model names. */
|
|
11
11
|
const DEFAULT_MODELS = {
|
|
12
12
|
claude: { strong: "claude-sonnet-4-6" },
|
|
13
|
-
codex: { strong: "
|
|
13
|
+
codex: { strong: "sol", cheap: "luna" },
|
|
14
14
|
gemini: { strong: "gemini-3.1-pro-preview", cheap: "gemini-3-flash-preview" },
|
|
15
15
|
opencode: { strong: "anthropic/claude-sonnet-4-6", cheap: "google/gemini-2.0-flash" },
|
|
16
16
|
antigravity: { strong: "Gemini 3.1 Pro (High)", cheap: "Gemini 3.5 Flash (Low)" }
|
|
17
17
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
const BACKBURNER_MODEL_DEFAULTS = {
|
|
19
|
+
strong: "sol",
|
|
20
|
+
balanced: "terra",
|
|
21
|
+
cheap: "luna"
|
|
22
|
+
};
|
|
21
23
|
export class ConfigureModelsStep {
|
|
22
24
|
name = "configure-models";
|
|
23
25
|
description = "Configure model assignments";
|
|
@@ -29,35 +31,31 @@ export class ConfigureModelsStep {
|
|
|
29
31
|
message: "Skipped: no providers available to configure"
|
|
30
32
|
};
|
|
31
33
|
}
|
|
32
|
-
const providerOptions = available.map((p) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
34
|
+
const providerOptions = available.map((p) => {
|
|
35
|
+
const modelNote = p.models !== undefined && p.models.length > 0
|
|
36
|
+
? `, ${p.models.length} model${p.models.length === 1 ? "" : "s"}`
|
|
37
|
+
: "";
|
|
38
|
+
return `${p.name}${p.version !== undefined ? ` (${p.version}${modelNote})` : modelNote}`;
|
|
39
|
+
});
|
|
40
|
+
process.stdout.write("\nSelect providers to enable. Backburner will configure strong and cheap agents for each selected provider.\n");
|
|
41
|
+
process.stdout.write("Strong role tasks: product, planning, review, discussion, implementation_manager, retrospective\n");
|
|
42
|
+
process.stdout.write("Cheap role tasks: implementation, discussion\n");
|
|
43
|
+
const selectedProviderIndices = await prompter.multiSelect("Providers to enable:", providerOptions, available.map((_, index) => index));
|
|
44
|
+
const selectedProviders = selectedProviderIndices
|
|
45
|
+
.map((index) => available[index])
|
|
46
|
+
.filter((provider) => provider !== undefined);
|
|
47
|
+
if (selectedProviders.length === 0) {
|
|
48
|
+
return { status: "failed", message: "No providers selected for agent configuration" };
|
|
47
49
|
}
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
const agents = [];
|
|
51
|
+
for (const provider of selectedProviders) {
|
|
52
|
+
const strongDefault = selectDefaultModel(provider, "strong");
|
|
53
|
+
const cheapDefault = selectDefaultModel(provider, "cheap");
|
|
54
|
+
showAvailableModels(provider, strongDefault, cheapDefault);
|
|
55
|
+
const strongModel = await prompter.ask(`Model for ${provider.name} (strong):`, strongDefault);
|
|
56
|
+
const cheapModel = await prompter.ask(`Model for ${provider.name} (cheap):`, cheapDefault);
|
|
57
|
+
agents.push(buildAgent(`${provider.name}-strong`, provider.name, provider.command, "strong", STRONG_ROLES, strongModel || strongDefault), buildAgent(`${provider.name}-cheap`, provider.name, provider.command, "cheap", CHEAP_ROLES, cheapModel || cheapDefault));
|
|
52
58
|
}
|
|
53
|
-
const strongDefaults = DEFAULT_MODELS[strongProvider.name];
|
|
54
|
-
const cheapDefaults = DEFAULT_MODELS[cheapProvider.name];
|
|
55
|
-
const strongModel = await prompter.ask(`Model for ${strongProvider.name} (strong):`, strongDefaults.strong);
|
|
56
|
-
const cheapModel = await prompter.ask(`Model for ${cheapProvider.name} (cheap):`, cheapDefaults.cheap ?? cheapDefaults.strong);
|
|
57
|
-
const agents = [
|
|
58
|
-
buildAgent(`${strongProvider.name}-strong`, strongProvider.name, strongProvider.command, "strong", STRONG_ROLES, strongModel || strongDefaults.strong),
|
|
59
|
-
buildAgent(`${cheapProvider.name}-cheap`, cheapProvider.name, cheapProvider.command, "cheap", CHEAP_ROLES, cheapModel || (cheapDefaults.cheap ?? cheapDefaults.strong))
|
|
60
|
-
];
|
|
61
59
|
if (ctx.configDraft === undefined) {
|
|
62
60
|
ctx.configDraft = buildEmptyDraft(ctx);
|
|
63
61
|
}
|
|
@@ -67,18 +65,81 @@ export class ConfigureModelsStep {
|
|
|
67
65
|
message: `Configured ${agents.length} agents`,
|
|
68
66
|
details: agents.map((a) => {
|
|
69
67
|
const modelNote = a.model !== undefined ? ` (${a.model})` : "";
|
|
70
|
-
return `${a.id}: ${a.provider}${modelNote} [${a.modelClass}]
|
|
68
|
+
return `${a.id}: ${a.provider}${modelNote} [${a.modelClass}] -> ${a.roles.join(", ")}`;
|
|
71
69
|
})
|
|
72
70
|
};
|
|
73
71
|
}
|
|
74
72
|
}
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
73
|
+
function showAvailableModels(provider, strongDefault, cheapDefault) {
|
|
74
|
+
const models = provider.models ?? [];
|
|
75
|
+
if (models.length === 0) {
|
|
76
|
+
process.stdout.write(`\n${provider.name} did not expose a model list. Press enter to use the defaults.\n`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const shownModels = rankModelOptions(models, [strongDefault, cheapDefault]);
|
|
80
|
+
process.stdout.write(`\nAvailable ${provider.name} models`);
|
|
81
|
+
if (shownModels.length < models.length) {
|
|
82
|
+
process.stdout.write(` (showing ${shownModels.length} of ${models.length})`);
|
|
83
|
+
}
|
|
84
|
+
process.stdout.write(":\n");
|
|
85
|
+
for (const model of shownModels) {
|
|
86
|
+
const labels = [
|
|
87
|
+
model === strongDefault ? "strong default" : undefined,
|
|
88
|
+
model === cheapDefault ? "cheap default" : undefined
|
|
89
|
+
].filter((label) => label !== undefined);
|
|
90
|
+
const suffix = labels.length > 0 ? ` (${labels.join(", ")})` : "";
|
|
91
|
+
process.stdout.write(` - ${model}${suffix}\n`);
|
|
92
|
+
}
|
|
93
|
+
process.stdout.write("Press enter at each prompt to accept the default, or type any model name.\n");
|
|
94
|
+
}
|
|
95
|
+
function rankModelOptions(models, defaults) {
|
|
96
|
+
const ranked = [];
|
|
97
|
+
const add = (model) => {
|
|
98
|
+
if (model !== undefined && model.length > 0 && !ranked.includes(model)) {
|
|
99
|
+
ranked.push(model);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
for (const defaultModel of defaults) {
|
|
103
|
+
add(defaultModel);
|
|
104
|
+
}
|
|
105
|
+
for (const target of ["sol", "terra", "luna"]) {
|
|
106
|
+
add(findDiscoveredModel(models, [target]));
|
|
107
|
+
}
|
|
108
|
+
for (const model of models) {
|
|
109
|
+
add(model);
|
|
110
|
+
if (ranked.length >= 12) {
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return ranked;
|
|
115
|
+
}
|
|
116
|
+
function selectDefaultModel(provider, modelClass) {
|
|
117
|
+
const configuredDefaults = DEFAULT_MODELS[provider.name];
|
|
118
|
+
const backburnerDefault = BACKBURNER_MODEL_DEFAULTS[modelClass];
|
|
119
|
+
const providerDefault = configuredDefaults[modelClass] ?? configuredDefaults.strong;
|
|
120
|
+
const discoveredBackburnerDefault = findDiscoveredModel(provider.models ?? [], [
|
|
121
|
+
backburnerDefault
|
|
122
|
+
]);
|
|
123
|
+
const discoveredProviderDefault = providerDefault !== undefined
|
|
124
|
+
? findDiscoveredModel(provider.models ?? [], [providerDefault])
|
|
125
|
+
: undefined;
|
|
126
|
+
return discoveredBackburnerDefault ?? discoveredProviderDefault ?? providerDefault ?? backburnerDefault;
|
|
127
|
+
}
|
|
128
|
+
function findDiscoveredModel(models, candidates) {
|
|
129
|
+
for (const candidate of candidates) {
|
|
130
|
+
const match = models.find((model) => model.toLowerCase() === candidate.toLowerCase());
|
|
131
|
+
if (match !== undefined) {
|
|
132
|
+
return match;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
for (const candidate of candidates) {
|
|
136
|
+
const normalizedCandidate = candidate.toLowerCase();
|
|
137
|
+
const match = models.find((model) => model.toLowerCase().includes(normalizedCandidate));
|
|
138
|
+
if (match !== undefined) {
|
|
139
|
+
return match;
|
|
140
|
+
}
|
|
80
141
|
}
|
|
81
|
-
return
|
|
142
|
+
return undefined;
|
|
82
143
|
}
|
|
83
144
|
function buildAgent(id, provider, command, modelClass, roles, model) {
|
|
84
145
|
return {
|
|
@@ -11,9 +11,15 @@ export class DiscoverProvidersStep {
|
|
|
11
11
|
ctx.discoveredProviders = await service.discoverAll();
|
|
12
12
|
const available = ctx.discoveredProviders.filter((p) => p.available);
|
|
13
13
|
const unavailable = ctx.discoveredProviders.filter((p) => !p.available);
|
|
14
|
-
const details = ctx.discoveredProviders.map((p) =>
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
const details = ctx.discoveredProviders.map((p) => {
|
|
15
|
+
if (!p.available) {
|
|
16
|
+
return `x ${p.name} (${p.command}): not found`;
|
|
17
|
+
}
|
|
18
|
+
const modelNote = p.models !== undefined && p.models.length > 0
|
|
19
|
+
? `, ${p.models.length} model${p.models.length === 1 ? "" : "s"} discovered`
|
|
20
|
+
: ", model list unavailable";
|
|
21
|
+
return `ok ${p.name} (${p.command}): ${p.version ?? "found"}${modelNote}`;
|
|
22
|
+
});
|
|
17
23
|
if (available.length === 0) {
|
|
18
24
|
ctx.warnings.push("No AI providers found. Install at least one of: antigravity, gemini, claude, codex, opencode");
|
|
19
25
|
return {
|
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
import { defaultDemoRepoName } from "../services/demo-repo.js";
|
|
1
2
|
export class SelectReposStep {
|
|
3
|
+
demoRepoCreator;
|
|
2
4
|
name = "select-repos";
|
|
3
5
|
description = "Select repositories to manage";
|
|
6
|
+
constructor(demoRepoCreator) {
|
|
7
|
+
this.demoRepoCreator = demoRepoCreator;
|
|
8
|
+
}
|
|
4
9
|
async run(ctx, prompter) {
|
|
5
10
|
const { discoveredRepos } = ctx;
|
|
11
|
+
const setupChoice = await this.promptSetupChoice(ctx, prompter);
|
|
12
|
+
if (setupChoice === "demo") {
|
|
13
|
+
return await this.promptCreateDemoRepo(ctx, prompter);
|
|
14
|
+
}
|
|
15
|
+
if (setupChoice === "skip") {
|
|
16
|
+
return this.noRepositoryWarning();
|
|
17
|
+
}
|
|
6
18
|
if (discoveredRepos.length === 0) {
|
|
7
19
|
return await this.promptManualEntry(ctx, prompter);
|
|
8
20
|
}
|
|
@@ -29,15 +41,7 @@ export class SelectReposStep {
|
|
|
29
41
|
}
|
|
30
42
|
}
|
|
31
43
|
if (selected.length === 0) {
|
|
32
|
-
|
|
33
|
-
if (fallback) {
|
|
34
|
-
return await this.promptManualEntry(ctx, prompter);
|
|
35
|
-
}
|
|
36
|
-
return {
|
|
37
|
-
status: "warning",
|
|
38
|
-
message: "No repositories selected",
|
|
39
|
-
details: ["You can add repositories by editing repos.json later."]
|
|
40
|
-
};
|
|
44
|
+
return await this.promptNoSelection(ctx, prompter);
|
|
41
45
|
}
|
|
42
46
|
ctx.selectedRepos = selected;
|
|
43
47
|
return {
|
|
@@ -49,6 +53,95 @@ export class SelectReposStep {
|
|
|
49
53
|
})
|
|
50
54
|
};
|
|
51
55
|
}
|
|
56
|
+
async promptSetupChoice(ctx, prompter) {
|
|
57
|
+
const hasDiscoveredRepos = ctx.discoveredRepos.length > 0;
|
|
58
|
+
const options = [
|
|
59
|
+
hasDiscoveredRepos
|
|
60
|
+
? "Select existing repositories"
|
|
61
|
+
: "Enter an existing repository manually",
|
|
62
|
+
...(ctx.githubUser !== undefined ? ["Create a private Backburner demo repo"] : []),
|
|
63
|
+
"Skip repository setup for now"
|
|
64
|
+
];
|
|
65
|
+
const choice = await prompter.choose("How do you want to set up repositories?", options, 0);
|
|
66
|
+
if (choice === 0) {
|
|
67
|
+
return "existing";
|
|
68
|
+
}
|
|
69
|
+
if (ctx.githubUser !== undefined && choice === 1) {
|
|
70
|
+
return "demo";
|
|
71
|
+
}
|
|
72
|
+
return "skip";
|
|
73
|
+
}
|
|
74
|
+
async promptNoSelection(ctx, prompter) {
|
|
75
|
+
const options = [
|
|
76
|
+
"Add an existing repository manually",
|
|
77
|
+
...(ctx.githubUser !== undefined ? ["Create a private Backburner demo repo"] : []),
|
|
78
|
+
"Skip repository setup for now"
|
|
79
|
+
];
|
|
80
|
+
const defaultIndex = 0;
|
|
81
|
+
const choice = await prompter.choose("No repositories selected. How do you want to continue?", options, defaultIndex);
|
|
82
|
+
if (choice === 0) {
|
|
83
|
+
return await this.promptManualEntry(ctx, prompter);
|
|
84
|
+
}
|
|
85
|
+
if (ctx.githubUser !== undefined && choice === 1) {
|
|
86
|
+
return await this.promptCreateDemoRepo(ctx, prompter);
|
|
87
|
+
}
|
|
88
|
+
return this.noRepositoryWarning();
|
|
89
|
+
}
|
|
90
|
+
async promptCreateDemoRepo(ctx, prompter) {
|
|
91
|
+
if (ctx.githubUser === undefined) {
|
|
92
|
+
return this.noRepositoryWarning();
|
|
93
|
+
}
|
|
94
|
+
if (this.demoRepoCreator === undefined) {
|
|
95
|
+
return {
|
|
96
|
+
status: "warning",
|
|
97
|
+
message: "Private demo repo creation is unavailable",
|
|
98
|
+
details: ["You can create a private repository manually and re-run onboarding."]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const repoName = (await prompter.ask("Private demo repository name:", defaultDemoRepoName())).trim();
|
|
102
|
+
if (!isValidRepoName(repoName)) {
|
|
103
|
+
return {
|
|
104
|
+
status: "warning",
|
|
105
|
+
message: "Private demo repo was not created",
|
|
106
|
+
details: ["Repository names can contain letters, numbers, dots, underscores, and hyphens."]
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const confirmed = await prompter.confirm(`Create private GitHub repository ${ctx.githubUser}/${repoName} and clone it under ${ctx.codeRoot}?`, false);
|
|
110
|
+
if (!confirmed) {
|
|
111
|
+
return this.noRepositoryWarning();
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const repo = await this.demoRepoCreator.createPrivateDemoRepo({
|
|
115
|
+
owner: ctx.githubUser,
|
|
116
|
+
name: repoName,
|
|
117
|
+
codeRoot: ctx.codeRoot
|
|
118
|
+
});
|
|
119
|
+
ctx.discoveredRepos = [repo, ...ctx.discoveredRepos];
|
|
120
|
+
ctx.selectedRepos = [repo];
|
|
121
|
+
return {
|
|
122
|
+
status: "passed",
|
|
123
|
+
message: `Private demo repository selected: ${repo.owner}/${repo.name}`,
|
|
124
|
+
details: [
|
|
125
|
+
"Visibility: private",
|
|
126
|
+
`Local clone: ${repo.localPath ?? `${repo.owner}/${repo.name}`}`
|
|
127
|
+
]
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
status: "failed",
|
|
133
|
+
message: `Failed to create private demo repo: ${err instanceof Error ? err.message : String(err)}`,
|
|
134
|
+
issues: [
|
|
135
|
+
{
|
|
136
|
+
title: "Private demo repo creation failed",
|
|
137
|
+
cause: err instanceof Error ? err.message : String(err),
|
|
138
|
+
suggestedFix: "Create a private GitHub repository manually, clone it locally, then re-run onboarding.",
|
|
139
|
+
canAutoRemediate: false
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
52
145
|
async promptManualEntry(ctx, prompter) {
|
|
53
146
|
const owner = (await prompter.ask("Repository owner (e.g. myorg):")).trim();
|
|
54
147
|
const name = (await prompter.ask("Repository name (e.g. my-repo):")).trim();
|
|
@@ -73,4 +166,14 @@ export class SelectReposStep {
|
|
|
73
166
|
message: `1 repository selected: ${owner}/${name}`
|
|
74
167
|
};
|
|
75
168
|
}
|
|
169
|
+
noRepositoryWarning() {
|
|
170
|
+
return {
|
|
171
|
+
status: "warning",
|
|
172
|
+
message: "No repositories selected",
|
|
173
|
+
details: ["You can add repositories by editing repos.json later."]
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function isValidRepoName(name) {
|
|
178
|
+
return /^[A-Za-z0-9._-]+$/.test(name);
|
|
76
179
|
}
|