Skip to content

Commit a213c79

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/run-template-watch-urls
# Conflicts: # src/commands/run.tsx
2 parents 246c54e + 932d164 commit a213c79

6 files changed

Lines changed: 219 additions & 19 deletions

File tree

src/cli.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { registerLogin } from './commands/login'
33
import { registerMe } from './commands/me'
44
import { registerRun } from './commands/run'
55
import { registerConfig } from './commands/config'
6+
import { registerTemplate } from './commands/template'
67
import { registerUsage } from './commands/usage'
78
import { registerPing } from './commands/ping'
89
import { VERSION } from './lib/constants'
@@ -18,6 +19,7 @@ registerLogin(program)
1819
registerMe(program)
1920
registerRun(program)
2021
registerConfig(program)
22+
registerTemplate(program)
2123
registerUsage(program)
2224
registerPing(program)
2325

src/commands/config.ts

Lines changed: 58 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -61,28 +61,67 @@ export function registerConfig(program: Command): void {
6161

6262
config
6363
.command('init [path]')
64-
.description(`Scaffold a starter agent config YAML (default: ${DEFAULT_CONFIG_PATH})`)
64+
.description(
65+
`Scaffold a starter agent config YAML locally (default: ${DEFAULT_CONFIG_PATH}), ` +
66+
'or with --template create the agent in a repo by opening a pull request',
67+
)
6568
.option('-f, --force', 'overwrite the file if it already exists')
66-
.action((path: string | undefined, opts: { force?: boolean }) => {
67-
// Configs are sourced from YAML in GitHub, not created through the API
68-
// (see documents/eng/ELLIPSIS_API_AND_CLI.md), so `init` is a local
69-
// scaffold the user commits to a path Ellipsis syncs from.
70-
const target = path ?? DEFAULT_CONFIG_PATH
71-
if (existsSync(target) && !opts.force) {
72-
console.error(`error: ${target} already exists (use --force to overwrite)`)
73-
process.exitCode = 1
74-
return
75-
}
76-
const name = basename(target, extname(target))
77-
mkdirSync(dirname(target), { recursive: true })
78-
writeFileSync(target, starterConfig(name))
79-
console.log(`✓ wrote ${target}`)
80-
console.log(
81-
'Commit it to your default branch — Ellipsis syncs agent configs from GitHub.',
82-
)
83-
})
69+
.option(
70+
'--template <slug>',
71+
'create the agent from an Ellipsis template by opening a pull request (see `agent template list`)',
72+
)
73+
.option(
74+
'--repo <name>',
75+
'repository name to open the pull request against (required with --template)',
76+
)
77+
.option(
78+
'--path <path>',
79+
'file path within the repo for the config (default: agents/<slug>.yaml; must be a synced location)',
80+
)
81+
.action(
82+
async (
83+
path: string | undefined,
84+
opts: { force?: boolean; template?: string; repo?: string; path?: string },
85+
) => {
86+
// With --template the agent is created in your repo: Ellipsis opens a
87+
// pull request that adds the config file and returns it. Without it,
88+
// this is a local scaffold you commit yourself.
89+
if (opts.template) {
90+
if (!opts.repo) {
91+
console.error('error: --repo <name> is required with --template')
92+
process.exitCode = 1
93+
return
94+
}
95+
await runAction(async () => {
96+
const created = await new ApiClient().createAgentConfig({
97+
template_id: opts.template,
98+
repository: opts.repo!,
99+
path: opts.path,
100+
})
101+
console.log(`✓ opened a pull request adding the agent (${created.path})`)
102+
console.log(created.pull_request_url)
103+
console.log('Merge it to deploy the agent.')
104+
})
105+
return
106+
}
107+
const target = path ?? DEFAULT_CONFIG_PATH
108+
if (existsSync(target) && !opts.force) {
109+
console.error(`error: ${target} already exists (use --force to overwrite)`)
110+
process.exitCode = 1
111+
return
112+
}
113+
const name = basename(target, extname(target))
114+
mkdirSync(dirname(target), { recursive: true })
115+
writeFileSync(target, starterConfig(name))
116+
console.log(`✓ wrote ${target}`)
117+
console.log(COMMIT_HINT)
118+
},
119+
)
84120
}
85121

