From d2af536bfc5c1f7d6d42d09c378c491de9289260 Mon Sep 17 00:00:00 2001 From: Jason Chiu Date: Mon, 30 Mar 2026 13:35:36 -0700 Subject: [PATCH] Add agent command group with create-from-dir support - Add rli agent list/get/create/create-from-dir commands - create-from-dir reads .runloopignore (gitignore syntax) for tar exclusions - Falls back to sensible defaults (.git, node_modules, __pycache__, etc.) - Reads agent.yaml for config (skills, webhooks, setup commands) - Packages directory, uploads via object storage, creates agent Co-Authored-By: Claude Opus 4.6 --- README.md | 9 ++ package.json | 1 + pnpm-lock.yaml | 3 + src/commands/agent/create-from-dir.ts | 215 ++++++++++++++++++++++++++ src/commands/agent/create.ts | 103 ++++++++++++ src/commands/agent/get.ts | 20 +++ src/commands/agent/list.ts | 31 ++++ src/utils/commands.ts | 78 ++++++++++ 8 files changed, 460 insertions(+) create mode 100644 src/commands/agent/create-from-dir.ts create mode 100644 src/commands/agent/create.ts create mode 100644 src/commands/agent/get.ts create mode 100644 src/commands/agent/list.ts diff --git a/README.md b/README.md index c4ac873..7210655 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,15 @@ rli blueprint prune # Delete old blueprint builds, keeping rli blueprint from-dockerfile # Create a blueprint from a Dockerfile ... ``` +### Agent Commands (alias: `agt`) + +```bash +rli agent list # List agents +rli agent get # Get agent details +rli agent create # Create an agent +rli agent create-from-dir # Create an agent from a directory (rea... +``` + ### Object Commands (alias: `obj`) ```bash diff --git a/package.json b/package.json index c4bcb9d..7f7a39e 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "express": "^5.2.1", "figures": "^6.1.0", "gradient-string": "^3.0.0", + "ignore": "^7.0.5", "ink": "^6.6.0", "ink-big-text": "^2.0.0", "ink-gradient": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ffcf1..3eaea19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: gradient-string: specifier: ^3.0.0 version: 3.0.0 + ignore: + specifier: ^7.0.5 + version: 7.0.5 ink: specifier: ^6.6.0 version: 6.6.0(@types/react@19.2.10)(react@19.2.0) diff --git a/src/commands/agent/create-from-dir.ts b/src/commands/agent/create-from-dir.ts new file mode 100644 index 0000000..eab3e8c --- /dev/null +++ b/src/commands/agent/create-from-dir.ts @@ -0,0 +1,215 @@ +/** + * Create agent from a directory — reads config, packages directory, uploads, and registers. + * + * Reads .runloopignore (gitignore syntax) to determine which files to exclude from the tar. + * Falls back to sensible defaults if no .runloopignore exists. + */ + +import { readFile, stat, mkdtemp, rm, readdir } from "fs/promises"; +import { join, basename, resolve, relative } from "path"; +import { tmpdir } from "os"; +import { execFileSync } from "child_process"; +import { existsSync } from "fs"; +import { parse as parseYaml } from "yaml"; +import ignore from "ignore"; +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +const DEFAULT_IGNORE_PATTERNS = [ + ".git", + "node_modules", + "__pycache__", + ".venv", + ".env", + "dist", + "build", + ".DS_Store", + "*.pyc", + ".runloopignore", +]; + +interface AgentConfig { + name?: string; + version?: string; + setup_commands?: string[]; + skills?: Array<{ + name: string; + description?: string; + definition: Record; + }>; + webhooks?: Array<{ + url: string; + events?: string[]; + secret?: string; + }>; +} + +interface CreateFromDirOptions { + path: string; + name?: string; + version?: string; + public?: boolean; + output?: string; +} + +/** + * Recursively collect all file paths relative to root, respecting ignore patterns. + */ +async function collectFiles( + root: string, + ig: ReturnType, +): Promise { + const files: string[] = []; + + async function walk(dir: string) { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + const relPath = relative(root, fullPath); + + // Check if this path should be ignored + if (ig.ignores(relPath)) { + continue; + } + + if (entry.isDirectory()) { + // Also check directory with trailing slash + if (ig.ignores(relPath + "/")) { + continue; + } + await walk(fullPath); + } else if (entry.isFile() || entry.isSymbolicLink()) { + files.push(relPath); + } + } + } + + await walk(root); + return files; +} + +export async function createFromDirCommand(options: CreateFromDirOptions) { + try { + const dirPath = resolve(options.path); + + // Verify directory exists + const dirStat = await stat(dirPath); + if (!dirStat.isDirectory()) { + throw new Error(`Path is not a directory: ${dirPath}`); + } + + // Read agent config if it exists + let config: AgentConfig = {}; + for (const configName of ["agent.yaml", "agent.yml", "agent.json"]) { + const configPath = join(dirPath, configName); + if (existsSync(configPath)) { + const raw = await readFile(configPath, "utf-8"); + config = configName.endsWith(".json") + ? JSON.parse(raw) + : parseYaml(raw); + console.error(`Using config from ${configName}`); + break; + } + } + + // Resolve name and version (CLI flags override config) + const agentName = options.name || config.name || basename(dirPath); + const agentVersion = options.version || config.version; + if (!agentVersion) { + throw new Error( + "Version is required. Provide --version or set 'version' in agent.yaml", + ); + } + + // Build ignore filter from .runloopignore or defaults + const ig = ignore(); + const ignorePath = join(dirPath, ".runloopignore"); + if (existsSync(ignorePath)) { + const ignoreContent = await readFile(ignorePath, "utf-8"); + ig.add(ignoreContent); + console.error("Using .runloopignore"); + } else { + ig.add(DEFAULT_IGNORE_PATTERNS); + console.error( + "No .runloopignore found, using defaults: " + + DEFAULT_IGNORE_PATTERNS.join(", "), + ); + } + + // Also exclude agent config files from the tar + ig.add(["agent.yaml", "agent.yml", "agent.json"]); + + // Collect files to include + const files = await collectFiles(dirPath, ig); + if (files.length === 0) { + throw new Error("No files to package after applying ignore rules"); + } + console.error(`Packaging ${files.length} files...`); + + // Create tar.gz in a temp directory + const tmpDir = await mkdtemp(join(tmpdir(), "rl-agent-")); + const tarPath = join(tmpDir, `${agentName}.tar.gz`); + + try { + // Use system tar to create archive from the file list + execFileSync("tar", ["-czf", tarPath, "-C", dirPath, ...files], { + stdio: "pipe", + }); + + // Upload via object storage + const client = getClient(); + const fileBuffer = await readFile(tarPath); + + // Step 1: Create object + const createResponse = await client.objects.create({ + name: `${agentName}-${agentVersion}.tar.gz`, + content_type: "tgz", + }); + + // Step 2: Upload + const uploadResponse = await fetch(createResponse.upload_url!, { + method: "PUT", + body: fileBuffer, + headers: { + "Content-Length": fileBuffer.length.toString(), + }, + }); + if (!uploadResponse.ok) { + throw new Error(`Upload failed: HTTP ${uploadResponse.status}`); + } + + // Step 3: Complete + await client.objects.complete(createResponse.id); + console.error(`Uploaded object: ${createResponse.id}`); + + // Step 4: Create agent with object source + const body: Record = { + name: agentName, + version: agentVersion, + is_public: options.public || false, + source: { + type: "object", + object: { + object_id: createResponse.id, + agent_setup: config.setup_commands || [], + }, + }, + }; + + if (config.skills && config.skills.length > 0) { + body.skills = config.skills; + } + if (config.webhooks && config.webhooks.length > 0) { + body.webhooks = config.webhooks; + } + + const agent = await client.post("/v1/agents", { body }); + output(agent, { format: options.output, defaultFormat: "json" }); + } finally { + // Clean up temp directory + await rm(tmpDir, { recursive: true, force: true }); + } + } catch (error) { + outputError("Failed to create agent from directory", error); + } +} diff --git a/src/commands/agent/create.ts b/src/commands/agent/create.ts new file mode 100644 index 0000000..e61912e --- /dev/null +++ b/src/commands/agent/create.ts @@ -0,0 +1,103 @@ +/** + * Create agent command + */ + +import { getClient } from "../../utils/client.js"; +import { output, outputError } from "../../utils/output.js"; + +interface CreateAgentOptions { + name: string; + version: string; + sourceType?: string; + gitRepository?: string; + gitRef?: string; + npmPackage?: string; + npmRegistryUrl?: string; + pipPackage?: string; + pipIndexUrl?: string; + objectId?: string; + setupCommands?: string; + public?: boolean; + output?: string; +} + +export async function createAgentCommand(options: CreateAgentOptions) { + try { + const client = getClient(); + + const body: Record = { + name: options.name, + version: options.version, + is_public: options.public || false, + }; + + // Build source config based on source type + if (options.sourceType) { + const setupCommands = options.setupCommands + ? options.setupCommands + .split("\n") + .map((cmd: string) => cmd.trim()) + .filter((cmd: string) => cmd.length > 0) + : undefined; + + switch (options.sourceType) { + case "git": { + if (!options.gitRepository) { + throw new Error("--git-repository is required for git source type"); + } + const git: Record = { + repository: options.gitRepository, + }; + if (options.gitRef) git.ref = options.gitRef; + if (setupCommands) git.agent_setup = setupCommands; + body.source = { type: "git", git }; + break; + } + case "npm": { + if (!options.npmPackage) { + throw new Error("--npm-package is required for npm source type"); + } + const npm: Record = { + package_name: options.npmPackage, + }; + if (options.npmRegistryUrl) npm.registry_url = options.npmRegistryUrl; + if (setupCommands) npm.agent_setup = setupCommands; + body.source = { type: "npm", npm }; + break; + } + case "pip": { + if (!options.pipPackage) { + throw new Error("--pip-package is required for pip source type"); + } + const pip: Record = { + package_name: options.pipPackage, + }; + if (options.pipIndexUrl) pip.registry_url = options.pipIndexUrl; + if (setupCommands) pip.agent_setup = setupCommands; + body.source = { type: "pip", pip }; + break; + } + case "object": { + if (!options.objectId) { + throw new Error("--object-id is required for object source type"); + } + const object: Record = { + object_id: options.objectId, + }; + if (setupCommands) object.agent_setup = setupCommands; + body.source = { type: "object", object }; + break; + } + default: + throw new Error( + `Unsupported source type: ${options.sourceType}. Must be one of: git, npm, pip, object`, + ); + } + } + + const agent = await client.post("/v1/agents", { body }); + output(agent, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to create agent", error); + } +} diff --git a/src/commands/agent/get.ts b/src/commands/agent/get.ts new file mode 100644 index 0000000..aa1a549 --- /dev/null +++ b/src/commands/agent/get.ts @@ -0,0 +1,20 @@ +/** + * Get agent details command + */ + +import { getAgent } from "../../services/agentService.js"; +import { output, outputError } from "../../utils/output.js"; + +interface GetAgentOptions { + id: string; + output?: string; +} + +export async function getAgentCommand(options: GetAgentOptions) { + try { + const agent = await getAgent(options.id); + output(agent, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to get agent", error); + } +} diff --git a/src/commands/agent/list.ts b/src/commands/agent/list.ts new file mode 100644 index 0000000..ab37576 --- /dev/null +++ b/src/commands/agent/list.ts @@ -0,0 +1,31 @@ +/** + * List agents command + */ + +import { listAgents } from "../../services/agentService.js"; +import { output, outputError, parseLimit } from "../../utils/output.js"; + +interface ListAgentsCommandOptions { + limit?: string; + name?: string; + search?: string; + public?: boolean; + private?: boolean; + output?: string; +} + +export async function listAgentsCommand(options: ListAgentsCommandOptions) { + try { + const result = await listAgents({ + limit: parseLimit(options.limit), + name: options.name, + search: options.search, + publicOnly: options.public, + privateOnly: options.private, + }); + + output(result.agents, { format: options.output, defaultFormat: "json" }); + } catch (error) { + outputError("Failed to list agents", error); + } +} diff --git a/src/utils/commands.ts b/src/utils/commands.ts index 0a25c86..b9e23ca 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -568,6 +568,84 @@ export function createProgram(): Command { await createBlueprintFromDockerfile(options); }); + // Agent commands + const agent = program + .command("agent") + .description("Manage agents") + .alias("agt"); + + agent + .command("list") + .description("List agents") + .option("--limit ", "Max results", "20") + .option("--name ", "Filter by name (partial match)") + .option("--search ", "Search by agent ID or name") + .option("--public", "List public agents only") + .option("--private", "List private agents only") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (options) => { + const { listAgentsCommand } = await import("../commands/agent/list.js"); + await listAgentsCommand(options); + }); + + agent + .command("get ") + .description("Get agent details") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (id, options) => { + const { getAgentCommand } = await import("../commands/agent/get.js"); + await getAgentCommand({ id, ...options }); + }); + + agent + .command("create") + .description("Create an agent") + .requiredOption("--name ", "Agent name") + .requiredOption("--version ", "Agent version (semver or SHA)") + .option("--source-type ", "Source type: git, npm, pip, object") + .option("--git-repository ", "Git repository URL") + .option("--git-ref ", "Git ref (branch/tag/commit)") + .option("--npm-package ", "NPM package name") + .option("--npm-registry-url ", "NPM registry URL") + .option("--pip-package ", "PyPI package name") + .option("--pip-index-url ", "PyPI index URL") + .option("--object-id ", "Object ID for object source") + .option("--setup-commands ", "Newline-separated setup commands") + .option("--public", "Make agent publicly accessible") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (options) => { + const { createAgentCommand } = + await import("../commands/agent/create.js"); + await createAgentCommand(options); + }); + + agent + .command("create-from-dir ") + .description( + "Create an agent from a directory (reads agent.yaml config and .runloopignore)", + ) + .option("--name ", "Agent name (overrides agent.yaml)") + .option("--version ", "Agent version (overrides agent.yaml)") + .option("--public", "Make agent publicly accessible") + .option( + "-o, --output [format]", + "Output format: text|json|yaml (default: json)", + ) + .action(async (path, options) => { + const { createFromDirCommand } = + await import("../commands/agent/create-from-dir.js"); + await createFromDirCommand({ path, ...options }); + }); + // Object storage commands const object = program .command("object")