@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 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": "gpt-5",
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": "opencode-cheap",
196
- "provider": "opencode",
197
+ "id": "codex-cheap",
198
+ "provider": "codex",
197
199
  "modelClass": "cheap",
198
- "model": "your-fast-model",
200
+ "model": "luna",
199
201
  "roles": [
200
202
  "implementation",
201
203
  "discussion"
202
204
  ],
203
- "command": "opencode",
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
+ }
@@ -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
- constructor() {
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: process.stdin,
11
- output: process.stdout
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
- process.stdout.write(`${lines.join("\n")}\n`);
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
- process.stdout.write(` Please enter a number between 1 and ${options.length}\n`);
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
- process.stdout.write(`${lines.join("\n")}\n`);
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
- if (answer === "all")
65
- return options.map((_, i) => i);
66
- if (answer === "" || answer === "none")
67
- return preSelected ?? [];
68
- const parts = answer.split(",").map((p) => p.trim());
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
- /** Suggested default model names per provider. */
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: "o3" },
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
- /** Preference order when choosing defaults for the user. */
19
- const STRONG_PREFERENCE = ["antigravity", "gemini", "claude", "codex", "opencode"];
20
- const CHEAP_PREFERENCE = ["antigravity", "gemini", "claude", "codex", "opencode"];
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) => `${p.name}${p.version !== undefined ? ` (${p.version})` : ""}`);
33
- const providerNames = available.map((p) => p.name);
34
- const defaultStrongIdx = indexOfFirstMatch(providerNames, STRONG_PREFERENCE);
35
- const defaultCheapIdx = indexOfFirstMatch(providerNames, CHEAP_PREFERENCE);
36
- let strongIdx;
37
- let cheapIdx;
38
- if (available.length === 1) {
39
- strongIdx = 0;
40
- cheapIdx = 0;
41
- }
42
- else {
43
- process.stdout.write("\nStrong role tasks: product, planning, review, discussion, implementation_manager, retrospective\n");
44
- strongIdx = await prompter.choose("Provider for strong tasks:", providerOptions, defaultStrongIdx);
45
- process.stdout.write("\nCheap role tasks: implementation, discussion\n");
46
- cheapIdx = await prompter.choose("Provider for cheap tasks:", providerOptions, defaultCheapIdx);
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 strongProvider = available[strongIdx];
49
- const cheapProvider = available[cheapIdx];
50
- if (strongProvider === undefined || cheapProvider === undefined) {
51
- return { status: "failed", message: "Provider selection out of range" };
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}] ${a.roles.join(", ")}`;
68
+ return `${a.id}: ${a.provider}${modelNote} [${a.modelClass}] -> ${a.roles.join(", ")}`;
71
69
  })
72
70
  };
73
71
  }
74
72
  }
75
- function indexOfFirstMatch(names, preference) {
76
- for (const pref of preference) {
77
- const idx = names.indexOf(pref);
78
- if (idx >= 0)
79
- return idx;
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 0;
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) => p.available
15
- ? `✓ ${p.name} (${p.command}): ${p.version ?? "found"}`
16
- : `✗ ${p.name} (${p.command}): not found`);
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
- const fallback = await prompter.confirm("No repositories selected. Add one manually?", true);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backburner/cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Local GitHub-based agent orchestration CLI",
6
6
  "license": "MIT",