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
27 changes: 27 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,33 @@ pnpm test:watch
- **Strict mode** enabled
- **JSX:** react-jsx (for Ink/React dashboard)

## Output Mode vs Interaction Mode

The CLI separates two axes:

| Axis | Question | API |
| -------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------- |
| **Output mode** | How should output be formatted? | `isJsonMode()` from `src/utils/output.ts` |
| **Interaction mode** | Who is driving the CLI? | `isHumanMode()`, `isAgentMode()`, `isCiMode()`, `isPromptAllowed()` from `src/utils/interaction-mode.ts` |

Guidelines for new code:

- Use `isJsonMode()` **only** to choose between structured JSON and human-formatted output. Do not use it to decide whether to prompt, open a browser, or skip a confirmation.
- Use `isPromptAllowed()` (== `isHumanMode()`) before any clack prompt or interactive flow.
- Use `isAgentMode()` to add agent-specific recovery hints, manual-fallback wording, or host-execution warnings.
- Use `isCiMode()` to refuse browser-based flows and to prefer terse failures over recovery handoff text.
- For destructive operations, require an explicit `--yes`/`--force` flag whenever `!isPromptAllowed()` regardless of output mode.
- For `auth_required` and other deterministic failures, attach recovery metadata via `src/utils/recovery-hints.ts` so agents can parse `error.recovery.hints[]`.

Legacy compatibility — do not regress these:

- `WORKOS_NO_PROMPT=1` keeps mapping to agent interaction behavior **and** JSON output (legacy alias).
- `WORKOS_FORCE_TTY=1` only affects output mode (forces human). It must not change interaction mode.
- Non-TTY stdout still defaults output to JSON and interaction to agent.
- `isNonInteractiveEnvironment()` from `src/utils/environment.ts` is a thin wrapper over `!isHumanMode()` kept for backward compatibility. Prefer the explicit interaction-mode predicates in new code.

The full backwards-compat matrix lives in `src/utils/mode-compatibility.spec.ts`.

## Making Changes

### Adding a New Framework
Expand Down
42 changes: 39 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,7 +584,18 @@ workos --help --json | jq '.commands[].name'

## Scripting & Automation

The CLI auto-detects non-TTY environments (piped output, CI, coding agents) and switches to machine-friendly behavior. No flags required — just pipe it.
The CLI separates **output mode** from **interaction mode**:

- `--json` (or non-TTY auto-detection) controls **output formatting** only.
- `--mode human|agent|ci` (or `WORKOS_MODE=...`) controls **interaction behavior** — prompts, browser launch, host trust, destructive confirmation.

For coding agents, set both axes explicitly:

```bash
WORKOS_MODE=agent workos doctor --json --skip-ai
```

The CLI also auto-detects non-TTY environments (piped output, CI, coding agents) and falls back to machine-friendly defaults. No flags are required — just pipe it — but explicit mode is recommended for agents.

### JSON Output

