Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,15 @@ rli blueprint prune <name> # 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 <id> # Get agent details
rli agent create # Create an agent
rli agent create-from-dir <path> # Create an agent from a directory (rea...
```

### Object Commands (alias: `obj`)

```bash
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

215 changes: 215 additions & 0 deletions src/commands/agent/create-from-dir.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}>;
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<typeof ignore>,
): Promise<string[]> {
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<string, unknown> = {
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);
}
}
103 changes: 103 additions & 0 deletions src/commands/agent/create.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {
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);
}
}
20 changes: 20 additions & 0 deletions src/commands/agent/get.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading