feat: non-TTY mode for agent-friendly CLI#75
Conversation
Add foundational layer for non-interactive CLI usage following gh CLI patterns. Agents and CI pipelines can now use the CLI without TTY. - Add output mode system (src/utils/output.ts) with JSON/human modes, auto-detecting non-TTY and supporting --json global flag - Add standardized exit codes (0=success, 1=error, 2=cancelled, 4=auth) - Add WORKOS_NO_PROMPT and WORKOS_FORCE_TTY env var support - Guard ensure-auth.ts to exit code 4 instead of opening browser in non-TTY mode (silent token refresh still works) - Update handleApiError in org/user commands to use structured errors - Add ideation specs for remaining phases (management commands, help, headless installer)
… installer
Phase 2 — Management commands JSON output:
- env/org/user commands produce structured JSON in non-TTY mode
- Non-interactive guards for env add (requires args) and env switch
(requires name)
- Empty list states return valid JSON ({ data: [] })
Phase 3 — Agent-discoverable help:
- --help --json outputs machine-readable command tree with types,
defaults, and positional schemas
- Subcommand scoping (e.g. workos env --help --json)
- Improved all command descriptions for agent readability
Phase 4 — Headless installer with NDJSON streaming:
- HeadlessAdapter auto-resolves all interactive decisions with
sensible defaults
- Streams progress as NDJSON events to stdout
- Flag overrides: --no-branch, --no-commit, --create-pr, --no-git-check
- --api-key and --client-id now visible flags
- Removed non-TTY error block in install command
When the refresh token is expired or invalidated (e.g., after rotation), the old credentials remained in the keyring. This caused a loop where every CLI invocation found dead creds, tried to refresh, failed, and opened the browser for login. Now clearCredentials() is called before triggering login on all failure paths: invalid_grant, network/server errors, no refresh token, and invalid credentials file. The next run sees "no credentials" and prompts a clean login instead of retrying with stale tokens.
- env list --json no longer leaks full API keys; outputs hasApiKey and hasClientId booleans instead - WORKOS_FORCE_TTY now requires explicit '1' or 'true' value, matching WORKOS_NO_PROMPT behavior (setting WORKOS_FORCE_TTY=false no longer incorrectly forces TTY mode)
- Change outputSuccess data param from Record<string, unknown> to object, eliminating as unknown as Record<string, unknown> double casts in org/user commands - Remove redundant section divider comment in headless-adapter
- Extract duplicated handleApiError from organization.ts and user.ts into shared createApiErrorHandler() factory in lib/api-error-handler.ts - Cache hideBin(process.argv) result to avoid double-parsing in bin.ts - Simplify flag inversion mapping in run.ts (remove verbose ternaries) - Lazy-import HeadlessAdapter in run-with-core.ts to avoid loading it in interactive mode
Add headless adapter, output utilities, and help-json to project structure. Document three CLI modes (CLI, Dashboard, Headless) and non-TTY behavior (JSON output, exit codes, env vars). Update new-resource-command guide with output.ts and help-json.ts steps.
Document non-TTY features: JSON output, headless installer with NDJSON streaming, exit codes, environment variables, and --help --json command discovery. Update installer options with new flags (--no-branch, --no-commit, --create-pr, --no-git-check, --api-key, --client-id). Add org alias to command list.
Remove file tree, restated built-ins (ESM/TS/Ink+React), testing section, and verbose examples from CLAUDE.md. Reduces from 157 to 55 lines (~46% token savings). Add docs/ to .gitignore to enforce the existing "never commit docs/" convention via tooling.
There was a problem hiding this comment.
Pull request overview
This PR makes the WorkOS CLI automation/agent-friendly by adding non-TTY detection and machine-readable outputs, while preserving existing interactive behavior for human users.
Changes:
- Introduces an output-mode system (human vs JSON), standardized exit codes, and NDJSON streaming utilities.
- Adds headless installer execution via a new adapter selected by non-interactive environment detection.
- Updates management commands (env/organization/user) to support structured JSON output and structured error handling.
Reviewed changes
Copilot reviewed 34 out of 35 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/types.ts | Extends installer option types for new headless/git/PR flags. |
| src/utils/telemetry-types.ts | Adds headless as a telemetry installer mode. |
| src/utils/output.ts | Adds centralized output-mode helpers for JSON/human output and structured errors. |
| src/utils/output.spec.ts | Adds unit tests for output-mode helpers. |
| src/utils/ndjson.ts | Adds NDJSON writer utility for streaming headless progress events. |
| src/utils/ndjson.spec.ts | Adds unit tests for NDJSON writer behavior. |
| src/utils/help-json.ts | Adds static command/option registry for --help --json machine-readable output. |
| src/utils/help-json.spec.ts | Adds tests for the help JSON command tree output. |
| src/utils/exit-codes.ts | Adds standardized exit code helpers (gh-style). |
| src/utils/exit-codes.spec.ts | Adds tests for standardized exit code helpers. |
| src/utils/environment.ts | Adds env var overrides (WORKOS_NO_PROMPT, WORKOS_FORCE_TTY) to non-interactive detection. |
| src/utils/analytics.ts | Allows analytics session start to record headless mode. |
| src/run.ts | Adds new CLI args and maps them into installer options (validate/commit/branch/git-check). |
| src/lib/run-with-core.ts | Selects HeadlessAdapter in non-interactive environments and reports telemetry mode. |
| src/lib/ensure-auth.ts | Adds non-TTY auth guard (exit code 4) and clears stale credentials on refresh failures. |
| src/lib/ensure-auth.spec.ts | Expands auth tests for non-TTY behavior and credential-clearing paths. |
| src/lib/api-key.ts | Switches missing API key behavior to structured error + exit helper. |
| src/lib/api-key.spec.ts | Updates tests to assert structured exit behavior for missing API key. |
| src/lib/api-error-handler.ts | Adds reusable API error handler that emits structured errors and exits. |
| src/lib/adapters/index.ts | Exposes HeadlessAdapter from adapters barrel. |
| src/lib/adapters/headless-adapter.ts | Implements headless adapter emitting NDJSON progress and auto-resolving prompts. |
| src/lib/adapters/headless-adapter.spec.ts | Adds unit tests for headless adapter event handling and decisions. |
| src/commands/user.ts | Adds JSON output mode for list/get and structured success/error handling. |
| src/commands/user.spec.ts | Adds tests for JSON output mode in user commands. |
| src/commands/organization.ts | Adds JSON output mode for list/get and structured success/error handling; uses shared API error handler. |
| src/commands/organization.spec.ts | Adds tests for JSON output mode in organization commands. |
| src/commands/install.ts | Removes non-interactive hard error block so install can run headless. |
| src/commands/env.ts | Adds JSON output for env list and structured errors; adds non-interactive arg guards for env add. |
| src/commands/env.spec.ts | Adds tests for JSON output mode in env commands. |
| src/bin.ts | Resolves output mode early, adds --help --json interception, exposes installer flags, adds org alias. |
| package.json | Splits formatter into format and format:check for CI. |
| README.md | Documents non-TTY behavior, JSON/NDJSON usage, exit codes, and new flags. |
| CLAUDE.md | Updates contributor docs to reflect new architecture and help-json registry requirement. |
| .gitignore | Adds docs/ ignore entry (local artifacts). |
| .github/workflows/ci.yml | Switches CI format step to pnpm format:check. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -28,16 +27,6 @@ export async function handleInstall(argv: ArgumentsCamelCase<InstallerArgs>): Pr | |||
| clack.log.error('CI mode requires --install-dir (directory to install WorkOS AuthKit in)'); | |||
| process.exit(1); | |||
| } | |||
| } else if (isNonInteractiveEnvironment()) { | |||
| clack.intro(chalk.inverse('WorkOS AuthKit Installer')); | |||
| clack.log.error( | |||
| 'This installer requires an interactive terminal (TTY) to run.\n' + | |||
| 'It appears you are running in a non-interactive environment.\n' + | |||
| 'Please run the installer in an interactive terminal.\n\n' + | |||
| 'For CI/CD environments, use --ci mode:\n' + | |||
| ' workos install --ci --api-key sk_xxx --client-id client_xxx', | |||
| ); | |||
| process.exit(1); | |||
| } | |||
There was a problem hiding this comment.
Now that non-TTY execution routes into headless mode (the previous non-interactive hard-error block was removed), the install command’s error path needs to avoid writing clack/human logs to stdout/stderr that could corrupt the NDJSON stream. Ensure failures in non-interactive/JSON mode emit structured output (NDJSON/JSON error) and exit with the standardized code, rather than using clack output intended for humans.
src/utils/help-json.ts
Outdated
| name: 'no-validate', | ||
| type: 'boolean', | ||
| description: 'Skip post-installation validation (includes build check)', | ||
| required: false, | ||
| default: false, | ||
| hidden: false, | ||
| }, | ||
| { |
There was a problem hiding this comment.
help-json registry for the install command is out of sync with bin.ts: it still lists no-validate instead of the new validate option (default true / --no-validate negation) and it omits the new branch, commit, create-pr, and git-check options. This will make --help --json lie to agents/scripts; update the registry so it mirrors the actual yargs option names, defaults, and descriptions.
| name: 'no-validate', | |
| type: 'boolean', | |
| description: 'Skip post-installation validation (includes build check)', | |
| required: false, | |
| default: false, | |
| hidden: false, | |
| }, | |
| { | |
| name: 'validate', | |
| type: 'boolean', | |
| description: 'Run post-installation validation (includes build check)', | |
| required: false, | |
| default: true, | |
| hidden: false, | |
| }, | |
| { | |
| name: 'branch', | |
| type: 'string', | |
| description: 'Git branch name to use for installation changes (defaults to current branch)', | |
| required: false, | |
| hidden: false, | |
| }, | |
| { | |
| name: 'commit', | |
| type: 'string', | |
| description: 'Git commit message to use when recording installation changes', | |
| required: false, | |
| hidden: false, | |
| }, | |
| { | |
| name: 'create-pr', | |
| type: 'boolean', | |
| description: 'Create a pull request for installation changes when possible', | |
| required: false, | |
| default: false, | |
| hidden: false, | |
| }, | |
| { | |
| name: 'git-check', | |
| type: 'boolean', | |
| description: 'Verify a clean git working tree before applying installation changes', | |
| required: false, | |
| default: true, | |
| hidden: false, | |
| }, | |
| { |
| }, | ||
| ], | ||
| }, | ||
| { |
There was a problem hiding this comment.
--help --json subcommand scoping doesn’t recognize the org alias (only organization exists in this registry). As a result, workos org --help --json will return the full tree instead of the org subtree. Consider either adding an explicit org entry/alias mapping here, or normalizing aliases in buildCommandTree().
| { | |
| { | |
| name: 'org', | |
| description: 'Alias of the `organization` command (manage WorkOS organizations)', | |
| options: [insecureStorageOpt, apiKeyOpt], | |
| }, | |
| { |
| if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { | ||
| const { buildCommandTree } = await import('./utils/help-json.js'); | ||
| const command = rawArgs.find((a) => !a.startsWith('-')); | ||
| outputJson(buildCommandTree(command)); |
There was a problem hiding this comment.
The --help --json interception picks the subtree key via rawArgs.find((a) => !a.startsWith('-')), which will return org for workos org --help --json. Since buildCommandTree() only matches organization, this falls back to the full tree. Normalize known aliases (e.g. map org→organization) or pass the resolved canonical command name so subtree output works for aliases.
| outputJson(buildCommandTree(command)); | |
| // Normalize known aliases so help-json subtree works for aliased commands | |
| const aliasMap: Record<string, string> = { | |
| org: 'organization', | |
| }; | |
| const normalizedCommand = | |
| command && Object.prototype.hasOwnProperty.call(aliasMap, command) | |
| ? aliasMap[command] | |
| : command; | |
| outputJson(buildCommandTree(normalizedCommand)); |
| let adapter: InstallerAdapter; | ||
| if (isNonInteractiveEnvironment()) { | ||
| const { HeadlessAdapter } = await import('./adapters/headless-adapter.js'); | ||
| adapter = new HeadlessAdapter({ | ||
| emitter, | ||
| sendEvent, | ||
| debug: augmentedOptions.debug, | ||
| options: { | ||
| apiKey: augmentedOptions.apiKey, | ||
| clientId: augmentedOptions.clientId, | ||
| noBranch: augmentedOptions.noBranch, | ||
| noCommit: augmentedOptions.noCommit, | ||
| createPr: augmentedOptions.createPr, | ||
| noGitCheck: augmentedOptions.noGitCheck, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
noGitCheck is plumbed into HeadlessAdapter options, but the installer state machine always runs the gitCheck parallel state and checkGitStatus actor unconditionally (it doesn’t consult augmentedOptions.noGitCheck). That means --no-git-check / git-check=false currently won’t actually skip the dirty-tree check (and headless mode will still get git:dirty). Wire noGitCheck into checkGitStatus (return clean immediately) or add a guard to bypass the gitCheck state when it’s set.
src/lib/adapters/headless-adapter.ts
Outdated
| writeNDJSON({ type: 'git:status', dirty: true, files }); | ||
| writeNDJSON({ type: 'git:decision', action: 'continue' }); | ||
| this.sendEvent({ type: 'GIT_CONFIRMED' }); |
There was a problem hiding this comment.
In headless mode, git:dirty is always auto-confirmed (GIT_CONFIRMED). This is risky for CI/agent runs because it will proceed on a dirty working tree without an explicit opt-in, and it also makes the intended --no-git-check behavior moot when the git check still runs. Consider defaulting to GIT_CANCELLED (fail fast) unless the user explicitly disabled the check / confirmed via a flag, and include an NDJSON error explaining how to proceed.
| writeNDJSON({ type: 'git:status', dirty: true, files }); | |
| writeNDJSON({ type: 'git:decision', action: 'continue' }); | |
| this.sendEvent({ type: 'GIT_CONFIRMED' }); | |
| // Always report git status so consumers can see the dirty files. | |
| writeNDJSON({ type: 'git:status', dirty: true, files }); | |
| // In headless mode, fail fast by default on a dirty working tree to avoid | |
| // accidentally proceeding in CI/agent environments. Allow opt-out via | |
| // the explicit --no-git-check flag (this.options.noGitCheck). | |
| if (this.options.noGitCheck) { | |
| writeNDJSON({ type: 'git:decision', action: 'continue' }); | |
| this.sendEvent({ type: 'GIT_CONFIRMED' }); | |
| return; | |
| } | |
| writeNDJSON({ | |
| type: 'error', | |
| code: 'git_dirty', | |
| message: | |
| 'Git working tree is dirty in non-interactive mode. Commit or stash your changes, ' + | |
| 'or rerun with --no-git-check to proceed on a dirty working tree.', | |
| }); | |
| writeNDJSON({ type: 'git:decision', action: 'cancel' }); | |
| this.sendEvent({ type: 'GIT_CANCELLED' }); | |
| process.exit(ExitCode.GENERAL_ERROR); |
| export function outputSuccess(message: string, data?: object): void { | ||
| if (currentMode === 'json') { | ||
| console.log(JSON.stringify({ status: 'ok', message, ...data })); | ||
| } else { | ||
| console.log(chalk.green(message)); | ||
| if (data) { | ||
| console.log(JSON.stringify(data, null, 2)); | ||
| } |
There was a problem hiding this comment.
outputSuccess() builds the JSON object as { status: 'ok', message, ...data }, which allows data to silently override status/message if it contains those keys (and it flattens resource payload fields into the top-level response). To keep the success schema stable and avoid collisions, consider nesting payload under a data property or otherwise preventing reserved-key overrides.
1. install.ts: Use structured errors in non-TTY instead of clack output that could corrupt NDJSON stream 2. help-json.ts: Sync registry — rename no-validate to validate, add branch/commit/create-pr/git-check flags 3. help-json.ts: org alias now handled via alias normalization 4. bin.ts: Normalize org→organization in --help --json interception so subtree scoping works for aliases 5. run-with-core.ts: (addressed via adapter change) --no-git-check now respected by headless adapter 6. headless-adapter.ts: Fail fast on dirty git by default in headless mode — requires explicit --no-git-check to proceed 7. output.ts: Nest outputSuccess data under `data` key to prevent key collisions with status/message fields
Summary
The WorkOS CLI currently assumes a human at a terminal — 26+ interactive prompts (via clack), browser-based OAuth, and chalk-formatted tables make it unusable by coding agents like Claude Code, Codex, or Cursor. Agents must either avoid the CLI entirely or rely on users to copy-paste credentials and command output, defeating the purpose of automation.
This PR adds comprehensive non-TTY support following the
ghCLI model: automatic non-TTY detection, structured JSON output, standardized exit codes, and a headless installer adapter. Zero breaking changes for humans — all existing interactive behavior is preserved.What changed
Core infrastructure
src/utils/output.ts) —OutputModeresolved once at startup. Auto-detects non-TTY → JSON. Global--jsonflag for explicit control. Shared helpers:outputJson(),outputSuccess(),outputError(),outputTable(),exitWithError()src/utils/exit-codes.ts) — Standardized following gh convention: 0=success, 1=general error, 2=cancelled, 4=auth requiredWORKOS_NO_PROMPT=1forces non-interactive mode (suppresses all prompts + switches to JSON).WORKOS_FORCE_TTY=1forces interactive mode even when pipedManagement commands (env, organization, user)
--jsonflagenv add(requires name + apiKey args) andenv switch(requires name arg){ "data": [...] }with proper empty statesenv listJSON output (onlyhasApiKeyboolean exposed)orgalias fororganizationcommandAgent-discoverable help
--help --jsonoutputs machine-readable command tree (commands, options, positionals, types, defaults)workos env --help --jsonreturns only the env subtreeHeadless installer
src/lib/adapters/headless-adapter.ts) — third adapter alongside CLI and Dashboard. Subscribes to same state machine events, auto-resolves all interactive decisions with sensible defaultssrc/utils/ndjson.ts) — progress events streamed as newline-delimited JSON to stdout (detection, auth, git status, agent progress, validation, completion)--no-branch,--no-commit,--create-pr,--no-git-check--api-keyand--client-idnow visible flags (were hidden)Auth improvements
ensureAuthenticated()exits code 4 instead of opening browser when no TTYWORKOS_API_KEYenv var respected for management commands (bypasses OAuth)resolveApiKey()uses structured error output instead of throwing raw errorsclearCredentials()called on all refresh failure paths (invalid_grant, network errors, no refresh token). Prevents the loop where expired tokens cause repeated browser opensFlag naming fix
--no-validateoption tovalidate(default: true) so yargs--no-validatenegation works correctly with strict mode. Same for--no-branch→branch,--no-commit→commit,--no-git-check→git-check. The--no-*flags still work as before from user perspective.Environment variables
WORKOS_NO_PROMPT=1WORKOS_FORCE_TTY=1WORKOS_API_KEYExit codes