propr-cli 0.8.3 → 0.8.5

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.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/dist/api/relay.js +10 -0
  3. package/dist/assets/env.example.txt +182 -59
  4. package/dist/auth/githubLogin.js +66 -0
  5. package/dist/commands/agentCommands.js +74 -0
  6. package/dist/commands/agentValidation.js +548 -0
  7. package/dist/commands/checkCommands.js +981 -76
  8. package/dist/commands/imageCommands.js +60 -0
  9. package/dist/commands/index.js +3 -0
  10. package/dist/commands/initStack.js +50 -1
  11. package/dist/commands/relayCommands.js +45 -12
  12. package/dist/commands/setup/agents.js +185 -0
  13. package/dist/commands/setup/engine.js +956 -0
  14. package/dist/commands/setup/github.js +181 -0
  15. package/dist/commands/setup/sequential.js +501 -0
  16. package/dist/commands/setup/state.js +242 -0
  17. package/dist/commands/setup/types.js +85 -0
  18. package/dist/commands/setupCommand.js +85 -0
  19. package/dist/commands/stackCommands.js +14 -2
  20. package/dist/commands/systemCommands.js +49 -2
  21. package/dist/commands/tunnelCommand.js +562 -0
  22. package/dist/config/ConfigManager.js +22 -0
  23. package/dist/config/types.js +1 -0
  24. package/dist/index.js +14 -45
  25. package/dist/orchestrator/format.js +46 -0
  26. package/dist/orchestrator/index.js +7 -2
  27. package/dist/orchestrator/manifest.json +12 -11
  28. package/dist/orchestrator/orchestrator.mjs +872 -73
  29. package/dist/tui/AgentTableApp.js +86 -0
  30. package/dist/tui/CheckApp.js +202 -0
  31. package/dist/tui/SetupApp.js +586 -0
  32. package/dist/tui/SetupApp.test.js +172 -0
  33. package/dist/tui/app.js +84 -0
  34. package/dist/tui/render.js +28 -2
  35. package/dist/utils/envFile.js +45 -0
  36. package/dist/vendor/shared/githubEventIntakeMode.js +41 -0
  37. package/dist/vendor/shared/index.js +17 -0
  38. package/dist/vendor/shared/intakeModePrerequisites.js +76 -0
  39. package/dist/vendor/shared/modelDefinitions.js +4 -4
  40. package/dist/vendor/shared/proprCompatibility.js +70 -0
  41. package/dist/vendor/shared/proprServiceUrls.js +124 -0
  42. package/dist/vendor/shared/statusKeys.js +14 -0
  43. package/dist/vendor/shared/validateRoutingUrl.js +46 -0
  44. package/package.json +3 -3
