Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ agent config list # list saved agent configs
agent config get <config-id> # show one config as YAML (-o json for JSON)
agent config init [path] # scaffold a starter config (default: agents/my_agent.yaml)
agent config create --repo api --file agents/foo.yaml # create an agent via a pull request (or --template <slug>)
agent config default # the effective default agent for the repo you are standing in
agent config default set <config-id> # set the account default agent (--repo [owner/name] for one repo)
agent config default clear # clear the account default (--repo [owner/name] for one repo)

agent integrations # every connected integration in one table
agent github repos # repositories connected to the GitHub installation
Expand Down
1 change: 1 addition & 0 deletions skills/ellipsis/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ Author and inspect agents:
```sh
agent config init # scaffold agents/my_agent.yaml
agent config create --template code-reviewer --repo api # deploy via PR
agent config default set <configId> # the agent a bare start runs (--repo for one repo)
agent template list # browse maintained templates
agent integrations # connected GitHub/Slack/Linear/Sentry
agent sandbox variable set LINEAR_API_KEY=...
Expand Down
157 changes: 155 additions & 2 deletions src/commands/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,22 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
import { basename, dirname, extname } from 'node:path'
import { ApiClient } from '../lib/api'
import { resolveAppBase } from '../lib/config'
import { repoFromCwd } from '../lib/laptop'
import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output'
import { configUrl } from '../lib/urls'
import { readConfigFile } from './session'
import type { CreateAgentConfigRequest, SavedAgentConfig } from '../lib/types'
import type {
AgentDefaultView,
CreateAgentConfigRequest,
SavedAgentConfig,
} from '../lib/types'

const DEFAULT_CONFIG_PATH = 'agents/my_agent.yaml'

export function registerConfig(program: Command): void {
const config = program.command('config').description('Inspect saved agent configurations')
const config = program
.command('config')
.description('Inspect saved agent configurations and manage defaults')

config
.command('list')
Expand Down Expand Up @@ -112,6 +119,125 @@ export function registerConfig(program: Command): void {
},
)

// ------------------------------- defaults --------------------------------
// The default-config ladder a bare session start resolves: repo default ->
// account default -> the bare platform config. Rung-addressed, never row
// ids: writes target the ACCOUNT rung unless --repo names (or detects) a
// repository. Reading is context-aware (bare `agent config default` shows
// the effective default where you stand); writes never are — a mutation
// whose target depends on your cwd would be a footgun, so --repo is always
// explicit.
const defaults = config
.command('default')
.alias('defaults')
.description('Show and manage default agent configs (account and per-repo)')
.option('--json', 'output raw JSON')
// Bare `agent config default`: the effective default for the repo you're
// standing in, computed locally from GET /v1/defaults + the origin remote
// (the same ladder session start resolves server-side).
.action(async (opts: { json?: boolean }) => {
await runAction(async () => {
const rungs = await new ApiClient().listAgentDefaults()
const repo = repoFromCwd(process.cwd())
const repoRung = repo
? rungs.find((d) => d.repository?.toLowerCase() === repo.toLowerCase())
: undefined
const accountRung = rungs.find((d) => d.repository === null)
const effective = repoRung ?? accountRung
if (opts.json) {
printJson({ repository: repo ?? null, effective: effective ?? null })
return
}
if (!effective) {
console.log(
repo
? `no default set for ${repo} or the account (sessions start on the bare config)`
: 'no account default set (sessions start on the bare config)',
)
return
}
const rung = effective.repository
? `repo default for ${effective.repository}`
: 'account default'
console.log(`using config "${defaultName(effective)}" (${rung})${brokenSuffix(effective)}`)
})
})

defaults
.command('list')
.alias('ls')
.description('List all default-config rungs (GET /v1/defaults)')
.option('--json', 'output raw JSON')
.action(async (opts: { json?: boolean }) => {
await runAction(async () => {
const rungs = await new ApiClient().listAgentDefaults()
if (opts.json) {
printJson(rungs)
return
}
if (rungs.length === 0) {
console.log('No defaults set. Sessions start on the bare config.')
return
}
printTable(
['RUNG', 'CONFIG', 'CONFIG ID', 'STATUS', 'UPDATED'],
rungs.map((d) => [
d.repository ?? 'account',
d.config_name ?? '—',
d.config_id,
d.broken ? `broken: ${d.broken}` : 'ok',
formatTs(d.updated_at),
]),
)
})
})

defaults
.command('set <configId>')
.description(
'Set the account default agent config, or a repo default with --repo (PUT /v1/defaults)',
)
.option(
'--repo [repository]',
'target a repo rung: "owner/name", or no value for the repo you are standing in',
)
.option('--json', 'output raw JSON')
.action(async (configId: string, opts: { repo?: string | boolean; json?: boolean }) => {
await runAction(async () => {
const repository = resolveRepoFlag(opts.repo)
const set = await new ApiClient().putAgentDefault({
config_id: configId,
...(repository ? { repository } : {}),
})
if (opts.json) {
printJson(set)
return
}
const rung = set.repository ? `default for ${set.repository}` : 'account default'
console.log(`✓ set ${rung} to "${defaultName(set)}" (${set.config_id})`)
})
})

defaults
.command('clear')
.alias('rm')
.description(
'Clear the account default agent config, or a repo default with --repo (DELETE /v1/defaults)',
)
.option(
'--repo [repository]',
'target a repo rung: "owner/name", or no value for the repo you are standing in',
)
.action(async (opts: { repo?: string | boolean }) => {
await runAction(async () => {
const repository = resolveRepoFlag(opts.repo)
await new ApiClient().deleteAgentDefault(repository)
console.log(
`✓ cleared ${repository ? `default for ${repository}` : 'account default'}`,
)
})
})

config
.command('init [path]')
.description(
Expand Down Expand Up @@ -175,6 +301,33 @@ export function registerConfig(program: Command): void {
const COMMIT_HINT =
'Commit it to your default branch. Ellipsis syncs agent configs from GitHub.'

// --repo semantics on defaults mutations: absent -> the account rung; bare
// --repo -> the repo you're standing in (from the origin remote, an error
// when there isn't one); --repo owner/name -> that repo.
function resolveRepoFlag(repo: string | boolean | undefined): string | undefined {
if (repo === undefined || repo === false) return undefined
if (repo === true) {
const detected = repoFromCwd(process.cwd())
if (!detected) {
throw new Error(
'no git repository detected here; pass --repo owner/name or run inside a clone',
)
}
return detected
}
return repo
}

function defaultName(d: AgentDefaultView): string {
return d.config_name ?? d.config_id
}

// A set-but-broken rung fails session starts closed (never a silent
// fall-through), so surface it wherever the rung is shown.
function brokenSuffix(d: AgentDefaultView): string {
return d.broken ? ` (broken: ${d.broken})` : ''
}

// 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
24 changes: 24 additions & 0 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { resolveApiBase, resolveToken } from './config'
import { USER_AGENT } from './constants'
import type {
AgentDefaultView,
AgentSession,
AgentTemplate,
AnalyticsMetricsQuery,
Expand All @@ -23,6 +24,7 @@ import type {
GetSessionIdeResponse,
GetSessionPortResponse,
ListAgentConfigsResponse,
ListAgentDefaultsResponse,
ListAgentSessionsQuery,
ListAgentSessionsResponse,
ListAgentTemplatesResponse,
Expand All @@ -37,6 +39,7 @@ import type {
ListSessionTranscriptsResponse,
ListSlackChannelsResponse,
ListSlackMembersResponse,
PutAgentDefaultRequest,
ReplayAgentSessionRequest,
SendSessionMessageRequest,
SandboxVariableInput,
Expand Down Expand Up @@ -327,6 +330,27 @@ export class ApiClient {
return this.request('GET', `/v1/configs/${encodeURIComponent(configId)}`)
}

// ------------------------------ defaults --------------------------------
// The default-config ladder (repo default -> account default -> bare),
// addressed by rung: `repository` is "owner/name" for a repo default and
// null/omitted for the account default — never a row id. Mutations are
// refused for sandbox tokens (403).

async listAgentDefaults(): Promise<AgentDefaultView[]> {
const res = await this.request<ListAgentDefaultsResponse>('GET', '/v1/defaults')
return res.defaults
}

putAgentDefault(req: PutAgentDefaultRequest): Promise<AgentDefaultView> {
return this.request('PUT', '/v1/defaults', req)
}

// Clears a rung: the account default when `repository` is omitted, that
// repo's default otherwise. 404 when the rung isn't set.
deleteAgentDefault(repository?: string): Promise<void> {
return this.request('DELETE', '/v1/defaults', undefined, { repository })
}

// -------------------------- sandbox variables ---------------------------
// All three return the full current list (the backend echoes it after every
// mutation), so callers can render the resulting state.
Expand Down
26 changes: 26 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,32 @@ export interface ListAgentConfigsResponse {
configs: SavedAgentConfig[]
}

// One rung of the default-config ladder (GET /v1/defaults). Rungs are
// addressed by `repository`: "owner/name" for a repo default, null for the
// account-wide default — never by row id.
export interface AgentDefaultView {
id: string
repository: string | null
config_id: string
// The pointed-at config's name; null when the config is gone (see broken).
config_name: string | null
// Why this rung can't serve sessions (config_deleted | config_disabled |
// config_pending_pr | repo_inaccessible); null when healthy.
broken: string | null
updated_at: string
}

export interface ListAgentDefaultsResponse {
defaults: AgentDefaultView[]
}

// Body of PUT /v1/defaults: point a rung at a config. `repository` omitted
// sets the account default; "owner/name" sets that repo's default.
export interface PutAgentDefaultRequest {
repository?: string
config_id: string
}

// Create-config payload for POST /v1/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.
Expand Down