@launchchair/agent 0.4.11 → 0.5.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 +35 -32
- package/README.md +63 -32
- package/launchchair-cli.mjs +253 -42
- package/local-bridge.mjs +34 -3
- package/mcp-bridge-server.mjs +113 -61
- package/mcp-capabilities.mjs +227 -0
- package/package.json +3 -1
- package/project-workspace.mjs +309 -0
- package/server.json +4 -4
package/launchchair-cli.mjs
CHANGED
|
@@ -14,8 +14,13 @@ import os from "node:os";
|
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import process from "node:process";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import {
|
|
18
|
+
bindProjectWorkspace,
|
|
19
|
+
listProjectWorkspaceBindings,
|
|
20
|
+
unbindProjectWorkspace,
|
|
21
|
+
} from "./project-workspace.mjs";
|
|
17
22
|
|
|
18
|
-
const VERSION = "0.
|
|
23
|
+
const VERSION = "0.5.0";
|
|
19
24
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
20
25
|
const DEFAULT_BASE_URL = "https://www.launchchair.io";
|
|
21
26
|
const CONFIG_DIR = process.env.LAUNCHCHAIR_CONFIG_DIR?.trim()
|
|
@@ -104,10 +109,16 @@ function configValue(key, envNames = [], fallback = "") {
|
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
function token() {
|
|
112
|
+
const config = loadConfig();
|
|
113
|
+
if (config.mcpLoggedOut === true) return "";
|
|
114
|
+
if (config.mcpPreferSavedToken === true && config.agentToken) return config.agentToken;
|
|
107
115
|
return configValue("agentToken", ["LAUNCHCHAIR_AGENT_TOKEN", "LAUNCHCHAIR_API_KEY"]);
|
|
108
116
|
}
|
|
109
117
|
|
|
110
118
|
function tokenSource() {
|
|
119
|
+
const config = loadConfig();
|
|
120
|
+
if (config.mcpLoggedOut === true) return "logged out";
|
|
121
|
+
if (config.mcpPreferSavedToken === true && config.agentToken) return CONFIG_PATH;
|
|
111
122
|
if (env("LAUNCHCHAIR_AGENT_TOKEN")) return "LAUNCHCHAIR_AGENT_TOKEN";
|
|
112
123
|
if (env("LAUNCHCHAIR_API_KEY")) return "LAUNCHCHAIR_API_KEY";
|
|
113
124
|
if (loadConfig().agentToken) return CONFIG_PATH;
|
|
@@ -166,6 +177,7 @@ Use MCP-only when you want agent tools inside Codex/Claude/Hermes.
|
|
|
166
177
|
Use CLI + MCP when you also want quick terminal commands, diagnostics, and bridge control.
|
|
167
178
|
|
|
168
179
|
Commands:
|
|
180
|
+
--version Show the installed package version
|
|
169
181
|
doctor Check token, docs, API reachability, and local bridge files
|
|
170
182
|
login [--token TOKEN] Open browser login and save a LaunchChair Agent API token locally
|
|
171
183
|
logout Remove the saved local token
|
|
@@ -178,10 +190,15 @@ Commands:
|
|
|
178
190
|
run-once Claim and run one queued LaunchChair job through the local bridge
|
|
179
191
|
bridge Start the long-running local bridge for Run with Agent
|
|
180
192
|
mcp-server Run the stdio MCP server using saved CLI config
|
|
193
|
+
tools [--json] List the available MCP tools and their input schemas
|
|
194
|
+
call TOOL [--json JSON|--input FILE]
|
|
195
|
+
Call any MCP tool; JSON output, nonzero exit on tool errors
|
|
181
196
|
setup codex|claude|claude-desktop|claude-code|terminal [options]
|
|
182
|
-
Guided
|
|
197
|
+
Guided global CLI/MCP setup with isolated project workspaces
|
|
198
|
+
bind --project-id ID --cwd PATH Bind one LaunchChair project to a verified local Git clone
|
|
199
|
+
unbind --project-id ID Remove one project's saved local repository binding
|
|
183
200
|
config Show local CLI config without printing the token
|
|
184
|
-
config set KEY VALUE Save base-url
|
|
201
|
+
config set KEY VALUE Save base-url or project-id
|
|
185
202
|
mcp-config codex|claude [options]
|
|
186
203
|
Print MCP config or Codex custom MCP form fields
|
|
187
204
|
install-agent-md [--cwd PATH] Create a small AGENTS.md pointer in a fresh repo
|
|
@@ -195,6 +212,8 @@ Create options:
|
|
|
195
212
|
--start-loop
|
|
196
213
|
--save-project Save the created project id as the CLI default
|
|
197
214
|
--idempotency-key KEY
|
|
215
|
+
--idea-intent preserve|explore Default: preserve the supplied idea, even with low scores
|
|
216
|
+
--validation-gate very_high Require pain >=85, opportunity >=85, saturation <=30 before build
|
|
198
217
|
|
|
199
218
|
Feature create options:
|
|
200
219
|
--project-kind feature
|
|
@@ -215,7 +234,7 @@ Continue options:
|
|
|
215
234
|
--completion-target TEXT Natural-language target for the loop run
|
|
216
235
|
|
|
217
236
|
Setup options:
|
|
218
|
-
--cwd PATH
|
|
237
|
+
--cwd PATH Bind this repo only when --project-id is also supplied
|
|
219
238
|
--token TOKEN Optional manual lc_at token fallback to save during setup
|
|
220
239
|
--project-id ID Optional default LaunchChair project id
|
|
221
240
|
--base-url URL Optional LaunchChair base URL
|
|
@@ -238,16 +257,21 @@ Login options:
|
|
|
238
257
|
--timeout SECONDS Default: 600
|
|
239
258
|
|
|
240
259
|
Env:
|
|
241
|
-
LAUNCHCHAIR_AGENT_TOKEN Optional
|
|
260
|
+
LAUNCHCHAIR_AGENT_TOKEN Optional token fallback when no saved login takes precedence
|
|
242
261
|
LAUNCHCHAIR_BASE_URL Default: https://www.launchchair.io
|
|
243
262
|
LAUNCHCHAIR_PROJECT_ID Optional default project id
|
|
244
|
-
LC_AGENT_CODEX_CWD
|
|
245
|
-
LC_AGENT_CLAUDE_CODE_CWD
|
|
263
|
+
LC_AGENT_CODEX_CWD Legacy candidate only; verified per project before use
|
|
264
|
+
LC_AGENT_CLAUDE_CODE_CWD Legacy candidate only; verified per project before use
|
|
246
265
|
|
|
247
266
|
MCP config options:
|
|
248
267
|
--ui-fields Print fields for Codex Desktop's custom MCP form
|
|
249
268
|
--copy-config Copy generated output to clipboard on macOS
|
|
250
269
|
|
|
270
|
+
Tool call options:
|
|
271
|
+
--json JSON Tool arguments as a JSON object; defaults to {}
|
|
272
|
+
--input FILE Read tool arguments from a JSON file; use - for stdin
|
|
273
|
+
--timeout SECONDS Request timeout. Default: 60, max: 3600
|
|
274
|
+
|
|
251
275
|
Config:
|
|
252
276
|
${CONFIG_PATH}
|
|
253
277
|
`);
|
|
@@ -301,7 +325,13 @@ async function api(pathname, options = {}) {
|
|
|
301
325
|
if (!response.ok) {
|
|
302
326
|
const retryAfter = response.headers.get("retry-after");
|
|
303
327
|
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
304
|
-
|
|
328
|
+
const next = body?.upgradeUrl
|
|
329
|
+
? ` Upgrade: ${body.upgradeUrl}`
|
|
330
|
+
: response.status === 401 ? " Run launchchair login to reconnect." : "";
|
|
331
|
+
const error = new Error(`${body?.error ?? response.statusText}${suffix}${next}`);
|
|
332
|
+
error.status = response.status;
|
|
333
|
+
error.retryAfterSeconds = Number(retryAfter) || 10;
|
|
334
|
+
throw error;
|
|
305
335
|
}
|
|
306
336
|
return body;
|
|
307
337
|
}
|
|
@@ -324,7 +354,10 @@ async function publicApi(pathname, options = {}) {
|
|
|
324
354
|
if (!response.ok) {
|
|
325
355
|
const retryAfter = response.headers.get("retry-after");
|
|
326
356
|
const suffix = retryAfter ? ` Retry after ${retryAfter}s.` : "";
|
|
327
|
-
|
|
357
|
+
const error = new Error(`${body?.error ?? response.statusText}${suffix}`);
|
|
358
|
+
error.status = response.status;
|
|
359
|
+
error.retryAfterSeconds = Number(retryAfter) || 10;
|
|
360
|
+
throw error;
|
|
328
361
|
}
|
|
329
362
|
return body;
|
|
330
363
|
}
|
|
@@ -384,6 +417,8 @@ async function login(args) {
|
|
|
384
417
|
}
|
|
385
418
|
saveConfig({
|
|
386
419
|
agentToken: incomingToken,
|
|
420
|
+
mcpLoggedOut: false,
|
|
421
|
+
mcpPreferSavedToken: true,
|
|
387
422
|
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
388
423
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
389
424
|
});
|
|
@@ -427,7 +462,7 @@ async function login(args) {
|
|
|
427
462
|
|
|
428
463
|
const timeoutSeconds =
|
|
429
464
|
typeof args.timeout === "string" && Number.isFinite(Number(args.timeout)) ? Math.max(10, Number(args.timeout)) : Number(start.expiresIn ?? 600);
|
|
430
|
-
|
|
465
|
+
let intervalSeconds = Math.max(5, Number(start.interval ?? 5));
|
|
431
466
|
const startedAt = Date.now();
|
|
432
467
|
print("");
|
|
433
468
|
print("Waiting for approval. Leave this terminal open.");
|
|
@@ -442,10 +477,12 @@ async function login(args) {
|
|
|
442
477
|
});
|
|
443
478
|
} catch (error) {
|
|
444
479
|
const message = error instanceof Error ? error.message : String(error);
|
|
480
|
+
if (error.status === 429) { intervalSeconds = Math.max(5, error.retryAfterSeconds); continue; }
|
|
445
481
|
if (/pending/i.test(message)) continue;
|
|
446
482
|
throw error;
|
|
447
483
|
}
|
|
448
484
|
if (poll?.status === "pending") {
|
|
485
|
+
intervalSeconds = Math.max(5, Number(poll.interval) || intervalSeconds);
|
|
449
486
|
write(paint(".", "dim"));
|
|
450
487
|
continue;
|
|
451
488
|
}
|
|
@@ -453,6 +490,8 @@ async function login(args) {
|
|
|
453
490
|
print("");
|
|
454
491
|
saveConfig({
|
|
455
492
|
agentToken: poll.token,
|
|
493
|
+
mcpLoggedOut: false,
|
|
494
|
+
mcpPreferSavedToken: true,
|
|
456
495
|
baseUrl: baseUrl(),
|
|
457
496
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
458
497
|
});
|
|
@@ -471,11 +510,12 @@ async function login(args) {
|
|
|
471
510
|
}
|
|
472
511
|
|
|
473
512
|
function logout() {
|
|
474
|
-
deleteConfigKeys(["agentToken"]);
|
|
513
|
+
deleteConfigKeys(["agentToken", "pendingMcpLogin"]);
|
|
514
|
+
saveConfig({ mcpLoggedOut: true });
|
|
475
515
|
title("CLI logout");
|
|
476
516
|
ok(`Removed saved token from ${CONFIG_PATH}`);
|
|
477
517
|
if (env("LAUNCHCHAIR_AGENT_TOKEN") || env("LAUNCHCHAIR_API_KEY")) {
|
|
478
|
-
warn("An environment token is still set
|
|
518
|
+
warn("An environment token is still set; it remains ignored until you log in again.");
|
|
479
519
|
}
|
|
480
520
|
}
|
|
481
521
|
|
|
@@ -599,6 +639,14 @@ async function createProject(args) {
|
|
|
599
639
|
throw new Error("--project-kind must be product or feature.");
|
|
600
640
|
}
|
|
601
641
|
const featureProject = projectKind === "feature";
|
|
642
|
+
const ideaIntent = args["idea-intent"] ?? "preserve";
|
|
643
|
+
if (!["preserve", "explore"].includes(ideaIntent)) {
|
|
644
|
+
throw new Error("--idea-intent must be preserve or explore.");
|
|
645
|
+
}
|
|
646
|
+
const validationGate = args["validation-gate"];
|
|
647
|
+
if (validationGate !== undefined && validationGate !== "very_high") {
|
|
648
|
+
throw new Error("--validation-gate must be very_high. For custom thresholds, use launchchair call create_project --input FILE.");
|
|
649
|
+
}
|
|
602
650
|
const featureDescription = String(args["feature-description"] ?? args["one-liner"] ?? "").trim();
|
|
603
651
|
const featureProblem = String(args.problem ?? "").trim();
|
|
604
652
|
if (featureProject && (!featureDescription || !featureProblem)) {
|
|
@@ -657,7 +705,7 @@ async function createProject(args) {
|
|
|
657
705
|
const body = await api("/api/agent/v1/projects", {
|
|
658
706
|
method: "POST",
|
|
659
707
|
headers,
|
|
660
|
-
body: JSON.stringify(requestBody),
|
|
708
|
+
body: JSON.stringify({ ...requestBody, ideaIntent, validationGate }),
|
|
661
709
|
});
|
|
662
710
|
const projectId = body?.project?.id ?? body?.projectId ?? "";
|
|
663
711
|
title("Project created");
|
|
@@ -767,9 +815,9 @@ function buildBridgeEnv(projectId = defaultProjectId()) {
|
|
|
767
815
|
LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
|
|
768
816
|
LAUNCHCHAIR_AGENT_TOKEN: token(),
|
|
769
817
|
LAUNCHCHAIR_PROJECT_ID: projectId,
|
|
770
|
-
LC_AGENT_CODEX_CWD: configValue("codexCwd", ["LC_AGENT_CODEX_CWD"], ""),
|
|
771
|
-
LC_AGENT_CLAUDE_CODE_CWD: configValue("claudeCodeCwd", ["LC_AGENT_CLAUDE_CODE_CWD"], ""),
|
|
772
818
|
};
|
|
819
|
+
delete childEnv.LC_AGENT_CODEX_CWD;
|
|
820
|
+
delete childEnv.LC_AGENT_CLAUDE_CODE_CWD;
|
|
773
821
|
Object.keys(childEnv).forEach((key) => {
|
|
774
822
|
if (childEnv[key] === undefined || childEnv[key] === null || childEnv[key] === "") {
|
|
775
823
|
delete childEnv[key];
|
|
@@ -869,6 +917,100 @@ function spawnMcpServer(extraArgs = []) {
|
|
|
869
917
|
});
|
|
870
918
|
}
|
|
871
919
|
|
|
920
|
+
// Use the bundled MCP so terminal calls share its login, scope checks, and host workflow.
|
|
921
|
+
async function requestMcp(method, params, timeoutMs = 60_000) {
|
|
922
|
+
const child = spawn(process.execPath, [path.join(SCRIPT_DIR, "mcp-bridge-server.mjs")], {
|
|
923
|
+
env: buildBridgeEnv(),
|
|
924
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
925
|
+
});
|
|
926
|
+
let buffer = "";
|
|
927
|
+
let serial = 0;
|
|
928
|
+
const pending = new Map();
|
|
929
|
+
const failPending = (error) => {
|
|
930
|
+
for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(error); }
|
|
931
|
+
pending.clear();
|
|
932
|
+
};
|
|
933
|
+
child.on("error", failPending);
|
|
934
|
+
child.on("exit", (code) => failPending(new Error(`MCP server exited before replying (code ${code ?? "unknown"}).`)));
|
|
935
|
+
child.stdin.on("error", failPending);
|
|
936
|
+
child.stderr.on("data", (chunk) => process.stderr.write(chunk));
|
|
937
|
+
child.stdout.setEncoding("utf8");
|
|
938
|
+
child.stdout.on("data", (chunk) => {
|
|
939
|
+
buffer += chunk;
|
|
940
|
+
let newline;
|
|
941
|
+
while ((newline = buffer.indexOf("\n")) !== -1) {
|
|
942
|
+
const line = buffer.slice(0, newline).trim();
|
|
943
|
+
buffer = buffer.slice(newline + 1);
|
|
944
|
+
if (!line) continue;
|
|
945
|
+
let message;
|
|
946
|
+
try { message = JSON.parse(line); }
|
|
947
|
+
catch { failPending(new Error("MCP server returned invalid JSON.")); return; }
|
|
948
|
+
const entry = pending.get(message.id);
|
|
949
|
+
if (!entry) continue;
|
|
950
|
+
pending.delete(message.id);
|
|
951
|
+
clearTimeout(entry.timer);
|
|
952
|
+
if (message.error) entry.reject(new Error(message.error.message || "MCP request failed."));
|
|
953
|
+
else entry.resolve(message.result);
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
const rpc = (rpcMethod, rpcParams) => new Promise((resolve, reject) => {
|
|
957
|
+
const id = ++serial;
|
|
958
|
+
const timer = setTimeout(() => {
|
|
959
|
+
pending.delete(id);
|
|
960
|
+
reject(new Error("MCP request timed out. Check the project's run status before retrying a write."));
|
|
961
|
+
}, timeoutMs);
|
|
962
|
+
pending.set(id, { resolve, reject, timer });
|
|
963
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method: rpcMethod, params: rpcParams })}\n`);
|
|
964
|
+
});
|
|
965
|
+
try {
|
|
966
|
+
await rpc("initialize", { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "launchchair-cli", version: VERSION } });
|
|
967
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`);
|
|
968
|
+
return await rpc(method, params);
|
|
969
|
+
} finally {
|
|
970
|
+
child.stdin.end();
|
|
971
|
+
child.kill();
|
|
972
|
+
failPending(new Error("MCP client closed."));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
async function listTools(args) {
|
|
977
|
+
const result = await requestMcp("tools/list", {});
|
|
978
|
+
if (args.json === true) return print(JSON.stringify(result, null, 2));
|
|
979
|
+
title("MCP tools");
|
|
980
|
+
for (const tool of result.tools ?? []) print(`${tool.name}\n ${tool.description}`);
|
|
981
|
+
print("\nUse launchchair tools --json for input schemas, then launchchair call TOOL --json '{...}'.");
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function callTool(args) {
|
|
985
|
+
const requestedName = args._[0];
|
|
986
|
+
if (!requestedName) throw new Error("call requires a tool name. Run launchchair tools to list them.");
|
|
987
|
+
if (args.json !== undefined && args.input !== undefined) throw new Error("Use either --json or --input, not both.");
|
|
988
|
+
let input = "{}";
|
|
989
|
+
if (args.json !== undefined) {
|
|
990
|
+
if (typeof args.json !== "string") throw new Error("--json requires a JSON object.");
|
|
991
|
+
input = args.json;
|
|
992
|
+
} else if (args.input !== undefined) {
|
|
993
|
+
if (typeof args.input !== "string") throw new Error("--input requires a file path or - for stdin.");
|
|
994
|
+
input = readFileSync(args.input === "-" ? 0 : path.resolve(args.input), "utf8");
|
|
995
|
+
}
|
|
996
|
+
let toolArgs;
|
|
997
|
+
try { toolArgs = JSON.parse(input); }
|
|
998
|
+
catch { throw new Error("Tool arguments must be valid JSON."); }
|
|
999
|
+
if (!toolArgs || typeof toolArgs !== "object" || Array.isArray(toolArgs)) throw new Error("Tool arguments must be a JSON object.");
|
|
1000
|
+
const timeoutSeconds = args.timeout === undefined ? 60 : Number(args.timeout);
|
|
1001
|
+
if (typeof args.timeout === "boolean" || !Number.isFinite(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 3600) {
|
|
1002
|
+
throw new Error("--timeout must be between 1 and 3600 seconds.");
|
|
1003
|
+
}
|
|
1004
|
+
const result = await requestMcp("tools/call", {
|
|
1005
|
+
name: requestedName.startsWith("launchchair_") ? requestedName : `launchchair_${requestedName}`,
|
|
1006
|
+
arguments: toolArgs,
|
|
1007
|
+
}, timeoutSeconds * 1000);
|
|
1008
|
+
// LaunchChair tools return one JSON text block. Keep other MCP content intact.
|
|
1009
|
+
print(result.content?.length === 1 && result.content[0].type === "text"
|
|
1010
|
+
? result.content[0].text : JSON.stringify(result, null, 2));
|
|
1011
|
+
if (result.isError) process.exitCode = 1;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
872
1014
|
function normalizedMcpTarget(target) {
|
|
873
1015
|
return targetDetails(target || "codex").mcpTarget || "codex";
|
|
874
1016
|
}
|
|
@@ -878,18 +1020,14 @@ function tomlString(value) {
|
|
|
878
1020
|
}
|
|
879
1021
|
|
|
880
1022
|
function mcpEnvForTarget(target, options = {}) {
|
|
881
|
-
const includeTokenPlaceholder = options.includeTokenPlaceholder !== false;
|
|
882
1023
|
const details = targetDetails(target || "codex");
|
|
883
1024
|
const projectId = defaultProjectId();
|
|
884
|
-
const cwd = configValue(details.cwdConfigKey, [details.cwdEnvName], "/absolute/path/to/repo-or-empty-folder");
|
|
885
1025
|
const envBlock = {
|
|
886
1026
|
LAUNCHCHAIR_BASE_URL: baseUrl(),
|
|
887
1027
|
LAUNCHCHAIR_CONFIG_DIR: CONFIG_DIR,
|
|
888
|
-
...(token() || !includeTokenPlaceholder ? {} : { LAUNCHCHAIR_AGENT_TOKEN: "<optional full lc_at token when launchchair login was not used>" }),
|
|
889
1028
|
...(projectId ? { LAUNCHCHAIR_PROJECT_ID: projectId } : {}),
|
|
890
1029
|
LC_AGENT_ROUTER_GPT_TARGET: details.routerTarget,
|
|
891
1030
|
LC_AGENT_ROUTER_CODEX_TARGET: details.routerTarget,
|
|
892
|
-
[details.cwdEnvName]: cwd,
|
|
893
1031
|
};
|
|
894
1032
|
return Object.fromEntries(Object.entries(envBlock).filter(([, value]) => String(value ?? "").trim()));
|
|
895
1033
|
}
|
|
@@ -911,6 +1049,7 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
|
|
|
911
1049
|
stdio: ["pipe", "pipe", "pipe"],
|
|
912
1050
|
});
|
|
913
1051
|
let settled = false;
|
|
1052
|
+
let discoveredTools = [];
|
|
914
1053
|
let stdout = "";
|
|
915
1054
|
let stderr = "";
|
|
916
1055
|
|
|
@@ -961,13 +1100,29 @@ function verifyMcpServer(target, timeoutMs = 20_000) {
|
|
|
961
1100
|
const toolNames = Array.isArray(response?.result?.tools)
|
|
962
1101
|
? response.result.tools.map((tool) => String(tool?.name || "")).filter(Boolean)
|
|
963
1102
|
: [];
|
|
964
|
-
const requiredTools = [
|
|
1103
|
+
const requiredTools = [
|
|
1104
|
+
"launchchair_status", "launchchair_create_project", "launchchair_list_projects",
|
|
1105
|
+
"launchchair_login", "launchchair_login_status", "launchchair_create_token",
|
|
1106
|
+
"launchchair_next_step", "launchchair_complete_run", "launchchair_get_validation_gate",
|
|
1107
|
+
"launchchair_update_bucket", "launchchair_get_starter", "launchchair_export_mvp_spec",
|
|
1108
|
+
];
|
|
965
1109
|
const missingTools = requiredTools.filter((name) => !toolNames.includes(name));
|
|
966
1110
|
if (missingTools.length) {
|
|
967
1111
|
finish(new Error(`MCP verification did not discover required tools: ${missingTools.join(", ")}`));
|
|
968
1112
|
return;
|
|
969
1113
|
}
|
|
970
|
-
|
|
1114
|
+
discoveredTools = toolNames;
|
|
1115
|
+
if (!token()) { finish(null, toolNames); continue; }
|
|
1116
|
+
send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "launchchair_status", arguments: {} } });
|
|
1117
|
+
}
|
|
1118
|
+
if (response?.id === 3) {
|
|
1119
|
+
let status = {};
|
|
1120
|
+
try { status = JSON.parse(response.result?.content?.[0]?.text || "{}"); } catch {}
|
|
1121
|
+
if (response.error || response.result?.isError || status.authenticated !== true) {
|
|
1122
|
+
finish(new Error(`MCP authentication verification failed: ${status.error || response.error?.message || "not authenticated"}`));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
finish(null, discoveredTools);
|
|
971
1126
|
}
|
|
972
1127
|
}
|
|
973
1128
|
});
|
|
@@ -1025,13 +1180,12 @@ ${envLines}`;
|
|
|
1025
1180
|
|
|
1026
1181
|
function renderMcpUiFields(target) {
|
|
1027
1182
|
const normalized = normalizedMcpTarget(target);
|
|
1028
|
-
const details = targetDetails(normalized);
|
|
1029
1183
|
const server = mcpServerConfig(normalized);
|
|
1030
1184
|
const envLines =
|
|
1031
1185
|
Object.entries(server.env)
|
|
1032
1186
|
.map(([key, value]) => ` ${key} = ${value}`)
|
|
1033
1187
|
.join("\n") || " (none)";
|
|
1034
|
-
const workingDir =
|
|
1188
|
+
const workingDir = path.join(CONFIG_DIR, "projects");
|
|
1035
1189
|
return `Name
|
|
1036
1190
|
launchchair
|
|
1037
1191
|
|
|
@@ -1173,14 +1327,20 @@ function printMcpConfig(target, args = {}) {
|
|
|
1173
1327
|
|
|
1174
1328
|
function showConfig() {
|
|
1175
1329
|
const config = loadConfig();
|
|
1330
|
+
const bindings = listProjectWorkspaceBindings(CONFIG_PATH);
|
|
1176
1331
|
title("CLI config");
|
|
1177
1332
|
row("Path", CONFIG_PATH, existsSync(CONFIG_PATH) ? "dim" : "yellow");
|
|
1178
1333
|
row("Base URL", baseUrl(), "cyan");
|
|
1179
1334
|
row("Token", maskToken(token()), token() ? "green" : "yellow");
|
|
1180
1335
|
row("Token source", tokenSource(), token() ? "green" : "yellow");
|
|
1181
1336
|
row("Project ID", defaultProjectId() || "not set", defaultProjectId() ? "cyan" : "dim");
|
|
1182
|
-
row("
|
|
1183
|
-
|
|
1337
|
+
row("Project bindings", Object.keys(bindings).length, Object.keys(bindings).length > 0 ? "cyan" : "dim");
|
|
1338
|
+
for (const [projectId, binding] of Object.entries(bindings)) {
|
|
1339
|
+
row(` ${projectId.slice(0, 12)}`, typeof binding === "string" ? binding : binding?.path ?? "invalid", "cyan");
|
|
1340
|
+
}
|
|
1341
|
+
if (config.codexCwd || config.claudeCodeCwd || env("LC_AGENT_CODEX_CWD") || env("LC_AGENT_CLAUDE_CODE_CWD")) {
|
|
1342
|
+
row("Legacy CWD", "ignored unless its Git origin matches the current project", "yellow");
|
|
1343
|
+
}
|
|
1184
1344
|
}
|
|
1185
1345
|
|
|
1186
1346
|
function setConfig(args) {
|
|
@@ -1189,16 +1349,50 @@ function setConfig(args) {
|
|
|
1189
1349
|
const allowed = {
|
|
1190
1350
|
"base-url": "baseUrl",
|
|
1191
1351
|
"project-id": "projectId",
|
|
1192
|
-
"codex-cwd": "codexCwd",
|
|
1193
|
-
"claude-code-cwd": "claudeCodeCwd",
|
|
1194
1352
|
};
|
|
1195
1353
|
if (!key || !value || !allowed[key]) {
|
|
1196
|
-
throw new Error("Usage: launchchair config set base-url|project-id
|
|
1354
|
+
throw new Error("Usage: launchchair config set base-url|project-id VALUE. Use launchchair bind for local repositories.");
|
|
1197
1355
|
}
|
|
1198
1356
|
saveConfig({ [allowed[key]]: key === "base-url" ? value.replace(/\/+$/, "") : value });
|
|
1199
1357
|
ok(`Saved ${key} to ${CONFIG_PATH}`);
|
|
1200
1358
|
}
|
|
1201
1359
|
|
|
1360
|
+
async function getRunnerWorkspaceContext(projectId) {
|
|
1361
|
+
const payload = await api(`/api/agent/v1/projects/${encodeURIComponent(projectId)}/runner-workspace`);
|
|
1362
|
+
return payload?.workspace && typeof payload.workspace === "object" ? payload.workspace : { projectId };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
async function bindProject(args) {
|
|
1366
|
+
const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
|
|
1367
|
+
const cwd = String(args.cwd || "").trim();
|
|
1368
|
+
if (!projectId || !cwd) {
|
|
1369
|
+
throw new Error("Usage: launchchair bind --project-id ID --cwd /absolute/path/to/repo");
|
|
1370
|
+
}
|
|
1371
|
+
const context = await getRunnerWorkspaceContext(projectId);
|
|
1372
|
+
const repository = context?.repository && typeof context.repository === "object" ? context.repository : {};
|
|
1373
|
+
const binding = bindProjectWorkspace({
|
|
1374
|
+
configPath: CONFIG_PATH,
|
|
1375
|
+
projectId,
|
|
1376
|
+
cwd,
|
|
1377
|
+
repositoryUrl: String(repository.repoUrl || repository.cloneUrl || "").trim(),
|
|
1378
|
+
});
|
|
1379
|
+
installAgentMd({ cwd: binding.path });
|
|
1380
|
+
title("project repository bound");
|
|
1381
|
+
row("Project ID", projectId, "cyan");
|
|
1382
|
+
row("Local repository", binding.path, "cyan");
|
|
1383
|
+
if (binding.repositoryUrl) row("Verified origin", binding.repositoryUrl, "green");
|
|
1384
|
+
ok("Future runs for this LaunchChair project will use this repository.");
|
|
1385
|
+
ok("Already-running bridges will pick up this binding on the next run.");
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function unbindProject(args) {
|
|
1389
|
+
const projectId = String(args["project-id"] || defaultProjectId() || "").trim();
|
|
1390
|
+
if (!projectId) throw new Error("Usage: launchchair unbind --project-id ID");
|
|
1391
|
+
const existed = unbindProjectWorkspace({ configPath: CONFIG_PATH, projectId });
|
|
1392
|
+
if (existed) ok(`Removed local repository binding for ${projectId}.`);
|
|
1393
|
+
else warn(`No local repository binding existed for ${projectId}.`);
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1202
1396
|
function installAgentMd(args) {
|
|
1203
1397
|
const cwd = path.resolve(String(args.cwd || process.cwd()));
|
|
1204
1398
|
const target = path.join(cwd, "AGENTS.md");
|
|
@@ -1242,7 +1436,10 @@ function installLaunchChairSkill(args) {
|
|
|
1242
1436
|
"# LaunchChair workflow",
|
|
1243
1437
|
"",
|
|
1244
1438
|
"Use the installed LaunchChair MCP tools before web search or local repo discovery for LaunchChair project work.",
|
|
1245
|
-
"Call launchchair_status if capability context is unclear.",
|
|
1439
|
+
"Call launchchair_status if capability context is unclear. If login is required, use launchchair_login and launchchair_login_status; never print token secrets.",
|
|
1440
|
+
"Prefer launchchair_next_step and execute its claimed prompt in this host agent using existing plugins, then complete_run. Use local runners only when explicitly requested.",
|
|
1441
|
+
"Preserve supplied ideas and requirements even when validation is low. ideaIntent=preserve is the default; exploration and pivots require an explicit user request.",
|
|
1442
|
+
"Only impose a validation gate when requested. VERY high means validationGate=very_high (pain>=85, opportunity>=85, saturation<=30). Never fabricate evidence or weaken the gate.",
|
|
1246
1443
|
"For a new product, use launchchair_create_project.",
|
|
1247
1444
|
"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 authentication issue instead.",
|
|
1248
1445
|
"Use planToBuildBoard=true for planning through the Feature build board.",
|
|
@@ -1258,17 +1455,23 @@ function installLaunchChairSkill(args) {
|
|
|
1258
1455
|
|
|
1259
1456
|
async function setup(args) {
|
|
1260
1457
|
const details = targetDetails(args._[0] || "codex");
|
|
1261
|
-
const
|
|
1458
|
+
const requestedCwd = typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd.trim()) : "";
|
|
1262
1459
|
const writeConfig =
|
|
1263
1460
|
args["write-config"] === true ||
|
|
1264
1461
|
(args["copy-config"] !== true && args["ui-fields"] !== true && args["print-config"] !== true);
|
|
1265
1462
|
const incomingToken = typeof args.token === "string" ? args.token.trim() : "";
|
|
1266
1463
|
const incomingBaseUrl = typeof args["base-url"] === "string" ? args["base-url"].trim().replace(/\/+$/, "") : undefined;
|
|
1267
1464
|
const incomingProjectId = typeof args["project-id"] === "string" ? args["project-id"].trim() : "";
|
|
1465
|
+
const bindingProjectId = incomingProjectId || defaultProjectId();
|
|
1466
|
+
|
|
1467
|
+
if (requestedCwd && !bindingProjectId) {
|
|
1468
|
+
throw new Error("--cwd must be paired with --project-id. Global runner directories are no longer supported.");
|
|
1469
|
+
}
|
|
1268
1470
|
|
|
1269
1471
|
title(`guided setup for ${details.label}`);
|
|
1270
|
-
|
|
1271
|
-
|
|
1472
|
+
const projectWorkspaceRoot = path.join(CONFIG_DIR, "projects");
|
|
1473
|
+
mkdirSync(projectWorkspaceRoot, { recursive: true, mode: 0o700 });
|
|
1474
|
+
ok(`Isolated project workspace root ready: ${projectWorkspaceRoot}`);
|
|
1272
1475
|
|
|
1273
1476
|
if (incomingToken) {
|
|
1274
1477
|
if (!incomingToken.startsWith("lc_at_")) {
|
|
@@ -1276,6 +1479,8 @@ async function setup(args) {
|
|
|
1276
1479
|
}
|
|
1277
1480
|
saveConfig({
|
|
1278
1481
|
agentToken: incomingToken,
|
|
1482
|
+
mcpLoggedOut: false,
|
|
1483
|
+
mcpPreferSavedToken: true,
|
|
1279
1484
|
baseUrl: incomingBaseUrl ?? baseUrl(),
|
|
1280
1485
|
projectId: incomingProjectId || defaultProjectId() || undefined,
|
|
1281
1486
|
});
|
|
@@ -1287,10 +1492,6 @@ async function setup(args) {
|
|
|
1287
1492
|
});
|
|
1288
1493
|
}
|
|
1289
1494
|
|
|
1290
|
-
saveConfig({ [details.cwdConfigKey]: cwd });
|
|
1291
|
-
ok(`Saved ${details.cwdEnvName}: ${cwd}`);
|
|
1292
|
-
installAgentMd({ cwd });
|
|
1293
|
-
|
|
1294
1495
|
if (!token() && args["skip-login"] !== true) {
|
|
1295
1496
|
print("");
|
|
1296
1497
|
warn("No saved LaunchChair token found. Starting browser approval now.");
|
|
@@ -1306,8 +1507,12 @@ async function setup(args) {
|
|
|
1306
1507
|
});
|
|
1307
1508
|
}
|
|
1308
1509
|
|
|
1510
|
+
if (requestedCwd) {
|
|
1511
|
+
await bindProject({ "project-id": bindingProjectId, cwd: requestedCwd });
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1309
1514
|
if (details.mcpTarget === "codex") {
|
|
1310
|
-
installLaunchChairSkill({ cwd });
|
|
1515
|
+
installLaunchChairSkill({ cwd: path.dirname(CONFIG_DIR) });
|
|
1311
1516
|
}
|
|
1312
1517
|
|
|
1313
1518
|
print("");
|
|
@@ -1362,11 +1567,11 @@ async function setup(args) {
|
|
|
1362
1567
|
}
|
|
1363
1568
|
}
|
|
1364
1569
|
|
|
1365
|
-
if (!details.terminalOnly &&
|
|
1570
|
+
if (!details.terminalOnly && args["skip-mcp-check"] !== true) {
|
|
1366
1571
|
const toolNames = await verifyMcpServer(details.mcpTarget);
|
|
1367
1572
|
print("");
|
|
1368
|
-
title("MCP verification passed");
|
|
1369
|
-
ok(`
|
|
1573
|
+
title(token() ? "MCP verification passed" : "MCP discovery verified — login required");
|
|
1574
|
+
ok(`Discovered ${toolNames.length} LaunchChair tools.${token() ? " Authenticated status verified." : " Call launchchair_login in your agent."}`);
|
|
1370
1575
|
} else if (!details.terminalOnly && !token()) {
|
|
1371
1576
|
warn("MCP verification was skipped because no LaunchChair token is configured.");
|
|
1372
1577
|
} else if (!details.terminalOnly && args["skip-mcp-check"] === true) {
|
|
@@ -1405,12 +1610,13 @@ async function setup(args) {
|
|
|
1405
1610
|
print(`2. Restart or reload ${details.label} so the MCP server is exposed to fresh chats.`);
|
|
1406
1611
|
}
|
|
1407
1612
|
print("Use the generated command and argument fields exactly as printed; setup resolves the local Node and CLI paths for this machine.");
|
|
1408
|
-
print("
|
|
1613
|
+
print("Your agent can use launchchair_next_step and launchchair_complete_run with its connected plugins.");
|
|
1614
|
+
print("For unattended local-runner jobs queued from LaunchChair's Run with agent button, optionally run:");
|
|
1409
1615
|
print(` ${paint("launchchair bridge", "cyan")}`);
|
|
1410
1616
|
print("Start a fresh agent chat. The agent should discover the LaunchChair MCP tools automatically.");
|
|
1411
1617
|
}
|
|
1412
1618
|
print("");
|
|
1413
|
-
row("
|
|
1619
|
+
row("Workspace mode", requestedCwd ? `project-bound: ${requestedCwd}` : "isolated per LaunchChair project", "cyan");
|
|
1414
1620
|
row("Runner target", details.routerTarget, "cyan");
|
|
1415
1621
|
row("Config file", CONFIG_PATH, "dim");
|
|
1416
1622
|
|
|
@@ -1429,6 +1635,7 @@ async function main() {
|
|
|
1429
1635
|
const [command = "help", ...rest] = process.argv.slice(2);
|
|
1430
1636
|
const args = parseArgs(rest);
|
|
1431
1637
|
if (command === "help" || command === "--help" || command === "-h") return help();
|
|
1638
|
+
if (command === "--version" || command === "-v" || command === "version") return print(VERSION);
|
|
1432
1639
|
if (command === "doctor") return doctor();
|
|
1433
1640
|
if (command === "login") return login(args);
|
|
1434
1641
|
if (command === "logout") return logout();
|
|
@@ -1440,7 +1647,11 @@ async function main() {
|
|
|
1440
1647
|
if (command === "run-once") return spawnBridge(["--once", "--verbose"]);
|
|
1441
1648
|
if (command === "bridge") return spawnBridge(["--verbose"]);
|
|
1442
1649
|
if (command === "mcp-server") return spawnMcpServer();
|
|
1650
|
+
if (command === "tools") return listTools(args);
|
|
1651
|
+
if (command === "call") return callTool(args);
|
|
1443
1652
|
if (command === "setup") return setup(args);
|
|
1653
|
+
if (command === "bind") return bindProject(args);
|
|
1654
|
+
if (command === "unbind") return unbindProject(args);
|
|
1444
1655
|
if (command === "config" && args._[0] === "set") return setConfig({ ...args, _: args._.slice(1) });
|
|
1445
1656
|
if (command === "config") return showConfig();
|
|
1446
1657
|
if (command === "mcp-config") return printMcpConfig(args._[0] || "codex", args);
|