premanmcp 0.3.5 → 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/README.md +94 -25
- package/bin/cli.js +50 -301
- package/bin/connect.js +432 -0
- package/bin/hosted.js +455 -0
- package/bin/shared.js +348 -0
- package/dist/server.js +110 -1
- package/dist/user_auth_flow.js +7 -1
- package/package.json +3 -2
package/bin/connect.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `preman connect` — pick a coding agent and get connected, in one command.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the hand-edited MCP config: the user picks Cursor / Claude Code /
|
|
5
|
+
* Codex from a list, PreMan logs them in if needed, writes that agent's config
|
|
6
|
+
* itself, and binds the connection to their account with a pair code.
|
|
7
|
+
*
|
|
8
|
+
* Optionally captures a cloud-dispatch credential in the same pass so PreMan can
|
|
9
|
+
* start agent runs for them later (SCRUM-124). That step is always skippable and
|
|
10
|
+
* never fails the connect.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
14
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
assertOk,
|
|
20
|
+
authenticateTerminal,
|
|
21
|
+
backendUrl,
|
|
22
|
+
buildServerConfig,
|
|
23
|
+
callBackendJson,
|
|
24
|
+
hasKeyAvailable,
|
|
25
|
+
makeArgs,
|
|
26
|
+
promptSecret,
|
|
27
|
+
promptText,
|
|
28
|
+
readJsonFile,
|
|
29
|
+
resolveApiKey,
|
|
30
|
+
writeJsonFile,
|
|
31
|
+
} from "./shared.js";
|
|
32
|
+
|
|
33
|
+
const EXIT_USAGE = 2;
|
|
34
|
+
|
|
35
|
+
class ConnectError extends Error {
|
|
36
|
+
constructor(message, exitCode = 1) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.exitCode = exitCode;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Supported agents. `id` matches the backend's normalize_agent() vocabulary so
|
|
44
|
+
* the link record and this CLI cannot disagree about who is connected.
|
|
45
|
+
*/
|
|
46
|
+
const AGENTS = [
|
|
47
|
+
{
|
|
48
|
+
id: "cursor",
|
|
49
|
+
label: "Cursor",
|
|
50
|
+
aliases: ["cursor"],
|
|
51
|
+
dispatch: { credential: "Cursor API key", needsRoutine: false },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "claude_code",
|
|
55
|
+
label: "Claude Code",
|
|
56
|
+
aliases: ["claude", "claude-code", "claude_code", "claudecode"],
|
|
57
|
+
dispatch: { credential: "Claude Code routine token", needsRoutine: true },
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "codex",
|
|
61
|
+
label: "Codex",
|
|
62
|
+
aliases: ["codex", "openai-codex", "openai_codex"],
|
|
63
|
+
dispatch: null, // No public fire API; stays on the copy-paste path.
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
function findAgent(value) {
|
|
68
|
+
const raw = String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
|
|
69
|
+
return AGENTS.find((a) => a.id === raw || a.aliases.includes(raw)) || null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onPath(binary) {
|
|
73
|
+
const probe = spawnSync(process.platform === "win32" ? "where" : "which", [binary], {
|
|
74
|
+
stdio: "ignore",
|
|
75
|
+
});
|
|
76
|
+
return probe.status === 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Best guess at which agent this machine actually uses, for the default pick. */
|
|
80
|
+
function detectAgents() {
|
|
81
|
+
const home = os.homedir();
|
|
82
|
+
return {
|
|
83
|
+
cursor: existsSync(path.join(home, ".cursor")) || Boolean(process.env.CURSOR_TRACE_ID),
|
|
84
|
+
claude_code: onPath("claude") || existsSync(path.join(home, ".claude.json")),
|
|
85
|
+
codex: onPath("codex") || existsSync(process.env.CODEX_HOME || path.join(home, ".codex")),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function promptAgentChoice(detected) {
|
|
90
|
+
process.stdout.write("Which coding agent?\n");
|
|
91
|
+
AGENTS.forEach((agent, index) => {
|
|
92
|
+
const mark = detected[agent.id] ? " (detected)" : "";
|
|
93
|
+
process.stdout.write(` ${index + 1}. ${agent.label}${mark}\n`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const defaultIndex = Math.max(
|
|
97
|
+
0,
|
|
98
|
+
AGENTS.findIndex((a) => detected[a.id])
|
|
99
|
+
);
|
|
100
|
+
const answer = await promptText(`Pick [${defaultIndex + 1}]: `);
|
|
101
|
+
if (!answer) return AGENTS[defaultIndex];
|
|
102
|
+
|
|
103
|
+
const byNumber = Number.parseInt(answer, 10);
|
|
104
|
+
if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= AGENTS.length) {
|
|
105
|
+
return AGENTS[byNumber - 1];
|
|
106
|
+
}
|
|
107
|
+
const byName = findAgent(answer);
|
|
108
|
+
if (byName) return byName;
|
|
109
|
+
throw new ConnectError(`Not a valid choice: ${answer}`, EXIT_USAGE);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Config writers ──────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
function cursorConfigPath(projectInstall) {
|
|
115
|
+
return projectInstall
|
|
116
|
+
? path.join(process.cwd(), ".cursor", "mcp.json")
|
|
117
|
+
: path.join(os.homedir(), ".cursor", "mcp.json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function writeCursorConfig({ serverName, serverConfig, projectInstall }) {
|
|
121
|
+
const configPath = cursorConfigPath(projectInstall);
|
|
122
|
+
const config = { ...readJsonFile(configPath) };
|
|
123
|
+
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
124
|
+
writeJsonFile(configPath, config);
|
|
125
|
+
return { path: configPath, how: "wrote" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Claude Code owns ~/.claude.json, so prefer its own CLI. Fall back to writing
|
|
130
|
+
* the config directly when `claude` is not installed — a user can connect before
|
|
131
|
+
* installing the agent.
|
|
132
|
+
*/
|
|
133
|
+
export function writeClaudeConfig({ serverName, serverConfig, projectInstall }) {
|
|
134
|
+
if (onPath("claude")) {
|
|
135
|
+
const envArgs = Object.entries(serverConfig.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]);
|
|
136
|
+
const args = [
|
|
137
|
+
"mcp",
|
|
138
|
+
"add",
|
|
139
|
+
serverName,
|
|
140
|
+
"--scope",
|
|
141
|
+
projectInstall ? "project" : "user",
|
|
142
|
+
...envArgs,
|
|
143
|
+
"--",
|
|
144
|
+
serverConfig.command,
|
|
145
|
+
...serverConfig.args,
|
|
146
|
+
];
|
|
147
|
+
try {
|
|
148
|
+
execFileSync("claude", args, { stdio: "pipe" });
|
|
149
|
+
return { path: projectInstall ? ".mcp.json" : "Claude Code user config", how: "registered via claude mcp add" };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const detail = error?.stderr?.toString().trim() || error.message;
|
|
152
|
+
process.stdout.write(`Note: claude mcp add failed (${detail}); writing the config directly.\n`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (projectInstall) {
|
|
157
|
+
const configPath = path.join(process.cwd(), ".mcp.json");
|
|
158
|
+
const config = { ...readJsonFile(configPath) };
|
|
159
|
+
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
160
|
+
writeJsonFile(configPath, config);
|
|
161
|
+
return { path: configPath, how: "wrote" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Merge only mcpServers: the user config holds a lot of unrelated state.
|
|
165
|
+
// CLAUDE_CONFIG_DIR relocates that file, and writing to ~ when it is set
|
|
166
|
+
// produces a config Claude Code never reads.
|
|
167
|
+
const configPath = path.join(process.env.CLAUDE_CONFIG_DIR || os.homedir(), ".claude.json");
|
|
168
|
+
const config = { ...readJsonFile(configPath) };
|
|
169
|
+
config.mcpServers = { ...(config.mcpServers || {}), [serverName]: serverConfig };
|
|
170
|
+
writeJsonFile(configPath, config);
|
|
171
|
+
return { path: configPath, how: "wrote" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Render the `[mcp_servers.<name>]` block for Codex's TOML config. Only strings
|
|
176
|
+
* and string arrays appear here, so a hand-rolled writer is enough — no TOML
|
|
177
|
+
* dependency for one fixed shape.
|
|
178
|
+
*/
|
|
179
|
+
export function renderCodexToml(serverName, serverConfig) {
|
|
180
|
+
const str = (value) => JSON.stringify(String(value));
|
|
181
|
+
const lines = [
|
|
182
|
+
`[mcp_servers.${serverName}]`,
|
|
183
|
+
`command = ${str(serverConfig.command)}`,
|
|
184
|
+
`args = [${serverConfig.args.map(str).join(", ")}]`,
|
|
185
|
+
];
|
|
186
|
+
const env = Object.entries(serverConfig.env);
|
|
187
|
+
if (env.length) {
|
|
188
|
+
lines.push("", `[mcp_servers.${serverName}.env]`);
|
|
189
|
+
for (const [key, value] of env) lines.push(`${key} = ${str(value)}`);
|
|
190
|
+
}
|
|
191
|
+
return `${lines.join("\n")}\n`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Replace an existing preman block, or append a new one, leaving the rest alone. */
|
|
195
|
+
export function upsertCodexToml(existing, serverName, block) {
|
|
196
|
+
const header = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`, "m");
|
|
197
|
+
if (!header.test(existing)) {
|
|
198
|
+
const separator = existing.trim() ? "\n" : "";
|
|
199
|
+
return `${existing.trimEnd()}${separator}\n${block}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const lines = existing.split("\n");
|
|
203
|
+
const out = [];
|
|
204
|
+
let skipping = false;
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
const isOurHeader = new RegExp(`^\\[mcp_servers\\.${serverName}(\\.|\\])`).test(line);
|
|
207
|
+
if (isOurHeader) {
|
|
208
|
+
skipping = true;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (skipping && /^\[/.test(line) && !isOurHeader) skipping = false;
|
|
212
|
+
if (!skipping) out.push(line);
|
|
213
|
+
}
|
|
214
|
+
return `${out.join("\n").trimEnd()}\n\n${block}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function writeCodexConfig({ serverName, serverConfig }) {
|
|
218
|
+
const configPath = path.join(
|
|
219
|
+
process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
|
|
220
|
+
"config.toml"
|
|
221
|
+
);
|
|
222
|
+
const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
|
|
223
|
+
const next = upsertCodexToml(existing, serverName, renderCodexToml(serverName, serverConfig));
|
|
224
|
+
mkdirSync(path.dirname(configPath), { recursive: true });
|
|
225
|
+
writeFileSync(configPath, next, { mode: 0o600 });
|
|
226
|
+
chmodSync(configPath, 0o600); // mode above is ignored for an existing file
|
|
227
|
+
return { path: configPath, how: "wrote" };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const WRITERS = {
|
|
231
|
+
cursor: writeCursorConfig,
|
|
232
|
+
claude_code: writeClaudeConfig,
|
|
233
|
+
codex: writeCodexConfig,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// ── Pairing ─────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
async function startPairing(args, agent, apiKey) {
|
|
239
|
+
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent", {
|
|
240
|
+
token: apiKey,
|
|
241
|
+
json: { agent: agent.id, project_path: process.cwd(), start_pairing: true },
|
|
242
|
+
});
|
|
243
|
+
if (!result.ok) {
|
|
244
|
+
process.stdout.write(
|
|
245
|
+
`Note: could not start pairing (${result.status_code}); the config still works, ` +
|
|
246
|
+
"your agent will link on its first PreMan call.\n"
|
|
247
|
+
);
|
|
248
|
+
return "";
|
|
249
|
+
}
|
|
250
|
+
return String(result.pair_code || "");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function waitForConnection(args, apiKey, { intervalMs = 3000, timeoutMs = 300000 } = {}) {
|
|
254
|
+
const deadline = Date.now() + timeoutMs;
|
|
255
|
+
let interrupted = false;
|
|
256
|
+
const onInterrupt = () => {
|
|
257
|
+
interrupted = true;
|
|
258
|
+
};
|
|
259
|
+
process.on("SIGINT", onInterrupt);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
while (Date.now() < deadline && !interrupted) {
|
|
263
|
+
const status = await callBackendJson(args, "GET", "/workbench/coding-agent", {
|
|
264
|
+
token: apiKey,
|
|
265
|
+
});
|
|
266
|
+
if (status.ok && status.connected) return true;
|
|
267
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
process.off("SIGINT", onInterrupt);
|
|
271
|
+
}
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── Dispatch credential (SCRUM-124) ─────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
async function captureDispatchCredential(args, agent, apiKey) {
|
|
278
|
+
if (!agent.dispatch) return;
|
|
279
|
+
if (args.has("--skip-dispatch-credential")) return;
|
|
280
|
+
|
|
281
|
+
let secret = args.value("--dispatch-credential", "");
|
|
282
|
+
let routineId = args.value("--routine-id", "");
|
|
283
|
+
|
|
284
|
+
if (!secret) {
|
|
285
|
+
if (!process.stdin.isTTY) return;
|
|
286
|
+
process.stdout.write(
|
|
287
|
+
`\nOptional: paste a ${agent.dispatch.credential} so PreMan can start ${agent.label} runs for you.\n`
|
|
288
|
+
);
|
|
289
|
+
secret = await promptSecret("(Enter to skip): ");
|
|
290
|
+
if (!secret) return;
|
|
291
|
+
if (agent.dispatch.needsRoutine && !routineId) {
|
|
292
|
+
routineId = await promptText("Routine id or URL: ");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (agent.dispatch.needsRoutine && !routineId) {
|
|
297
|
+
process.stdout.write("Skipped: a routine id is required alongside the token.\n");
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const body = agent.dispatch.needsRoutine
|
|
302
|
+
? { provider: agent.id, secret, routine_id: extractRoutineId(routineId) }
|
|
303
|
+
: { provider: agent.id, secret };
|
|
304
|
+
|
|
305
|
+
const result = await callBackendJson(args, "PUT", "/workbench/coding-agent/dispatch", {
|
|
306
|
+
token: apiKey,
|
|
307
|
+
json: body,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
if (result.ok) {
|
|
311
|
+
process.stdout.write("Cloud dispatch enabled — PreMan can now start runs for you.\n");
|
|
312
|
+
} else if (result.status_code === 404 || result.status_code === 405) {
|
|
313
|
+
process.stdout.write("Note: this PreMan backend does not support cloud dispatch yet; skipped.\n");
|
|
314
|
+
} else {
|
|
315
|
+
process.stdout.write(`Note: could not save the credential (${result.status_code}); skipped.\n`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Accept either a bare trig_… id or the routine URL it appears in. */
|
|
320
|
+
export function extractRoutineId(value) {
|
|
321
|
+
const raw = String(value || "").trim();
|
|
322
|
+
const match = raw.match(/trig_[A-Za-z0-9]+/);
|
|
323
|
+
return match ? match[0] : raw;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── Command ─────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export const CONNECT_HELP = `
|
|
329
|
+
Connect options:
|
|
330
|
+
--agent <name> cursor | claude-code | codex (skips the picker)
|
|
331
|
+
--project Write project-local config instead of the user config
|
|
332
|
+
--api-key <key> PreMan API key. If omitted, stored credentials are used
|
|
333
|
+
--email <email> Pre-fill the email prompt when logging in
|
|
334
|
+
--backend <url> PreMan backend URL
|
|
335
|
+
--frontend <url> PreMan frontend URL
|
|
336
|
+
--name <name> MCP server name. Defaults to preman
|
|
337
|
+
--dispatch-credential <t> Cloud-dispatch token (non-interactive)
|
|
338
|
+
--routine-id <id> Claude Code routine id, with --dispatch-credential
|
|
339
|
+
--skip-dispatch-credential Do not ask for a cloud-dispatch credential
|
|
340
|
+
--skip-login Write config without interactive terminal auth
|
|
341
|
+
--no-pair Do not mint a pair code
|
|
342
|
+
--no-wait Do not wait for the agent to check in
|
|
343
|
+
--print Print the config instead of writing it
|
|
344
|
+
`;
|
|
345
|
+
|
|
346
|
+
export async function connectCommand(commandArgs) {
|
|
347
|
+
const args = makeArgs(commandArgs);
|
|
348
|
+
const serverName = args.value("--name", "preman");
|
|
349
|
+
const projectInstall = args.has("--project");
|
|
350
|
+
const printOnly = args.has("--print");
|
|
351
|
+
const interactive = Boolean(process.stdin.isTTY);
|
|
352
|
+
|
|
353
|
+
let agent = findAgent(args.value("--agent", ""));
|
|
354
|
+
if (!agent && args.value("--agent", "")) {
|
|
355
|
+
throw new ConnectError(
|
|
356
|
+
`Unknown agent: ${args.value("--agent", "")}. Use cursor, claude-code, or codex.`,
|
|
357
|
+
EXIT_USAGE
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!agent) {
|
|
362
|
+
if (!interactive) {
|
|
363
|
+
throw new ConnectError(
|
|
364
|
+
"preman connect needs a terminal. In CI pass --agent <cursor|claude-code|codex> " +
|
|
365
|
+
"and --api-key pm_live_… (or --print).",
|
|
366
|
+
EXIT_USAGE
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
agent = await promptAgentChoice(detectAgents());
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (printOnly) {
|
|
373
|
+
const serverConfig = buildServerConfig(args);
|
|
374
|
+
if (agent.id === "codex") {
|
|
375
|
+
process.stdout.write(renderCodexToml(serverName, serverConfig));
|
|
376
|
+
} else {
|
|
377
|
+
process.stdout.write(`${JSON.stringify({ mcpServers: { [serverName]: serverConfig } }, null, 2)}\n`);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!args.has("--skip-login") && !hasKeyAvailable(args)) {
|
|
383
|
+
if (!interactive) {
|
|
384
|
+
throw new ConnectError(
|
|
385
|
+
"No PreMan credentials. Pass --api-key pm_live_… or set PREMAN_API_KEY.",
|
|
386
|
+
EXIT_USAGE
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
process.stdout.write("First, let's connect your PreMan account.\n");
|
|
390
|
+
await authenticateTerminal(args);
|
|
391
|
+
process.stdout.write("\n");
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const apiKey = resolveApiKey(args);
|
|
395
|
+
|
|
396
|
+
let pairCode = "";
|
|
397
|
+
if (apiKey && !args.has("--no-pair")) {
|
|
398
|
+
pairCode = await startPairing(args, agent, apiKey);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const serverConfig = buildServerConfig(args, { pairCode });
|
|
402
|
+
const written = WRITERS[agent.id]({ serverName, serverConfig, projectInstall });
|
|
403
|
+
|
|
404
|
+
process.stdout.write(
|
|
405
|
+
`\n${agent.label} connected: ${written.how} ${written.path}\n` +
|
|
406
|
+
`Server name: ${serverName}\n` +
|
|
407
|
+
`Backend: ${serverConfig.env.PREMAN_BACKEND}\n`
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
// Not gated on TTY: --dispatch-credential is the non-interactive path, and the
|
|
411
|
+
// prompt inside only runs when there is a terminal to prompt on.
|
|
412
|
+
await captureDispatchCredential(args, agent, apiKey);
|
|
413
|
+
|
|
414
|
+
if (!pairCode || args.has("--no-wait") || !interactive) {
|
|
415
|
+
process.stdout.write(
|
|
416
|
+
`\nRestart ${agent.label}, then ask it: "run preman_status" to finish linking.\n`
|
|
417
|
+
);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
process.stdout.write(
|
|
422
|
+
`\nRestart ${agent.label} and ask it: "run preman_status"\n` +
|
|
423
|
+
"Waiting for your agent to check in… (Ctrl+C to stop waiting)\n"
|
|
424
|
+
);
|
|
425
|
+
const connected = await waitForConnection(args, apiKey);
|
|
426
|
+
process.stdout.write(
|
|
427
|
+
connected
|
|
428
|
+
? `Connected as ${agent.label}.\n`
|
|
429
|
+
: `No check-in yet. Open ${agent.label} and ask it to "run preman_status" — ` +
|
|
430
|
+
"it will link on its first PreMan call.\n"
|
|
431
|
+
);
|
|
432
|
+
}
|