122+
const COMMIT_HINT =
123+
'Commit it to your default branch. Ellipsis syncs agent configs from GitHub.'
124+
86125
// A minimal valid agent config. `claude.system` is the only required field;
87126
// everything else has a server-side default. Roots Ellipsis syncs from:
88127
// agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml.

src/commands/template.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { type Command } from 'commander'
2+
import { ApiClient } from '../lib/api'
3+
import { printJson, printTable, runAction } from '../lib/output'
4+
5+
export function registerTemplate(program: Command): void {
6+
const template = program
7+
.command('template')
8+
.description('Browse the built-in Ellipsis agent templates')
9+
10+
template
11+
.command('list')
12+
.description('List built-in agent templates (GET /v1/agents/templates)')
13+
.option('--json', 'output raw JSON')
14+
.action(async (opts: { json?: boolean }) => {
15+
await runAction(async () => {
16+
const templates = await new ApiClient().listAgentTemplates()
17+
if (opts.json) {
18+
printJson(templates)
19+
return
20+
}
21+
if (templates.length === 0) {
22+
console.log('No templates found.')
23+
return
24+
}
25+
// The description is the template's own one-line summary, served by the
26+
// API — kept here so it never drifts from the shipped template.
27+
printTable(
28+
['SLUG', 'NAME', 'DESCRIPTION'],
29+
templates.map((t) => [t.slug, t.name, t.description]),
30+
)
31+
console.log('\nCreate one: agent config init --template <slug> --repo <name>')
32+
})
33+
})
34+
}

