@launchchair/agent 0.4.0
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/AGENTS.md +248 -0
- package/LICENSE +8 -0
- package/README.md +188 -0
- package/chatgpt-runner.mjs +239 -0
- package/claude-code-runner.mjs +141 -0
- package/claude-runner.mjs +218 -0
- package/codex-runner.mjs +123 -0
- package/launchchair-cli.mjs +1013 -0
- package/local-bridge.mjs +540 -0
- package/mcp-bridge-server.mjs +1101 -0
- package/package.json +43 -0
- package/runner-command.mjs +96 -0
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LaunchChair Agent CLI.
|
|
5
|
+
*
|
|
6
|
+
* Thin convenience wrapper around the public Agent API and local bridge.
|
|
7
|
+
* MCP-only setups do not need this file; CLI users can use it for doctor,
|
|
8
|
+
* project discovery/creation, and local bridge control.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import process from "node:process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const VERSION = "0.4.0";
|
|
19
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const DEFAULT_BASE_URL = "https://www.launchchair.io";
|
|
21
|
+
const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
|
|
22
|
+
? path.resolve(process.env.LAUNCHCHAIR_CONFIG_DIR.trim())
|
|
23
|
+
: path.join(os.homedir() || process.cwd(), ".launchchair");
|
|
24
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
|
25
|
+
|
|
26
|
+
const COLORS_ENABLED = !process.env.NO_COLOR && process.env.TERM !== "dumb";
|
|
27
|
+
const color = {
|
|
28
|
+
reset: "\u001b[0m",
|
|
29
|
+
bold: "\u001b[1m",
|
|
30
|
+
dim: "\u001b[2m",
|
|
31
|
+
green: "\u001b[32m",
|
|
32
|
+
yellow: "\u001b[33m",
|
|
33
|
+
red: "\u001b[31m",
|
|
34
|
+
cyan: "\u001b[36m",
|
|
35
|
+
purple: "\u001b[35m",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function env(name, fallback = "") {
|
|
39
|
+
const value = process.env[name];
|
|
40
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function paint(value, tone) {
|
|
44
|
+
if (!COLORS_ENABLED || !color[tone]) return value;
|
|
45
|
+
return `${color[tone]}${value}${color.reset}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function loadConfig() {
|
|
49
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(CONFIG_PATH, "utf8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function saveConfig(patch) {
|
|
60
|
+
const next = {
|
|
61
|
+
...loadConfig(),
|
|
62
|
+
...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)),
|
|
63
|
+
};
|
|
64
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
65
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
try {
|
|
67
|
+
chmodSync(CONFIG_PATH, 0o600);
|
|
68
|
+
} catch {
|
|
69
|
+
// Best effort only. Some filesystems do not support POSIX chmod.
|
|
70
|
+
}
|
|
71
|
+
return next;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function deleteConfigKeys(keys) {
|
|
75
|
+
const current = loadConfig();
|
|
76
|
+
keys.forEach((key) => {
|
|
77
|
+
delete current[key];
|
|
78
|
+
});
|
|
79
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
80
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`, { mode: 0o600 });
|
|
81
|
+
return current;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function configValue(key, envNames = [], fallback = "") {
|
|
85
|
+
for (const name of envNames) {
|
|
86
|
+
const value = env(name);
|
|
87
|
+
if (value) return value;
|
|
88
|
+
}
|
|
89
|
+
const config = loadConfig();
|
|
90
|
+
const value = config[key];
|
|
91
|
+
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function token() {
|
|
95
|
+
return configValue("agentToken", ["LAUNCHCHAIR_AGENT_TOKEN", "LAUNCHCHAIR_API_KEY"]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function tokenSource() {
|
|
99
|
+
if (env("LAUNCHCHAIR_AGENT_TOKEN")) return "LAUNCHCHAIR_AGENT_TOKEN";
|
|
100
|
+
if (env("LAUNCHCHAIR_API_KEY")) return "LAUNCHCHAIR_API_KEY";
|
|
101
|
+
if (loadConfig().agentToken) return CONFIG_PATH;
|
|
102
|
+
return "missing";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function baseUrl() {
|
|
106
|
+
return configValue("baseUrl", ["LAUNCHCHAIR_BASE_URL"], DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultProjectId() {
|
|
110
|
+
return configValue("projectId", ["LAUNCHCHAIR_PROJECT_ID"]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function maskToken(value) {
|
|
114
|
+
if (!value) return "missing";
|
|
115
|
+
if (value.length <= 18) return `${value.slice(0, 6)}...`;
|
|
116
|
+
return `${value.slice(0, 10)}...${value.slice(-6)}`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function print(message = "") {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.log(message);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function write(message = "") {
|
|
125
|
+
// eslint-disable-next-line no-console
|
|
126
|
+
process.stdout.write(message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function title(message) {
|
|
130
|
+
print(`${paint("LaunchChair", "purple")} ${paint(message, "bold")}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function row(label, value, tone = "dim") {
|
|
134
|
+
print(`${paint(label.padEnd(18), "dim")} ${paint(String(value), tone)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function ok(message) {
|
|
138
|
+
print(`${paint("OK", "green")} ${message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function warn(message) {
|
|
142
|
+
print(`${paint("WARN", "yellow")} ${message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function fail(message) {
|
|
146
|
+
print(`${paint("ERROR", "red")} ${message}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function help() {
|
|
150
|
+
print(`
|
|
151
|
+
LaunchChair Agent CLI v${VERSION}
|
|
152
|
+
|
|
153
|
+
Use MCP-only when you want agent tools inside Codex/Claude/Hermes.
|
|
154
|
+
Use CLI + MCP when you also want quick terminal commands, diagnostics, and bridge control.
|
|
155
|
+
|
|
156
|
+
Commands:
|
|
157
|
+
doctor Check token, docs, API reachability, and local bridge files
|
|
158
|
+
login [--token TOKEN] Open browser login and save a LaunchChair Agent API token locally
|
|
159
|
+
logout Remove the saved local token
|
|
160
|
+
whoami Show the current API identity and token source
|
|
161
|
+
projects [--query name] List projects visible to the token
|
|
162
|
+
create --name NAME [options] Create a LaunchChair project
|
|
163
|
+
continue [--project-id ID] Ask LaunchChair to continue the project loop once
|
|
164
|
+
run-once Claim and run one queued LaunchChair job through the local bridge
|
|
165
|
+
bridge Start the long-running local bridge for Run with Agent
|
|
166
|
+
setup codex|claude|terminal [options]
|
|
167
|
+
Guided setup for CLI, CWD, AGENTS.md, MCP config, and bridge
|
|
168
|
+
config Show local CLI config without printing the token
|
|
169
|
+
config set KEY VALUE Save base-url, project-id, codex-cwd, or claude-code-cwd
|
|
170
|
+
mcp-config codex|claude Print MCP config with current paths/defaults
|
|
171
|
+
install-agent-md [--cwd PATH] Create a small AGENTS.md pointer in a fresh repo
|
|
172
|
+
|
|
173
|
+
Create options:
|
|
174
|
+
--one-liner TEXT
|
|
175
|
+
--project-type b2b|b2c|both
|
|
176
|
+
--problem TEXT
|
|
177
|
+
--target-user TEXT
|
|
178
|
+
--solution TEXT
|
|
179
|
+
--start-loop
|
|
180
|
+
--save-project Save the created project id as the CLI default
|
|
181
|
+
--idempotency-key KEY
|
|
182
|
+
|
|
183
|
+
Feature create options:
|
|
184
|
+
--project-kind feature
|
|
185
|
+
--feature-description TEXT Feature idea; --one-liner can be used as a shortcut
|
|
186
|
+
--product-description TEXT Existing product context
|
|
187
|
+
--current-workflow TEXT Current workflow being changed
|
|
188
|
+
--desired-workflow TEXT Desired workflow after the feature ships
|
|
189
|
+
--changes-existing-workflow Mark the feature as changing existing product behavior
|
|
190
|
+
--plan-to-build-board Default for feature projects unless --create-only is used
|
|
191
|
+
--create-only Create the feature project without planning to the build board
|
|
192
|
+
--full-loop After planning, continue into Build MVP
|
|
193
|
+
--max-steps NUMBER Max planning loop steps. Default: 12, max: 25
|
|
194
|
+
--build-max-steps NUMBER Max build loop steps for --full-loop. Default: 25
|
|
195
|
+
|
|
196
|
+
Continue options:
|
|
197
|
+
--project-id ID Optional when LAUNCHCHAIR_PROJECT_ID or config project-id is set
|
|
198
|
+
--stop-phase-key KEY Stop after a phase, for example market_validation
|
|
199
|
+
--completion-target TEXT Natural-language target for the loop run
|
|
200
|
+
|
|
201
|
+
Setup options:
|
|
202
|
+
--cwd PATH Repo folder or empty folder the agent should build into
|
|
203
|
+
--token TOKEN Optional manual lc_at token fallback to save during setup
|
|
204
|
+
--project-id ID Optional default LaunchChair project id
|
|
205
|
+
--base-url URL Optional LaunchChair base URL
|
|
206
|
+
--skip-login Do not start browser login during setup
|
|
207
|
+
--no-open During setup login, print the approval URL instead of opening a browser
|
|
208
|
+
--timeout SECONDS During setup login, approval timeout. Default: 600
|
|
209
|
+
--start-bridge Start the local bridge after setup
|
|
210
|
+
|
|
211
|
+
Login options:
|
|
212
|
+
--token TOKEN Manual fallback: save an existing lc_at token
|
|
213
|
+
--base-url URL Optional LaunchChair base URL
|
|
214
|
+
--project-id ID Optional default LaunchChair project id
|
|
215
|
+
--name TEXT Optional token name shown in Settings -> API Access
|
|
216
|
+
--no-open Print the approval URL instead of opening a browser
|
|
217
|
+
--timeout SECONDS Default: 600
|
|
218
|
+
|
|
219
|
+
Env:
|
|
220
|
+
LAUNCHCHAIR_AGENT_TOKEN Optional full lc_at token override
|
|
221
|
+
LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
|
|
222
|
+
LAUNCHCHAIR_PROJECT_ID Optional default project id
|
|
223
|
+
LC_AGENT_CODEX_CWD Folder Codex should build into
|
|
224
|
+
LC_AGENT_CLAUDE_CODE_CWD Folder Claude Code should build into
|
|
225
|
+
|
|
226
|
+
Config:
|
|
227
|
+
${CONFIG_PATH}
|
|
228
|
+
`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function parseArgs(argv) {
|
|
232
|
+
const result = { _: [] };
|
|
233
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
234
|
+
const arg = argv[index];
|
|
235
|
+
if (!arg.startsWith("--")) {
|
|
236
|
+
result._.push(arg);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const eqIndex = arg.indexOf("=");
|
|
240
|
+
if (eqIndex > 2) {
|
|
241
|
+
result[arg.slice(2, eqIndex)] = arg.slice(eqIndex + 1);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const key = arg.slice(2);
|
|
245
|
+
const next = argv[index + 1];
|
|
246
|
+
if (!next || next.startsWith("--")) {
|
|
247
|
+
result[key] = true;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
result[key] = next;
|
|
251
|
+
index += 1;
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function api(pathname, options = {}) {
|
|
257
|
+
const agentToken = token();
|
|
258
|
+
if (!agentToken) {
|
|
259
|
+
throw new Error("Missing LaunchChair token. Run: launchchair login");
|
|
260
|
+
}
|
|
261
|
+
const response = await fetch(`${baseUrl()}${pathname}`, {
|
|
262
|
+
...options,
|
|
263
|
+
headers: {
|
|
264
|
+
Authorization: `Bearer ${agentToken}`,
|
|
265
|
+
"Content-Type": "application/json",
|
|
266
|
+
...(options.headers ?? {}),
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
const text = await response.text();
|
|
270
|
+
let body = null;
|
|
271
|
+
try {
|
|
272
|
+
body = text ? JSON.parse(text) : null;
|
|
273
|
+
} catch {
|
|
274
|
+
body = { raw: text };
|
|
275
|
+
}
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const retryAfter = response.headers.get("retry-after");
|
|
278
|
+
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
279
|
+
throw new Error(`${body?.error ?? response.statusText}${suffix}`);
|
|
280
|
+
}
|
|
281
|
+
return body;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function publicApi(pathname, options = {}) {
|
|
285
|
+
const response = await fetch(`${baseUrl()}${pathname}`, {
|
|
286
|
+
...options,
|
|
287
|
+
headers: {
|
|
288
|
+
"Content-Type": "application/json",
|
|
289
|
+
...(options.headers ?? {}),
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
const text = await response.text();
|
|
293
|
+
let body = null;
|
|
294
|
+
try {
|
|
295
|
+
body = text ? JSON.parse(text) : null;
|
|
296
|
+
} catch {
|
|
297
|
+
body = { raw: text };
|
|
298
|
+
}
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
const retryAfter = response.headers.get("retry-after");
|
|
301
|
+
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
302
|
+
throw new Error(`${body?.error ?? response.statusText}${suffix}`);
|
|
303
|
+
}
|
|
304
|
+
return body;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function sleep(ms) {
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
setTimeout(resolve, ms);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function openBrowser(url) {
|
|
314
|
+
const platform = process.platform;
|
|
315
|
+
const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
316
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
317
|
+
const child = spawn(command, args, {
|
|
318
|
+
stdio: "ignore",
|
|
319
|
+
detached: true,
|
|
320
|
+
});
|
|
321
|
+
child.on("error", () => {});
|
|
322
|
+
child.unref();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function doctor() {
|
|
326
|
+
title("CLI doctor");
|
|
327
|
+
row("Base URL", baseUrl(), "cyan");
|
|
328
|
+
row("Config file", existsSync(CONFIG_PATH) ? CONFIG_PATH : "not created yet", existsSync(CONFIG_PATH) ? "dim" : "yellow");
|
|
329
|
+
row("Token", token() ? `${maskToken(token())} (${tokenSource()})` : "missing", token() ? "green" : "yellow");
|
|
330
|
+
row("Local bridge", existsSync(path.join(SCRIPT_DIR, "local-bridge.mjs")) ? "present" : "missing", existsSync(path.join(SCRIPT_DIR, "local-bridge.mjs")) ? "green" : "red");
|
|
331
|
+
row("MCP bridge", existsSync(path.join(SCRIPT_DIR, "mcp-bridge-server.mjs")) ? "present" : "missing", existsSync(path.join(SCRIPT_DIR, "mcp-bridge-server.mjs")) ? "green" : "red");
|
|
332
|
+
row("AGENTS.md", existsSync(path.join(SCRIPT_DIR, "AGENTS.md")) ? "present" : "missing", existsSync(path.join(SCRIPT_DIR, "AGENTS.md")) ? "green" : "yellow");
|
|
333
|
+
if (!token()) {
|
|
334
|
+
warn("Run launchchair login to approve this CLI in your browser.");
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const status = await api("/api/agent/v1");
|
|
338
|
+
ok(`API reachable (${status?.version ?? "v1"})`);
|
|
339
|
+
const projects = await api("/api/agent/v1/projects?limit=5");
|
|
340
|
+
row("Visible projects", projects?.count ?? projects?.projects?.length ?? 0, "green");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function login(args) {
|
|
344
|
+
const incomingToken = typeof args.token === "string" ? args.token.trim() : "";
|
|
345
|
+
const incomingBaseUrl = typeof args["base-url"] === "string" ? args["base-url"].trim().replace(/\/+$/, "") : undefined;
|
|
346
|
+
const incomingProjectId = typeof args["project-id"] === "string" ? args["project-id"].trim() : undefined;
|
|
347
|
+
if (incomingBaseUrl || incomingProjectId) {
|
|
348
|
+
saveConfig({
|
|
349
|
+
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
350
|
+
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
title("CLI login");
|
|
355
|
+
|
|
356
|
+
if (incomingToken) {
|
|
357
|
+
if (!incomingToken.startsWith("lc_at_")) {
|
|
358
|
+
throw new Error("LaunchChair agent tokens start with lc_at_. Paste the full Agent API token.");
|
|
359
|
+
}
|
|
360
|
+
saveConfig({
|
|
361
|
+
agentToken: incomingToken,
|
|
362
|
+
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
363
|
+
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
364
|
+
});
|
|
365
|
+
ok(`Saved token to ${CONFIG_PATH}`);
|
|
366
|
+
row("Token", maskToken(incomingToken), "green");
|
|
367
|
+
row("Base URL", incomingBaseUrl ?? baseUrl(), "cyan");
|
|
368
|
+
if (incomingProjectId || defaultProjectId()) row("Project ID", incomingProjectId || defaultProjectId(), "cyan");
|
|
369
|
+
try {
|
|
370
|
+
const status = await api("/api/agent/v1");
|
|
371
|
+
ok(`API reachable (${status?.version ?? "v1"})`);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
warn(`Token was saved, but API verification failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
row("Base URL", baseUrl(), "cyan");
|
|
379
|
+
const start = await publicApi("/api/agent/v1/cli-login/start", {
|
|
380
|
+
method: "POST",
|
|
381
|
+
body: JSON.stringify({
|
|
382
|
+
clientName: `LaunchChair CLI on ${os.hostname() || "local machine"}`,
|
|
383
|
+
tokenName: typeof args.name === "string" && args.name.trim() ? args.name.trim() : `LaunchChair CLI (${os.hostname() || "local"})`,
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
if (!start?.deviceCode || !start?.verificationUriComplete) {
|
|
387
|
+
throw new Error("LaunchChair did not return a complete CLI login challenge.");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
row("Code", start.userCode ?? "unknown", "bold");
|
|
391
|
+
row("Approve", start.verificationUriComplete, "cyan");
|
|
392
|
+
if (args["no-open"] === true) {
|
|
393
|
+
warn("Browser opening skipped. Open the approval URL above.");
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
openBrowser(start.verificationUriComplete);
|
|
397
|
+
ok("Opened browser approval page.");
|
|
398
|
+
} catch {
|
|
399
|
+
warn("Could not open a browser automatically. Open the approval URL above.");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const timeoutSeconds =
|
|
404
|
+
typeof args.timeout === "string" && Number.isFinite(Number(args.timeout)) ? Math.max(10, Number(args.timeout)) : Number(start.expiresIn ?? 600);
|
|
405
|
+
const intervalSeconds = Math.max(2, Number(start.interval ?? 2));
|
|
406
|
+
const startedAt = Date.now();
|
|
407
|
+
print("");
|
|
408
|
+
print("Waiting for approval. Leave this terminal open.");
|
|
409
|
+
|
|
410
|
+
while (Date.now() - startedAt < timeoutSeconds * 1000) {
|
|
411
|
+
await sleep(intervalSeconds * 1000);
|
|
412
|
+
let poll;
|
|
413
|
+
try {
|
|
414
|
+
poll = await publicApi("/api/agent/v1/cli-login/poll", {
|
|
415
|
+
method: "POST",
|
|
416
|
+
body: JSON.stringify({ deviceCode: start.deviceCode }),
|
|
417
|
+
});
|
|
418
|
+
} catch (error) {
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
420
|
+
if (/pending/i.test(message)) continue;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
if (poll?.status === "pending") {
|
|
424
|
+
write(paint(".", "dim"));
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (poll?.status === "approved" && poll?.token) {
|
|
428
|
+
print("");
|
|
429
|
+
saveConfig({
|
|
430
|
+
agentToken: poll.token,
|
|
431
|
+
baseUrl: baseUrl(),
|
|
432
|
+
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
433
|
+
});
|
|
434
|
+
ok(`Saved token to ${CONFIG_PATH}`);
|
|
435
|
+
row("Token", maskToken(poll.token), "green");
|
|
436
|
+
if (poll.tokenRecord?.tokenPrefix) row("Token prefix", poll.tokenRecord.tokenPrefix, "green");
|
|
437
|
+
const status = await api("/api/agent/v1");
|
|
438
|
+
ok(`API reachable (${status?.version ?? "v1"})`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
if (poll?.status === "expired") throw new Error("CLI login expired. Run launchchair login again.");
|
|
442
|
+
if (poll?.status === "denied") throw new Error("CLI login was denied.");
|
|
443
|
+
if (poll?.status === "token_creation_failed") throw new Error(poll.error ?? "LaunchChair could not create a CLI token.");
|
|
444
|
+
}
|
|
445
|
+
throw new Error("CLI login timed out. Run launchchair login again.");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function logout() {
|
|
449
|
+
deleteConfigKeys(["agentToken"]);
|
|
450
|
+
title("CLI logout");
|
|
451
|
+
ok(`Removed saved token from ${CONFIG_PATH}`);
|
|
452
|
+
if (env("LAUNCHCHAIR_AGENT_TOKEN") || env("LAUNCHCHAIR_API_KEY")) {
|
|
453
|
+
warn("An environment token is still set, so CLI commands may remain authenticated in this shell.");
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function whoami() {
|
|
458
|
+
title("CLI identity");
|
|
459
|
+
row("Base URL", baseUrl(), "cyan");
|
|
460
|
+
row("Token source", tokenSource(), token() ? "green" : "yellow");
|
|
461
|
+
row("Token", maskToken(token()), token() ? "green" : "yellow");
|
|
462
|
+
row("Project ID", defaultProjectId() || "not set", defaultProjectId() ? "cyan" : "dim");
|
|
463
|
+
if (!token()) return;
|
|
464
|
+
const status = await api("/api/agent/v1");
|
|
465
|
+
row("API", `ok (${status?.version ?? "v1"})`, "green");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function listProjects(args) {
|
|
469
|
+
const query = typeof args.query === "string" ? args.query : "";
|
|
470
|
+
const params = new URLSearchParams();
|
|
471
|
+
if (query) params.set("query", query);
|
|
472
|
+
const body = await api(`/api/agent/v1/projects${params.toString() ? `?${params.toString()}` : ""}`);
|
|
473
|
+
const projects = Array.isArray(body?.projects) ? body.projects : [];
|
|
474
|
+
title("Projects");
|
|
475
|
+
if (!projects.length) {
|
|
476
|
+
warn(query ? `No projects matched "${query}".` : "No projects visible to this token.");
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
projects.forEach((project) => {
|
|
480
|
+
print(`${paint(project.name ?? "Untitled project", "bold")} ${paint(project.id ?? "", "dim")}`);
|
|
481
|
+
if (project.updatedAt || project.createdAt) row("Updated", project.updatedAt ?? project.createdAt, "dim");
|
|
482
|
+
if (project.url) row("Open", project.url, "cyan");
|
|
483
|
+
print("");
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function createProject(args) {
|
|
488
|
+
const name = typeof args.name === "string" ? args.name : "";
|
|
489
|
+
if (!name) {
|
|
490
|
+
throw new Error("create requires --name.");
|
|
491
|
+
}
|
|
492
|
+
const projectKind = String(args["project-kind"] ?? "product").trim().toLowerCase();
|
|
493
|
+
if (projectKind !== "product" && projectKind !== "feature") {
|
|
494
|
+
throw new Error("--project-kind must be product or feature.");
|
|
495
|
+
}
|
|
496
|
+
const featureProject = projectKind === "feature";
|
|
497
|
+
const featureDescription = String(args["feature-description"] ?? args["one-liner"] ?? "").trim();
|
|
498
|
+
const featureProblem = String(args.problem ?? "").trim();
|
|
499
|
+
if (featureProject && (!featureDescription || !featureProblem)) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
"Feature creation requires --feature-description (or --one-liner) and --problem. The Feature beta must also be enabled for the token owner.",
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
const changesExistingWorkflow =
|
|
505
|
+
args["changes-existing-workflow"] === true ||
|
|
506
|
+
String(args["changes-existing-workflow"] ?? "").toLowerCase() === "true" ||
|
|
507
|
+
Boolean(String(args["current-workflow"] ?? "").trim()) ||
|
|
508
|
+
Boolean(String(args["desired-workflow"] ?? "").trim());
|
|
509
|
+
const planToBuildBoard =
|
|
510
|
+
featureProject &&
|
|
511
|
+
args["create-only"] !== true &&
|
|
512
|
+
String(args["plan-to-build-board"] ?? "true").toLowerCase() !== "false";
|
|
513
|
+
const headers = {};
|
|
514
|
+
if (typeof args["idempotency-key"] === "string") {
|
|
515
|
+
headers["Idempotency-Key"] = args["idempotency-key"];
|
|
516
|
+
}
|
|
517
|
+
const requestBody = featureProject
|
|
518
|
+
? {
|
|
519
|
+
name,
|
|
520
|
+
oneLiner: featureDescription,
|
|
521
|
+
projectKind: "feature",
|
|
522
|
+
featureSnapshot: {
|
|
523
|
+
description: featureDescription,
|
|
524
|
+
problem: featureProblem,
|
|
525
|
+
existingProductDescription: String(args["product-description"] ?? "").trim() || undefined,
|
|
526
|
+
changesExistingWorkflow,
|
|
527
|
+
currentWorkflow: changesExistingWorkflow
|
|
528
|
+
? String(args["current-workflow"] ?? "").trim() || undefined
|
|
529
|
+
: undefined,
|
|
530
|
+
desiredOutcome: changesExistingWorkflow
|
|
531
|
+
? String(args["desired-workflow"] ?? "").trim() || undefined
|
|
532
|
+
: undefined,
|
|
533
|
+
},
|
|
534
|
+
startLoop: false,
|
|
535
|
+
clientRequestId: args["idempotency-key"] ?? undefined,
|
|
536
|
+
}
|
|
537
|
+
: {
|
|
538
|
+
name,
|
|
539
|
+
oneLiner: args["one-liner"] ?? "",
|
|
540
|
+
projectType: args["project-type"] ?? "both",
|
|
541
|
+
ideaSnapshot:
|
|
542
|
+
args.problem || args["target-user"] || args.solution
|
|
543
|
+
? {
|
|
544
|
+
problem: args.problem ?? "",
|
|
545
|
+
targetUser: args["target-user"] ?? "",
|
|
546
|
+
solution: args.solution ?? "",
|
|
547
|
+
}
|
|
548
|
+
: undefined,
|
|
549
|
+
startLoop: args["start-loop"] === true,
|
|
550
|
+
clientRequestId: args["idempotency-key"] ?? undefined,
|
|
551
|
+
};
|
|
552
|
+
const body = await api("/api/agent/v1/projects", {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers,
|
|
555
|
+
body: JSON.stringify(requestBody),
|
|
556
|
+
});
|
|
557
|
+
const projectId = body?.project?.id ?? body?.projectId ?? "";
|
|
558
|
+
title("Project created");
|
|
559
|
+
row("Name", body?.project?.name ?? name, "bold");
|
|
560
|
+
row("Project ID", projectId || "unknown", "cyan");
|
|
561
|
+
if (body?.project?.url) row("Project URL", body.project.url, "cyan");
|
|
562
|
+
if (body?.project?.phasesUrl) row("Phases URL", body.project.phasesUrl, "cyan");
|
|
563
|
+
if (args["save-project"] === true && projectId) {
|
|
564
|
+
saveConfig({ projectId });
|
|
565
|
+
ok("Saved project id as the CLI default.");
|
|
566
|
+
}
|
|
567
|
+
if (body?.loop) {
|
|
568
|
+
row("Loop", body.loop.status ?? "started", body.loop.status === "error" ? "yellow" : "green");
|
|
569
|
+
}
|
|
570
|
+
if (planToBuildBoard && projectId) {
|
|
571
|
+
const maxSteps = Math.min(Math.max(Number.parseInt(String(args["max-steps"] ?? "12"), 10) || 12, 1), 25);
|
|
572
|
+
const planning = await planFeatureToBuildBoard(projectId, maxSteps);
|
|
573
|
+
row("Planning", planning?.status ?? "stopped", planning?.status === "build_board_ready" ? "green" : "yellow");
|
|
574
|
+
if (planning?.board?.url) row("Build Board", planning.board.url, "cyan");
|
|
575
|
+
if (planning?.board?.cardCount !== undefined) row("Build cards", planning.board.cardCount, "green");
|
|
576
|
+
if (planning?.message) row("Result", planning.message, "dim");
|
|
577
|
+
if (args["full-loop"] === true && planning?.status === "build_board_ready") {
|
|
578
|
+
const build = await runFeatureBuild(projectId, Math.min(Math.max(Number.parseInt(String(args["build-max-steps"] ?? "25"), 10) || 25, 1), 25));
|
|
579
|
+
row("Feature build", build?.status ?? "stopped", build?.status === "phase_complete" ? "green" : "yellow");
|
|
580
|
+
if (build?.message) row("Build result", build.message, "dim");
|
|
581
|
+
if (build?.humanInputUrl) row("Needs user", build.humanInputUrl, "yellow");
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function continueProject(args) {
|
|
587
|
+
const projectId = args["project-id"] || defaultProjectId();
|
|
588
|
+
if (!projectId) {
|
|
589
|
+
throw new Error("continue requires --project-id, LAUNCHCHAIR_PROJECT_ID, or config set project-id.");
|
|
590
|
+
}
|
|
591
|
+
const body = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, {
|
|
592
|
+
method: "POST",
|
|
593
|
+
body: JSON.stringify({
|
|
594
|
+
stopPhaseKey: args["stop-phase-key"] ?? null,
|
|
595
|
+
completionTarget: args["completion-target"] ?? null,
|
|
596
|
+
}),
|
|
597
|
+
});
|
|
598
|
+
title("Project loop");
|
|
599
|
+
row("Project ID", projectId, "cyan");
|
|
600
|
+
row("Status", body?.status ?? "queued", body?.status === "error" ? "yellow" : "green");
|
|
601
|
+
if (body?.message) row("Message", body.message, "dim");
|
|
602
|
+
if (body?.phaseComplete?.url) row("Phase synthesis", body.phaseComplete.url, "cyan");
|
|
603
|
+
if (body?.humanInputUrl) row("Needs user", body.humanInputUrl, "yellow");
|
|
604
|
+
if (body?.queuedRun?.id) row("Queued run", body.queuedRun.id, "cyan");
|
|
605
|
+
if (body?.board?.url) row("Build Board", body.board.url, "cyan");
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function targetDetails(target) {
|
|
609
|
+
if (target === "terminal" || target === "cli" || target === "shell") {
|
|
610
|
+
return {
|
|
611
|
+
label: "Terminal agent",
|
|
612
|
+
mcpTarget: null,
|
|
613
|
+
cwdConfigKey: "codexCwd",
|
|
614
|
+
cwdEnvName: "LC_AGENT_CODEX_CWD",
|
|
615
|
+
routerTarget: "cli",
|
|
616
|
+
terminalOnly: true,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (target === "claude" || target === "claude-code" || target === "claude_code") {
|
|
620
|
+
return {
|
|
621
|
+
label: "Claude Desktop + Claude Code",
|
|
622
|
+
mcpTarget: "claude",
|
|
623
|
+
cwdConfigKey: "claudeCodeCwd",
|
|
624
|
+
cwdEnvName: "LC_AGENT_CLAUDE_CODE_CWD",
|
|
625
|
+
routerTarget: "claude_code",
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
return {
|
|
629
|
+
label: "Codex Desktop",
|
|
630
|
+
mcpTarget: "codex",
|
|
631
|
+
cwdConfigKey: "codexCwd",
|
|
632
|
+
cwdEnvName: "LC_AGENT_CODEX_CWD",
|
|
633
|
+
routerTarget: "codex",
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function buildBridgeEnv(projectId = defaultProjectId()) {
|
|
638
|
+
const childEnv = {
|
|
639
|
+
...process.env,
|
|
640
|
+
LAUNCHCHAIR_BASE_URL: baseUrl(),
|
|
641
|
+
LAUNCHCHAIR_AGENT_TOKEN: token(),
|
|
642
|
+
LAUNCHCHAIR_PROJECT_ID: projectId,
|
|
643
|
+
LC_AGENT_CODEX_CWD: configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], ""),
|
|
644
|
+
LC_AGENT_CLAUDE_CODE_CWD: configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], ""),
|
|
645
|
+
};
|
|
646
|
+
Object.keys(childEnv).forEach((key) => {
|
|
647
|
+
if (childEnv[key] === undefined || childEnv[key] === null || childEnv[key] === "") {
|
|
648
|
+
delete childEnv[key];
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
return childEnv;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function runBridgeOnce(projectId) {
|
|
655
|
+
const bridgePath = path.join(SCRIPT_DIR, "local-bridge.mjs");
|
|
656
|
+
return new Promise((resolve, reject) => {
|
|
657
|
+
const child = spawn(process.execPath, [bridgePath, "--once", "--verbose"], {
|
|
658
|
+
stdio: "inherit",
|
|
659
|
+
env: buildBridgeEnv(projectId),
|
|
660
|
+
});
|
|
661
|
+
child.on("error", reject);
|
|
662
|
+
child.on("exit", (code) => {
|
|
663
|
+
if ((code ?? 0) === 0) {
|
|
664
|
+
resolve();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
reject(new Error(`LaunchChair local bridge exited with code ${code ?? "unknown"}.`));
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function planFeatureToBuildBoard(projectId, maxSteps) {
|
|
673
|
+
let last = null;
|
|
674
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
675
|
+
const loop = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, {
|
|
676
|
+
method: "POST",
|
|
677
|
+
body: JSON.stringify({
|
|
678
|
+
stopPhaseKey: "build",
|
|
679
|
+
completionTarget: "build_board_ready",
|
|
680
|
+
}),
|
|
681
|
+
});
|
|
682
|
+
last = loop;
|
|
683
|
+
if (loop?.status === "build_board_ready") {
|
|
684
|
+
return loop;
|
|
685
|
+
}
|
|
686
|
+
if (loop?.status !== "queued_run" && loop?.status !== "waiting_for_run") {
|
|
687
|
+
return loop;
|
|
688
|
+
}
|
|
689
|
+
await runBridgeOnce(projectId);
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
status: "max_steps_reached",
|
|
693
|
+
projectId,
|
|
694
|
+
message: `Feature planning stopped after maxSteps=${maxSteps}. Run launchchair continue to inspect or resume it.`,
|
|
695
|
+
previous: last,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function runFeatureBuild(projectId, maxSteps) {
|
|
700
|
+
let last = null;
|
|
701
|
+
for (let step = 0; step < maxSteps; step += 1) {
|
|
702
|
+
const loop = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/loop/continue`, {
|
|
703
|
+
method: "POST",
|
|
704
|
+
body: JSON.stringify({
|
|
705
|
+
stopPhaseKey: "build",
|
|
706
|
+
completionTarget: "phase_complete",
|
|
707
|
+
}),
|
|
708
|
+
});
|
|
709
|
+
last = loop;
|
|
710
|
+
if (loop?.status !== "queued_run" && loop?.status !== "waiting_for_run") {
|
|
711
|
+
return loop;
|
|
712
|
+
}
|
|
713
|
+
await runBridgeOnce(projectId);
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
status: "max_steps_reached",
|
|
717
|
+
projectId,
|
|
718
|
+
message: `Feature build stopped after maxSteps=${maxSteps}. Run launchchair continue to inspect or resume it.`,
|
|
719
|
+
previous: last,
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function spawnBridge(extraArgs = []) {
|
|
724
|
+
const bridgePath = path.join(SCRIPT_DIR, "local-bridge.mjs");
|
|
725
|
+
const child = spawn(process.execPath, [bridgePath, ...extraArgs], {
|
|
726
|
+
stdio: "inherit",
|
|
727
|
+
env: buildBridgeEnv(),
|
|
728
|
+
});
|
|
729
|
+
child.on("exit", (code) => {
|
|
730
|
+
process.exit(code ?? 0);
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function renderMcpConfig(target) {
|
|
735
|
+
const bridgePath = path.join(SCRIPT_DIR, "mcp-bridge-server.mjs");
|
|
736
|
+
const agentTokenPlaceholder = "<full lc_at token from LaunchChair Settings API Access>";
|
|
737
|
+
const projectId = defaultProjectId() || "optional-if-agent-creates-project";
|
|
738
|
+
if (target === "claude") {
|
|
739
|
+
return JSON.stringify(
|
|
740
|
+
{
|
|
741
|
+
mcpServers: {
|
|
742
|
+
launchchair: {
|
|
743
|
+
command: "node",
|
|
744
|
+
args: [bridgePath],
|
|
745
|
+
env: {
|
|
746
|
+
LAUNCHCHAIR_BASE_URL: baseUrl(),
|
|
747
|
+
LAUNCHCHAIR_AGENT_TOKEN: agentTokenPlaceholder,
|
|
748
|
+
LAUNCHCHAIR_PROJECT_ID: projectId,
|
|
749
|
+
LC_AGENT_ROUTER_GPT_TARGET: "claude_code",
|
|
750
|
+
LC_AGENT_ROUTER_CODEX_TARGET: "claude_code",
|
|
751
|
+
LC_AGENT_CLAUDE_CODE_CWD: configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], "/absolute/path/to/repo-or-empty-folder"),
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
},
|
|
756
|
+
null,
|
|
757
|
+
2,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
return `[mcp_servers.launchchair]
|
|
761
|
+
command = "node"
|
|
762
|
+
args = ["${bridgePath}"]
|
|
763
|
+
required = true
|
|
764
|
+
startup_timeout_sec = 20
|
|
765
|
+
tool_timeout_sec = 3600
|
|
766
|
+
default_tools_approval_mode = "approve"
|
|
767
|
+
|
|
768
|
+
[mcp_servers.launchchair.env]
|
|
769
|
+
LAUNCHCHAIR_BASE_URL = "${baseUrl()}"
|
|
770
|
+
LAUNCHCHAIR_AGENT_TOKEN = "${agentTokenPlaceholder}"
|
|
771
|
+
LAUNCHCHAIR_PROJECT_ID = "${projectId}"
|
|
772
|
+
LC_AGENT_ROUTER_GPT_TARGET = "codex"
|
|
773
|
+
LC_AGENT_ROUTER_CODEX_TARGET = "codex"
|
|
774
|
+
LC_AGENT_CODEX_CWD = "${configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], "/absolute/path/to/repo-or-empty-folder")}"`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function printMcpConfig(target) {
|
|
778
|
+
print(renderMcpConfig(target));
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function showConfig() {
|
|
782
|
+
const config = loadConfig();
|
|
783
|
+
title("CLI config");
|
|
784
|
+
row("Path", CONFIG_PATH, existsSync(CONFIG_PATH) ? "dim" : "yellow");
|
|
785
|
+
row("Base URL", baseUrl(), "cyan");
|
|
786
|
+
row("Token", maskToken(token()), token() ? "green" : "yellow");
|
|
787
|
+
row("Token source", tokenSource(), token() ? "green" : "yellow");
|
|
788
|
+
row("Project ID", defaultProjectId() || "not set", defaultProjectId() ? "cyan" : "dim");
|
|
789
|
+
row("Codex CWD", configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], "not set"), config.codexCwd || env("LC_AGENT_CODEX_CWD") ? "cyan" : "dim");
|
|
790
|
+
row("Claude Code CWD", configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], "not set"), config.claudeCodeCwd || env("LC_AGENT_CLAUDE_CODE_CWD") ? "cyan" : "dim");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function setConfig(args) {
|
|
794
|
+
const key = args._[0];
|
|
795
|
+
const value = args._[1];
|
|
796
|
+
const allowed = {
|
|
797
|
+
"base-url": "baseUrl",
|
|
798
|
+
"project-id": "projectId",
|
|
799
|
+
"codex-cwd": "codexCwd",
|
|
800
|
+
"claude-code-cwd": "claudeCodeCwd",
|
|
801
|
+
};
|
|
802
|
+
if (!key || !value || !allowed[key]) {
|
|
803
|
+
throw new Error("Usage: launchchair config set base-url|project-id|codex-cwd|claude-code-cwd VALUE");
|
|
804
|
+
}
|
|
805
|
+
saveConfig({ [allowed[key]]: key === "base-url" ? value.replace(/\/+$/, "") : value });
|
|
806
|
+
ok(`Saved ${key} to ${CONFIG_PATH}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function installAgentMd(args) {
|
|
810
|
+
const cwd = path.resolve(String(args.cwd || process.cwd()));
|
|
811
|
+
const target = path.join(cwd, "AGENTS.md");
|
|
812
|
+
const bootstrapPath = path.join(SCRIPT_DIR, "AGENTS.md");
|
|
813
|
+
if (existsSync(target)) {
|
|
814
|
+
print(`AGENTS.md already exists: ${target}`);
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
writeFileSync(
|
|
818
|
+
target,
|
|
819
|
+
[
|
|
820
|
+
"# LaunchChair Agent Bootstrap Pointer",
|
|
821
|
+
"",
|
|
822
|
+
`Before searching this repo, read ${bootstrapPath}.`,
|
|
823
|
+
"Use LaunchChair MCP/API first for product phases, project discovery, project creation, and phase loops.",
|
|
824
|
+
"Do not ask for extra confirmation before using LaunchChair MCP/API when the user already requested the action and the token permits it.",
|
|
825
|
+
"Only inspect this repo for Stack Setup, Build MVP, Design/Landing Page, SEO implementation, smoke testing, security review, or code remediation.",
|
|
826
|
+
"",
|
|
827
|
+
].join("\n"),
|
|
828
|
+
);
|
|
829
|
+
print(`Created ${target}`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function installLaunchChairSkill(args) {
|
|
833
|
+
const cwd = path.resolve(String(args.cwd || process.cwd()));
|
|
834
|
+
const featureEnabled = args.featureEnabled === true;
|
|
835
|
+
const skillDir = path.join(cwd, ".agents", "skills", "launchchair");
|
|
836
|
+
const target = path.join(skillDir, "SKILL.md");
|
|
837
|
+
if (existsSync(target)) {
|
|
838
|
+
print(`LaunchChair skill already exists: ${target}`);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
mkdirSync(skillDir, { recursive: true });
|
|
842
|
+
writeFileSync(
|
|
843
|
+
target,
|
|
844
|
+
[
|
|
845
|
+
"---",
|
|
846
|
+
"name: launchchair",
|
|
847
|
+
featureEnabled
|
|
848
|
+
? "description: Use whenever the user mentions LaunchChair or asks to create, plan, validate, or build a Product or a Feature in an existing product with LaunchChair."
|
|
849
|
+
: "description: Use whenever the user mentions LaunchChair or asks to create, plan, validate, or build a Product with LaunchChair.",
|
|
850
|
+
"---",
|
|
851
|
+
"",
|
|
852
|
+
"# LaunchChair workflow",
|
|
853
|
+
"",
|
|
854
|
+
"Use the installed LaunchChair MCP tools before web search or local repo discovery for LaunchChair project work.",
|
|
855
|
+
"Call launchchair_status if capability context is unclear.",
|
|
856
|
+
"For a new product, use launchchair_create_project.",
|
|
857
|
+
...(featureEnabled
|
|
858
|
+
? [
|
|
859
|
+
"For a feature in an existing product, use launchchair_create_feature_project. Never substitute a Product project if that tool is unavailable; report the setup or beta-access issue instead.",
|
|
860
|
+
"Use planToBuildBoard=true for planning through the Feature build board.",
|
|
861
|
+
"Use fullLoop=true only when the user asks to implement/build the feature; continue agent-run QA and stop for production SQL or a genuine external authorization boundary.",
|
|
862
|
+
]
|
|
863
|
+
: []),
|
|
864
|
+
"Do not ask for extra confirmation for ordinary LaunchChair MCP calls when the user already requested the workflow and the token permits it.",
|
|
865
|
+
"",
|
|
866
|
+
].join("\n"),
|
|
867
|
+
);
|
|
868
|
+
print(`Created ${target}`);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function setup(args) {
|
|
872
|
+
const details = targetDetails(args._[0] || "codex");
|
|
873
|
+
const cwd = path.resolve(String(args.cwd || process.cwd()));
|
|
874
|
+
const incomingToken = typeof args.token === "string" ? args.token.trim() : "";
|
|
875
|
+
const incomingBaseUrl = typeof args["base-url"] === "string" ? args["base-url"].trim().replace(/\/+$/, "") : undefined;
|
|
876
|
+
const incomingProjectId = typeof args["project-id"] === "string" ? args["project-id"].trim() : "";
|
|
877
|
+
|
|
878
|
+
title(`guided setup for ${details.label}`);
|
|
879
|
+
mkdirSync(cwd, { recursive: true });
|
|
880
|
+
ok(`Build folder ready: ${cwd}`);
|
|
881
|
+
|
|
882
|
+
if (incomingToken) {
|
|
883
|
+
if (!incomingToken.startsWith("lc_at_")) {
|
|
884
|
+
throw new Error("LaunchChair agent tokens start with lc_at_. Paste the full Agent API token.");
|
|
885
|
+
}
|
|
886
|
+
saveConfig({
|
|
887
|
+
agentToken: incomingToken,
|
|
888
|
+
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
889
|
+
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
890
|
+
});
|
|
891
|
+
ok(`Saved token to ${CONFIG_PATH}`);
|
|
892
|
+
} else if (incomingBaseUrl || incomingProjectId) {
|
|
893
|
+
saveConfig({
|
|
894
|
+
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
895
|
+
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
saveConfig({ [details.cwdConfigKey]: cwd });
|
|
900
|
+
ok(`Saved ${details.cwdEnvName}: ${cwd}`);
|
|
901
|
+
installAgentMd({ cwd });
|
|
902
|
+
|
|
903
|
+
if (!token() && args["skip-login"] !== true) {
|
|
904
|
+
print("");
|
|
905
|
+
warn("No saved LaunchChair token found. Starting browser approval now.");
|
|
906
|
+
await login({
|
|
907
|
+
"base-url": incomingBaseUrl ?? baseUrl(),
|
|
908
|
+
"project-id": incomingProjectId || defaultProjectId() || undefined,
|
|
909
|
+
name:
|
|
910
|
+
typeof args.name === "string" && args.name.trim()
|
|
911
|
+
? args.name.trim()
|
|
912
|
+
: `LaunchChair ${details.label} CLI (${os.hostname() || "local"})`,
|
|
913
|
+
"no-open": args["no-open"],
|
|
914
|
+
timeout: args.timeout,
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
if (details.mcpTarget === "codex") {
|
|
919
|
+
let featureEnabled = false;
|
|
920
|
+
if (token()) {
|
|
921
|
+
try {
|
|
922
|
+
const status = await api("/api/agent/v1");
|
|
923
|
+
featureEnabled = Boolean(status?.capabilities?.featureProjectWorkflow);
|
|
924
|
+
} catch {
|
|
925
|
+
// Install the public Product workflow when gated capability discovery is unavailable.
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
installLaunchChairSkill({ cwd, featureEnabled });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
print("");
|
|
932
|
+
title("doctor");
|
|
933
|
+
try {
|
|
934
|
+
await doctor();
|
|
935
|
+
} catch (error) {
|
|
936
|
+
warn(`Doctor could not finish: ${error instanceof Error ? error.message : String(error)}`);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
print("");
|
|
940
|
+
if (details.terminalOnly) {
|
|
941
|
+
title("terminal agent commands");
|
|
942
|
+
print("Give a terminal agent the build folder and let it run these commands when needed:");
|
|
943
|
+
print(` ${paint("launchchair doctor", "cyan")}`);
|
|
944
|
+
print(` ${paint("launchchair projects", "cyan")}`);
|
|
945
|
+
print(` ${paint("launchchair create --name \"My Project\" --one-liner \"...\" --project-type b2b --save-project", "cyan")}`);
|
|
946
|
+
print(` ${paint("launchchair continue --project-id <project-id>", "cyan")}`);
|
|
947
|
+
print(` ${paint("launchchair run-once", "cyan")}`);
|
|
948
|
+
print(` ${paint("launchchair bridge", "cyan")}`);
|
|
949
|
+
} else {
|
|
950
|
+
title("MCP config to paste");
|
|
951
|
+
print(renderMcpConfig(details.mcpTarget));
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
print("");
|
|
955
|
+
title("next steps");
|
|
956
|
+
if (!token()) {
|
|
957
|
+
warn("No token is saved yet. Run browser login, then rerun setup or start the bridge:");
|
|
958
|
+
print(` ${paint("launchchair login", "cyan")}`);
|
|
959
|
+
print(` ${paint("launchchair login --token <full lc_at token>", "dim")} ${paint("(manual fallback)", "dim")}`);
|
|
960
|
+
}
|
|
961
|
+
if (details.terminalOnly) {
|
|
962
|
+
print("1. Open your terminal agent in the build folder above.");
|
|
963
|
+
print("2. Ask it to read AGENTS.md before searching the repo.");
|
|
964
|
+
print("3. For product/project work, have it use launchchair projects/create/continue before local code discovery.");
|
|
965
|
+
print("4. Keep launchchair bridge running only when LaunchChair UI Run with agent jobs should be picked up.");
|
|
966
|
+
} else {
|
|
967
|
+
print(`1. Paste the MCP config above into ${details.label}.`);
|
|
968
|
+
print(`2. Restart or reload ${details.label} so the MCP server is exposed to fresh chats.`);
|
|
969
|
+
print("3. Keep this bridge running when you want LaunchChair's Run with agent button to work:");
|
|
970
|
+
print(` ${paint("launchchair bridge", "cyan")}`);
|
|
971
|
+
print("4. Start a fresh agent chat. Codex will discover the LaunchChair skill and MCP server instructions automatically.");
|
|
972
|
+
}
|
|
973
|
+
print("");
|
|
974
|
+
row("Build folder", cwd, "cyan");
|
|
975
|
+
row("Runner target", details.routerTarget, "cyan");
|
|
976
|
+
row("Config file", CONFIG_PATH, "dim");
|
|
977
|
+
|
|
978
|
+
if (args["start-bridge"] === true) {
|
|
979
|
+
if (!token()) {
|
|
980
|
+
warn("Bridge was not started because no token is configured yet.");
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
print("");
|
|
984
|
+
ok("Starting LaunchChair local bridge. Leave this terminal open.");
|
|
985
|
+
spawnBridge(["--verbose"]);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function main() {
|
|
990
|
+
const [command = "help", ...rest] = process.argv.slice(2);
|
|
991
|
+
const args = parseArgs(rest);
|
|
992
|
+
if (command === "help" || command === "--help" || command === "-h") return help();
|
|
993
|
+
if (command === "doctor") return doctor();
|
|
994
|
+
if (command === "login") return login(args);
|
|
995
|
+
if (command === "logout") return logout();
|
|
996
|
+
if (command === "whoami") return whoami();
|
|
997
|
+
if (command === "projects") return listProjects(args);
|
|
998
|
+
if (command === "create") return createProject(args);
|
|
999
|
+
if (command === "continue") return continueProject(args);
|
|
1000
|
+
if (command === "run-once") return spawnBridge(["--once", "--verbose"]);
|
|
1001
|
+
if (command === "bridge") return spawnBridge(["--verbose"]);
|
|
1002
|
+
if (command === "setup") return setup(args);
|
|
1003
|
+
if (command === "config" && args._[0] === "set") return setConfig({ ...args, _: args._.slice(1) });
|
|
1004
|
+
if (command === "config") return showConfig();
|
|
1005
|
+
if (command === "mcp-config") return printMcpConfig(args._[0] || "codex");
|
|
1006
|
+
if (command === "install-agent-md") return installAgentMd(args);
|
|
1007
|
+
throw new Error(`Unknown command: ${command}`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
main().catch((error) => {
|
|
1011
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
1012
|
+
process.exit(1);
|
|
1013
|
+
});
|