Skip to content
Open
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
4 changes: 2 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ Guidelines for new code:
- 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:
Mode resolution — do not regress these:

- `WORKOS_NO_PROMPT=1` keeps mapping to agent interaction behavior **and** JSON output (legacy alias).
- `WORKOS_MODE=agent` maps to agent interaction behavior **and** JSON output (via `resolveEffectiveOutputMode`). The old `WORKOS_NO_PROMPT` alias has been removed.
- `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.
Expand Down
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,10 +663,10 @@ In agent mode the CLI:

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

Legacy compatibility:
Mode resolution notes:

- `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.
- `WORKOS_MODE=agent` sets agent interaction behavior and forces JSON output. (This replaces the removed `WORKOS_NO_PROMPT` alias.)
- `WORKOS_FORCE_TTY=1` forces 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
Expand Down Expand Up @@ -697,7 +697,6 @@ 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_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 |

Expand Down
8 changes: 5 additions & 3 deletions src/bin-command-telemetry.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,11 @@ function runCli(args: string[], envOverrides: NodeJS.ProcessEnv = {}) {
// silently produce no event and fail. Tests that exercise env precedence
// override this explicitly via envOverrides.
WORKOS_TELEMETRY: 'true',
// Unroutable URL: the flush fails, so the queued events are persisted to
// the pending file on exit where we can inspect the real payload.
WORKOS_TELEMETRY_URL: 'http://127.0.0.1:59999/cli',
// Unroutable API base: telemetry derives ${WORKOS_API_URL}/cli, so the
// flush fails and the queued events are persisted to the pending file on
// exit where we can inspect the real payload. (The tested commands fail
// validation / crash before any real API call, so this host is never hit.)
WORKOS_API_URL: 'http://127.0.0.1:59999',
WORKOS_API_KEY: 'sk_dummy_for_test',
...envOverrides,
};
Expand Down
2 changes: 1 addition & 1 deletion src/bin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env node

