@backburner/cli 0.1.0 → 0.1.2

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.
@@ -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 {
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, defaultWorkspacesRootForCodeRoot } from "../../utils/paths.js";
2
+ import { defaultBackburnerManagementRoot, defaultBackburnerOutputsRoot, defaultWorkspacesRootForCodeRoot, resolveUserPath } from "../../utils/paths.js";
3
3
  export class ConfigurePathsStep {
4
4
  name = "configure-paths";
5
5
  description = "Configure directories";
@@ -15,10 +15,10 @@ export class ConfigurePathsStep {
15
15
  const outputsDir = await prompter.ask("Outputs directory (state, logs):", defaultOutputs);
16
16
  const logDir = await prompter.ask("Log directory:", ctx.logDir || path.join(outputsDir || defaultOutputs, "logs"));
17
17
  const workspacesDir = await prompter.ask("Local workspaces directory (where Backburner creates git worktrees):", defaultWorkspaces);
18
- ctx.managementDir = managementDir || defaultManagement;
19
- ctx.outputsDir = outputsDir || defaultOutputs;
20
- ctx.logDir = logDir || path.join(ctx.outputsDir, "logs");
21
- ctx.workspacesDir = workspacesDir || defaultWorkspaces;
18
+ ctx.managementDir = resolveUserPath(managementDir || defaultManagement);
19
+ ctx.outputsDir = resolveUserPath(outputsDir || defaultOutputs);
20
+ ctx.logDir = resolveUserPath(logDir || path.join(ctx.outputsDir, "logs"));
21
+ ctx.workspacesDir = resolveUserPath(workspacesDir || defaultWorkspaces);
22
22
  return {
23
23
  status: "passed",
24
24
  message: "Directories configured",
@@ -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,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import { ConfigWriter } from "../config-writer.js";
3
+ import { DEFAULT_BACKBURNER_LABELS } from "../config-writer.js";
3
4
  export class GenerateConfigStep {
4
5
  name = "generate-config";
5
6
  description = "Generating configuration";
@@ -101,6 +102,7 @@ function buildRepoDraft(repo, codeRoot) {
101
102
  name: repo.name,
102
103
  localPath,
103
104
  defaultBranch: repo.defaultBranch || "main",
104
- enabled: true
105
+ enabled: true,
106
+ labels: { ...DEFAULT_BACKBURNER_LABELS }
105
107
  };
106
108
  }
@@ -0,0 +1,104 @@
1
+ import { GitHubLabelProvisioner } from "../services/label-provisioner.js";
2
+ const LABEL_DEFINITIONS = {
3
+ agentInProgress: {
4
+ description: "Backburner is actively working on this item.",
5
+ color: "6F42C1"
6
+ },
7
+ agentProductApproved: {
8
+ description: "Approve Backburner to start product discovery from this issue.",
9
+ color: "2DA44E"
10
+ },
11
+ agentPlanApproved: {
12
+ description: "Approve Backburner to implement an accepted plan.",
13
+ color: "0969DA"
14
+ },
15
+ agentReview: {
16
+ description: "Ask Backburner to review this pull request.",
17
+ color: "BF8700"
18
+ },
19
+ planBreakdownNeeded: {
20
+ description: "Ask Backburner to break a plan into implementation packets.",
21
+ color: "8250DF"
22
+ },
23
+ agentSyncParentBranch: {
24
+ description: "Allow Backburner to sync the parent branch from the default branch.",
25
+ color: "1F883D"
26
+ },
27
+ prepareForMerge: {
28
+ description: "Ask Backburner to prepare this pull request for merge.",
29
+ color: "CF222E"
30
+ }
31
+ };
32
+ export class ProvisionLabelsStep {
33
+ commandRunner;
34
+ name = "provision-labels";
35
+ description = "Creating GitHub labels";
36
+ constructor(commandRunner) {
37
+ this.commandRunner = commandRunner;
38
+ }
39
+ async run(ctx, _prompter) {
40
+ const draft = ctx.configDraft;
41
+ if (draft === undefined || draft.repos.length === 0) {
42
+ return {
43
+ status: "skipped",
44
+ message: "Skipped: no repositories selected for label creation"
45
+ };
46
+ }
47
+ const targets = buildLabelTargets(draft);
48
+ const provisioner = new GitHubLabelProvisioner(this.commandRunner);
49
+ const results = await provisioner.provisionLabels(targets);
50
+ const failures = results.filter((result) => result.status === "failed");
51
+ if (failures.length > 0) {
52
+ ctx.warnings.push("Some GitHub labels could not be created. You can retry onboarding or create them manually from repos.json.");
53
+ return {
54
+ status: "warning",
55
+ message: `Created labels with ${failures.length} failure${failures.length === 1 ? "" : "s"}`,
56
+ details: [
57
+ ...failures.map((failure) => {
58
+ const suffix = failure.message !== undefined ? `: ${failure.message}` : "";
59
+ return `${failure.repoSlug}: ${failure.labelName}${suffix}`;
60
+ }),
61
+ ...buildManualLabelDetails(targets)
62
+ ]
63
+ };
64
+ }
65
+ return {
66
+ status: "passed",
67
+ message: `Created or updated ${results.length} GitHub label${results.length === 1 ? "" : "s"}`,
68
+ details: targets.map((target) => `${target.owner}/${target.name}: ${target.labels.length} labels`)
69
+ };
70
+ }
71
+ }
72
+ function buildManualLabelDetails(targets) {
73
+ return targets.flatMap((target) => {
74
+ const repoSlug = `${target.owner}/${target.name}`;
75
+ return [
76
+ `Manual labels page for ${repoSlug}: https://github.com/${repoSlug}/labels`,
77
+ `Required labels: ${target.labels.map((label) => label.name).join(", ")}`
78
+ ];
79
+ });
80
+ }
81
+ function buildLabelTargets(draft) {
82
+ return draft.repos.map((repo) => ({
83
+ owner: repo.owner,
84
+ name: repo.name,
85
+ labels: buildRepoLabels(repo)
86
+ }));
87
+ }
88
+ function buildRepoLabels(repo) {
89
+ return [
90
+ buildLabel("agentInProgress", repo.labels.agentInProgress),
91
+ buildLabel("agentProductApproved", repo.labels.agentProductApproved),
92
+ buildLabel("agentPlanApproved", repo.labels.agentPlanApproved),
93
+ buildLabel("agentReview", repo.labels.agentReview),
94
+ buildLabel("planBreakdownNeeded", repo.labels.planBreakdownNeeded),
95
+ buildLabel("agentSyncParentBranch", repo.labels.agentSyncParentBranch),
96
+ buildLabel("prepareForMerge", repo.labels.prepareForMerge)
97
+ ];
98
+ }
99
+ function buildLabel(key, name) {
100
+ return {
101
+ name,
102
+ ...LABEL_DEFINITIONS[key]
103
+ };
104
+ }
@@ -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 (Backburner will create/update its labels on them)"
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
  }