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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ prints a clickable dashboard link. The stream protocol is specified in
`agent login` uses the device-code flow: it requests a code pair, prints a
verification URL (and opens it unless `--no-browser`), and polls until you
approve the request in the dashboard. The issued user token is stored under
`~/.config/ellipsis/config.json` (mode 0600) and attributes sessions to you.
`~/.ellipsis/config.json` (mode 0600) and attributes sessions to you.

**Credentials resolve in this order (highest wins):** explicit argument →
environment (`ELLIPSIS_API_TOKEN` / `ELLIPSIS_API_BASE_URL`, with the legacy
Expand All @@ -142,9 +142,11 @@ 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.
Hosts and tokens live in `~/.ellipsis/config.json` (mode 0600); set
`ELLIPSIS_CONFIG_DIR` to relocate it. A config file from before hosts existed
is migrated on first use — your existing login becomes a host named for its API
base — and a config left at the old `~/.config/ellipsis` location is still read
until the next write lands it in `~/.ellipsis`, so nothing needs re-doing.

## Develop

Expand Down
3 changes: 3 additions & 0 deletions src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ 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 })

registerLogin(program)
registerHost(program)
Expand Down
5 changes: 5 additions & 0 deletions src/commands/me.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Command } from 'commander'
import { ApiClient } from '../lib/api'
import { requireToken } from '../lib/config'
import { printJson, runAction } from '../lib/output'
import type { WhoAmI } from '../lib/types'

