From 3425b0d19a787509313571ba3ba903f92f1e67f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:58:47 +0000 Subject: [PATCH 1/2] fix: replace the unknown-command help dump with a concise error An unknown command (e.g. `polylane setup` on an old bundle) printed the full ~46-line root help. It now prints a two-line error with exit 2, in both text and JSON output. `polylane help ` shared the same path and gets the same fix. --- src/commands/help.ts | 10 +++++++--- src/main.ts | 15 ++++++++++----- test/resolve.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/src/commands/help.ts b/src/commands/help.ts index 1d5b927..b397cee 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,6 +1,8 @@ import type { Command } from '../command'; import type { Config } from '../config/schema'; import { registry, renderHelp } from '../registry'; +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; export const helpCommand: Command = { name: 'help', @@ -13,9 +15,11 @@ export const helpCommand: Command = { } const resolved = registry.resolve(positional); if (!resolved) { - process.stderr.write(`Unknown command: ${positional.join(' ')}\n`); - process.stdout.write(renderHelp(null, config.noColor)); - return; + throw new CLIError( + `Unknown command: polylane ${positional.join(' ')}`, + ExitCode.USAGE, + 'Run `polylane --help` to list commands' + ); } process.stdout.write(renderHelp(resolved.command, config.noColor)); }, diff --git a/src/main.ts b/src/main.ts index de80a37..244e00a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -60,7 +60,7 @@ async function printRootHelp(noColor: boolean): Promise { const config = loadConfig({} as GlobalFlags); status = await buildStatusLine(config); } catch { - status = 'Not logged in.'; + status = 'Not signed in.'; } process.stdout.write(renderRootHelp(noColor, status)); } @@ -100,9 +100,14 @@ async function run(): Promise { } const { flags } = parseFlags(argv, [], GLOBAL_OPTIONS); const config = loadConfig(flags as GlobalFlags); - process.stderr.write(`Unknown command: polylane ${commandPath.join(' ')}\n\n`); - await printRootHelp(config.noColor); - process.exit(ExitCode.USAGE); + handleError( + new CLIError( + `Unknown command: polylane ${commandPath.join(' ')}`, + ExitCode.USAGE, + 'Run `polylane --help` to list commands' + ), + config + ); } const { command, consumed } = resolved; @@ -144,7 +149,7 @@ async function run(): Promise { if (!NO_AUTH_COMMANDS.has(command.name)) { if (!credential) { throw new CLIError( - 'Not logged in.', + 'Not signed in.', ExitCode.AUTH, 'polylane auth login --api-key sk_xxxxx (API key)\n' + ' polylane auth login (OAuth browser flow)\n' + diff --git a/test/resolve.test.ts b/test/resolve.test.ts index b585977..6e24e9d 100644 --- a/test/resolve.test.ts +++ b/test/resolve.test.ts @@ -2,6 +2,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { registry } from '../src/registry'; import { registerAllCommands } from '../src/commands'; +import { helpCommand } from '../src/commands/help'; +import { setupCommand } from '../src/commands/setup'; +import { CLIError } from '../src/errors/base'; +import { ExitCode } from '../src/errors/codes'; +import { mockConfig } from './helpers/config'; registerAllCommands(); @@ -71,3 +76,44 @@ describe('command resolution', () => { assert.equal(subs.length, 5); }); }); + +describe('unknown command handling', () => { + it('help throws a concise usage error instead of dumping root help', async () => { + const written: string[] = []; + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + written.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + await assert.rejects( + helpCommand.execute(mockConfig(), {}, { _: ['nope', 'sub'] }), + (err: unknown) => { + assert.ok(err instanceof CLIError); + assert.equal(err.message, 'Unknown command: polylane nope sub'); + assert.equal(err.exitCode, ExitCode.USAGE); + assert.ok(err.hint?.includes('polylane --help')); + return true; + } + ); + } finally { + process.stdout.write = origWrite; + } + assert.deepEqual(written, []); + }); +}); + +describe('setup failure copy', () => { + it('rejects an unknown agent with the supported list', async () => { + await assert.rejects( + setupCommand.execute(mockConfig(), {}, { _: [], agent: ['emacs'] }), + (err: unknown) => { + assert.ok(err instanceof CLIError); + assert.equal(err.message, 'Unknown agent: "emacs"'); + assert.equal(err.exitCode, ExitCode.USAGE); + assert.ok(err.hint?.includes('claude')); + return true; + } + ); + }); +}); From 62a18e7889417b7d20a1e5199194c0b3cfd76a50 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 02:58:47 +0000 Subject: [PATCH 2/2] refactor: tighten user-facing copy across prompts, receipts, and errors One verb family for auth state (signed in/out), spinner labels with a real ellipsis, singular/plural interpolation, and every dead-end error now names the next command. ERRORS.md rows updated to match. --- ERRORS.md | 11 ++++++----- src/auth/oauth.ts | 10 +++++----- src/client/http.ts | 2 +- src/commands/auth/login.ts | 20 ++++++++++---------- src/commands/auth/logout.ts | 6 +++--- src/commands/auth/signup.ts | 6 +++--- src/commands/cloud/connect.ts | 3 ++- src/commands/cloud/disconnect.ts | 2 +- src/commands/config/set.ts | 2 +- src/commands/feed/list.ts | 2 +- src/commands/helpers.ts | 2 +- src/commands/integration/connect.ts | 2 +- src/commands/integration/disconnect.ts | 2 +- src/commands/integration/show.ts | 9 +++++++-- src/commands/note/global-clear.ts | 2 +- src/commands/setup.ts | 6 +++--- src/commands/telemetry/enable.ts | 2 +- src/commands/thread/ask.ts | 2 +- src/commands/thread/continue.ts | 2 +- src/commands/thread/export.ts | 5 +++-- src/commands/update.ts | 4 ++-- src/commands/workspace/use.ts | 11 ++++++++--- src/errors/api.ts | 4 ++-- src/errors/handler.ts | 4 ++-- src/registry.ts | 2 +- src/utils/prompt.ts | 2 +- test/errors.test.ts | 2 ++ 27 files changed, 71 insertions(+), 56 deletions(-) diff --git a/ERRORS.md b/ERRORS.md index 05dd2e9..2e54f98 100644 --- a/ERRORS.md +++ b/ERRORS.md @@ -61,9 +61,9 @@ Every command inherits these. Specific messages are subject to change, but the * | Scenario | Typical message | |---|---| -| No credentials | `Not logged in.` with a hint listing `auth login` variants | -| HTTP 401 | `Unauthorized — check your API key` | -| HTTP 403 | `Forbidden — insufficient permissions` | +| No credentials | `Not signed in.` with a hint listing `auth login` variants | +| HTTP 401 | `Not authenticated` | +| HTTP 403 | `Permission denied` | | OAuth refresh failed | `Token refresh failed` with a hint to re-authenticate | | WebSocket upgrade rejected (for streaming commands) | `WebSocket upgrade rejected ()` | @@ -71,13 +71,14 @@ Every command inherits these. Specific messages are subject to change, but the * | Scenario | Typical message | |---|---| -| HTTP 429 | `Rate limit exceeded` + any `Retry-After` | +| HTTP 429 | `Rate limited` + any `Retry-After` | | HTTP 426 | `Plan upgrade required` | ### Usage (exit `2`) | Scenario | Typical message | |---|---| +| Unknown command | `Unknown command: polylane ` with a hint pointing at `polylane --help` | | Unknown flag | `Unknown flag: ` | | Flag requires a value | `Flag requires a value` | | Flag expects a number | `Flag expects a number, got ""` | @@ -86,7 +87,7 @@ Every command inherits these. Specific messages are subject to change, but the * | Invalid domain / workspace ID / timeout / output format | per-validator error with a hint | | `--body` invalid JSON | `Invalid JSON in --body: ` | | `--body-file` unreadable | bubbled fs error (`ENOENT`, `EACCES`, …) | -| Destructive command without `--yes` in non-interactive mode | `Refusing to without --yes in non-interactive mode` | +| Destructive command without `--yes` in non-interactive mode | `Confirmation required` with a hint to re-run with `--yes` | ### Workspace context (exit `2`) diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 6abad5d..837f169 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -344,8 +344,8 @@ export async function oauthBrowserFlow(config: Config): Promise { - const spinner = new Spinner('Validating API key'); + const spinner = new Spinner('Validating API key…'); spinner.start(); try { const res = await request( @@ -41,7 +41,7 @@ async function validateApiKey(config: Config, key: string): Promise { - const spinner = new Spinner('Fetching workspaces'); + const spinner = new Spinner('Fetching workspaces…'); spinner.start(); try { const list = await requestJson<{ items: WorkspaceItem[]; count: number }>( @@ -73,12 +73,12 @@ async function selectWorkspace(config: Config, user: WhoamiResult): Promise( { nonInteractive: config.nonInteractive }, - 'Select default workspace', + 'Default workspace', list.items.map((ws) => ({ value: ws.id, label: ws.name, @@ -104,7 +104,7 @@ async function apiKeyLogin(config: Config, key: string): Promise { const user = await validateApiKey(config, key); const name = user.forename ? `${user.forename}${user.surname ? ' ' + user.surname : ''}` : user.email ?? user.id; - process.stderr.write(`\nAuthenticated as ${name} (${user.email ?? user.id})\n`); + process.stderr.write(`\nSigned in as ${name} (${user.email ?? user.id})\n`); const configWithKey: Config = { ...config, apiKey: key }; const wsId = await selectWorkspace(configWithKey, user); @@ -148,7 +148,7 @@ async function oauthLogin(config: Config, useBrowser: boolean): Promise { url: '/v1/auth/whoami', }); const name = user.forename ? `${user.forename}${user.surname ? ' ' + user.surname : ''}` : user.email ?? user.id; - process.stderr.write(`\nAuthenticated as ${name} (${user.email ?? user.id})\n`); + process.stderr.write(`\nSigned in as ${name} (${user.email ?? user.id})\n`); const wsId = await selectWorkspace(configWithAuth, user); if (wsId) { writeConfigFile({ workspace_id: wsId }); @@ -194,7 +194,7 @@ export const authLoginCommand: Command = { const method = await promptSelect<'browser' | 'device' | 'api-key'>( { nonInteractive: config.nonInteractive }, - 'How would you like to authenticate?', + 'Sign in with', [ { value: 'browser', label: 'OAuth (browser)', hint: 'Recommended' }, { value: 'device', label: 'OAuth (device code)', hint: 'For SSH/headless' }, @@ -203,7 +203,7 @@ export const authLoginCommand: Command = { ); if (method === 'api-key') { - const key = await promptPassword({ nonInteractive: config.nonInteractive }, 'Enter API key'); + const key = await promptPassword({ nonInteractive: config.nonInteractive }, 'API key'); await apiKeyLogin(config, key); return; } diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 53cfc9f..f711078 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -38,7 +38,7 @@ export const authLogoutCommand: Command = { if (cred) { await revokeToken(config, cred.accessToken); deleteCredentials(); - process.stderr.write('OAuth credentials revoked and removed\n'); + process.stderr.write('Revoked OAuth token and removed credentials\n'); } const existing = loadConfigFile(); @@ -46,10 +46,10 @@ export const authLogoutCommand: Command = { const next = { ...existing }; delete next.api_key; replaceConfigFile(next); - process.stderr.write('API key cleared from config file\n'); + process.stderr.write('Removed API key from config\n'); } - process.stderr.write('Logged out\n'); + process.stderr.write('Signed out\n'); void flags; }, diff --git a/src/commands/auth/signup.ts b/src/commands/auth/signup.ts index 37e1fa9..f662239 100644 --- a/src/commands/auth/signup.ts +++ b/src/commands/auth/signup.ts @@ -24,7 +24,7 @@ interface SignupEnvelope { export const authSignupCommand: Command = { name: 'auth signup', - description: 'Bootstrap a new Polylane account with email + password', + description: 'Create a Polylane account with email and password', operationId: 'auth.signup', options: [ { flag: '--email ', description: 'Email address', type: 'string' }, @@ -78,7 +78,7 @@ export const authSignupCommand: Command = { if (result.token) { note( [ - `You are signed in. The session is valid until ${expiresAt}.`, + `Signed in. Session valid until ${expiresAt}.`, ``, `Onboarding (in order):`, ``, @@ -111,7 +111,7 @@ export const authSignupCommand: Command = { ); outro('Signed in.'); } else { - outro('Signup accepted but no session returned. Run `polylane auth login` to authenticate.'); + outro('Account created, but no session returned. Run `polylane auth login`.'); } }, }; diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index dd7dd2c..84a6997 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -132,7 +132,8 @@ export const cloudConnectCommand: Command = { // All other providers return { accounts, failures } synchronously. formatOutput(config, result); if ('failures' in result && result.failures.length > 0 && !config.quiet) { - process.stderr.write(`\n${result.failures.length} account(s) failed to connect — see "failures" above.\n`); + const n = result.failures.length; + process.stderr.write(`\n${n} account${n === 1 ? '' : 's'} failed to connect — see "failures" above.\n`); } }, }; diff --git a/src/commands/cloud/disconnect.ts b/src/commands/cloud/disconnect.ts index e86149b..6519370 100644 --- a/src/commands/cloud/disconnect.ts +++ b/src/commands/cloud/disconnect.ts @@ -20,7 +20,7 @@ export const cloudDisconnectCommand: Command = { const yes = getArgBoolean(args, 'yes') === true; if (!yes) { if (!isInteractive(config.nonInteractive)) { - throw new CLIError('Refusing to disconnect without --yes in non-interactive mode', ExitCode.USAGE); + throw new CLIError('Confirmation required', ExitCode.USAGE, `Re-run with --yes to disconnect ${id}`); } const confirmed = await promptConfirm( { nonInteractive: config.nonInteractive }, diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts index 3a5ae0a..29f5ff0 100644 --- a/src/commands/config/set.ts +++ b/src/commands/config/set.ts @@ -72,7 +72,7 @@ export const configSetCommand: Command = { throw new CLIError( `Invalid telemetry value: "${value}"`, ExitCode.USAGE, - `Use one of: ${[...truthy, ...falsy].join(', ')}` + 'Use true/false, yes/no, on/off, 1/0, or enabled/disabled' ); } break; diff --git a/src/commands/feed/list.ts b/src/commands/feed/list.ts index c46dcff..a08fdc2 100644 --- a/src/commands/feed/list.ts +++ b/src/commands/feed/list.ts @@ -76,7 +76,7 @@ export const feedListCommand: Command = { const limit = getArgNumber(args, 'limit') ?? 20; if (limit < 1 || limit > 200) { - throw new CLIError('Invalid --limit', ExitCode.USAGE, 'Must be between 1 and 200'); + throw new CLIError(`Invalid --limit: ${limit}`, ExitCode.USAGE, 'Use 1–200'); } const since = getArgString(args, 'since'); diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 146bca8..ab77dea 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -48,7 +48,7 @@ export function parseDuration(value: string): number { throw new CLIError( `Invalid duration: "${value}"`, ExitCode.USAGE, - 'Use forms like 1h, 24h, 30m, 7d' + 'Use a duration like 30m, 1h, 24h, or 7d' ); } const n = Number(match[1]); diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 879d8f8..96f364a 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -100,7 +100,7 @@ export const integrationConnectCommand: Command = { } else if (isInteractive(config.nonInteractive)) { authMethod = await promptSelect<'none' | 'bearer' | 'oauth'>( { nonInteractive: config.nonInteractive }, - 'How should this MCP server be authenticated?', + 'Authentication', [ { value: 'none', label: 'No authentication', hint: 'Public server' }, { value: 'bearer', label: 'Bearer token', hint: 'API key / token' }, diff --git a/src/commands/integration/disconnect.ts b/src/commands/integration/disconnect.ts index bf991f5..c833d3e 100644 --- a/src/commands/integration/disconnect.ts +++ b/src/commands/integration/disconnect.ts @@ -20,7 +20,7 @@ export const integrationDisconnectCommand: Command = { const yes = getArgBoolean(args, 'yes') === true; if (!yes) { if (!isInteractive(config.nonInteractive)) { - throw new CLIError('Refusing to disconnect without --yes in non-interactive mode', ExitCode.USAGE); + throw new CLIError('Confirmation required', ExitCode.USAGE, `Re-run with --yes to disconnect ${id}`); } const confirmed = await promptConfirm( { nonInteractive: config.nonInteractive }, diff --git a/src/commands/integration/show.ts b/src/commands/integration/show.ts index d96c9e7..d4d53a7 100644 --- a/src/commands/integration/show.ts +++ b/src/commands/integration/show.ts @@ -3,6 +3,8 @@ import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; import { formatOutput } from '../../output/formatter'; import { requireWorkspace, requirePositional } from '../helpers'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; export const integrationShowCommand: Command = { name: 'integration show', @@ -17,8 +19,11 @@ export const integrationShowCommand: Command = { const result = await api.integrationsList(workspaceId, { id, perPage: 1 }); const integration = result.items[0]; if (!integration) { - process.stderr.write(`No integration with id ${id}\n`); - process.exit(1); + throw new CLIError( + `No integration with ID ${id}`, + ExitCode.GENERAL, + 'List integrations with `polylane integration list`' + ); } formatOutput(config, integration); }, diff --git a/src/commands/note/global-clear.ts b/src/commands/note/global-clear.ts index 35f4b99..4f97b1d 100644 --- a/src/commands/note/global-clear.ts +++ b/src/commands/note/global-clear.ts @@ -27,6 +27,6 @@ export const noteGlobalClearCommand: Command = { const api = new PolylaneAPI(config); await api.notesGlobalDel(workspaceId); - process.stdout.write(`Cleared\n`); + process.stdout.write(`Cleared global note\n`); }, }; diff --git a/src/commands/setup.ts b/src/commands/setup.ts index af3e55c..ed4a319 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -316,7 +316,7 @@ export const setupCommand: Command = { if (selected.length === 0) { say('No coding agents detected.'); - say(`Target one explicitly with --agent (${AGENTS.map((a) => a.id).join(', ')}).`); + say(`Configure one anyway with --agent (${AGENTS.map((a) => a.id).join(', ')}).`); return; } @@ -350,9 +350,9 @@ export const setupCommand: Command = { const credential = await tryResolveCredential(config); if (credential) { - say('Authentication: signed in.'); + say('Signed in.'); } else { - say('Authentication: not signed in. Run `polylane auth login` to authenticate.'); + say('Not signed in. Run `polylane auth login`.'); } }, }; diff --git a/src/commands/telemetry/enable.ts b/src/commands/telemetry/enable.ts index 51bed7f..00501f4 100644 --- a/src/commands/telemetry/enable.ts +++ b/src/commands/telemetry/enable.ts @@ -10,7 +10,7 @@ export const telemetryEnableCommand: Command = { process.stderr.write('Telemetry enabled.\n'); if (process.env.DO_NOT_TRACK && process.env.DO_NOT_TRACK !== '0' && process.env.DO_NOT_TRACK !== 'false') { process.stderr.write( - ' Note: DO_NOT_TRACK is set in your environment, which overrides the config. Unset it to actually send telemetry.\n' + ' Note: DO_NOT_TRACK is set and overrides this setting. Unset it to send telemetry.\n' ); } }, diff --git a/src/commands/thread/ask.ts b/src/commands/thread/ask.ts index 5fa882f..4cca599 100644 --- a/src/commands/thread/ask.ts +++ b/src/commands/thread/ask.ts @@ -87,7 +87,7 @@ export const threadAskCommand: Command = { } const useSpinner = !config.quiet && config.output !== 'json'; - const spinner = useSpinner ? new Spinner(`Waiting for assistant on ${thread.id}`) : null; + const spinner = useSpinner ? new Spinner(`Waiting for reply on ${thread.id}…`) : null; if (spinner) spinner.start(); try { diff --git a/src/commands/thread/continue.ts b/src/commands/thread/continue.ts index 043b047..5787c7c 100644 --- a/src/commands/thread/continue.ts +++ b/src/commands/thread/continue.ts @@ -83,7 +83,7 @@ export const threadContinueCommand: Command = { } const useSpinner = !config.quiet && config.output !== 'json'; - const spinner = useSpinner ? new Spinner(`Waiting for assistant on ${threadId}`) : null; + const spinner = useSpinner ? new Spinner(`Waiting for reply on ${threadId}…`) : null; if (spinner) spinner.start(); try { diff --git a/src/commands/thread/export.ts b/src/commands/thread/export.ts index 4448867..a7eb1c0 100644 --- a/src/commands/thread/export.ts +++ b/src/commands/thread/export.ts @@ -3,6 +3,8 @@ import type { Command } from '../../command'; import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; import { requireWorkspace, requirePositional, getArgString } from '../helpers'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; type Format = 'md' | 'pdf'; @@ -31,8 +33,7 @@ export const threadExportCommand: Command = { return; } if (format === 'pdf') { - process.stderr.write('PDF output requires --out\n'); - process.exit(2); + throw new CLIError('PDF export requires --out ', ExitCode.USAGE); } process.stdout.write(buffer.toString('utf-8')); }, diff --git a/src/commands/update.ts b/src/commands/update.ts index 51f7009..91aa626 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -50,7 +50,7 @@ export const updateCommand: Command = { const latest = await fetchLatest(); if (!latest) { - process.stderr.write('Could not fetch latest version from npm\n'); + process.stderr.write("Couldn't reach npm to check for updates\n"); return; } @@ -61,7 +61,7 @@ export const updateCommand: Command = { process.stderr.write(`Run: npm install -g @coreplane/polylane@${latest}\n`); process.stderr.write(` or: brew upgrade polylane\n`); } else { - process.stderr.write(`Already on the latest version\n`); + process.stderr.write(`Up to date\n`); } }, }; diff --git a/src/commands/workspace/use.ts b/src/commands/workspace/use.ts index 807c7e5..7c4ab3f 100644 --- a/src/commands/workspace/use.ts +++ b/src/commands/workspace/use.ts @@ -3,6 +3,8 @@ import type { Config } from '../../config/schema'; import { PolylaneAPI } from '../../generated/client'; import { writeConfigFile } from '../../config/loader'; import { requirePositional } from '../helpers'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; export const workspaceUseCommand: Command = { name: 'workspace use', @@ -19,13 +21,16 @@ export const workspaceUseCommand: Command = { } else { const list = await api.workspacesList({ slug: raw, perPage: 5 }); if (list.items.length === 0) { - process.stderr.write(`No workspace matching "${raw}"\n`); - process.exit(1); + throw new CLIError( + `No workspace matching "${raw}"`, + ExitCode.GENERAL, + 'List workspaces with `polylane workspace list`' + ); } workspace = list.items[0]!; } writeConfigFile({ workspace_id: workspace.id }); - process.stderr.write(`Using workspace ${workspace.id} (${workspace.name})\n`); + process.stderr.write(`Using workspace ${workspace.name} (${workspace.id})\n`); }, }; diff --git a/src/errors/api.ts b/src/errors/api.ts index 29cf3ef..38cbc67 100644 --- a/src/errors/api.ts +++ b/src/errors/api.ts @@ -15,13 +15,13 @@ export function mapApiError(status: number, error: ApiErrorPayload | null): CLIE return new CLIError(detail || 'Bad request', ExitCode.USAGE); case 401: return new CLIError( - detail || 'Unauthorized - check your API key', + detail || 'Not authenticated', ExitCode.AUTH, 'Run `polylane auth login`' ); case 403: return new CLIError( - detail || 'Forbidden - insufficient permissions', + detail || 'Permission denied', ExitCode.AUTH, 'Check your API key scopes or workspace permissions' ); diff --git a/src/errors/handler.ts b/src/errors/handler.ts index 8b846cd..b0a81ce 100644 --- a/src/errors/handler.ts +++ b/src/errors/handler.ts @@ -15,11 +15,11 @@ function wrapUnknown(err: unknown): CLIError { const msg = err.message; if (name === 'AbortError' || msg.includes('aborted') || msg.includes('timeout')) { - return new CLIError('Request timed out', ExitCode.TIMEOUT, 'Try increasing --timeout'); + return new CLIError('Request timed out', ExitCode.TIMEOUT, 'Raise --timeout'); } if (hasCode(err, 'ECONNREFUSED')) { - return new CLIError('Connection refused', ExitCode.NETWORK, 'Check the API domain'); + return new CLIError('Connection refused', ExitCode.NETWORK, 'Check --domain and your network'); } if (hasCode(err, 'ENOTFOUND')) { return new CLIError( diff --git a/src/registry.ts b/src/registry.ts index 21f9b5b..5d62f3a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -158,7 +158,7 @@ function statusLine(config: Config | null): string | null { export async function buildStatusLine(config: Config): Promise { const cred = await tryResolveCredential(config); - if (!cred) return 'Not logged in.'; + if (!cred) return 'Not signed in.'; const ws = config.workspaceId ? `workspace: ${config.workspaceId}` : 'no workspace set'; if (cred.type === 'api-key') { diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 3ab64e1..1427208 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -12,7 +12,7 @@ function ensureInteractive(ctx: PromptContext, fieldName: string): void { throw new CLIError( `Missing required input: ${fieldName}`, ExitCode.USAGE, - 'Run with the appropriate flag or enable interactive mode' + 'Pass the value as a flag or run in a TTY' ); } } diff --git a/test/errors.test.ts b/test/errors.test.ts index 824db24..3464dc5 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -30,12 +30,14 @@ describe('mapApiError', () => { it('maps 401 -> AUTH with hint', () => { const err = mapApiError(401, { message: 'Unauthorized' }); assert.equal(err.exitCode, ExitCode.AUTH); + assert.equal(err.message, 'Not authenticated'); assert.ok(err.hint?.includes('polylane auth login')); }); it('maps 403 -> AUTH', () => { const err = mapApiError(403, { message: 'Forbidden' }); assert.equal(err.exitCode, ExitCode.AUTH); + assert.equal(err.message, 'Permission denied'); }); it('maps 404 -> GENERAL', () => {