diff --git a/README.md b/README.md index 2d99e6a..af31595 100644 --- a/README.md +++ b/README.md @@ -56,18 +56,18 @@ agent session start --config-file f.json # ...or from an inline config agent session start --template welcome-to-ellipsis # ...or from a maintained template agent session start --config --config-override "budget:\n session: 5" # override config fields for this session agent session start --config --watch # start and immediately stream it -agent session list --limit 20 # list recent sessions (filter by --source, --author, --days, …) +agent session list --limit 20 # list recent sessions (filter by --source, --author, --since, …) agent session search "webhook retries" # search session history: transcripts, recaps, created PRs, similarity agent session search "acme/api#512" --author tony --since "3 days ago" # PR-shaped queries and facets agent session get # inspect one session (prints a dashboard link) agent session get --watch # follow a session until it finishes -agent session records # read a session's stored transcript, one line per record +agent session record # read a session's stored transcript, one line per record agent session connect # connect to a session: transcript + live output + send messages agent session connect # inside an Ellipsis sandbox: connects to the running session agent session stop # stop an in-flight session agent config list # list saved agent configs -agent config get # show one config as YAML (-o json for JSON) +agent config get # show one config as YAML (--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 @@ -76,7 +76,7 @@ agent config default clear # clear the account default (--repo [owner/nam agent model list # list selectable agent models (the account default is marked) -agent integrations # every connected integration in one table +agent integration # every connected integration in one table agent github repos # repositories connected to the GitHub installation agent github members # org roster (the logins/ids --author accepts), with linked Slack identities agent slack channels # channels in the connected Slack workspace @@ -91,17 +91,23 @@ agent asset delete # delete an asset (it disappears from list/get agent sandbox variable list # list sandbox env variable names (values are write-only) agent sandbox variable set A=1 B=2 # create/update variables (or --from-file .env/.json) -agent sandbox variable rm K # delete a variable +agent sandbox variable delete K # delete a variable agent budget # current budget summary agent usage # usage dashboard for the period -agent analytics reviewers --account-type bot # which apps review the most PRs -agent analytics prs --days 30 # PR volume/trend with human vs bot splits -agent analytics reviews --repo my-service # review totals + top reviewers +agent analytics reviewer --account-type bot # which apps review the most PRs +agent analytics pr --days 30 # PR volume/trend with human vs bot splits +agent analytics review --repo my-service # review totals + top reviewers agent ping # check authenticated /v1 connectivity ``` +Every command shown is singular. The plural spelling of each (`agent assets`, +`agent sessions`, `agent analytics prs`) is a hidden alias that works but is +left out of `--help`. See +[`skills/cli-conventions`](skills/cli-conventions/SKILL.md) for the full +argument, flag, and help-text conventions. + Most commands accept `--json` to print the raw API response. The CLI talks to the public `/v1` REST API. Point it at a different instance durably with `agent host` (below), or per-invocation with `ELLIPSIS_API_BASE_URL` (or the diff --git a/scripts/smoke-local.sh b/scripts/smoke-local.sh index f0390b6..8a39901 100755 --- a/scripts/smoke-local.sh +++ b/scripts/smoke-local.sh @@ -104,7 +104,7 @@ run me run budget run usage run config list -run run list --limit 5 +run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 58a1137..54202e1 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -44,7 +44,7 @@ run me run budget run usage run config list -run run list --limit 5 +run session list --limit 5 echo "== Logout should clear the token (next call 401s) ==" run logout diff --git a/skills/cli-conventions/SKILL.md b/skills/cli-conventions/SKILL.md new file mode 100644 index 0000000..c5f332b --- /dev/null +++ b/skills/cli-conventions/SKILL.md @@ -0,0 +1,125 @@ +--- +name: cli-conventions +description: How to name commands, arguments, and flags in the Ellipsis agent CLI, and how to write its --help text. Use when adding or renaming a command, adding a flag, writing or reviewing a command description, or changing anything under src/commands/ in the ellipsis-dev/cli repo. +--- + +# Ellipsis CLI conventions + +The primary reader of `agent --help` is a coding agent deciding its next call. +It reads once, from a cold start, with no memory of the last release. Every +rule here follows from that: **one spelling per concept, intent over +transport, no clutter to scan past.** + +Helpers live in `src/lib/help.ts`. Use them; do not hand-roll `.alias()` or +route text. + +## Command names + +Singular nouns, one verb per action. + +``` +agent asset list agent asset delete +agent session start agent config default set +``` + +- **The noun is singular, always.** `asset`, not `assets`. `hook`, not + `hooks`. `analytics` is the sole exception: it is a mass noun with no + singular form. +- **The plural still works, hidden.** Register it with `alsoKnownAs`, which + keeps it callable but strips it from every help surface. `agent assets list` + runs and prints nothing extra. +- **Read-only integration browsers use a bare plural leaf**: `github repos`, + `slack channels`, `linear teams`, `sentry orgs`. They have no + get/create/delete to disambiguate against, so the extra `list` is noise. + Anything with more than one verb gets ` `: `asset list`, + `asset get`, `asset upload`, `asset delete`. +- **`delete` is the shown verb**, with `rm` as a hidden alias. Never the + reverse. +- **`list` is the shown verb**, with `ls` hidden. + +```ts +const asset = alsoKnownAs( + program.command('asset').description('...'), + 'assets', +) + +apiRoutes( + alsoKnownAs(asset.command('delete ').description('...'), 'rm'), + 'DELETE /v1/assets/{id}', +) +``` + +## Arguments + +Kebab-case placeholders: ``, ``, ``, +``, ``. Never camelCase, and never a bare `` when the +type matters. + +## Flags + +One meaning per short flag, across the whole CLI. The reserved ones: + +| Short | Long | Meaning | +| ----- | ---- | ------- | +| `-o` | `--output ` | a file to write to. Never a format. | +| `-d` | `--detach` | start and return. Never `--days`. | +| `-c` | `--config ` | a saved agent config | +| `-f` | `--file` / `--from-file` / `--config-file` | an input file. Never `--force`. | +| `-r` | `--repo` | a repository | +| `-s` | `--source` | a session source | +| `-a` | `--author` | a GitHub login | +| `-l` | `--limit ` | a result cap | +| `-t` | `--template ` | a template | +| `-p` | `--prompt` / `--parent` | (context-dependent, both session-scoped) | +| `-w` | `--watch` | block and stream | +| `-m` | `--metadata` | repeatable key=value | +| `-n` | `--tail ` | tail N entries | + +Other rules: + +- **`--json` is the only way to ask for JSON.** There is no `-o json`, no + `--format`. Description: `output raw JSON`, plus a parenthetical when the + JSON differs from the table (`output raw JSON (full record payloads)`). +- **Time windows are `--since` / `--until`, plus `--days `.** Both accept + ISO 8601 and `today`, `yesterday`, `N days ago` via `parseWhen`. `--start` / + `--end` are dead; do not reintroduce them. +- **Repeatable flags say so**: `(repeatable)` at the end of the description. +- **Coerce and validate in `src/lib/args.ts`**, so a typo fails locally with + the full list of valid values instead of a server 422. + +## Descriptions + +One line, imperative verb first, no trailing period. + +- **Say what the caller gets, not which endpoint answers.** `List your stored + assets, newest first` — not `List assets (GET /v1/assets)`. +- **Routes go in the long help**, last, via `apiRoutes(cmd, 'GET /v1/...')`. + When a command also has an `addHelpText('after', ...)` usage note, chain the + note *inside* the `apiRoutes()` call so the route line still lands last. +- **Name the concept the same way every time.** The object under `agent + config` is an **agent config** — never "configuration", never bare "agent". +- **No em dashes or en dashes** in any new user-facing string. Use a colon, a + comma, or two sentences. (Legacy table placeholders still hold `—`; do not + add more, and prefer `-` for new ones.) +- **No `a|b` alias spellings in prose.** `model|models` tells the reader + nothing and doubles the width of the term column. +- Point at the command that answers the follow-up question: + `(see \`agent model list\`)`, `(see \`agent github members\`)`. + +## Top-level help + +`agent --help` is grouped, not flat: Sessions, Agents, Platform, +Integrations, Spend, Account. Groups live in `TOP_LEVEL_GROUPS` in +`src/lib/help.ts`. **A new top-level command must be added to a group** or it +falls through to "Other", which is the signal that someone forgot. + +## Checklist for a new command + +1. Singular name; plural registered via `alsoKnownAs`. +2. Kebab-case argument placeholders. +3. Short flags match the reserved table, or have none. +4. Description: imperative, one line, no route, no em dash. +5. Routes via `apiRoutes`, chained outside any usage note. +6. `--json` if it prints structured data. +7. New top-level command added to `TOP_LEVEL_GROUPS`. +8. `npm run typecheck && npm test`, then read the actual `--help` output. diff --git a/skills/ellipsis/SKILL.md b/skills/ellipsis/SKILL.md index 0d87f93..6aa3aee 100644 --- a/skills/ellipsis/SKILL.md +++ b/skills/ellipsis/SKILL.md @@ -82,24 +82,22 @@ sandbox image actually builds, then deploy. The steps below are that flow. agent sandbox variable set --from-file .env ``` -5. Draft a config and prove its environment before anything deploys. - `agent sandbox build` runs the config's environment definition (base image - plus your Dockerfile layers, repository checkout, dependency setup, and - with `--hooks` the lifecycle hooks) with no agent and no token spend, - streaming the build output. A broken toolchain install fails here in - minutes with its full log, not inside your first real session, and a green - build is cached so that first session starts warm. +5. Draft a config and run it from the local file before anything deploys. + Starting from `--config-file` provisions the sandbox from that config's + environment definition (base image plus your Dockerfile layers, repository + checkout, dependency setup, lifecycle hooks) and streams the startup output, + so a broken toolchain install surfaces with its full log on this run rather + than after the config is merged. A successful build is cached, so the next + session starts warm. ```sh agent config init agents/my_agent.yaml - agent sandbox build start --config-file agents/my_agent.yaml --watch - agent sandbox build start --config-file agents/my_agent.yaml --hooks --watch + agent session start --config-file agents/my_agent.yaml --watch ``` -6. Test a session from the local file, then deploy it as a pull request: +6. Deploy it as a pull request: ```sh - agent session start --config-file agents/my_agent.yaml --watch agent config create --repo api --file agents/my_agent.yaml ``` @@ -121,8 +119,8 @@ The same flow with transcripts: https://www.ellipsis.dev/docs/guides/cli-setup - **Sandbox**: each session gets an isolated cloud sandbox with Python, Node, git, the `gh` CLI, and the repositories pre-cloned. Dependency installs are cached into the image, so repeat sessions start in seconds. Compute, - lifecycle hooks, and extra image layers are per-agent YAML, and - `agent sandbox build` tests the image on its own, before any agent runs. + lifecycle hooks, and extra image layers are per-agent YAML, and a + `--config-file` start exercises them before the config is merged. - **Secrets and permissions**: credentials are stored once in a write-only variable store and injected by name; nothing in a sandbox can read values back. Each agent's GitHub token narrows to the permissions and repositories @@ -173,7 +171,7 @@ agent login # device-code auth tied to GitHub identity Start and follow work: ```sh -agent session start --config --watch # start a session and stream it +agent session start --config --watch # start a session and stream it agent session start --template welcome-to-ellipsis agent session list --limit 20 agent session get --watch # follow until it finishes @@ -188,16 +186,16 @@ Search and audit what agents have done: ```sh agent session search "webhook retries" # transcripts, recaps, PRs, similarity -agent session records # stored feed, one line per record +agent session record # stored feed, one line per record agent session log # download the complete session log -agent analytics reviewers --account-type bot # human vs bot PR analytics +agent analytics reviewer --account-type bot # human vs bot PR analytics ``` Hand local work to the cloud, and sync local Claude Code sessions into the same searchable history: ```sh -agent hooks install # transcript sync via CC hooks +agent hook install # transcript sync via CC hooks agent session handoff "finish the validator; tests fail on shift boundaries" ``` @@ -206,13 +204,13 @@ 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 config default set # the config a bare start runs (--repo for one repo) agent template list # browse maintained templates agent model list # the model ids valid under `claude:` -agent integrations # connected GitHub/Slack/Linear/Sentry +agent integration # connected GitHub/Slack/Linear/Sentry agent sandbox variable set LINEAR_API_KEY=... -agent sandbox build start --config-file agents/my_agent.yaml --watch - # prove the image before deploying +agent session start --config-file agents/my_agent.yaml --watch + # prove a config before deploying ``` ## Defining an agent diff --git a/src/cli.tsx b/src/cli.tsx index 6a3f77a..749a978 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -6,10 +6,10 @@ import { registerSession } from './commands/session' import { registerConfig } from './commands/config' import { registerSandbox } from './commands/sandbox' import { registerAsset } from './commands/asset' -import { registerHooks } from './commands/hooks' +import { registerHook } from './commands/hooks' import { registerTemplate } from './commands/template' import { registerModel } from './commands/model' -import { registerIntegrations } from './commands/integrations' +import { registerIntegration } from './commands/integrations' import { registerGithub } from './commands/github' import { registerSlack } from './commands/slack' import { registerLinear } from './commands/linear' @@ -18,6 +18,7 @@ import { registerUsage } from './commands/usage' import { registerAnalytics } from './commands/analytics' import { registerPing } from './commands/ping' import { VERSION } from './lib/constants' +import { configureCliHelp } from './lib/help' import { canHostSessionsUi, defaultStartRequest, runSessionsUi } from './ui/launch' const program = new Command() @@ -26,9 +27,10 @@ program .name('agent') .description('Ellipsis agent CLI: drive the Ellipsis cloud from your terminal') .version(VERSION) - // Set before the register* calls so every subcommand inherits it and lists - // its own subcommands alphabetically too. - .configureHelp({ sortSubcommands: true }) + +// Set before the register* calls so every subcommand inherits the same help +// rendering (sorted, alias-free, grouped at the top level). +configureCliHelp(program) registerLogin(program) registerHost(program) @@ -37,10 +39,10 @@ registerSession(program) registerConfig(program) registerSandbox(program) registerAsset(program) -registerHooks(program) +registerHook(program) registerTemplate(program) registerModel(program) -registerIntegrations(program) +registerIntegration(program) registerGithub(program) registerSlack(program) registerLinear(program) diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index f09a46d..95c0021 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -1,7 +1,8 @@ import type { Command } from 'commander' import { InvalidArgumentError } from 'commander' import { ApiClient } from '../lib/api' -import { collect, toInt } from '../lib/args' +import { collect, parseWhen, toInt } from '../lib/args' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' import type { AnalyticsAccountType, @@ -9,29 +10,37 @@ import type { ReviewerUsage, } from '../lib/types' -// `agent analytics` — GitHub PR + review analytics over GET /v1/analytics/*: -// the same aggregation behind the app's /analytics dashboard, so questions -// like "which apps review the most PRs?" are answerable from the terminal -// (`agent analytics reviewers --account-type bot`). Human-readable tables by -// default; --json prints the raw API response for agents/scripts. +// `agent analytics` is the same aggregation behind the dashboard's analytics +// page, so questions like "which apps review the most PRs?" are answerable +// from the terminal (`agent analytics reviewer --account-type bot`). +// Human-readable tables by default; --json prints the raw API response. // Window flags shared by every subcommand. The server defaults to the last -// 30 days; `--days` is mutually exclusive with `--start`. +// 30 days; `--days` is mutually exclusive with `--since`. interface WindowOpts { days?: number - start?: string - end?: string + since?: string + until?: string } function windowQuery(opts: WindowOpts): AnalyticsWindowQuery { - return { days: opts.days, start: opts.start, end: opts.end } + return { days: opts.days, start: opts.since, end: opts.until } } -function addWindowOptions(cmd: Command): Command { - return cmd - .option('-d, --days ', 'look back N days (default: 30)', toInt) - .option('--start ', 'window start (ISO timestamp; excludes --days)') - .option('--end ', 'window end (ISO timestamp; default: now)') +// The window flags plus the route line, in that order, so `--help` ends with +// the API detail rather than burying the usage note under it. +function addWindow(cmd: Command, route: string): Command { + return apiRoutes( + cmd + .option('--days ', 'look back N days (default: 30)', toInt) + .option('--since ', 'window start, which excludes --days', (v: string) => parseWhen(v)) + .option('--until ', 'window end (default: now)', (v: string) => parseWhen(v)) + .addHelpText( + 'after', + '\n--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', + ), + route, + ) } function toAccountType(value: string): AnalyticsAccountType { @@ -57,18 +66,19 @@ function toReviewerSort(value: string): ReviewerSort { } export function registerAnalytics(program: Command): void { + // "analytics" is a mass noun, so it has no singular form to alias. const analytics = program .command('analytics') - .description( - 'GitHub PR + review analytics for your org (GET /v1/analytics/*)', - ) + .description('Aggregate pull request and review activity across the org') - addWindowOptions( - analytics - .command('reviewers') - .description( - 'Who reviewed the most PRs — people and apps (e.g. --account-type bot for apps only)', - ), + addWindow( + alsoKnownAs( + analytics + .command('reviewer') + .description('Rank who reviews the most PRs, people and apps alike'), + 'reviewers', + ), + 'GET /v1/analytics/metrics', ) .option( '-r, --repo ', @@ -131,10 +141,14 @@ export function registerAnalytics(program: Command): void { }, ) - addWindowOptions( - analytics - .command('prs') - .description('Pull-request volume and trend, with human vs bot splits'), + addWindow( + alsoKnownAs( + analytics + .command('pr') + .description('Show pull request volume and trend, split human vs bot'), + 'prs', + ), + 'GET /v1/analytics/pull-requests', ) .option( '--account-type ', @@ -190,10 +204,14 @@ export function registerAnalytics(program: Command): void { }, ) - addWindowOptions( - analytics - .command('reviews') - .description('Review activity: totals, verdicts, and the top reviewers'), + addWindow( + alsoKnownAs( + analytics + .command('review') + .description('Show review totals, verdicts, and the top reviewers'), + 'reviews', + ), + 'GET /v1/analytics/reviews', ) .option( '-r, --repo ', diff --git a/src/commands/asset.ts b/src/commands/asset.ts index b4bd4d2..ac01371 100644 --- a/src/commands/asset.ts +++ b/src/commands/asset.ts @@ -2,6 +2,7 @@ import { type Command } from 'commander' import { readFileSync, writeFileSync } from 'node:fs' import { basename } from 'node:path' import { ApiClient, ApiError } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { formatTs, printJson, printTable, runAction } from '../lib/output' import type { AssetView, CreateAssetRequest, GetAssetResponse } from '../lib/types' @@ -75,15 +76,19 @@ export function formatSize(bytes: number): string { } export function registerAsset(program: Command): void { - const asset = program - .command('asset') - .description('Store files on the Ellipsis platform and share them as org-gated links') + const asset = alsoKnownAs( + program + .command('asset') + .description('Store files on the platform and share them as org-gated links'), + 'assets', + ) - asset - .command('upload ') - .description( - 'Upload a PNG and print its org-gated URL — paste it into a PR comment (POST /v1/assets)', - ) + apiRoutes( + asset + .command('upload ') + .description('Upload a PNG and print its org-gated URL, ready to paste into a PR comment'), + 'POST /v1/assets', + ) .option('--json', 'output raw JSON') .action(async (path: string, opts: { json?: boolean }) => { await runAction(async () => { @@ -96,12 +101,15 @@ export function registerAsset(program: Command): void { }) }) - asset - .command('list') - .alias('ls') - .description("List the customer's stored assets, newest first (GET /v1/assets)") + apiRoutes( + alsoKnownAs( + asset.command('list').description('List your stored assets, newest first'), + 'ls', + ), + 'GET /v1/assets', + ) .option('--session ', 'only assets uploaded by this agent session') - .option('--limit ', 'max results (server cap: 250)', parsePositiveInt) + .option('-l, --limit ', 'max results (server cap: 250)', parsePositiveInt) .option('--json', 'output raw JSON') .action(async (opts: { session?: string; limit?: number; json?: boolean }) => { await runAction(async () => { @@ -130,11 +138,13 @@ export function registerAsset(program: Command): void { }) }) - asset - .command('get ') - .description( - 'Show one asset; -o downloads the bytes to a file (GET /v1/assets/{id} + presigned S3 GET)', - ) + apiRoutes( + asset + .command('get ') + .description("Print one asset's metadata, or download its bytes with -o"), + 'GET /v1/assets/{id}', + 'presigned S3 GET', + ) .option('-o, --output ', 'write the file contents to this path') .option('--json', 'output raw JSON (includes the short-lived download_url)') .action(async (assetId: string, opts: { output?: string; json?: boolean }) => { @@ -153,10 +163,15 @@ export function registerAsset(program: Command): void { }) }) - asset - .command('delete ') - .alias('rm') - .description('Delete an asset — it disappears from list/get and its link stops resolving (DELETE /v1/assets/{id})') + apiRoutes( + alsoKnownAs( + asset + .command('delete ') + .description('Delete an asset, so its link stops resolving'), + 'rm', + ), + 'DELETE /v1/assets/{id}', + ) .option('--json', 'output raw JSON') .action(async (assetId: string, opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/config.ts b/src/commands/config.ts index 8839d31..eafc731 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -1,8 +1,9 @@ -import { InvalidArgumentError, type Command } from 'commander' +import { type Command } from 'commander' 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 { alsoKnownAs, apiRoutes } from '../lib/help' import { repoFromCwd } from '../lib/laptop' import { formatTs, printJson, printTable, printYaml, runAction } from '../lib/output' import { configUrl } from '../lib/urls' @@ -16,13 +17,20 @@ import type { const DEFAULT_CONFIG_PATH = 'agents/my_agent.yaml' export function registerConfig(program: Command): void { - const config = program - .command('config') - .description('Inspect saved agent configurations and manage defaults') + const config = alsoKnownAs( + program + .command('config') + .description('Inspect your agent configs and set which one runs by default'), + 'configs', + ) - config - .command('list') - .description('List saved agent configurations (GET /v1/configs)') + apiRoutes( + alsoKnownAs( + config.command('list').description('List your saved agent configs'), + 'ls', + ), + 'GET /v1/configs', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -47,15 +55,18 @@ export function registerConfig(program: Command): void { }) }) - config - .command('get ') - .description('Get a single agent configuration (GET /v1/configs/{id})') - .option('-o, --output ', 'output format: yaml (default) or json', parseFormat, 'yaml') - .action(async (configId: string, opts: { output: 'yaml' | 'json' }) => { + apiRoutes( + config + .command('get ') + .description('Print one agent config as YAML, or as JSON with --json'), + 'GET /v1/configs/{id}', + ) + .option('--json', 'output raw JSON') + .action(async (configId: string, opts: { json?: boolean }) => { await runAction(async () => { const api = new ApiClient() - // -o json is the machine-readable mode: emit only the raw config. - if (opts.output === 'json') { + // --json is the machine-readable mode: emit only the raw config. + if (opts.json) { printJson(await api.getAgentConfig(configId)) return } @@ -67,15 +78,18 @@ export function registerConfig(program: Command): void { }) }) - // Create an agent the same way the dashboard does: Ellipsis opens a pull - // request adding the config YAML to the repo, and the agent goes live when - // it merges. Distinct from `config init`, which scaffolds a local file. - config - .command('create') - .description('Create an agent by opening a pull request with its config (POST /v1/configs)') + // Create an agent config the same way the dashboard does: Ellipsis opens a + // pull request adding the YAML to the repo, and the agent goes live when it + // merges. Distinct from `config init`, which scaffolds a local file. + apiRoutes( + config + .command('create') + .description('Create an agent config by opening a pull request that adds it to a repo'), + 'POST /v1/configs', + ) .requiredOption( - '--repo ', - 'repository name in your account to open the pull request against', + '-r, --repo ', + 'repository in your account to open the pull request against', ) .option('-f, --file ', 'agent config file (.yaml/.yml or .json) to add') .option( @@ -112,7 +126,7 @@ export function registerConfig(program: Command): void { printJson(created) return } - console.log(`✓ opened a pull request adding the agent (${created.path})`) + console.log(`✓ opened a pull request adding the agent config (${created.path})`) console.log(created.pull_request_url) console.log('Merge it to deploy the agent.') }) @@ -127,10 +141,15 @@ export function registerConfig(program: Command): void { // 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)') + const defaults = apiRoutes( + alsoKnownAs( + config + .command('default') + .description('Show or set which agent config runs when a session names none'), + 'defaults', + ), + 'GET /v1/defaults', + ) .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 @@ -163,10 +182,15 @@ export function registerConfig(program: Command): void { }) }) - defaults - .command('list') - .alias('ls') - .description('List all default-config rungs (GET /v1/defaults)') + apiRoutes( + alsoKnownAs( + defaults + .command('list') + .description('List every default that is set, account rung and per-repo rungs'), + 'ls', + ), + 'GET /v1/defaults', + ) .option('--json', 'output raw JSON') // The group also defines --json (for the bare view), and commander parses // parent options even when they follow the subcommand name — so read the @@ -195,13 +219,14 @@ export function registerConfig(program: Command): void { }) }) - defaults - .command('set ') - .description( - 'Set the account default agent config, or a repo default with --repo (PUT /v1/defaults)', - ) + apiRoutes( + defaults + .command('set ') + .description('Set the account default agent config, or a repo default with --repo'), + 'PUT /v1/defaults', + ) .option( - '--repo [repository]', + '-r, --repo [repository]', 'target a repo rung: "owner/name", or no value for the repo you are standing in', ) .option('--json', 'output raw JSON') @@ -223,14 +248,18 @@ export function registerConfig(program: Command): void { }, ) - defaults - .command('clear') - .alias('rm') - .description( - 'Clear the account default agent config, or a repo default with --repo (DELETE /v1/defaults)', - ) + apiRoutes( + alsoKnownAs( + defaults + .command('clear') + .description('Clear the account default agent config, or a repo default with --repo'), + 'rm', + 'delete', + ), + 'DELETE /v1/defaults', + ) .option( - '--repo [repository]', + '-r, --repo [repository]', 'target a repo rung: "owner/name", or no value for the repo you are standing in', ) .action(async (opts: { repo?: string | boolean }) => { @@ -243,20 +272,23 @@ export function registerConfig(program: Command): void { }) }) - config - .command('init [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') + apiRoutes( + config + .command('init [path]') + .description( + `Scaffold a starter agent config YAML locally (default: ${DEFAULT_CONFIG_PATH})`, + ), + 'POST /v1/configs with --template', + ) + // No `-f` short: CLI-wide, `-f` means an input file (see `config create`). + .option('--force', 'overwrite the file if it already exists') .option( - '--template ', - 'create the agent from an Ellipsis template by opening a pull request (see `agent template list`)', + '-t, --template ', + 'instead scaffold from a template, in a repo, by pull request (see `agent template list`)', ) .option( - '--repo ', - 'repository name to open the pull request against (required with --template)', + '-r, --repo ', + 'repository to open the pull request against (required with --template)', ) .option( '--path ', @@ -282,7 +314,7 @@ export function registerConfig(program: Command): void { repository: opts.repo!, path: opts.path, }) - console.log(`✓ opened a pull request adding the agent (${created.path})`) + console.log(`✓ opened a pull request adding the agent config (${created.path})`) console.log(created.pull_request_url) console.log('Merge it to deploy the agent.') }) @@ -337,7 +369,7 @@ function brokenSuffix(d: AgentDefaultView): string { // everything else has a server-side default. Roots Ellipsis syncs from: // agents/, .agents/, ellipsis/, .ellipsis/ (any depth), as .yaml/.yml. function starterConfig(name: string): string { - return `# Ellipsis agent config — commit this to your default branch; Ellipsis syncs it + return `# Ellipsis agent config. Commit this to your default branch; Ellipsis syncs it # from GitHub. Valid locations: agents/, .agents/, ellipsis/, .ellipsis/ (any depth). ellipsis: version: v1 @@ -350,7 +382,7 @@ claude: You are an Ellipsis agent. Describe the task you want it to perform here. # model: claude-opus-4-8 # optional; defaults to the account default -# Optional — uncomment and fill in as needed: +# Optional, uncomment and fill in as needed: # triggers: # - type: cron # schedule: "0 9 * * 1-5" # weekdays at 09:00 @@ -375,9 +407,3 @@ function editedBy(c: SavedAgentConfig): string { return by?.login ?? '—' } -function parseFormat(value: string): 'yaml' | 'json' { - if (value !== 'yaml' && value !== 'json') { - throw new InvalidArgumentError("output format must be 'yaml' or 'json'") - } - return value -} diff --git a/src/commands/connect.ts b/src/commands/connect.ts index 1b532f1..bd09a03 100644 --- a/src/commands/connect.ts +++ b/src/commands/connect.ts @@ -46,20 +46,20 @@ export { connectability } export function registerConnect(session: Command): void { session - .command('connect [sessionId]') - .description( - 'Connect to a cloud session: view the conversation, follow it live, and send messages', - ) + .command('connect [session-id]') + .description('Open a session: read the conversation, follow it live, send messages') .option('--no-records', 'skip replaying prior records on open') .option( '--no-input', - 'follow read-only: never open the composer, even on a keyed session (for non-interactive callers)', + 'follow read-only, never opening the composer (for non-interactive callers)', ) .addHelpText( 'after', - `\nMessage mode: render the conversation, follow it live, and send lines through -the session inbox — single-writer-safe and usable headless / inside a sandbox. -Pass --no-input to follow read-only from a script or agent (no TTY needed).`, + `\nMessages go through the session inbox, so this is single-writer-safe and works +headless or from inside the session's own sandbox, where the id is optional. +Pass --no-input to follow read-only from a script or agent (no TTY needed). + +API: GET /v1/sessions/{id}, GET /v1/sessions/{id}/records, POST /v1/sessions/{id}/messages, WS /v1/sessions/{id}/stream`, ) .action(async (sessionId: string | undefined, opts: { records: boolean; input: boolean }) => { await runAction(async () => { @@ -101,7 +101,7 @@ export async function runConnect( // --no-input forces watch-only even when the session would accept messages. const canSend = readOnly ? false : c.canSend const reason = - readOnly && c.canSend ? 'read-only (--no-input) — following without the composer' : c.reason + readOnly && c.canSend ? 'read-only (--no-input): following without the composer' : c.reason const notice = [startupNotice, reason].filter(Boolean).join(' · ') || null const url = sessionUrl(resolveAppBase(), me.customer_login, sessionId) // The config identity for the footer meta line: the caller's resolved name diff --git a/src/commands/github.ts b/src/commands/github.ts index 02ce0c0..ddcd0ca 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -1,15 +1,25 @@ import { type Command } from 'commander' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' +// Read-only browsers of a connected integration, so each resource is one bare +// plural command (`github repos`) rather than a ` list` pair: there is no +// get/create/delete to disambiguate it from. export function registerGithub(program: Command): void { const github = program .command('github') .description('Browse the connected GitHub installation') - github - .command('repos') - .description('List repositories connected to the installation (GET /v1/github/repos)') + apiRoutes( + alsoKnownAs( + github + .command('repos') + .description('List the repositories the GitHub installation can reach'), + 'repo', + ), + 'GET /v1/github/repos', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -34,9 +44,17 @@ export function registerGithub(program: Command): void { }) }) - github - .command('members') - .description('List the GitHub org roster with linked Slack identities (GET /v1/github/members)') + apiRoutes( + alsoKnownAs( + github + .command('members') + .description( + 'List the org roster: the logins --author accepts, plus linked Slack identities', + ), + 'member', + ), + 'GET /v1/github/members', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/hooks.ts b/src/commands/hooks.ts index 5fe811a..cc464b7 100644 --- a/src/commands/hooks.ts +++ b/src/commands/hooks.ts @@ -1,4 +1,5 @@ import type { Command } from 'commander' +import { alsoKnownAs } from '../lib/help' import { formatTs, printJson, printTable, runAction } from '../lib/output' import { toInt } from '../lib/args' import { @@ -18,10 +19,10 @@ import { type HookSyncStats, } from '../lib/laptop' -// `agent hooks …` — manage the Claude Code hooks + per-repo enrollment that +// `agent hook …` manages the Claude Code hooks and per-repo enrollment that // drive laptop transcript sync (`agent session sync`). Installing the hooks // alone syncs nothing: consent is per-repo opt-in, so a repo must also be -// enrolled (`agent hooks enroll`, run inside the repo) before its sessions +// enrolled (`agent hook enroll`, run inside the repo) before its sessions // are captured. // Resolve the repo to enroll/unenroll: an explicit "owner/name" arg wins, @@ -42,14 +43,17 @@ function resolveRepo(explicit: string | undefined): string { return repo } -export function registerHooks(program: Command): void { - const hooks = program - .command('hooks') - .description('Manage Claude Code hooks + repo enrollment for transcript sync') +export function registerHook(program: Command): void { + const hooks = alsoKnownAs( + program + .command('hook') + .description('Sync local Claude Code transcripts to Ellipsis, per enrolled repo'), + 'hooks', + ) hooks .command('install') - .description('Install the Stop + SessionEnd hooks that run `agent session sync`') + .description('Install the Stop and SessionEnd hooks that run `agent session sync`') .action(async () => runAction(async () => { const { path } = installHooks() @@ -57,7 +61,7 @@ export function registerHooks(program: Command): void { const enrolled = enrolledRepos() if (enrolled.length === 0) { console.log( - 'No repositories enrolled yet — nothing will sync. Run `agent hooks enroll` inside a repo to opt it in.', + 'No repositories enrolled yet, so nothing will sync. Run `agent hook enroll` inside a repo to opt it in.', ) } }), @@ -65,7 +69,7 @@ export function registerHooks(program: Command): void { hooks .command('uninstall') - .description('Remove the `agent session sync` hooks (enrollment is kept)') + .description('Remove the sync hooks, keeping every repo enrollment') .action(async () => runAction(async () => { const { path, changed } = uninstallHooks() @@ -77,8 +81,8 @@ export function registerHooks(program: Command): void { hooks .command('status') - .description('Show hook installation + enrolled repositories') - .option('--json', 'print JSON instead of text') + .description('Show which hooks are installed and which repos are enrolled') + .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => runAction(async () => { const installed = hooksInstalled() @@ -103,20 +107,23 @@ export function registerHooks(program: Command): void { if (stats?.last_sync_at) { console.log('') console.log( - `Last sync ${ago(stats.last_sync_at)} (${stats.last_outcome}) · ` + - `${stats.synced_24h} synced / ${stats.failed_24h} failed in 24h · ` + + `Last sync ${ago(stats.last_sync_at)} (${stats.last_outcome}), ` + + `${stats.synced_24h} synced / ${stats.failed_24h} failed in 24h, ` + `${spooledPendingCount()} spooled pending`, ) } }), ) - hooks - .command('logs') - .description('Show the activity log the background `agent session sync` hooks write') + alsoKnownAs( + hooks + .command('log') + .description('Show what the background syncs did, newest last (they are otherwise silent)'), + 'logs', + ) .option('-n, --tail ', 'show the last N entries', toInt, 20) .option('--failures', 'only show entries whose outcome is not "synced"') - .option('--json', 'print NDJSON (one log entry per line)') + .option('--json', 'output raw JSON (NDJSON, one entry per line)') .action(async (opts: { tail: number; failures?: boolean; json?: boolean }) => runAction(async () => { let entries = readSyncLog() @@ -151,8 +158,8 @@ export function registerHooks(program: Command): void { hooks .command('stats') - .description('Show sync stats from the plain-JSON stats object the hooks maintain') - .option('--json', 'print the raw stats object') + .description('Show sync counts: last outcome, 24h synced/failed, spooled pending') + .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => runAction(async () => { const stats = readHookStats() @@ -190,7 +197,7 @@ export function registerHooks(program: Command): void { hooks .command('enroll [repo]') - .description('Opt a repository (default: the cwd\'s origin) into transcript sync') + .description("Opt a repository into transcript sync (default: the cwd's origin)") .action(async (repo: string | undefined) => runAction(async () => { const resolved = resolveRepo(repo) @@ -198,7 +205,7 @@ export function registerHooks(program: Command): void { console.log(`Enrolled ${resolved}. Claude Code sessions in this repo will sync.`) const installed = hooksInstalled() if (!installed.Stop || !installed.SessionEnd) { - console.log('Hooks are not installed — run `agent hooks install` to start syncing.') + console.log('Hooks are not installed. Run `agent hook install` to start syncing.') } }), ) diff --git a/src/commands/host.ts b/src/commands/host.ts index 0b4438c..a859e97 100644 --- a/src/commands/host.ts +++ b/src/commands/host.ts @@ -10,6 +10,7 @@ import { updateHost, useHost, } from '../lib/config' +import { alsoKnownAs } from '../lib/help' import { printTable } from '../lib/output' // `agent host …` manages the Ellipsis instances the CLI can target — prod, @@ -19,14 +20,17 @@ import { printTable } from '../lib/output' // resolves against the active host (unless ELLIPSIS_API_BASE_URL / // ELLIPSIS_API_TOKEN override it, e.g. inside a sandbox). export function registerHost(program: Command): void { - const host = program - .command('host') - .description('Manage the Ellipsis instances the CLI targets (prod / beta / self-hosted)') + const host = alsoKnownAs( + program + .command('host') + .description('Manage which Ellipsis instance the CLI targets (prod, beta, self-hosted)'), + 'hosts', + ) - host - .command('list', { isDefault: false }) - .alias('ls') - .description('List configured hosts (the active one is marked *)') + alsoKnownAs( + host.command('list').description('List the configured hosts (the active one is marked *)'), + 'ls', + ) .action(() => { const hosts = listHosts() if (hosts.length === 0) { @@ -47,7 +51,7 @@ export function registerHost(program: Command): void { host .command('add ') - .description('Add a host and switch to it (then `agent login` to authenticate)') + .description('Add a host and switch to it, then run `agent login` to authenticate') .option( '--app-base ', 'dashboard URL for building links / login (default: derived from the API URL)', @@ -86,23 +90,23 @@ export function registerHost(program: Command): void { }, ) - host - .command('delete ') - .alias('rm') - .description('Remove a host and its stored token') + alsoKnownAs( + host.command('delete ').description('Remove a host and its stored token'), + 'rm', + ) .action((name: string) => { const wasActive = activeHostName() === name deleteHost(name) console.log(`✓ removed host "${name}"`) if (wasActive) { - console.log('That was the active host — set a new one with `agent host use `.') + console.log('That was the active host. Set a new one with `agent host use `.') } }) - host - .command('current') - .alias('show') - .description('Show the active host and how it resolves') + alsoKnownAs( + host.command('current').description('Show the active host and how it resolves'), + 'show', + ) .action(() => { const name = activeHostName() const envApi = process.env.ELLIPSIS_API_BASE_URL || process.env.ELLIPSIS_API_BASE diff --git a/src/commands/integrations.ts b/src/commands/integrations.ts index 14cbc89..a7d2b0a 100644 --- a/src/commands/integrations.ts +++ b/src/commands/integrations.ts @@ -1,12 +1,19 @@ import { type Command } from 'commander' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' import type { GetIntegrationsResponse } from '../lib/types' -export function registerIntegrations(program: Command): void { - program - .command('integrations') - .description('Show every connected integration (GET /v1/integrations)') +export function registerIntegration(program: Command): void { + apiRoutes( + alsoKnownAs( + program + .command('integration') + .description('Show which integrations are connected, in one table'), + 'integrations', + ), + 'GET /v1/integrations', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -17,7 +24,7 @@ export function registerIntegrations(program: Command): void { } printTable(['INTEGRATION', 'STATUS', 'DETAILS'], integrationRows(integrations)) console.log( - '\nList resources: agent github repos | agent slack channels | agent linear teams | agent sentry orgs', + '\nList resources: agent github repos, agent slack channels, agent linear teams, agent sentry orgs', ) }) }) diff --git a/src/commands/linear.ts b/src/commands/linear.ts index 2aa74ba..d22f6aa 100644 --- a/src/commands/linear.ts +++ b/src/commands/linear.ts @@ -1,5 +1,6 @@ import { type Command } from 'commander' import { ApiClient, requireConnected } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' export function registerLinear(program: Command): void { @@ -7,9 +8,15 @@ export function registerLinear(program: Command): void { .command('linear') .description('Browse the connected Linear organization') - linear - .command('teams') - .description('List teams in the connected Linear organization (GET /v1/linear/teams)') + apiRoutes( + alsoKnownAs( + linear + .command('teams') + .description('List the Linear teams, marking which have Ellipsis enabled'), + 'team', + ), + 'GET /v1/linear/teams', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/login.ts b/src/commands/login.ts index e50a0b0..a5b9819 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -12,8 +12,8 @@ import { cliAuthUrl } from '../lib/urls' export function registerLogin(program: Command): void { program .command('login') - .description('Authenticate with Ellipsis via the device-code flow (against the active host)') - .option('--no-browser', 'do not auto-open the verification URL (for headless / SSH)') + .description('Authenticate against the active host via the device-code flow') + .option('--no-browser', 'do not auto-open the verification URL (for headless or SSH)') .action(async (opts: { browser?: boolean }) => { const api = new ApiClient() try { @@ -44,7 +44,7 @@ export function registerLogin(program: Command): void { program .command('logout') - .description('Remove stored credentials (the active host, or --all hosts)') + .description("Remove the active host's stored token, or every host's with --all") .option('--all', 'clear the stored token for every host, not just the active one') .action((opts: { all?: boolean }) => { // Clear only the on-disk token(s); the host entries (api/app base) stay so diff --git a/src/commands/me.ts b/src/commands/me.ts index 387e52b..3768b0e 100644 --- a/src/commands/me.ts +++ b/src/commands/me.ts @@ -1,6 +1,7 @@ import type { Command } from 'commander' import { ApiClient } from '../lib/api' import { requireToken } from '../lib/config' +import { apiRoutes } from '../lib/help' import { printJson, runAction } from '../lib/output' import type { WhoAmI } from '../lib/types' @@ -15,9 +16,10 @@ export function renderMe(me: WhoAmI): void { } export function registerMe(program: Command): void { - program - .command('me') - .description('Show the identity behind the current credential (GET /v1/me)') + apiRoutes( + program.command('me').description('Show the identity behind the current credential'), + 'GET /v1/me', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/model.ts b/src/commands/model.ts index df52ca4..3cd037c 100644 --- a/src/commands/model.ts +++ b/src/commands/model.ts @@ -1,19 +1,25 @@ import { type Command } from 'commander' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' export function registerModel(program: Command): void { - const model = program - .command('model') - // Resource sub-groups register their plural as an alias so the two - // spellings can never diverge into different surfaces. - .alias('models') - .description('Browse the models your agent can run on') + const model = alsoKnownAs( + program.command('model').description('Browse the models an agent can run on'), + 'models', + ) - model - .command('list') - .alias('ls') - .description('List selectable agent models (GET /v1/models)') + apiRoutes( + alsoKnownAs( + model + .command('list') + .description( + 'List the models an agent config can select (the account default is marked)', + ), + 'ls', + ), + 'GET /v1/models', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/ping.ts b/src/commands/ping.ts index 695ca04..42a7948 100644 --- a/src/commands/ping.ts +++ b/src/commands/ping.ts @@ -1,10 +1,14 @@ import type { Command } from 'commander' import { ApiClient, ApiError } from '../lib/api' +import { apiRoutes } from '../lib/help' export function registerPing(program: Command): void { - program - .command('ping') - .description('Check authenticated connectivity to the Ellipsis /v1 API') + apiRoutes( + program + .command('ping') + .description('Check that the API is reachable and the credential is valid'), + 'GET /v1/me', + ) .action(async () => { // There's no unauthenticated health route on the public API, so we probe // the lightest authenticated endpoint (/v1/me): a 200 proves the API is @@ -12,7 +16,7 @@ export function registerPing(program: Command): void { const api = new ApiClient() try { const me = await api.whoami() - console.log(`ok — ${me.customer_login} (${me.customer_id})`) + console.log(`ok: ${me.customer_login} (${me.customer_id})`) } catch (err) { if (err instanceof ApiError && err.status === 401) { // Reachable, just not authenticated — point the user at login. diff --git a/src/commands/sandbox.ts b/src/commands/sandbox.ts index addcf97..126f5d4 100644 --- a/src/commands/sandbox.ts +++ b/src/commands/sandbox.ts @@ -1,24 +1,31 @@ import { type Command } from 'commander' import { readFileSync } from 'node:fs' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { formatTs, printJson, printTable, runAction } from '../lib/output' import type { SandboxVariableInput, SandboxVariableSummary } from '../lib/types' export function registerSandbox(program: Command): void { - const sandbox = program.command('sandbox').description('Manage sandbox resources') + const sandbox = program + .command('sandbox') + .description('Manage what every sandbox this account runs gets') - const variable = sandbox - .command('variable') - // Resource sub-groups register their plural as an alias so the two - // spellings can never diverge into different surfaces. - .alias('variables') - .alias('var') - .description('Manage sandbox environment variables (values are write-only)') + const variable = alsoKnownAs( + sandbox + .command('variable') + .description('Manage the env variables injected into every sandbox (values are write-only)'), + 'variables', + 'var', + 'env', + ) - variable - .command('list') - .alias('ls') - .description('List sandbox environment variables (GET /v1/sandboxes/variables)') + apiRoutes( + alsoKnownAs( + variable.command('list').description('List the variable names (never their values)'), + 'ls', + ), + 'GET /v1/sandboxes/variables', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -27,9 +34,12 @@ export function registerSandbox(program: Command): void { }) }) - variable - .command('set [assignments...]') - .description('Create or update variables, e.g. `set A=1 B=2` (PUT /v1/sandboxes/variables)') + apiRoutes( + variable + .command('set [assignments...]') + .description('Create or update variables, e.g. `set A=1 B=2`'), + 'PUT /v1/sandboxes/variables', + ) .option('-f, --from-file ', 'load variables from a .env or .json file') .option('--json', 'output raw JSON') .action(async (assignments: string[], opts: { fromFile?: string; json?: boolean }) => { @@ -44,10 +54,10 @@ export function registerSandbox(program: Command): void { }) }) - variable - .command('rm ') - .alias('delete') - .description('Delete a variable (DELETE /v1/sandboxes/variables/{name})') + apiRoutes( + alsoKnownAs(variable.command('delete ').description('Delete a variable'), 'rm'), + 'DELETE /v1/sandboxes/variables/{name}', + ) .option('--json', 'output raw JSON') .action(async (name: string, opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/sentry.ts b/src/commands/sentry.ts index b14dd7f..7a54f9f 100644 --- a/src/commands/sentry.ts +++ b/src/commands/sentry.ts @@ -1,5 +1,6 @@ import { type Command } from 'commander' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' export function registerSentry(program: Command): void { @@ -7,9 +8,14 @@ export function registerSentry(program: Command): void { .command('sentry') .description('Browse the connected Sentry organizations') - sentry - .command('orgs') - .description('List connected Sentry organizations (GET /v1/sentry/organizations)') + apiRoutes( + alsoKnownAs( + sentry.command('orgs').description('List the connected Sentry organizations'), + 'org', + 'organizations', + ), + 'GET /v1/sentry/organizations', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/session.tsx b/src/commands/session.tsx index 75b7590..d1d603d 100644 --- a/src/commands/session.tsx +++ b/src/commands/session.tsx @@ -23,6 +23,7 @@ import { toInt, toNumber, } from '../lib/args' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { sessionUrl } from '../lib/urls' import { sessionStatusWord, @@ -80,22 +81,27 @@ const TERMINAL_STATUSES: ReadonlySet = new Set', - 'start from a saved agent config id (default: the platform default config)', + '-c, --config ', + 'start from a saved agent config (default: the resolved default config)', ) .option( '-f, --config-file ', @@ -106,18 +112,21 @@ export function registerSession(program: Command): void { 'start from a maintained session template (e.g. welcome-to-ellipsis)', ) .option( - '-o, --config-override ', + '--config-override ', 'partial agent config (YAML/JSON) merged onto the chosen config for this session, e.g. "budget:\\n session: 5"', ) .option( '--config-override-file ', 'read the partial config override from a file (.yaml/.yml or .json) instead of inline', ) - .option('--model ', 'set claude.model for this session (e.g. claude-opus-4-8)') - .option('--system ', 'set claude.system (the agent system prompt) for this session') .option( - '--repo ', - 'check out a repository in the sandbox (repeatable; "name" defaults owner to your account)', + '--model ', + 'override claude.model for this session (see `agent model list`)', + ) + .option('--system ', 'override claude.system, the agent system prompt') + .option( + '-r, --repo ', + 'check out a repository in the sandbox (repeatable; a bare name means your account)', collect, [] as string[], ) @@ -128,7 +137,7 @@ export function registerSession(program: Command): void { '--rebuild', 'skip the sandbox image cache: fresh full build (image layers, clones, image.setup), whose snapshot refreshes the cache', ) - .option('--budget ', 'spend limit in USD for this session (budget.session)', toNumber) + .option('--budget ', 'spend limit in USD for this session', toNumber) .option( '-p, --prompt ', "the session prompt, appended to the agent's initial user query (or pass it positionally)", @@ -139,18 +148,18 @@ export function registerSession(program: Command): void { collectKeyValue, {} as Record, ) - .option('-d, --detach', 'start and return immediately (fire-and-forget; the default)') + .option('-d, --detach', 'start and return immediately, the default') .option( '-w, --watch', 'block until the session reaches a terminal status, streaming live output', ) .option( '--quiet', - 'with --watch, wait without streaming — print only the final result and exit with a matching code', + 'with --watch, wait without streaming: print only the final result and exit with a matching code', ) .option( '--connect', - 'after starting, wait for the sandbox and connect: view the conversation, follow it live, and send messages', + 'after starting, open the conversation: follow it live and send messages', ) .option('--json', 'output raw JSON') .action( @@ -304,18 +313,32 @@ export function registerSession(program: Command): void { }, ) - session - .command('list') - .description('List recent agent sessions (GET /v1/sessions)') - .option('-c, --config ', 'filter by config id') - .option('-s, --source ', 'filter by source (repeatable)', collect, [] as string[]) + apiRoutes( + alsoKnownAs( + session.command('list').description('List recent sessions, newest first'), + 'ls', + ).addHelpText( + 'after', + '\nSources: laptop, react, manual, api, cli, mention, cron. ' + + '--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', + ), + 'GET /v1/sessions', + 'GET /v1/github/members to resolve --author', + ) + .option('-c, --config ', 'only sessions run by this saved agent config') + .option( + '-s, --source ', + 'only sessions from this source (repeatable)', + collectSource, + [] as string[], + ) .option( '-a, --author ', - 'only sessions attributed to this developer (a GitHub login, see `agent github members`)', + 'only sessions attributed to this GitHub login (see `agent github members`)', ) - .option('-d, --days ', 'look back N days', toInt) - .option('--start ', 'start of the time window (ISO 8601)') - .option('--end ', 'end of the time window (ISO 8601)') + .option('--days ', 'look back N days', toInt) + .option('--since ', 'only sessions at or after this time', (v: string) => parseWhen(v)) + .option('--until ', 'only sessions at or before this time', (v: string) => parseWhen(v)) .option('-l, --limit ', 'max sessions to return', toInt, 50) .option('--json', 'output raw JSON') .action( @@ -324,8 +347,8 @@ export function registerSession(program: Command): void { source: string[] author?: string days?: number - start?: string - end?: string + since?: string + until?: string limit: number json?: boolean }) => { @@ -336,8 +359,8 @@ export function registerSession(program: Command): void { source: opts.source.length ? (opts.source as AgentSessionSource[]) : undefined, author_id: opts.author ? await resolveAuthorId(api, opts.author) : undefined, days: opts.days, - start: opts.start, - end: opts.end, + start: opts.since, + end: opts.until, limit: opts.limit, }) if (opts.json) { @@ -353,7 +376,7 @@ export function registerSession(program: Command): void { sessions.map((s) => [ s.id, s.status, - s.source ?? '—', + s.source ?? '-', formatTs(s.created_at), usdFromMillicents( s.cost_tokens + s.cost_sandbox_cpu + s.cost_sandbox_memory + s.cost_fee, @@ -364,28 +387,50 @@ export function registerSession(program: Command): void { }, ) - session - .command('search ') - .description( - 'Search sessions by step text, recap text, created PR, or similarity (GET /v1/sessions/search)', + apiRoutes( + session + .command('search ') + .description('Search session history by transcript text, recap, created PR, or similarity') + .addHelpText( + 'after', + '\nA PR-shaped query ("#512", "acme/api#512", or a pull request URL) also finds the ' + + 'session that created that exact pull request.\n' + + 'Sources: laptop, react, manual, api, cli, mention, cron. ' + + '--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', + ), + 'GET /v1/sessions/search', + 'GET /v1/github/members to resolve --author', + ) + .option( + '-a, --author ', + 'only sessions attributed to this GitHub login (see `agent github members`)', ) - .addHelpText( - 'after', - '\nA PR-shaped query ("#512", "acme/api#512", or a pull request URL) also finds the ' + - 'session that created that exact pull request.\n' + - 'Sources: laptop, react, manual, api, cli, mention, cron. ' + - '--since/--until accept ISO 8601 or "today", "yesterday", "N days ago".', + .option( + '-c, --config ', + 'only sessions run by this saved agent config (repeatable)', + collect, + [] as string[], ) .option( - '-a, --author ', - 'only sessions attributed to this developer (a GitHub login, see `agent github members`)', + '-s, --source ', + 'only sessions from this source (repeatable)', + collectSource, + [] as string[], + ) + .option('-r, --repo ', 'only sessions on this repository (a bare name works too)') + .option( + '--status ', + 'only sessions in this status (repeatable)', + collectStatus, + [] as string[], ) - .option('-c, --config ', 'only sessions run by this saved config (repeatable)', collect, [] as string[]) - .option('-s, --source ', 'filter by source (repeatable)', collectSource, [] as string[]) - .option('-r, --repo ', 'only sessions on this repository ("owner/name" or a bare name)') - .option('--status ', 'filter by session status (repeatable)', collectStatus, [] as string[]) .option('--scope ', 'what to search: records, recaps, or both', parseScope, 'both') - .option('--session ', 'restrict the search to this session (repeatable)', collect, [] as string[]) + .option( + '--session ', + 'restrict the search to this session (repeatable)', + collect, + [] as string[], + ) .option('--since ', 'only sessions at or after this time', (v: string) => parseWhen(v)) .option('--until ', 'only sessions at or before this time', (v: string) => parseWhen(v)) .option('-l, --limit ', 'max result sessions (up to 100)', toInt, 20) @@ -437,15 +482,21 @@ export function registerSession(program: Command): void { } } console.log( - '\nInspect one: agent session get ; full log: agent session log ', + '\nInspect one: agent session get . Full log: agent session log ', ) }) }, ) - session - .command('records ') - .description("Read a session's records (GET /v1/sessions/{id}/records)") + apiRoutes( + alsoKnownAs( + session + .command('record ') + .description("Print a session's stored transcript, one line per record"), + 'records', + ), + 'GET /v1/sessions/{id}/records', + ) .option('--json', 'output raw JSON (full record payloads)') .action(async (sessionId: string, opts: { json?: boolean }) => { await runAction(async () => { @@ -465,12 +516,18 @@ export function registerSession(program: Command): void { }) }) - session - .command('log ') - .description("Download a session's complete log (GET /v1/sessions/{id}/log)") + apiRoutes( + alsoKnownAs( + session + .command('log ') + .description("Download a session's complete archived log to stdout or a file"), + 'logs', + ), + 'GET /v1/sessions/{id}/log', + ) .option('-o, --output ', 'write to a file instead of stdout') .option('--gzip', 'keep the concatenated .jsonl.gz bytes as-is (skip gunzip)') - .option('--json', 'print the log manifest (incl. segment download URLs), download nothing') + .option('--json', 'output raw JSON (the manifest with segment URLs); downloads nothing') .action( async ( sessionId: string, @@ -508,22 +565,26 @@ export function registerSession(program: Command): void { if (!manifest.caught_up) { console.error( `note: the archive trails the live feed (archived through ` + - `${manifest.archived_through_feed_seq} of ${manifest.latest_feed_seq}); ` + - 're-run shortly for the complete log', + `${manifest.archived_through_feed_seq} of ${manifest.latest_feed_seq}). ` + + 'Re-run shortly for the complete log.', ) } }) }, ) - session - .command('get ') - .description('Get a single agent session (GET /v1/sessions/{id})') + apiRoutes( + session + .command('get ') + .description("Show one session's status, cost, and dashboard link"), + 'GET /v1/sessions/{id}', + 'WS /v1/sessions/{id}/stream with --watch', + ) .option( '-w, --watch', 'block until the session reaches a terminal status, streaming live output', ) - .option('--quiet', 'with --watch, wait without streaming — print only the final result') + .option('--quiet', 'with --watch, wait without streaming: print only the final result') .option('--json', 'output raw JSON') .action( async (sessionId: string, opts: { watch?: boolean; quiet?: boolean; json?: boolean }) => { @@ -552,15 +613,19 @@ export function registerSession(program: Command): void { }) }) - session - .command('replay ') - .description("Re-run an existing session's trigger input (POST /v1/sessions/{id}/replay)") + apiRoutes( + session + .command('replay ') + .description("Re-run an existing session's trigger input as a fresh session"), + 'POST /v1/sessions/{id}/replay', + 'WS /v1/sessions/{id}/stream with --watch', + ) .option( - '-c, --config ', - "run against a different saved config instead of the original session's snapshot", + '-c, --config ', + "run against a different saved agent config instead of the original session's snapshot", ) .option( - '-o, --config-override ', + '--config-override ', 'partial agent config (YAML/JSON) merged onto the config for this replay, e.g. "claude:\\n model: claude-opus-4-8"', ) .option( @@ -575,7 +640,7 @@ export function registerSession(program: Command): void { '-w, --watch', 'block until the session reaches a terminal status, streaming live output', ) - .option('--quiet', 'with --watch, wait without streaming — print only the final result') + .option('--quiet', 'with --watch, wait without streaming: print only the final result') .option('--json', 'output raw JSON') .action( async ( @@ -632,11 +697,14 @@ export function registerSession(program: Command): void { // refs/ellipsis/handoff/, and start a fresh cloud session on the // built-in handoff config with that prompt as its query — never a // literal `claude --resume` of the local session. - session - .command('handoff ') - .description('Hand the current repo + a synced session off to a cloud agent') + apiRoutes( + session + .command('handoff ') + .description('Hand this repo and a synced local session off to a cloud agent'), + 'POST /v1/sessions', + ) .requiredOption( - '-p, --parent ', + '-p, --parent ', 'the synced laptop session to chain from (see `agent session list --source laptop`)', ) .option('--cwd ', 'repository to hand off (default: current directory)') @@ -658,7 +726,7 @@ export function registerSession(program: Command): void { console.log( dirty ? `✓ pushed working-tree snapshot ${sha.slice(0, 12)} to ${ref}` - : `✓ working tree clean — handing off HEAD ${sha.slice(0, 12)} via ${ref}`, + : `✓ working tree clean, handing off HEAD ${sha.slice(0, 12)} via ${ref}`, ) } const api = new ApiClient() @@ -678,18 +746,21 @@ export function registerSession(program: Command): void { ) // The laptop-transcript sync (design: LOCAL_CLAUDE_CODE.md §7.1). Normally - // invoked by the Claude Code Stop/SessionEnd hooks `agent hooks install` + // invoked by the Claude Code Stop/SessionEnd hooks `agent hook install` // writes, with the hook's JSON context on stdin; the flags exist for manual // runs and testing. In hook mode every failure path is a QUIET no-op (exit // 0): consent gaps (unenrolled repo), a logged-out CLI, and network errors // must never surface into someone's Claude Code session. Network failures // spool to disk and flush on the next successful sync. - session - .command('sync') - .description('Sync a Claude Code transcript to Ellipsis (invoked by CC hooks)') + apiRoutes( + session + .command('sync') + .description('Sync a local Claude Code transcript up, as the installed hooks do'), + 'POST /v1/sessions/sync', + ) .option('--transcript ', 'transcript JSONL path (default: from hook stdin)') .option('--session-id ', 'Claude Code session id (default: from hook stdin)') - .option('--reason ', 'stop | session_end (default: from hook stdin)') + .option('--reason ', 'stop or session_end (default: from hook stdin)') .option('--cwd ', 'session working directory (default: from hook stdin)') .option('--json', 'output raw JSON') .action( @@ -706,9 +777,10 @@ export function registerSession(program: Command): void { }, ) - session - .command('stop ') - .description('Stop an in-flight session (POST /v1/sessions/{id}/stop)') + apiRoutes( + session.command('stop ').description('Stop an in-flight session'), + 'POST /v1/sessions/{id}/stop', + ) .option('--json', 'output raw JSON') .action(async (sessionId: string, opts: { json?: boolean }) => { await runAction(async () => { @@ -727,16 +799,19 @@ export function registerSession(program: Command): void { // credential in it, so it is durable and safe to share with any org member. // 409s (sandbox idle/torn down) carry curated server messages; runAction // surfaces them as-is. - session - .command('ide ') - .description("Open the session's browser IDE (GET /v1/sessions/{id}/ide)") - .addHelpText( - 'after', - "\nThe IDE shares the live sandbox's working tree with the agent. The URL is the " + - 'membership-gated dashboard page for the sandbox, so it is safe to share with ' + - 'org members; if the session is idle, send it a message to wake it first ' + - '(agent session connect).', - ) + apiRoutes( + session + .command('ide ') + .description("Open the browser IDE into the session's live sandbox") + .addHelpText( + 'after', + "\nThe IDE shares the live sandbox's working tree with the agent. The URL is the " + + 'membership-gated dashboard page for the sandbox, so it is safe to share with ' + + 'org members. If the session is idle, send it a message to wake it first ' + + '(agent session connect).', + ), + 'GET /v1/sessions/{id}/ide', + ) .option('--no-open', 'print the URL without opening a browser') .option('--json', 'output raw JSON') .action(async (sessionId: string, opts: { open: boolean; json?: boolean }) => { @@ -754,16 +829,19 @@ export function registerSession(program: Command): void { // A preview port's link (GET /v1/sessions/{id}/ports/{port}) — a dev // server the agent or the IDE user started in the sandbox, opened through // the same membership-gated dashboard page as the IDE. - session - .command('port ') - .description("Open a preview port on the session's sandbox (GET /v1/sessions/{id}/ports/{port})") - .addHelpText( - 'after', - '\nAny TCP port serves (3000, 5173, 8000, 8080 are just the usual dev-server ' + - 'picks). The URL is the membership-gated dashboard page deep-linked to the ' + - 'port — safe to share with org members; the preview renders while something ' + - 'in the sandbox listens on that port.', - ) + apiRoutes( + session + .command('port ') + .description('Open a preview of a port the sandbox is listening on') + .addHelpText( + 'after', + '\nAny TCP port serves (3000, 5173, 8000, 8080 are just the usual dev-server ' + + 'picks). The URL is the membership-gated dashboard page deep-linked to the ' + + 'port, so it is safe to share with org members. The preview renders while ' + + 'something in the sandbox listens on that port.', + ), + 'GET /v1/sessions/{id}/ports/{port}', + ) .option('--no-open', 'print the URL without opening a browser') .option('--json', 'output raw JSON') .action( @@ -924,7 +1002,7 @@ export async function watchSession( const s = await api.getAgentSession(sessionId) if (s.status !== last) { if (!json) { - const reason = s.status_reason ? ` — ${s.status_reason}` : '' + const reason = s.status_reason ? `: ${s.status_reason}` : '' console.log(`${nowClock()} ${s.status}${reason}`) } last = s.status @@ -1150,13 +1228,13 @@ export async function fetchLogSegment(segment: SessionLogSegment): Promise { @@ -1247,7 +1325,7 @@ async function syncTranscript(opts: { if (!repo || !enrolledRepos().includes(repo.toLowerCase())) { return quit( 'skipped_unenrolled', - `repository ${repo ?? `at ${cwd}`} is not enrolled (agent hooks enroll)`, + `repository ${repo ?? `at ${cwd}`} is not enrolled (agent hook enroll)`, ) } if (!resolveToken()) { diff --git a/src/commands/slack.ts b/src/commands/slack.ts index fd02e7b..8188f81 100644 --- a/src/commands/slack.ts +++ b/src/commands/slack.ts @@ -1,5 +1,6 @@ import { type Command } from 'commander' import { ApiClient, requireConnected } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' import { printJson, printTable, runAction } from '../lib/output' export function registerSlack(program: Command): void { @@ -7,9 +8,13 @@ export function registerSlack(program: Command): void { .command('slack') .description('Browse the connected Slack workspace') - slack - .command('channels') - .description('List channels in the connected Slack workspace (GET /v1/slack/channels)') + apiRoutes( + alsoKnownAs( + slack.command('channels').description('List the channels in the Slack workspace'), + 'channel', + ), + 'GET /v1/slack/channels', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -34,11 +39,15 @@ export function registerSlack(program: Command): void { }) }) - slack - .command('members') - .description( - 'List members of the Slack workspace with linked GitHub identities (GET /v1/slack/members)', - ) + apiRoutes( + alsoKnownAs( + slack + .command('members') + .description('List the workspace members, with linked GitHub identities'), + 'member', + ), + 'GET /v1/slack/members', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/template.ts b/src/commands/template.ts index 730560a..06d4705 100644 --- a/src/commands/template.ts +++ b/src/commands/template.ts @@ -1,15 +1,23 @@ import { type Command } from 'commander' import { ApiClient } from '../lib/api' +import { alsoKnownAs, apiRoutes } from '../lib/help' 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') + const template = alsoKnownAs( + program.command('template').description('Browse the built-in agent templates'), + 'templates', + ) - template - .command('list') - .description('List built-in agent templates (GET /v1/templates)') + apiRoutes( + alsoKnownAs( + template + .command('list') + .description('List the built-in agent templates and the slugs --template takes'), + 'ls', + ), + 'GET /v1/templates', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 8f100ee..28a7153 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -1,11 +1,13 @@ import type { Command } from 'commander' import { ApiClient } from '../lib/api' +import { apiRoutes } from '../lib/help' import { printJson, runAction, usd, usdFromMillicents } from '../lib/output' export function registerUsage(program: Command): void { - program - .command('budget') - .description('Show the current budget summary (GET /v1/budget)') + apiRoutes( + program.command('budget').description("Show this period's spend against the account budget"), + 'GET /v1/budget', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { @@ -22,9 +24,12 @@ export function registerUsage(program: Command): void { }) }) - program - .command('usage') - .description('Show the usage dashboard for the current period (GET /v1/usage)') + apiRoutes( + program + .command('usage') + .description("Show this period's tokens and cost, broken down by model"), + 'GET /v1/usage', + ) .option('--json', 'output raw JSON') .action(async (opts: { json?: boolean }) => { await runAction(async () => { diff --git a/src/lib/help.ts b/src/lib/help.ts new file mode 100644 index 0000000..7a69b71 --- /dev/null +++ b/src/lib/help.ts @@ -0,0 +1,105 @@ +import { Help, type Command } from 'commander' + +// Help rendering rules for the whole CLI. The audience is a coding agent +// reading `--help` to decide its next call, so the surface it sees must be one +// spelling per concept: singular nouns, no alias clutter, no transport detail +// in the one-line description. + +// The one shown spelling of every command is its singular name; plurals and +// short forms stay callable as aliases but are never rendered (see +// `alsoKnownAs`). Overriding these two Help methods is what makes an alias +// invisible: commander otherwise prints `name|alias` in both places. +function withoutAliases(term: string, cmd: Command): string { + const alias = cmd.aliases()[0] + return alias ? term.replace(`${cmd.name()}|${alias}`, cmd.name()) : term +} + +// Top-level commands, grouped by what the caller is trying to do. A command +// missing from every group still renders (under "Other") rather than silently +// vanishing from help. +const TOP_LEVEL_GROUPS: ReadonlyArray<{ title: string; commands: readonly string[] }> = [ + { title: 'Sessions', commands: ['session'] }, + { title: 'Agents', commands: ['config', 'model', 'template'] }, + { title: 'Platform', commands: ['sandbox', 'asset', 'hook'] }, + { title: 'Integrations', commands: ['integration', 'github', 'slack', 'linear', 'sentry'] }, + { title: 'Spend', commands: ['budget', 'usage', 'analytics'] }, + { title: 'Account', commands: ['login', 'logout', 'me', 'host', 'ping'] }, +] + +export function configureCliHelp(program: Command): void { + program.configureHelp({ + sortSubcommands: true, + subcommandTerm(cmd: Command) { + return withoutAliases(Help.prototype.subcommandTerm.call(this, cmd), cmd) + }, + commandUsage(cmd: Command) { + return withoutAliases(Help.prototype.commandUsage.call(this, cmd), cmd) + }, + formatHelp(cmd: Command, helper: Help) { + if (cmd.parent) return Help.prototype.formatHelp.call(helper, cmd, helper) + return formatTopLevelHelp(cmd, helper) + }, + }) +} + +// `agent --help`: same layout commander produces, except the flat 20-command +// list is split into task groups so a caller can find the right group without +// reading every description. +function formatTopLevelHelp(cmd: Command, helper: Help): string { + const termWidth = helper.padWidth(cmd, helper) + const indent = 2 + const gap = 2 + // `|| 80`, not `??`: a TTY can report columns as 0, which would disable + // wrapping entirely rather than falling back to the default width. + const helpWidth = helper.helpWidth || 80 + const item = (term: string, description: string): string => + description + ? helper.wrap( + `${term.padEnd(termWidth + gap)}${description}`, + helpWidth - indent, + termWidth + gap, + ) + : term + const block = (lines: string[]): string => + lines.join('\n').replace(/^/gm, ' '.repeat(indent)) + + const out = [`Usage: ${helper.commandUsage(cmd)}`, ''] + const description = helper.commandDescription(cmd) + if (description) out.push(helper.wrap(description, helpWidth, 0), '') + + const options = helper + .visibleOptions(cmd) + .map((o) => item(helper.optionTerm(o), helper.optionDescription(o))) + if (options.length) out.push('Options:', block(options), '') + + const ungrouped = new Map(helper.visibleCommands(cmd).map((c) => [c.name(), c])) + const rowsFor = (names: readonly string[]): string[] => + names.flatMap((name) => { + const sub = ungrouped.get(name) + if (!sub) return [] + ungrouped.delete(name) + return [item(helper.subcommandTerm(sub), helper.subcommandDescription(sub))] + }) + for (const group of TOP_LEVEL_GROUPS) { + const rows = rowsFor(group.commands) + if (rows.length) out.push(`${group.title}:`, block(rows), '') + } + const rest = rowsFor([...ungrouped.keys()]) + if (rest.length) out.push('Other:', block(rest), '') + + return out.join('\n') +} + +// Extra spellings that keep working but never show in help: the plural of a +// singular command name, and the short forms people type from muscle memory. +export function alsoKnownAs(cmd: Command, ...aliases: string[]): Command { + for (const alias of aliases) cmd.alias(alias) + return cmd +} + +// The REST routes a command calls, appended to its long help. Descriptions +// stay about intent; the transport detail is one `--help` away for a caller +// that needs to reach the same data directly. +export function apiRoutes(cmd: Command, ...routes: string[]): Command { + return cmd.addHelpText('after', `\nAPI: ${routes.join(', ')}`) +} diff --git a/src/lib/laptop.ts b/src/lib/laptop.ts index db34bd7..6fe5c2c 100644 --- a/src/lib/laptop.ts +++ b/src/lib/laptop.ts @@ -1,4 +1,4 @@ -// Laptop transcript sync plumbing (`agent hooks …` + `agent session sync`) — +// Laptop transcript sync plumbing (`agent hook …` + `agent session sync`) — // the client half of documents/eng/LOCAL_CLAUDE_CODE.md §7.1 in the monorepo. // // Claude Code fires `Stop` (once per turn) and `SessionEnd` hooks whose @@ -272,9 +272,9 @@ export function spooledPendingCount(): number { // every failure path by design (async hooks' output is discarded and a broken // sync must never disturb a Claude Code session), so nothing is visible in the // session itself. Instead, every sync attempt appends one JSONL line to -// hooks/sync.log.jsonl (read by `agent hooks logs`) and atomically rewrites +// hooks/sync.log.jsonl (read by `agent hook log`) and atomically rewrites // hooks/stats.json — a plain JSON object anything can read without invoking -// the CLI (read by `agent hooks stats`). All of it is best-effort: a logging +// the CLI (read by `agent hook stats`). All of it is best-effort: a logging // failure stays silent so hook mode keeps its exit-0 guarantee. // ---------------------------------------------------------------------------