Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: non-TTY mode for agent-friendly CLI#75

Merged
nicknisi merged 17 commits intomainfrom
nicknisi/non-tty
Mar 2, 2026
Merged

feat: non-TTY mode for agent-friendly CLI#75
nicknisi merged 17 commits intomainfrom
nicknisi/non-tty

Conversation

@nicknisi
Copy link
Member

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 gh CLI 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

  • Output mode system (src/utils/output.ts) — OutputMode resolved once at startup. Auto-detects non-TTY → JSON. Global --json flag for explicit control. Shared helpers: outputJson(), outputSuccess(), outputError(), outputTable(), exitWithError()
  • Exit codes (src/utils/exit-codes.ts) — Standardized following gh convention: 0=success, 1=general error, 2=cancelled, 4=auth required
  • Environment variablesWORKOS_NO_PROMPT=1 forces non-interactive mode (suppresses all prompts + switches to JSON). WORKOS_FORCE_TTY=1 forces interactive mode even when piped

Management commands (env, organization, user)

  • All commands produce structured JSON when piped or with --json flag
  • Non-interactive guards for env add (requires name + apiKey args) and env switch (requires name arg)
  • List commands return { "data": [...] } with proper empty states
  • Structured JSON errors to stderr with error codes
  • API keys redacted in env list JSON output (only hasApiKey boolean exposed)
  • Added org alias for organization command

Agent-discoverable help

  • --help --json outputs machine-readable command tree (commands, options, positionals, types, defaults)
  • Subcommand scoping: workos env --help --json returns only the env subtree
  • Improved all command descriptions for agent readability

Headless installer

  • HeadlessAdapter (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 defaults
  • NDJSON streaming (src/utils/ndjson.ts) — progress events streamed as newline-delimited JSON to stdout (detection, auth, git status, agent progress, validation, completion)
  • Flag overrides: --no-branch, --no-commit, --create-pr, --no-git-check
  • --api-key and --client-id now visible flags (were hidden)
  • Removed non-TTY error block in install command — routes to headless adapter instead

Auth improvements

  • Non-TTY auth guard: ensureAuthenticated() exits code 4 instead of opening browser when no TTY
  • WORKOS_API_KEY env var respected for management commands (bypasses OAuth)
  • resolveApiKey() uses structured error output instead of throwing raw errors
  • Stale credential clearing: clearCredentials() called on all refresh failure paths (invalid_grant, network errors, no refresh token). Prevents the loop where expired tokens cause repeated browser opens

Flag naming fix

  • Renamed --no-validate option to validate (default: true) so yargs --no-validate negation works correctly with strict mode. Same for --no-branchbranch, --no-commitcommit, --no-git-checkgit-check. The --no-* flags still work as before from user perspective.

Environment variables

Variable Effect
WORKOS_NO_PROMPT=1 Force non-interactive mode + JSON output
WORKOS_FORCE_TTY=1 Force interactive mode even when piped
WORKOS_API_KEY API key for management commands (bypasses OAuth)

Exit codes

Code Meaning
0 Success
1 General error
2 Cancelled
4 Authentication required

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
@nicknisi
Copy link
Member Author

nicknisi commented Mar 2, 2026

@greptileai

nicknisi added 4 commits March 2, 2026 08:51
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.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 1 to 30
@@ -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);
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

Comment on lines +388 to +395
name: 'no-validate',
type: 'boolean',
description: 'Skip post-installation validation (includes build check)',
required: false,
default: false,
hidden: false,
},
{
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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,
},
{

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

},
],
},
{
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--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().

Suggested change
{
{
name: 'org',
description: 'Alias of the `organization` command (manage WorkOS organizations)',
options: [insecureStorageOpt, apiKeyOpt],
},
{

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

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));
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 orgorganization) or pass the resolved canonical command name so subtree output works for aliases.

Suggested change
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));

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

Comment on lines +197 to +212
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,
},
});
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

Comment on lines +150 to +152
writeNDJSON({ type: 'git:status', dirty: true, files });
writeNDJSON({ type: 'git:decision', action: 'continue' });
this.sendEvent({ type: 'GIT_CONFIRMED' });
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

Comment on lines +54 to +61
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));
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4be6daf.

nicknisi added 2 commits March 2, 2026 12:57
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
@nicknisi nicknisi merged commit df09d1e into main Mar 2, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants