spawn/packages/cli/src/commands/shared.ts
A db77121414
fix: reject disabled agents in CLI validation instead of silently proceeding (#3061)
resolveEntityKey() and checkEntity() checked manifest.agents[input] directly,
bypassing the disabled filter in agentKeys(). This let users run `spawn cursor
<cloud>` even though cursor is disabled, wasting time provisioning a VM for an
agent that can't route through OpenRouter. Now both functions check the disabled
flag and show the disabled_reason to the user.

Also removes stale cursor references from spawn skill templates injected into
child VMs.

Agent: code-health

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-27 10:22:18 +00:00

769 lines
26 KiB
TypeScript

import "../unicode-detect.js"; // Must be first: configures TERM before clack reads it
import type { Manifest } from "../manifest.js";
import * as fs from "node:fs";
import * as p from "@clack/prompts";
import { getErrorMessage, isString } from "@openrouter/spawn-shared";
import pc from "picocolors";
import pkg from "../../package.json" with { type: "json" };
import { agentKeys, cloudKeys, isStaleCache, loadManifest, matrixStatus } from "../manifest.js";
import { validateIdentifier, validatePrompt } from "../security.js";
import { hasSavedOpenRouterKey } from "../shared/oauth.js";
import { PkgVersionSchema, parseJsonObj } from "../shared/parse.js";
import { getSpawnCloudConfigPath } from "../shared/paths.js";
import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
// ── Constants ────────────────────────────────────────────────────────────────
export const VERSION = pkg.version;
export const FETCH_TIMEOUT = 10_000; // 10 seconds
export const NAME_COLUMN_WIDTH = 18;
export { PkgVersionSchema };
// ── Helpers ──────────────────────────────────────────────────────────────────
export { getErrorMessage };
export function handleCancel(): never {
p.outro(pc.dim("Cancelled."));
process.exit(0);
}
async function withSpinner<T>(msg: string, fn: () => Promise<T>, doneMsg?: string): Promise<T> {
const s = p.spinner({
output: process.stderr,
});
s.start(msg);
const r = await asyncTryCatch(fn);
s.stop(r.ok ? (doneMsg ?? msg.replace(/\.{3}$/, "")) : pc.red("Failed"));
if (!r.ok) {
throw r.error;
}
return r.data;
}
export async function loadManifestWithSpinner(): Promise<Manifest> {
const manifest = await withSpinner("Loading manifest...", loadManifest);
if (isStaleCache()) {
p.log.warn("Using cached manifest (offline). Data may be outdated.");
}
return manifest;
}
export function validateNonEmptyString(value: string, fieldName: string, helpCommand: string): void {
if (!value || value.trim() === "") {
p.log.error(`${fieldName} is required but was not provided.`);
p.log.info(`Run ${pc.cyan(helpCommand)} to see all available options.`);
process.exit(1);
}
}
export function mapToSelectOptions<
T extends {
name: string;
description: string;
},
>(
keys: string[],
items: Record<string, T>,
hintOverrides?: Record<string, string>,
): Array<{
value: string;
label: string;
hint: string;
}> {
return keys.map((key) => ({
value: key,
label: items[key].name,
hint: hintOverrides?.[key] ?? items[key].description,
}));
}
export function getImplementedClouds(manifest: Manifest, agent: string): string[] {
return cloudKeys(manifest).filter((c: string): boolean => matrixStatus(manifest, c, agent) === "implemented");
}
// ── Fuzzy matching ───────────────────────────────────────────────────────────
/** Levenshtein distance between two strings */
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from(
{
length: m + 1,
},
() => Array(n + 1).fill(0),
);
for (let i = 0; i <= m; i++) {
dp[i][0] = i;
}
for (let j = 0; j <= n; j++) {
dp[0][j] = j;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
/** Find the closest match from a list of candidates (max distance 3) */
export function findClosestMatch(input: string, candidates: string[]): string | null {
let best: string | null = null;
let bestDist = Number.POSITIVE_INFINITY;
for (const candidate of candidates) {
const dist = levenshtein(input.toLowerCase(), candidate.toLowerCase());
if (dist < bestDist) {
bestDist = dist;
best = candidate;
}
}
return bestDist <= 3 ? best : null;
}
/**
* Find the closest matching key by checking both keys and display names.
* Returns the key (not display name) of the best match, or null if no match within distance 3.
*/
export function findClosestKeyByNameOrKey(
input: string,
keys: string[],
getName: (key: string) => string,
): string | null {
let bestKey: string | null = null;
let bestDist = Number.POSITIVE_INFINITY;
const lower = input.toLowerCase();
for (const key of keys) {
const keyDist = levenshtein(lower, key.toLowerCase());
if (keyDist < bestDist) {
bestDist = keyDist;
bestKey = key;
}
const nameDist = levenshtein(lower, getName(key).toLowerCase());
if (nameDist < bestDist) {
bestDist = nameDist;
bestKey = key;
}
}
return bestDist <= 3 ? bestKey : null;
}
// ── Entity resolution ────────────────────────────────────────────────────────
/**
* Resolve user input to a valid entity key (agent or cloud).
* Tries: exact key -> case-insensitive key -> display name match (case-insensitive).
* Returns the key if found, or null.
*/
function resolveEntityKey(manifest: Manifest, input: string, kind: "agent" | "cloud"): string | null {
const collection = getEntityCollection(manifest, kind);
if (collection[input]) {
if (kind === "agent" && manifest.agents[input].disabled) {
return null;
}
return input;
}
const keys = getEntityKeys(manifest, kind);
const lower = input.toLowerCase();
for (const key of keys) {
if (key.toLowerCase() === lower) {
return key;
}
}
for (const key of keys) {
if (collection[key].name.toLowerCase() === lower) {
return key;
}
}
return null;
}
export function resolveAgentKey(manifest: Manifest, input: string): string | null {
return resolveEntityKey(manifest, input, "agent");
}
export function resolveCloudKey(manifest: Manifest, input: string): string | null {
return resolveEntityKey(manifest, input, "cloud");
}
interface EntityDef {
label: string;
labelPlural: string;
listCmd: string;
opposite: string;
}
const ENTITY_DEFS: Record<"agent" | "cloud", EntityDef> = {
agent: {
label: "agent",
labelPlural: "agents",
listCmd: "spawn agents",
opposite: "cloud provider",
},
cloud: {
label: "cloud",
labelPlural: "clouds",
listCmd: "spawn clouds",
opposite: "agent",
},
};
function getEntityCollection(manifest: Manifest, kind: "agent" | "cloud") {
return kind === "agent" ? manifest.agents : manifest.clouds;
}
function getEntityKeys(manifest: Manifest, kind: "agent" | "cloud") {
return kind === "agent" ? agentKeys(manifest) : cloudKeys(manifest);
}
/** Suggest a typo correction by fuzzy-matching against a set of keys */
function suggestTypoCorrection(value: string, manifest: Manifest, kind: "agent" | "cloud"): string | null {
const collection = getEntityCollection(manifest, kind);
const keys = getEntityKeys(manifest, kind);
return findClosestKeyByNameOrKey(value, keys, (k) => collection[k].name);
}
/** Check if user provided an entity of the wrong kind and suggest correction */
function checkWrongKind(value: string, kind: "agent" | "cloud", manifest: Manifest, def: EntityDef): boolean {
const oppositeKind = kind === "agent" ? "cloud" : "agent";
const oppositeCollection = getEntityCollection(manifest, oppositeKind);
if (oppositeCollection[value]) {
const kindLabel = kind === "agent" ? "a cloud provider" : "an agent";
const wrongLabel = kind === "agent" ? "an agent" : "a cloud provider";
p.log.info(`"${value}" is ${kindLabel}, not ${wrongLabel}.`);
p.log.info(`Usage: ${pc.cyan("spawn <agent> <cloud>")}`);
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
return true;
}
return false;
}
/** Check for typo in same kind and suggest correction */
function checkSameKindTypo(
value: string,
kind: "agent" | "cloud",
manifest: Manifest,
def: EntityDef,
collection: Record<
string,
{
name: string;
}
>,
): boolean {
const match = suggestTypoCorrection(value, manifest, kind);
if (match) {
p.log.info(`Did you mean ${pc.cyan(match)} (${collection[match].name})?`);
p.log.info(` ${pc.cyan(`spawn ${match}`)}`);
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
return true;
}
return false;
}
/** Check for typo in opposite kind (swapped arguments) and suggest correction */
function checkOppositeKindTypo(value: string, kind: "agent" | "cloud", manifest: Manifest): boolean {
const oppositeKind = kind === "agent" ? "cloud" : "agent";
const oppositeMatch = suggestTypoCorrection(value, manifest, oppositeKind);
if (oppositeMatch) {
const oppositeDef = ENTITY_DEFS[oppositeKind];
const oppositeCollection = getEntityCollection(manifest, oppositeKind);
p.log.info(
`"${pc.bold(value)}" looks like ${oppositeDef.label} ${pc.cyan(oppositeMatch)} (${oppositeCollection[oppositeMatch].name}).`,
);
p.log.info("Did you swap the agent and cloud arguments?");
p.log.info(`Usage: ${pc.cyan("spawn <agent> <cloud>")}`);
return true;
}
return false;
}
/** Report validation error for an entity and return false, or return true if valid */
export function checkEntity(manifest: Manifest, value: string, kind: "agent" | "cloud"): boolean {
const def = ENTITY_DEFS[kind];
const collection = getEntityCollection(manifest, kind);
if (collection[value]) {
if (kind === "agent" && manifest.agents[value].disabled) {
p.log.error(`${pc.bold(manifest.agents[value].name)} is temporarily disabled.`);
if (manifest.agents[value].disabled_reason) {
p.log.info(manifest.agents[value].disabled_reason);
}
return false;
}
return true;
}
p.log.error(`Unknown ${def.label}: ${pc.bold(value)}`);
// Try different correction strategies
if (checkWrongKind(value, kind, manifest, def)) {
return false;
}
if (checkSameKindTypo(value, kind, manifest, def, collection)) {
return false;
}
if (checkOppositeKindTypo(value, kind, manifest)) {
return false;
}
p.log.info(`Run ${pc.cyan(def.listCmd)} to see available ${def.labelPlural}.`);
return false;
}
export function validateEntity(manifest: Manifest, value: string, kind: "agent" | "cloud"): void {
if (!checkEntity(manifest, value, kind)) {
process.exit(1);
}
}
export async function validateAndGetEntity(
value: string,
kind: "agent" | "cloud",
): Promise<
[
manifest: Manifest,
key: string,
]
> {
const def = ENTITY_DEFS[kind];
const capitalLabel = def.label.charAt(0).toUpperCase() + def.label.slice(1);
const r = tryCatch(() => validateIdentifier(value, `${capitalLabel} name`));
if (!r.ok) {
p.log.error(getErrorMessage(r.error));
process.exit(1);
}
validateNonEmptyString(value, `${capitalLabel} name`, def.listCmd);
const manifest = await loadManifestWithSpinner();
validateEntity(manifest, value, kind);
return [
manifest,
value,
];
}
export function validateImplementation(manifest: Manifest, cloud: string, agent: string): void {
const status = matrixStatus(manifest, cloud, agent);
if (status !== "implemented") {
const agentName = manifest.agents[agent].name;
const cloudName = manifest.clouds[cloud].name;
p.log.error(`${agentName} on ${cloudName} is not yet implemented.`);
const availableClouds = getImplementedClouds(manifest, agent);
if (availableClouds.length > 0) {
// Prioritize clouds where the user already has credentials
const { sortedClouds, credCount } = prioritizeCloudsByCredentials(availableClouds, manifest);
const examples = sortedClouds.slice(0, 3).map((c) => {
const hasCredsMarker = hasCloudCredentials(manifest.clouds[c].auth) ? " (ready)" : "";
return `spawn ${agent} ${c}${hasCredsMarker}`;
});
console.log();
p.log.info(
`${agentName} is available on ${availableClouds.length} cloud${availableClouds.length > 1 ? "s" : ""}. Try one of these:`,
);
for (const cmd of examples) {
p.log.info(` ${pc.cyan(cmd)}`);
}
if (availableClouds.length > 3) {
p.log.info(`\nRun ${pc.cyan(`spawn ${agent}`)} to see all ${availableClouds.length} options.`);
}
if (credCount > 0) {
console.log();
p.log.info(`${pc.green("ready")} = credentials already set`);
}
} else {
console.log();
p.log.info("This agent has no implemented cloud providers yet.");
p.log.info(`Run ${pc.cyan("spawn matrix")} to see the full availability matrix.`);
}
process.exit(1);
}
}
// ── Credential helpers ───────────────────────────────────────────────────────
/** Map of cloud keys to their CLI tool names */
const CLOUD_CLI_MAP: Record<string, string> = {
gcp: "gcloud",
aws: "aws",
sprite: "sprite",
hetzner: "hcloud",
digitalocean: "doctl",
};
/** Check if the relevant CLI tool for a cloud provider is installed */
export function hasCloudCli(cloud: string): boolean {
const cli = CLOUD_CLI_MAP[cloud];
if (!cli) {
return false;
}
return Bun.which(cli) !== null;
}
/** Sort clouds by credential/CLI availability and build hint overrides for the picker.
* Four tiers: credentials set > featured cloud > CLI installed > neither. */
export function prioritizeCloudsByCredentials(
clouds: string[],
manifest: Manifest,
featuredCloud?: string[],
): {
sortedClouds: string[];
hintOverrides: Record<string, string>;
credCount: number;
cliCount: number;
} {
const withCreds: string[] = [];
const featured: string[] = [];
const withCli: string[] = [];
const rest: string[] = [];
for (const c of clouds) {
if (hasCloudCredentials(manifest.clouds[c].auth)) {
withCreds.push(c);
} else if (featuredCloud?.includes(c)) {
featured.push(c);
} else if (hasCloudCli(c)) {
withCli.push(c);
} else {
rest.push(c);
}
}
const hintOverrides: Record<string, string> = {};
for (const c of withCreds) {
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — credentials detected`;
}
for (const c of featured) {
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — recommended`;
}
for (const c of withCli) {
hintOverrides[c] = `${manifest.clouds[c].price ?? ""} — CLI installed`;
}
for (const c of rest) {
hintOverrides[c] = `${manifest.clouds[c].price ?? ""}${manifest.clouds[c].description}`;
}
return {
sortedClouds: [
...withCreds,
...featured,
...withCli,
...rest,
],
hintOverrides,
credCount: withCreds.length,
cliCount: withCli.length,
};
}
/** Build hint overrides for the agent picker showing cloud count and credential readiness */
export function buildAgentPickerHints(manifest: Manifest): Record<string, string> {
const hints: Record<string, string> = {};
for (const agent of agentKeys(manifest)) {
const implClouds = getImplementedClouds(manifest, agent);
if (implClouds.length === 0) {
hints[agent] = "no clouds available yet";
continue;
}
const readyCount = implClouds.filter((c) => hasCloudCredentials(manifest.clouds[c].auth)).length;
const cloudLabel = `${implClouds.length} cloud${implClouds.length !== 1 ? "s" : ""}`;
if (readyCount > 0) {
hints[agent] = `${cloudLabel}, ${readyCount} ready`;
} else {
hints[agent] = cloudLabel;
}
}
return hints;
}
/** Extract environment variable names from a cloud's auth field (e.g. "HCLOUD_TOKEN" or "UPCLOUD_USERNAME + UPCLOUD_PASSWORD") */
export function parseAuthEnvVars(auth: string): string[] {
return auth
.split(/\s*\+\s*/)
.map((s) => s.trim())
.filter((s) => /^[A-Z][A-Z0-9_]{3,}$/.test(s));
}
/** Format an auth env var line showing whether it's already set or needs to be exported */
function formatAuthVarLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const hint = urlHint ? ` ${pc.dim(`# ${urlHint}`)}` : "";
return ` ${pc.cyan(`export ${varName}=...`)}${hint}`;
}
/** Check if a cloud's required auth env vars are all set in the environment */
export function hasCloudCredentials(auth: string): boolean {
const vars = parseAuthEnvVars(auth);
if (vars.length === 0) {
return false;
}
return vars.every((v) => !!process.env[v]);
}
/** Format a single credential env var as a status line (green if set, red if missing) */
export function formatCredStatusLine(varName: string, urlHint?: string): string {
if (process.env[varName]) {
return ` ${pc.green(varName)} ${pc.dim("-- set")}`;
}
const suffix = urlHint ? ` ${pc.dim(urlHint)}` : "";
return ` ${pc.red(varName)} ${pc.dim("-- not set")}${suffix}`;
}
/** Check if credentials are saved in ~/.config/spawn/{cloud}.json */
function hasCloudConfigCredentials(cloud: string): boolean {
return unwrapOr(
tryCatch(() => {
const configPath = getSpawnCloudConfigPath(cloud);
if (!fs.existsSync(configPath)) {
return false;
}
const content = fs.readFileSync(configPath, "utf-8");
const config = parseJsonObj(content);
if (!config) {
return false;
}
// Check if config has any non-empty credentials
return Object.values(config).some((v) => isString(v) && v.trim().length > 0);
}),
false,
);
}
export function collectMissingCredentials(authVars: string[], cloud?: string): string[] {
const missing: string[] = [];
if (!process.env.OPENROUTER_API_KEY && !hasSavedOpenRouterKey()) {
missing.push("OPENROUTER_API_KEY");
}
for (const v of authVars) {
if (!process.env[v]) {
missing.push(v);
}
}
// If the cloud has saved config credentials, all vars (including cloud-specific ones) are covered
if (missing.length > 0 && cloud && hasCloudConfigCredentials(cloud)) {
return [];
}
return missing;
}
function getCredentialGuidance(cloud: string, onlyOpenRouter: boolean): string {
if (onlyOpenRouter) {
return "You will be prompted to authenticate with OpenRouter during setup.";
}
return `Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions.`;
}
export async function preflightCredentialCheck(manifest: Manifest, cloud: string): Promise<void> {
const cloudAuth = manifest.clouds[cloud].auth;
if (cloudAuth.toLowerCase() === "none") {
return;
}
const authVars = parseAuthEnvVars(cloudAuth);
const missing = collectMissingCredentials(authVars, cloud);
if (missing.length === 0) {
return;
}
const cloudName = manifest.clouds[cloud].name;
p.log.warn(`Missing credentials for ${cloudName}: ${missing.map((v) => pc.cyan(v)).join(", ")}`);
const onlyOpenRouter = missing.length === 1 && missing[0] === "OPENROUTER_API_KEY";
p.log.info(getCredentialGuidance(cloud, onlyOpenRouter));
// No confirmation needed — the warning + guidance above is sufficient.
// The orchestration pipeline will prompt for credentials as needed.
}
/** Build auth hint string from cloud auth field for error messages */
export function getAuthHint(manifest: Manifest, cloud: string): string | undefined {
const authVars = parseAuthEnvVars(manifest.clouds[cloud].auth);
return authVars.length > 0 ? authVars.join(" + ") : undefined;
}
/** Check which required env vars are set vs missing and return specific hints */
export function credentialHints(cloud: string, authHint?: string, verb = "Missing or invalid"): string[] {
if (!authHint) {
return [
` - ${verb} credentials (run ${pc.cyan(`spawn ${cloud}`)} for setup)`,
];
}
// Parse individual env var names from the auth hint (e.g. "HCLOUD_TOKEN" or "UPCLOUD_USERNAME + UPCLOUD_PASSWORD")
const authVars = authHint
.split(/\s*\+\s*/)
.map((s) => s.trim())
.filter(Boolean);
const allVars = [
...authVars,
"OPENROUTER_API_KEY",
];
const missing = allVars.filter((v) => !process.env[v]);
if (missing.length === 0) {
// All credentials are set -- the issue is likely something else
return [
` - Credentials appear to be set (${allVars.map((v) => pc.cyan(v)).join(", ")})`,
" The error may be due to invalid or expired credentials",
` Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions`,
];
}
// Show which specific vars are missing
const lines: string[] = [];
lines.push(" - Missing credentials:");
for (const v of missing) {
lines.push(` ${pc.cyan(v)} -- not set`);
}
lines.push(` Run ${pc.cyan(`spawn ${cloud}`)} for setup instructions`);
return lines;
}
export function isInteractiveTTY(): boolean {
return !!(process.stdin.isTTY && process.stdout.isTTY);
}
/** Validate inputs for injection attacks (SECURITY) and check they're non-empty */
export function validateRunSecurity(agent: string, cloud: string, prompt?: string): void {
const r = tryCatch(() => {
validateIdentifier(agent, "Agent name");
validateIdentifier(cloud, "Cloud name");
if (prompt) {
validatePrompt(prompt);
}
});
if (!r.ok) {
p.log.error(getErrorMessage(r.error));
process.exit(1);
}
validateNonEmptyString(agent, "Agent name", "spawn agents");
validateNonEmptyString(cloud, "Cloud name", "spawn clouds");
}
/** Validate agent and cloud exist in manifest, showing all errors before exiting */
export function validateEntities(manifest: Manifest, agent: string, cloud: string): void {
const agentValid = checkEntity(manifest, agent, "agent");
const cloudValid = checkEntity(manifest, cloud, "cloud");
if (!agentValid || !cloudValid) {
process.exit(1);
}
validateImplementation(manifest, cloud, agent);
}
// ── Info helpers ─────────────────────────────────────────────────────────────
/** Print name, description, url, and notes for a manifest entry */
export function printInfoHeader(entry: { name: string; description: string; url?: string; notes?: string }): void {
console.log();
console.log(`${pc.bold(entry.name)} ${pc.dim("--")} ${entry.description}`);
if (entry.url) {
console.log(pc.dim(` ${entry.url}`));
}
if (entry.notes) {
console.log(pc.dim(` ${entry.notes}`));
}
}
/** Group keys by a classifier function (e.g., cloud type) */
export function groupByType(keys: string[], getType: (key: string) => string): Record<string, string[]> {
const byType: Record<string, string[]> = {};
for (const key of keys) {
const type = getType(key);
if (!byType[type]) {
byType[type] = [];
}
byType[type].push(key);
}
return byType;
}
/** Print a grouped list of items with command hints */
export function printGroupedList(
byType: Record<string, string[]>,
getName: (key: string) => string,
getHint: (key: string) => string,
indent = " ",
): void {
for (const [type, keys] of Object.entries(byType)) {
console.log(`${indent}${pc.dim(type)}`);
for (const key of keys) {
console.log(
`${indent} ${pc.green(key.padEnd(NAME_COLUMN_WIDTH))} ${getName(key).padEnd(NAME_COLUMN_WIDTH)} ${pc.dim(getHint(key))}`,
);
}
}
}
function checkAllCredentialsReady(auth: string): boolean {
const hasCreds = hasCloudCredentials(auth);
const hasOpenRouterKey = !!process.env.OPENROUTER_API_KEY;
return hasOpenRouterKey && (hasCreds || auth.toLowerCase() === "none");
}
function printAuthVariableStatus(authVars: string[], cloudUrl?: string): void {
console.log(formatAuthVarLine("OPENROUTER_API_KEY", "https://openrouter.ai/settings/keys"));
for (let i = 0; i < authVars.length; i++) {
console.log(formatAuthVarLine(authVars[i], i === 0 ? cloudUrl : undefined));
}
}
/** Print quick-start instructions showing credential status and example spawn command */
export function printQuickStart(opts: {
auth: string;
authVars: string[];
cloudUrl?: string;
spawnCmd?: string;
}): void {
console.log();
if (checkAllCredentialsReady(opts.auth) && opts.spawnCmd) {
console.log(pc.bold("Quick start:") + " " + pc.green("credentials detected -- ready to go"));
console.log(` ${pc.cyan(opts.spawnCmd)}`);
return;
}
console.log(pc.bold("Quick start:"));
printAuthVariableStatus(opts.authVars, opts.cloudUrl);
if (opts.spawnCmd) {
console.log(` ${pc.cyan(opts.spawnCmd)}`);
}
}
export function getImplementedAgents(manifest: Manifest, cloud: string): string[] {
return agentKeys(manifest).filter((a: string): boolean => matrixStatus(manifest, cloud, a) === "implemented");
}
/** Resolve an agent/cloud key to its display name, or return the key as-is */
export function resolveDisplayName(manifest: Manifest | null, key: string, kind: "agent" | "cloud"): string {
if (!manifest) {
return key;
}
const entry = kind === "agent" ? manifest.agents[key] : manifest.clouds[key];
return entry ? entry.name : key;
}
export function buildRetryCommand(agent: string, cloud: string, prompt?: string, spawnName?: string): string {
const safeName = spawnName ? spawnName.replace(/"/g, '\\"') : "";
const nameFlag = spawnName ? ` --name "${safeName}"` : "";
if (!prompt) {
return `spawn ${agent} ${cloud}${nameFlag}`;
}
if (prompt.length <= 80) {
const safe = prompt.replace(/"/g, '\\"');
return `spawn ${agent} ${cloud}${nameFlag} --prompt "${safe}"`;
}
// Long prompts: suggest --prompt-file instead of truncating into a broken command
return `spawn ${agent} ${cloud}${nameFlag} --prompt-file <your-prompt-file>`;
}