@@ -0,0 +1,548 @@
1
+ /**
2
+ * Live agent validation for `propr check` (opt-in, see --validate-agents).
3
+ *
4
+ * Per agent, three checks:
5
+ * • Version — compare the agent CLI version on the host vs inside the image
6
+ * (free, no LLM call). Images are usually newer; a mismatch can
7
+ * explain host-vs-image behavior differences.
8
+ * • Host — run the agent's CLI on the host with host credentials (billable).
9
+ * • Image — run the agent's Docker image with the credential directory
10
+ * mounted, mirroring the worker (billable).
11
+ *
12
+ * Comparing host vs image pinpoints failures: host OK + image FAIL ⇒ a
13
+ * credential mount / container config problem rather than a bad credential.
14
+ *
15
+ * All checks run concurrently via async spawn. Image invocations mirror
16
+ * @propr/core's agent buildDockerArgs / analyze(); they are best-effort
17
+ * reconstructions — verify against real images on the host.
18
+ */
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { homedir, tmpdir } from "node:os";
22
+ import { isAbsolute, join } from "node:path";
23
+ const VALIDATION_PROMPT = "Respond in 3 words: which model are you?";
24
+ const VALIDATION_TIMEOUT_MS = 120_000;
25
+ const VERSION_TIMEOUT_MS = 30_000;
26
+ const AGENT_VALIDATION_CONCURRENCY = 1;
27
+ const WORKSPACE = "/home/node/workspace";
28
+ const PROMPT_FILE = "/home/node/propr-prompt.txt";
29
+ // Treated as failure even when the process exits 0 (e.g. an agent that prints
30
+ // "Authentication required …" and exits cleanly). This intentionally scans the
31
+ // whole response; the validation prompt is constrained enough that accidental
32
+ // mentions of words like "quota" should be rare.
33
+ const FAILURE_MARKERS = /authentication required|please (?:visit|log ?in|sign ?in|authenticate)|not (?:logged ?in|authenticated|signed ?in)|unauthorized|\b401\b|\b403\b|invalid (?:api ?key|credentials|token)|(?:missing|no) api key|api key (?:not|is missing|required)|login required|permission denied|errno 13|command not found|must provide a (?:message|command)|quota|rate limit/i;
34
+ // Container entrypoint / setup chatter to drop from the captured response.
35
+ const NOISE_MARKERS = /skipping firewall|gh_token|github token|safe\.directory|using legacy|config(?:uration)? (?:directory )?(?:available|not|mounted|found)|ownership|^warning:|operations may fail|contents of|^total \d|^[-d][rwx-]{9}|setting up|executing command|wrapper|operational comment|filtering/i;
36
+ // eslint-disable-next-line no-control-regex
37
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
38
+ /** Async exec with timeout + optional stdin; never rejects. */
39
+ function execAsync(cmd, args, opts) {
40
+ return new Promise((resolve) => {
41
+ const child = spawn(cmd, args, { cwd: opts.cwd, env: opts.env, stdio: ["pipe", "pipe", "pipe"] });
42
+ let stdout = "";
43
+ let stderr = "";
44
+ let settled = false;
45
+ const finish = (res) => {
46
+ if (settled)
47
+ return;
48
+ settled = true;
49
+ clearTimeout(timer);
50
+ resolve(res);
51
+ };
52
+ const timer = setTimeout(() => {
53
+ child.kill("SIGKILL");
54
+ finish({ status: null, stdout, stderr, error: Object.assign(new Error("timed out"), { code: "ETIMEDOUT" }) });
55
+ }, opts.timeoutMs);
56
+ child.stdout.on("data", (d) => { stdout += d.toString(); });
57
+ child.stderr.on("data", (d) => { stderr += d.toString(); });
58
+ child.on("error", (error) => finish({ status: null, stdout, stderr, error }));
59
+ child.on("close", (code) => finish({ status: code, stdout, stderr }));
60
+ child.stdin.on("error", () => { });
61
+ if (opts.input != null)
62
+ child.stdin.write(opts.input);
63
+ child.stdin.end();
64
+ });
65
+ }
66
+ function responseSummary(stdout, stderr) {
67
+ const meaningful = (text) => text
68
+ .replace(ANSI_RE, "")
69
+ .split("\n")
70
+ .map((l) => l.trim())
71
+ .filter((l) => l.length > 0 && !NOISE_MARKERS.test(l));
72
+ const lines = meaningful(stdout);
73
+ const last = lines.length ? lines[lines.length - 1] : meaningful(stderr).pop();
74
+ // Strip opencode's TUI status prefix (e.g. "> build · <model>") so the reply
75
+ // reads like the others.
76
+ return (last ?? "")
77
+ .replace(/^>\s*\w+\s*[·•∙]\s*/u, "")
78
+ .replace(/^>\s*/u, "")
79
+ .trim();
80
+ }
81
+ function truncateDetail(text, max) {
82
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
83
+ }
84
+ function shellArg(arg) {
85
+ return /^[A-Za-z0-9_./:=@+-]+$/.test(arg) ? arg : `"${arg.replace(/["\\$`]/g, "\\$&")}"`;
86
+ }
87
+ function hostDebugCommand(d) {
88
+ if (!d.hostBin || !d.hostInvocation)
89
+ return d.type;
90
+ const { args } = d.hostInvocation({ prompt: "test", promptFileHost: "test" });
91
+ return [d.hostBin, ...args].map(shellArg).join(" ");
92
+ }
93
+ /** Classify a finished run as ok/fail with a concise human detail. */
94
+ function evaluateRun(run) {
95
+ if (run.error?.code === "ETIMEDOUT") {
96
+ return { ok: false, detail: `timed out after ${VALIDATION_TIMEOUT_MS / 1000}s` };
97
+ }
98
+ if (run.status !== 0) {
99
+ const reason = (run.stderr || run.stdout || run.error?.message || "failed")
100
+ .replace(ANSI_RE, "")
101
+ .split("\n")
102
+ .map((l) => l.trim())
103
+ .filter(Boolean)
104
+ .pop();
105
+ return { ok: false, detail: `exit ${run.status ?? "?"}: ${truncateDetail(reason || "failed", 160)}` };
106
+ }
107
+ const combined = `${run.stdout}\n${run.stderr}`;
108
+ if (FAILURE_MARKERS.test(combined)) {
109
+ const line = combined
110
+ .replace(ANSI_RE, "")
111
+ .split("\n")
112
+ .map((l) => l.trim())
113
+ .find((l) => FAILURE_MARKERS.test(l));
114
+ return { ok: false, detail: truncateDetail(line || "authentication/availability error", 160) };
115
+ }
116
+ const response = responseSummary(run.stdout, run.stderr);
117
+ return { ok: true, detail: response ? truncateDetail(response, 200) : "(responded, no text captured)" };
118
+ }
119
+ function parseVersion(text) {
120
+ const match = text.replace(ANSI_RE, "").match(/\b(\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?)\b/);
121
+ return match?.[1];
122
+ }
123
+ /** Numeric-segment compare; returns sign of (a - b). */
124
+ function compareVersions(a, b) {
125
+ const pa = a.split(/[-.]/).map((n) => parseInt(n, 10) || 0);
126
+ const pb = b.split(/[-.]/).map((n) => parseInt(n, 10) || 0);
127
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
128
+ const x = pa[i] ?? 0;
129
+ const y = pb[i] ?? 0;
130
+ if (x > y)
131
+ return 1;
132
+ if (x < y)
133
+ return -1;
134
+ }
135
+ return 0;
136
+ }
137
+ /** Shared `docker run` prefix (ends with the image). */
138
+ function baseArgs(image, hostDir, containerConfigPath, workspaceDir, opts = {}) {
139
+ return [
140
+ "run", "--rm", "-i",
141
+ "--security-opt", "no-new-privileges",
142
+ "--cap-add", "CHOWN",
143
+ "--network", "bridge",
144
+ // Start as root so the image entrypoints can chown the mounts and drop to
145
+ // the node user — matching how @propr/core actually runs these images.
146
+ // Running as the host user breaks sudo/chown/mkdir inside the containers.
147
+ "--user", "0:0",
148
+ "-v", `${workspaceDir}:${WORKSPACE}:rw`,
149
+ "-v", `${hostDir}:${containerConfigPath}:${opts.configMode ?? "rw"}`,
150
+ "-e", "GH_TOKEN",
151
+ ...(opts.env ?? []),
152
+ ...(opts.extra ?? []),
153
+ "-w", WORKSPACE,
154
+ image,
155
+ ];
156
+ }
157
+ const home = homedir();
158
+ const DESCRIPTORS = [
159
+ {
160
+ type: "claude",
161
+ imageKey: "agent-claude",
162
+ hostDirKey: "hostClaudeDir",
163
+ envKey: "HOST_CLAUDE_DIR",
164
+ defaultHostDir: join(home, ".claude"),
165
+ hostBin: "claude",
166
+ hostInvocation: ({ prompt }) => ({ args: ["-p", prompt, "--output-format", "text"] }),
167
+ imageInvocation: ({ image, hostDir, workspaceDir }) => ({
168
+ args: [
169
+ ...baseArgs(image, hostDir, "/home/node/.claude", workspaceDir),
170
+ "claude", "-p", "-", "--output-format", "text", "--dangerously-skip-permissions",
171
+ ],
172
+ stdin: VALIDATION_PROMPT,
173
+ }),
174
+ versionArgs: ["claude", "--version"],
175
+ containerConfigPath: "/home/node/.claude",
176
+ loginArgs: ["claude", "login"],
177
+ },
178
+ {
179
+ type: "codex",
180
+ imageKey: "agent-codex",
181
+ hostDirKey: "hostCodexDir",
182
+ envKey: "HOST_CODEX_DIR",
183
+ defaultHostDir: join(home, ".codex"),
184
+ hostBin: "codex",
185
+ hostInvocation: ({ prompt }) => ({ args: ["exec", "--skip-git-repo-check", prompt] }),
186
+ imageInvocation: ({ image, hostDir, workspaceDir }) => ({
187
+ args: [
188
+ ...baseArgs(image, hostDir, "/home/node/.codex", workspaceDir, {
189
+ extra: ["--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined"],
190
+ }),
191
+ "codex", "exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--cd", WORKSPACE, "-",
192
+ ],
193
+ stdin: VALIDATION_PROMPT,
194
+ }),
195
+ versionArgs: ["codex", "--version"],
196
+ containerConfigPath: "/home/node/.codex",
197
+ loginArgs: ["codex", "login"],
198
+ },
199
+ {
200
+ type: "antigravity",
201
+ imageKey: "agent-antigravity",
202
+ hostDirKey: "hostAntigravityDir",
203
+ envKey: "HOST_ANTIGRAVITY_DIR",
204
+ defaultHostDir: join(home, ".gemini"),
205
+ hostBin: "agy",
206
+ hostInvocation: ({ prompt }) => ({ args: ["-p", prompt] }),
207
+ imageInvocation: ({ image, hostDir, workspaceDir }) => ({
208
+ args: [
209
+ ...baseArgs(image, hostDir, "/home/node/.gemini", workspaceDir, {
210
+ env: ["-e", "ANTIGRAVITY_CLI=1", "-e", "ANTIGRAVITY_CLI_TRUST_WORKSPACE=true"],
211
+ }),
212
+ "/bin/bash", "-lc", 'set -e\nexec agy --dangerously-skip-permissions --print - "$@"', "propr-antigravity",
213
+ ],
214
+ stdin: VALIDATION_PROMPT,
215
+ }),
216
+ versionArgs: ["agy", "--version"],
217
+ containerConfigPath: "/home/node/.gemini",
218
+ // `agy` (installed via the antigravity script, not an npm global) isn't on
219
+ // sudo's PATH when the entrypoint drops to the node user, so run it through a
220
+ // login shell — matching the validation invocation.
221
+ loginArgs: ["/bin/bash", "-lc", "exec agy login"],
222
+ loginExtraArgs: () => ["-e", "ANTIGRAVITY_CLI=1", "-e", "ANTIGRAVITY_CLI_TRUST_WORKSPACE=true"],
223
+ },
224
+ {
225
+ type: "opencode",
226
+ imageKey: "agent-opencode",
227
+ hostDirKey: "hostOpencodeXdgDir",
228
+ envKey: "HOST_OPENCODE_XDG_DIR",
229
+ defaultHostDir: join(home, ".config", "opencode"),
230
+ hostBin: "opencode",
231
+ hostInvocation: ({ prompt }) => ({ args: ["run", prompt] }),
232
+ imageInvocation: ({ image, hostDir, workspaceDir, cfg }) => {
233
+ const opencodeDataDir = resolveOpenCodeDataDir(hostDir, cfg);
234
+ const extra = opencodeDataDir
235
+ ? ["-v", `${opencodeDataDir}:/home/node/.local/share/opencode:rw`, "-e", "XDG_DATA_HOME=/home/node/.local/share"]
236
+ : [];
237
+ return {
238
+ args: [
239
+ ...baseArgs(image, hostDir, "/home/node/.config/opencode", workspaceDir, { extra }),
240
+ "opencode", "run", VALIDATION_PROMPT,
241
+ ],
242
+ };
243
+ },
244
+ versionArgs: ["opencode", "--version"],
245
+ containerConfigPath: "/home/node/.config/opencode",
246
+ loginArgs: ["opencode", "auth", "login"],
247
+ loginExtraArgs: (cfg) => cfg.hostOpencodeDataDir ? ["-v", `${cfg.hostOpencodeDataDir}:/home/node/.local/share/opencode:rw`] : [],
248
+ },
249
+ {
250
+ type: "vibe",
251
+ imageKey: "agent-vibe",
252
+ hostDirKey: "hostVibeDir",
253
+ envKey: "HOST_VIBE_DIR",
254
+ defaultHostDir: join(home, ".vibe"),
255
+ hostBin: "vibe",
256
+ // Host vibe (newer CLI) uses -p; the image's vibe build uses --prompt-file.
257
+ hostInvocation: ({ prompt }) => ({ args: ["-p", prompt] }),
258
+ imageInvocation: ({ image, hostDir, workspaceDir, promptFileHost, cfg }) => ({
259
+ args: [
260
+ ...baseArgs(image, hostDir, "/home/node/.vibe", workspaceDir, {
261
+ configMode: "ro",
262
+ env: [
263
+ ...(cfg.mistralApiKey ? ["-e", "MISTRAL_API_KEY"] : []),
264
+ "-e", "VIBE_SOURCE_HOME=/home/node/.vibe",
265
+ "-e", "HOME=/tmp/propr-vibe-home",
266
+ "-e", "VIBE_RUNTIME_HOME=/tmp/propr-vibe-home",
267
+ "-e", "XDG_CACHE_HOME=/tmp/propr-vibe-cache",
268
+ "-e", "XDG_CONFIG_HOME=/tmp/propr-vibe-config",
269
+ "-e", "XDG_DATA_HOME=/tmp/propr-vibe-data",
270
+ "-e", "XDG_STATE_HOME=/tmp/propr-vibe-state",
271
+ "-e", "VIBE_READ_ONLY_CONFIG=1",
272
+ ],
273
+ extra: ["-v", `${promptFileHost}:${PROMPT_FILE}:ro`],
274
+ }),
275
+ "--prompt-file", PROMPT_FILE,
276
+ ],
277
+ }),
278
+ versionArgs: ["--version"],
279
+ containerConfigPath: "/home/node/.vibe",
280
+ // Vibe has no interactive login flow here — it uses MISTRAL_API_KEY or a
281
+ // pre-populated ~/.vibe — so loginArgs is intentionally omitted.
282
+ },
283
+ ];
284
+ function imagePresent(orch, tag) {
285
+ return orch.docker(["images", "-q", tag], { capture: true }).stdout.trim().length > 0;
286
+ }
287
+ function commandExists(bin) {
288
+ return spawnSync("which", [bin], { encoding: "utf-8" }).status === 0;
289
+ }
290
+ function validateBindPath(name, value) {
291
+ if (!value || !isAbsolute(value) || value.includes("~") || /[\0\r\n]/.test(value)) {
292
+ return `${name} must be an absolute path without '~' or control characters (requires Linux host paths)`;
293
+ }
294
+ if (value.includes(":")) {
295
+ return `${name} cannot contain ':' because it is used in a Docker bind mount (requires Linux — Windows-style paths like C:\\... are not supported)`;
296
+ }
297
+ return null;
298
+ }
299
+ function inferOpenCodeDataDir(configDir) {
300
+ const normalized = configDir.replace(/\/+$/, "");
301
+ if (!normalized.endsWith("/.config/opencode"))
302
+ return undefined;
303
+ return `${normalized.slice(0, -"/.config/opencode".length)}/.local/share/opencode`;
304
+ }
305
+ function resolveOpenCodeDataDir(configDir, cfg) {
306
+ if (cfg.hostOpencodeDataDir)
307
+ return cfg.hostOpencodeDataDir;
308
+ const inferred = inferOpenCodeDataDir(configDir);
309
+ return inferred && existsSync(inferred) ? inferred : undefined;
310
+ }
311
+ /** The agent types that would be validated for the given filter (for seeding a live view). */
312
+ export function agentTypesFor(filter, cfg) {
313
+ const normalized = normalizeAgentFilter(filter);
314
+ const selected = normalized.length ? DESCRIPTORS.filter((d) => normalized.includes(d.type)) : configuredDescriptors(cfg);
315
+ return selected.map((d) => d.type);
316
+ }
317
+ function normalizeAgentFilter(filter) {
318
+ return Array.from(new Set((filter ?? []).map((agent) => agent.trim().toLowerCase()).filter(Boolean)));
319
+ }
320
+ export function validateAgentFilter(filter) {
321
+ const known = new Set(DESCRIPTORS.map((d) => d.type));
322
+ const agents = normalizeAgentFilter(filter);
323
+ return { agents, unknown: agents.filter((agent) => !known.has(agent)) };
324
+ }
325
+ export function validAgentTypes() {
326
+ return DESCRIPTORS.map((d) => d.type);
327
+ }
328
+ function configuredDescriptors(cfg) {
329
+ if (!cfg)
330
+ return DESCRIPTORS;
331
+ return DESCRIPTORS.filter((d) => {
332
+ const hasImage = Boolean(cfg.images[d.imageKey]);
333
+ const hasCredentialMount = Boolean(cfg[d.hostDirKey]);
334
+ const hasApiOnlyCredential = d.type === "vibe" && Boolean(cfg.mistralApiKey);
335
+ return hasImage && (hasCredentialMount || hasApiOnlyCredential);
336
+ });
337
+ }
338
+ async function mapWithConcurrency(items, limit, mapper) {
339
+ const results = new Array(items.length);
340
+ let next = 0;
341
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
342
+ while (next < items.length) {
343
+ const index = next;
344
+ next += 1;
345
+ results[index] = await mapper(items[index]);
346
+ }
347
+ });
348
+ await Promise.all(workers);
349
+ return results;
350
+ }
351
+ async function versionInfo(d, image, orch) {
352
+ const hostPromise = d.hostBin && commandExists(d.hostBin)
353
+ ? execAsync(d.hostBin, ["--version"], { timeoutMs: VERSION_TIMEOUT_MS }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`))
354
+ : Promise.resolve(undefined);
355
+ const imagePromise = image && imagePresent(orch, image)
356
+ ? execAsync("docker", ["run", "--rm", "--network=none", image, ...d.versionArgs], { timeoutMs: VERSION_TIMEOUT_MS }).then((r) => parseVersion(`${r.stdout}\n${r.stderr}`))
357
+ : Promise.resolve(undefined);
358
+ const [host, img] = await Promise.all([hostPromise, imagePromise]);
359
+ const drift = host && img && host !== img ? (compareVersions(img, host) < 0 ? "older" : "newer") : undefined;
360
+ return { host, image: img, drift };
361
+ }
362
+ function versionText(row) {
363
+ const parts = [];
364
+ if (row.hostVersion)
365
+ parts.push(`host ${row.hostVersion}`);
366
+ if (row.imageVersion)
367
+ parts.push(`image ${row.imageVersion}`);
368
+ if (parts.length === 0)
369
+ return "version not detected";
370
+ return `${parts.join(" / ")}${row.drift ? ` (image ${row.drift})` : ""}`;
371
+ }
372
+ /** Flatten structured rows into CheckResults (for --json and exit-code logic). */
373
+ export function agentRowsToChecks(rows) {
374
+ const out = [];
375
+ for (const row of rows) {
376
+ const versionDetected = Boolean(row.hostVersion || row.imageVersion);
377
+ out.push({ name: `Version: ${row.type}`, status: versionDetected ? "ok" : "warn", detail: versionText(row), group: "Agents" });
378
+ if (row.host)
379
+ out.push({ name: `Host: ${row.type}`, status: row.host.status, detail: row.host.detail, group: "Agents", ...(row.host.fix ? { fix: row.host.fix } : {}) });
380
+ out.push({ name: `Image: ${row.type}`, status: row.image.status, detail: row.image.detail, group: "Agents", ...(row.image.fix ? { fix: row.image.fix } : {}) });
381
+ }
382
+ return out;
383
+ }
384
+ /**
385
+ * Validate configured agents by default — version (host vs image), host CLI
386
+ * call, image call. Explicit --agents can still request any known agent.
387
+ */
388
+ export async function validateAgents(orch, cfg, options = {}) {
389
+ const { agents, unknown } = validateAgentFilter(options.agents);
390
+ if (unknown.length > 0) {
391
+ throw new Error(`unknown agent type${unknown.length === 1 ? "" : "s"} '${unknown.join(", ")}'. Valid agents: ${validAgentTypes().join(", ")}`);
392
+ }
393
+ const selected = agents.length
394
+ ? DESCRIPTORS.filter((d) => agents.includes(d.type))
395
+ : configuredDescriptors(cfg);
396
+ const tmp = mkdtempSync(join(tmpdir(), "propr-validate-"));
397
+ const workspaceDir = join(tmp, "workspace");
398
+ mkdirSync(workspaceDir, { recursive: true });
399
+ const promptFileHost = join(tmp, "prompt.txt");
400
+ writeFileSync(promptFileHost, `${VALIDATION_PROMPT}\n`);
401
+ const runHost = async (d) => {
402
+ if (!d.hostInvocation || !d.hostBin)
403
+ return undefined;
404
+ if (!commandExists(d.hostBin)) {
405
+ return { status: "warn", detail: `${d.hostBin} not installed on host — skipped` };
406
+ }
407
+ const { args, stdin } = d.hostInvocation({ prompt: VALIDATION_PROMPT, promptFileHost });
408
+ const run = await execAsync(d.hostBin, args, { input: stdin, cwd: workspaceDir, timeoutMs: VALIDATION_TIMEOUT_MS });
409
+ const ev = evaluateRun(run);
410
+ return { status: ev.ok ? "ok" : "fail", detail: ev.detail, ...(ev.ok ? {} : { fix: `Run \`${hostDebugCommand(d)}\` on the host to debug ${d.type} auth.` }) };
411
+ };
412
+ const runImage = async (d, image, hostDir) => {
413
+ if (!image || !imagePresent(orch, image)) {
414
+ return { status: "warn", detail: `image ${image ?? d.imageKey} not present — skipped` };
415
+ }
416
+ if (!hostDir) {
417
+ return {
418
+ status: "warn",
419
+ detail: `${d.envKey} is not set — image validation skipped because the stack will not mount ${d.defaultHostDir}`,
420
+ fix: `Set ${d.envKey} in .env or run \`propr check --fix\` to add a detected credential directory.`,
421
+ };
422
+ }
423
+ const invalidHostDir = validateBindPath(d.envKey, hostDir);
424
+ if (invalidHostDir) {
425
+ return { status: "warn", detail: `${invalidHostDir} — image validation skipped` };
426
+ }
427
+ if (!existsSync(hostDir)) {
428
+ return { status: "warn", detail: `credentials dir ${hostDir} not found — skipped` };
429
+ }
430
+ if (d.type === "opencode" && !resolveOpenCodeDataDir(hostDir, cfg)) {
431
+ return {
432
+ status: "warn",
433
+ detail: `OpenCode config is mounted but auth data dir was not found for ${hostDir}`,
434
+ fix: "Set HOST_OPENCODE_DATA_DIR to the host OpenCode data path (usually ~/.local/share/opencode).",
435
+ };
436
+ }
437
+ const { args, stdin } = d.imageInvocation({ image, hostDir, workspaceDir, promptFileHost, cfg });
438
+ const run = await execAsync("docker", args, {
439
+ input: stdin,
440
+ env: d.type === "vibe" && cfg.mistralApiKey ? { ...process.env, MISTRAL_API_KEY: cfg.mistralApiKey } : undefined,
441
+ timeoutMs: VALIDATION_TIMEOUT_MS,
442
+ });
443
+ const ev = evaluateRun(run);
444
+ const loginHint = d.loginArgs ? ` Re-authenticate with: propr agent login ${d.type}.` : "";
445
+ return {
446
+ status: ev.ok ? "ok" : "fail",
447
+ detail: ev.detail,
448
+ ...(ev.ok ? {} : { fix: `Check ${d.type} auth/mounts (creds: ${hostDir}).${loginHint} If "Host: ${d.type}" passed but this failed, it's a credential mount/config issue.` }),
449
+ };
450
+ };
451
+ try {
452
+ if (selected.length === 0) {
453
+ options.onProgress?.("no configured agents found; set agent credential mounts in .env or pass --agents to validate a specific agent");
454
+ return [];
455
+ }
456
+ options.onProgress?.(`running checks for ${selected.length} configured agent${selected.length === 1 ? "" : "s"} (one agent at a time)…`);
457
+ return await mapWithConcurrency(selected, AGENT_VALIDATION_CONCURRENCY, async (d) => {
458
+ const image = cfg.images[d.imageKey];
459
+ let hostDir = cfg[d.hostDirKey];
460
+ if (d.type === "vibe" && !hostDir && cfg.mistralApiKey) {
461
+ hostDir = join(tmp, "vibe-config");
462
+ mkdirSync(hostDir, { recursive: true, mode: 0o700 });
463
+ }
464
+ // Emit each cell as it resolves so a live view can fill the table in.
465
+ const versionP = versionInfo(d, image, orch).then((v) => {
466
+ options.onUpdate?.(d.type, { field: "version", hostVersion: v.host, imageVersion: v.image, drift: v.drift });
467
+ return v;
468
+ });
469
+ const hostP = runHost(d).then((h) => {
470
+ options.onUpdate?.(d.type, { field: "host", cell: h ?? { status: "warn", detail: "no host CLI invocation" } });
471
+ return h;
472
+ });
473
+ const imageP = runImage(d, image, hostDir).then((i) => {
474
+ options.onUpdate?.(d.type, { field: "image", cell: i });
475
+ return i;
476
+ });
477
+ const [version, host, imageResult] = await Promise.all([versionP, hostP, imageP]);
478
+ return { type: d.type, hostVersion: version.host, imageVersion: version.image, drift: version.drift, host, image: imageResult };
479
+ });
480
+ }
481
+ finally {
482
+ rmSync(tmp, { recursive: true, force: true });
483
+ }
484
+ }
485
+ /** Agent types that support an interactive login through their image. */
486
+ export function loginableAgents() {
487
+ return DESCRIPTORS.filter((d) => d.loginArgs).map((d) => d.type);
488
+ }
489
+ /**
490
+ * Build the interactive `docker run` invocation that logs the user into an agent
491
+ * through its image, writing credentials to the mounted host directory. The
492
+ * caller runs the returned dockerArgs with inherited stdio (-it).
493
+ */
494
+ export function planAgentLogin(type, cfg, workspaceDir, validateDockerBindPath = validateBindPath) {
495
+ const d = DESCRIPTORS.find((x) => x.type === type);
496
+ if (!d)
497
+ return { error: `unknown agent '${type}'` };
498
+ if (!d.loginArgs) {
499
+ return { error: `${type} has no interactive login — authenticate via its API key (e.g. MISTRAL_API_KEY) or a pre-populated ${d.defaultHostDir}` };
500
+ }
501
+ const image = cfg.images[d.imageKey];
502
+ if (!image)
503
+ return { error: `no image configured for ${type}` };
504
+ const hostDir = cfg[d.hostDirKey] ?? d.defaultHostDir;
505
+ const invalidHostDir = validateDockerBindPath(d.envKey, hostDir);
506
+ if (invalidHostDir)
507
+ return { error: invalidHostDir };
508
+ const invalidWorkspaceDir = validateDockerBindPath("workspace", workspaceDir);
509
+ if (invalidWorkspaceDir)
510
+ return { error: invalidWorkspaceDir };
511
+ const dockerArgs = [
512
+ "run", "--rm", "-it",
513
+ "--security-opt", "no-new-privileges",
514
+ "--cap-add", "CHOWN",
515
+ "--network", "bridge",
516
+ "--user", "0:0",
517
+ "-v", `${workspaceDir}:${WORKSPACE}:rw`,
518
+ "-v", `${hostDir}:${d.containerConfigPath}:rw`,
519
+ "-e", "GH_TOKEN",
520
+ ...(d.loginExtraArgs?.(cfg) ?? []),
521
+ "-w", WORKSPACE,
522
+ image,
523
+ ...d.loginArgs,
524
+ ];
525
+ return { plan: { image, hostDir, dockerArgs } };
526
+ }
527
+ /**
528
+ * Read subscription usage from the external `agent-tank` CLI (if installed),
529
+ * via `agent-tank --once --json`. Tracks Claude/Codex/Antigravity plan limits;
530
+ * never throws. Slow (it runs each CLI's /usage), so callers run it concurrently.
531
+ */
532
+ export async function getAgentTankUsage() {
533
+ if (!commandExists("agent-tank"))
534
+ return { installed: false };
535
+ const ver = await execAsync("agent-tank", ["--version"], { timeoutMs: 10_000 });
536
+ const version = parseVersion(`${ver.stdout}\n${ver.stderr}`);
537
+ const res = await execAsync("agent-tank", ["--once", "--json"], { timeoutMs: 90_000 });
538
+ if (res.error?.code === "ETIMEDOUT")
539
+ return { installed: true, version, error: "timed out reading usage" };
540
+ try {
541
+ const data = JSON.parse(res.stdout.trim());
542
+ return { installed: true, version, usage: data };
543
+ }
544
+ catch {
545
+ const reason = (res.stderr || res.stdout || "could not parse agent-tank output").trim().split("\n").pop();
546
+ return { installed: true, version, error: (reason || "could not parse agent-tank output").slice(0, 160) };
547
+ }
548
+ }