feat(codex-auth): add ccsx auth CLI subcommands (create/login/switch/use/show/remove)

Implements the user-facing surface for ccsx auth profile management.
After `ccsx auth create work` (auto-spawns codex login with CODEX_HOME
pinned per D11), users can `eval "$(ccsx auth use work)"` in any shell
to scope all subsequent codex invocations to that profile — letting
two terminals run two different Codex accounts concurrently.

- codex-auth-router: dispatches argv to subcommand handlers
- create: idempotent, --force re-links config.toml preserving auth.json
  (D9), then auto-spawns codex login with CODEX_HOME pinned (D11);
  filesystem ops happen before registry write to avoid registry orphans
  on EACCES/ENOSPC
- login: standalone re-auth for an existing profile
- switch: persistent default in YAML registry
- use: STDOUT-DISCIPLINED — emits only shell-evalable exports;
  bash/zsh/fish/PowerShell/cmd syntaxes via shell-detect; sets
  CCS_NO_PRE_DISPATCH=1 at module load to suppress recovery/migration
  banners that would otherwise contaminate eval (C2)
- show: list (active(missing) row at top per D14) + detail views
- remove: default-profile guard, active-shell warn, --yes / --force
- ASCII-only output, NO_COLOR honored, all errors to stderr via
  exitWithError
- pre-dispatch.ts: early-return when CCS_NO_PRE_DISPATCH=1, placed
  before autoMigrate which is itself a stdout writer

