From 6fb574418801359db7347948729465c54ae57d57 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Fri, 17 Jul 2026 17:22:19 -0400 Subject: [PATCH] agent config default sub-group: show/set/clear the default-config ladder - config default (alias defaults): bare = the effective default for the repo you're standing in, computed client-side from GET /v1/defaults + repoFromCwd - list/ls: every rung with broken status; set [--repo [owner/name]] (no flag = account rung, bare --repo = detected repo); clear (alias rm) - api: listAgentDefaults/putAgentDefault/deleteAgentDefault over rung-addressed /v1/defaults; README + skill notes --- README.md | 3 + skills/ellipsis/SKILL.md | 1 + src/commands/config.ts | 157 ++++++++++++++++++++++++++++++++++++++- src/lib/api.ts | 24 ++++++ src/lib/types.ts | 26 +++++++ 5 files changed, 209 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea2cfd8..c035a03 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ agent config list # list saved agent configs agent config get # 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 ) +agent config default # the effective default agent for the repo you are standing in +agent config default set # 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 diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 0f4a376..2c6751f 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -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 # 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=... diff --git a/src/commands/config.ts b/src/commands/config.ts index 38097fd..4b7940e 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -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') @@ -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 ') + .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( @@ -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. diff --git a/src/lib/api.ts b/src/lib/api.ts index f31b0c7..92da5d0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,6 +1,7 @@ import { resolveApiBase, resolveToken } from './config' import { USER_AGENT } from './constants' import type { + AgentDefaultView, AgentSession, AgentTemplate, AnalyticsMetricsQuery, @@ -23,6 +24,7 @@ import type { GetSessionIdeResponse, GetSessionPortResponse, ListAgentConfigsResponse, + ListAgentDefaultsResponse, ListAgentSessionsQuery, ListAgentSessionsResponse, ListAgentTemplatesResponse, @@ -37,6 +39,7 @@ import type { ListSessionTranscriptsResponse, ListSlackChannelsResponse, ListSlackMembersResponse, + PutAgentDefaultRequest, ReplayAgentSessionRequest, SendSessionMessageRequest, SandboxVariableInput, @@ -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 { + const res = await this.request('GET', '/v1/defaults') + return res.defaults + } + + putAgentDefault(req: PutAgentDefaultRequest): Promise { + 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 { + 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. diff --git a/src/lib/types.ts b/src/lib/types.ts index 9d33198..5bb0c39 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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.