Skip to content
Merged

. #9

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
2 changes: 2 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,6 +19,7 @@ registerLogin(program)
registerMe(program)
registerRun(program)
registerConfig(program)
registerTemplate(program)
registerUsage(program)
registerPing(program)

Expand Down
77 changes: 58 additions & 19 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <slug>',
'create the agent from an Ellipsis template by opening a pull request (see `agent template list`)',
)
.option(
'--repo <name>',
'repository name to open the pull request against (required with --template)',
)
.option(
'--path <path>',
'file path within the repo for the config (default: agents/<slug>.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 <name> 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.
Expand Down
9 changes: 6 additions & 3 deletions src/commands/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentRunStatus> = new Set<AgentRunStatus>([
'completed',
Expand Down Expand Up @@ -135,13 +139,12 @@ export function registerRun(program: Command): void {
.command('get <runId>')
.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 <seconds>', '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)
Expand Down
34 changes: 34 additions & 0 deletions src/commands/template.ts
Original file line number Diff line number Diff line change
@@ -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 <slug> --repo <name>')
})
})
}
24 changes: 24 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<CreatedAgentConfig> {
return this.request('POST', '/v1/agents/configs', req)
}

getAgentConfig(configId: string): Promise<SavedAgentConfig> {
return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`)
}

// ---------------------------- agent templates ---------------------------

async listAgentTemplates(): Promise<AgentTemplate[]> {
const res = await this.request<ListAgentTemplatesResponse>(
'GET',
'/v1/agents/templates',
)
return res.templates
}

getAgentTemplate(slug: string): Promise<AgentTemplate> {
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.

Expand Down
37 changes: 37 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.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[]
Expand Down
64 changes: 64 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
})
})
Loading