Expand All @@ -605,6 +616,30 @@ workos org list 2>&1
# → { "error": { "code": "no_api_key", "message": "No API key configured..." } }
```

### Agent Mode

When a coding agent drives the CLI, set agent mode explicitly so behavior is deterministic regardless of TTY:

```bash
WORKOS_MODE=agent workos doctor --json --skip-ai
WORKOS_MODE=agent workos install --api-key ... --client-id ...
```

In agent mode the CLI:

- Never prompts. Missing required arguments fail with structured errors instead of opening prompts.
- Treats browser launch as best-effort. Auth flows always print the manual URL and code.
- Probes host capabilities (home directory, keychain, browser launch). Host failures emit a `HOST_EXECUTION_UNTRUSTED` issue from `workos doctor` so agents can recognize sandboxed runs.
- Requires explicit confirmation flags (e.g. `--yes`, `--force`) for destructive operations.

In `ci` mode the CLI additionally refuses browser-based auth flows and prefers terse failures over recovery handoff text.

Legacy compatibility:

- `WORKOS_NO_PROMPT=1` continues to work and is treated as agent interaction behavior plus JSON output.
- `WORKOS_FORCE_TTY=1` continues to force human **output** mode but does not change interaction mode.
- Non-TTY without an explicit mode still defaults output to JSON and interaction to agent.

### Headless Installer

In non-TTY, the installer streams progress as NDJSON (one JSON object per line):
Expand Down Expand Up @@ -632,8 +667,9 @@ workos install --api-key sk_test_xxx --client-id client_xxx --no-commit 2>/dev/n
| ------------------------ | --------------------------------------------------------- |
| `WORKOS_API_KEY` | API key for management commands (bypasses stored config) |
| `WORKOS_API_BASE_URL` | Override API base URL (set automatically by `workos dev`) |
| `WORKOS_NO_PROMPT=1` | Force non-interactive mode + JSON output |
| `WORKOS_FORCE_TTY=1` | Force interactive mode even when piped |
| `WORKOS_MODE` | Interaction mode: `human`, `agent`, or `ci` |
| `WORKOS_NO_PROMPT=1` | Legacy alias: agent interaction behavior + JSON output |
| `WORKOS_FORCE_TTY=1` | Force human (non-JSON) **output** mode even when piped |
| `WORKOS_TELEMETRY=false` | Disable telemetry |

### Command Discovery
Expand Down
51 changes: 39 additions & 12 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,43 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) {
process.exit(1);
}

import { isNonInteractiveEnvironment } from './utils/environment.js';
import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js';
import {
InvalidInteractionModeError,
isPromptAllowed,
resolveInteractionMode,
setInteractionMode,
} from './utils/interaction-mode.js';
import {
resolveEffectiveOutputMode,
resolveOutputMode,
setOutputMode,
isJsonMode,
outputJson,
exitWithError,
} from './utils/output.js';
import clack from './utils/clack.js';
import { registerSubcommand } from './utils/register-subcommand.js';

// Resolve output mode early from raw argv (before yargs parses)
const rawArgs = hideBin(process.argv);
const hasJsonFlag = rawArgs.includes('--json');
setOutputMode(resolveOutputMode(hasJsonFlag));
const baseOutputMode = resolveOutputMode(hasJsonFlag);
setOutputMode(baseOutputMode);
try {
const interaction = resolveInteractionMode({ argv: rawArgs });
setInteractionMode(interaction);
setOutputMode(resolveEffectiveOutputMode(baseOutputMode, interaction));
} catch (error) {
if (error instanceof InvalidInteractionModeError) {
exitWithError({ code: 'invalid_mode', message: error.message });
}
throw error;
}

// Intercept --help --json before yargs parses (yargs exits on --help)
if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) {
const { buildCommandTree } = await import('./utils/help-json.js');
const commandAliases: Record<string, string> = { org: 'organization' };
const rawCommand = rawArgs.find((a) => !a.startsWith('-'));
const command = rawCommand ? (commandAliases[rawCommand] ?? rawCommand) : undefined;
const { buildCommandTree, extractHelpJsonCommand } = await import('./utils/help-json.js');
const command = extractHelpJsonCommand(rawArgs);
outputJson(buildCommandTree(command));
process.exit(0);
}
Expand Down Expand Up @@ -171,8 +192,8 @@ const installerOptions = {
},
};

// Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean)
if (!isJsonMode()) await checkForUpdates();
// Check for updates (blocks up to 500ms, skip in JSON/non-human modes to keep machine streams clean)
if (!isJsonMode() && isPromptAllowed()) await checkForUpdates();

yargs(rawArgs)
.parserConfiguration({ 'populate--': true })
Expand All @@ -183,6 +204,12 @@ yargs(rawArgs)
describe: 'Output results as JSON (auto-enabled in non-TTY)',
global: true,
})
.option('mode', {
type: 'string',
choices: ['human', 'agent', 'ci'] as const,
describe: 'Interaction mode: human, coding agent, or CI automation',
global: true,
})
.middleware(async (argv) => {
// Warn about unclaimed environments before management commands.
// Excluded: auth/claim/install/dashboard handle their own credential flows;
Expand Down Expand Up @@ -395,7 +422,7 @@ yargs(rawArgs)
'Switch active environment',
(y) => y.positional('name', { type: 'string', describe: 'Environment name' }),
async (argv) => {
if (!argv.name && isNonInteractiveEnvironment()) {
if (!argv.name && !isPromptAllowed()) {
exitWithError({
code: 'missing_args',
message: 'Environment name required. Usage: workos env switch <name>',
Expand Down Expand Up @@ -2370,8 +2397,8 @@ yargs(rawArgs)
'WorkOS AuthKit CLI',
(yargs) => yargs.options(insecureStorageOption),
async (argv) => {
// Non-TTY: show help
if (isNonInteractiveEnvironment()) {
// Non-human modes: show help instead of prompting
if (!isPromptAllowed()) {
yargs(rawArgs).showHelp();
return;
}
Expand Down
98 changes: 83 additions & 15 deletions src/commands/api/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { Catalog } from './catalog.js';
import type { ApiResponse } from './request.js';

Expand Down Expand Up @@ -69,12 +72,8 @@ vi.mock('../../utils/clack.js', () => ({
},
}));

vi.mock('../../utils/environment.js', () => ({
isNonInteractiveEnvironment: vi.fn(() => false),
}));

const { setOutputMode } = await import('../../utils/output.js');
const { isNonInteractiveEnvironment } = await import('../../utils/environment.js');
const { resetInteractionModeForTests, setInteractionMode } = await import('../../utils/interaction-mode.js');
const { runApiInteractive, runApiLs, runApiRequest } = await import('./index.js');

function buildResponse(overrides: Partial<ApiResponse> = {}): ApiResponse {
Expand All @@ -94,6 +93,7 @@ describe('runApiInteractive', () => {

beforeEach(() => {
vi.clearAllMocks();
resetInteractionModeForTests();
consoleOutput = [];
stderrOutput = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
Expand All @@ -112,11 +112,11 @@ describe('runApiInteractive', () => {
setOutputMode('human');
});

it('prints usage instructions when stdin/stdout is non-interactive', async () => {
it('prints usage instructions when interaction mode is agent', async () => {
setOutputMode('human');
vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true);
await runApiInteractive();
expect(consoleOutput.join('\n')).toContain('Interactive mode requires a TTY');
setInteractionMode({ mode: 'agent', source: 'env' });
await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/);
expect(stderrOutput.join('\n')).toContain('Interactive API mode requires human mode');
});

it('emits a structured tty_required error in JSON mode when non-interactive', async () => {
Expand All @@ -138,10 +138,8 @@ describe('runApiInteractive', () => {

it('refuses to enter interactive mode in JSON mode even when a TTY is present', async () => {
setOutputMode('json');
// Default mock returns false (TTY present); JSON mode must short-circuit
// before isNonInteractiveEnvironment() is even called.
// Default interaction mode is human; JSON mode must still short-circuit.
await expect(runApiInteractive()).rejects.toThrow(/__exit__:1/);
expect(isNonInteractiveEnvironment).not.toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalledWith(1);
expect(consoleOutput).toEqual([]);
const errorLine = stderrOutput.find((line) => {
Expand All @@ -161,6 +159,7 @@ describe('runApiLs', () => {

beforeEach(() => {
vi.clearAllMocks();
resetInteractionModeForTests();
consoleOutput = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
consoleOutput.push(args.map(String).join(' '));
Expand Down Expand Up @@ -215,6 +214,7 @@ describe('runApiRequest', () => {

beforeEach(() => {
vi.clearAllMocks();
resetInteractionModeForTests();
consoleOutput = [];
stderrOutput = [];
vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
Expand Down Expand Up @@ -367,14 +367,14 @@ describe('runApiRequest', () => {
expect(mockApiRequest).toHaveBeenCalledWith(expect.objectContaining({ method: 'POST', body: '' }));
});

it('refuses mutating requests without --yes in non-interactive human mode', async () => {
it('refuses mutating requests without --yes in agent mode with human output', async () => {
setOutputMode('human');
vi.mocked(isNonInteractiveEnvironment).mockReturnValueOnce(true);
setInteractionMode({ mode: 'agent', source: 'env' });
await expect(runApiRequest('/organizations', { method: 'POST', data: '{}' })).rejects.toThrow(/__exit__:1/);
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockApiRequest).not.toHaveBeenCalled();
expect(mockConfirm).not.toHaveBeenCalled();
expect(stderrOutput.some((l) => l.includes('Refusing to POST'))).toBe(true);
expect(stderrOutput.some((l) => l.includes('agent mode require --yes'))).toBe(true);
});

it('exits with confirmation_required in JSON mode when a mutating request lacks --yes', async () => {
Expand All @@ -392,6 +392,74 @@ describe('runApiRequest', () => {
}
});
expect(errorLine).toBeDefined();
const parsed = JSON.parse(errorLine!);
expect(parsed.error.recovery.hints[0].command).toBe("workos api /organizations --method POST '--data={}' --yes");
});

it('uses equals-form data flags so leading hyphens are preserved in confirmation recovery commands', async () => {
setOutputMode('json');
await expect(runApiRequest('/organizations', { method: 'POST', data: '-x' })).rejects.toThrow(/__exit__:1/);
const errorLine = stderrOutput.find((line) => {
try {
const parsed = JSON.parse(line) as { error?: { code?: string } };
return parsed.error?.code === 'confirmation_required';
} catch {
return false;
}
});
expect(errorLine).toBeDefined();
const parsed = JSON.parse(errorLine!);
expect(parsed.error.recovery.hints[0].command).toBe('workos api /organizations --method POST --data=-x --yes');
});

it('preserves --file in confirmation recovery commands', async () => {
setOutputMode('json');
const dir = mkdtempSync(join(tmpdir(), 'workos-api-'));
const file = join(dir, 'body.json');
writeFileSync(file, '{"name":"Acme"}');
try {
await expect(runApiRequest('/organizations', { method: 'PATCH', file })).rejects.toThrow(/__exit__:1/);
const errorLine = stderrOutput.find((line) => {
try {
const parsed = JSON.parse(line) as { error?: { code?: string } };
return parsed.error?.code === 'confirmation_required';
} catch {
return false;
}
});
expect(errorLine).toBeDefined();
const parsed = JSON.parse(errorLine!);
expect(parsed.error.recovery.hints[0].command).toBe(
`workos api /organizations --method PATCH --file=${file} --yes`,
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

it('omits confirmation recovery commands for stdin bodies', async () => {
setOutputMode('json');
const stdinBody = (async function* () {
yield Buffer.from('{"name":"Acme"}');
})();
const originalStdin = process.stdin;
Object.defineProperty(process, 'stdin', { value: stdinBody, configurable: true });
try {
await expect(runApiRequest('/organizations', { method: 'POST', file: '-' })).rejects.toThrow(/__exit__:1/);
} finally {
Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
}
const errorLine = stderrOutput.find((line) => {
try {
const parsed = JSON.parse(line) as { error?: { code?: string } };
return parsed.error?.code === 'confirmation_required';
} catch {
return false;
}
});
expect(errorLine).toBeDefined();
const parsed = JSON.parse(errorLine!);
expect(parsed.error.recovery.hints[0].command).toBeUndefined();
});

it('exits with empty_stdin_body when --file - is used and stdin is empty', async () => {
Expand Down
Loading
Loading