Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,17 @@ skills:
## Usage

```sh
agent login # device-code auth (use --no-browser for SSH)
agent logout # remove stored credentials
agent login # device-code auth against the active host
agent logout # remove stored credentials (--all for every host)
agent me # show the current credential's identity

agent host list # list configured hosts (the active one is marked *)
agent host add beta https://beta-api.ellipsis.dev # add a host and switch to it
agent host use prod # switch the active host
agent host current # show the active host and how it resolves
agent host set beta --rename staging # rename / re-point a host (--api-base / --app-base)
agent host delete beta # remove a host and its stored token

agent session start --config <id> # start a session from a saved config
agent session start --config-file f.json # ...or from an inline config
agent session start --template welcome-to-ellipsis # ...or from a maintained template
Expand Down Expand Up @@ -91,8 +98,9 @@ agent ping # check authenticated /v1 connectivity
```

Most commands accept `--json` to print the raw API response. The CLI talks to
the public `/v1` REST API; point it elsewhere with `ELLIPSIS_API_BASE_URL`
(or the legacy `ELLIPSIS_API_BASE`).
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
legacy `ELLIPSIS_API_BASE`).

`--watch` (on both `session start` and `session get`) streams the session's
output live over WebSocket until it reaches a terminal status, falling back to
Expand All @@ -109,12 +117,31 @@ approve the request in the dashboard. The issued user token is stored under

**Credentials resolve in this order (highest wins):** explicit argument →
environment (`ELLIPSIS_API_TOKEN` / `ELLIPSIS_API_BASE_URL`, with the legacy
`ELLIPSIS_API_BASE` accepted as a fallback) → config file → default. This lets
the CLI run headlessly — e.g. inside an Ellipsis cloud sandbox where a
per-sandbox token and base URL are injected into the environment — with no
`agent login` and no config file on disk. `agent logout` only clears the
on-disk token; a token supplied via `ELLIPSIS_API_TOKEN` lives in the
environment and keeps working until you unset it.
`ELLIPSIS_API_BASE` accepted as a fallback) → the **active host** in the config
file → default (prod). This lets the CLI run headlessly — e.g. inside an
Ellipsis cloud sandbox where a per-sandbox token and base URL are injected into
the environment — with no `agent login` and no config file on disk. `agent
logout` only clears the on-disk token (`--all` for every host); a token supplied
via `ELLIPSIS_API_TOKEN` lives in the environment and keeps working until you
unset it.

### Hosts

