premanmcp 0.10.6 → 0.11.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/README.md +11 -5
- package/bin/account.js +19 -4
- package/bin/api_tools.js +8 -0
- package/bin/cli.js +8 -2
- package/bin/connect/agents.js +198 -0
- package/bin/connect/checkin.js +503 -0
- package/bin/connect/configs.js +276 -0
- package/bin/connect/dispatch.js +131 -0
- package/bin/connect/errors.js +16 -0
- package/bin/connect/guide.js +535 -0
- package/bin/connect/pairing.js +94 -0
- package/bin/connect.js +101 -1554
- package/bin/desktop.js +28 -3
- package/bin/detect.js +42 -3
- package/bin/hook.js +10 -1
- package/bin/hosted.js +4 -4
- package/bin/progress.js +2 -5
- package/bin/runner.js +3 -30
- package/bin/shared.js +26 -1
- package/bin/status.js +1 -6
- package/bin/tests.js +341 -0
- package/bin/verify.js +82 -54
- package/dist/server.js +112 -5
- package/package.json +2 -2
package/bin/connect.js
CHANGED
|
@@ -1,1570 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `preman connect` — pick a coding agent and get connected, in one command.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* This file is the command itself: preflight, the order the steps run in, and
|
|
5
|
+
* the ladder that gets the first PreMan call to happen. The parts it orchestrates
|
|
6
|
+
* live next door, because each one grew its own reasons to be careful and reading
|
|
7
|
+
* them interleaved stopped being possible at around a thousand lines:
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* connect/agents.js which agent, and how it is asked
|
|
10
|
+
* connect/configs.js writing each agent's config, and proving it landed
|
|
11
|
+
* connect/pairing.js pair codes and the credential behind them
|
|
12
|
+
* connect/checkin.js self-test, agent run, terminal opening, waiting
|
|
13
|
+
* connect/guide.js endpoints, first test, runner, desktop, integrations, hook
|
|
14
|
+
* connect/dispatch.js the cloud-dispatch credential
|
|
15
|
+
*
|
|
16
|
+
* Everything those modules export is re-exported at the bottom, so `cli.js` and
|
|
17
|
+
* the smoke suites keep importing `bin/connect.js` and cannot tell this changed.
|
|
11
18
|
*/
|
|
12
19
|
|
|
13
|
-
import { execFileSync, spawn, spawnSync } from "node:child_process";
|
|
14
|
-
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
20
|
import os from "node:os";
|
|
16
21
|
import path from "node:path";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
17
23
|
|
|
18
|
-
import { callTool as callPremanTool, printTestSummary } from "./api_tools.js";
|
|
19
|
-
import { installDesktopCommand } from "./desktop.js";
|
|
20
|
-
import { installHook, hookStatus } from "./hook.js";
|
|
21
|
-
import { MARK, awsCommand, githubCommand, slackCommand } from "./integrations.js";
|
|
22
|
-
import {
|
|
23
|
-
confirmRunnerOnline,
|
|
24
|
-
pairingIsLive,
|
|
25
|
-
readRunnerState,
|
|
26
|
-
registerRunner,
|
|
27
|
-
runnerIsAlive,
|
|
28
|
-
startBackground,
|
|
29
|
-
} from "./runner.js";
|
|
30
24
|
import {
|
|
31
25
|
apiKeyIsExplicit,
|
|
32
|
-
assertOk,
|
|
33
26
|
authenticateTerminal,
|
|
34
27
|
backendUrl,
|
|
35
28
|
buildServerConfig,
|
|
36
|
-
callBackendJson,
|
|
37
|
-
clearStoredCredentials,
|
|
38
29
|
cliInvocation,
|
|
39
|
-
frontendUrl,
|
|
40
30
|
hasKeyAvailable,
|
|
41
|
-
LAUNCHER_ARGS,
|
|
42
|
-
LAUNCHER_COMMAND,
|
|
43
31
|
makeArgs,
|
|
32
|
+
onPath,
|
|
44
33
|
pathPremanOwner,
|
|
45
|
-
promptSecret,
|
|
46
|
-
promptText,
|
|
47
|
-
readJsonFile,
|
|
48
34
|
resolveApiKey,
|
|
49
|
-
writeJsonFile,
|
|
50
35
|
} from "./shared.js";
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
credential: "Claude Code routine token",
|
|
84
|
-
needsRoutine: true,
|
|
85
|
-
// Named explicitly because "Claude Code routine token" reads like an
|
|
86
|
-
// Anthropic API key, which is a different credential from a different page.
|
|
87
|
-
source: "claude.ai/code/routines → your routine → Add API trigger",
|
|
88
|
-
},
|
|
89
|
-
snippetHint: "run:",
|
|
90
|
-
restartHint: 'Start a new Claude Code session and run `claude mcp list` — "preman" should be listed.',
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
id: "codex",
|
|
94
|
-
label: "Codex",
|
|
95
|
-
aliases: ["codex", "openai-codex", "openai_codex"],
|
|
96
|
-
dispatch: null, // No public fire API; stays on the copy-paste path.
|
|
97
|
-
snippetHint: "append to ~/.codex/config.toml",
|
|
98
|
-
restartHint: "Restart Codex so it re-reads its config.toml.",
|
|
99
|
-
},
|
|
100
|
-
];
|
|
101
|
-
|
|
102
|
-
function findAgent(value) {
|
|
103
|
-
const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
|
|
104
|
-
return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function onPath(binary) {
|
|
108
|
-
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
|
|
109
|
-
stdio: "ignore",
|
|
110
|
-
});
|
|
111
|
-
return probe.status === 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Why this agent's CLI cannot run unattended right now, or "".
|
|
116
|
-
*
|
|
117
|
-
* Installed is not the same as usable. `cursor-agent` sits on PATH and exits 1
|
|
118
|
-
* on every `-p` run until someone signs in, which surfaced as "Cursor did not
|
|
119
|
-
* register any endpoints" and sent people looking at PreMan for an hour. Only
|
|
120
|
-
* Cursor is probed because only its CLI has a cheap non-interactive status
|
|
121
|
-
* subcommand; the others are diagnosed from their own output when they fail.
|
|
122
|
-
*/
|
|
123
|
-
export function agentBlocker(agentId) {
|
|
124
|
-
if (agentId !== "cursor") return "";
|
|
125
|
-
const probe = spawnSync("cursor-agent", ["status"], {
|
|
126
|
-
encoding: "utf8",
|
|
127
|
-
timeout: 15000,
|
|
128
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
129
|
-
});
|
|
130
|
-
const text = `${probe.stdout || ""}${probe.stderr || ""}`;
|
|
131
|
-
if (/not logged in|not authenticated|no active session/i.test(text)) {
|
|
132
|
-
return "cursor-agent is installed but not signed in — run `cursor-agent login`";
|
|
133
|
-
}
|
|
134
|
-
return "";
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Best guess at which agent this machine actually uses, for the default pick. */
|
|
138
|
-
function detectAgents() {
|
|
139
|
-
const home = os.homedir();
|
|
140
|
-
return {
|
|
141
|
-
cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
|
|
142
|
-
claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
|
|
143
|
-
codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* The agent whose session this command is running inside, if any.
|
|
149
|
-
*
|
|
150
|
-
* A better default than "first one installed": someone who types this into an
|
|
151
|
-
* agent's terminal almost always means that agent, and on a machine with all
|
|
152
|
-
* three installed the detected-order default is usually wrong.
|
|
153
|
-
*/
|
|
154
|
-
export function runningInside(env = process.env) {
|
|
155
|
-
if (env.CLAUDECODE || env.CLAUDE_CODE) return "claude_code";
|
|
156
|
-
if (env.CURSOR_TRACE_ID) return "cursor";
|
|
157
|
-
return "";
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Is PreMan's MCP server already written into this agent's config? */
|
|
161
|
-
function agentIsLinked(agentId, serverName = "preman") {
|
|
162
|
-
try {
|
|
163
|
-
const home = os.homedir();
|
|
164
|
-
if (agentId === "cursor") {
|
|
165
|
-
return Boolean(readJsonFile(path.join(home, ".cursor", "mcp.json")).mcpServers?.[serverName]);
|
|
166
|
-
}
|
|
167
|
-
if (agentId === "claude_code") {
|
|
168
|
-
return Boolean(readJsonFile(path.join(home, ".claude.json")).mcpServers?.[serverName]);
|
|
169
|
-
}
|
|
170
|
-
if (agentId === "codex") {
|
|
171
|
-
const file = path.join(process.env.CODEX_HOME || path.join(home, ".codex"), "config.toml");
|
|
172
|
-
return existsSync(file) && readFileSync(file, "utf8").includes(`[mcp_servers.${serverName}]`);
|
|
173
|
-
}
|
|
174
|
-
} catch {
|
|
175
|
-
// An unreadable config is not an answer; fall through to the other signals.
|
|
176
|
-
}
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Which agent this machine should pair as, or "" when only a person can say.
|
|
182
|
-
*
|
|
183
|
-
* Pairing needs an agent id, and demanding `--agent` for it turned "start the
|
|
184
|
-
* runner" into a command someone had to be told twice. The signals are ordered
|
|
185
|
-
* by how much they actually know: the session we are inside, then the agent
|
|
186
|
-
* PreMan is already configured in, then the only one installed. Asking is the
|
|
187
|
-
* last resort rather than the first, and a machine with no terminal to ask in
|
|
188
|
-
* gets "" so the caller can fail with a sentence instead of hanging on a prompt.
|
|
189
|
-
*/
|
|
190
|
-
export async function resolveAgentForPairing({ interactive = Boolean(process.stdin.isTTY) } = {}) {
|
|
191
|
-
const inside = runningInside();
|
|
192
|
-
if (inside) return inside;
|
|
193
|
-
|
|
194
|
-
const linked = AGENTS.filter((agent) => agentIsLinked(agent.id));
|
|
195
|
-
if (linked.length === 1) return linked[0].id;
|
|
196
|
-
|
|
197
|
-
const detected = detectAgents();
|
|
198
|
-
const present = AGENTS.filter((agent) => detected[agent.id]);
|
|
199
|
-
if (present.length === 1) return present[0].id;
|
|
200
|
-
|
|
201
|
-
if (!interactive) return "";
|
|
202
|
-
const picked = await promptAgentChoice(detected);
|
|
203
|
-
return picked?.id || "";
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function promptAgentChoice(detected, insideId = runningInside()) {
|
|
207
|
-
process.stdout.write("Which coding agent?\n");
|
|
208
|
-
AGENTS.forEach((agent, index) => {
|
|
209
|
-
let mark = "";
|
|
210
|
-
if (agent.id === insideId) mark = " (this session)";
|
|
211
|
-
else if (detected[agent.id]) mark = " (detected)";
|
|
212
|
-
process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
const inside = AGENTS.findIndex((a) => a.id === insideId);
|
|
216
|
-
const defaultIndex =
|
|
217
|
-
inside >= 0
|
|
218
|
-
? inside
|
|
219
|
-
: Math.max(
|
|
220
|
-
0,
|
|
221
|
-
AGENTS.findIndex((a) => detected[a.id])
|
|
222
|
-
);
|
|
223
|
-
const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
|
|
224
|
-
if (!answer) return AGENTS[defaultIndex];
|
|
225
|
-
|
|
226
|
-
const byNumber = Number.parseInt(answer, 10);
|
|
227
|
-
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
|
|
228
|
-
return AGENTS[byNumber - 1];
|
|
229
|
-
}
|
|
230
|
-
const byName = findAgent(answer);
|
|
231
|
-
if (byName) return byName;
|
|
232
|
-
throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ── Config writers ──────────────────────────────────────────────────────
|
|
236
|
-
|
|
237
|
-
function cursorConfigPath(projectInstall) {
|
|
238
|
-
return projectInstall
|
|
239
|
-
? path.join(process.cwd(), ".cursor", "mcp.json")
|
|
240
|
-
: path.join(os.homedir(), ".cursor", "mcp.json");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function writeCursorConfig({ serverName, serverConfig, projectInstall }) {
|
|
244
|
-
const configPath = cursorConfigPath(projectInstall);
|
|
245
|
-
const config = { ...readJsonFile(configPath) };
|
|
246
|
-
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
247
|
-
writeJsonFile(configPath, config);
|
|
248
|
-
return { path: configPath, how: "wrote" };
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** The `claude mcp add` invocation, shared by the writer and the printed fallback. */
|
|
252
|
-
export function claudeMcpAddArgs(serverName, serverConfig, projectInstall) {
|
|
253
|
-
const envArgs = Object.entries(serverConfig.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
254
|
-
return [
|
|
255
|
-
"mcp",
|
|
256
|
-
"add",
|
|
257
|
-
serverName,
|
|
258
|
-
"--scope",
|
|
259
|
-
projectInstall ? "project" : "user",
|
|
260
|
-
...envArgs,
|
|
261
|
-
"--",
|
|
262
|
-
serverConfig.command,
|
|
263
|
-
...serverConfig.args,
|
|
264
|
-
];
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Claude Code owns ~/.claude.json, so prefer its own CLI. Fall back to writing
|
|
269
|
-
* the config directly when `claude` is not installed — a user can connect before
|
|
270
|
-
* installing the agent.
|
|
271
|
-
*/
|
|
272
|
-
export function writeClaudeConfig({ serverName, serverConfig, projectInstall }) {
|
|
273
|
-
if (onPath("claude")) {
|
|
274
|
-
const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
|
|
275
|
-
try {
|
|
276
|
-
execFileSync("claude", args, { stdio: "pipe" });
|
|
277
|
-
return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
|
|
278
|
-
} catch (error) {
|
|
279
|
-
const detail = error?.stderr?.toString().trim() || error.message;
|
|
280
|
-
process.stdout.write(`Note: claude mcp add failed (${detail}); writing the config directly.\n`);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (projectInstall) {
|
|
285
|
-
const configPath = path.join(process.cwd(), ".mcp.json");
|
|
286
|
-
const config = { ...readJsonFile(configPath) };
|
|
287
|
-
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
288
|
-
writeJsonFile(configPath, config);
|
|
289
|
-
return { path: configPath, how: "wrote" };
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// Merge only mcpServers: the user config holds a lot of unrelated state.
|
|
293
|
-
// CLAUDE_CONFIG_DIR relocates that file, and writing to ~ when it is set
|
|
294
|
-
// produces a config Claude Code never reads.
|
|
295
|
-
const configPath = path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
|
|
296
|
-
const config = { ...readJsonFile(configPath) };
|
|
297
|
-
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
298
|
-
writeJsonFile(configPath, config);
|
|
299
|
-
return { path: configPath, how: "wrote" };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Render the `[mcp_servers.<name>]` block for Codex's TOML config. Only strings
|
|
304
|
-
* and string arrays appear here, so a hand-rolled writer is enough — no TOML
|
|
305
|
-
* dependency for one fixed shape.
|
|
306
|
-
*/
|
|
307
|
-
export function renderCodexToml(serverName, serverConfig) {
|
|
308
|
-
const str = (value) => JSON.stringify(String(value));
|
|
309
|
-
const lines = [
|
|
310
|
-
`[mcp_servers.${serverName}]`,
|
|
311
|
-
`command = ${str(serverConfig.command)}`,
|
|
312
|
-
`args = [${serverConfig.args.map(str).join(", ")}]`,
|
|
313
|
-
];
|
|
314
|
-
const env = Object.entries(serverConfig.env);
|
|
315
|
-
if (env.length) {
|
|
316
|
-
lines.push("", `[mcp_servers.${serverName}.env]`);
|
|
317
|
-
for (const [key, value] of env) lines.push(`${key} = ${str(value)}`);
|
|
318
|
-
}
|
|
319
|
-
return `${lines.join("\n")}\n`;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/** Replace an existing preman block, or append a new one, leaving the rest alone. */
|
|
323
|
-
export function upsertCodexToml(existing, serverName, block) {
|
|
324
|
-
const header = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`, "m");
|
|
325
|
-
if (!header.test(existing)) {
|
|
326
|
-
const separator = existing.trim() ? "\n" : "";
|
|
327
|
-
return `${existing.trimEnd()}${separator}\n${block}`;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
const lines = existing.split("\n");
|
|
331
|
-
const out = [];
|
|
332
|
-
let skipping = false;
|
|
333
|
-
for (const line of lines) {
|
|
334
|
-
const isOurHeader = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`).test(line);
|
|
335
|
-
if (isOurHeader) {
|
|
336
|
-
skipping = true;
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
if (skipping && /^\[/.test(line) && !isOurHeader) skipping = false;
|
|
340
|
-
if (!skipping) out.push(line);
|
|
341
|
-
}
|
|
342
|
-
return `${out.join("\n").trimEnd()}\n\n${block}`;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function writeCodexConfig({ serverName, serverConfig }) {
|
|
346
|
-
const configPath = path.join(
|
|
347
|
-
process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
|
|
348
|
-
"config.toml"
|
|
349
|
-
);
|
|
350
|
-
const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
351
|
-
const next = upsertCodexToml(existing, serverName, renderCodexToml(serverName, serverConfig));
|
|
352
|
-
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
353
|
-
writeFileSync(configPath, next, { mode: 0o600 });
|
|
354
|
-
chmodSync(configPath, 0o600); // mode above is ignored for an existing file
|
|
355
|
-
return { path: configPath, how: "wrote" };
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
const WRITERS = {
|
|
359
|
-
cursor: writeCursorConfig,
|
|
360
|
-
claude_code: writeClaudeConfig,
|
|
361
|
-
codex: writeCodexConfig,
|
|
362
|
-
};
|
|
363
|
-
|
|
364
|
-
// ── Copy-paste fallbacks ────────────────────────────────────────────────
|
|
365
|
-
|
|
366
|
-
/** Quote only what a shell would otherwise mangle, so the line stays readable. */
|
|
367
|
-
function shellQuote(value) {
|
|
368
|
-
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${String(value).replace(/'/g, `'\\''`)}'`;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** What a user would paste by hand to get exactly what the writer would have written. */
|
|
372
|
-
export function renderAgentSnippet(agentId, serverName, serverConfig, { projectInstall = false } = {}) {
|
|
373
|
-
if (agentId === "codex") return renderCodexToml(serverName, serverConfig);
|
|
374
|
-
if (agentId === "claude_code") {
|
|
375
|
-
const args = claudeMcpAddArgs(serverName, serverConfig, projectInstall);
|
|
376
|
-
return `claude ${args.map(shellQuote).join(" ")}\n`;
|
|
377
|
-
}
|
|
378
|
-
return `${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
/** Every agent's snippet, for when we could not pick one for the user. */
|
|
382
|
-
export function renderAllAgentSnippets(args, serverName, { projectInstall = false } = {}) {
|
|
383
|
-
// Build config WITHOUT resolved API key to avoid leaking secrets in CI logs.
|
|
384
|
-
// Users must supply --api-key or set PREMAN_API_KEY separately.
|
|
385
|
-
const env = {
|
|
386
|
-
PREMAN_BACKEND: backendUrl(args),
|
|
387
|
-
PREMAN_FRONTEND: frontendUrl(args),
|
|
388
|
-
};
|
|
389
|
-
const serverConfig = {
|
|
390
|
-
command: LAUNCHER_COMMAND,
|
|
391
|
-
args: [...LAUNCHER_ARGS],
|
|
392
|
-
env,
|
|
393
|
-
};
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
return AGENTS.map((agent) => {
|
|
397
|
-
// Adjust hint based on projectInstall, matching verifyWrittenConfig logic
|
|
398
|
-
let hint = agent.snippetHint;
|
|
399
|
-
if (agent.id === "cursor" && projectInstall) {
|
|
400
|
-
hint = "merge into .cursor/mcp.json";
|
|
401
|
-
} else if (agent.id === "claude_code") {
|
|
402
|
-
hint = projectInstall ? "run (with --scope project):" : "run:";
|
|
403
|
-
}
|
|
404
|
-
return `# ${agent.label} — ${hint}\n` + renderAgentSnippet(agent.id, serverName, serverConfig, { projectInstall });
|
|
405
|
-
}).join("\n");
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ── Post-write validation ───────────────────────────────────────────────
|
|
409
|
-
|
|
410
|
-
/** The lines belonging to our `[mcp_servers.<name>]` block, or null if absent. */
|
|
411
|
-
function codexBlockLines(text, serverName) {
|
|
412
|
-
const lines = text.split("\n");
|
|
413
|
-
const start = lines.findIndex((line) => line.trim() === `[mcp_servers.${serverName}]`);
|
|
414
|
-
if (start === -1) return null;
|
|
415
|
-
const block = [];
|
|
416
|
-
for (let i = start + 1; i < lines.length; i += 1) {
|
|
417
|
-
const line = lines[i];
|
|
418
|
-
if (/^\[/.test(line) && !line.startsWith(`[mcp_servers.${serverName}.`)) break;
|
|
419
|
-
block.push(line);
|
|
420
|
-
}
|
|
421
|
-
return block;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Read back what we just wrote, so a silently-failed write is not reported as a
|
|
426
|
-
* success. Proves the entry is on disk (or that Claude Code knows about it) —
|
|
427
|
-
* whether the agent has actually loaded it is only ever proven by a check-in.
|
|
428
|
-
*
|
|
429
|
-
* Never throws: a verification that cannot run must not fail the connect. An
|
|
430
|
-
* unreadable state reports "unknown" and stays quiet, because a wrong warning
|
|
431
|
-
* costs more trust than a missing one.
|
|
432
|
-
*/
|
|
433
|
-
export function verifyWrittenConfig(agent, { serverName, written }) {
|
|
434
|
-
try {
|
|
435
|
-
if (agent.id === "codex") {
|
|
436
|
-
const text = written.path && existsSync(written.path) ? readFileSync(written.path, "utf8") : "";
|
|
437
|
-
const block = codexBlockLines(text, serverName);
|
|
438
|
-
if (!block) {
|
|
439
|
-
return { status: "mismatch", detail: `no [mcp_servers.${serverName}] block in ${written.path}` };
|
|
440
|
-
}
|
|
441
|
-
if (!block.some((line) => line.trim() === `command = "${LAUNCHER_COMMAND}"`)) {
|
|
442
|
-
return {
|
|
443
|
-
status: "mismatch",
|
|
444
|
-
detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
return { status: "verified" };
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Claude Code registered the server itself — ask its CLI what it ended up with.
|
|
451
|
-
if (agent.id === "claude_code" && written.how !== "wrote") {
|
|
452
|
-
const probe = spawnSync("claude", ["mcp", "get", serverName], { stdio: "ignore" });
|
|
453
|
-
if (probe.error) return { status: "unknown", detail: "could not run claude" };
|
|
454
|
-
return probe.status === 0
|
|
455
|
-
? { status: "verified" }
|
|
456
|
-
: { status: "mismatch", detail: `claude mcp get ${serverName} did not find the server` };
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const entry = readJsonFile(written.path).mcpServers?.[serverName];
|
|
460
|
-
if (!entry) return { status: "mismatch", detail: `${serverName} is missing from ${written.path}` };
|
|
461
|
-
if (entry.command !== LAUNCHER_COMMAND) {
|
|
462
|
-
return {
|
|
463
|
-
status: "mismatch",
|
|
464
|
-
detail: `${written.path} does not launch ${LAUNCHER_COMMAND}`,
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
return { status: "verified" };
|
|
468
|
-
} catch (error) {
|
|
469
|
-
return { status: "unknown", detail: error.message };
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
* Mint the pair code, distinguishing "could not reach it" from "this key is dead".
|
|
477
|
-
*
|
|
478
|
-
* 401 is the backend saying the key is invalid or revoked, which is the one
|
|
479
|
-
* failure the caller must not shrug off: every other outcome still leaves a
|
|
480
|
-
* usable config, but writing a revoked key into an agent's config produces a
|
|
481
|
-
* setup that looks connected and fails on every call.
|
|
482
|
-
*/
|
|
483
|
-
async function startPairing(args, agent, apiKey) {
|
|
484
|
-
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
|
|
485
|
-
token: apiKey,
|
|
486
|
-
json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
|
|
487
|
-
});
|
|
488
|
-
if (result.status_code === 401) return { pairCode: "", stale: true };
|
|
489
|
-
if (!result.ok) {
|
|
490
|
-
process.stdout.write(
|
|
491
|
-
`Note: could not start pairing (${result.status_code}); the config still works, ` +
|
|
492
|
-
"your agent will link on its first PreMan call.\n"
|
|
493
|
-
);
|
|
494
|
-
return { pairCode: "", stale: false };
|
|
495
|
-
}
|
|
496
|
-
return { pairCode: String(result.pair_code || ""), stale: false };
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
/**
|
|
500
|
-
* The --no-pair stand-in for the auth check pairing performs implicitly.
|
|
501
|
-
*
|
|
502
|
-
* Declining a pair code should not also decline noticing the key is dead,
|
|
503
|
-
* otherwise this flag writes exactly the broken config pairing would catch.
|
|
504
|
-
*/
|
|
505
|
-
async function verifyKey(args, apiKey) {
|
|
506
|
-
const result = await callBackendJson(args, "GET", "/auth/me", { token: apiKey });
|
|
507
|
-
return { pairCode: "", stale: result.status_code === 401 };
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/** Whichever of the two ways to learn the backend still accepts this key. */
|
|
511
|
-
async function checkKeyAndPair(args, agent, apiKey) {
|
|
512
|
-
return args.has("--no-pair")
|
|
513
|
-
? verifyKey(args, apiKey)
|
|
514
|
-
: startPairing(args, agent, apiKey);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Replace credentials the backend has rejected, and return the new key.
|
|
519
|
-
*
|
|
520
|
-
* Only stored credentials can be replaced here: an explicit --api-key or
|
|
521
|
-
* PREMAN_API_KEY would just be re-read, so those say so and stop rather than
|
|
522
|
-
* looping. Refusing to continue is deliberate — the alternative is writing a
|
|
523
|
-
* key we know is dead into the agent's config.
|
|
524
|
-
*/
|
|
525
|
-
async function refreshStaleCredentials(args) {
|
|
526
|
-
if (apiKeyIsExplicit(args)) {
|
|
527
|
-
const source = args.value("--api-key", "") ? "--api-key" : "PREMAN_API_KEY";
|
|
528
|
-
throw new ConnectError(
|
|
529
|
-
`The PreMan API key from ${source} is invalid or revoked. Create a new one in the ` +
|
|
530
|
-
`dashboard, or drop ${source} and run '${cliInvocation()} connect' to sign in.`,
|
|
531
|
-
EXIT_USAGE
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
if (args.has("--skip-login") || !process.stdin.isTTY) {
|
|
535
|
-
throw new ConnectError(
|
|
536
|
-
"Saved PreMan credentials are invalid or revoked. Run " +
|
|
537
|
-
`'${cliInvocation()} logout', then connect again to sign in.`,
|
|
538
|
-
EXIT_USAGE
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
process.stdout.write(
|
|
543
|
-
"\nYour saved PreMan credentials are no longer valid — the key was revoked, or the\n" +
|
|
544
|
-
"account behind it was removed. Signing you in again.\n"
|
|
545
|
-
);
|
|
546
|
-
clearStoredCredentials();
|
|
547
|
-
await authenticateTerminal(args);
|
|
548
|
-
process.stdout.write("\n");
|
|
549
|
-
return resolveApiKey(args);
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
export async function waitForConnection(
|
|
553
|
-
args,
|
|
554
|
-
apiKey,
|
|
555
|
-
{
|
|
556
|
-
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
557
|
-
timeoutMs = Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000,
|
|
558
|
-
stopWhen = null,
|
|
559
|
-
// Called once per unsuccessful poll, so a wait measured in minutes can show
|
|
560
|
-
// that it is still a wait rather than a hang.
|
|
561
|
-
onPoll = null,
|
|
562
|
-
} = {}
|
|
563
|
-
) {
|
|
564
|
-
const deadline = Date.now() + timeoutMs;
|
|
565
|
-
let interrupted = false;
|
|
566
|
-
const onInterrupt = () => {
|
|
567
|
-
interrupted = true;
|
|
568
|
-
};
|
|
569
|
-
process.on("SIGINT", onInterrupt);
|
|
570
|
-
|
|
571
|
-
try {
|
|
572
|
-
while (Date.now() < deadline && !interrupted) {
|
|
573
|
-
const status = await callBackendJson(args, "GET", "/workbench/coding-agent", {
|
|
574
|
-
token: apiKey,
|
|
575
|
-
});
|
|
576
|
-
if (status.ok && status.connected) return true;
|
|
577
|
-
if (stopWhen && stopWhen()) return false;
|
|
578
|
-
if (onPoll) onPoll();
|
|
579
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
580
|
-
}
|
|
581
|
-
} finally {
|
|
582
|
-
process.off("SIGINT", onInterrupt);
|
|
583
|
-
}
|
|
584
|
-
return false;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/** A dot per poll, and the newline that closes the run of them. */
|
|
588
|
-
function pollTicker() {
|
|
589
|
-
let dots = 0;
|
|
590
|
-
return {
|
|
591
|
-
tick() {
|
|
592
|
-
dots += 1;
|
|
593
|
-
process.stdout.write(".");
|
|
594
|
-
},
|
|
595
|
-
end() {
|
|
596
|
-
if (!dots) return;
|
|
597
|
-
dots = 0;
|
|
598
|
-
process.stdout.write("\n");
|
|
599
|
-
},
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// ── Auto check-in ───────────────────────────────────────────────────────
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* How to ask each agent to make one PreMan call without opening its UI.
|
|
607
|
-
*
|
|
608
|
-
* The link is established by the agent's first call, so anything that reaches
|
|
609
|
-
* `preman_status` finishes the connect. Claude Code needs the server on its
|
|
610
|
-
* allow-list because print mode refuses un-allowed MCP tools rather than
|
|
611
|
-
* prompting.
|
|
612
|
-
*/
|
|
613
|
-
export function headlessCheckIn(agent, serverName) {
|
|
614
|
-
const prompt = checkInPrompt(serverName);
|
|
615
|
-
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
616
|
-
if (agent.id === "claude_code") {
|
|
617
|
-
return { bin: "claude", args: ["-p", prompt, "--allowedTools", `mcp__${serverName}`] };
|
|
618
|
-
}
|
|
619
|
-
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
620
|
-
return null;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function checkInPrompt(serverName) {
|
|
624
|
-
return `Call the ${serverName} MCP tool preman_status and report the result.`;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* How to start each agent as the session the user actually works in.
|
|
629
|
-
*
|
|
630
|
-
* Print mode (`-p`, `codex exec`) answers once and exits: it is a batch call, not
|
|
631
|
-
* the agent anybody goes on to use, and its failures are invisible because
|
|
632
|
-
* nobody is looking at it. The interactive form is the same prompt without that
|
|
633
|
-
* flag — it needs a terminal of its own, which is what openInNewTerminal is for.
|
|
634
|
-
*/
|
|
635
|
-
export function interactiveCheckIn(agent, serverName) {
|
|
636
|
-
const prompt = checkInPrompt(serverName);
|
|
637
|
-
if (agent.id === "cursor") return { bin: "cursor-agent", args: [prompt] };
|
|
638
|
-
if (agent.id === "claude_code") {
|
|
639
|
-
return { bin: "claude", args: ["--allowedTools", `mcp__${serverName}`, prompt] };
|
|
640
|
-
}
|
|
641
|
-
if (agent.id === "codex") return { bin: "codex", args: [prompt] };
|
|
642
|
-
return null;
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
/** A command line for a spec, quoted for the shell the terminal will start. */
|
|
646
|
-
export function commandLine(spec) {
|
|
647
|
-
return [spec.bin, ...spec.args].map(shellQuote).join(" ");
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
/** Escape a shell line into an AppleScript string literal. */
|
|
651
|
-
function appleScriptString(value) {
|
|
652
|
-
return `"${String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
/**
|
|
656
|
-
* The terminal emulator to open a window in, or null when we know of none.
|
|
657
|
-
*
|
|
658
|
-
* `sync` marks the launchers that hand the work to an app and return — those
|
|
659
|
-
* report their own failure through an exit code, which is worth waiting for.
|
|
660
|
-
* The rest *are* the window, so they are spawned detached and outlive us.
|
|
661
|
-
*/
|
|
662
|
-
function terminalLauncher(line) {
|
|
663
|
-
if (process.platform === "darwin") {
|
|
664
|
-
const iterm = existsSync("/Applications/iTerm.app");
|
|
665
|
-
const script = iterm
|
|
666
|
-
? `tell application "iTerm"\nactivate\nset w to (create window with default profile)\ntell current session of w to write text ${appleScriptString(line)}\nend tell`
|
|
667
|
-
: `tell application "Terminal"\nactivate\ndo script ${appleScriptString(line)}\nend tell`;
|
|
668
|
-
return { bin: "osascript", args: ["-e", script], label: iterm ? "iTerm" : "Terminal", sync: true };
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
if (process.platform === "win32") {
|
|
672
|
-
return {
|
|
673
|
-
bin: "cmd.exe",
|
|
674
|
-
args: ["/c", "start", "cmd.exe", "/k", line],
|
|
675
|
-
label: "Command Prompt",
|
|
676
|
-
sync: true,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
// Keep the shell alive afterwards: an agent that exits immediately would
|
|
681
|
-
// otherwise take its own error message off the screen with it.
|
|
682
|
-
const body = `${line}; exec ${process.env.SHELL || "sh"}`;
|
|
683
|
-
const candidates = [
|
|
684
|
-
{ bin: "x-terminal-emulator", args: ["-e", "sh", "-c", body], label: "terminal" },
|
|
685
|
-
{ bin: "gnome-terminal", args: ["--", "sh", "-c", body], label: "GNOME Terminal" },
|
|
686
|
-
{ bin: "konsole", args: ["-e", "sh", "-c", body], label: "Konsole" },
|
|
687
|
-
{ bin: "xterm", args: ["-e", "sh", "-c", body], label: "xterm" },
|
|
688
|
-
];
|
|
689
|
-
return candidates.find((candidate) => onPath(candidate.bin)) || null;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/**
|
|
693
|
-
* Open `command` in a new terminal window, best effort.
|
|
694
|
-
*
|
|
695
|
-
* Same contract as openUrl: never throws, and declines rather than guessing when
|
|
696
|
-
* nobody is watching — `PREMAN_NO_TERMINAL` for an explicit no, and a non-TTY
|
|
697
|
-
* stdout for CI, piped output and test runs. Callers fall back to a headless run.
|
|
698
|
-
*/
|
|
699
|
-
export function openInNewTerminal(command, options = {}) {
|
|
700
|
-
const cwd = options?.cwd || process.cwd();
|
|
701
|
-
const optOut = (process.env.PREMAN_NO_TERMINAL || "").trim().toLowerCase();
|
|
702
|
-
if (optOut && !["0", "false", "no"].includes(optOut)) {
|
|
703
|
-
return { opened: false, reason: "PREMAN_NO_TERMINAL is set" };
|
|
704
|
-
}
|
|
705
|
-
if (!process.stdout.isTTY) return { opened: false, reason: "not running in a terminal" };
|
|
706
|
-
|
|
707
|
-
const launcher = terminalLauncher(`cd ${shellQuote(cwd)} && ${command}`);
|
|
708
|
-
if (!launcher) return { opened: false, reason: "no terminal emulator found" };
|
|
709
|
-
|
|
710
|
-
try {
|
|
711
|
-
if (launcher.sync) {
|
|
712
|
-
const done = spawnSync(launcher.bin, launcher.args, { stdio: "ignore", timeout: 20000 });
|
|
713
|
-
if (done.error) return { opened: false, reason: done.error.message };
|
|
714
|
-
if (done.status !== 0) {
|
|
715
|
-
return { opened: false, reason: `${launcher.bin} exited with code ${done.status}` };
|
|
716
|
-
}
|
|
717
|
-
} else {
|
|
718
|
-
const child = spawn(launcher.bin, launcher.args, { stdio: "ignore", detached: true });
|
|
719
|
-
child.unref();
|
|
720
|
-
}
|
|
721
|
-
return { opened: true, terminal: launcher.label };
|
|
722
|
-
} catch (error) {
|
|
723
|
-
return { opened: false, reason: error.message };
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
/**
|
|
728
|
-
* Finish the link ourselves instead of asking the user to go restart their agent.
|
|
729
|
-
*
|
|
730
|
-
* The config on disk is already correct at this point; all that is missing is
|
|
731
|
-
* one call from the agent, and telling someone to make it from another terminal
|
|
732
|
-
* is a dead end in the one terminal they are sitting in.
|
|
733
|
-
*
|
|
734
|
-
* So start the agent. Interactively, in a window of its own, because that is the
|
|
735
|
-
* session the user keeps: a print-mode run answers once, exits, and leaves them
|
|
736
|
-
* exactly where they started. Only when no window can be opened — CI, SSH, a
|
|
737
|
-
* machine with no terminal emulator — does this fall back to the headless run,
|
|
738
|
-
* which still finishes the link even though nobody sees it happen.
|
|
739
|
-
*
|
|
740
|
-
* `cwd` is where the agent starts, and it is load-bearing rather than tidiness:
|
|
741
|
-
* the MCP server reads its repo config out of the working directory, so starting
|
|
742
|
-
* the agent elsewhere is how a caller escapes a directory that would otherwise
|
|
743
|
-
* redirect it to a backend this key was never issued for.
|
|
744
|
-
*
|
|
745
|
-
* Returns `ran: false` when the agent's binary is absent or will not start, and
|
|
746
|
-
* the caller falls back to the printed instructions.
|
|
747
|
-
*/
|
|
748
|
-
export async function autoCheckIn(
|
|
749
|
-
args,
|
|
750
|
-
agent,
|
|
751
|
-
apiKey,
|
|
752
|
-
{
|
|
753
|
-
// Never outlast the connect's own wait budget: this phase is part of it, not
|
|
754
|
-
// an extra one bolted on the front.
|
|
755
|
-
timeoutMs = Math.min(
|
|
756
|
-
Number(process.env.PREMAN_AUTO_CHECKIN_MS) || 120000,
|
|
757
|
-
Number(process.env.PREMAN_CONNECT_WAIT_MS) || 300000
|
|
758
|
-
),
|
|
759
|
-
serverName = "preman",
|
|
760
|
-
intervalMs = Number(process.env.PREMAN_CONNECT_POLL_MS) || 3000,
|
|
761
|
-
cwd = process.cwd(),
|
|
762
|
-
onLaunch = () => {},
|
|
763
|
-
onPoll = null,
|
|
764
|
-
} = {}
|
|
765
|
-
) {
|
|
766
|
-
const session = interactiveCheckIn(agent, serverName);
|
|
767
|
-
if (session && onPath(session.bin)) {
|
|
768
|
-
const { opened, terminal } = openInNewTerminal(commandLine(session), { cwd });
|
|
769
|
-
if (opened) {
|
|
770
|
-
onLaunch({ mode: "interactive", bin: session.bin, terminal });
|
|
771
|
-
// Detached, so there is no exit to watch for and no output to quote: the
|
|
772
|
-
// agent is in front of the user now, and the deadline is all that bounds
|
|
773
|
-
// this. Whoever is watching the window can Ctrl+C out of the wait.
|
|
774
|
-
const connected = await waitForConnection(args, apiKey, { intervalMs, timeoutMs, onPoll });
|
|
775
|
-
return { ran: true, connected, command: session.bin, interactive: true, terminal };
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
const spec = headlessCheckIn(agent, serverName);
|
|
780
|
-
if (!spec) return { ran: false, connected: false, reason: "no headless mode" };
|
|
781
|
-
if (!onPath(spec.bin)) return { ran: false, connected: false, reason: `${spec.bin} is not on PATH` };
|
|
782
|
-
|
|
783
|
-
let child;
|
|
784
|
-
try {
|
|
785
|
-
// Piped rather than ignored: an agent that runs and does not check in used to
|
|
786
|
-
// report exactly that and nothing else, which is the least useful sentence
|
|
787
|
-
// available. Its own last words usually name the cause.
|
|
788
|
-
child = spawn(spec.bin, spec.args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
789
|
-
} catch (error) {
|
|
790
|
-
return { ran: false, connected: false, reason: error.message };
|
|
791
|
-
}
|
|
792
|
-
onLaunch({ mode: "headless", bin: spec.bin });
|
|
793
|
-
|
|
794
|
-
let spawnError = null;
|
|
795
|
-
let exitedAt = 0;
|
|
796
|
-
let output = "";
|
|
797
|
-
const absorb = (chunk) => {
|
|
798
|
-
output = `${output}${chunk}`.slice(-4000);
|
|
799
|
-
};
|
|
800
|
-
child.stdout?.setEncoding("utf8");
|
|
801
|
-
child.stderr?.setEncoding("utf8");
|
|
802
|
-
child.stdout?.on("data", absorb);
|
|
803
|
-
child.stderr?.on("data", absorb);
|
|
804
|
-
child.on("error", (error) => {
|
|
805
|
-
spawnError = error;
|
|
806
|
-
exitedAt = exitedAt || Date.now();
|
|
807
|
-
});
|
|
808
|
-
child.on("exit", () => {
|
|
809
|
-
exitedAt = exitedAt || Date.now();
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
// The check-in can land moments after the agent's own process ends, so keep
|
|
813
|
-
// polling briefly past its exit rather than declaring failure at the edge.
|
|
814
|
-
const grace = intervalMs * 2;
|
|
815
|
-
try {
|
|
816
|
-
const connected = await waitForConnection(args, apiKey, {
|
|
817
|
-
intervalMs,
|
|
818
|
-
timeoutMs,
|
|
819
|
-
onPoll,
|
|
820
|
-
stopWhen: () => Boolean(exitedAt) && Date.now() - exitedAt > grace,
|
|
821
|
-
});
|
|
822
|
-
if (spawnError && !connected) {
|
|
823
|
-
return { ran: false, connected: false, reason: spawnError.message, output };
|
|
824
|
-
}
|
|
825
|
-
return { ran: true, connected, command: spec.bin, output };
|
|
826
|
-
} finally {
|
|
827
|
-
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
/** The last non-empty line of an agent's output, for a one-line diagnosis. */
|
|
832
|
-
export function lastLine(output, cap = 200) {
|
|
833
|
-
const lines = String(output || "")
|
|
834
|
-
.split("\n")
|
|
835
|
-
.map((line) => line.trim())
|
|
836
|
-
.filter(Boolean);
|
|
837
|
-
return lines.length ? lines[lines.length - 1].slice(0, cap) : "";
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// ── MCP self-test ───────────────────────────────────────────────────────
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* How long to give the self-test, and whether to run it at all.
|
|
844
|
-
*
|
|
845
|
-
* `PREMAN_SELFTEST_MS=0` turns it off for a whole process, which is what a test
|
|
846
|
-
* run wants: the launcher is `npm exec premanmcp@latest`, so every case that
|
|
847
|
-
* reaches this would otherwise go to the registry.
|
|
848
|
-
*/
|
|
849
|
-
export function selfTestBudgetMs() {
|
|
850
|
-
const raw = process.env.PREMAN_SELFTEST_MS;
|
|
851
|
-
if (raw === undefined || raw === "") return 60000;
|
|
852
|
-
const parsed = Number(raw);
|
|
853
|
-
return Number.isFinite(parsed) ? parsed : 60000;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Run the config we just wrote, exactly as the agent will, and call one tool.
|
|
858
|
-
*
|
|
859
|
-
* This is both the fastest way to finish the link and the only check that proves
|
|
860
|
-
* the whole chain — launcher, package, key, backend — rather than proving the
|
|
861
|
-
* file is on disk. It matters because the failure it catches is invisible
|
|
862
|
-
* otherwise: a repo-local `preman-mcp.config.json` (or a stale global env) can
|
|
863
|
-
* redirect the server to a backend the key was never issued for, and every
|
|
864
|
-
* symptom of that points at the key.
|
|
865
|
-
*
|
|
866
|
-
* Speaks the two JSON-RPC calls an MCP client makes on startup. Never throws.
|
|
867
|
-
*/
|
|
868
|
-
export async function mcpSelfTest(
|
|
869
|
-
serverConfig,
|
|
870
|
-
{ timeoutMs = selfTestBudgetMs(), cwd = process.cwd() } = {}
|
|
871
|
-
) {
|
|
872
|
-
if (timeoutMs <= 0) return { ok: false, reason: "self-test disabled" };
|
|
873
|
-
let child;
|
|
874
|
-
try {
|
|
875
|
-
child = spawn(serverConfig.command, serverConfig.args, {
|
|
876
|
-
cwd,
|
|
877
|
-
env: { ...process.env, ...serverConfig.env },
|
|
878
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
879
|
-
});
|
|
880
|
-
} catch (error) {
|
|
881
|
-
return { ok: false, reason: error.message };
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
return new Promise((resolve) => {
|
|
885
|
-
let stdout = "";
|
|
886
|
-
let stderr = "";
|
|
887
|
-
let settled = false;
|
|
888
|
-
|
|
889
|
-
const finish = (result) => {
|
|
890
|
-
if (settled) return;
|
|
891
|
-
settled = true;
|
|
892
|
-
clearTimeout(timer);
|
|
893
|
-
if (child.exitCode === null && child.signalCode === null) child.kill();
|
|
894
|
-
resolve(result);
|
|
895
|
-
};
|
|
896
|
-
|
|
897
|
-
const timer = setTimeout(
|
|
898
|
-
() => finish({ ok: false, reason: `the MCP server did not answer within ${Math.round(timeoutMs / 1000)}s`, stderr: lastLine(stderr) }),
|
|
899
|
-
timeoutMs
|
|
900
|
-
);
|
|
901
|
-
|
|
902
|
-
const send = (message) => {
|
|
903
|
-
try {
|
|
904
|
-
child.stdin.write(`${JSON.stringify(message)}\n`);
|
|
905
|
-
} catch (error) {
|
|
906
|
-
finish({ ok: false, reason: error.message });
|
|
907
|
-
}
|
|
908
|
-
};
|
|
909
|
-
|
|
910
|
-
child.on("error", (error) => finish({ ok: false, reason: error.message }));
|
|
911
|
-
child.on("exit", (code) =>
|
|
912
|
-
finish({
|
|
913
|
-
ok: false,
|
|
914
|
-
reason: `the MCP server exited with code ${code}`,
|
|
915
|
-
stderr: lastLine(stderr),
|
|
916
|
-
})
|
|
917
|
-
);
|
|
918
|
-
|
|
919
|
-
child.stdout.setEncoding("utf8");
|
|
920
|
-
child.stderr.setEncoding("utf8");
|
|
921
|
-
child.stderr.on("data", (chunk) => {
|
|
922
|
-
stderr = `${stderr}${chunk}`.slice(-4000);
|
|
923
|
-
});
|
|
924
|
-
child.stdout.on("data", (chunk) => {
|
|
925
|
-
stdout += chunk;
|
|
926
|
-
let newline = stdout.indexOf("\n");
|
|
927
|
-
while (newline !== -1) {
|
|
928
|
-
const line = stdout.slice(0, newline).trim();
|
|
929
|
-
stdout = stdout.slice(newline + 1);
|
|
930
|
-
newline = stdout.indexOf("\n");
|
|
931
|
-
if (!line.startsWith("{")) continue;
|
|
932
|
-
let message;
|
|
933
|
-
try {
|
|
934
|
-
message = JSON.parse(line);
|
|
935
|
-
} catch {
|
|
936
|
-
continue;
|
|
937
|
-
}
|
|
938
|
-
if (message.id === 1) {
|
|
939
|
-
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
940
|
-
send({
|
|
941
|
-
jsonrpc: "2.0",
|
|
942
|
-
id: 2,
|
|
943
|
-
method: "tools/call",
|
|
944
|
-
params: { name: "preman_status", arguments: {} },
|
|
945
|
-
});
|
|
946
|
-
continue;
|
|
947
|
-
}
|
|
948
|
-
if (message.id !== 2) continue;
|
|
949
|
-
const text = message.result?.content?.[0]?.text;
|
|
950
|
-
let status = {};
|
|
951
|
-
try {
|
|
952
|
-
status = text ? JSON.parse(text) : {};
|
|
953
|
-
} catch {
|
|
954
|
-
status = { raw: text };
|
|
955
|
-
}
|
|
956
|
-
finish({ ok: !message.error, status, reason: message.error?.message || "" });
|
|
957
|
-
}
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
send({
|
|
961
|
-
jsonrpc: "2.0",
|
|
962
|
-
id: 1,
|
|
963
|
-
method: "initialize",
|
|
964
|
-
params: {
|
|
965
|
-
protocolVersion: "2024-11-05",
|
|
966
|
-
capabilities: {},
|
|
967
|
-
clientInfo: { name: "preman-connect-selftest", version: "1" },
|
|
968
|
-
},
|
|
969
|
-
});
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
974
|
-
|
|
975
|
-
async function captureDispatchCredential(args, agent, apiKey, { prompt = true } = {}) {
|
|
976
|
-
if (!agent.dispatch) return;
|
|
977
|
-
if (args.has("--skip-dispatch-credential")) return;
|
|
978
|
-
|
|
979
|
-
let secret = args.value("--dispatch-credential", "");
|
|
980
|
-
let routineId = args.value("--routine-id", "");
|
|
981
|
-
|
|
982
|
-
if (!secret) {
|
|
983
|
-
if (!prompt || !process.stdin.isTTY) return;
|
|
984
|
-
// Framed as the expected step rather than an optional aside. Without it
|
|
985
|
-
// PreMan can only suggest fixes; with it, it can run them. Presenting it as
|
|
986
|
-
// "optional, press Enter to skip" meant almost everyone skipped the thing
|
|
987
|
-
// that makes the product act rather than advise.
|
|
988
|
-
process.stdout.write(
|
|
989
|
-
`\nOptional — let PreMan start ${agent.label} runs for you, so it can apply\n` +
|
|
990
|
-
`fixes and run checks on a schedule instead of only telling you what to do.\n` +
|
|
991
|
-
`Get the token from ${agent.dispatch.source}.\n`
|
|
992
|
-
);
|
|
993
|
-
secret = await promptSecret(`Paste your ${agent.dispatch.credential} (Enter to skip): `);
|
|
994
|
-
if (!secret) {
|
|
995
|
-
process.stdout.write(
|
|
996
|
-
`Skipped. Run '${cliInvocation()} dispatch --agent ${agent.id.replace("_", "-")}' when you have the token.\n`
|
|
997
|
-
);
|
|
998
|
-
return;
|
|
999
|
-
}
|
|
1000
|
-
if (agent.dispatch.needsRoutine && !routineId) {
|
|
1001
|
-
routineId = await promptText("Routine id or URL: ");
|
|
1002
|
-
}
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
if (agent.dispatch.needsRoutine && !routineId) {
|
|
1006
|
-
process.stdout.write("Skipped: a routine id is required alongside the token.\n");
|
|
1007
|
-
return;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
const body = agent.dispatch.needsRoutine
|
|
1011
|
-
? { provider: agent.id, secret, routine_id: extractRoutineId(routineId) }
|
|
1012
|
-
: { provider: agent.id, secret };
|
|
1013
|
-
|
|
1014
|
-
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent/dispatch", {
|
|
1015
|
-
token: apiKey,
|
|
1016
|
-
json: body,
|
|
1017
|
-
});
|
|
1018
|
-
|
|
1019
|
-
if (result.ok) {
|
|
1020
|
-
process.stdout.write("Cloud dispatch enabled — PreMan can now start runs for you.\n");
|
|
1021
|
-
} else if (result.status_code === 404 || result.status_code === 405) {
|
|
1022
|
-
process.stdout.write("Note: this PreMan backend does not support cloud dispatch yet; skipped.\n");
|
|
1023
|
-
} else {
|
|
1024
|
-
process.stdout.write(`Note: could not save the credential (${result.status_code}); skipped.\n`);
|
|
1025
|
-
}
|
|
1026
|
-
}
|
|
1027
|
-
|
|
1028
|
-
export const DISPATCH_HELP = `
|
|
1029
|
-
Dispatch options:
|
|
1030
|
-
--agent <name> cursor | claude-code (defaults to the connected agent)
|
|
1031
|
-
--dispatch-credential <t> Cloud-dispatch token (non-interactive)
|
|
1032
|
-
--routine-id <id> Claude Code routine id, with --dispatch-credential
|
|
1033
|
-
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
1034
|
-
--backend <url> PreMan backend URL
|
|
1035
|
-
`;
|
|
1036
|
-
|
|
1037
|
-
/**
|
|
1038
|
-
* `preman dispatch` — store the cloud-dispatch credential on its own.
|
|
1039
|
-
*
|
|
1040
|
-
* Exists so connect never has to ask for a token mid-onboarding. Someone who
|
|
1041
|
-
* skipped it (or did not have it yet) comes back here instead of re-running the
|
|
1042
|
-
* whole connect.
|
|
1043
|
-
*/
|
|
1044
|
-
export async function dispatchCommand(commandArgs) {
|
|
1045
|
-
const args = makeArgs(commandArgs);
|
|
1046
|
-
const apiKey = resolveApiKey(args);
|
|
1047
|
-
if (!apiKey) {
|
|
1048
|
-
throw new ConnectError(
|
|
1049
|
-
`No PreMan credentials. Run '${cliInvocation()} connect' first, or pass --api-key pm_live_….`,
|
|
1050
|
-
EXIT_USAGE
|
|
1051
|
-
);
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
let agent = findAgent(args.value("--agent", ""));
|
|
1055
|
-
if (!agent && args.value("--agent", "")) {
|
|
1056
|
-
throw new ConnectError(
|
|
1057
|
-
`Unknown agent: ${args.value("--agent", "")}. Use cursor or claude-code.`,
|
|
1058
|
-
EXIT_USAGE
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
if (!agent) {
|
|
1063
|
-
const current = await callBackendJson(args, "GET", "/workbench/coding-agent", { token: apiKey });
|
|
1064
|
-
agent = findAgent(current.ok ? current.agent : "");
|
|
1065
|
-
if (!agent) {
|
|
1066
|
-
throw new ConnectError(
|
|
1067
|
-
"Could not tell which agent to set up. Pass --agent cursor or --agent claude-code.",
|
|
1068
|
-
EXIT_USAGE
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
if (!agent.dispatch) {
|
|
1074
|
-
throw new ConnectError(`${agent.label} has no cloud-dispatch API yet.`, EXIT_USAGE);
|
|
1075
|
-
}
|
|
1076
|
-
if (!args.value("--dispatch-credential", "") && !process.stdin.isTTY) {
|
|
1077
|
-
throw new ConnectError(
|
|
1078
|
-
"preman dispatch needs a terminal, or --dispatch-credential <token>.",
|
|
1079
|
-
EXIT_USAGE
|
|
1080
|
-
);
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
await captureDispatchCredential(args, agent, apiKey);
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
/** Accept either a bare trig_… id or the routine URL it appears in. */
|
|
1087
|
-
export function extractRoutineId(value) {
|
|
1088
|
-
const raw = String(value || "").trim();
|
|
1089
|
-
const match = raw.match(/trig_[A-Za-z0-9]+/);
|
|
1090
|
-
return match ? match[0] : raw;
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
// ── Guided first run ────────────────────────────────────────────────────
|
|
1094
|
-
|
|
1095
|
-
const MANUAL_STEPS =
|
|
1096
|
-
" preman endpoints discover # brief for your agent → endpoints.json\n" +
|
|
1097
|
-
" preman endpoints setup --file endpoints.json # register runnable requests\n" +
|
|
1098
|
-
" preman test <request-id> # generate + run your first scenarios\n";
|
|
1099
|
-
|
|
1100
|
-
function nextStepsBlock(agent) {
|
|
1101
|
-
return (
|
|
1102
|
-
"\nNext steps:\n" +
|
|
1103
|
-
` 1. Restart ${agent.label}, then ask it: "run preman_status" to finish linking.\n` +
|
|
1104
|
-
` 2. ${cliInvocation()} runner start --background # let PreMan run work here\n` +
|
|
1105
|
-
` 3. ${cliInvocation()} endpoints discover # brief for your agent → endpoints.json\n` +
|
|
1106
|
-
` 4. ${cliInvocation()} hook install # test affected endpoints on git push\n`
|
|
1107
|
-
);
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
/**
|
|
1111
|
-
* What is left once the link is done, as commands rather than as questions.
|
|
1112
|
-
*
|
|
1113
|
-
* Connect used to run all of these itself, which bought a prompt per step and a
|
|
1114
|
-
* wait as long as the slowest broken one — and a link that had already succeeded
|
|
1115
|
-
* ended on a failed runner and a five-minute GitHub poll. Each is one command, so
|
|
1116
|
-
* this lists them and gets out of the way.
|
|
1117
|
-
*/
|
|
1118
|
-
function whatIsLeftBlock(args) {
|
|
1119
|
-
const cli = cliInvocation();
|
|
1120
|
-
return (
|
|
1121
|
-
"\nWhat is left:\n" +
|
|
1122
|
-
` ${cli} endpoints discover # map this repo's endpoints\n` +
|
|
1123
|
-
` ${cli} runner start --background # let PreMan apply fixes on this machine\n` +
|
|
1124
|
-
` ${cli} github # connect GitHub, or do it in the dashboard\n` +
|
|
1125
|
-
`\n${cli} status reports your endpoints, last push and integrations.\n` +
|
|
1126
|
-
`Watch runs at ${frontendUrl(args)}\n`
|
|
1127
|
-
);
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
/**
|
|
1131
|
-
* Ask, or take the step's own default when nobody can answer.
|
|
1132
|
-
*
|
|
1133
|
-
* `--yes` returns the default rather than a blanket yes, which matters for
|
|
1134
|
-
* exactly one step: the desktop app defaults to no because it downloads a
|
|
1135
|
-
* hundred-odd megabytes and writes to /Applications. A blanket yes made
|
|
1136
|
-
* `preman connect --yes` do that unattended, which is not what anyone means by
|
|
1137
|
-
* "do not ask me questions" -- they get `preman install-desktop` for that.
|
|
1138
|
-
*/
|
|
1139
|
-
async function confirm(question, { assumeYes = false, defaultYes = true } = {}) {
|
|
1140
|
-
if (assumeYes) return defaultYes;
|
|
1141
|
-
if (!process.stdin.isTTY) return false;
|
|
1142
|
-
const answer = (await promptText(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"}: `)).toLowerCase();
|
|
1143
|
-
if (answer === "") return defaultYes;
|
|
1144
|
-
return answer.startsWith("y");
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
function step(title) {
|
|
1148
|
-
process.stdout.write(`\n${title}\n`);
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
/** What the account can see right now: registry rows, and the runnable subset. */
|
|
1152
|
-
async function endpointCounts(args) {
|
|
1153
|
-
const inventory = await callPremanTool(args, "get_endpoints", {
|
|
1154
|
-
include_workbench: true,
|
|
1155
|
-
limit: 200,
|
|
1156
|
-
});
|
|
1157
|
-
const runnable = inventory.workbench_requests || [];
|
|
1158
|
-
return {
|
|
1159
|
-
registered: (inventory.endpoints || []).length,
|
|
1160
|
-
runnable: runnable.length,
|
|
1161
|
-
first: runnable[0] || null,
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/**
|
|
1166
|
-
* The one-shot command that has an agent execute the discovery brief.
|
|
1167
|
-
*
|
|
1168
|
-
* Same shape as `headlessCheckIn`, with the read tools the walk needs added to
|
|
1169
|
-
* Claude Code's allow-list: print mode refuses anything un-allowed instead of
|
|
1170
|
-
* asking, so an agent given only the MCP server can call PreMan and read nothing.
|
|
1171
|
-
*/
|
|
1172
|
-
export function headlessDiscovery(agent, serverName, instructions = []) {
|
|
1173
|
-
const prompt = [
|
|
1174
|
-
`Register this project's HTTP endpoints in PreMan using the ${serverName} MCP tools.`,
|
|
1175
|
-
"Work autonomously and do not ask for confirmation.",
|
|
1176
|
-
"",
|
|
1177
|
-
...instructions,
|
|
1178
|
-
].join("\n");
|
|
1179
|
-
if (agent.id === "cursor") return { bin: "cursor-agent", args: ["-p", prompt] };
|
|
1180
|
-
if (agent.id === "claude_code") {
|
|
1181
|
-
return {
|
|
1182
|
-
bin: "claude",
|
|
1183
|
-
args: ["-p", prompt, "--allowedTools", `mcp__${serverName},Read,Grep,Glob`],
|
|
1184
|
-
};
|
|
1185
|
-
}
|
|
1186
|
-
if (agent.id === "codex") return { bin: "codex", args: ["exec", prompt] };
|
|
1187
|
-
return null;
|
|
1188
|
-
}
|
|
1189
|
-
|
|
1190
|
-
/**
|
|
1191
|
-
* Get this project's endpoints into PreMan without handing anyone homework.
|
|
1192
|
-
*
|
|
1193
|
-
* Discovery is the agent's job — only it can read the repo — but "here is a brief,
|
|
1194
|
-
* go paste it somewhere" is the step everybody dropped out on, and the commands
|
|
1195
|
-
* printed after it referenced a file the user never had. So run the agent on the
|
|
1196
|
-
* brief and report the two numbers that matter.
|
|
1197
|
-
*
|
|
1198
|
-
* Never throws: a failed discovery must not fail the connect.
|
|
1199
|
-
*/
|
|
1200
|
-
export async function discoverEndpoints(args, agent, serverName, blockedHere = "") {
|
|
1201
|
-
try {
|
|
1202
|
-
const before = await endpointCounts(args);
|
|
1203
|
-
if (before.registered) {
|
|
1204
|
-
process.stdout.write(
|
|
1205
|
-
`${MARK.ok()} ${before.registered} endpoint(s) · ${before.runnable} runnable\n`
|
|
1206
|
-
);
|
|
1207
|
-
return before;
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
const brief = await callPremanTool(args, "discover_endpoints_from_codebase", { base_path: "." });
|
|
1211
|
-
const spec = headlessDiscovery(agent, serverName, brief.instructions || []);
|
|
1212
|
-
// Discovery has to read *this* repo, so it cannot be moved out of a directory
|
|
1213
|
-
// that redirects it the way the check-in was — it can only be skipped, before
|
|
1214
|
-
// it spends ten minutes proving what the self-test already found out.
|
|
1215
|
-
const blocker = blockedHere || (spec && onPath(spec.bin) ? agentBlocker(agent.id) : "");
|
|
1216
|
-
if (!spec || !onPath(spec.bin) || blocker) {
|
|
1217
|
-
// Say why before printing homework: "here is a brief" reads as PreMan not
|
|
1218
|
-
// working, when the actual answer is one login away.
|
|
1219
|
-
if (blocker) process.stdout.write(`${MARK.skip()} ${blocker}\n`);
|
|
1220
|
-
for (const line of brief.instructions || []) process.stdout.write(`${line}\n`);
|
|
1221
|
-
process.stdout.write(`\nHand this brief to ${agent.label}, then run:\n${MANUAL_STEPS}`);
|
|
1222
|
-
return before;
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
process.stdout.write(`Asking ${agent.label} to map this codebase…\n`);
|
|
1226
|
-
const timeoutMs = Number(process.env.PREMAN_DISCOVER_MS) || 600000;
|
|
1227
|
-
const outcome = await runAgentOnce(spec, timeoutMs);
|
|
1228
|
-
const after = await endpointCounts(args);
|
|
1229
|
-
|
|
1230
|
-
if (!after.registered) {
|
|
1231
|
-
// Both halves: the exit code says the run failed, the agent's own last line
|
|
1232
|
-
// says why, and either one alone has sent someone down the wrong path.
|
|
1233
|
-
const why =
|
|
1234
|
-
[outcome.reason, lastLine(outcome.output)].filter(Boolean).join(" · ") ||
|
|
1235
|
-
"it registered nothing";
|
|
1236
|
-
process.stdout.write(
|
|
1237
|
-
`${MARK.fail()} ${agent.label} did not register any endpoints (${why}).\n` +
|
|
1238
|
-
` Run it yourself: ${cliInvocation()} endpoints discover\n`
|
|
1239
|
-
);
|
|
1240
|
-
return after;
|
|
1241
|
-
}
|
|
1242
|
-
process.stdout.write(
|
|
1243
|
-
`${MARK.ok()} ${after.registered} endpoint(s) · ${after.runnable} runnable\n`
|
|
1244
|
-
);
|
|
1245
|
-
return after;
|
|
1246
|
-
} catch (error) {
|
|
1247
|
-
process.stdout.write(`${MARK.fail()} Could not read your endpoints: ${error.message}\n`);
|
|
1248
|
-
return { registered: 0, runnable: 0, first: null };
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
/** Run one bounded, non-interactive agent invocation and keep its tail. */
|
|
1253
|
-
async function runAgentOnce(spec, timeoutMs) {
|
|
1254
|
-
return new Promise((resolve) => {
|
|
1255
|
-
let child;
|
|
1256
|
-
try {
|
|
1257
|
-
child = spawn(spec.bin, spec.args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
1258
|
-
} catch (error) {
|
|
1259
|
-
resolve({ ok: false, reason: error.message, output: "" });
|
|
1260
|
-
return;
|
|
1261
|
-
}
|
|
1262
|
-
let output = "";
|
|
1263
|
-
const absorb = (chunk) => {
|
|
1264
|
-
output = `${output}${chunk}`.slice(-8000);
|
|
1265
|
-
};
|
|
1266
|
-
child.stdout?.setEncoding("utf8");
|
|
1267
|
-
child.stderr?.setEncoding("utf8");
|
|
1268
|
-
child.stdout?.on("data", absorb);
|
|
1269
|
-
child.stderr?.on("data", absorb);
|
|
1270
|
-
|
|
1271
|
-
const timer = setTimeout(() => {
|
|
1272
|
-
child.kill("SIGTERM");
|
|
1273
|
-
resolve({ ok: false, reason: `timed out after ${Math.round(timeoutMs / 1000)}s`, output });
|
|
1274
|
-
}, timeoutMs);
|
|
1275
|
-
child.on("error", (error) => {
|
|
1276
|
-
clearTimeout(timer);
|
|
1277
|
-
resolve({ ok: false, reason: error.message, output });
|
|
1278
|
-
});
|
|
1279
|
-
child.on("close", (code) => {
|
|
1280
|
-
clearTimeout(timer);
|
|
1281
|
-
resolve({
|
|
1282
|
-
ok: code === 0,
|
|
1283
|
-
reason: code === 0 ? "" : `${spec.bin} exited with code ${code}`,
|
|
1284
|
-
output,
|
|
1285
|
-
});
|
|
1286
|
-
});
|
|
1287
|
-
});
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
/** Generate and run scenarios against the first runnable request, if they want it. */
|
|
1291
|
-
async function runFirstTest(args, runnable, { assumeYes }) {
|
|
1292
|
-
const label = `${runnable.method || "GET"} ${runnable.url || ""}`.trim();
|
|
1293
|
-
if (!(await confirm(`Run a first test against ${label}?`, { assumeYes }))) {
|
|
1294
|
-
process.stdout.write(`${MARK.skip()} Whenever you are ready: ${cliInvocation()} test ${runnable.id}\n`);
|
|
1295
|
-
return;
|
|
1296
|
-
}
|
|
1297
|
-
try {
|
|
1298
|
-
process.stdout.write("Generating scenarios…\n");
|
|
1299
|
-
const result = await callPremanTool(args, "generate_endpoint_tests", {
|
|
1300
|
-
target: runnable.id,
|
|
1301
|
-
run: true,
|
|
1302
|
-
allow_writes: false,
|
|
1303
|
-
max_cases: 10,
|
|
1304
|
-
});
|
|
1305
|
-
printTestSummary(result);
|
|
1306
|
-
} catch (error) {
|
|
1307
|
-
process.stdout.write(`${MARK.fail()} Could not run the first test: ${error.message}\n`);
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
/**
|
|
1312
|
-
* Whether a stored pairing can still be used, rather than merely matching.
|
|
1313
|
-
*
|
|
1314
|
-
* Matching agent and path only prove the state was written for this project. The
|
|
1315
|
-
* token behind it can be revoked, replaced by another device, or left over from an
|
|
1316
|
-
* API key that no longer exists — and reusing one of those starts a daemon that
|
|
1317
|
-
* 401s on its first stream and exits, which reads as "the runner cannot start
|
|
1318
|
-
* here" when the pairing is the only thing that needed replacing.
|
|
1319
|
-
*
|
|
1320
|
-
* A backend we cannot reach answers "usable": re-pairing is for a token the
|
|
1321
|
-
* backend rejected, not for a network that was briefly down.
|
|
1322
|
-
*/
|
|
1323
|
-
export async function runnerPairingIsUsable(args, existing, agentId, { cwd = process.cwd() } = {}) {
|
|
1324
|
-
if (!existing || existing.agent !== agentId) return false;
|
|
1325
|
-
if (path.resolve(String(existing.project_path || "")) !== path.resolve(cwd)) return false;
|
|
1326
|
-
return pairingIsLive(args, existing);
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/**
|
|
1330
|
-
* Pair this machine as a runner and leave it running.
|
|
1331
|
-
*
|
|
1332
|
-
* Without this, everything PreMan finds later is advice: it can describe a fix
|
|
1333
|
-
* and cannot apply one. With it, the queue has somewhere to land.
|
|
1334
|
-
*/
|
|
1335
|
-
async function setUpRunner(args, agent, { assumeYes }) {
|
|
1336
|
-
if (args.has("--no-runner")) return { state: "skipped" };
|
|
1337
|
-
const existing = readRunnerState();
|
|
1338
|
-
if (existing && runnerIsAlive()) {
|
|
1339
|
-
process.stdout.write(`${MARK.ok()} Runner already running for ${existing.project_path}\n`);
|
|
1340
|
-
return { state: "running" };
|
|
1341
|
-
}
|
|
1342
|
-
if (
|
|
1343
|
-
!(await confirm(
|
|
1344
|
-
`Let PreMan run ${agent.label} here when it finds something to fix?`,
|
|
1345
|
-
{ assumeYes }
|
|
1346
|
-
))
|
|
1347
|
-
) {
|
|
1348
|
-
process.stdout.write(
|
|
1349
|
-
`${MARK.skip()} Skipped. Start it later: ${cliInvocation()} runner start --background\n`
|
|
1350
|
-
);
|
|
1351
|
-
return { state: "skipped" };
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1354
|
-
try {
|
|
1355
|
-
if (!(await runnerPairingIsUsable(args, existing, agent.id))) {
|
|
1356
|
-
await registerRunner(args, { agent: agent.id, projectPath: process.cwd() });
|
|
1357
|
-
}
|
|
1358
|
-
const started = startBackground([]);
|
|
1359
|
-
// Claiming a runner that died two seconds later is worse than saying nothing:
|
|
1360
|
-
// the whole point of this step is that PreMan can act, and someone told it
|
|
1361
|
-
// can will wait for fixes that no device is listening for.
|
|
1362
|
-
const up = await confirmRunnerOnline(started);
|
|
1363
|
-
if (up.state === "exited") {
|
|
1364
|
-
process.stdout.write(
|
|
1365
|
-
`${MARK.fail()} The runner exited right after starting.\n` +
|
|
1366
|
-
(up.detail ? ` ${up.detail}\n` : "") +
|
|
1367
|
-
` Log: ${started.log} Retry: ${cliInvocation()} runner start --background\n`
|
|
1368
|
-
);
|
|
1369
|
-
return { state: "failed", detail: up.detail };
|
|
1370
|
-
}
|
|
1371
|
-
process.stdout.write(
|
|
1372
|
-
`${MARK.ok()} Runner ${up.state === "online" ? "online" : "starting"} (pid ${started.pid}) — PreMan can apply fixes on this machine.\n` +
|
|
1373
|
-
` Log: ${started.log} Stop: ${cliInvocation()} runner stop\n`
|
|
1374
|
-
);
|
|
1375
|
-
// Paired and online still cannot run anything if the agent it dispatches to
|
|
1376
|
-
// will not start, and every job would fail with the same opaque exit code.
|
|
1377
|
-
const blocker = agentBlocker(agent.id);
|
|
1378
|
-
if (blocker) {
|
|
1379
|
-
process.stdout.write(` ${MARK.skip()} Jobs will fail until you fix this: ${blocker}\n`);
|
|
1380
|
-
}
|
|
1381
|
-
return { state: "running", pid: started.pid };
|
|
1382
|
-
} catch (error) {
|
|
1383
|
-
process.stdout.write(
|
|
1384
|
-
`${MARK.fail()} Could not start the runner: ${error.message}\n` +
|
|
1385
|
-
` Retry later: ${cliInvocation()} runner start --background\n`
|
|
1386
|
-
);
|
|
1387
|
-
return { state: "failed", detail: error.message };
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
/** Offer the desktop app. Optional by design — the terminal flow is complete without it. */
|
|
1392
|
-
async function offerDesktop(args, { assumeYes }) {
|
|
1393
|
-
if (args.has("--no-desktop")) return { state: "skipped" };
|
|
1394
|
-
if (process.platform !== "darwin") return { state: "unsupported" };
|
|
1395
|
-
if (existsSync("/Applications/PreMan.app")) {
|
|
1396
|
-
process.stdout.write(`${MARK.ok()} PreMan desktop app already installed.\n`);
|
|
1397
|
-
return { state: "installed" };
|
|
1398
|
-
}
|
|
1399
|
-
// Default no, unlike every other step here: this one downloads a hundred-odd
|
|
1400
|
-
// megabytes and writes to /Applications, which nobody should get by pressing
|
|
1401
|
-
// Enter to move past a prompt.
|
|
1402
|
-
if (
|
|
1403
|
-
!(await confirm("Install the PreMan desktop app to watch runs and endpoints?", {
|
|
1404
|
-
assumeYes,
|
|
1405
|
-
defaultYes: false,
|
|
1406
|
-
}))
|
|
1407
|
-
) {
|
|
1408
|
-
process.stdout.write(
|
|
1409
|
-
`${MARK.skip()} Skipped. Install later: ${cliInvocation()} install-desktop\n`
|
|
1410
|
-
);
|
|
1411
|
-
return { state: "skipped" };
|
|
1412
|
-
}
|
|
1413
|
-
try {
|
|
1414
|
-
return await installDesktopCommand([]);
|
|
1415
|
-
} catch (error) {
|
|
1416
|
-
process.stdout.write(`${MARK.fail()} Desktop install failed: ${error.message}\n`);
|
|
1417
|
-
return { state: "failed", detail: error.message };
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/**
|
|
1422
|
-
* Which of GitHub, AWS and Slack this account already has.
|
|
1423
|
-
*
|
|
1424
|
-
* One probe per provider because each owns its own list route; a provider whose
|
|
1425
|
-
* route errors is reported as unknown rather than missing, so a backend hiccup
|
|
1426
|
-
* does not send someone through an install they already did.
|
|
1427
|
-
*/
|
|
1428
|
-
export async function integrationStatus(args, apiKey) {
|
|
1429
|
-
const probe = async (method, route, pick) => {
|
|
1430
|
-
try {
|
|
1431
|
-
const result = await callBackendJson(args, method, route, { token: apiKey });
|
|
1432
|
-
if (!result.ok) return null;
|
|
1433
|
-
return (pick(result) || []).length;
|
|
1434
|
-
} catch {
|
|
1435
|
-
return null;
|
|
1436
|
-
}
|
|
1437
|
-
};
|
|
1438
|
-
const [github, aws, slack] = await Promise.all([
|
|
1439
|
-
probe("GET", "/integrations/github", (r) => r.list || r.integrations || r.repos),
|
|
1440
|
-
probe("GET", "/aws-links", (r) => r.links || r.list),
|
|
1441
|
-
probe("GET", "/slack/connections", (r) => r.connections || r.list),
|
|
1442
|
-
]);
|
|
1443
|
-
return { github, aws, slack };
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/**
|
|
1447
|
-
* Show what is connected, and offer to finish what is not.
|
|
1448
|
-
*
|
|
1449
|
-
* Each install lives in the customer's browser session, so every one of these
|
|
1450
|
-
* opens a URL and polls our own API until the other side reports the connection
|
|
1451
|
-
* exists — the terminal picks the result up on its own, whether they finished in
|
|
1452
|
-
* the browser or in the desktop app.
|
|
1453
|
-
*/
|
|
1454
|
-
async function connectIntegrations(args, apiKey, { assumeYes }) {
|
|
1455
|
-
if (args.has("--no-integrations")) return;
|
|
1456
|
-
const status = await integrationStatus(args, apiKey);
|
|
1457
|
-
const providers = [
|
|
1458
|
-
{ key: "github", label: "GitHub", question: "Connect GitHub?", run: () => githubCommand(args) },
|
|
1459
|
-
{ key: "aws", label: "AWS", question: "Connect AWS?", run: () => awsCommand(args) },
|
|
1460
|
-
{ key: "slack", label: "Slack", question: "Connect Slack?", run: () => slackCommand(args) },
|
|
1461
|
-
];
|
|
1462
|
-
|
|
1463
|
-
for (const provider of providers) {
|
|
1464
|
-
const count = status[provider.key];
|
|
1465
|
-
if (count === null) {
|
|
1466
|
-
process.stdout.write(`${MARK.skip()} ${provider.label} — could not check\n`);
|
|
1467
|
-
} else if (count > 0) {
|
|
1468
|
-
process.stdout.write(`${MARK.ok()} ${provider.label} connected\n`);
|
|
1469
|
-
} else {
|
|
1470
|
-
process.stdout.write(`${MARK.skip()} ${provider.label} not connected\n`);
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
for (const provider of providers) {
|
|
1475
|
-
if (status[provider.key] !== 0) continue;
|
|
1476
|
-
if (!(await confirm(provider.question, { assumeYes, defaultYes: false }))) continue;
|
|
1477
|
-
try {
|
|
1478
|
-
await provider.run();
|
|
1479
|
-
} catch (error) {
|
|
1480
|
-
// One provider's failure must not cost the customer the ones that worked.
|
|
1481
|
-
process.stdout.write(`${MARK.fail()} Could not finish ${provider.label}: ${error.message}\n`);
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/**
|
|
1487
|
-
* Install the pre-push hook, so a push is what triggers the tests.
|
|
1488
|
-
*
|
|
1489
|
-
* Not a question: testing what you just changed before it ships is the product,
|
|
1490
|
-
* the hook cannot block a push, and `--no-hook` / `PREMAN_SKIP_HOOK=1` are both
|
|
1491
|
-
* still there. Asking only produced accounts that never tested anything.
|
|
1492
|
-
*/
|
|
1493
|
-
async function setUpPushTesting(args) {
|
|
1494
|
-
if (args.has("--no-hook")) return { state: "skipped" };
|
|
1495
|
-
const current = (() => {
|
|
1496
|
-
try {
|
|
1497
|
-
return hookStatus({ probe: true });
|
|
1498
|
-
} catch {
|
|
1499
|
-
return null; // not a git repository
|
|
1500
|
-
}
|
|
1501
|
-
})();
|
|
1502
|
-
if (!current) {
|
|
1503
|
-
process.stdout.write(`${MARK.skip()} Not a git repository — no push testing here.\n`);
|
|
1504
|
-
return { state: "unavailable" };
|
|
1505
|
-
}
|
|
1506
|
-
// A hook whose invocation went stale, or no longer answers, is reinstalled
|
|
1507
|
-
// rather than reported as on: it is the case where PreMan looks connected and
|
|
1508
|
-
// silently checks nothing.
|
|
1509
|
-
if (current.state === "installed" && current.current && current.works) {
|
|
1510
|
-
process.stdout.write(`${MARK.ok()} Push testing already on.\n`);
|
|
1511
|
-
return { state: "installed" };
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
const result = installHook(args);
|
|
1515
|
-
if (result.action === "conflict") {
|
|
1516
|
-
process.stdout.write(
|
|
1517
|
-
`${MARK.skip()} You already have a pre-push hook. Replace it: ${cliInvocation()} hook install --force\n`
|
|
1518
|
-
);
|
|
1519
|
-
return result;
|
|
1520
|
-
}
|
|
1521
|
-
if (result.action === "unproven") {
|
|
1522
|
-
process.stdout.write(
|
|
1523
|
-
`${MARK.skip()} No push testing: ${result.detail}\n` +
|
|
1524
|
-
` Then: ${cliInvocation()} hook install\n`
|
|
1525
|
-
);
|
|
1526
|
-
return result;
|
|
1527
|
-
}
|
|
1528
|
-
process.stdout.write(
|
|
1529
|
-
`${MARK.ok()} Push testing on — \`git push\` now checks the endpoints you touched.\n` +
|
|
1530
|
-
" It never blocks a push; PREMAN_SKIP_HOOK=1 silences it.\n"
|
|
1531
|
-
);
|
|
1532
|
-
return result;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
/**
|
|
1536
|
-
* Everything after the link, in one pass, for whoever asks for it with `--guide`.
|
|
1537
|
-
*
|
|
1538
|
-
* Order follows what a new account needs to see: what PreMan found, then where to
|
|
1539
|
-
* watch it, then who to tell, then when to run it. It is opt-in because running
|
|
1540
|
-
* all of it unasked is what turned a finished connect into a screen of prompts,
|
|
1541
|
-
* a dead runner and a five-minute wait on an integration nobody had asked for.
|
|
1542
|
-
*/
|
|
1543
|
-
async function guidedFirstRun(args, agent, apiKey, serverName, { blockedHere = "" } = {}) {
|
|
1544
|
-
const assumeYes = args.has("--yes");
|
|
1545
|
-
|
|
1546
|
-
step("Endpoints");
|
|
1547
|
-
const counts = await discoverEndpoints(args, agent, serverName, blockedHere);
|
|
1548
|
-
|
|
1549
|
-
if (counts.first) {
|
|
1550
|
-
step("First test");
|
|
1551
|
-
await runFirstTest(args, counts.first, { assumeYes });
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
|
-
step("Runner");
|
|
1555
|
-
await setUpRunner(args, agent, { assumeYes });
|
|
1556
|
-
|
|
1557
|
-
step("Desktop app");
|
|
1558
|
-
await offerDesktop(args, { assumeYes });
|
|
1559
|
-
|
|
1560
|
-
step("Integrations");
|
|
1561
|
-
await connectIntegrations(args, apiKey, { assumeYes });
|
|
1562
|
-
|
|
1563
|
-
step("Testing on push");
|
|
1564
|
-
await setUpPushTesting(args);
|
|
1565
|
-
|
|
1566
|
-
process.stdout.write(`\nDone. Watch it at ${frontendUrl(args)}\n`);
|
|
1567
|
-
}
|
|
36
|
+
import { MARK } from "./integrations.js";
|
|
37
|
+
import { ConnectError, EXIT_USAGE } from "./connect/errors.js";
|
|
38
|
+
import {
|
|
39
|
+
agentBlocker,
|
|
40
|
+
detectAgents,
|
|
41
|
+
findAgent,
|
|
42
|
+
promptAgentChoice,
|
|
43
|
+
} from "./connect/agents.js";
|
|
44
|
+
import {
|
|
45
|
+
WRITERS,
|
|
46
|
+
renderAgentSnippet,
|
|
47
|
+
renderAllAgentSnippets,
|
|
48
|
+
renderCodexToml,
|
|
49
|
+
verifyWrittenConfig,
|
|
50
|
+
} from "./connect/configs.js";
|
|
51
|
+
import { checkKeyAndPair, refreshStaleCredentials } from "./connect/pairing.js";
|
|
52
|
+
import {
|
|
53
|
+
autoCheckIn,
|
|
54
|
+
lastLine,
|
|
55
|
+
mcpSelfTest,
|
|
56
|
+
pollTicker,
|
|
57
|
+
selfTestBudgetMs,
|
|
58
|
+
waitForConnection,
|
|
59
|
+
} from "./connect/checkin.js";
|
|
60
|
+
import { captureDispatchCredential } from "./connect/dispatch.js";
|
|
61
|
+
import {
|
|
62
|
+
discoverEndpoints,
|
|
63
|
+
guidedFirstRun,
|
|
64
|
+
nextStepsBlock,
|
|
65
|
+
setUpPushTesting,
|
|
66
|
+
whatIsLeftBlock,
|
|
67
|
+
} from "./connect/guide.js";
|
|
1568
68
|
|
|
1569
69
|
// ── Preflight ───────────────────────────────────────────────────────────
|
|
1570
70
|
|
|
@@ -1683,7 +183,7 @@ export async function connectCommand(commandArgs) {
|
|
|
1683
183
|
|
|
1684
184
|
if (printOnly) {
|
|
1685
185
|
if (!agent) agent = await promptAgentChoice(detectAgents());
|
|
1686
|
-
const serverConfig = buildServerConfig(args);
|
|
186
|
+
const serverConfig = buildServerConfig(args, { codingAgent: agent.id });
|
|
1687
187
|
if (agent.id === "codex") {
|
|
1688
188
|
process.stdout.write(renderCodexToml(serverName, serverConfig));
|
|
1689
189
|
} else {
|
|
@@ -1715,6 +215,7 @@ export async function connectCommand(commandArgs) {
|
|
|
1715
215
|
let apiKey = resolveApiKey(args);
|
|
1716
216
|
|
|
1717
217
|
let pairCode = "";
|
|
218
|
+
let pairingId = "";
|
|
1718
219
|
if (apiKey) {
|
|
1719
220
|
let pairing = await checkKeyAndPair(args, agent, apiKey);
|
|
1720
221
|
if (pairing.stale) {
|
|
@@ -1725,9 +226,13 @@ export async function connectCommand(commandArgs) {
|
|
|
1725
226
|
}
|
|
1726
227
|
}
|
|
1727
228
|
pairCode = pairing.pairCode;
|
|
229
|
+
pairingId = pairing.pairingId;
|
|
1728
230
|
}
|
|
1729
231
|
|
|
1730
|
-
const serverConfig = buildServerConfig(args, {
|
|
232
|
+
const serverConfig = buildServerConfig(args, {
|
|
233
|
+
pairCode,
|
|
234
|
+
codingAgent: agent.id,
|
|
235
|
+
});
|
|
1731
236
|
const written = WRITERS[agent.id]({ serverName, serverConfig, projectInstall });
|
|
1732
237
|
|
|
1733
238
|
process.stdout.write(
|
|
@@ -1741,7 +246,7 @@ export async function connectCommand(commandArgs) {
|
|
|
1741
246
|
// Generate hint that matches the actual install location
|
|
1742
247
|
let hint = agent.snippetHint;
|
|
1743
248
|
if (agent.id === "cursor" && projectInstall) {
|
|
1744
|
-
hint =
|
|
249
|
+
hint = `merge into .cursor/mcp.json, then run: cursor-agent mcp enable ${serverName}`;
|
|
1745
250
|
} else if (agent.id === "claude_code") {
|
|
1746
251
|
hint = projectInstall ? "run (with --scope project):" : "run:";
|
|
1747
252
|
}
|
|
@@ -1766,6 +271,7 @@ export async function connectCommand(commandArgs) {
|
|
|
1766
271
|
written,
|
|
1767
272
|
serverConfig,
|
|
1768
273
|
projectInstall,
|
|
274
|
+
pairingId,
|
|
1769
275
|
});
|
|
1770
276
|
// The agent goes back to the caller because `onboard` runs steps after this one
|
|
1771
277
|
// that need to know which agent to drive, and asking twice is a question we
|
|
@@ -1793,6 +299,16 @@ export async function connectCommand(commandArgs) {
|
|
|
1793
299
|
}
|
|
1794
300
|
if (!args.has("--no-guide")) {
|
|
1795
301
|
await setUpPushTesting(args);
|
|
302
|
+
// Discovery is back inline, alone. What made the old full pass bad was its
|
|
303
|
+
// tail — a runner that fails on its own and a five-minute GitHub poll, after
|
|
304
|
+
// the link had already succeeded. Discovery is the one step PreMan is
|
|
305
|
+
// useless without: with no endpoints there is nothing to test, nothing to
|
|
306
|
+
// schedule and nothing for the push hook just installed above to check. It
|
|
307
|
+
// asks nothing, never throws, and skips itself when the repo is already
|
|
308
|
+
// mapped or no agent can be run.
|
|
309
|
+
await discoverEndpoints(args, agent, serverName, {
|
|
310
|
+
blockedHere: checkIn.blockedHere,
|
|
311
|
+
});
|
|
1796
312
|
process.stdout.write(whatIsLeftBlock(args));
|
|
1797
313
|
}
|
|
1798
314
|
await captureDispatchCredential(args, agent, apiKey, { prompt: false });
|
|
@@ -1870,7 +386,7 @@ async function establishCheckIn(
|
|
|
1870
386
|
args,
|
|
1871
387
|
agent,
|
|
1872
388
|
apiKey,
|
|
1873
|
-
{ serverName, written, serverConfig, projectInstall = false }
|
|
389
|
+
{ serverName, written, serverConfig, projectInstall = false, pairingId = "" }
|
|
1874
390
|
) {
|
|
1875
391
|
const notes = [];
|
|
1876
392
|
// Both set only when this directory redirects the agent: where it can be
|
|
@@ -1901,7 +417,15 @@ async function establishCheckIn(
|
|
|
1901
417
|
`so your agent will use ${status.backend_url}.`
|
|
1902
418
|
);
|
|
1903
419
|
}
|
|
1904
|
-
if (
|
|
420
|
+
if (
|
|
421
|
+
test.ok &&
|
|
422
|
+
status.authenticated &&
|
|
423
|
+
(await waitForConnection(args, apiKey, {
|
|
424
|
+
timeoutMs: 15000,
|
|
425
|
+
expectedAgent: agent.id,
|
|
426
|
+
pairingId,
|
|
427
|
+
}))
|
|
428
|
+
) {
|
|
1905
429
|
return done(true);
|
|
1906
430
|
}
|
|
1907
431
|
note(
|
|
@@ -1959,7 +483,11 @@ async function establishCheckIn(
|
|
|
1959
483
|
if (
|
|
1960
484
|
retry.ok &&
|
|
1961
485
|
retry.status?.authenticated &&
|
|
1962
|
-
(await waitForConnection(args, apiKey, {
|
|
486
|
+
(await waitForConnection(args, apiKey, {
|
|
487
|
+
timeoutMs: 15000,
|
|
488
|
+
expectedAgent: agent.id,
|
|
489
|
+
pairingId,
|
|
490
|
+
}))
|
|
1963
491
|
) {
|
|
1964
492
|
return done(true);
|
|
1965
493
|
}
|
|
@@ -1984,6 +512,7 @@ async function establishCheckIn(
|
|
|
1984
512
|
launch = await autoCheckIn(args, agent, apiKey, {
|
|
1985
513
|
serverName,
|
|
1986
514
|
cwd: elsewhere || process.cwd(),
|
|
515
|
+
pairingId,
|
|
1987
516
|
onPoll: ticker.tick,
|
|
1988
517
|
onLaunch: ({ mode, terminal }) =>
|
|
1989
518
|
say(
|
|
@@ -2021,7 +550,13 @@ async function establishCheckIn(
|
|
|
2021
550
|
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
2022
551
|
);
|
|
2023
552
|
|
|
2024
|
-
if (
|
|
553
|
+
if (
|
|
554
|
+
await waitForConnection(args, apiKey, {
|
|
555
|
+
expectedAgent: agent.id,
|
|
556
|
+
pairingId,
|
|
557
|
+
onPoll: ticker.tick,
|
|
558
|
+
})
|
|
559
|
+
) {
|
|
2025
560
|
ticker.end();
|
|
2026
561
|
return done(true);
|
|
2027
562
|
}
|
|
@@ -2036,3 +571,15 @@ async function establishCheckIn(
|
|
|
2036
571
|
);
|
|
2037
572
|
return done(false);
|
|
2038
573
|
}
|
|
574
|
+
|
|
575
|
+
// ── Facade ──────────────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
// Kept so every existing importer -- cli.js, the smoke suites, and the two
|
|
578
|
+
// that reach in by dynamic import -- sees the surface it always did.
|
|
579
|
+
|
|
580
|
+
export { agentBlocker, resolveAgentForPairing, resolveAgentToDrive, runningInside } from "./connect/agents.js";
|
|
581
|
+
export { autoCheckIn, commandLine, headlessCheckIn, interactiveCheckIn, lastLine, mcpSelfTest, openInNewTerminal, selfTestBudgetMs, waitForConnection } from "./connect/checkin.js";
|
|
582
|
+
export { claudeMcpAddArgs, cursorMcpEnableArgs, renderAgentSnippet, renderAllAgentSnippets, renderCodexToml, upsertCodexToml, verifyWrittenConfig, writeClaudeConfig, writeCodexConfig, writeCursorConfig } from "./connect/configs.js";
|
|
583
|
+
export { DISPATCH_HELP, dispatchCommand, extractRoutineId } from "./connect/dispatch.js";
|
|
584
|
+
export { discoverEndpoints, headlessDiscovery, integrationStatus, runnerPairingIsUsable } from "./connect/guide.js";
|
|
585
|
+
export { ConnectError, EXIT_USAGE } from "./connect/errors.js";
|