diff --git a/CLAUDE.md b/CLAUDE.md index 0651b85..1bf1b3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f4eadac..2eb83bd 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 diff --git a/README.md b/README.md index 7a4571c..d1b6e1f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/skills/workos-management/SKILL.md b/skills/workos-management/SKILL.md index 62bb81d..4d9a605 100644 --- a/skills/workos-management/SKILL.md +++ b/skills/workos-management/SKILL.md @@ -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). diff --git a/src/bin.ts b/src/bin.ts index 87c1b24..5891d0f 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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'; @@ -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') @@ -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', diff --git a/src/commands/auth-status.ts b/src/commands/auth-status.ts new file mode 100644 index 0000000..c59e03b --- /dev/null +++ b/src/commands/auth-status.ts @@ -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 { + 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})`)); + } +} diff --git a/src/commands/login.ts b/src/commands/login.ts index 5b73276..6c612b0 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -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'; @@ -76,8 +77,8 @@ export async function runLogin(): Promise { // 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; } @@ -90,8 +91,8 @@ export async function runLogin(): Promise { 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 { diff --git a/src/doctor/checks/ai-analysis.ts b/src/doctor/checks/ai-analysis.ts index 43f09bf..0b25b39 100644 --- a/src/doctor/checks/ai-analysis.ts +++ b/src/doctor/checks/ai-analysis.ts @@ -61,10 +61,10 @@ async function callModel(prompt: string, model: string): Promise { 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()!; @@ -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(); diff --git a/src/lib/adapters/cli-adapter.ts b/src/lib/adapters/cli-adapter.ts index 085c346..9bed10f 100644 --- a/src/lib/adapters/cli-adapter.ts +++ b/src/lib/adapters/cli-adapter.ts @@ -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'); diff --git a/src/lib/agent-interface.ts b/src/lib/agent-interface.ts index 33a73bf..557438c 100644 --- a/src/lib/agent-interface.ts +++ b/src/lib/agent-interface.ts @@ -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'; @@ -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 @@ -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.', }); }, }, @@ -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'); diff --git a/src/lib/credential-proxy.ts b/src/lib/credential-proxy.ts index 63638bf..7b7b895 100644 --- a/src/lib/credential-proxy.ts +++ b/src/lib/credential-proxy.ts @@ -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; diff --git a/src/lib/ensure-auth.ts b/src/lib/ensure-auth.ts index 8e0be91..ba47ba8 100644 --- a/src/lib/ensure-auth.ts +++ b/src/lib/ensure-auth.ts @@ -77,7 +77,9 @@ export async function ensureAuthenticated(): Promise { 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(); @@ -89,7 +91,7 @@ export async function ensureAuthenticated(): Promise { // 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`); @@ -103,7 +105,7 @@ export async function ensureAuthenticated(): Promise { // 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(); diff --git a/src/lib/run-with-core.ts b/src/lib/run-with-core.ts index 57bcbc8..b7f47c1 100644 --- a/src/lib/run-with-core.ts +++ b/src/lib/run-with-core.ts @@ -223,7 +223,7 @@ export async function runWithCore(options: InstallerOptions): Promise { 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 diff --git a/src/lib/token-refresh-client.ts b/src/lib/token-refresh-client.ts index ad7fb1c..7952a07 100644 --- a/src/lib/token-refresh-client.ts +++ b/src/lib/token-refresh-client.ts @@ -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', }; } diff --git a/src/lib/token-refresh.spec.ts b/src/lib/token-refresh.spec.ts index 2b88884..4f04e42 100644 --- a/src/lib/token-refresh.spec.ts +++ b/src/lib/token-refresh.spec.ts @@ -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 () => { diff --git a/src/lib/token-refresh.ts b/src/lib/token-refresh.ts index 709ece1..8430607 100644 --- a/src/lib/token-refresh.ts +++ b/src/lib/token-refresh.ts @@ -27,7 +27,7 @@ export async function ensureValidToken(): Promise { 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.', }; } diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index 357f0d6..4fc9c4e 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -37,7 +37,8 @@ export async function checkForUpdates(): Promise { 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.) diff --git a/src/utils/exit-codes.ts b/src/utils/exit-codes.ts index 3cfc111..ab77f8f 100644 --- a/src/utils/exit-codes.ts +++ b/src/utils/exit-codes.ts @@ -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.', }); } diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index 08a9bf2..4744301 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -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', diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 6d46bc4..f8a44cb 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -96,15 +96,20 @@ const paginationOpts: OptionSchema[] = [ const commands: CommandSchema[] = [ { - name: 'login', + name: 'auth login', description: 'Authenticate with WorkOS via browser-based OAuth', options: [insecureStorageOpt], }, { - name: 'logout', + name: 'auth logout', description: 'Remove stored WorkOS credentials and tokens', options: [insecureStorageOpt], }, + { + name: 'auth status', + description: 'Show current authentication status', + options: [insecureStorageOpt], + }, { name: 'install-skill', description: 'Install bundled AuthKit skills to coding agents (Claude Code, Codex, Cursor, Goose)',