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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
## Non-TTY Behavior

- **Output**: Auto-switches to JSON when piped or `--json` flag. `WORKOS_FORCE_TTY=1` overrides.
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos login` or `WORKOS_API_KEY` env var.
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var.
- **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }`
- **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention)
- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`
Expand Down
5 changes: 3 additions & 2 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ src/
│ ├── user.ts # workos user (get/list/update/delete)
│ ├── install.ts # workos install
│ ├── install-skill.ts # workos install-skill
│ ├── login.ts # workos login
│ └── logout.ts # workos logout
│ ├── auth-status.ts # workos auth status
│ ├── login.ts # workos auth login
│ └── logout.ts # workos auth logout
├── dashboard/ # Ink/React TUI components
├── nextjs/ # Next.js installer agent
├── react/ # React SPA installer agent
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,13 @@ The CLI uses WorkOS Connect OAuth device flow for authentication:

```bash
# Login (opens browser for authentication)
workos login
workos auth login

# Check current auth status
workos auth status

# Logout (clears stored credentials)
workos logout
workos auth logout
```

OAuth credentials are stored in the system keychain (with `~/.workos/credentials.json` fallback). Access tokens are not persisted long-term for security - users re-authenticate when tokens expire.
Expand Down
2 changes: 1 addition & 1 deletion skills/workos-management/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Manage WorkOS resources (orgs, users, roles, SSO, directories, webh

# WorkOS Management Commands

Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos login` or `WORKOS_API_KEY` env var.
Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos auth login` or `WORKOS_API_KEY` env var.

All commands support `--json` for structured output. Use `--json` when you need to parse output (e.g., extract an ID).

Expand Down
53 changes: 40 additions & 13 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) {
}

import { isNonInteractiveEnvironment } from './utils/environment.js';
import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js';
import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js';
import clack from './utils/clack.js';
import { registerSubcommand } from './utils/register-subcommand.js';

Expand Down Expand Up @@ -177,8 +177,8 @@ const installerOptions = {
},
};

// Check for updates (blocks up to 500ms)
await checkForUpdates();
// Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean)
if (!isJsonMode()) await checkForUpdates();

yargs(rawArgs)
.env('WORKOS_INSTALLER')
Expand All @@ -188,16 +188,43 @@ yargs(rawArgs)
describe: 'Output results as JSON (auto-enabled in non-TTY)',
global: true,
})
.command('login', 'Authenticate with WorkOS via browser-based OAuth', insecureStorageOption, async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runLogin } = await import('./commands/login.js');
await runLogin();
process.exit(0);
})
.command('logout', 'Remove stored WorkOS credentials and tokens', insecureStorageOption, async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runLogout } = await import('./commands/logout.js');
await runLogout();
.command('auth', 'Manage authentication (login, logout, status)', (yargs) => {
yargs.options(insecureStorageOption);
registerSubcommand(
yargs,
'login',
'Authenticate with WorkOS via browser-based OAuth',
(y) => y,
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runLogin } = await import('./commands/login.js');
await runLogin();
process.exit(0);
},
);
registerSubcommand(
yargs,
'logout',
'Remove stored WorkOS credentials and tokens',
(y) => y,
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runLogout } = await import('./commands/logout.js');
await runLogout();
},
);
registerSubcommand(
yargs,
'status',
'Show current authentication status',
(y) => y,
async (argv) => {
await applyInsecureStorage(argv.insecureStorage);
const { runAuthStatus } = await import('./commands/auth-status.js');
await runAuthStatus();
},
);
return yargs.demandCommand(1, 'Please specify an auth subcommand').strict();
})
.command(
'install-skill',
Expand Down
60 changes: 60 additions & 0 deletions src/commands/auth-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import chalk from 'chalk';
import { getCredentials, isTokenExpired } from '../lib/credentials.js';
import { getActiveEnvironment } from '../lib/config-store.js';
import { isJsonMode, outputJson } from '../utils/output.js';

function formatTimeRemaining(ms: number): string {
if (ms <= 0) return 'expired';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
return `${seconds}s`;
}

export async function runAuthStatus(): Promise<void> {
const creds = getCredentials();
const activeEnv = getActiveEnvironment();

if (!creds) {
if (isJsonMode()) {
outputJson({ authenticated: false });
return;
}
console.log(chalk.yellow('Not logged in'));
console.log(chalk.dim('Run `workos auth login` to authenticate'));
return;
}

const expired = isTokenExpired(creds);
const timeRemaining = creds.expiresAt - Date.now();

if (isJsonMode()) {
outputJson({
authenticated: true,
email: creds.email ?? null,
userId: creds.userId,
tokenExpired: expired,
tokenExpiresAt: new Date(creds.expiresAt).toISOString(),
tokenExpiresIn: expired ? null : formatTimeRemaining(timeRemaining),
hasRefreshToken: !!creds.refreshToken,
activeEnvironment: activeEnv ? { name: activeEnv.name, type: activeEnv.type } : null,
});
return;
}

console.log(chalk.green(`Logged in as ${creds.email ?? creds.userId}`));

if (expired) {
console.log(chalk.yellow(`Token expired ${formatTimeRemaining(-timeRemaining)} ago`));
} else {
console.log(chalk.dim(`Token expires in ${formatTimeRemaining(timeRemaining)}`));
}

console.log(chalk.dim(`Refresh token: ${creds.refreshToken ? 'present' : 'absent'}`));

if (activeEnv) {
console.log(chalk.dim(`Environment: ${activeEnv.name} (${activeEnv.type})`));
}
}
9 changes: 5 additions & 4 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import open from 'opn';
import chalk from 'chalk';
import clack from '../utils/clack.js';
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
Expand Down Expand Up @@ -76,8 +77,8 @@ export async function runLogin(): Promise<void> {
// Check if already logged in with valid token
if (getAccessToken()) {
const creds = getCredentials();
clack.log.info(`Already logged in as ${creds?.email ?? 'unknown'}`);
clack.log.info('Run `workos logout` to log out');
console.log(chalk.green(`Already logged in as ${creds?.email ?? 'unknown'}`));
console.log(chalk.dim('Run `workos auth logout` to log out'));
return;
}

Expand All @@ -90,8 +91,8 @@ export async function runLogin(): Promise<void> {
if (result.accessToken && result.expiresAt) {
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
logInfo('[login] Session refreshed via refresh token');
clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`);
clack.log.info('Run `workos logout` to log out');
console.log(chalk.green(`Already logged in as ${existingCreds.email ?? 'unknown'}`));
console.log(chalk.dim('Run `workos auth logout` to log out'));
return;
}
} catch {
Expand Down
6 changes: 3 additions & 3 deletions src/doctor/checks/ai-analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ async function callModel(prompt: string, model: string): Promise<string> {
if (!creds) throw new Error('Not authenticated');

if (isTokenExpired(creds)) {
if (!creds.refreshToken) throw new Error('Session expired — run `workos login` to re-authenticate');
if (!creds.refreshToken) throw new Error('Session expired — run `workos auth login` to re-authenticate');
const result = await refreshAccessToken(getAuthkitDomain(), getCliAuthClientId());
if (!result.success || !result.accessToken || !result.expiresAt) {
throw new Error('Session expired — run `workos login` to re-authenticate');
throw new Error('Session expired — run `workos auth login` to re-authenticate');
}
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
creds = getCredentials()!;
Expand Down Expand Up @@ -111,7 +111,7 @@ export async function checkAiAnalysis(context: AnalysisContext, options: { skipA
process.stderr.write(` ${line}\n`);
}
process.stderr.write('\n');
return skippedResult('Not authenticated — run `workos login` for AI-powered analysis');
return skippedResult('Not authenticated — run `workos auth login` for AI-powered analysis');
}

const startTime = Date.now();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/adapters/cli-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ export class CLIAdapter implements InstallerAdapter {

// Add actionable hints for common errors
if (message.includes('authentication') || message.includes('auth')) {
clack.log.info('Try running: workos logout && workos install');
clack.log.info('Try running: workos auth logout && workos install');
}
if (message.includes('ENOENT') || message.includes('not found')) {
clack.log.info('Ensure you are in a project directory');
Expand Down
11 changes: 5 additions & 6 deletions src/lib/agent-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
* Uses Claude Agent SDK directly with WorkOS MCP server
*/

import path from 'path';
import { getPackageRoot } from '../utils/paths.js';
import { debug, logInfo, logWarn, logError, initLogFile, getLogFilePath } from '../utils/debug.js';
import type { InstallerOptions } from '../utils/types.js';
Expand Down Expand Up @@ -361,12 +360,12 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
// Check/refresh authentication for production (unless skipping auth)
if (!options.skipAuth && !options.local) {
if (!hasCredentials()) {
throw new Error('Not authenticated. Run `workos login` to authenticate.');
throw new Error('Not authenticated. Run `workos auth login` to authenticate.');
}

const creds = getCredentials();
if (!creds) {
throw new Error('Not authenticated. Run `workos login` to authenticate.');
throw new Error('Not authenticated. Run `workos auth login` to authenticate.');
}

// Check if we have refresh token capability and proxy is not disabled
Expand All @@ -387,7 +386,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
onRefreshExpired: () => {
logError('[agent-interface] Session expired, refresh token invalid');
options.emitter?.emit('error', {
message: 'Session expired. Run `workos login` to re-authenticate.',
message: 'Session expired. Run `workos auth login` to re-authenticate.',
});
},
},
Expand All @@ -404,9 +403,9 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
// No refresh token OR proxy disabled - fall back to old behavior (5 min limit)
if (!creds.refreshToken) {
logWarn('[agent-interface] No refresh token available, session limited to 5 minutes');
logWarn('[agent-interface] Run `workos login` to enable extended sessions');
logWarn('[agent-interface] Run `workos auth login` to enable extended sessions');
options.emitter?.emit('status', {
message: 'Note: Run `workos login` to enable extended sessions',
message: 'Note: Run `workos auth login` to enable extended sessions',
});
} else {
logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY');
Expand Down
2 changes: 1 addition & 1 deletion src/lib/credential-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ async function handleRequest(
res.end(
JSON.stringify({
error: 'credentials_unavailable',
message: 'Not authenticated. Run `workos login` first.',
message: 'Not authenticated. Run `workos auth login` first.',
}),
);
return;
Expand Down
8 changes: 5 additions & 3 deletions src/lib/ensure-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,9 @@ export async function ensureAuthenticated(): Promise<EnsureAuthResult> {
if (refreshResult.errorType === 'invalid_grant') {
clearCredentials();
if (isNonInteractiveEnvironment()) {
exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.');
exitWithAuthRequired(
'Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.',
);
}
logInfo('[ensure-auth] Refresh token expired, triggering login');
await runLogin();
Expand All @@ -89,7 +91,7 @@ export async function ensureAuthenticated(): Promise<EnsureAuthResult> {
// Network or server error - keep credentials intact for retry
if (isNonInteractiveEnvironment()) {
exitWithAuthRequired(
`Authentication refresh failed (${refreshResult.errorType}). Run \`workos login\` in an interactive terminal.`,
`Authentication refresh failed (${refreshResult.errorType}). Run \`workos auth login\` in an interactive terminal.`,
);
}
logInfo(`[ensure-auth] Refresh failed (${refreshResult.errorType}), triggering login`);
Expand All @@ -103,7 +105,7 @@ export async function ensureAuthenticated(): Promise<EnsureAuthResult> {
// Case 4: No refresh token available — clear stale creds, must login
clearCredentials();
if (isNonInteractiveEnvironment()) {
exitWithAuthRequired('Session expired. Run `workos login` in an interactive terminal to re-authenticate.');
exitWithAuthRequired('Session expired. Run `workos auth login` in an interactive terminal to re-authenticate.');
}
logInfo('[ensure-auth] No refresh token, triggering login');
await runLogin();
Expand Down
2 changes: 1 addition & 1 deletion src/lib/run-with-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ export async function runWithCore(options: InstallerOptions): Promise<void> {
if (!token) {
// This should rarely happen since bin.ts handles auth first
// But keep as safety net for programmatic usage
throw new Error('Not authenticated. Run `workos login` first.');
throw new Error('Not authenticated. Run `workos auth login` first.');
}

// Set telemetry from existing credentials
Expand Down
2 changes: 1 addition & 1 deletion src/lib/token-refresh-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export async function refreshAccessToken(authkitDomain: string, clientId: string
if (errorData.error === 'invalid_grant') {
return {
success: false,
error: 'Session expired. Run `workos login` to re-authenticate.',
error: 'Session expired. Run `workos auth login` to re-authenticate.',
errorType: 'invalid_grant',
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/token-refresh.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ describe('token-refresh', () => {

expect(result.success).toBe(false);
expect(result.error).toContain('Session expired');
expect(result.error).toContain('workos login');
expect(result.error).toContain('workos auth login');
});

it('preserves credentials on valid token', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/token-refresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export async function ensureValidToken(): Promise<TokenValidationResult> {
logInfo('[ensureValidToken] Token expired, re-authentication required');
return {
success: false,
error: 'Session expired. Run `workos login` to re-authenticate.',
error: 'Session expired. Run `workos auth login` to re-authenticate.',
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/lib/version-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export async function checkForUpdates(): Promise<void> {
if (lt(currentVersion, latestVersion)) {
hasWarned = true;
yellow(`Update available: ${currentVersion} → ${latestVersion}`);
dim(`Run: npx workos@latest`);
dim('Run: npx workos@latest');
console.log();
}
} catch {
// Silently ignore all errors (timeout, network, parse, etc.)
Expand Down
2 changes: 1 addition & 1 deletion src/utils/exit-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ export function exitWithCode(code: ExitCodeValue, error?: { code: string; messag
export function exitWithAuthRequired(message?: string): never {
exitWithCode(ExitCode.AUTH_REQUIRED, {
code: 'auth_required',
message: message ?? 'Not authenticated. Run `workos login` in an interactive terminal, or set WORKOS_API_KEY.',
message: message ?? 'Not authenticated. Run `workos auth login` in an interactive terminal, or set WORKOS_API_KEY.',
});
}
5 changes: 3 additions & 2 deletions src/utils/help-json.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ describe('help-json', () => {
const names = (tree as { commands: { name: string }[] }).commands.map((c) => c.name);
expect(names).toEqual(
expect.arrayContaining([
'login',
'logout',
'auth login',
'auth logout',
'auth status',
'install-skill',
'doctor',
'env',
Expand Down
Loading