src/lib/api.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { resolveApiBase, resolveToken } from './config'
22
import type {
33
AgentRun,
4+
AgentTemplate,
45
BudgetSummary,
56
CliAuthPoll,
67
CliAuthStart,
8+
CreateAgentConfigRequest,
9+
CreatedAgentConfig,
710
ListAgentConfigsResponse,
811
ListAgentRunsQuery,
912
ListAgentRunsResponse,
13+
ListAgentTemplatesResponse,
1014
SavedAgentConfig,
1115
StartAgentRunRequest,
1216
UsageDashboard,
@@ -110,10 +114,30 @@ export class ApiClient {
110114
return res.configs
111115
}
112116

117+
// Opens a pull request that adds the config's YAML to the repo's agents/
118+
// directory; the agent goes live once it merges and syncs.
119+
createAgentConfig(req: CreateAgentConfigRequest): Promise<CreatedAgentConfig> {
120+
return this.request('POST', '/v1/agents/configs', req)
121+
}
122+
113123
getAgentConfig(configId: string): Promise<SavedAgentConfig> {
114124
return this.request('GET', `/v1/agents/configs/${encodeURIComponent(configId)}`)
115125
}
116126

127+
// ---------------------------- agent templates ---------------------------
128+
129+
async listAgentTemplates(): Promise<AgentTemplate[]> {
130+
const res = await this.request<ListAgentTemplatesResponse>(
131+
'GET',
132+
'/v1/agents/templates',
133+
)
134+
return res.templates
135+
}
136+
137+
getAgentTemplate(slug: string): Promise<AgentTemplate> {
138+
return this.request('GET', `/v1/agents/templates/${encodeURIComponent(slug)}`)
139+
}
140+
117141
// --------------------------- device-code auth ---------------------------
118142
// Unauthenticated: the CLI has no credential yet — that's what it's obtaining.
119143

src/lib/types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,43 @@ export interface ListAgentConfigsResponse {
133133
configs: SavedAgentConfig[]
134134
}
135135

136+
// Create-config payload for POST /v1/agents/configs. Exactly one of `config`
137+
// (inline) or `template_id` (a gallery template slug). `repository` is a bare
138+
// repo name in the caller's account — the owner is always the account.
139+
export interface CreateAgentConfigRequest {
140+
config?: AgentConfig
141+
template_id?: string
142+
repository: string
143+
// File path within the repo. Omit for the default agents/<slug>.yaml; if set
144+
// it must be a location Ellipsis syncs (.yaml/.yml under agents/, .agents/,
145+
// ellipsis/, or .ellipsis/ at any depth).
146+
path?: string
147+
}
148+
149+
// Result of creating a config: the pending row plus the pull request that adds
150+
// its YAML file. The agent goes live once that PR merges and syncs.
151+
export interface CreatedAgentConfig {
152+
config: SavedAgentConfig
153+
path: string
154+
pull_request_url: string
155+
}
156+
157+
// A built-in starter template served by GET /v1/agents/templates. `yaml` is the
158+
// schema-valid agent config the CLI writes to disk; the rest is display copy.
159+
export interface AgentTemplate {
160+
slug: string
161+
name: string
162+
description: string
163+
tags: string[]
164+
summary: string
165+
use_case: string
166+
yaml: string
167+
}
168+
169+
export interface ListAgentTemplatesResponse {
170+
templates: AgentTemplate[]
171+
}
172+
136173
export interface ListAgentRunsQuery {
137174
config_id?: string
138175
source?: AgentRunSource[]

test/api.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,67 @@ describe('ApiClient.request', () => {
9292
expect(err.message).toContain('GET /x failed: 500 boom')
9393
})
9494
})
95+
96+
describe('agent templates', () => {
97+
afterEach(() => vi.unstubAllGlobals())
98+
99+
it('unwraps the templates array from the list response', async () => {
100+
const fetchMock = vi.fn(
101+
async () =>
102+
new Response(JSON.stringify({ templates: [{ slug: 'a' }, { slug: 'b' }] }), {
103+
status: 200,
104+
}),
105+
)
106+
vi.stubGlobal('fetch', fetchMock)
107+
108+
const out = await new ApiClient('http://api.test', 't').listAgentTemplates()
109+
expect(out.map((t) => t.slug)).toEqual(['a', 'b'])
110+
expect(fetchMock.mock.calls[0][0]).toBe('http://api.test/v1/agents/templates')
111+
})
112+
113+
it('fetches a single template by slug (encoded)', async () => {
114+
const fetchMock = vi.fn(
115+
async () =>
116+
new Response(JSON.stringify({ slug: 'ci-failure-triager', yaml: 'x' }), { status: 200 }),
117+
)
118+
vi.stubGlobal('fetch', fetchMock)
119+
120+
const out = await new ApiClient('http://api.test', 't').getAgentTemplate('ci-failure-triager')
121+
expect(out.yaml).toBe('x')
122+
expect(fetchMock.mock.calls[0][0]).toBe(
123+
'http://api.test/v1/agents/templates/ci-failure-triager',
124+
)
125+
})
126+
})
127+
128+
describe('createAgentConfig', () => {
129+
afterEach(() => vi.unstubAllGlobals())
130+
131+
it('POSTs template_id + repository and returns the pull request', async () => {
132+
const fetchMock = vi.fn(
133+
async () =>
134+
new Response(
135+
JSON.stringify({
136+
config: { id: 'cfg_1' },
137+
path: 'agents/ci-failure-triager.yaml',
138+
pull_request_url: 'https://github.com/octocat/api/pull/7',
139+
}),
140+
{ status: 200 },
141+
),
142+
)
143+
vi.stubGlobal('fetch', fetchMock)
144+
145+
const out = await new ApiClient('http://api.test', 't').createAgentConfig({
146+
template_id: 'ci-failure-triager',
147+
repository: 'api',
148+
})
149+
expect(out.pull_request_url).toBe('https://github.com/octocat/api/pull/7')
150+
const [url, init] = fetchMock.mock.calls[0]
151+
expect(url).toBe('http://api.test/v1/agents/configs')
152+
expect((init as RequestInit).method).toBe('POST')
153+
expect(JSON.parse((init as RequestInit).body as string)).toEqual({
154+
template_id: 'ci-failure-triager',
155+
repository: 'api',
156+
})
157+
})
158+
})

0 commit comments

Comments
 (0)