`agent host` selects which Ellipsis instance the CLI targets — Ellipsis Cloud,
a preview environment, or a self-hosted deployment — so you can switch without
re-exporting env vars. `agent host add <name> <api-url>` registers an instance
and makes it active; `agent host use <name>` switches; `agent host list` shows
them all (the active one marked `*`). Each host keeps its own token (so
switching doesn't re-authenticate) and its own dashboard/app URL. The app URL
is derived from the API URL by default (`api.` → `app.`); a self-hosted instance
whose dashboard host isn't a mechanical swap sets it explicitly with `agent host
add … --app-base <url>` (or `agent host set <name> --app-base <url>`). `agent
login` then authenticates the active host, and every link the CLI prints points
at that host's dashboard.

Hosts and tokens live in `~/.config/ellipsis/config.json` (mode 0600). A config
file from before hosts existed is migrated in place on first use — your existing
login becomes a host named for its API base — so nothing needs re-doing.

## Develop

Expand Down
2 changes: 2 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Command } from 'commander'
import { registerLogin } from './commands/login'
import { registerHost } from './commands/host'
import { registerMe } from './commands/me'
import { registerSession } from './commands/session'
import { registerConfig } from './commands/config'
Expand All @@ -25,6 +26,7 @@ program
.version(VERSION)

registerLogin(program)
registerHost(program)
registerMe(program)
registerSession(program)
registerConfig(program)
Expand Down
125 changes: 125 additions & 0 deletions src/commands/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { InvalidArgumentError, type Command } from 'commander'
import {
activeHostName,
addHost,
deleteHost,
deriveAppBase,
listHosts,
resolveApiBase,
resolveAppBase,
updateHost,
useHost,
} from '../lib/config'
import { printTable } from '../lib/output'

// `agent host …` manages the Ellipsis instances the CLI can target — prod,
// beta, or a self-hosted deployment — and which one is active. It does NOT
// authenticate: `agent host add` / `use` set WHERE the CLI points; `agent
// login` sets the credential for wherever it's pointing. Every other command
// 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)')

host
.command('list', { isDefault: false })
.alias('ls')
.description('List configured hosts (the active one is marked *)')
.action(() => {
const hosts = listHosts()
if (hosts.length === 0) {
console.log('No hosts configured. Add one with `agent host add <name> <api-url>`.')
return
}
printTable(
['', 'NAME', 'API', 'APP', 'LOGGED IN'],
hosts.map((h) => [
h.active ? '*' : '',
h.name,
h.host.apiBase,
h.host.appBase ?? deriveAppBase(h.host.apiBase),
h.host.token ? 'yes' : 'no',
]),
)
})

host
.command('add <name> <api-url>')
.description('Add a host and switch to it (then `agent login` to authenticate)')
.option(
'--app-base <url>',
'dashboard URL for building links / login (default: derived from the API URL)',
)
.action((name: string, apiUrl: string, opts: { appBase?: string }) => {
addHost(name, requireUrl(apiUrl, 'api-url'), opts.appBase && requireUrl(opts.appBase, '--app-base'))
console.log(`✓ added host "${name}" · now active`)
console.log('Run `agent login` to authenticate against it.')
})

host
.command('use <name>')
.description('Switch the active host')
.action((name: string) => {
useHost(name)
console.log(`✓ active host: ${name}`)
})

host
.command('set <name>')
.description('Change an existing host (API URL, app URL, or rename it)')
.option('--api-base <url>', 'change the API URL')
.option('--app-base <url>', 'change the dashboard URL')
.option('--rename <name>', 'rename the host')
.action(
(name: string, opts: { apiBase?: string; appBase?: string; rename?: string }) => {
if (!opts.apiBase && !opts.appBase && !opts.rename) {
throw new Error('nothing to change: pass --api-base, --app-base, and/or --rename')
}
updateHost(name, {
apiBase: opts.apiBase && requireUrl(opts.apiBase, '--api-base'),
appBase: opts.appBase && requireUrl(opts.appBase, '--app-base'),
rename: opts.rename,
})
console.log(`✓ updated host "${opts.rename ?? name}"`)
},
)

host
.command('delete <name>')
.alias('rm')
.description('Remove a host and its stored token')
.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 <name>`.')
}
})

host
.command('current')
.alias('show')
.description('Show the active host and how it resolves')
.action(() => {
const name = activeHostName()
const envApi = process.env.ELLIPSIS_API_BASE_URL || process.env.ELLIPSIS_API_BASE
// resolveApiBase/resolveAppBase apply the env-override precedence, so this
// reflects what commands will actually hit — not just the stored host.
console.log(`host: ${name ?? '(none — using defaults)'}`)
console.log(`api: ${resolveApiBase()}`)
console.log(`app: ${resolveAppBase()}`)
if (envApi) {
console.log('note: ELLIPSIS_API_BASE_URL is set and overrides the active host.')
}
})
}

function requireUrl(value: string, label: string): string {
if (!/^https?:\/\//i.test(value)) {
throw new InvalidArgumentError(`${label} must be an http(s) URL (got "${value}")`)
}
return value.replace(/\/+$/, '')
}
51 changes: 34 additions & 17 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,35 @@
import type { Command } from 'commander'
import { ApiClient } from '../lib/api'
import { loadConfig, saveConfig } from '../lib/config'
import {
activeHostName,
clearActiveHostToken,
clearAllTokens,
resolveAppBase,
} from '../lib/config'
import { deviceLogin, openBrowser, persistToken } from '../lib/auth'
import { cliAuthUrl } from '../lib/urls'

export function registerLogin(program: Command): void {
program
.command('login')
.description('Authenticate with Ellipsis via the device-code flow')
.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)')
.action(async (opts: { browser?: boolean }) => {
const api = new ApiClient()
try {
const { token } = await deviceLogin(api, {
onPrompt: (start) => {
// Build the approval URL from the app base of the host the CLI is
// pointed at, NOT the server's verification_uri_complete — the
// backend defaults that to prod, so a beta / self-hosted login would
// otherwise be sent to the prod dashboard (where the code can't be
// approved). See cliAuthUrl / resolveAppBase.
const verificationUrl = cliAuthUrl(resolveAppBase(), start.user_code)
console.log('To authenticate, open this URL and approve the request:')
console.log(` ${start.verification_uri_complete}`)
console.log(` ${verificationUrl}`)
console.log(`Verification code: ${start.user_code}`)
if (opts.browser !== false) {
openBrowser(start.verification_uri_complete)
openBrowser(verificationUrl)
}
console.log('Waiting for approval…')
},
Expand All @@ -32,20 +44,25 @@ export function registerLogin(program: Command): void {

program
.command('logout')
.description('Remove stored credentials')
.action(() => {
// Drop the token but keep apiBase so the next login targets the same API.
const { apiBase } = loadConfig()
saveConfig(apiBase ? { apiBase } : {})
// logout only clears the on-disk token. A token supplied via
// ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives in the environment and
// keeps working — don't claim to have cleared what we can't.
if (process.env.ELLIPSIS_API_TOKEN) {
console.log(
'Removed stored credentials. ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.',
)
.description('Remove stored credentials (the active host, or --all hosts)')
.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
// the next `agent login` targets the same instance.
if (opts.all) {
clearAllTokens()
} else {
console.log('Logged out.')
clearActiveHostToken()
}
// A token supplied via ELLIPSIS_API_TOKEN (e.g. inside a sandbox) lives in
// the environment and keeps working — don't claim to have cleared what we
// can't.
const envNote = process.env.ELLIPSIS_API_TOKEN
? ' ELLIPSIS_API_TOKEN is still set in the environment, so that session stays active until it is unset.'
: ''
const scope = opts.all
? 'all hosts'
: (activeHostName() ?? 'the active host')
console.log(`Removed stored credentials for ${scope}.${envNote}`)
})
}
10 changes: 5 additions & 5 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawn } from 'node:child_process'
import { ApiClient } from './api'
import { saveConfig, loadConfig } from './config'
import { setActiveHostToken } from './config'
import type { CliAuthStart } from './types'

export interface DeviceLoginHandlers {
Expand Down Expand Up @@ -52,11 +52,11 @@ export async function deviceLogin(
throw new Error('Timed out waiting for approval.')
}

// Persists the token alongside the apiBase it was minted against, so a token
// obtained from a local/staging backend isn't silently reused against prod.
// Persists the token on the active host (seeding one at the resolved base if
// the user logged in before adding a host), so a token minted against one
// instance is never silently reused against another.
export function persistToken(token: string): void {
const config = loadConfig()
saveConfig({ ...config, token })
setActiveHostToken(token)
}

// Best-effort browser open; never throws (headless/SSH sessions just won't have
Expand Down
Loading
Loading