CLI Adapters

Give your agent structured access to any CLI tool — with discovery, execution, and consistent output handling.

Overview

Agents are great at running CLI commands. But without structure, every script reinvents how to invoke a CLI, check if it's installed, and parse its output.

CLI adapters solve this with a small interface — similar to database adapters but for command-line tools. Each adapter wraps a single CLI (gh, ffmpeg, stripe, aws) and provides:

  • Discovery — the agent can list what CLIs are available and what they do
  • Availability checks — is the CLI installed?
  • Consistent execution — stdout, stderr, and exit code in a standard format

The interface

Every CLI adapter implements CliAdapter:

import type { CliAdapter, CliResult } from "@agent-native/core/adapters/cli";

interface CliAdapter {
  name: string;           // "gh", "stripe", "ffmpeg"
  description: string;    // What the agent sees during discovery
  isAvailable(): Promise<boolean>;
  execute(args: string[]): Promise<CliResult>;
}

interface CliResult {
  stdout: string;
  stderr: string;
  exitCode: number;
}

ShellCliAdapter

For most CLIs, you don't need a custom class. ShellCliAdapter wraps any CLI binary with sensible defaults:

import { ShellCliAdapter } from "@agent-native/core/adapters/cli";

const gh = new ShellCliAdapter({
  command: "gh",
  description: "GitHub CLI — manage repos, PRs, issues, and releases",
});

const ffmpeg = new ShellCliAdapter({
  command: "ffmpeg",
  description: "Audio/video processing and transcoding",
  timeoutMs: 120_000, // 2 min for long encodes
});

const stripe = new ShellCliAdapter({
  command: "stripe",
  description: "Stripe CLI — manage payments, webhooks, and customers",
  env: { STRIPE_API_KEY: process.env.STRIPE_SECRET_KEY! },
});

Options

OptionTypeDescription
commandstringBinary name or path (required)
descriptionstringWhat the CLI does — shown to the agent (required)
namestringDisplay name (defaults to command)
envRecordExtra environment variables merged with process.env
cwdstringWorking directory (defaults to process.cwd())
timeoutMsnumberExecution timeout (default: 30000)

Registry

The CliRegistry collects adapters so the agent can discover what's available at runtime:

import { CliRegistry, ShellCliAdapter } from "@agent-native/core/adapters/cli";

const cliRegistry = new CliRegistry();

cliRegistry.register(new ShellCliAdapter({
  command: "gh",
  description: "GitHub CLI — manage repos, PRs, issues, and releases",
}));

cliRegistry.register(new ShellCliAdapter({
  command: "ffmpeg",
  description: "Audio/video processing and transcoding",
}));

// List all registered CLIs
cliRegistry.list();
// → [{ name: "gh", ... }, { name: "ffmpeg", ... }]

// List only installed CLIs
await cliRegistry.listAvailable();
// → [{ name: "gh", ... }]  (if ffmpeg isn't installed)

// Get a full summary for agent discovery
await cliRegistry.describe();
// → [{ name: "gh", description: "...", available: true },
//    { name: "ffmpeg", description: "...", available: false }]

// Execute a command
const gh = cliRegistry.get("gh");
const result = await gh?.execute(["pr", "list", "--json", "title,url"]);
console.log(result?.stdout);

Custom adapters

When you need more than ShellCliAdapter provides — custom auth, output parsing, or pre/post processing — implement CliAdapter directly:

import type { CliAdapter, CliResult } from "@agent-native/core/adapters/cli";
import { execFile } from "node:child_process";

export class DockerAdapter implements CliAdapter {
  name = "docker";
  description = "Docker container management — build, run, and manage containers";

  async isAvailable(): Promise<boolean> {
    try {
      const result = await this.execute(["info", "--format", "{{.ServerVersion}}"]);
      return result.exitCode === 0;
    } catch {
      return false;
    }
  }

  async execute(args: string[]): Promise<CliResult> {
    return new Promise((resolve) => {
      execFile("docker", args, {
        timeout: 60_000,
        maxBuffer: 10 * 1024 * 1024,
        encoding: "utf-8",
      }, (error, stdout, stderr) => {
        resolve({
          stdout: stdout ?? "",
          stderr: stderr ?? "",
          exitCode: (error as any)?.code ?? 0,
        });
      });
    });
  }
}

Server route

Expose the registry to the UI via an API route so scripts and components can discover and invoke CLIs:

// server/index.ts
import { createServer } from "@agent-native/core";
import { CliRegistry, ShellCliAdapter } from "@agent-native/core/adapters/cli";

const app = createServer();
const cliRegistry = new CliRegistry();

cliRegistry.register(new ShellCliAdapter({
  command: "gh",
  description: "GitHub CLI",
}));

// Discovery endpoint — agent can query this
app.get("/api/cli", async (_req, res) => {
  const tools = await cliRegistry.describe();
  res.json(tools);
});

// Execution endpoint
app.post("/api/cli/:name", async (req, res) => {
  const adapter = cliRegistry.get(req.params.name);
  if (!adapter) return res.status(404).json({ error: "CLI not found" });

  const { args } = req.body;
  const result = await adapter.execute(args ?? []);
  res.json(result);
});

Using from scripts

Scripts can use CLI adapters directly for structured access:

// scripts/list-prs.ts
import { ShellCliAdapter } from "@agent-native/core/adapters/cli";

const gh = new ShellCliAdapter({
  command: "gh",
  description: "GitHub CLI",
});

export default async function listPrs() {
  if (!await gh.isAvailable()) {
    console.error("GitHub CLI not installed. Run: brew install gh");
    process.exit(1);
  }

  const result = await gh.execute([
    "pr", "list", "--json", "title,url,state", "--limit", "10"
  ]);

  if (result.exitCode !== 0) {
    console.error(result.stderr);
    process.exit(1);
  }

  const prs = JSON.parse(result.stdout);
  const fs = await import("node:fs/promises");
  await fs.writeFile("data/prs.json", JSON.stringify(prs, null, 2));
  console.log(`Fetched ${prs.length} PRs`);
}

Or skip the adapter entirely and call the CLI directly in a script — adapters are useful when you want discovery, availability checks, and consistent error handling, but they're not required. Use whichever approach fits.