57 unit tests, all green. Help text cross-references the ccsxp/ccsx
distinction since the binaries differ by one character (H5).
This commit is contained in:
Tam Nhu Tran committed 2026-05-17 14:44:44 -04:00
1 parent 358b703d98
commit bf92645b35
21 files changed
+2570

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
/**
* Help text for `ccsx auth` command tree.
* ASCII-only. Includes ccsxp scope clarifier (H5).
*/
export function printCodexAuthHelp(): void {
process.stdout.write(`CCS Concurrent Codex Account Management
Usage
ccsx auth <command> [options]
Commands
create <name> Create a new Codex profile (idempotent)
login <name> Run \`codex login\` against the profile (auto-creates if missing)
switch <name> Set the persistent default Codex profile
use <name> Emit shell-eval exports to activate a profile in this shell only
show [name] List profiles or show details for one
remove <name> Delete a profile (auth.json + profile dir + registry entry)
import-default <name> Migrate legacy ~/.codex/auth.json into a new profile
Shell activation (per terminal)
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
Examples
ccsx auth create work
ccsx auth login work # OAuth in browser
ccsx auth create personal
ccsx auth login personal
eval "$(ccsx auth use work)" # terminal A
eval "$(ccsx auth use personal)" # terminal B
codex # each terminal uses its own account
ccsx auth show
ccsx auth switch personal # change persistent default
ccsx auth remove old --yes
Options
--yes, -y Skip confirmation (remove)
--force Re-link config.toml (create) | override default check (remove) |
overwrite existing profile (import-default)
--json JSON output (show)
--shell <s> Override shell detection (use): bash|zsh|fish|pwsh|cmd
--with-history Copy history.jsonl + sessions/ too (import-default, default: off)
--force-while-running Allow import-default even if Codex is running (risky)
Notes
Auth state (auth.json) and history.jsonl are isolated per profile.
config.toml is shared via symlink to ~/.codex/config.toml.
Default profile (switch) is persistent across shells.
Active profile (use) is per-terminal via CODEX_HOME / CCS_CODEX_PROFILE.
Note: This feature applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
export function printCodexAuthUseHelp(): void {
process.stdout.write(`ccsx auth use — Activate a Codex profile in the current shell
Usage
ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]
Description
Emits shell-evalable export statements to stdout. Use within eval "$(...)"
only. Output to stdout is shell-evaluatable; do not pipe to other commands.
All errors and informational messages go to stderr so the eval is never
contaminated.
Shell evaluation
bash/zsh: eval "$(ccsx auth use work)"
fish: ccsx auth use work | source
pwsh: ccsx auth use work | Invoke-Expression
cmd: (not supported natively; use PowerShell)
Options
--shell <s> Override auto-detected shell: bash|zsh|fish|pwsh|cmd
Note: This profile applies only to native \`codex\`. \`ccsxp\` ignores
CCS_CODEX_PROFILE and uses its own cliproxy pool.
`);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Codex auth command router.
*
* Exports runCodexAuth(argv) which routes argv[0] (the subcommand) to
* the appropriate handler. Returns an exit code (0 = success, non-zero = error).
*
* Phase 3 wires this into src/bin/codex-runtime.ts for argv[2]==='auth'.
*/
import { CodexProfileRegistry } from './codex-profile-registry';
import { printCodexAuthHelp } from './codex-auth-help';
import {
handleCreateCodex,
handleLoginCodex,
handleSwitchCodex,
handleUseCodex,
handleShowCodex,
handleRemoveCodex,
handleImportDefaultCodex,
} from './commands/index';
import type { CodexCommandContext } from './commands/types';
const packageJson = require('../../package.json') as { version: string };
/**
* Route a `ccsx auth <subcommand> [...args]` invocation.
*
* @param argv - Arguments after `auth`, e.g. ['create', 'work'] or ['--help']
* @returns Exit code (0 success, 1 user error, 2+ system error)
*/
export async function runCodexAuth(argv: string[]): Promise<number> {
const [subcommand, ...rest] = argv;
// Help / no-arg
if (!subcommand || subcommand === '--help' || subcommand === '-h' || subcommand === 'help') {
printCodexAuthHelp();
return 0;
}
// Version passthrough
if (subcommand === '--version' || subcommand === '-v') {
process.stdout.write(`ccsx auth ${packageJson.version}\n`);
return 0;
}
const registry = new CodexProfileRegistry();
const ctx: CodexCommandContext = {
registry,
version: packageJson.version,
};
try {
switch (subcommand) {
case 'create':
await handleCreateCodex(ctx, rest);
return 0;
case 'login':
await handleLoginCodex(ctx, rest);
return 0;
case 'switch':
await handleSwitchCodex(ctx, rest);
return 0;
case 'use':
await handleUseCodex(ctx, rest);
return 0;
case 'show':
await handleShowCodex(ctx, rest);
return 0;
case 'remove':
await handleRemoveCodex(ctx, rest);
return 0;
case 'import-default':
await handleImportDefaultCodex(ctx, rest);
return 0;
default:
process.stderr.write(`[X] Unknown command: ${subcommand}\n`);
process.stderr.write(` ccsx auth --help\n`);
return 1;
}
} catch (err) {
// Unhandled errors from handlers (e.g. process.exit called inside)
// These should be rare — handlers use exitWithError() which calls process.exit
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`[X] Unexpected error in ccsx auth ${subcommand}: ${msg}\n`);
return 1;
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* codex-auth create command.
* Creates a new profile dir + shared config.toml symlink.
* After creation, auto-spawns `codex login` with CODEX_HOME pinned (D11).
* --force: re-link config.toml only, preserve auth.json (D9).
*/
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index';
import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:create');
export async function handleCreateCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth create <name> [--force]');
const { profileName, force } = parsed;
if (!profileName) {
console.log(`Usage: ccsx auth create <name> [--force]`);
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
const profileDir = resolveCodexProfileDir(profileName);
// Idempotent: profile already exists
if (registry.hasProfile(profileName)) {
if (force) {
// --force: only re-link config.toml, preserve auth.json
console.log(info(`Profile already exists: ${profileName} (re-linking config.toml)`));
_ensureSymlinkSafe(profileDir);
console.log(ok(`Profile config.toml re-linked.`));
console.log(` Profile dir: ${profileDir}`);
} else {
console.log(info(`Profile already exists: ${profileName}`));
console.log(` Profile dir: ${profileDir}`);
console.log(` Run: ccsx auth login ${profileName}`);
}
return;
}
// Create profile dir + symlink FIRST (filesystem is more failure-prone than registry write).
// Avoids registry orphan if mkdir hits EACCES/ENOSPC.
try {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
_ensureSymlinkSafe(profileDir);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if ((err as NodeJS.ErrnoException).code === 'EACCES') {
exitWithError(msg, ExitCode.GENERAL_ERROR);
return;
}
throw err;
}
// Now register in the profile registry
try {
registry.createProfile(profileName, {
created: new Date().toISOString(),
last_used: null,
email: undefined,
plan_type: undefined,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('corrupt') || msg.includes('Failed to write')) {
exitWithError(
`Profile registry is corrupt. Backup and remove the file to re-init.\n ${msg}`,
ExitCode.GENERAL_ERROR
);
return;
}
throw err;
}
const authJsonPath = path.join(profileDir, 'auth.json');
const authExists = fs.existsSync(authJsonPath);
console.log(info(`Creating Codex profile: ${profileName}`));
console.log('');
console.log(` Profile dir : ${profileDir}`);
console.log(` Auth state : ${authExists ? 'authenticated' : 'not authenticated'}`);
console.log('');
console.log(ok('Profile created.'));
// D11: auto-spawn codex login after creating the profile
await _spawnLogin(profileName, profileDir, ctx);
}
function _ensureSymlinkSafe(profileDir: string): void {
try {
ensureSharedConfigSymlink(profileDir);
} catch (err) {
// Symlink creation failure — warn + continue (Windows fallback documented)
process.stderr.write(
`[!] Symlinks unavailable; using copy. config.toml edits won't propagate.\n`
);
logger.warn('codex-auth.create.symlink-failed', 'Symlink creation failed', {
profileDir,
error: err instanceof Error ? err.message : String(err),
});
}
}
async function _spawnLogin(
profileName: string,
profileDir: string,
ctx: CodexCommandContext
): Promise<void> {
const codexCli = detectCodexCli();
if (!codexCli) {
process.stderr.write(`[!] codex CLI not found — skipping auto-login.\n`);
process.stderr.write(` Install: npm i -g @openai/codex\n`);
process.stderr.write(` Then run: ccsx auth login ${profileName}\n`);
return;
}
console.log('');
console.log(`Next step: logging in to Codex...`);
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
await new Promise<void>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
resolve();
});
child.on('exit', (code) => {
const authJsonPath = path.join(profileDir, 'auth.json');
if (code === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
if (identity.email || identity.plan_type) {
ctx.registry.updateProfile(profileName, {
last_used: new Date().toISOString(),
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
}
const emailStr = identity.email ? ` as ${identity.email}` : '';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in${emailStr}${planStr}`));
} else if (code === 0) {
process.stderr.write(
`[!] codex login exited cleanly but no auth.json. Skipping registry update.\n`
);
} else {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
process.stderr.write(` Retry: ccsx auth login ${profileName}\n`);
}
resolve();
});
});
}
+19
View File
@@ -0,0 +1,19 @@
/**
* Barrel export for codex-auth command handlers.
*/
export { handleCreateCodex } from './create-command';
export { handleLoginCodex } from './login-command';
export { handleSwitchCodex } from './switch-command';
export { handleUseCodex } from './use-command';
export { handleShowCodex } from './show-command';
export { handleRemoveCodex } from './remove-command';
export { handleImportDefaultCodex } from './import-default-command';
export type { CodexCommandContext, CodexAuthArgs, CodexProfileOutput } from './types';
export {
parseArgs,
rejectUnsupportedOptions,
isValidCodexProfileName,
getProfileNameError,
formatRelativeTime,
} from './types';
+138
View File
@@ -0,0 +1,138 @@
/**
* codex-auth login command.
* Spawns `codex login` with CODEX_HOME pinned to the profile dir.
* Auto-creates profile if it doesn't exist yet.
* Updates registry with email/plan from JWT after successful login.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as childProcess from 'child_process';
import { createLogger } from '../../services/logging';
import { initUI, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir, ensureSharedConfigSymlink } from '../index';
import { decodeAccountIdentity } from '../codex-account-identity';
import { detectCodexCli } from '../../targets/codex-detector';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
const logger = createLogger('codex-auth:cmd:login');
export async function handleLoginCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth login <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth login <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
// Auto-create profile if missing
if (!registry.hasProfile(profileName)) {
console.log(info(`Auto-creating profile ${profileName}`));
registry.createProfile(profileName, {
created: new Date().toISOString(),
last_used: null,
});
const profileDir = resolveCodexProfileDir(profileName);
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
try {
ensureSharedConfigSymlink(profileDir);
} catch {
process.stderr.write(`[!] Symlink creation failed; continuing without shared config.\n`);
}
}
const codexCli = detectCodexCli();
if (!codexCli) {
console.log('');
console.log('Install:');
console.log(' npm i -g @openai/codex');
console.log(' # or follow https://github.com/openai/codex#install');
console.log('');
console.log(`After installing, re-run:`);
console.log(` ccsx auth login ${profileName}`);
exitWithError('codex CLI not found', ExitCode.BINARY_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(profileName);
// Ensure profile dir exists (may have been deleted)
if (!fs.existsSync(profileDir)) {
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
try {
ensureSharedConfigSymlink(profileDir);
} catch {
process.stderr.write(`[!] Symlink creation failed; continuing.\n`);
}
}
const authJsonPath = path.join(profileDir, 'auth.json');
const authJsonExisted = fs.existsSync(authJsonPath);
console.log(info(`Launching codex login for profile: ${profileName}`));
console.log(` CODEX_HOME=${profileDir}`);
console.log('');
const exitCode = await new Promise<number>((resolve) => {
const child = childProcess.spawn(codexCli, ['login'], {
stdio: 'inherit',
env: { ...process.env, CODEX_HOME: profileDir },
windowsHide: true,
});
child.on('error', (err) => {
process.stderr.write(`[X] Failed to execute codex: ${err.message}\n`);
logger.warn('codex-auth.login.spawn-error', 'Spawn failed', { error: err.message });
resolve(ExitCode.BINARY_ERROR);
});
child.on('exit', (code) => {
resolve(code ?? 1);
});
});
if (exitCode === 0 && fs.existsSync(authJsonPath)) {
const identity = decodeAccountIdentity(authJsonPath);
const now = new Date().toISOString();
registry.updateProfile(profileName, {
last_used: now,
email: identity.email,
plan_type: identity.plan_type ?? null,
account_id: identity.account_id,
});
const emailStr = identity.email ?? '<unknown>';
const planStr = identity.plan_type ? ` (plan: ${identity.plan_type})` : '';
console.log(ok(`Logged in as ${emailStr}${planStr}`));
console.log(` Profile: ${profileName}`);
console.log(` Updated: ${now}`);
} else if (exitCode === 0) {
process.stderr.write(
'[!] codex login exited cleanly but no auth.json. Skipping registry update.\n'
);
} else {
if (!authJsonExisted) {
process.stderr.write(
`[!] Login cancelled or failed. Profile ${profileName} remains unauthenticated.\n`
);
} else {
process.stderr.write('[!] Login failed. Previous credentials may still be valid.\n');
}
process.exit(ExitCode.AUTH_ERROR);
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* codex-auth remove command.
* Deletes profile dir + registry entry.
* Guards: refuses to remove the default when others exist (unless --force).
* Best-effort warning if CCS_CODEX_PROFILE points to it.
* --yes skips confirmation prompt.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, ok } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
export async function handleRemoveCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth remove <name> [--yes|-y] [--force]');
const { profileName, yes, force } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth remove <name> [--yes|-y] [--force]');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const allProfiles = registry.listProfiles();
const isDefault = registry.getDefault() === profileName;
// Default guard: refuse if others exist and no --force
if (isDefault && allProfiles.length > 1 && !force) {
const others = allProfiles.filter((n) => n !== profileName);
console.log(` Switch first: ccsx auth switch ${others[0]}`);
console.log(` Or override : ccsx auth remove ${profileName} --force`);
exitWithError('Cannot remove default profile', ExitCode.PROFILE_ERROR);
return;
}
// Active-env warning (best-effort — can only see current shell)
if (process.env.CCS_CODEX_PROFILE === profileName) {
process.stderr.write(`[!] CCS_CODEX_PROFILE in this shell points to "${profileName}".\n`);
process.stderr.write(` After removal, codex sessions in this shell will fail until you\n`);
const others = allProfiles.filter((n) => n !== profileName);
if (others.length > 0) {
process.stderr.write(
` run: eval "$(ccsx auth use ${others[0]})" or unset CCS_CODEX_PROFILE.\n`
);
} else {
process.stderr.write(` run: unset CCS_CODEX_PROFILE\n`);
}
}
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const authExists = fs.existsSync(authJsonPath);
const dirExists = fs.existsSync(profileDir);
// Load cached email for impact summary
const meta = registry.getProfile(profileName);
let emailStr = meta.email ?? null;
if (!emailStr && authExists) {
const identity = decodeAccountIdentity(authJsonPath);
emailStr = identity.email ?? null;
}
// Ghost case: dir already gone
if (!dirExists) {
process.stderr.write(`[!] Profile dir was already missing; removing registry entry only.\n`);
registry.removeProfile(profileName);
console.log(ok(`Profile removed: ${profileName}`));
return;
}
// Impact summary
console.log(`Profile "${profileName}" will be removed.`);
console.log(` Profile dir : ${profileDir}`);
console.log(` auth.json : ${authExists ? 'present (will be deleted)' : 'not found'}`);
console.log(` Email : ${emailStr ?? '<unknown>'}`);
console.log('');
// Confirm unless --yes
if (!yes) {
const confirmed = await InteractivePrompt.confirm('Delete this profile?', {
default: false,
});
if (!confirmed) {
console.log(info('Cancelled.'));
return;
}
}
// Remove dir then registry entry
try {
fs.rmSync(profileDir, { recursive: true, force: true });
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code === 'EACCES') {
exitWithError('Permission denied', ExitCode.GENERAL_ERROR);
return;
}
throw err;
}
registry.removeProfile(profileName);
console.log(ok(`Profile removed: ${profileName}`));
}
+123
View File
@@ -0,0 +1,123 @@
/**
* codex-auth show command.
* List mode: table of all profiles with STATE column.
* Detail mode: delegated to show-detail-view.ts.
* --json: machine-readable output.
* D14: active(missing) row at top when CCS_CODEX_PROFILE points to deleted profile.
*/
import * as fs from 'fs';
import * as path from 'path';
import { initUI, info, table } from '../../utils/ui';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import { showProfileDetail } from './show-detail-view';
import { parseArgs, rejectUnsupportedOptions, formatRelativeTime } from './types';
import type { CodexCommandContext, CodexProfileOutput } from './types';
export async function handleShowCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth show [name] [--json]');
const { profileName, json } = parsed;
if (profileName) {
return showProfileDetail(profileName, ctx, !!json);
}
return _showList(ctx, !!json);
}
// ── List view ─────────────────────────────────────────────────────────────────
function _showList(ctx: CodexCommandContext, json: boolean): void {
const { registry } = ctx;
const names = registry.listProfiles();
const defaultName = registry.getDefault();
const activeName = process.env.CCS_CODEX_PROFILE ?? null;
// D14: check if CCS_CODEX_PROFILE points to a deleted/missing profile
const activeMissing =
activeName !== null && activeName.length > 0 && !registry.hasProfile(activeName);
interface Row {
name: string;
email: string;
plan: string;
lastUsed: string;
state: string;
missing?: boolean;
}
const rows: Row[] = [];
// D14: active(missing) row at top
if (activeMissing) {
rows.push({
name: activeName ?? '',
email: '<unknown>',
plan: '-',
lastUsed: 'never',
state: 'active(missing)',
missing: true,
});
}
for (const name of names) {
const meta = registry.getProfile(name);
const states: string[] = [];
if (name === defaultName) states.push('default');
if (name === activeName) states.push('active');
const profileDir = resolveCodexProfileDir(name);
const authJsonPath = path.join(profileDir, 'auth.json');
let email = meta.email ?? '<unknown>';
if (fs.existsSync(authJsonPath) && !meta.email) {
const identity = decodeAccountIdentity(authJsonPath);
email = identity.email ?? '<unknown>';
}
const lastUsed = meta.last_used ? formatRelativeTime(new Date(meta.last_used)) : 'never';
rows.push({ name, email, plan: meta.plan_type ?? '-', lastUsed, state: states.join(',') });
}
if (json) {
const profiles: CodexProfileOutput[] = rows.map((r) => {
const meta = r.missing ? null : registry.getProfile(r.name);
const profileDir = r.missing ? '' : resolveCodexProfileDir(r.name);
return {
name: r.name,
is_default: r.name === defaultName,
is_active: r.name === activeName,
created: meta?.created ?? '',
last_used: meta?.last_used ?? null,
email: r.email === '<unknown>' ? null : r.email,
plan: r.plan === '-' ? null : r.plan,
account_id: null,
profile_dir: profileDir,
auth_json_exists: r.missing ? false : fs.existsSync(path.join(profileDir, 'auth.json')),
auth_json_mtime: null,
config_toml_link_target: null,
};
});
console.log(JSON.stringify({ profiles }, null, 2));
return;
}
if (names.length === 0 && !activeMissing) {
console.log(info('No Codex profiles yet.'));
console.log(' Create one: ccsx auth create <name>');
return;
}
const count = names.length + (activeMissing ? 1 : 0);
console.log(`Codex Profiles (${count})`);
console.log('');
const header = ['NAME', 'EMAIL', 'PLAN', 'LAST_USED', 'STATE'];
const tableRows = [header, ...rows.map((r) => [r.name, r.email, r.plan, r.lastUsed, r.state])];
console.log(table(tableRows, { colWidths: [14, 26, 8, 14, 18] }));
console.log('');
console.log(info('Default persists across shells. Active is current shell only.'));
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Detail view renderer for `ccsx auth show <name>`.
* Extracted from show-command.ts to keep files under 200 lines.
*/
import * as fs from 'fs';
import * as path from 'path';
import { table } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { decodeAccountIdentity } from '../codex-account-identity';
import type { CodexCommandContext, CodexProfileOutput } from './types';
export function showProfileDetail(
profileName: string,
ctx: CodexCommandContext,
json: boolean
): void {
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
return;
}
const meta = registry.getProfile(profileName);
const profileDir = resolveCodexProfileDir(profileName);
const authJsonPath = path.join(profileDir, 'auth.json');
const configTomlPath = path.join(profileDir, 'config.toml');
const authExists = fs.existsSync(authJsonPath);
let authMtime: string | null = null;
let identity = {
email: undefined as string | undefined,
plan_type: undefined as string | undefined,
account_id: undefined as string | undefined,
};
let authState = 'missing';
if (authExists) {
try {
const stat = fs.statSync(authJsonPath);
authMtime = stat.mtime.toISOString();
identity = decodeAccountIdentity(authJsonPath) as typeof identity;
authState = `present (mtime: ${new Date(authMtime).toLocaleString()})`;
} catch {
authState = 'present (unreadable)';
}
}
// Inspect config.toml symlink
let configTarget: string | null = null;
try {
const lstat = fs.lstatSync(configTomlPath);
if (lstat.isSymbolicLink()) {
configTarget = fs.readlinkSync(configTomlPath);
} else {
configTarget = `${configTomlPath} (regular file, not symlink)`;
}
} catch {
configTarget = null;
}
const isDefault = registry.getDefault() === profileName;
const isActive = process.env.CCS_CODEX_PROFILE === profileName;
const states: string[] = [];
if (isDefault) states.push('default');
if (isActive) states.push('active');
const stateStr = states.join(',');
if (json) {
const out: CodexProfileOutput = {
name: profileName,
is_default: isDefault,
is_active: isActive,
created: meta.created,
last_used: meta.last_used ?? null,
email: identity.email ?? null,
plan: meta.plan_type ?? null,
account_id: identity.account_id ?? null,
profile_dir: profileDir,
auth_json_exists: authExists,
auth_json_mtime: authMtime,
config_toml_link_target: configTarget,
};
console.log(JSON.stringify(out, null, 2));
return;
}
const badge = stateStr ? ` (${stateStr})` : '';
console.log(`Codex Profile: ${profileName}${badge}`);
console.log('');
const rows: [string, string][] = [
['Name', profileName],
['Profile dir', profileDir],
['config.toml', configTarget ? `-> ${configTarget} (symlink)` : '(not linked)'],
['auth.json', authState],
['Email', identity.email ?? (authExists ? '<invalid>' : '<unknown>')],
['Plan', meta.plan_type ?? (authExists ? '<invalid>' : '<unknown>')],
['Account ID', identity.account_id ?? '-'],
['Created', new Date(meta.created).toLocaleString()],
['Last used', meta.last_used ? new Date(meta.last_used).toLocaleString() : 'never'],
['CODEX_HOME (env)', process.env.CODEX_HOME ?? 'unset'],
['CCS_CODEX_PROFILE', process.env.CCS_CODEX_PROFILE ?? 'unset'],
];
console.log(table(rows, { colWidths: [20, 55] }));
// H4: warn if config.toml is a regular file (not symlink)
if (configTarget && configTarget.includes('regular file')) {
process.stderr.write(
`[!] config.toml is a regular file, not a symlink. Config changes won't propagate.\n`
);
process.stderr.write(` Run: ccsx auth create ${profileName} --force\n`);
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* codex-auth switch command.
* Sets the persistent default Codex profile in the registry.
*/
import { initUI, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { CodexCommandContext } from './types';
export async function handleSwitchCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth switch <name>');
const { profileName } = parsed;
if (!profileName) {
console.log('Usage: ccsx auth switch <name>');
exitWithError('Profile name required', ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
exitWithError(nameError, ExitCode.PROFILE_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
exitWithError(
`Profile not found: ${profileName}. Available: ${availableStr}`,
ExitCode.PROFILE_ERROR
);
return;
}
registry.setDefault(profileName);
const meta = registry.getProfile(profileName);
const emailStr = meta.email ? `\n Email: ${meta.email}` : '';
const planStr = meta.plan_type ? `\n Plan : ${meta.plan_type}` : '';
console.log(ok(`Default Codex profile: ${profileName}`));
if (emailStr) process.stdout.write(emailStr + '\n');
if (planStr) process.stdout.write(planStr + '\n');
console.log('');
console.log('[i] This is the persistent default. To use a different profile in the');
console.log(` current shell only, run: eval "$(ccsx auth use <other>)"`);
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Shared types and utilities for codex-auth command handlers.
*/
import { color } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import type { CodexProfileRegistry } from '../codex-profile-registry';
// Re-export for convenience in command modules
export { formatRelativeTime } from '../../utils/time';
// ── Context ──────────────────────────────────────────────────────────────────
export interface CodexCommandContext {
registry: CodexProfileRegistry;
version: string;
}
// ── CLI args ─────────────────────────────────────────────────────────────────
export interface CodexAuthArgs {
profileName?: string;
yes?: boolean;
json?: boolean;
force?: boolean;
shell?: string;
unknownFlags?: string[];
}
// ── Profile output shape (JSON mode) ─────────────────────────────────────────
export interface CodexProfileOutput {
name: string;
is_default: boolean;
is_active: boolean;
created: string;
last_used: string | null;
email: string | null;
plan: string | null;
account_id: string | null;
profile_dir: string;
auth_json_exists: boolean;
auth_json_mtime: string | null;
config_toml_link_target: string | null;
}
// ── Name validation ───────────────────────────────────────────────────────────
const RESERVED = new Set(['default', 'current']);
/**
* Profile name must match /^[a-z0-9][a-z0-9_-]{0,63}$/ and not be reserved.
* Rejects uppercase, path separators, leading dash/underscore, length >64.
*/
export function isValidCodexProfileName(name: string): boolean {
if (!name || name.length > 64) return false;
if (RESERVED.has(name)) return false;
if (name.includes('/') || name.includes('\\')) return false;
return /^[a-z0-9][a-z0-9_-]{0,63}$/.test(name);
}
export function getProfileNameError(name: string): string | null {
if (!name) return 'Profile name is required.';
if (RESERVED.has(name)) return `Profile name "${name}" is reserved.`;
if (name.includes('/') || name.includes('\\'))
return 'Profile name must not contain path separators.';
if (name.length > 64) return 'Profile name must be 64 characters or fewer.';
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
return 'Profile name must match [a-z0-9][a-z0-9_-]{0,63}.';
return null;
}
// ── Arg parsing ───────────────────────────────────────────────────────────────
export function parseArgs(args: string[]): CodexAuthArgs {
const result: CodexAuthArgs = { unknownFlags: [] };
const positional: string[] = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--yes' || arg === '-y') {
result.yes = true;
} else if (arg === '--json') {
result.json = true;
} else if (arg === '--force') {
result.force = true;
} else if (arg === '--shell') {
result.shell = args[++i];
} else if (arg.startsWith('--shell=')) {
result.shell = arg.slice('--shell='.length);
} else if (arg.startsWith('-') && arg !== '--') {
if (result.unknownFlags) result.unknownFlags.push(arg);
} else if (arg !== '--') {
positional.push(arg);
}
}
if (positional.length > 0) {
result.profileName = positional[0];
}
return result;
}
export function rejectUnsupportedOptions(parsed: CodexAuthArgs, usage: string): void {
if (parsed.unknownFlags && parsed.unknownFlags.length > 0) {
console.log(`Usage: ${color(usage, 'command')}`);
exitWithError('Unknown options', ExitCode.GENERAL_ERROR);
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* codex-auth use command.
*
* STDOUT DISCIPLINE (C2, R4): stdout contains ONLY shell-evalable export
* statements. ALL errors, hints, and info messages go to STDERR so that
* `eval "$(ccsx auth use <name>)"` is never contaminated.
*
* Belt-and-suspenders for C2: the primary protection is
* `src/bin/codex-runtime-router.ts`, which dispatches `auth` subcommands
* BEFORE pre-dispatch runs at all. This IIFE is a fallback for any future
* code path (e.g., direct import from a different bin entry) that bypasses
* the router and would otherwise let pre-dispatch banners hit stdout.
*/
(function guardPreDispatch() {
const argv = process.argv;
// argv[2] is the first user arg to ccsx (e.g. "auth"), argv[3] is the subcommand
for (let i = 2; i < argv.length - 1; i++) {
if (argv[i] === 'auth' && argv[i + 1] === 'use') {
process.env.CCS_NO_PRE_DISPATCH = '1';
break;
}
}
})();
// ── End guard — safe to import CCS modules now ───────────────────────────────
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { resolveCodexProfileDir } from '../codex-profile-paths';
import { detectShell, formatExport } from '../shell-detect';
import { parseArgs, rejectUnsupportedOptions, getProfileNameError } from './types';
import type { Shell } from '../shell-detect';
import type { CodexCommandContext } from './types';
const VALID_SHELLS = new Set<string>(['bash', 'zsh', 'fish', 'pwsh', 'cmd']);
export async function handleUseCodex(ctx: CodexCommandContext, args: string[]): Promise<void> {
const parsed = parseArgs(args);
rejectUnsupportedOptions(parsed, 'ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]');
const { profileName, shell: shellOverride } = parsed;
// All errors → stderr, empty stdout
if (!profileName) {
process.stderr.write('[X] Profile name is required.\n');
process.stderr.write('Usage: ccsx auth use <name> [--shell <bash|zsh|fish|pwsh|cmd>]\n');
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const nameError = getProfileNameError(profileName);
if (nameError) {
process.stderr.write(`[X] ${nameError}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
if (shellOverride !== undefined && !VALID_SHELLS.has(shellOverride)) {
process.stderr.write(
`[X] Unsupported --shell value: "${shellOverride}". Valid: bash, zsh, fish, pwsh, cmd\n`
);
process.exit(ExitCode.GENERAL_ERROR);
return;
}
const { registry } = ctx;
if (!registry.hasProfile(profileName)) {
const available = registry.listProfiles();
const availableStr = available.length > 0 ? available.join(', ') : '<none>';
process.stderr.write(`[X] Profile not found: ${profileName}. Available: ${availableStr}\n`);
process.exit(ExitCode.PROFILE_ERROR);
return;
}
const profileDir = resolveCodexProfileDir(profileName);
const shell: Shell =
shellOverride !== undefined
? (shellOverride as Shell)
: detectShell(process.env, process.platform);
// ── STDOUT: only export statements ──────────────────────────────────────────
process.stdout.write(formatExport(shell, 'CODEX_HOME', profileDir) + '\n');
process.stdout.write(formatExport(shell, 'CCS_CODEX_PROFILE', profileName) + '\n');
// ── STDERR: human-readable hint ─────────────────────────────────────────────
process.stderr.write(`[i] Codex profile "${profileName}" active in this shell. Run: codex\n`);
if (shell === 'cmd') {
process.stderr.write('[i] Note: cmd.exe cannot eval output from a subprocess natively.\n');
process.stderr.write(
' Use PowerShell: ccsx auth use ' + profileName + ' | Invoke-Expression\n'
);
}
// Note: This profile applies only to native `codex`.
// `ccsxp` ignores CCS_CODEX_PROFILE and uses its own cliproxy pool.
}
// suppress unused import warning — exitWithError is available but we use process.exit
// for stdout purity in this command
void exitWithError;
+62
View File
@@ -0,0 +1,62 @@
/**
* Shell detection for codex-auth use command.
* Determines current shell to emit correct eval-safe export syntax.
*/
export type Shell = 'bash' | 'zsh' | 'fish' | 'pwsh' | 'cmd';
/**
* Detect current shell from environment.
* On Windows: PSModulePath presence → pwsh, else cmd.
* On Unix: inspect $SHELL suffix.
*/
export function detectShell(
env: NodeJS.ProcessEnv = process.env,
platform: string = process.platform
): Shell {
if (platform === 'win32') {
return env.PSModulePath ? 'pwsh' : 'cmd';
}
const sh = (env.SHELL ?? '').toLowerCase();
if (sh.endsWith('/fish')) return 'fish';
if (sh.endsWith('/zsh')) return 'zsh';
return 'bash'; // default for bash, sh, dash, ksh
}
/**
* Single-quote escape for POSIX shells (bash/zsh/fish).
* Closes the single-quote, inserts escaped quote, reopens.
*/
function posixSingleQuote(value: string): string {
return "'" + value.replace(/'/g, "'\\''") + "'";
}
/**
* Double-quote escape for PowerShell.
* Wraps in double quotes; escapes internal double quotes by doubling them
* and backtick-escapes $ to prevent variable interpolation.
*/
function pwshDoubleQuote(value: string): string {
return '"' + value.replace(/"/g, '""').replace(/\$/g, '`$') + '"';
}
/**
* Format a single env var export statement for the target shell.
* Used by use-command to emit eval-safe lines.
*/
export function formatExport(shell: Shell, key: string, value: string): string {
switch (shell) {
case 'fish':
return `set -gx ${key} ${posixSingleQuote(value)};`;
case 'pwsh':
return `$env:${key} = ${pwshDoubleQuote(value)}`;
case 'cmd':
// cmd.exe: no quoting — values are used verbatim.
// NOTE: cmd.exe cannot eval output from a node process natively.
// Users should prefer PowerShell. See --help for details.
return `set ${key}=${value}`;
default:
// bash / zsh
return `export ${key}=${posixSingleQuote(value)}`;
}
}
+8
View File
@@ -59,6 +59,14 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
}
}
// CCS_NO_PRE_DISPATCH guard — set by `ccsx auth use` to keep stdout clean
// for shell eval. Must be checked BEFORE autoMigrate/recovery, both of which
// write to stdout and would otherwise contaminate `eval "$(ccsx auth use <name>)"`.
// See: src/codex-auth/commands/use-command.ts (C2 in plan.md §Validation findings)
if (process.env.CCS_NO_PRE_DISPATCH === '1') {
return false;
}
// Auto-migrate to unified config format (silent if already migrated)
// Skip if user is explicitly running migrate command
if (firstArg !== 'migrate') {