Expand All @@ -20,6 +21,10 @@ export function registerMe(program: Command): void {
.option('--json', 'output raw JSON')
.action(async (opts: { json?: boolean }) => {
await runAction(async () => {
// Fail fast with the login hint when no credential exists anywhere;
// without this the request would go out unauthenticated and come back
// as a 401.
requireToken()
const me = await new ApiClient().whoami()
if (opts.json) {
printJson(me)
Expand Down
32 changes: 28 additions & 4 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@ import { DEFAULT_API_BASE } from './constants'
// A 0600 file under ~/.config is fine for the skeleton, not for shipping — and
// multi-host means several tokens on disk, so this matters more now.
// Resolved lazily (not at import) so ELLIPSIS_CONFIG_DIR is honored at runtime.
function configDir(): string {
return process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis')
// ~/.ellipsis (not ~/.config/ellipsis): the CLI creates it itself directly
// under $HOME, so it can never inherit a root-owned ~/.config left behind by
// an old `sudo` install — the most common first-run EACCES on macOS.
export function configDir(): string {
return process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.ellipsis')
}

function configFile(): string {
return join(configDir(), 'config.json')
}

// Where pre-0.17 installs kept the config. Read-only fallback so the move to
// ~/.ellipsis doesn't log anyone out; the first saveConfig writes the new
// location and it wins from then on.
function legacyConfigFile(): string {
return join(homedir(), '.config', 'ellipsis', 'config.json')
}

// One Ellipsis instance the CLI can target (prod, beta, or a self-hosted
// deployment). `apiBase` is the /v1 host; `appBase` is the dashboard host used
// to build clickable links and the login verification URL — derived from
Expand Down Expand Up @@ -89,8 +99,22 @@ function migrate(raw: unknown): CliConfig {

export function loadConfig(): CliConfig {
const file = configFile()
if (!existsSync(file)) return { version: 2, hosts: {} }
return migrate(JSON.parse(readFileSync(file, 'utf8')))
if (existsSync(file)) return migrate(JSON.parse(readFileSync(file, 'utf8')))
// No config at the current path: fall back to the legacy XDG location, but
// only for the default dir — an explicit ELLIPSIS_CONFIG_DIR must resolve
// exactly (tests and sandboxes rely on that isolation).
if (!process.env.ELLIPSIS_CONFIG_DIR) {
const legacy = legacyConfigFile()
if (existsSync(legacy)) {
try {
return migrate(JSON.parse(readFileSync(legacy, 'utf8')))
} catch {
// Unreadable legacy file (often a root-owned ~/.config from an old
// sudo run — the problem ~/.ellipsis exists to avoid). Start fresh.
}
}
}
return { version: 2, hosts: {} }
}

export function saveConfig(config: CliConfig): void {
Expand Down
12 changes: 3 additions & 9 deletions src/lib/laptop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { getEnrolledRepos, setEnrolledRepos } from './config'
import { configDir, getEnrolledRepos, setEnrolledRepos } from './config'
import type { SyncAgentSessionRequest } from './types'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -221,10 +221,7 @@ export function redactLine(line: string): string {
// ---------------------------------------------------------------------------

function spoolDir(): string {
return join(
process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis'),
'spool',
)
return join(configDir(), 'spool')
}

export function spoolSync(req: SyncAgentSessionRequest): string {
Expand Down Expand Up @@ -326,10 +323,7 @@ const FAILURE_OUTCOMES: ReadonlySet<SyncOutcome> = new Set<SyncOutcome>([
])

function hooksDir(): string {
return join(
process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis'),
'hooks',
)
return join(configDir(), 'hooks')
}

export function syncLogPath(): string {
Expand Down
18 changes: 17 additions & 1 deletion src/lib/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// structured output the same way.

import { stringify as stringifyYaml } from 'yaml'
import { ApiError } from './api'
import { envToken } from './config'

export function printJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2))
Expand Down Expand Up @@ -67,13 +69,27 @@ export function usd(amount: number): string {
return `$${amount.toFixed(2)}`
}

// A 401 means the credential itself was rejected (expired, revoked, or
// malformed) regardless of which endpoint hit it, so tell the user how to get
// a new one instead of echoing the raw HTTP failure. The remedy depends on
// where the token came from: an env token outranks the config file in the
// precedence chain, so `agent login` alone can't replace it.
export function friendlyErrorMessage(err: unknown): string {
if (err instanceof ApiError && err.status === 401) {
return envToken()
? 'The server rejected ELLIPSIS_API_TOKEN. Check the token, or unset it and run `agent login`.'
: 'Your login is invalid or has expired. Run `agent login` to re-authenticate.'
}
return (err as Error).message
}

// Wraps a command body so API/network failures print a clean message and set a
// non-zero exit code instead of dumping a stack trace.
export async function runAction(fn: () => Promise<void>): Promise<void> {
try {
await fn()
} catch (err) {
console.error(`error: ${(err as Error).message}`)
console.error(`error: ${friendlyErrorMessage(err)}`)
process.exitCode = 1
}
}
59 changes: 23 additions & 36 deletions src/ui/ConnectApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,13 +421,14 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
})
.catch((err: unknown) => {
if (abort.current.signal.aborted) return
const msg =
err instanceof StreamUnavailableError
? `live stream unavailable (${err.message}) — polling for updates`
: `stream error: ${(err as Error).message}`
setNotice(msg)
// No socket: the poll below keeps the transcript current; only a
// watch-only session (which would never poll long) exits here.
// A dropped/unreachable socket is not worth announcing: the poll below
// keeps the transcript current, so the fallback is invisible. Only a
// genuine stream error gets a notice.
if (!(err instanceof StreamUnavailableError)) {
setNotice(`stream error: ${(err as Error).message}`)
}
// No socket: only a watch-only session (which would never poll long)
// exits here.
if (!canSend) exit()
})
.finally(() => {
Expand Down Expand Up @@ -638,15 +639,20 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {
}
}, [items, expanded, pendingTools])

// The persistent footer status line, kept minimal: the current status and
// the running spend (cumulative total + the last turn's cost). Session
// identity lives in the banner; command hints live in --help. The total
// prefers the server's ledger figure (live via cost_millicents frames,
// climbing mid-turn); the CC-derived result total is the fallback against
// older backends.
// The persistent footer status line: the current status, the running spend
// (cumulative total + the last turn's cost), and the session identity — the
// dashboard link rendered as the session id, the model, and the CLI version.
// Command hints live in --help. The total prefers the server's ledger figure
// (live via cost_millicents frames, climbing mid-turn); the CC-derived
// result total is the fallback against older backends.
const totalStr = `$${(serverCostUsd ?? cost.total ?? 0).toFixed(2)}`
const lastStepStr = cost.lastStep != null ? ` (Last step: $${cost.lastStep.toFixed(2)})` : ''
const metaLine = `${status} · ${totalStr} total${lastStepStr}`
const metaLine = [
`${status} · ${totalStr} total${lastStepStr}`,
hyperlink(props.sessionUrl, sessionId),
...(props.model ? [props.model] : []),
`v${VERSION}`,
].join(' · ')
// Three distinct, factual activity signals — never whimsy. All render IN the
// transcript, on the block they describe, not above the composer:
// - `infraActivity`: the sandbox is spawning/waking (scheduled/starting/
Expand Down Expand Up @@ -678,28 +684,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement {

return (
<Box flexDirection="column" minHeight={termRows - 1}>
{/* The banner: brand + version, then the session's dashboard link —
Claude Code's header, Ellipsis-flavoured. Session identity lives here
and in the footer; nothing is printed to scrollback before the app. */}
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text>
<Text color="cyan" bold>
{' ●●● '}
</Text>
<Text bold> Ellipsis</Text>
<Text dimColor> v{VERSION}</Text>
</Text>
<Text dimColor>
{' '}
{hyperlink(props.sessionUrl, props.sessionUrl)}
</Text>
{props.model && (
<Text dimColor>
{' '}
{props.model}
</Text>
)}
</Box>
{/* No banner: session identity (dashboard link, model, version) lives in
the footer meta line, so the transcript starts at the top edge and
nothing is printed to scrollback before the app. */}
{/* Sandbox spawn/wake progress, at the top where startup belongs;
re-renders in place and disappears once the session is live. */}
{infraActivity && (
Expand Down
80 changes: 80 additions & 0 deletions test/config-legacy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

// The ~/.config/ellipsis fallback only engages when ELLIPSIS_CONFIG_DIR is
// unset, so unlike config.test.ts these tests can't isolate via that env var.
// Instead homedir() is mocked into a throwaway directory per test.
let home: string
vi.mock('node:os', async (importOriginal) => {
const os = await importOriginal<typeof import('node:os')>()
return { ...os, homedir: () => home }
})

import { loadConfig, resolveToken, setActiveHostToken } from '../src/lib/config'

function writeLegacyConfig(contents: string): void {
const dir = join(home, '.config', 'ellipsis')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'config.json'), contents)
}

function newConfigFile(): string {
return join(home, '.ellipsis', 'config.json')
}

beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'ellipsis-home-'))
delete process.env.ELLIPSIS_CONFIG_DIR
delete process.env.ELLIPSIS_API_TOKEN
})

afterEach(() => {
rmSync(home, { recursive: true, force: true })
})

describe('legacy ~/.config/ellipsis fallback', () => {
it('reads a legacy config when ~/.ellipsis has none', () => {
writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' }))
expect(resolveToken()).toBe('legacy_tok')
})

it('prefers ~/.ellipsis over the legacy location once it exists', () => {
writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' }))
mkdirSync(join(home, '.ellipsis'), { recursive: true })
writeFileSync(newConfigFile(), JSON.stringify({ token: 'new_tok' }))
expect(resolveToken()).toBe('new_tok')
})

it('migrates to ~/.ellipsis on the first write, keeping legacy state', () => {
writeLegacyConfig(
JSON.stringify({ token: 'legacy_tok', enrolledRepos: ['acme/api'] }),
)
setActiveHostToken('fresh_tok')
expect(existsSync(newConfigFile())).toBe(true)
const saved = JSON.parse(readFileSync(newConfigFile(), 'utf8'))
expect(saved.hosts.prod).toMatchObject({
token: 'fresh_tok',
enrolledRepos: ['acme/api'],
})
expect(resolveToken()).toBe('fresh_tok')
})

it('starts fresh when the legacy file is unreadable garbage', () => {
writeLegacyConfig('not json{{')
expect(loadConfig()).toEqual({ version: 2, hosts: {} })
})

it('ELLIPSIS_CONFIG_DIR disables the fallback entirely', () => {
writeLegacyConfig(JSON.stringify({ token: 'legacy_tok' }))
const isolated = mkdtempSync(join(tmpdir(), 'ellipsis-cfg-'))
process.env.ELLIPSIS_CONFIG_DIR = isolated
try {
expect(resolveToken()).toBeUndefined()
} finally {
delete process.env.ELLIPSIS_CONFIG_DIR
rmSync(isolated, { recursive: true, force: true })
}
})
})
44 changes: 42 additions & 2 deletions test/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest'
import { relativeAge, usd, usdFromMillicents, usdNumberFromMillicents } from '../src/lib/output'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { ApiError } from '../src/lib/api'
import {
friendlyErrorMessage,
relativeAge,
usd,
usdFromMillicents,
usdNumberFromMillicents,
} from '../src/lib/output'

describe('usdFromMillicents', () => {
it('converts millicents to dollars (1 cent = 1000 millicents)', () => {
Expand Down Expand Up @@ -41,3 +48,36 @@ describe('usd', () => {
expect(usd(100)).toBe('$100.00')
})
})

describe('friendlyErrorMessage', () => {
beforeEach(() => {
delete process.env.ELLIPSIS_API_TOKEN
})
afterEach(() => {
delete process.env.ELLIPSIS_API_TOKEN
})

it('maps a 401 to a re-login hint instead of the raw HTTP failure', () => {
const err = new ApiError(401, 'GET', '/v1/me', 'Unauthorized', 'req_1')
expect(friendlyErrorMessage(err)).toBe(
'Your login is invalid or has expired. Run `agent login` to re-authenticate.',
)
})

it('blames ELLIPSIS_API_TOKEN when the rejected credential came from the env', () => {
process.env.ELLIPSIS_API_TOKEN = 'stale_tok'
const err = new ApiError(401, 'GET', '/v1/me', 'Unauthorized')
expect(friendlyErrorMessage(err)).toMatch(/ELLIPSIS_API_TOKEN/)
})

it('passes other ApiErrors through with the server detail intact', () => {
const err = new ApiError(409, 'POST', '/v1/sessions/s_1/messages', 'Session is closed')
expect(friendlyErrorMessage(err)).toBe(
'POST /v1/sessions/s_1/messages failed: 409 Session is closed',
)
})

it('passes plain errors through unchanged', () => {
expect(friendlyErrorMessage(new Error('boom'))).toBe('boom')
})
})