From e4c080b0b3d0ee39530180cff55f3d1d92606450 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 20 Jul 2026 11:02:47 -0400 Subject: [PATCH 1/3] config: move config dir to ~/.ellipsis, friendly 401s, fail fast in `me` without a token - Default config dir is now ~/.ellipsis (was ~/.config/ellipsis): the CLI creates it directly under $HOME, so a root-owned ~/.config left behind by an old sudo install can no longer cause first-run EACCES. ELLIPSIS_CONFIG_DIR still overrides. A config at the legacy path is read as a fallback until the first write lands it in ~/.ellipsis, so existing logins survive the move. - runAction maps 401 ApiErrors to a re-login hint (env-token vs stored-login variants) instead of echoing the raw HTTP failure. - `agent me` calls requireToken() before hitting GET /v1/me, so a missing credential fails fast with the login hint instead of sending an unauthenticated request. --- README.md | 10 +++-- src/commands/me.ts | 5 +++ src/lib/config.ts | 32 +++++++++++++-- src/lib/laptop.ts | 12 ++---- src/lib/output.ts | 18 ++++++++- test/config-legacy.test.ts | 80 ++++++++++++++++++++++++++++++++++++++ test/output.test.ts | 44 ++++++++++++++++++++- 7 files changed, 181 insertions(+), 20 deletions(-) create mode 100644 test/config-legacy.test.ts diff --git a/README.md b/README.md index c035a03..2bee8ee 100644 --- a/README.md +++ b/README.md @@ -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 @@ -142,9 +142,11 @@ add … --app-base ` (or `agent host set --app-base `). `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 diff --git a/src/commands/me.ts b/src/commands/me.ts index c43258a..387e52b 100644 --- a/src/commands/me.ts +++ b/src/commands/me.ts @@ -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' @@ -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) diff --git a/src/lib/config.ts b/src/lib/config.ts index 1e218fc..2abda0e 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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 @@ -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 { diff --git a/src/lib/laptop.ts b/src/lib/laptop.ts index f4e21ef..db34bd7 100644 --- a/src/lib/laptop.ts +++ b/src/lib/laptop.ts @@ -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' // --------------------------------------------------------------------------- @@ -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 { @@ -326,10 +323,7 @@ const FAILURE_OUTCOMES: ReadonlySet = new Set([ ]) function hooksDir(): string { - return join( - process.env.ELLIPSIS_CONFIG_DIR ?? join(homedir(), '.config', 'ellipsis'), - 'hooks', - ) + return join(configDir(), 'hooks') } export function syncLogPath(): string { diff --git a/src/lib/output.ts b/src/lib/output.ts index 3e50c56..d29050c 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -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)) @@ -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): Promise { try { await fn() } catch (err) { - console.error(`error: ${(err as Error).message}`) + console.error(`error: ${friendlyErrorMessage(err)}`) process.exitCode = 1 } } diff --git a/test/config-legacy.test.ts b/test/config-legacy.test.ts new file mode 100644 index 0000000..a64bc43 --- /dev/null +++ b/test/config-legacy.test.ts @@ -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() + 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 }) + } + }) +}) diff --git a/test/output.test.ts b/test/output.test.ts index 2707d8b..ed2ded9 100644 --- a/test/output.test.ts +++ b/test/output.test.ts @@ -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)', () => { @@ -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') + }) +}) From 17423880d8bea2189c86a764c152983a95d43ddf Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 20 Jul 2026 11:04:00 -0400 Subject: [PATCH 2/3] connect: drop the stream-unavailable notice (polling fallback is invisible); help: sort subcommands alphabetically --- src/cli.tsx | 3 +++ src/ui/ConnectApp.tsx | 15 ++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli.tsx b/src/cli.tsx index 6b90b7d..44becf8 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -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) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 4fe9a0b..1f831c5 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -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(() => { From bd3bf8991335873f2babf7c8d26815e459398d14 Mon Sep 17 00:00:00 2001 From: hbrooks Date: Mon, 20 Jul 2026 11:05:35 -0400 Subject: [PATCH 3/3] connect: fold the banner into the footer meta line (session id link, model, version) --- src/ui/ConnectApp.tsx | 44 +++++++++++++++---------------------------- 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/src/ui/ConnectApp.tsx b/src/ui/ConnectApp.tsx index 1f831c5..73e017d 100644 --- a/src/ui/ConnectApp.tsx +++ b/src/ui/ConnectApp.tsx @@ -639,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/ @@ -679,28 +684,9 @@ export function ConnectApp(props: ConnectAppProps): React.ReactElement { return ( - {/* 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. */} - - - - {' ●●● '} - - Ellipsis - v{VERSION} - - - {' '} - {hyperlink(props.sessionUrl, props.sessionUrl)} - - {props.model && ( - - {' '} - {props.model} - - )} - + {/* 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 && (