// Load .env.local for local development when --local flag is used
if (process.argv.includes('--local') || process.env.INSTALLER_DEV) {
if (process.argv.includes('--local') || process.env.WORKOS_DEV) {
const { config } = await import('dotenv');
// bin.ts compiles to dist/bin.js, so go up one level to find .env.local
const { fileURLToPath } = await import('node:url');
Expand Down
6 changes: 3 additions & 3 deletions src/cli.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ export const config = {
model: 'claude-opus-4-5-20251101',
doctorModel: 'claude-haiku-4-5-20251001',

// Production defaults - override via env vars for local dev
// Production defaults - override via env vars for local dev.
// The LLM gateway and CLI telemetry endpoints live under the WorkOS API
// host, so they're derived from WORKOS_API_URL rather than configured here.
workos: {
clientId: 'client_01KFKHSZWK9ADVJV854PDFQCCR',
authkitDomain: 'https://signin.workos.com',
llmGatewayUrl: 'https://api.workos.com/llm-gateway',
telemetryUrl: 'https://api.workos.com/cli',
},

telemetry: {
Expand Down
12 changes: 6 additions & 6 deletions src/commands/debug.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,16 +593,16 @@ describe('debug commands', () => {

it('outputs valid JSON in json mode', async () => {
jsonMode = true;
process.env.WORKOS_NO_PROMPT = '1';
process.env.WORKOS_DEBUG = '1';

await runDebugEnv();

const parsed = JSON.parse(consoleOutput[0]);
expect(parsed.variables.WORKOS_NO_PROMPT.value).toBe('1');
expect(parsed.set).toContain('WORKOS_NO_PROMPT');
expect(parsed.unset).not.toContain('WORKOS_NO_PROMPT');
expect(parsed.variables.WORKOS_DEBUG.value).toBe('1');
expect(parsed.set).toContain('WORKOS_DEBUG');
expect(parsed.unset).not.toContain('WORKOS_DEBUG');

delete process.env.WORKOS_NO_PROMPT;
delete process.env.WORKOS_DEBUG;
});

it('lists all known env vars', async () => {
Expand All @@ -614,7 +614,7 @@ describe('debug commands', () => {
expect(Object.keys(parsed.variables)).toContain('WORKOS_API_KEY');
expect(Object.keys(parsed.variables)).toContain('WORKOS_FORCE_TTY');
expect(Object.keys(parsed.variables)).toContain('WORKOS_TELEMETRY');
expect(Object.keys(parsed.variables)).toContain('INSTALLER_DEV');
expect(Object.keys(parsed.variables)).toContain('WORKOS_DEV');
});
});
});
30 changes: 22 additions & 8 deletions src/commands/debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,20 +371,34 @@ interface EnvVarInfo {
effect: string;
}

const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
/**
* Catalog of WORKOS_-prefixed environment variables the CLI reads.
*
* This is the single source of truth for `workos debug env`. A unit test
* (env-var-catalog.spec.ts) scans the source for `process.env.WORKOS_*` reads
* and fails if any are missing here, so this list can't silently drift.
*/
export const ENV_VAR_CATALOG: { name: string; effect: string }[] = [
// Credentials
{ name: 'WORKOS_API_KEY', effect: 'Bypasses credential resolution — used directly for API calls' },
{ name: 'WORKOS_SECRET_KEY', effect: 'API secret key credential, used for authenticated API calls' },
{ name: 'WORKOS_CLIENT_ID', effect: 'WorkOS client ID used during credential resolution' },
// Interaction & output
{ name: 'WORKOS_MODE', effect: 'Controls interaction behavior: human, agent, or CI' },
{ name: 'WORKOS_AGENT', effect: 'Set to "1" to force agent interaction mode' },
{ name: 'WORKOS_FORCE_TTY', effect: 'Forces human (non-JSON) output mode, even when piped' },
{ name: 'WORKOS_NO_PROMPT', effect: 'Legacy compatibility alias for agent interaction behavior and JSON output' },
{ name: 'WORKOS_DEBUG', effect: 'Set to "1" to enable verbose debug logging for all commands' },
{ name: 'WORKOS_TELEMETRY', effect: 'Set to "false" to disable telemetry' },
{ name: 'WORKOS_API_URL', effect: 'Overrides API base URL (default: https://api.workos.com)' },
// URLs (WORKOS_API_URL is the single base; gateway + telemetry derive from it)
{ name: 'WORKOS_API_URL', effect: 'Overrides API base URL; also reroutes the LLM gateway and CLI telemetry endpoints' },
{ name: 'WORKOS_DASHBOARD_URL', effect: 'Overrides dashboard URL (default: https://dashboard.workos.com)' },
{ name: 'WORKOS_AUTHKIT_DOMAIN', effect: 'Overrides AuthKit domain from settings' },
{ name: 'WORKOS_LLM_GATEWAY_URL', effect: 'Overrides LLM gateway URL from settings' },
{ name: 'WORKOS_TELEMETRY_URL', effect: 'Overrides CLI telemetry URL from settings' },
{ name: 'INSTALLER_DEV', effect: 'Enables dev mode — loads .env.local at startup' },
{ name: 'INSTALLER_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' },
{ name: 'WORKOS_BASE_URL', effect: 'AuthKit base URL, read during doctor environment checks' },
{ name: 'WORKOS_REDIRECT_URI', effect: 'OAuth redirect URI, read during doctor environment checks' },
{ name: 'WORKOS_COOKIE_DOMAIN', effect: 'Session cookie domain, read during doctor environment checks' },
// Development
{ name: 'WORKOS_DEV', effect: 'Enables dev mode — loads .env.local at startup' },
{ name: 'WORKOS_DISABLE_PROXY', effect: 'Disables the credential proxy for gateway auth' },
];

export async function runDebugEnv(): Promise<void> {
Expand Down
62 changes: 62 additions & 0 deletions src/commands/env-var-catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { readdir, readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { ENV_VAR_CATALOG } from './debug.js';

// Resolve the src/ root from this file's location (src/commands/*.spec.ts).
const SRC_DIR = fileURLToPath(new URL('..', import.meta.url));

// Matches dot-access env reads: `process.env.WORKOS_X` and the destructured
// `env.WORKOS_X` form (e.g. interaction-mode.ts), while the lookbehind excludes
// identifiers like `projectEnv.WORKOS_X` / `sdkEnv.WORKOS_X`.
//
// Coverage is intentionally limited to dot access — it does NOT catch bracket
// access (`process.env['WORKOS_X']`) or object destructuring
// (`const { WORKOS_X } = process.env`). Those forms aren't used today; if one is
// introduced, add the var to the catalog manually. This guard exists to catch
// the common case, not to be exhaustive.
const ENV_READ_PATTERN = /(?:process\.env|(?<![\w$])env)\.(WORKOS_[A-Z0-9_]+)/g;
Comment thread
greptile-apps[bot] marked this conversation as resolved.

async function collectTsFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) return collectTsFiles(fullPath);
if (!entry.name.endsWith('.ts')) return [];
if (entry.name.endsWith('.spec.ts') || entry.name.endsWith('.d.ts')) return [];
return [fullPath];
}),
);
return files.flat();
}

describe('WORKOS_ env var catalog (debug env)', () => {
it('documents every WORKOS_-prefixed env var read via dot access', async () => {
const files = await collectTsFiles(SRC_DIR);
const discovered = new Set<string>();

for (const file of files) {
const contents = await readFile(file, 'utf-8');
for (const match of contents.matchAll(ENV_READ_PATTERN)) {
discovered.add(match[1]);
}
}

const cataloged = new Set(ENV_VAR_CATALOG.map((v) => v.name));
const missing = [...discovered].filter((name) => !cataloged.has(name)).sort();

expect(
missing,
`These WORKOS_ env vars are read in src/ but missing from ENV_VAR_CATALOG in debug.ts: ${missing.join(', ')}`,
).toEqual([]);
});

it('has no duplicate or stale entries', () => {
const names = ENV_VAR_CATALOG.map((v) => v.name);
expect(new Set(names).size).toBe(names.length);
// Every cataloged var must use the WORKOS_ prefix (no INSTALLER_* drift).
expect(names.every((n) => n.startsWith('WORKOS_'))).toBe(true);
});
Comment thread
greptile-apps[bot] marked this conversation as resolved.
});
3 changes: 2 additions & 1 deletion src/doctor/checks/ai-analysis.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Anthropic from '@anthropic-ai/sdk';
import { getLlmGatewayUrl, getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js';
import { getAuthkitDomain, getCliAuthClientId, getConfig } from '../../lib/settings.js';
import { getLlmGatewayUrl } from '../../utils/urls.js';
import { getCredentials, isTokenExpired, updateTokens, diagnoseCredentials } from '../../lib/credentials.js';
import { refreshAccessToken } from '../../lib/token-refresh-client.js';
import { buildDoctorPrompt, type AnalysisContext } from '../agent-prompt.js';
Expand Down
1 change: 0 additions & 1 deletion src/doctor/output.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ describe('doctor output', () => {
it('formats interaction mode sources for human output', () => {
expect(formatInteractionModeSource('flag')).toBe('--mode');
expect(formatInteractionModeSource('env')).toBe('WORKOS_MODE');
expect(formatInteractionModeSource('workos_no_prompt')).toBe('WORKOS_NO_PROMPT');
expect(formatInteractionModeSource('ci_env')).toBe('CI environment');
expect(formatInteractionModeSource('agent_env')).toBe('agent environment');
expect(formatInteractionModeSource('non_tty')).toBe('non-TTY');
Expand Down
2 changes: 0 additions & 2 deletions src/doctor/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,8 +267,6 @@ export function formatInteractionModeSource(source: InteractionModeSource): stri
return '--mode';
case 'env':
return 'WORKOS_MODE';
case 'workos_no_prompt':
return 'WORKOS_NO_PROMPT';
case 'ci_env':
return 'CI environment';
case 'agent_env':
Expand Down
10 changes: 4 additions & 6 deletions src/lib/agent-interface.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ const { mockQuery, mockConfig } = vi.hoisted(() => ({
workos: {
clientId: 'client_test',
authkitDomain: 'test.workos.com',
llmGatewayUrl: 'http://localhost:8000',
telemetryUrl: 'http://localhost:8000/cli',
},
telemetry: { enabled: false, eventName: 'test_event' },
proxy: { refreshThresholdMs: 300000 },
Expand Down Expand Up @@ -55,6 +53,10 @@ vi.mock('./settings.js', () => ({
getCliAuthClientId: vi.fn(() => 'client_test'),
}));

vi.mock('../utils/urls.js', () => ({
getLlmGatewayUrl: vi.fn(() => 'http://localhost:8000'),
}));

vi.mock('./credentials.js', () => ({
hasCredentials: vi.fn(() => false),
getCredentials: vi.fn(() => null),
Expand All @@ -74,10 +76,6 @@ vi.mock('./config-store.js', () => ({
isUnclaimedEnvironment: vi.fn(() => false),
}));

vi.mock('../utils/urls.js', () => ({
getLlmGatewayUrlFromHost: vi.fn(() => 'http://localhost:8000'),
}));

import { runAgent, AgentErrorType, initializeAgent, type AgentConfig } from './agent-interface.js';
import { startCredentialProxy, startClaimTokenProxy } from './credential-proxy.js';
import { getActiveEnvironment, isUnclaimedEnvironment } from './config-store.js';
Expand Down
8 changes: 4 additions & 4 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import type { InstallerOptions } from '../utils/types.js';
import { analytics } from '../utils/analytics.js';
import { INSTALLER_INTERACTION_EVENT_NAME } from './constants.js';
import { LINTING_TOOLS } from './safe-tools.js';
import { getLlmGatewayUrlFromHost } from '../utils/urls.js';
import { formatWorkOSCommand } from '../utils/command-invocation.js';
import { getConfig } from './settings.js';
import { getLlmGatewayUrl } from '../utils/urls.js';
import { getCredentials, hasCredentials } from './credentials.js';
import { ensureValidToken } from './token-refresh.js';
import type { InstallerEventEmitter } from './events.js';
Expand Down Expand Up @@ -375,7 +375,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
analytics.setTag('api_mode', 'direct');
} else {
// Gateway mode (existing behavior)
const gatewayUrl = getLlmGatewayUrlFromHost();
const gatewayUrl = getLlmGatewayUrl();

// Check for unclaimed environment — use claim token auth
const activeEnv = getActiveEnvironment();
Expand Down Expand Up @@ -405,7 +405,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
}

// Check if we have refresh token capability and proxy is not disabled
if (creds.refreshToken && process.env.INSTALLER_DISABLE_PROXY !== '1') {
if (creds.refreshToken && process.env.WORKOS_DISABLE_PROXY !== '1') {
// Start credential proxy with lazy refresh
logInfo('[agent-interface] Starting credential proxy with lazy refresh...');
const appConfig = getConfig();
Expand Down Expand Up @@ -447,7 +447,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
message: `Note: Run \`${formatWorkOSCommand('auth login')}\` to enable extended sessions`,
});
} else {
logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY');
logWarn('[agent-interface] Proxy disabled via WORKOS_DISABLE_PROXY');
}

const refreshResult = await ensureValidToken();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/ai-content.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Anthropic from '@anthropic-ai/sdk';
import { startCredentialProxy } from './credential-proxy.js';
import { getLlmGatewayUrl, getAuthkitDomain, getCliAuthClientId, getConfig } from './settings.js';
import { getAuthkitDomain, getCliAuthClientId, getConfig } from './settings.js';
import { getLlmGatewayUrl } from '../utils/urls.js';
import { getCredentials } from './credentials.js';
import { logInfo, logError } from '../utils/debug.js';

Expand Down
3 changes: 2 additions & 1 deletion src/lib/run-with-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import { getConfig, saveConfig, getActiveEnvironment, isUnclaimedEnvironment } f
import { checkForEnvFiles, discoverCredentials } from './credential-discovery.js';
import { requestDeviceCode, pollForToken } from './device-auth.js';
import { fetchStagingCredentials as fetchStagingCredentialsApi } from './staging-api.js';
import { getCliAuthClientId, getAuthkitDomain, getTelemetryUrl } from './settings.js';
import { getCliAuthClientId, getAuthkitDomain } from './settings.js';
import { getTelemetryUrl } from '../utils/urls.js';
import { analytics } from '../utils/analytics.js';
import { getVersion } from './settings.js';
import { isInGitRepo, getUncommittedOrUntrackedFiles } from '../utils/clack-utils.js';
Expand Down
25 changes: 5 additions & 20 deletions src/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ export interface InstallerConfig {
workos: {
clientId: string;
authkitDomain: string;
llmGatewayUrl: string;
telemetryUrl: string;
};
telemetry: {
enabled: boolean;
Expand Down Expand Up @@ -57,8 +55,7 @@ export function getConfig(): InstallerConfig {
}

/**
* Get the CLI auth client ID.
* Env var overrides config default.
* Get the CLI auth client ID (from config; not env-overridable).
*/
export function getCliAuthClientId(): string {
return config.workos.clientId;
Expand All @@ -67,23 +64,11 @@ export function getCliAuthClientId(): string {
/**
* Get the AuthKit domain.
* Env var overrides config default.
*
* Note: WorkOS service endpoints (API, dashboard, LLM gateway, telemetry)
* live in utils/urls.ts. AuthKit's domain stays here because it's config-
* backed rather than derived from the API host.
*/
export function getAuthkitDomain(): string {
return process.env.WORKOS_AUTHKIT_DOMAIN || config.workos.authkitDomain;
}

/**
* Get the LLM gateway URL.
* Env var overrides config default.
*/
export function getLlmGatewayUrl(): string {
return process.env.WORKOS_LLM_GATEWAY_URL || config.workos.llmGatewayUrl;
}

/**
* Get the CLI telemetry URL.
* Env var overrides config default.
*/
export function getTelemetryUrl(): string {
return process.env.WORKOS_TELEMETRY_URL || config.workos.telemetryUrl;
}
Loading
Loading