diff --git a/src/agent/import.ts b/src/agent/import.ts index 6b4c2f7..9989cca 100644 --- a/src/agent/import.ts +++ b/src/agent/import.ts @@ -16,6 +16,13 @@ export interface ImportAgentOptions { stripSkills?: boolean; } +export interface ImportFromRegistryOptions { + handle: string; // e.g., "@cpfiffer/co-3" + modelOverride?: string; + stripMessages?: boolean; + stripSkills?: boolean; +} + export interface ImportAgentResult { agent: AgentState; skills?: string[]; @@ -220,3 +227,86 @@ async function downloadGitHubDirectory( } } } + +/** + * Registry constants + */ +const AGENT_REGISTRY_OWNER = "letta-ai"; +const AGENT_REGISTRY_REPO = "agent-file"; +const AGENT_REGISTRY_BRANCH = "main"; + +/** + * Parse a registry handle (e.g., "@cpfiffer/co-3") into author and agent name + */ +function parseRegistryHandle(handle: string): { author: string; name: string } { + // Handle can be "@author/name" or "author/name" + const normalized = handle.startsWith("@") ? handle.slice(1) : handle; + const parts = normalized.split("/"); + + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Invalid import handle "${handle}". Use format: @author/agentname`, + ); + } + + return { author: parts[0], name: parts[1] }; +} + +/** + * Import an agent from the letta-ai/agent-file registry + * Downloads the .af file from GitHub and imports it + */ +export async function importAgentFromRegistry( + options: ImportFromRegistryOptions, +): Promise { + const { tmpdir } = await import("node:os"); + const { join } = await import("node:path"); + const { writeFile, unlink } = await import("node:fs/promises"); + + const { author, name } = parseRegistryHandle(options.handle); + + // Construct the raw GitHub URL + // Pattern: agents/@{author}/{name}/{name}.af + const rawUrl = `https://raw.githubusercontent.com/${AGENT_REGISTRY_OWNER}/${AGENT_REGISTRY_REPO}/refs/heads/${AGENT_REGISTRY_BRANCH}/agents/@${author}/${name}/${name}.af`; + + // Download the .af file + const response = await fetch(rawUrl); + if (!response.ok) { + if (response.status === 404) { + throw new Error( + `Agent @${author}/${name} not found in registry. Check that the agent exists at https://github.com/${AGENT_REGISTRY_OWNER}/${AGENT_REGISTRY_REPO}/tree/${AGENT_REGISTRY_BRANCH}/agents/@${author}/${name}`, + ); + } + throw new Error( + `Failed to download agent @${author}/${name}: ${response.statusText}`, + ); + } + + const afContent = await response.text(); + + // Write to a temp file + const tempPath = join( + tmpdir(), + `letta-import-${author}-${name}-${Date.now()}.af`, + ); + await writeFile(tempPath, afContent, "utf-8"); + + try { + // Import using the existing file-based import + const result = await importAgentFromFile({ + filePath: tempPath, + modelOverride: options.modelOverride, + stripMessages: options.stripMessages ?? true, + stripSkills: options.stripSkills ?? false, + }); + + return result; + } finally { + // Clean up temp file + try { + await unlink(tempPath); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/src/headless.ts b/src/headless.ts index 3351568..9b50983 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -349,6 +349,8 @@ export async function handleHeadlessCommand( } // Validate --from-af flag + // 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"); @@ -362,6 +364,21 @@ export async function handleHeadlessCommand( console.error("Error: --from-af cannot be used with --new"); process.exit(1); } + + // Check if this looks like a registry handle (@author/name) + if (fromAfFile.startsWith("@")) { + // 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]) { + console.error( + `Error: Invalid registry handle "${fromAfFile}". Use format: @author/agentname`, + ); + process.exit(1); + } + } } if (initBlocksRaw && !forceNew) { @@ -495,15 +512,30 @@ export async function handleHeadlessCommand( } } - // Priority 1: Import from AgentFile template + // Priority 1: Import from AgentFile template (local file or registry) if (!agent && fromAfFile) { - const { importAgentFromFile } = await import("./agent/import"); - const result = await importAgentFromFile({ - filePath: fromAfFile, - modelOverride: model, - stripMessages: true, - stripSkills: false, - }); + let result: { agent: AgentState; skills?: string[] }; + + if (isRegistryImport) { + // Import from letta-ai/agent-file registry + const { importAgentFromRegistry } = await import("./agent/import"); + result = await importAgentFromRegistry({ + handle: fromAfFile, + modelOverride: model, + stripMessages: true, + stripSkills: false, + }); + } else { + // Import from local file + const { importAgentFromFile } = await import("./agent/import"); + result = await importAgentFromFile({ + filePath: fromAfFile, + modelOverride: model, + stripMessages: true, + stripSkills: false, + }); + } + agent = result.agent; isNewlyCreatedAgent = true; diff --git a/src/index.ts b/src/index.ts index 6fc224a..866a1ed 100755 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ OPTIONS --skills Custom path to skills directory (default: .skills in current directory) --sleeptime Enable sleeptime memory management (only for new agents) --from-af 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 @@ -723,6 +724,8 @@ async function main(): Promise { } // Validate --from-af flag + // 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"); @@ -740,13 +743,29 @@ async function main(): Promise { console.error("Error: --from-af cannot be used with --new"); process.exit(1); } - // Verify file exists - const { resolve } = await import("node:path"); - const { existsSync } = await import("node:fs"); - const resolvedPath = resolve(fromAfFile); - if (!existsSync(resolvedPath)) { - console.error(`Error: AgentFile not found: ${resolvedPath}`); - process.exit(1); + + // Check if this looks like a registry handle (@author/name) + if (fromAfFile.startsWith("@")) { + // 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]) { + console.error( + `Error: Invalid registry handle "${fromAfFile}". Use format: @author/agentname`, + ); + process.exit(1); + } + } else { + // Local file - verify it exists + const { resolve } = await import("node:path"); + const { existsSync } = await import("node:fs"); + const resolvedPath = resolve(fromAfFile); + if (!existsSync(resolvedPath)) { + console.error(`Error: AgentFile not found: ${resolvedPath}`); + process.exit(1); + } } } @@ -962,6 +981,7 @@ async function main(): Promise { toolset, skillsDirectory, fromAfFile, + isRegistryImport, }: { continueSession: boolean; forceNew: boolean; @@ -973,6 +993,7 @@ async function main(): Promise { toolset?: "codex" | "default" | "gemini"; skillsDirectory?: string; fromAfFile?: string; + isRegistryImport?: boolean; }) { const [showKeybindingSetup, setShowKeybindingSetup] = useState< boolean | null @@ -1458,16 +1479,31 @@ async function main(): Promise { let agent: AgentState | null = null; let isNewlyCreatedAgent = false; - // Priority 1: Import from AgentFile template + // Priority 1: Import from AgentFile template (local file or registry) if (fromAfFile) { setLoadingState("importing"); - const { importAgentFromFile } = await import("./agent/import"); - const result = await importAgentFromFile({ - filePath: fromAfFile, - modelOverride: model, - stripMessages: true, - stripSkills: false, - }); + let result: { agent: AgentState; skills?: string[] }; + + if (isRegistryImport) { + // Import from letta-ai/agent-file registry + const { importAgentFromRegistry } = await import("./agent/import"); + result = await importAgentFromRegistry({ + handle: fromAfFile, + modelOverride: model, + stripMessages: true, + stripSkills: false, + }); + } else { + // Import from local file + const { importAgentFromFile } = await import("./agent/import"); + result = await importAgentFromFile({ + filePath: fromAfFile, + modelOverride: model, + stripMessages: true, + stripSkills: false, + }); + } + agent = result.agent; isNewlyCreatedAgent = true; setAgentProvenance({ @@ -2033,6 +2069,7 @@ async function main(): Promise { toolset: specifiedToolset as "codex" | "default" | "gemini" | undefined, skillsDirectory: skillsDirectory, fromAfFile: fromAfFile, + isRegistryImport: isRegistryImport, }), { exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard