refactor: unify CLI flag parsing across interactive and headless (#1137)
Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
345
src/cli/args.ts
Normal file
345
src/cli/args.ts
Normal file
@@ -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: "<list>",
|
||||
description:
|
||||
'Comma-separated memory blocks to initialize when using --new-agent (e.g., "persona,skills")',
|
||||
},
|
||||
},
|
||||
"base-tools": {
|
||||
parser: { type: "string" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<list>",
|
||||
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: "<id>", description: "Use a specific agent ID" },
|
||||
},
|
||||
name: {
|
||||
parser: { type: "string", short: "n" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<name>",
|
||||
description:
|
||||
"Resume agent by name (from pinned agents, case-insensitive)",
|
||||
},
|
||||
},
|
||||
model: {
|
||||
parser: { type: "string", short: "m" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<id>",
|
||||
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: "<id>",
|
||||
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: "<name>",
|
||||
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: "<fmt>",
|
||||
description: "Output format for headless mode (text, json, stream-json)",
|
||||
continuationLines: ["Default: text"],
|
||||
},
|
||||
},
|
||||
"input-format": {
|
||||
parser: { type: "string" },
|
||||
mode: "headless",
|
||||
help: {
|
||||
argLabel: "<fmt>",
|
||||
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: "<id>",
|
||||
description: "Inject agent-to-agent system reminder (headless mode)",
|
||||
},
|
||||
},
|
||||
skills: {
|
||||
parser: { type: "string" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<path>",
|
||||
description:
|
||||
"Custom path to skills directory (default: .skills in current directory)",
|
||||
},
|
||||
},
|
||||
"skill-sources": {
|
||||
parser: { type: "string" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<csv>",
|
||||
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: "<path>",
|
||||
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: "<m>",
|
||||
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: "<mode>",
|
||||
description: "Sleeptime trigger: off, step-count, compaction-event",
|
||||
},
|
||||
},
|
||||
"reflection-behavior": {
|
||||
parser: { type: "string" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<mode>",
|
||||
description: "Sleeptime behavior: reminder, auto-launch",
|
||||
},
|
||||
},
|
||||
"reflection-step-count": {
|
||||
parser: { type: "string" },
|
||||
mode: "both",
|
||||
help: {
|
||||
argLabel: "<n>",
|
||||
description: "Sleeptime step-count interval (positive integer)",
|
||||
},
|
||||
},
|
||||
"max-turns": { parser: { type: "string" }, mode: "headless" },
|
||||
} as const satisfies Record<string, CliFlagDefinition>;
|
||||
|
||||
const CLI_FLAG_ENTRIES = Object.entries(CLI_FLAG_CATALOG) as Array<
|
||||
[string, CliFlagDefinition]
|
||||
>;
|
||||
|
||||
export const CLI_OPTIONS: Record<string, CliFlagParserConfig> =
|
||||
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<typeof parseCliArgs>;
|
||||
78
src/cli/flagUtils.ts
Normal file
78
src/cli/flagUtils.ts
Normal file
@@ -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;
|
||||
}
|
||||
37
src/cli/startupFlagValidation.ts
Normal file
37
src/cli/startupFlagValidation.ts
Normal file
@@ -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 <agent-id>");
|
||||
}
|
||||
}
|
||||
|
||||
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}"`);
|
||||
}
|
||||
}
|
||||
325
src/headless.ts
325
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 <agent-id>");
|
||||
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);
|
||||
}
|
||||
|
||||
352
src/index.ts
352
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 <list> Comma-separated memory blocks to initialize when using --new-agent (e.g., "persona,skills")
|
||||
--base-tools <list> Comma-separated base tools to attach when using --new-agent (e.g., "memory,web_search,fetch_webpage")
|
||||
-a, --agent <id> Use a specific agent ID
|
||||
-n, --name <name> Resume agent by name (from pinned agents, case-insensitive)
|
||||
-m, --model <id> Model ID or handle (e.g., "opus-4.5" or "anthropic/claude-opus-4-5")
|
||||
-s, --system <id> System prompt ID or subagent name (applies to new or existing agent)
|
||||
--toolset <name> Toolset mode: "auto", "codex", "default", or "gemini" (manual values override model-based auto-selection)
|
||||
-p, --prompt Headless prompt mode
|
||||
--output-format <fmt> Output format for headless mode (text, json, stream-json)
|
||||
Default: text
|
||||
--input-format <fmt> 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 <id> Inject agent-to-agent system reminder (headless mode)
|
||||
--skills <path> Custom path to skills directory (default: .skills in current directory)
|
||||
--skill-sources <csv> Skill sources: all,bundled,global,agent,project (default: all)
|
||||
--no-skills Disable all skill sources
|
||||
--no-bundled-skills Disable bundled skills only
|
||||
--import <path> 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 <mode>
|
||||
Sleeptime trigger: off, step-count, compaction-event
|
||||
--reflection-behavior <mode>
|
||||
Sleeptime behavior: reminder, auto-launch
|
||||
--reflection-step-count <n>
|
||||
Sleeptime step-count interval (positive integer)
|
||||
${renderCliOptionsHelp()}
|
||||
|
||||
SUBCOMMANDS (JSON-only)
|
||||
letta memfs status --agent <id>
|
||||
@@ -398,69 +377,14 @@ async function main(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
// 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<string, unknown>;
|
||||
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<void> {
|
||||
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 <agent-id>");
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
| 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<void> {
|
||||
}
|
||||
} 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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
133
src/tests/cli/args.test.ts
Normal file
133
src/tests/cli/args.test.ts
Normal file
@@ -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 <m>");
|
||||
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");
|
||||
});
|
||||
});
|
||||
51
src/tests/cli/flag-utils.test.ts
Normal file
51
src/tests/cli/flag-utils.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
60
src/tests/cli/startup-flag-validation.test.ts
Normal file
60
src/tests/cli/startup-flag-validation.test.ts
Normal file
@@ -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 <agent-id>");
|
||||
|
||||
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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user