From c8fb2cc3b12782828dfff20e1ef486e7c22e0c04 Mon Sep 17 00:00:00 2001 From: Devansh Jain <31609257+devanshrj@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:29:56 -0800 Subject: [PATCH] refactor: unify CLI flag parsing across interactive and headless (#1137) Co-authored-by: cpacker --- src/cli/args.ts | 345 +++++++++++++++++ src/cli/flagUtils.ts | 78 ++++ src/cli/startupFlagValidation.ts | 37 ++ src/headless.ts | 325 ++++++++-------- src/index.ts | 352 ++++++++---------- src/tests/cli/args.test.ts | 133 +++++++ src/tests/cli/flag-utils.test.ts | 51 +++ src/tests/cli/startup-flag-validation.test.ts | 60 +++ src/tests/startup-flow.test.ts | 89 ++++- 9 files changed, 1091 insertions(+), 379 deletions(-) create mode 100644 src/cli/args.ts create mode 100644 src/cli/flagUtils.ts create mode 100644 src/cli/startupFlagValidation.ts create mode 100644 src/tests/cli/args.test.ts create mode 100644 src/tests/cli/flag-utils.test.ts create mode 100644 src/tests/cli/startup-flag-validation.test.ts diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..d056c3e --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,345 @@ +import { parseArgs } from "node:util"; + +export type CliFlagMode = "interactive" | "headless" | "both"; + +type CliFlagParserConfig = { + type: "string" | "boolean"; + short?: string; + multiple?: boolean; +}; + +type CliFlagHelpConfig = { + argLabel?: string; + description: string; + continuationLines?: string[]; +}; + +interface CliFlagDefinition { + parser: CliFlagParserConfig; + mode: CliFlagMode; + help?: CliFlagHelpConfig; +} + +export const CLI_FLAG_CATALOG = { + help: { + parser: { type: "boolean", short: "h" }, + mode: "both", + help: { description: "Show this help and exit" }, + }, + version: { + parser: { type: "boolean", short: "v" }, + mode: "both", + help: { description: "Print version and exit" }, + }, + info: { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Show current directory, skills, and pinned agents" }, + }, + continue: { + parser: { type: "boolean", short: "c" }, + mode: "both", + help: { + description: "Resume last session (agent + conversation) directly", + }, + }, + resume: { + parser: { type: "boolean", short: "r" }, + mode: "interactive", + help: { description: "Open agent selector UI after loading" }, + }, + conversation: { parser: { type: "string", short: "C" }, mode: "both" }, + "new-agent": { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Create new agent directly (skip profile selection)" }, + }, + new: { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Create new conversation (for concurrent sessions)" }, + }, + "init-blocks": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: + 'Comma-separated memory blocks to initialize when using --new-agent (e.g., "persona,skills")', + }, + }, + "base-tools": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: + 'Comma-separated base tools to attach when using --new-agent (e.g., "memory,web_search,fetch_webpage")', + }, + }, + agent: { + parser: { type: "string", short: "a" }, + mode: "both", + help: { argLabel: "", description: "Use a specific agent ID" }, + }, + name: { + parser: { type: "string", short: "n" }, + mode: "both", + help: { + argLabel: "", + description: + "Resume agent by name (from pinned agents, case-insensitive)", + }, + }, + model: { + parser: { type: "string", short: "m" }, + mode: "both", + help: { + argLabel: "", + description: + 'Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5")', + }, + }, + embedding: { parser: { type: "string" }, mode: "both" }, + system: { + parser: { type: "string", short: "s" }, + mode: "both", + help: { + argLabel: "", + description: + "System prompt ID or subagent name (applies to new or existing agent)", + }, + }, + "system-custom": { parser: { type: "string" }, mode: "both" }, + "system-append": { parser: { type: "string" }, mode: "headless" }, + "memory-blocks": { parser: { type: "string" }, mode: "both" }, + "block-value": { + parser: { type: "string", multiple: true }, + mode: "headless", + }, + toolset: { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: + 'Toolset mode: "auto", "codex", "default", or "gemini" (manual values override model-based auto-selection)', + }, + }, + prompt: { + parser: { type: "boolean", short: "p" }, + mode: "headless", + help: { description: "Headless prompt mode" }, + }, + // Advanced/internal flags intentionally hidden from --help output. + // They remain in the shared catalog for strict parsing parity. + run: { parser: { type: "boolean" }, mode: "headless" }, + tools: { parser: { type: "string" }, mode: "both" }, + allowedTools: { parser: { type: "string" }, mode: "both" }, + disallowedTools: { parser: { type: "string" }, mode: "both" }, + "permission-mode": { parser: { type: "string" }, mode: "both" }, + yolo: { parser: { type: "boolean" }, mode: "both" }, + "output-format": { + parser: { type: "string" }, + mode: "headless", + help: { + argLabel: "", + description: "Output format for headless mode (text, json, stream-json)", + continuationLines: ["Default: text"], + }, + }, + "input-format": { + parser: { type: "string" }, + mode: "headless", + help: { + argLabel: "", + description: "Input format for headless mode (stream-json)", + continuationLines: [ + "When set, reads JSON messages from stdin for bidirectional communication", + ], + }, + }, + "include-partial-messages": { + parser: { type: "boolean" }, + mode: "headless", + help: { + description: + "Emit stream_event wrappers for each chunk (stream-json only)", + }, + }, + "from-agent": { + parser: { type: "string" }, + mode: "headless", + help: { + argLabel: "", + description: "Inject agent-to-agent system reminder (headless mode)", + }, + }, + skills: { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: + "Custom path to skills directory (default: .skills in current directory)", + }, + }, + "skill-sources": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: + "Skill sources: all,bundled,global,agent,project (default: all)", + }, + }, + "pre-load-skills": { parser: { type: "string" }, mode: "headless" }, + // Legacy alias retained for backward compatibility; use --import in docs/errors. + "from-af": { parser: { type: "string" }, mode: "both" }, + import: { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: "Create agent from an AgentFile (.af) template", + continuationLines: ["Use @author/name to import from the agent registry"], + }, + }, + // Internal headless metadata tag assignment (not part of primary user help). + tags: { parser: { type: "string" }, mode: "headless" }, + memfs: { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Enable memory filesystem for this agent" }, + }, + "no-memfs": { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Disable memory filesystem for this agent" }, + }, + "memfs-startup": { + parser: { type: "string" }, + mode: "headless", + help: { + argLabel: "", + description: + "Startup memfs pull policy for headless mode: blocking, background, or skip", + }, + }, + "no-skills": { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Disable all skill sources" }, + }, + "no-bundled-skills": { + parser: { type: "boolean" }, + mode: "both", + help: { description: "Disable bundled skills only" }, + }, + "no-system-info-reminder": { + parser: { type: "boolean" }, + mode: "both", + help: { + description: + "Disable first-turn environment reminder (device/git/cwd context)", + }, + }, + "reflection-trigger": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: "Sleeptime trigger: off, step-count, compaction-event", + }, + }, + "reflection-behavior": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: "Sleeptime behavior: reminder, auto-launch", + }, + }, + "reflection-step-count": { + parser: { type: "string" }, + mode: "both", + help: { + argLabel: "", + description: "Sleeptime step-count interval (positive integer)", + }, + }, + "max-turns": { parser: { type: "string" }, mode: "headless" }, +} as const satisfies Record; + +const CLI_FLAG_ENTRIES = Object.entries(CLI_FLAG_CATALOG) as Array< + [string, CliFlagDefinition] +>; + +export const CLI_OPTIONS: Record = + Object.fromEntries( + CLI_FLAG_ENTRIES.map(([name, definition]) => [name, definition.parser]), + ); +// Column width for left-aligned flag labels in generated --help output. +const HELP_LABEL_WIDTH = 24; + +function formatHelpFlagLabel( + flagName: string, + definition: CliFlagDefinition, +): string { + const argLabel = definition.help?.argLabel; + const longName = `--${flagName}${argLabel ? ` ${argLabel}` : ""}`; + const short = definition.parser.short; + if (!short) { + return longName; + } + return `-${short}, ${longName}`; +} + +function formatHelpEntry( + flagName: string, + definition: CliFlagDefinition, +): string { + const help = definition.help; + if (!help) { + return ""; + } + + const label = formatHelpFlagLabel(flagName, definition); + const lines: string[] = []; + const continuation = help.continuationLines ?? []; + + if (label.length >= HELP_LABEL_WIDTH) { + lines.push(` ${label}`); + lines.push(` ${"".padEnd(HELP_LABEL_WIDTH)}${help.description}`); + } else { + const spacing = " ".repeat(HELP_LABEL_WIDTH - label.length); + lines.push(` ${label}${spacing}${help.description}`); + } + + for (const line of continuation) { + lines.push(` ${"".padEnd(HELP_LABEL_WIDTH)}${line}`); + } + return lines.join("\n"); +} + +export function renderCliOptionsHelp(): string { + return CLI_FLAG_ENTRIES.filter(([, definition]) => Boolean(definition.help)) + .map(([flagName, definition]) => formatHelpEntry(flagName, definition)) + .filter((entry) => entry.length > 0) + .join("\n"); +} + +export function preprocessCliArgs(args: string[]): string[] { + return args.map((arg) => (arg === "--conv" ? "--conversation" : arg)); +} + +export function parseCliArgs(args: string[], strict: boolean) { + return parseArgs({ + args, + options: CLI_OPTIONS, + strict, + allowPositionals: true, + }); +} + +export type ParsedCliArgs = ReturnType; diff --git a/src/cli/flagUtils.ts b/src/cli/flagUtils.ts new file mode 100644 index 0000000..985f1fd --- /dev/null +++ b/src/cli/flagUtils.ts @@ -0,0 +1,78 @@ +export function parseCsvListFlag( + value: string | undefined, +): string[] | undefined { + if (value === undefined) { + return undefined; + } + + const trimmed = value.trim(); + if (!trimmed || trimmed.toLowerCase() === "none") { + return []; + } + + return trimmed + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function normalizeConversationShorthandFlags(options: { + specifiedConversationId: string | null | undefined; + specifiedAgentId: string | null | undefined; +}) { + let { specifiedConversationId, specifiedAgentId } = options; + + if (specifiedConversationId?.startsWith("agent-")) { + if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) { + throw new Error( + `Conflicting agent IDs: --agent ${specifiedAgentId} vs --conv ${specifiedConversationId}`, + ); + } + specifiedAgentId = specifiedConversationId; + specifiedConversationId = "default"; + } + + return { specifiedConversationId, specifiedAgentId }; +} + +export function resolveImportFlagAlias(options: { + importFlagValue: string | undefined; + fromAfFlagValue: string | undefined; +}): string | undefined { + return options.importFlagValue ?? options.fromAfFlagValue; +} + +export function parsePositiveIntFlag(options: { + rawValue: string | undefined; + flagName: string; +}): number | undefined { + const { rawValue, flagName } = options; + if (rawValue === undefined) { + return undefined; + } + const parsed = Number.parseInt(rawValue, 10); + if (Number.isNaN(parsed) || parsed <= 0) { + throw new Error( + `--${flagName} must be a positive integer, got: ${rawValue}`, + ); + } + return parsed; +} + +export function parseJsonArrayFlag( + rawValue: string, + flagName: string, +): unknown[] { + let parsed: unknown; + try { + parsed = JSON.parse(rawValue); + } catch (error) { + throw new Error( + `Invalid --${flagName} JSON: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!Array.isArray(parsed)) { + throw new Error(`${flagName} must be a JSON array`); + } + return parsed; +} diff --git a/src/cli/startupFlagValidation.ts b/src/cli/startupFlagValidation.ts new file mode 100644 index 0000000..024f569 --- /dev/null +++ b/src/cli/startupFlagValidation.ts @@ -0,0 +1,37 @@ +export interface FlagConflictCheck { + when: unknown; + message: string; +} + +export function validateFlagConflicts(options: { + guard: unknown; + checks: FlagConflictCheck[]; +}): void { + const { guard, checks } = options; + if (!guard) { + return; + } + const firstConflict = checks.find((check) => Boolean(check.when)); + if (firstConflict) { + throw new Error(firstConflict.message); + } +} + +export function validateConversationDefaultRequiresAgent(options: { + specifiedConversationId: string | null | undefined; + specifiedAgentId: string | null | undefined; + forceNew: boolean | null | undefined; +}): void { + const { specifiedConversationId, specifiedAgentId, forceNew } = options; + if (specifiedConversationId === "default" && !specifiedAgentId && !forceNew) { + throw new Error("--conv default requires --agent "); + } +} + +export function validateRegistryHandleOrThrow(handle: string): void { + const normalized = handle.startsWith("@") ? handle.slice(1) : handle; + const parts = normalized.split("/"); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error(`Invalid registry handle "${handle}"`); + } +} diff --git a/src/headless.ts b/src/headless.ts index e2495a6..4145a50 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { parseArgs } from "node:util"; import type { Letta } from "@letta-ai/letta-client"; import { APIError } from "@letta-ai/letta-client/core/error"; import type { @@ -36,6 +35,14 @@ import { updateAgentLLMConfig, updateAgentSystemPrompt } from "./agent/modify"; import { resolveSkillSourcesSelection } from "./agent/skillSources"; import type { SkillSource } from "./agent/skills"; import { SessionStats } from "./agent/stats"; +import type { ParsedCliArgs } from "./cli/args"; +import { + normalizeConversationShorthandFlags, + parseCsvListFlag, + parseJsonArrayFlag, + parsePositiveIntFlag, + resolveImportFlagAlias, +} from "./cli/flagUtils"; import { createBuffers, type Line, @@ -60,6 +67,11 @@ import { type DrainStreamHook, drainStreamWithResume, } from "./cli/helpers/stream"; +import { + validateConversationDefaultRequiresAgent, + validateFlagConflicts, + validateRegistryHandleOrThrow, +} from "./cli/startupFlagValidation"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants"; import { mergeQueuedTurnInput, @@ -173,13 +185,16 @@ function parseReflectionOverrides( } if (stepCountRaw !== undefined) { - const parsed = Number.parseInt(stepCountRaw, 10); - if (Number.isNaN(parsed) || parsed <= 0) { + try { + overrides.stepCount = parsePositiveIntFlag({ + rawValue: stepCountRaw, + flagName: "reflection-step-count", + }); + } catch { throw new Error( `Invalid --reflection-step-count "${stepCountRaw}". Expected a positive integer.`, ); } - overrides.stepCount = parsed; } return overrides; @@ -248,68 +263,13 @@ async function applyReflectionOverrides( } export async function handleHeadlessCommand( - argv: string[], + parsedArgs: ParsedCliArgs, model?: string, skillsDirectoryOverride?: string, skillSourcesOverride?: SkillSource[], systemInfoReminderEnabledOverride?: boolean, ) { - // Parse CLI args - // Include all flags from index.ts to prevent them from being treated as positionals - const { values, positionals } = parseArgs({ - args: argv, - options: { - // Flags used in headless mode - continue: { type: "boolean", short: "c" }, - resume: { type: "boolean", short: "r" }, - conversation: { type: "string" }, - "new-agent": { type: "boolean" }, - new: { type: "boolean" }, // Deprecated - kept for helpful error message - agent: { type: "string", short: "a" }, - model: { type: "string", short: "m" }, - embedding: { type: "string" }, - system: { type: "string", short: "s" }, - "system-custom": { type: "string" }, - "system-append": { type: "string" }, - "memory-blocks": { type: "string" }, - "block-value": { type: "string", multiple: true }, - toolset: { type: "string" }, - prompt: { type: "boolean", short: "p" }, - "output-format": { type: "string" }, - "input-format": { type: "string" }, - "include-partial-messages": { type: "boolean" }, - "from-agent": { type: "string" }, - // Additional flags from index.ts that need to be filtered out - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, - run: { type: "boolean" }, - tools: { type: "string" }, - allowedTools: { type: "string" }, - disallowedTools: { type: "string" }, - "permission-mode": { type: "string" }, - yolo: { type: "boolean" }, - skills: { type: "string" }, - "skill-sources": { type: "string" }, - "pre-load-skills": { type: "string" }, - "init-blocks": { type: "string" }, - "base-tools": { type: "string" }, - "from-af": { type: "string" }, - tags: { type: "string" }, - - memfs: { type: "boolean" }, - "no-memfs": { type: "boolean" }, - "memfs-startup": { type: "string" }, // "blocking" | "background" | "skip" - "no-skills": { type: "boolean" }, - "no-bundled-skills": { type: "boolean" }, - "no-system-info-reminder": { type: "boolean" }, - "reflection-trigger": { type: "string" }, - "reflection-behavior": { type: "string" }, - "reflection-step-count": { type: "string" }, - "max-turns": { type: "string" }, // Maximum number of agentic turns - }, - strict: false, - allowPositionals: true, - }); + const { values, positionals } = parsedArgs; // Set tool filter if provided (controls which tools are loaded) if (values.tools !== undefined) { @@ -417,6 +377,7 @@ export async function handleHeadlessCommand( // Resolve agent (same logic as interactive mode) let agent: AgentState | null = null; let specifiedAgentId = values.agent as string | undefined; + const specifiedAgentName = values.name as string | undefined; let specifiedConversationId = values.conversation as string | undefined; const shouldContinue = values.continue as boolean | undefined; const forceNew = values["new-agent"] as boolean | undefined; @@ -452,7 +413,10 @@ export async function handleHeadlessCommand( ? "standard" : undefined; const shouldAutoEnableMemfsForNewAgent = !memfsFlag && !noMemfsFlag; - const fromAfFile = values["from-af"] as string | undefined; + const fromAfFile = resolveImportFlagAlias({ + importFlagValue: values.import as string | undefined, + fromAfFlagValue: values["from-af"] as string | undefined, + }); const preLoadSkillsRaw = values["pre-load-skills"] as string | undefined; const systemInfoReminderEnabled = systemInfoReminderEnabledOverride ?? @@ -487,31 +451,20 @@ export async function handleHeadlessCommand( } })(); - // Parse and validate base tools - let tags: string[] | undefined; - if (tagsRaw !== undefined) { - const trimmed = tagsRaw.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { - tags = []; - } else { - tags = trimmed - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - } - } + const tags = parseCsvListFlag(tagsRaw); // Parse and validate max-turns if provided let maxTurns: number | undefined; - if (maxTurnsRaw !== undefined) { - const parsed = parseInt(maxTurnsRaw, 10); - if (Number.isNaN(parsed) || parsed <= 0) { - console.error( - `Error: --max-turns must be a positive integer, got: ${maxTurnsRaw}`, - ); - process.exit(1); - } - maxTurns = parsed; + try { + maxTurns = parsePositiveIntFlag({ + rawValue: maxTurnsRaw, + flagName: "max-turns", + }); + } catch (error) { + console.error( + `Error: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); } if (preLoadSkillsRaw && resolvedSkillSources.length === 0) { @@ -521,21 +474,31 @@ export async function handleHeadlessCommand( process.exit(1); } - // Handle --conv {agent-id} shorthand: --conv agent-xyz → --agent agent-xyz --conv default - if (specifiedConversationId?.startsWith("agent-")) { - if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) { - console.error( - `Error: Conflicting agent IDs: --agent ${specifiedAgentId} vs --conv ${specifiedConversationId}`, - ); - process.exit(1); - } - specifiedAgentId = specifiedConversationId; - specifiedConversationId = "default"; + try { + const normalized = normalizeConversationShorthandFlags({ + specifiedConversationId, + specifiedAgentId, + }); + specifiedConversationId = normalized.specifiedConversationId ?? undefined; + specifiedAgentId = normalized.specifiedAgentId ?? undefined; + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); } // Validate --conv default requires --agent (unless --new-agent will create one) - if (specifiedConversationId === "default" && !specifiedAgentId && !forceNew) { - console.error("Error: --conv default requires --agent "); + try { + validateConversationDefaultRequiresAgent({ + specifiedConversationId, + specifiedAgentId, + forceNew, + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); console.error("Usage: letta --agent agent-xyz --conv default"); console.error(" or: letta --conv agent-xyz (shorthand)"); process.exit(1); @@ -561,53 +524,84 @@ export async function handleHeadlessCommand( } } - // Validate --conversation flag (mutually exclusive with agent-selection flags) - // Exception: --conv default requires --agent - if (specifiedConversationId && specifiedConversationId !== "default") { - if (specifiedAgentId) { - console.error("Error: --conversation cannot be used with --agent"); - process.exit(1); - } - if (forceNew) { - console.error("Error: --conversation cannot be used with --new-agent"); - process.exit(1); - } - if (fromAfFile) { - console.error("Error: --conversation cannot be used with --from-af"); - process.exit(1); - } - if (shouldContinue) { - console.error("Error: --conversation cannot be used with --continue"); - process.exit(1); - } + // Validate shared mutual-exclusion rules for startup flags. + try { + validateFlagConflicts({ + guard: specifiedConversationId && specifiedConversationId !== "default", + checks: [ + { + when: specifiedAgentId, + message: "--conversation cannot be used with --agent", + }, + { + when: specifiedAgentName, + message: "--conversation cannot be used with --name", + }, + { + when: forceNew, + message: "--conversation cannot be used with --new-agent", + }, + { + when: fromAfFile, + message: "--conversation cannot be used with --import", + }, + { + when: shouldContinue, + message: "--conversation cannot be used with --continue", + }, + ], + }); + + validateFlagConflicts({ + guard: forceNewConversation, + checks: [ + { + when: shouldContinue, + message: "--new cannot be used with --continue", + }, + { + when: specifiedConversationId, + message: "--new cannot be used with --conversation", + }, + ], + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); } - // Validate --new flag (create new conversation) - if (forceNewConversation) { - if (shouldContinue) { - console.error("Error: --new cannot be used with --continue"); - process.exit(1); - } - if (specifiedConversationId) { - console.error("Error: --new cannot be used with --conversation"); - process.exit(1); - } - } - - // Validate --from-af flag + // Validate --import flag (also accepts legacy --from-af) // Detect if it's a registry handle (e.g., @author/name) or a local file path let isRegistryImport = false; if (fromAfFile) { - if (specifiedAgentId) { - console.error("Error: --from-af cannot be used with --agent"); - process.exit(1); - } - if (shouldContinue) { - console.error("Error: --from-af cannot be used with --continue"); - process.exit(1); - } - if (forceNew) { - console.error("Error: --from-af cannot be used with --new"); + try { + validateFlagConflicts({ + guard: fromAfFile, + checks: [ + { + when: specifiedAgentId, + message: "--import cannot be used with --agent", + }, + { + when: specifiedAgentName, + message: "--import cannot be used with --name", + }, + { + when: shouldContinue, + message: "--import cannot be used with --continue", + }, + { + when: forceNew, + message: "--import cannot be used with --new-agent", + }, + ], + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); process.exit(1); } @@ -616,17 +610,29 @@ export async function handleHeadlessCommand( // Definitely a registry handle isRegistryImport = true; // Validate handle format - const normalized = fromAfFile.slice(1); - const parts = normalized.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { + try { + validateRegistryHandleOrThrow(fromAfFile); + } catch { console.error( - `Error: Invalid registry handle "${fromAfFile}". Use format: @author/agentname`, + `Error: Invalid registry handle "${fromAfFile}". Use format: letta --import @author/agentname`, ); process.exit(1); } } } + // Validate --name flag + if (specifiedAgentName) { + if (specifiedAgentId) { + console.error("Error: --name cannot be used with --agent"); + process.exit(1); + } + if (forceNew) { + console.error("Error: --name cannot be used with --new-agent"); + process.exit(1); + } + } + if (initBlocksRaw && !forceNew) { console.error( "Error: --init-blocks can only be used together with --new to control initial memory blocks.", @@ -634,18 +640,7 @@ export async function handleHeadlessCommand( process.exit(1); } - let initBlocks: string[] | undefined; - if (initBlocksRaw !== undefined) { - const trimmed = initBlocksRaw.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { - initBlocks = []; - } else { - initBlocks = trimmed - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - } - } + const initBlocks = parseCsvListFlag(initBlocksRaw); if (baseToolsRaw && !forceNew) { console.error( @@ -654,18 +649,7 @@ export async function handleHeadlessCommand( process.exit(1); } - let baseTools: string[] | undefined; - if (baseToolsRaw !== undefined) { - const trimmed = baseToolsRaw.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { - baseTools = []; - } else { - baseTools = trimmed - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - } - } + const baseTools = parseCsvListFlag(baseToolsRaw); // Validate system prompt options (--system and --system-custom are mutually exclusive) if (systemPromptPreset && systemCustom) { @@ -693,10 +677,9 @@ export async function handleHeadlessCommand( process.exit(1); } try { - memoryBlocks = JSON.parse(memoryBlocksJson); - if (!Array.isArray(memoryBlocks)) { - throw new Error("memory-blocks must be a JSON array"); - } + memoryBlocks = parseJsonArrayFlag(memoryBlocksJson, "memory-blocks") as + | Array<{ label: string; value: string; description?: string }> + | Array<{ blockId: string }>; // Validate each block has required fields for (const block of memoryBlocks) { const hasBlockId = @@ -715,7 +698,7 @@ export async function handleHeadlessCommand( } } catch (error) { console.error( - `Error: Invalid --memory-blocks JSON: ${error instanceof Error ? error.message : String(error)}`, + `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } diff --git a/src/index.ts b/src/index.ts index 80fbfe3..056a566 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,4 @@ #!/usr/bin/env bun -import { parseArgs } from "node:util"; import { APIError } from "@letta-ai/letta-client/core/error"; import type { AgentState } from "@letta-ai/letta-client/resources/agents/agents"; import type { Message } from "@letta-ai/letta-client/resources/agents/messages"; @@ -21,10 +20,27 @@ import { import { updateAgentLLMConfig, updateAgentSystemPrompt } from "./agent/modify"; import { resolveSkillSourcesSelection } from "./agent/skillSources"; import { LETTA_CLOUD_API_URL } from "./auth/oauth"; +import { + type ParsedCliArgs, + parseCliArgs, + preprocessCliArgs, + renderCliOptionsHelp, +} from "./cli/args"; import { ConversationSelector } from "./cli/components/ConversationSelector"; +import { + normalizeConversationShorthandFlags, + parseCsvListFlag, + parseJsonArrayFlag, + resolveImportFlagAlias, +} from "./cli/flagUtils"; import { formatErrorDetails } from "./cli/helpers/errorFormatter"; import type { ApprovalRequest } from "./cli/helpers/stream"; import { ProfileSelectionInline } from "./cli/profile-selection"; +import { + validateConversationDefaultRequiresAgent, + validateFlagConflicts, + validateRegistryHandleOrThrow, +} from "./cli/startupFlagValidation"; import { runSubcommand } from "./cli/subcommands/router"; import { permissionMode } from "./permissions/mode"; import { settingsManager } from "./settings-manager"; @@ -64,44 +80,7 @@ USAGE letta blocks ... Blocks subcommands (JSON-only) OPTIONS - -h, --help Show this help and exit - -v, --version Print version and exit - --info Show current directory, skills, and pinned agents - --continue Resume last session (agent + conversation) directly - -r, --resume Open agent selector UI after loading - --new Create new conversation (for concurrent sessions) - --new-agent Create new agent directly (skip profile selection) - --init-blocks Comma-separated memory blocks to initialize when using --new-agent (e.g., "persona,skills") - --base-tools Comma-separated base tools to attach when using --new-agent (e.g., "memory,web_search,fetch_webpage") - -a, --agent Use a specific agent ID - -n, --name Resume agent by name (from pinned agents, case-insensitive) - -m, --model Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5") - -s, --system System prompt ID or subagent name (applies to new or existing agent) - --toolset Toolset mode: "auto", "codex", "default", or "gemini" (manual values override model-based auto-selection) - -p, --prompt Headless prompt mode - --output-format Output format for headless mode (text, json, stream-json) - Default: text - --input-format Input format for headless mode (stream-json) - When set, reads JSON messages from stdin for bidirectional communication - --include-partial-messages - Emit stream_event wrappers for each chunk (stream-json only) - --from-agent Inject agent-to-agent system reminder (headless mode) - --skills Custom path to skills directory (default: .skills in current directory) - --skill-sources Skill sources: all,bundled,global,agent,project (default: all) - --no-skills Disable all skill sources - --no-bundled-skills Disable bundled skills only - --import Create agent from an AgentFile (.af) template - Use @author/name to import from the agent registry - --memfs Enable memory filesystem for this agent - --no-memfs Disable memory filesystem for this agent - --no-system-info-reminder - Disable first-turn environment reminder (device/git/cwd context) - --reflection-trigger - Sleeptime trigger: off, step-count, compaction-event - --reflection-behavior - Sleeptime behavior: reminder, auto-launch - --reflection-step-count - Sleeptime step-count interval (positive integer) +${renderCliOptionsHelp()} SUBCOMMANDS (JSON-only) letta memfs status --agent @@ -398,69 +377,14 @@ async function main(): Promise { } }); - // Parse command-line arguments (Bun-idiomatic approach using parseArgs) - // Preprocess args to support --conv as alias for --conversation - const processedArgs = process.argv.map((arg) => - arg === "--conv" ? "--conversation" : arg, - ); + // Parse command-line arguments from a shared schema used by both TUI and headless flows. + // Preprocess args to support --conv as an alias for --conversation. + const processedArgs = preprocessCliArgs(process.argv); - let values: Record; - let positionals: string[]; + let values: ParsedCliArgs["values"]; + let positionals: ParsedCliArgs["positionals"]; try { - const parsed = parseArgs({ - args: processedArgs, - options: { - help: { type: "boolean", short: "h" }, - version: { type: "boolean", short: "v" }, - info: { type: "boolean" }, - continue: { type: "boolean" }, // Deprecated - kept for error message - resume: { type: "boolean", short: "r" }, // Resume last session (or specific conversation with --conversation) - conversation: { type: "string", short: "C" }, // Specific conversation ID to resume (--conv alias supported) - "new-agent": { type: "boolean" }, // Force create a new agent - new: { type: "boolean" }, // Deprecated - kept for helpful error message - "init-blocks": { type: "string" }, - "base-tools": { type: "string" }, - agent: { type: "string", short: "a" }, - name: { type: "string", short: "n" }, - model: { type: "string", short: "m" }, - embedding: { type: "string" }, - system: { type: "string", short: "s" }, - "system-custom": { type: "string" }, - "system-append": { type: "string" }, - "memory-blocks": { type: "string" }, - "block-value": { type: "string", multiple: true }, - toolset: { type: "string" }, - prompt: { type: "boolean", short: "p" }, - run: { type: "boolean" }, - tools: { type: "string" }, - allowedTools: { type: "string" }, - disallowedTools: { type: "string" }, - "permission-mode": { type: "string" }, - yolo: { type: "boolean" }, - "output-format": { type: "string" }, - "input-format": { type: "string" }, - "include-partial-messages": { type: "boolean" }, - "from-agent": { type: "string" }, - skills: { type: "string" }, - "skill-sources": { type: "string" }, - "pre-load-skills": { type: "string" }, - "from-af": { type: "string" }, - import: { type: "string" }, - tags: { type: "string" }, - - memfs: { type: "boolean" }, - "no-memfs": { type: "boolean" }, - "no-skills": { type: "boolean" }, - "no-bundled-skills": { type: "boolean" }, - "no-system-info-reminder": { type: "boolean" }, - "reflection-trigger": { type: "string" }, - "reflection-behavior": { type: "string" }, - "reflection-step-count": { type: "string" }, - "max-turns": { type: "string" }, - }, - strict: true, - allowPositionals: true, - }); + const parsed = parseCliArgs(processedArgs, true); values = parsed.values; positionals = parsed.positionals; } catch (error) { @@ -532,22 +456,31 @@ async function main(): Promise { const initBlocksRaw = values["init-blocks"] as string | undefined; const baseToolsRaw = values["base-tools"] as string | undefined; let specifiedAgentId = (values.agent as string | undefined) ?? null; - - // Handle --conv {agent-id} shorthand: --conv agent-xyz → --agent agent-xyz --conv default - if (specifiedConversationId?.startsWith("agent-")) { - if (specifiedAgentId && specifiedAgentId !== specifiedConversationId) { - console.error( - `Error: Conflicting agent IDs: --agent ${specifiedAgentId} vs --conv ${specifiedConversationId}`, - ); - process.exit(1); - } - specifiedAgentId = specifiedConversationId; - specifiedConversationId = "default"; + try { + const normalized = normalizeConversationShorthandFlags({ + specifiedConversationId, + specifiedAgentId, + }); + specifiedConversationId = normalized.specifiedConversationId ?? null; + specifiedAgentId = normalized.specifiedAgentId ?? null; + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); } // Validate --conv default requires --agent (unless --new-agent will create one) - if (specifiedConversationId === "default" && !specifiedAgentId && !forceNew) { - console.error("Error: --conv default requires --agent "); + try { + validateConversationDefaultRequiresAgent({ + specifiedConversationId, + specifiedAgentId, + forceNew, + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); console.error("Usage: letta --agent agent-xyz --conv default"); console.error(" or: letta --conv agent-xyz (shorthand)"); process.exit(1); @@ -593,9 +526,10 @@ async function main(): Promise { process.exit(1); } })(); - const fromAfFile = - (values.import as string | undefined) ?? - (values["from-af"] as string | undefined); + const fromAfFile = resolveImportFlagAlias({ + importFlagValue: values.import as string | undefined, + fromAfFlagValue: values["from-af"] as string | undefined, + }); const isHeadless = values.prompt || values.run || !process.stdin.isTTY; // Fail if an unknown command/argument is passed (and we're not in headless mode where it might be a prompt) @@ -613,19 +547,7 @@ async function main(): Promise { process.exit(1); } - let initBlocks: string[] | undefined; - if (initBlocksRaw !== undefined) { - const trimmed = initBlocksRaw.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { - // Explicitly requested zero blocks - initBlocks = []; - } else { - initBlocks = trimmed - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - } - } + const initBlocks = parseCsvListFlag(initBlocksRaw); // --base-tools only makes sense when creating a brand new agent if (baseToolsRaw && !forceNew) { @@ -635,18 +557,7 @@ async function main(): Promise { process.exit(1); } - let baseTools: string[] | undefined; - if (baseToolsRaw !== undefined) { - const trimmed = baseToolsRaw.trim(); - if (!trimmed || trimmed.toLowerCase() === "none") { - baseTools = []; - } else { - baseTools = trimmed - .split(",") - .map((name) => name.trim()) - .filter((name) => name.length > 0); - } - } + const baseTools = parseCsvListFlag(baseToolsRaw); // Validate toolset if provided if ( @@ -697,10 +608,10 @@ async function main(): Promise { | undefined; if (memoryBlocksJson) { try { - memoryBlocks = JSON.parse(memoryBlocksJson); - if (!Array.isArray(memoryBlocks)) { - throw new Error("memory-blocks must be a JSON array"); - } + memoryBlocks = parseJsonArrayFlag( + memoryBlocksJson, + "memory-blocks", + ) as Array<{ label: string; value: string; description?: string }>; // Validate each block has required fields for (const block of memoryBlocks) { if ( @@ -714,75 +625,95 @@ async function main(): Promise { } } catch (error) { console.error( - `Error: Invalid --memory-blocks JSON: ${error instanceof Error ? error.message : String(error)}`, + `Error: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); } } - // Validate --conversation flag (mutually exclusive with agent-selection flags) - // Exception: --conv default requires --agent - if (specifiedConversationId && specifiedConversationId !== "default") { - if (specifiedAgentId) { - console.error("Error: --conversation cannot be used with --agent"); - process.exit(1); - } - if (specifiedAgentName) { - console.error("Error: --conversation cannot be used with --name"); - process.exit(1); - } - if (forceNew) { - console.error("Error: --conversation cannot be used with --new-agent"); - process.exit(1); - } - if (fromAfFile) { - console.error("Error: --conversation cannot be used with --import"); - process.exit(1); - } - if (shouldResume) { - console.error("Error: --conversation cannot be used with --resume"); - process.exit(1); - } - if (shouldContinue) { - console.error("Error: --conversation cannot be used with --continue"); - process.exit(1); - } - } + // Validate shared mutual-exclusion rules for startup flags. + try { + validateFlagConflicts({ + guard: specifiedConversationId && specifiedConversationId !== "default", + checks: [ + { + when: specifiedAgentId, + message: "--conversation cannot be used with --agent", + }, + { + when: specifiedAgentName, + message: "--conversation cannot be used with --name", + }, + { + when: forceNew, + message: "--conversation cannot be used with --new-agent", + }, + { + when: fromAfFile, + message: "--conversation cannot be used with --import", + }, + { + when: shouldResume, + message: "--conversation cannot be used with --resume", + }, + { + when: shouldContinue, + message: "--conversation cannot be used with --continue", + }, + ], + }); - // Validate --new flag (create new conversation) - if (forceNewConversation) { - if (shouldContinue) { - console.error("Error: --new cannot be used with --continue"); - process.exit(1); - } - if (specifiedConversationId) { - console.error("Error: --new cannot be used with --conversation"); - process.exit(1); - } - if (shouldResume) { - console.error("Error: --new cannot be used with --resume"); - process.exit(1); - } + validateFlagConflicts({ + guard: forceNewConversation, + checks: [ + { + when: shouldContinue, + message: "--new cannot be used with --continue", + }, + { + when: specifiedConversationId, + message: "--new cannot be used with --conversation", + }, + { when: shouldResume, message: "--new cannot be used with --resume" }, + ], + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); + process.exit(1); } // Validate --import flag (also accepts legacy --from-af) // Detect if it's a registry handle (e.g., @author/name) or a local file path let isRegistryImport = false; if (fromAfFile) { - if (specifiedAgentId) { - console.error("Error: --import cannot be used with --agent"); - process.exit(1); - } - if (specifiedAgentName) { - console.error("Error: --import cannot be used with --name"); - process.exit(1); - } - if (shouldResume) { - console.error("Error: --import cannot be used with --resume"); - process.exit(1); - } - if (forceNew) { - console.error("Error: --import cannot be used with --new"); + try { + validateFlagConflicts({ + guard: fromAfFile, + checks: [ + { + when: specifiedAgentId, + message: "--import cannot be used with --agent", + }, + { + when: specifiedAgentName, + message: "--import cannot be used with --name", + }, + { + when: shouldResume, + message: "--import cannot be used with --resume", + }, + { + when: forceNew, + message: "--import cannot be used with --new-agent", + }, + ], + }); + } catch (error) { + console.error( + error instanceof Error ? `Error: ${error.message}` : String(error), + ); process.exit(1); } @@ -791,9 +722,9 @@ async function main(): Promise { // Definitely a registry handle isRegistryImport = true; // Validate handle format - const normalized = fromAfFile.slice(1); - const parts = normalized.split("/"); - if (parts.length !== 2 || !parts[0] || !parts[1]) { + try { + validateRegistryHandleOrThrow(fromAfFile); + } catch { console.error( `Error: Invalid registry handle "${fromAfFile}". Use format: letta --import @author/agentname`, ); @@ -818,7 +749,7 @@ async function main(): Promise { process.exit(1); } if (forceNew) { - console.error("Error: --name cannot be used with --new"); + console.error("Error: --name cannot be used with --new-agent"); process.exit(1); } } @@ -986,9 +917,16 @@ async function main(): Promise { await loadTools(modelForTools); markMilestone("TOOLS_LOADED"); + // Keep headless startup in sync with interactive name resolution. + // If --name resolved to an agent ID, pass that through as --agent. + const headlessValues = + specifiedAgentId && values.agent !== specifiedAgentId + ? { ...values, agent: specifiedAgentId } + : values; + const { handleHeadlessCommand } = await import("./headless"); await handleHeadlessCommand( - processedArgs, + { values: headlessValues, positionals }, specifiedModel, skillsDirectory, resolvedSkillSources, diff --git a/src/tests/cli/args.test.ts b/src/tests/cli/args.test.ts new file mode 100644 index 0000000..227d4ae --- /dev/null +++ b/src/tests/cli/args.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "bun:test"; +import { + CLI_FLAG_CATALOG, + CLI_OPTIONS, + parseCliArgs, + preprocessCliArgs, + renderCliOptionsHelp, +} from "../../cli/args"; + +describe("shared CLI arg schema", () => { + test("catalog is the single source of truth for parser mapping and mode support", () => { + const catalogKeys = Object.keys(CLI_FLAG_CATALOG).sort(); + const optionKeys = Object.keys(CLI_OPTIONS).sort(); + expect(optionKeys).toEqual(catalogKeys); + + const validModes = new Set(["interactive", "headless", "both"]); + const validTypes = new Set(["boolean", "string"]); + + for (const [flagName, definition] of Object.entries(CLI_FLAG_CATALOG)) { + expect(validModes.has(definition.mode)).toBe(true); + expect(validTypes.has(definition.parser.type)).toBe(true); + expect(CLI_OPTIONS[flagName]).toEqual(definition.parser); + } + }); + + test("mode lookups include shared flags and exclude opposite-mode-only flags", () => { + const getFlagsForMode = (mode: "headless" | "interactive") => + Object.entries(CLI_FLAG_CATALOG) + .filter( + ([, definition]) => + definition.mode === "both" || definition.mode === mode, + ) + .map(([name]) => name); + const headlessFlags = getFlagsForMode("headless"); + const interactiveFlags = getFlagsForMode("interactive"); + + expect(headlessFlags).toContain("memfs-startup"); + expect(headlessFlags).not.toContain("resume"); + expect(interactiveFlags).toContain("resume"); + expect(interactiveFlags).not.toContain("memfs-startup"); + expect(headlessFlags).toContain("agent"); + expect(interactiveFlags).toContain("agent"); + }); + + test("rendered OPTIONS help is generated from catalog metadata", () => { + const help = renderCliOptionsHelp(); + expect(help).toContain("-h, --help"); + expect(help).toContain("-c, --continue"); + expect(help).toContain("--memfs-startup "); + expect(help).toContain("Default: text"); + expect(help).not.toContain("--run"); + + for (const [flagName, definition] of Object.entries( + CLI_FLAG_CATALOG, + ) as Array<[string, { help?: unknown }]>) { + if (!definition.help) continue; + expect(help).toContain(`--${flagName}`); + } + }); + + test("normalizes --conv alias to --conversation", () => { + const parsed = parseCliArgs( + preprocessCliArgs([ + "node", + "script", + "--conv", + "conv-123", + "-p", + "hello", + ]), + true, + ); + expect(parsed.values.conversation).toBe("conv-123"); + expect(parsed.positionals.slice(2).join(" ")).toBe("hello"); + }); + + test("recognizes headless-specific startup flags in strict mode", () => { + const parsed = parseCliArgs( + preprocessCliArgs([ + "node", + "script", + "-p", + "hello", + "--memfs-startup", + "background", + "--pre-load-skills", + "skill-a,skill-b", + "--max-turns", + "3", + "--block-value", + "persona=hello", + ]), + true, + ); + expect(parsed.values["memfs-startup"]).toBe("background"); + expect(parsed.values["pre-load-skills"]).toBe("skill-a,skill-b"); + expect(parsed.values["max-turns"]).toBe("3"); + expect(parsed.values["block-value"]).toEqual(["persona=hello"]); + }); + + test("treats --import argument as a flag value, not prompt text", () => { + const parsed = parseCliArgs( + preprocessCliArgs([ + "node", + "script", + "-p", + "hello", + "--import", + "@author/agent", + ]), + true, + ); + expect(parsed.values.import).toBe("@author/agent"); + expect(parsed.positionals.slice(2).join(" ")).toBe("hello"); + }); + + test("supports short aliases used by headless and interactive modes", () => { + const parsed = parseCliArgs( + preprocessCliArgs([ + "node", + "script", + "-p", + "hello", + "-c", + "-C", + "conv-123", + ]), + true, + ); + expect(parsed.values.continue).toBe(true); + expect(parsed.values.conversation).toBe("conv-123"); + }); +}); diff --git a/src/tests/cli/flag-utils.test.ts b/src/tests/cli/flag-utils.test.ts new file mode 100644 index 0000000..20a3e12 --- /dev/null +++ b/src/tests/cli/flag-utils.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { + parseCsvListFlag, + parseJsonArrayFlag, + parsePositiveIntFlag, + resolveImportFlagAlias, +} from "../../cli/flagUtils"; + +describe("flag utils", () => { + test("parseCsvListFlag handles undefined and none", () => { + expect(parseCsvListFlag(undefined)).toBeUndefined(); + expect(parseCsvListFlag("none")).toEqual([]); + expect(parseCsvListFlag("a, b ,c")).toEqual(["a", "b", "c"]); + }); + + test("resolveImportFlagAlias prefers --import", () => { + expect( + resolveImportFlagAlias({ + importFlagValue: "@author/agent", + fromAfFlagValue: "path.af", + }), + ).toBe("@author/agent"); + expect( + resolveImportFlagAlias({ + importFlagValue: undefined, + fromAfFlagValue: "path.af", + }), + ).toBe("path.af"); + }); + + test("parsePositiveIntFlag validates positive integers", () => { + expect( + parsePositiveIntFlag({ + rawValue: "3", + flagName: "max-turns", + }), + ).toBe(3); + expect(() => + parsePositiveIntFlag({ rawValue: "0", flagName: "max-turns" }), + ).toThrow("--max-turns must be a positive integer"); + }); + + test("parseJsonArrayFlag parses arrays and rejects non-arrays", () => { + expect( + parseJsonArrayFlag('[{"label":"persona"}]', "memory-blocks"), + ).toEqual([{ label: "persona" }]); + expect(() => + parseJsonArrayFlag('{"label":"persona"}', "memory-blocks"), + ).toThrow("memory-blocks must be a JSON array"); + }); +}); diff --git a/src/tests/cli/startup-flag-validation.test.ts b/src/tests/cli/startup-flag-validation.test.ts new file mode 100644 index 0000000..0e17d68 --- /dev/null +++ b/src/tests/cli/startup-flag-validation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { + validateConversationDefaultRequiresAgent, + validateFlagConflicts, + validateRegistryHandleOrThrow, +} from "../../cli/startupFlagValidation"; + +describe("startup flag validation helpers", () => { + test("conversation default requires agent unless new-agent is set", () => { + expect(() => + validateConversationDefaultRequiresAgent({ + specifiedConversationId: "default", + specifiedAgentId: null, + forceNew: false, + }), + ).toThrow("--conv default requires --agent "); + + expect(() => + validateConversationDefaultRequiresAgent({ + specifiedConversationId: "default", + specifiedAgentId: "agent-123", + forceNew: false, + }), + ).not.toThrow(); + }); + + test("conflict helpers throw the first matching conflict", () => { + expect(() => + validateFlagConflicts({ + guard: true, + checks: [ + { when: true, message: "conversation conflict" }, + { when: true, message: "should not hit second" }, + ], + }), + ).toThrow("conversation conflict"); + + expect(() => + validateFlagConflicts({ + guard: true, + checks: [{ when: true, message: "new conflict" }], + }), + ).toThrow("new conflict"); + + expect(() => + validateFlagConflicts({ + guard: "@author/agent", + checks: [{ when: true, message: "import conflict" }], + }), + ).toThrow("import conflict"); + }); + + test("registry handle validator accepts valid handles and rejects invalid ones", () => { + expect(() => validateRegistryHandleOrThrow("@author/agent")).not.toThrow(); + expect(() => validateRegistryHandleOrThrow("author/agent")).not.toThrow(); + expect(() => validateRegistryHandleOrThrow("@author")).toThrow( + 'Invalid registry handle "@author"', + ); + }); +}); diff --git a/src/tests/startup-flow.test.ts b/src/tests/startup-flow.test.ts index 367b024..ed7e61f 100644 --- a/src/tests/startup-flow.test.ts +++ b/src/tests/startup-flow.test.ts @@ -114,6 +114,19 @@ describe("Startup Flow - Flag Conflicts", () => { ); }); + test("--conversation conflicts with legacy --from-af using canonical --import error text", async () => { + const result = await runCli( + ["--conversation", "conv-123", "--from-af", "test.af"], + { expectExit: 1 }, + ); + expect(result.stderr).toContain( + "--conversation cannot be used with --import", + ); + expect(result.stderr).not.toContain( + "--conversation cannot be used with --from-af", + ); + }); + test("--conversation conflicts with --name", async () => { const result = await runCli( ["--conversation", "conv-123", "--name", "MyAgent"], @@ -123,6 +136,14 @@ describe("Startup Flow - Flag Conflicts", () => { "--conversation cannot be used with --name", ); }); + + test("--import conflicts with --name (including legacy --from-af alias)", async () => { + const result = await runCli(["--from-af", "test.af", "--name", "MyAgent"], { + expectExit: 1, + }); + expect(result.stderr).toContain("--import cannot be used with --name"); + expect(result.stderr).not.toContain("--from-af cannot be used with --name"); + }); }); describe("Startup Flow - Smoke", () => { @@ -130,7 +151,20 @@ describe("Startup Flow - Smoke", () => { const result = await runCli(["--name", "MyAgent", "--new-agent"], { expectExit: 1, }); - expect(result.stderr).toContain("--name cannot be used with --new"); + expect(result.stderr).toContain("--name cannot be used with --new-agent"); + }); + + test("--new + --name does not conflict (new conversation on named agent)", async () => { + const result = await runCli( + ["-p", "Say OK", "--new", "--name", "NonExistentAgent999"], + { expectExit: 1 }, + ); + // Should get past flag validation regardless of whether credentials exist. + expect(result.stderr).not.toContain("cannot be used with"); + expect( + result.stderr.includes("NonExistentAgent999") || + result.stderr.includes("Missing LETTA_API_KEY"), + ).toBe(true); }); test("--new-agent headless parses and reaches credential check", async () => { @@ -151,4 +185,57 @@ describe("Startup Flow - Smoke", () => { expect(result.stderr).toContain("Missing LETTA_API_KEY"); expect(result.stderr).not.toContain("Invalid toolset"); }); + + test("--memfs-startup is accepted for headless startup", async () => { + const result = await runCli( + ["--new-agent", "-p", "Say OK", "--memfs-startup", "background"], + { + expectExit: 1, + }, + ); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Unknown option '--memfs-startup'"); + }); + + test("-c alias for --continue is accepted", async () => { + const result = await runCli(["-p", "Say OK", "-c"], { + expectExit: 1, + }); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Unknown option '-c'"); + }); + + test("-C alias for --conversation is accepted", async () => { + const result = await runCli(["-p", "Say OK", "-C", "conv-123"], { + expectExit: 1, + }); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Unknown option '-C'"); + }); + + test("--import handle is accepted in headless mode", async () => { + const result = await runCli(["--import", "@author/agent", "-p", "Say OK"], { + expectExit: 1, + }); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Invalid registry handle"); + }); + + test("--max-turns and --pre-load-skills are accepted in headless mode", async () => { + const result = await runCli( + [ + "--new-agent", + "-p", + "Say OK", + "--max-turns", + "2", + "--pre-load-skills", + "foo,bar", + ], + { expectExit: 1 }, + ); + expect(result.stderr).toContain("Missing LETTA_API_KEY"); + expect(result.stderr).not.toContain("Unknown option '--max-turns'"); + expect(result.stderr).not.toContain("Unknown option '--pre-load-skills'"); + }); });