diff --git a/src/cli.tsx b/src/cli.tsx index 4b5be90..27c66ed 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,6 +3,7 @@ import { registerLogin } from './commands/login' import { registerMe } from './commands/me' import { registerRun } from './commands/run' import { registerConfig } from './commands/config' +import { registerTemplate } from './commands/template' import { registerUsage } from './commands/usage' import { registerPing } from './commands/ping' import { VERSION } from './lib/constants' @@ -18,6 +19,7 @@ registerLogin(program) registerMe(program) registerRun(program) registerConfig(program) +registerTemplate(program) registerUsage(program) registerPing(program) diff --git a/src/commands/config.ts b/src/commands/config.ts index 5febae9..3b029cd 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -54,28 +54,67 @@ export function registerConfig(program: Command): void { config .command('init [path]') - .description(`Scaffold a starter agent config YAML (default: ${DEFAULT_CONFIG_PATH})`) + .description( + `Scaffold a starter agent config YAML locally (default: ${DEFAULT_CONFIG_PATH}), ` + + 'or with --template create the agent in a repo by opening a pull request', + ) .option('-f, --force', 'overwrite the file if it already exists') - .action((path: string | undefined, opts: { force?: boolean }) => { - // Configs are sourced from YAML in GitHub, not created through the API - // (see documents/eng/ELLIPSIS_API_AND_CLI.md), so `init` is a local - // scaffold the user commits to a path Ellipsis syncs from. - const target = path ?? DEFAULT_CONFIG_PATH - if (existsSync(target) && !opts.force) { - console.error(`error: ${target} already exists (use --force to overwrite)`) - process.exitCode = 1 - return - } - const name = basename(target, extname(target)) - mkdirSync(dirname(target), { recursive: true }) - writeFileSync(target, starterConfig(name)) - console.log(`✓ wrote ${target}`) - console.log( - 'Commit it to your default branch — Ellipsis syncs agent configs from GitHub.', - ) - }) + .option( + '--template ', + 'create the agent from an Ellipsis template by opening a pull request (see `agent template list`)', + ) + .option( + '--repo ', + 'repository name to open the pull request against (required with --template)', + ) + .option( + '--path ', + 'file path within the repo for the config (default: agents/.yaml; must be a synced location)', + ) + .action( + async ( + path: string | undefined, + opts: { force?: boolean; template?: string; repo?: string; path?: string }, + ) => { + // With --template the agent is created in your repo: Ellipsis opens a + // pull request that adds the config file and returns it. Without it, + // this is a local scaffold you commit yourself. + if (opts.template) { + if (!opts.repo) { + console.error('error: --repo is required with --template') + process.exitCode = 1 + return + } + await runAction(async () => { + const created = await new ApiClient().createAgentConfig({ + template_id: opts.template, + repository: opts.repo!, + path: opts.path, + }) + console.log(`✓ opened a pull request adding the agent (${created.path})`) + console.log(created.pull_request_url) + console.log('Merge it to deploy the agent.') + }) + return + } + const target = path ?? DEFAULT_CONFIG_PATH + if (existsSync(target) && !opts.force) { + console.error(`error: ${target} already exists (use --force to overwrite)`) + process.exitCode = 1 + return + } + const name = basename(target, extname(target)) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, starterConfig(name)) + console.log(`✓ wrote ${target}`) + console.log(COMMIT_HINT) + }, + ) } +const COMMIT_HINT = + 'Commit it to your default branch. Ellipsis syncs agent configs from GitHub.' + // A minimal valid agent config. `claude.system` is the only required field; // everything else has a server-side default. Roots Ellipsis syncs from: // agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml. diff --git a/src/commands/run.tsx b/src/commands/run.tsx index 1198869..7d170fc 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -18,6 +18,10 @@ import type { StartAgentRunRequest, } from '../lib/types' +// Poll interval (seconds) for the REST status-polling fallback used when live +// streaming is unavailable. Not user-configurable — streaming is the norm. +const WATCH_POLL_INTERVAL_SECONDS = 3 + // Statuses past which a run no longer changes — `--watch` stops here. const TERMINAL_STATUSES: ReadonlySet = new Set([ 'completed', @@ -135,13 +139,12 @@ export function registerRun(program: Command): void { .command('get ') .description('Get a single agent run (GET /v1/agents/runs/{id})') .option('-w, --watch', 'poll until the run reaches a terminal status') - .option('-i, --interval ', 'poll interval for --watch', toInt, 3) .option('--json', 'output raw JSON') - .action(async (runId: string, opts: { watch?: boolean; interval: number; json?: boolean }) => { + .action(async (runId: string, opts: { watch?: boolean; json?: boolean }) => { await runAction(async () => { const api = new ApiClient() if (opts.watch) { - await watchRunStreaming(api, runId, opts.interval, opts.json) + await watchRunStreaming(api, runId, WATCH_POLL_INTERVAL_SECONDS, opts.json) return } const r = await api.getAgentRun(runId) diff --git a/src/commands/template.ts b/src/commands/template.ts new file mode 100644 index 0000000..3449c2b --- /dev/null +++ b/src/commands/template.ts @@ -0,0 +1,34 @@ +import { type Command } from 'commander' +import { ApiClient } from '../lib/api' +import { printJson, printTable, runAction } from '../lib/output' + +export function registerTemplate(program: Command): void { + const template = program + .command('template') + .description('Browse the built-in Ellipsis agent templates') + + template + .command('list') + .description('List built-in agent templates (GET /v1/agents/templates)') + .option('--json', 'output raw JSON') + .action(async (opts: { json?: boolean }) => { + await runAction(async () => { + const templates = await new ApiClient().listAgentTemplates() + if (opts.json) { + printJson(templates) + return + } + if (templates.length === 0) { + console.log('No templates found.') + return + } + // The description is the template's own one-line summary, served by the + // API — kept here so it never drifts from the shipped template. + printTable( + ['SLUG', 'NAME', 'DESCRIPTION'], + templates.map((t) => [t.slug, t.name, t.description]), + ) + console.log('\nCreate one: agent config init --template --repo ') + }) + }) +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 5d2c775..06805ed 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,12 +1,16 @@ import { resolveApiBase, resolveToken } from './config' import type { AgentRun, + AgentTemplate, BudgetSummary, CliAuthPoll, CliAuthStart, + CreateAgentConfigRequest, + CreatedAgentConfig, ListAgentConfigsResponse, ListAgentRunsQuery, ListAgentRunsResponse, + ListAgentTemplatesResponse, SavedAgentConfig, StartAgentRunRequest, UsageDashboard, @@ -110,10 +114,30 @@ export class ApiClient { return res.configs } + // Opens a pull request that adds the config's YAML to the repo's agents/ + // directory; the agent goes live once it merges and syncs. + createAgentConfig(req: CreateAgentConfigRequest): Promise { + return this.request('POST', '/v1/agents/configs', req) + } + getAgentConfig(configId: string): Promise { return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`) } + // ---------------------------- agent templates --------------------------- + + async listAgentTemplates(): Promise { + const res = await this.request( + 'GET', + '/v1/agents/templates', + ) + return res.templates + } + + getAgentTemplate(slug: string): Promise { + return this.request('GET', `/v1/agents/templates/${encodeURIComponent(slug)}`) + } + // --------------------------- device-code auth --------------------------- // Unauthenticated: the CLI has no credential yet — that's what it's obtaining. diff --git a/src/lib/types.ts b/src/lib/types.ts index 9586633..9bcfcde 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -132,6 +132,43 @@ export interface ListAgentConfigsResponse { configs: SavedAgentConfig[] } +// Create-config payload for POST /v1/agents/configs. Exactly one of `config` +// (inline) or `template_id` (a gallery template slug). `repository` is a bare +// repo name in the caller's account — the owner is always the account. +export interface CreateAgentConfigRequest { + config?: AgentConfig + template_id?: string + repository: string + // File path within the repo. Omit for the default agents/.yaml; if set + // it must be a location Ellipsis syncs (.yaml/.yml under agents/, .agents/, + // ellipsis/, or .ellipsis/ at any depth). + path?: string +} + +// Result of creating a config: the pending row plus the pull request that adds +// its YAML file. The agent goes live once that PR merges and syncs. +export interface CreatedAgentConfig { + config: SavedAgentConfig + path: string + pull_request_url: string +} + +// A built-in starter template served by GET /v1/agents/templates. `yaml` is the +// schema-valid agent config the CLI writes to disk; the rest is display copy. +export interface AgentTemplate { + slug: string + name: string + description: string + tags: string[] + summary: string + use_case: string + yaml: string +} + +export interface ListAgentTemplatesResponse { + templates: AgentTemplate[] +} + export interface ListAgentRunsQuery { config_id?: string source?: AgentRunSource[] diff --git a/test/api.test.ts b/test/api.test.ts index b1cdea6..b5bd09a 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -92,3 +92,67 @@ describe('ApiClient.request', () => { expect(err.message).toContain('GET /x failed: 500 boom') }) }) + +describe('agent templates', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('unwraps the templates array from the list response', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ templates: [{ slug: 'a' }, { slug: 'b' }] }), { + status: 200, + }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').listAgentTemplates() + expect(out.map((t) => t.slug)).toEqual(['a', 'b']) + expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/agents/templates') + }) + + it('fetches a single template by slug (encoded)', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ slug: 'ci-failure-triager', yaml: 'x' }), { status: 200 }), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').getAgentTemplate('ci-failure-triager') + expect(out.yaml).toBe('x') + expect(fetchMock.mock.calls[0][0]).toBe( + 'http://api.test/v1/agents/templates/ci-failure-triager', + ) + }) +}) + +describe('createAgentConfig', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('POSTs template_id + repository and returns the pull request', async () => { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + config: { id: 'cfg_1' }, + path: 'agents/ci-failure-triager.yaml', + pull_request_url: 'https://github.com/octocat/api/pull/7', + }), + { status: 200 }, + ), + ) + vi.stubGlobal('fetch', fetchMock) + + const out = await new ApiClient('http://api.test', 't').createAgentConfig({ + template_id: 'ci-failure-triager', + repository: 'api', + }) + expect(out.pull_request_url).toBe('https://github.com/octocat/api/pull/7') + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('http://api.test/v1/agents/configs') + expect((init as RequestInit).method).toBe('POST') + expect(JSON.parse((init as RequestInit).body as string)).toEqual({ + template_id: 'ci-failure-triager', + repository: 'api', + }) + }) +})