chore: support direct af downloads (#892)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
67
src/index.ts
67
src/index.ts
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user