chore: support direct af downloads (#892)

This commit is contained in:
Shubham Naik
2026-02-10 11:40:48 -08:00
committed by GitHub
parent 8449d4670f
commit d78ff62bc5
3 changed files with 182 additions and 23 deletions

View File

@@ -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<ImportAgentResult> {
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
}
}
}

View File

@@ -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;

View File

@@ -79,6 +79,7 @@ OPTIONS
--skills <path> Custom path to skills directory (default: .skills in current directory)
--sleeptime Enable sleeptime memory management (only for new agents)
--from-af <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
@@ -723,6 +724,8 @@ async function main(): Promise<void> {
}
// 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<void> {
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<void> {
toolset,
skillsDirectory,
fromAfFile,
isRegistryImport,
}: {
continueSession: boolean;
forceNew: boolean;
@@ -973,6 +993,7 @@ async function main(): Promise<void> {
toolset?: "codex" | "default" | "gemini";
skillsDirectory?: string;
fromAfFile?: string;
isRegistryImport?: boolean;
}) {
const [showKeybindingSetup, setShowKeybindingSetup] = useState<
boolean | null
@@ -1458,16 +1479,31 @@ async function main(): Promise<void> {
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<void> {
toolset: specifiedToolset as "codex" | "default" | "gemini" | undefined,
skillsDirectory: skillsDirectory,
fromAfFile: fromAfFile,
isRegistryImport: isRegistryImport,
}),
{
exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard