Skip to content

Commit 8137369

Browse files
recuu-pfegclaude
andcommitted
feat: toban init — interactive CLI onboarding
New command: npx toban init - Prompts for API URL, API key (validates against workspace) - Shows workspace info on success - Saves .toban/config.json for subsequent commands - toban start now falls back to config when flags not provided Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 75d823f commit 8137369

2 files changed

Lines changed: 196 additions & 4 deletions

File tree

src/cli.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { handleSprintPlan } from "./commands/plan.js";
2020
import { handlePropose } from "./commands/propose.js";
2121
import { handleReview } from "./commands/review.js";
2222
import { handleSprintComplete } from "./commands/sprint-complete.js";
23+
import { handleInit, loadConfig } from "./commands/init.js";
2324
import { runLoop } from "./commands/run-loop.js";
2425
import { createShutdownState, setupShutdownHandlers } from "./commands/shutdown.js";
2526

@@ -32,10 +33,12 @@ function printUsage(): void {
3233
toban - AI Agent Runner CLI
3334
3435
Usage:
36+
toban init
3537
toban start [options]
3638
toban sprint complete [--push]
3739
3840
Commands:
41+
init Initialize a new project (interactive setup)
3942
start Start the agent runner loop
4043
sprint complete Complete the current sprint and create a git tag
4144
@@ -74,11 +77,14 @@ function parseArgs(argv: string[]): CliArgs {
7477
return args[idx + 1];
7578
}
7679

77-
const apiUrl = getFlag("--api-url") ?? process.env.TOBAN_API_URL;
78-
const apiKey = getFlag("--api-key") ?? process.env.TOBAN_API_KEY;
80+
// Load .toban/config.json as fallback for api-url/api-key
81+
const config = loadConfig(process.cwd());
7982

80-
if (!apiUrl) { ui.error("--api-url or TOBAN_API_URL is required"); process.exit(1); }
81-
if (!apiKey) { ui.error("--api-key or TOBAN_API_KEY is required"); process.exit(1); }
83+
const apiUrl = getFlag("--api-url") ?? process.env.TOBAN_API_URL ?? config?.api_url;
84+
const apiKey = getFlag("--api-key") ?? process.env.TOBAN_API_KEY ?? config?.api_key;
85+
86+
if (!apiUrl) { ui.error("--api-url or TOBAN_API_URL is required (or run `toban init`)"); process.exit(1); }
87+
if (!apiKey) { ui.error("--api-key or TOBAN_API_KEY is required (or run `toban init`)"); process.exit(1); }
8288

8389
const hostname = (() => { try { return execSync("hostname", { encoding: "utf-8" }).trim(); } catch { return "agent"; } })();
8490
const explicitWorkingDir = getFlag("--working-dir");
@@ -104,6 +110,17 @@ function parseArgs(argv: string[]): CliArgs {
104110
// Main
105111
// ---------------------------------------------------------------------------
106112

113+
// Handle `init` early — it does not require --api-url/--api-key
114+
{
115+
const firstArg = process.argv[2];
116+
if (firstArg === "init") {
117+
handleInit().catch((err) => { ui.error(`Fatal: ${err}`); process.exit(1); });
118+
}
119+
}
120+
121+
// All other commands go through parseArgs (which requires api-url/api-key)
122+
if (process.argv[2] !== "init") {
123+
107124
const cliArgs = parseArgs(process.argv);
108125

109126
if (cliArgs.command === "plan") {
@@ -143,3 +160,5 @@ if (cliArgs.command === "plan") {
143160
setupShutdownHandlers(runner, shutdownState);
144161
runLoop(cliArgs, runner, shutdownState).catch((err) => { ui.error(`Fatal: ${err}`); process.exit(1); });
145162
} else { ui.error(`Unknown command: ${cliArgs.command}`); printUsage(); process.exit(1); }
163+
164+
} // end: non-init commands

src/commands/init.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* Init command — interactive project onboarding.
3+
*
4+
* Usage:
5+
* toban init
6+
*
7+
* Creates `.toban/config.json` in the current directory with API credentials
8+
* and workspace configuration.
9+
*/
10+
11+
import * as p from "@clack/prompts";
12+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13+
import { join } from "node:path";
14+
import { createApiClient, type WorkspaceInfo } from "../api-client.js";
15+
16+
// ---------------------------------------------------------------------------
17+
// Config types
18+
// ---------------------------------------------------------------------------
19+
20+
export interface TobanConfig {
21+
api_url: string;
22+
api_key: string;
23+
workspace_id: string;
24+
project_name: string;
25+
created_at: string;
26+
}
27+
28+
const CONFIG_DIR = ".toban";
29+
const CONFIG_FILE = "config.json";
30+
31+
// ---------------------------------------------------------------------------
32+
// Helpers
33+
// ---------------------------------------------------------------------------
34+
35+
function configPath(cwd: string): string {
36+
return join(cwd, CONFIG_DIR, CONFIG_FILE);
37+
}
38+
39+
export function loadConfig(cwd: string): TobanConfig | null {
40+
const path = configPath(cwd);
41+
if (!existsSync(path)) return null;
42+
try {
43+
return JSON.parse(readFileSync(path, "utf-8")) as TobanConfig;
44+
} catch {
45+
return null;
46+
}
47+
}
48+
49+
function isCancel(value: unknown): value is symbol {
50+
return p.isCancel(value);
51+
}
52+
53+
async function validateApiKey(apiUrl: string, apiKey: string): Promise<WorkspaceInfo | null> {
54+
try {
55+
const api = createApiClient(apiUrl, apiKey);
56+
return await api.fetchWorkspace();
57+
} catch {
58+
return null;
59+
}
60+
}
61+
62+
// ---------------------------------------------------------------------------
63+
// Main
64+
// ---------------------------------------------------------------------------
65+
66+
export async function handleInit(): Promise<void> {
67+
const cwd = process.cwd();
68+
69+
p.intro("toban init");
70+
71+
// Check for existing config
72+
const existing = loadConfig(cwd);
73+
if (existing) {
74+
const overwrite = await p.confirm({
75+
message: `.toban/config.json already exists (workspace: ${existing.project_name}). Overwrite?`,
76+
initialValue: false,
77+
});
78+
if (isCancel(overwrite) || !overwrite) {
79+
p.outro("Cancelled.");
80+
return;
81+
}
82+
}
83+
84+
// 1. API URL
85+
const apiUrl = await p.text({
86+
message: "API URL",
87+
placeholder: "https://api.toban.dev",
88+
defaultValue: "https://api.toban.dev",
89+
validate: (v) => {
90+
if (!v) return "API URL is required";
91+
try { new URL(v); } catch { return "Invalid URL"; }
92+
},
93+
});
94+
if (isCancel(apiUrl)) { p.outro("Cancelled."); return; }
95+
96+
// 2. API Key
97+
const apiKey = await p.text({
98+
message: "API key",
99+
placeholder: "tb_xxx",
100+
validate: (v) => {
101+
if (!v) return "API key is required";
102+
if (!v.startsWith("tb_")) return "API key must start with tb_";
103+
},
104+
});
105+
if (isCancel(apiKey)) { p.outro("Cancelled."); return; }
106+
107+
// 3. Validate key
108+
const spin = p.spinner();
109+
spin.start("Validating API key...");
110+
const workspace = await validateApiKey(apiUrl, apiKey);
111+
if (!workspace) {
112+
spin.stop("Validation failed");
113+
p.log.error("Could not connect to the API. Check your API URL and key.");
114+
p.outro("Setup failed.");
115+
process.exit(1);
116+
}
117+
spin.stop(`Connected to workspace: ${workspace.name}`);
118+
119+
// 4. Show workspace info
120+
const infoLines = [
121+
`Name: ${workspace.name}`,
122+
`ID: ${workspace.id}`,
123+
];
124+
if (workspace.github_repo) {
125+
infoLines.push(`Repo: ${workspace.github_repo}`);
126+
}
127+
if (workspace.language) {
128+
infoLines.push(`Lang: ${workspace.language}`);
129+
}
130+
p.note(infoLines.join("\n"), "Workspace");
131+
132+
// 5. Project name
133+
const projectName = await p.text({
134+
message: "Project name (for this directory)",
135+
defaultValue: workspace.name,
136+
placeholder: workspace.name,
137+
});
138+
if (isCancel(projectName)) { p.outro("Cancelled."); return; }
139+
140+
// 6. Save config
141+
const config: TobanConfig = {
142+
api_url: apiUrl,
143+
api_key: apiKey,
144+
workspace_id: workspace.id,
145+
project_name: projectName,
146+
created_at: new Date().toISOString(),
147+
};
148+
149+
const dir = join(cwd, CONFIG_DIR);
150+
mkdirSync(dir, { recursive: true });
151+
writeFileSync(configPath(cwd), JSON.stringify(config, null, 2) + "\n");
152+
153+
p.log.success(`Config saved to ${CONFIG_DIR}/${CONFIG_FILE}`);
154+
155+
// 7. Optionally create first sprint
156+
const createSprint = await p.confirm({
157+
message: "Create and start the first sprint now?",
158+
initialValue: false,
159+
});
160+
if (!isCancel(createSprint) && createSprint) {
161+
const api = createApiClient(apiUrl, apiKey);
162+
spin.start("Starting sprint...");
163+
try {
164+
const result = await api.startSprint();
165+
spin.stop(`Sprint #${result.sprint.number} started (${result.tasks.length} task(s))`);
166+
} catch (err) {
167+
spin.stop("Could not start sprint");
168+
p.log.warning(`${err instanceof Error ? err.message : String(err)}`);
169+
}
170+
}
171+
172+
p.outro("Done! Run `toban start` to begin.");
173+
}

0 commit comments

Comments
 (0)