Skip to content

Commit 276f22c

Browse files
authored
config: create an agent without a repository, and move it between the two owners (#113)
An agent is owned either by a file in a repository or by the API, and until now the CLI could only make the first kind. `config create` drops --repo to make an agent that has no file and is live at once; `config edit` and `config delete` change and remove it; `config link` and `config unlink` move an existing agent between the two owners.
1 parent 9442bb7 commit 276f22c

6 files changed

Lines changed: 165 additions & 28 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,12 @@ agent review init # scaffold a starter review pipeline (code_rev
7575
agent config list # list saved agent configs
7676
agent config get <config-id> # show one config as YAML (--json for JSON)
7777
agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml)
78-
agent config create --repo api --file agents/foo.yaml # create an agent via a pull request (or --template <slug>)
78+
agent config create --file agents/foo.yaml # create an agent, live at once (or --template <slug>)
79+
agent config create --repo api --file agents/foo.yaml # instead define it as a file, via a pull request
80+
agent config edit <config-id> --file agents/foo.yaml # replace its definition, live at once
81+
agent config delete <config-id> # delete it; the agent stops and its name is freed
82+
agent config link <config-id> --repo api # move it into a repository, via a pull request
83+
agent config unlink <config-id> # take it over from its file, so the API changes it
7984
agent config default # the effective default agent for the repo you are standing in
8085
agent config default set <config-id> # set the account default agent (--repo [owner/name] for one repo)
8186
agent config default clear # clear the account default (--repo [owner/name] for one repo)

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"test:watch": "vitest"
2121
},
2222
"dependencies": {
23-
"@ellipsis-dev/sdk": "^0.10.0",
23+
"@ellipsis-dev/sdk": "^0.11.0",
2424
"chalk": "^5.6.2",
2525
"cli-table3": "^0.6.5",
2626
"commander": "^12.1.0",

skills/ellipsis/SKILL.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -444,14 +444,24 @@ Author and deploy agents:
444444
agent config init agents/my_agent.yaml # scaffold a starter config locally
445445
agent config list # saved configs with their source file
446446
agent config get <config-id> # one config as YAML
447-
agent config create --repo api --file agents/my_agent.yaml # deploy via a pull request
447+
agent config create --file agents/my_agent.yaml # create it, live at once
448+
agent config edit <id> --file agents/my_agent.yaml # replace its definition, live at once
449+
agent config delete <id> # delete it; it stops and frees its name
450+
agent config link <id> --repo api # move it into a repo, via a pull request
451+
agent config unlink <id> # take it over from its file
448452
agent config default set <config-id> # the account default (--repo for one repo)
449453
agent template list # built-in templates and their slugs
450454
agent model list # the model ids valid under claude.model
451455
```
452456

453-
`agent config create` opens a pull request adding the file, exactly as the
454-
dashboard does; the agent goes live when it merges.
457+
An agent is owned by one of two writers, and that is what these verbs move.
458+
`agent config create` with no `--repo` creates it through the API alone: no
459+
file, live immediately, changed by `config edit`. With `--repo` it instead
460+
opens a pull request adding the file, exactly as the dashboard does, and the
461+
agent goes live when that merges — thereafter the file is what changes it, and
462+
`config edit` is refused. `config link` moves an API-owned agent into a
463+
repository (by pull request; it keeps running unchanged until the merge) and
464+
`config unlink` takes one back from its file, leaving the file in place, inert.
455465

456466
Platform and integrations:
457467

src/commands/config.ts

Lines changed: 141 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
AgentConfig,
1313
AgentDefaultView,
1414
CreateAgentConfigRequest,
15+
CreatedAgentConfig,
1516
SavedAgentConfig,
1617
} from '../lib/types'
1718

@@ -82,18 +83,20 @@ export function registerConfig(program: Command): void {
8283
})
8384
})
8485

85-
// Create an agent config the same way the dashboard does: Ellipsis opens a
86-
// pull request adding the YAML to the repo, and the agent goes live when it
87-
// merges. Distinct from `config init`, which scaffolds a local file.
86+
// Create an agent. Two shapes, chosen by --repo: with it, Ellipsis opens a
87+
// pull request adding the YAML to that repo and the agent goes live when it
88+
// merges (what the dashboard has always done); without it, the agent is
89+
// created through the API alone — no file, live at once, changed by `config
90+
// edit`. Distinct from `config init`, which scaffolds a local file.
8891
apiRoutes(
8992
config
9093
.command('create')
91-
.description('Create an agent config by opening a pull request that adds it to a repo'),
94+
.description('Create an agent, live immediately or by pull request with --repo'),
9295
'POST /agents/configs',
9396
)
94-
.requiredOption(
97+
.option(
9598
'-r, --repo <name>',
96-
'repository in your account to open the pull request against',
99+
'define the agent as a file in this repository, by pull request (default: no file, live at once)',
97100
)
98101
.option('-f, --file <path>', 'agent config file (.yaml/.yml or .json) to add')
99102
.option(
@@ -102,12 +105,12 @@ export function registerConfig(program: Command): void {
102105
)
103106
.option(
104107
'--path <path>',
105-
'file path within the repo for the config (default: agents/<slug>.yaml; must be a synced location)',
108+
'file path within the repo for the config (default: agents/<slug>.yaml; must be a synced location; needs --repo)',
106109
)
107110
.option('--json', 'output raw JSON')
108111
.action(
109112
async (opts: {
110-
repo: string
113+
repo?: string
111114
file?: string
112115
template?: string
113116
path?: string
@@ -119,6 +122,9 @@ export function registerConfig(program: Command): void {
119122
if (!opts.file === !opts.template) {
120123
throw new Error('provide exactly one of --file <path> or --template <slug>')
121124
}
125+
if (opts.path && !opts.repo) {
126+
throw new Error('--path names a location in a repository, so it needs --repo <name>')
127+
}
122128
const req: CreateAgentConfigRequest = {
123129
repository: opts.repo,
124130
path: opts.path,
@@ -130,13 +136,109 @@ export function registerConfig(program: Command): void {
130136
printJson(created)
131137
return
132138
}
133-
console.log(`✓ opened a pull request adding the agent config (${created.path})`)
134-
console.log(created.pull_request_url)
135-
console.log('Merge it to deploy the agent.')
139+
printCreated(created)
140+
})
141+
},
142+
)
143+
144+
// Replace an API-managed agent's whole definition, live at once. Refused for
145+
// an agent defined by a repository file (the next push would revert it) —
146+
// `config unlink` takes ownership first.
147+
apiRoutes(
148+
alsoKnownAs(
149+
config
150+
.command('edit <config-id>')
151+
.description("Replace an API-managed agent's definition from a file, live immediately"),
152+
'update',
153+
),
154+
'PUT /agents/configs/{id}',
155+
)
156+
.requiredOption('-f, --file <path>', 'agent config file (.yaml/.yml or .json) to replace it with')
157+
.option('--json', 'output raw JSON')
158+
.action(async (configId: string, opts: { file: string; json?: boolean }) => {
159+
await runAction(async () => {
160+
const { config: updated } = await api().agents.configs.update(configId, {
161+
config: readConfigFile(opts.file) as AgentConfig,
162+
})
163+
if (opts.json) {
164+
printJson(updated)
165+
return
166+
}
167+
console.log(`✓ updated "${configName(updated)}" (${updated.id}) — live now`)
168+
})
169+
})
170+
171+
apiRoutes(
172+
alsoKnownAs(
173+
config
174+
.command('delete <config-id>')
175+
.description('Delete an API-managed agent; it stops running and frees its name'),
176+
'rm',
177+
),
178+
'DELETE /agents/configs/{id}',
179+
)
180+
.option('--json', 'output raw JSON')
181+
.action(async (configId: string, opts: { json?: boolean }) => {
182+
await runAction(async () => {
183+
await api().agents.configs.delete(configId)
184+
// 204 No Content — nothing to echo, so confirm with what was addressed.
185+
if (opts.json) printJson({ id: configId, deleted: true })
186+
else console.log(`✓ deleted ${configId}`)
187+
})
188+
})
189+
190+
// The two ownership moves. `link` hands an API-managed agent over to a file
191+
// (by pull request; it keeps running unchanged until that merges); `unlink`
192+
// takes one back from its file (immediate, and the file is left inert).
193+
apiRoutes(
194+
config
195+
.command('link <config-id>')
196+
.description('Move an agent into a repository by opening a pull request that adds its file'),
197+
'POST /agents/configs/{id}/link',
198+
)
199+
.requiredOption('-r, --repo <name>', 'repository in your account to move the agent into')
200+
.option(
201+
'--path <path>',
202+
'file path within the repo for the config (default: agents/<slug>.yaml; must be a synced location)',
203+
)
204+
.option('--json', 'output raw JSON')
205+
.action(
206+
async (configId: string, opts: { repo: string; path?: string; json?: boolean }) => {
207+
await runAction(async () => {
208+
const linked = await api().agents.configs.link(configId, {
209+
repository: opts.repo,
210+
path: opts.path,
211+
})
212+
if (opts.json) {
213+
printJson(linked)
214+
return
215+
}
216+
console.log(`✓ opened a pull request adding the agent config (${linked.path})`)
217+
console.log(linked.pull_request_url)
218+
console.log('The agent keeps running meanwhile; merging hands it over to the file.')
136219
})
137220
},
138221
)
139222

223+
apiRoutes(
224+
config
225+
.command('unlink <config-id>')
226+
.description('Take an agent over from its file, so this API changes it instead'),
227+
'POST /agents/configs/{id}/unlink',
228+
)
229+
.option('--json', 'output raw JSON')
230+
.action(async (configId: string, opts: { json?: boolean }) => {
231+
await runAction(async () => {
232+
const { config: unlinked } = await api().agents.configs.unlink(configId)
233+
if (opts.json) {
234+
printJson(unlinked)
235+
return
236+
}
237+
console.log(`✓ took over "${configName(unlinked)}" (${unlinked.id})`)
238+
console.log('Its file no longer governs it and is left in place, inert.')
239+
})
240+
})
241+
140242
// ------------------------------- defaults --------------------------------
141243
// The default-config ladder a bare session start resolves: repo default ->
142244
// account default -> the bare platform config. Rung-addressed, never row
@@ -313,14 +415,13 @@ export function registerConfig(program: Command): void {
313415
return
314416
}
315417
await runAction(async () => {
316-
const created = await api().agents.configs.create({
317-
template_id: opts.template,
318-
repository: opts.repo!,
319-
path: opts.path,
320-
})
321-
console.log(`✓ opened a pull request adding the agent config (${created.path})`)
322-
console.log(created.pull_request_url)
323-
console.log('Merge it to deploy the agent.')
418+
printCreated(
419+
await api().agents.configs.create({
420+
template_id: opts.template,
421+
repository: opts.repo!,
422+
path: opts.path,
423+
}),
424+
)
324425
})
325426
return
326427
}
@@ -342,6 +443,23 @@ export function registerConfig(program: Command): void {
342443
const COMMIT_HINT =
343444
'Commit it to your default branch. Ellipsis syncs agent configs from GitHub.'
344445

446+
// A create answers two ways: with a repository the agent waits on a pull
447+
// request, without one it is already live and has no file.
448+
function printCreated(created: CreatedAgentConfig): void {
449+
if (created.pull_request_url) {
450+
console.log(`✓ opened a pull request adding the agent config (${created.path})`)
451+
console.log(created.pull_request_url)
452+
console.log('Merge it to deploy the agent.')
453+
return
454+
}
455+
console.log(`✓ created "${configName(created.config)}" (${created.config.id}) — live now`)
456+
console.log('It has no file; change it with `agent config edit`, or `agent config link` to move it into a repo.')
457+
}
458+
459+
function configName(c: SavedAgentConfig): string {
460+
return c.agent_config.ellipsis.name ?? c.id
461+
}
462+
345463
// --repo semantics on defaults mutations: absent -> the account rung; bare
346464
// --repo -> the repo you're standing in (from the origin remote, an error
347465
// when there isn't one); --repo owner/name -> that repo. Shared with
@@ -397,13 +515,15 @@ claude:
397515
}
398516

399517
// GitHub source as `path@branch` (repo is only an opaque numeric id in the API).
400-
// Prefixed with ⚠ when the last sync failed so it stands out in the list.
518+
// Prefixed with ⚠ when the last sync failed so it stands out in the list. An
519+
// API-managed agent has no file at all, which is a different thing from a
520+
// github-managed one whose source is momentarily unknown — so name it.
401521
function configSource(c: SavedAgentConfig): string {
402522
const s = c.agent_config_source_details as
403523
| { repo_id: number; path: string; branch: string }
404524
| null
405525
| undefined
406-
const base = s ? `${s.path}@${s.branch}` : '—'
526+
const base = s ? `${s.path}@${s.branch}` : c.managed_by === 'api' ? 'api' : '—'
407527
return c.last_sync_error ? `⚠ ${base}` : base
408528
}
409529

src/lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export type SavedAgentConfig = S['Config']
6060
export type ListAgentConfigsResponse = S['AgentConfigsListResponse']
6161
export type CreateAgentConfigRequest = Parameters<Ellipsis['agents']['configs']['create']>[0]
6262
export type CreatedAgentConfig = S['CreateAgentConfigResponse']
63+
export type ConfigManagedBy = S['ConfigManagedBy']
64+
export type LinkedAgentConfig = S['LinkAgentConfigResponse']
6365
export type AgentDefaultView = S['AgentDefault']
6466
export type ListAgentDefaultsResponse = S['AgentDefaultsListResponse']
6567
export type PutAgentDefaultRequest = S['PutAgentDefaultRequest']

0 commit comments

Comments
 (0)