From 50832eaf7b7cbbbcfafe06edcb8214ae73af4fda Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 16 May 2026 21:24:52 +0100 Subject: [PATCH 1/2] feat(auth): add `tw auth token view` via cli-core attachTokenViewCommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire @doist/cli-core/auth's `attachTokenViewCommand` as a subcommand of the existing `tw auth token [token]` setter — `tw auth token view` prints the stored token to stdout (no envelope) so it is pipe-safe for shell composition (`eval $(tw auth token view)`, etc.). - Commander matches subcommand names before the parent action, so `tw auth token view` always dispatches to the view path; Twist OAuth tokens are opaque random strings, so a literal "view" cannot collide with a real token value. - Refuses to print when TWIST_API_TOKEN is set (cli-core's `TOKEN_FROM_ENV` guard) — the env var takes precedence elsewhere so printing the saved token would disclose a secret the CLI did not manage. - `--user ` flag is exposed by cli-core unconditionally; matches the lone stored account via the existing `TwistTokenStore.active(ref)` path (id-or-label match through parseRef), so single-user semantics are preserved. No changes to the lib/ layer. Five new tests cover the happy path, TOKEN_FROM_ENV refusal, NOT_AUTHENTICATED, and --user id match/miss. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/commands/auth/auth.test.ts | 94 +++++++++++++++++++++++++++++++++- src/commands/auth/index.ts | 20 +++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 23b6813..22ce1ae 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -9,6 +9,7 @@ vi.mock('../../lib/auth.js', async (importOriginal) => { saveApiToken: vi.fn(), clearApiToken: vi.fn(), getAuthMetadata: vi.fn(), + probeApiToken: vi.fn(), } }) @@ -57,7 +58,14 @@ import { attachLoginCommand } from '@doist/cli-core/auth' import { TwistRequestError, type User } from '@doist/twist-sdk' import { createWrappedTwistClient } from '../../lib/api.js' import { type TwistAccount, type TwistTokenStore } from '../../lib/auth-provider.js' -import { clearApiToken, getAuthMetadata, saveApiToken } from '../../lib/auth.js' +import { + clearApiToken, + getAuthMetadata, + NoTokenError, + probeApiToken, + saveApiToken, + TOKEN_ENV_VAR, +} from '../../lib/auth.js' import { registerAuthCommand } from './index.js' import { attachTwistStatusCommand } from './status.js' @@ -66,6 +74,7 @@ const mockCreateInterface = vi.mocked(createInterface) const mockSaveApiToken = vi.mocked(saveApiToken) const mockClearApiToken = vi.mocked(clearApiToken) const mockGetAuthMetadata = vi.mocked(getAuthMetadata) +const mockProbeApiToken = vi.mocked(probeApiToken) const mockCreateWrappedTwistClient = vi.mocked(createWrappedTwistClient) const mockAttachLoginCommand = vi.mocked(attachLoginCommand) @@ -270,6 +279,89 @@ describe('auth command', () => { }) }) + describe('token view subcommand', () => { + const STORED_METADATA = { + authMode: 'read-write' as const, + authScope: 'user:read', + authUserId: 1, + authUserName: 'Test User', + source: 'secure-store' as const, + } + + let writeSpy: ReturnType + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + delete process.env[TOKEN_ENV_VAR] + }) + + afterEach(() => { + writeSpy.mockRestore() + delete process.env[TOKEN_ENV_VAR] + }) + + it('prints the stored token to stdout for pipe-safe consumption', async () => { + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await program.parseAsync(['node', 'tw', 'auth', 'token', 'view']) + + expect(writeSpy).toHaveBeenCalledWith('tk_stored_1234567890') + }) + + it('refuses to print when the env var is set so the CLI does not disclose an unmanaged token', async () => { + process.env[TOKEN_ENV_VAR] = 'env_token_supplied_externally' + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view']), + ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') + + expect(mockProbeApiToken).not.toHaveBeenCalled() + expect(writeSpy).not.toHaveBeenCalled() + }) + + it('throws NOT_AUTHENTICATED when no token is stored', async () => { + mockProbeApiToken.mockRejectedValueOnce(new NoTokenError()) + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view']), + ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') + + expect(writeSpy).not.toHaveBeenCalled() + }) + + it('matches --user against the stored account by numeric id', async () => { + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '1']) + + expect(writeSpy).toHaveBeenCalledWith('tk_stored_1234567890') + }) + + it('rejects --user with ACCOUNT_NOT_FOUND when the ref does not match the stored account', async () => { + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '999']), + ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') + + expect(writeSpy).not.toHaveBeenCalled() + }) + }) + describe('status subcommand', () => { // All happy-path tests drive a controllable snapshot store directly // into `attachTwistStatusCommand` so the `fetchLive` → diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 20d329b..958c4b7 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -1,5 +1,7 @@ +import { attachTokenViewCommand } from '@doist/cli-core/auth' import { Command } from 'commander' import { createTwistTokenStore } from '../../lib/auth-provider.js' +import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { attachTwistLoginCommand } from './login.js' import { attachTwistLogoutCommand } from './logout.js' import { attachTwistStatusCommand } from './status.js' @@ -18,7 +20,21 @@ export function registerAuthCommand(program: Command): void { attachTwistLogoutCommand(auth, store) attachTwistStatusCommand(auth, store) - auth.command('token [token]') - .description('Save API token for CLI authentication') + // `token` is a hybrid: the positional `[token]` saves, and the `view` + // subcommand prints. Commander matches subcommand names before the parent + // action, so `tw auth token view` always dispatches to the view path — + // Twist OAuth tokens are opaque random strings so the literal "view" can + // never collide with a real token value. + const tokenCmd = auth + .command('token [token]') + .description('Save API token for CLI authentication (or use a subcommand: `view`)') .action(loginWithToken) + + attachTokenViewCommand(tokenCmd, { + name: 'view', + store, + envVarName: TOKEN_ENV_VAR, + description: + 'Print the stored API token for the active user (or --user ) to stdout for use in scripts', + }) } From 086a493fb178455a6aac5e8ea851e0bc5f85bd20 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Sat, 16 May 2026 21:30:21 +0100 Subject: [PATCH 2/2] test+docs(auth): address PR #227 review feedback - src/lib/skills/content.ts + regenerated skills/twist-cli/SKILL.md: update the agent-facing reference from the (always-wrong) `tw auth token --user ` to the actual `tw auth token view [--user ]` surface introduced by this PR. - src/commands/auth/auth.test.ts: switch the TOKEN_FROM_ENV setup to `vi.stubEnv` / `vi.unstubAllEnvs` so a developer's exported TWIST_API_TOKEN is restored after the suite runs (no cross-test leakage). Tighten stdout assertions: replace `toHaveBeenCalledWith` on `process.stdout.write` with full-payload comparison via a `stdoutPayload()` helper, and assert `consoleSpy` was not called, so any future preamble/trailer added to the view output would fail the pipe-safety contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- skills/twist-cli/SKILL.md | 3 ++- src/commands/auth/auth.test.ts | 30 +++++++++++++++++++++--------- src/lib/skills/content.ts | 3 ++- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 597bc57..806fd30 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -26,7 +26,8 @@ tw auth status --user # Target a specific stored account (id, id:, tw auth logout # Remove saved token and auth metadata tw auth logout --json # Emits `{"ok": true}` (--ndjson is silent) tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token --user # Print the saved token for a specific stored account +tw auth token view # Print the saved token to stdout (pipe-safe; refuses if TWIST_API_TOKEN is set) +tw auth token view --user # Print the saved token for a specific stored account tw workspaces # List available workspaces tw workspace use # Set current workspace tw completion install # Install shell completions diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 22ce1ae..98a3915 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -290,17 +290,24 @@ describe('auth command', () => { let writeSpy: ReturnType + // Capture the full stdout payload so we can assert pipe-safety: any + // extra preamble/trailer added by a future change would change this + // string and fail the test. + function stdoutPayload(): string { + return writeSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('') + } + beforeEach(() => { writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) - delete process.env[TOKEN_ENV_VAR] }) afterEach(() => { writeSpy.mockRestore() - delete process.env[TOKEN_ENV_VAR] + vi.unstubAllEnvs() }) - it('prints the stored token to stdout for pipe-safe consumption', async () => { + it('prints exactly the stored token to stdout with no envelope (pipe-safe)', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') mockProbeApiToken.mockResolvedValueOnce({ token: 'tk_stored_1234567890', metadata: STORED_METADATA, @@ -309,11 +316,12 @@ describe('auth command', () => { const program = createProgram() await program.parseAsync(['node', 'tw', 'auth', 'token', 'view']) - expect(writeSpy).toHaveBeenCalledWith('tk_stored_1234567890') + expect(stdoutPayload()).toBe('tk_stored_1234567890') + expect(consoleSpy).not.toHaveBeenCalled() }) it('refuses to print when the env var is set so the CLI does not disclose an unmanaged token', async () => { - process.env[TOKEN_ENV_VAR] = 'env_token_supplied_externally' + vi.stubEnv(TOKEN_ENV_VAR, 'env_token_supplied_externally') const program = createProgram() await expect( @@ -321,10 +329,11 @@ describe('auth command', () => { ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') expect(mockProbeApiToken).not.toHaveBeenCalled() - expect(writeSpy).not.toHaveBeenCalled() + expect(stdoutPayload()).toBe('') }) it('throws NOT_AUTHENTICATED when no token is stored', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') mockProbeApiToken.mockRejectedValueOnce(new NoTokenError()) const program = createProgram() @@ -332,10 +341,11 @@ describe('auth command', () => { program.parseAsync(['node', 'tw', 'auth', 'token', 'view']), ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') - expect(writeSpy).not.toHaveBeenCalled() + expect(stdoutPayload()).toBe('') }) it('matches --user against the stored account by numeric id', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') mockProbeApiToken.mockResolvedValueOnce({ token: 'tk_stored_1234567890', metadata: STORED_METADATA, @@ -344,10 +354,12 @@ describe('auth command', () => { const program = createProgram() await program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '1']) - expect(writeSpy).toHaveBeenCalledWith('tk_stored_1234567890') + expect(stdoutPayload()).toBe('tk_stored_1234567890') + expect(consoleSpy).not.toHaveBeenCalled() }) it('rejects --user with ACCOUNT_NOT_FOUND when the ref does not match the stored account', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') mockProbeApiToken.mockResolvedValueOnce({ token: 'tk_stored_1234567890', metadata: STORED_METADATA, @@ -358,7 +370,7 @@ describe('auth command', () => { program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '999']), ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') - expect(writeSpy).not.toHaveBeenCalled() + expect(stdoutPayload()).toBe('') }) }) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index e33f5ac..d2e8689 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -30,7 +30,8 @@ tw auth status --user # Target a specific stored account (id, id:, tw auth logout # Remove saved token and auth metadata tw auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token --user # Print the saved token for a specific stored account +tw auth token view # Print the saved token to stdout (pipe-safe; refuses if TWIST_API_TOKEN is set) +tw auth token view --user # Print the saved token for a specific stored account tw workspaces # List available workspaces tw workspace use # Set current workspace tw completion install # Install shell completions