@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
1,199 lines • 80 kB
JavaScript
#!/usr/bin/env node
/**
* NeuroLink Auth Command
*
* Unified authentication command for AI providers supporting:
* - API key authentication (traditional)
* - OAuth 2.1 authentication with PKCE (for Claude subscription)
*
* Subcommands:
* - login: Authenticate with a provider (supports --add/--label for multi-account)
* - logout: Clear stored credentials
* - status: Show authentication status
* - refresh: Manually refresh OAuth tokens
* - list: List all authenticated accounts
* - remove: Remove an authenticated account
*
* Currently supports:
* - Anthropic (API key + OAuth)
*/
import fs from "fs";
import path from "path";
import { execFile } from "child_process";
import { randomBytes, createHash } from "crypto";
import inquirer from "inquirer";
import chalk from "chalk";
import ora from "ora";
import { logger } from "../../lib/utils/logger.js";
import { defaultTokenStore } from "../../lib/auth/tokenStore.js";
import { CLAUDE_CODE_CLIENT_ID, ANTHROPIC_AUTH_URL, ANTHROPIC_TOKEN_URL, ANTHROPIC_REDIRECT_URI, CLAUDE_CLI_USER_AGENT, OAUTH_BETA_HEADERS, } from "../../lib/auth/anthropicOAuth.js";
import { loadAccountQuotas } from "../../lib/proxy/accountQuota.js";
// =============================================================================
// CONSTANTS
// =============================================================================
const NEUROLINK_CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || ".", ".neurolink");
const ENV_FILE_PATH = path.join(process.cwd(), ".env");
// Anthropic OAuth Configuration (Claude Code Official) - For direct OAuth usage
// Uses claude.ai/oauth/authorize for Claude Pro/Max subscription access
const ANTHROPIC_OAUTH_CONFIG = {
clientId: CLAUDE_CODE_CLIENT_ID,
// NOTE: Uses claude.ai NOT console.anthropic.com for direct OAuth
authorizationUrl: ANTHROPIC_AUTH_URL,
tokenUrl: ANTHROPIC_TOKEN_URL,
redirectUri: ANTHROPIC_REDIRECT_URI,
// Scopes for direct OAuth (no API key creation needed)
scope: "user:profile user:inference",
userAgent: CLAUDE_CLI_USER_AGENT,
betaHeaders: OAUTH_BETA_HEADERS,
};
// Anthropic Console OAuth Configuration - For API key creation flow
// This uses console.anthropic.com for authorization which grants org:create_api_key scope
const ANTHROPIC_CONSOLE_OAUTH_CONFIG = {
clientId: CLAUDE_CODE_CLIENT_ID,
// Authorization URL for console (required for API key creation scope)
authorizationUrl: "https://console.anthropic.com/oauth/authorize",
tokenUrl: ANTHROPIC_TOKEN_URL,
redirectUri: ANTHROPIC_REDIRECT_URI,
// Required scopes - org:create_api_key is needed for API key creation
scope: "org:create_api_key user:profile user:inference",
userAgent: CLAUDE_CLI_USER_AGENT,
// API key creation endpoint
createApiKeyUrl: "https://api.anthropic.com/api/oauth/claude_cli/create_api_key",
};
// Supported providers
const SUPPORTED_PROVIDERS = ["anthropic"];
// =============================================================================
// SUBCOMMAND HANDLERS
// =============================================================================
/**
* Handle the login subcommand
* `neurolink auth login <provider>`
*
* When --add is specified, saves tokens to the TokenStore with a compound key
* (e.g., "anthropic:alice") to support multi-account pools.
*/
export async function handleLogin(argv) {
try {
const provider = argv.provider?.toLowerCase();
// Validate provider
if (!SUPPORTED_PROVIDERS.includes(provider)) {
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
process.exit(1);
}
// If method is specified, use it directly
// Each handler returns true when credentials were written to a file,
// false for .env-only or "keep existing" paths.
let wroteCredentials = false;
if (argv.method) {
if (argv.method === "api-key") {
wroteCredentials = await handleApiKeyAuth(provider, !argv.nonInteractive);
}
else if (argv.method === "oauth") {
await handleOAuthAuth(provider);
wroteCredentials = true;
}
else if (argv.method === "create-api-key") {
await handleCreateApiKeyOAuth(provider);
wroteCredentials = true;
}
}
else {
// Interactive mode - ask user which method they prefer
wroteCredentials = await handleInteractiveAuth(provider);
}
// Only save to TokenStore when the auth flow actually wrote credentials.
// Skip for .env-only or "keep existing" paths — re-reading a stale or
// nonexistent credentials file is wasteful and can produce wrong entries.
if (wroteCredentials) {
await saveAccountToPool(provider, argv.label);
}
}
catch (error) {
logger.error(chalk.red("Authentication failed:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
// ---------------------------------------------------------------------------
// Quota display helpers
// ---------------------------------------------------------------------------
/**
* Convert a future unix timestamp (seconds) into a human-readable relative
* duration like "2h 15m" or "4d 3h". Returns "now" if the time has passed.
*/
function formatTimeUntil(unixTimestamp) {
const ms = unixTimestamp * 1000 - Date.now();
if (ms <= 0) {
return "now";
}
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
const remainHours = hours % 24;
return `${days}d ${remainHours}h`;
}
return `${hours}h ${minutes}m`;
}
/**
* Format session/weekly quota into compact display strings.
*/
function formatQuotaColumns(quota) {
const sessionRemaining = Math.round((1 - quota.sessionUsed) * 100);
const weeklyRemaining = Math.round((1 - quota.weeklyUsed) * 100);
const colorize = (pct, text) => {
if (pct <= 10) {
return chalk.red(text);
}
if (pct <= 30) {
return chalk.yellow(text);
}
return chalk.green(text);
};
return {
sessionText: colorize(sessionRemaining, `${sessionRemaining}% left`),
weeklyText: colorize(weeklyRemaining, `${weeklyRemaining}% left`),
sessionReset: quota.sessionResetAt > 0
? chalk.gray(`resets ${formatTimeUntil(quota.sessionResetAt)}`)
: "",
weeklyReset: quota.weeklyResetAt > 0
? chalk.gray(`resets ${formatTimeUntil(quota.weeklyResetAt)}`)
: "",
};
}
/**
* Handle the list subcommand
* `neurolink auth list`
*
* Lists all authenticated accounts from the TokenStore.
*/
export async function handleList(argv) {
try {
const allKeys = await defaultTokenStore.listProviders();
if (allKeys.length === 0) {
if (argv.format === "json") {
logger.always(JSON.stringify([], null, 2));
}
else {
logger.always(chalk.yellow("\nNo authenticated accounts found.\n"));
logger.always(chalk.blue("Run 'neurolink auth login <provider>' to authenticate.\n"));
}
return;
}
// Build enriched account list with token metadata
const enrichedAccounts = await Promise.all(allKeys.map(async (key) => {
const parts = key.split(":");
const provider = parts[0];
const label = parts.length > 1 ? parts.slice(1).join(":") : undefined;
let tier;
let email;
let tokenStatus = "unknown";
let expiresAt;
// Derive email from the compound key label when it looks like an email.
// The credentials file is a shared singleton that gets overwritten on
// every login — reading email from it would show the LATEST login's
// email for ALL accounts. The label is the per-account source of truth.
if (label && label.includes("@")) {
email = label;
}
// Fall back to credentials file ONLY for the default (unlabeled) account.
// Compound-key entries (labeled accounts) must NOT read the shared
// credentials file because it gets overwritten on every login and would
// show the latest login's email/tier for all accounts. Per-account
// metadata is encoded in the token's scope field instead.
if (!email && !label) {
try {
const credPath = path.join(NEUROLINK_CONFIG_DIR, `${provider}-credentials.json`);
if (fs.existsSync(credPath)) {
const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
email = creds.email;
if (creds.subscriptionTier) {
tier = creds.subscriptionTier;
}
}
}
catch {
/* non-fatal */
}
}
try {
const tokens = await defaultTokenStore.loadTokens(key);
if (tokens) {
expiresAt = tokens.expiresAt;
const isExpired = defaultTokenStore.isTokenExpired(tokens, 0);
tokenStatus = isExpired ? "expired" : "valid";
// Extract per-account metadata from scope (e.g. "tier:pro email:user@example.com")
if (tokens.scope) {
if (!tier) {
const tierMatch = tokens.scope.match(/tier:(\w+)/);
if (tierMatch) {
tier = tierMatch[1];
}
}
if (!email) {
const emailMatch = tokens.scope.match(/email:(\S+)/);
if (emailMatch) {
email = emailMatch[1];
}
}
}
}
}
catch {
// Token load failed — show as unknown
}
return { key, provider, label, email, tier, tokenStatus, expiresAt };
}));
// Load persisted quota data (captured from proxy responses).
let quotas = {};
try {
quotas = await loadAccountQuotas();
}
catch {
// Non-fatal — quota display is best-effort
}
if (argv.format === "json") {
// Merge quota data into each account object for JSON output
const withQuota = enrichedAccounts.map((acct) => {
const quotaKey = acct.label ?? acct.key;
const quota = quotas[quotaKey] ?? null;
return { ...acct, quota };
});
logger.always(JSON.stringify(withQuota, null, 2));
}
else {
logger.always(chalk.bold("\nAuthenticated Accounts:\n"));
// Check if any account has quota data to decide column layout
const hasQuota = enrichedAccounts.some((acct) => {
const quotaKey = acct.label ?? acct.key;
return quotas[quotaKey] !== undefined;
});
// Table header
const colKey = "LABEL".padEnd(20);
const colEmail = "EMAIL".padEnd(28);
const colStatus = "TOKEN STATUS".padEnd(14);
const colProvider = "PROVIDER".padEnd(12);
const colSession = hasQuota ? "SESSION".padEnd(10) : "";
const colWeekly = hasQuota ? "WEEKLY".padEnd(10) : "";
logger.always(` ${chalk.gray(colKey)} ${chalk.gray(colProvider)} ${chalk.gray(colEmail)} ${chalk.gray(colStatus)}${hasQuota ? ` ${chalk.gray(colSession)} ${chalk.gray(colWeekly)}` : ""}`);
logger.always(` ${chalk.gray("-".repeat(hasQuota ? 100 : 78))}`);
for (const acct of enrichedAccounts) {
const displayLabel = (acct.label ?? acct.key).padEnd(20);
const displayEmail = (acct.email ?? "-").padEnd(28);
const displayProvider = acct.provider.padEnd(12);
let statusText;
if (acct.tokenStatus === "valid") {
statusText = chalk.green("valid".padEnd(14));
}
else if (acct.tokenStatus === "expired") {
statusText = chalk.red("expired".padEnd(14));
}
else {
statusText = chalk.yellow("unknown".padEnd(14));
}
const quotaKey = acct.label ?? acct.key;
const quota = quotas[quotaKey];
if (hasQuota && quota) {
const qc = formatQuotaColumns(quota);
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText} ${qc.sessionText.padEnd(10)} ${qc.weeklyText.padEnd(10)}`);
// Second line: reset times (indented under session/weekly columns)
if (qc.sessionReset || qc.weeklyReset) {
const indent = " ".repeat(2 + 20 + 1 + 12 + 1 + 28 + 1 + 14 + 1);
logger.always(`${indent}${(qc.sessionReset || "").padEnd(10)} ${qc.weeklyReset || ""}`);
}
}
else {
logger.always(` ${chalk.cyan(displayLabel)} ${displayProvider} ${displayEmail} ${statusText}${hasQuota ? " - -" : ""}`);
}
}
logger.always("");
}
}
catch (error) {
logger.error(chalk.red("Failed to list accounts:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the remove subcommand
* `neurolink auth remove <provider> --label <label>` or `neurolink auth remove <provider> --account <key>`
*
* Removes an authenticated account from the TokenStore.
* When neither --label nor --account is given, removes the default (unlabelled) account
* for the specified provider.
*/
export async function handleRemove(argv) {
try {
const provider = argv.provider?.toLowerCase() ?? "anthropic";
// Resolve the compound key from --account, --label, or provider default
let compoundKey;
if (argv.account) {
compoundKey = argv.account;
}
else if (argv.label) {
compoundKey = `${provider}:${argv.label}`;
}
else {
// Remove the default (unlabelled) account for this provider
compoundKey = provider;
}
// Check if the account exists
const allKeys = await defaultTokenStore.listProviders();
if (!allKeys.includes(compoundKey)) {
logger.error(chalk.red(`Account not found: ${compoundKey}`));
logger.always(chalk.blue("\nRun 'neurolink auth list' to see all authenticated accounts.\n"));
process.exit(1);
}
await defaultTokenStore.clearTokens(compoundKey);
// Only remove the legacy credentials file for bare provider keys.
// Compound keys (e.g., "anthropic:alice") should not touch the shared
// legacy credentials file — it may belong to the default account.
if (!compoundKey.includes(":")) {
const legacyCredFile = path.join(NEUROLINK_CONFIG_DIR, `${compoundKey}-credentials.json`);
try {
if (fs.existsSync(legacyCredFile)) {
fs.unlinkSync(legacyCredFile);
logger.debug(`Removed legacy credentials file: ${legacyCredFile}`);
}
}
catch {
// Non-fatal — legacy file may not exist or may already be gone
}
}
logger.always(chalk.green(`\nAccount removed: ${compoundKey}\n`));
}
catch (error) {
logger.error(chalk.red("Failed to remove account:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the logout subcommand
* `neurolink auth logout <provider>`
*/
export async function handleLogout(argv) {
try {
const provider = argv.provider?.toLowerCase();
// Validate provider
if (!SUPPORTED_PROVIDERS.includes(provider)) {
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
process.exit(1);
}
logger.always(chalk.blue(`\nClearing ${provider} credentials...\n`));
const spinner = argv.quiet
? null
: ora("Removing stored credentials...").start();
try {
// Clear stored credentials file
const credentialsFile = path.join(NEUROLINK_CONFIG_DIR, `${provider}-credentials.json`);
if (fs.existsSync(credentialsFile)) {
fs.unlinkSync(credentialsFile);
if (spinner) {
spinner.succeed("Stored credentials removed");
}
}
else {
if (spinner) {
spinner.info("No stored credentials found");
}
}
// Also clear from TokenStore — both the bare provider key and all
// compound-key entries (provider:label) used by the account pool.
try {
await defaultTokenStore.clearTokens(provider);
}
catch {
// Ignore if no tokens stored for bare key
}
try {
const allKeys = await defaultTokenStore.listProviders();
for (const k of allKeys) {
if (k.startsWith(`${provider}:`)) {
await defaultTokenStore.clearTokens(k);
logger.debug(`Cleared pooled account: ${k}`);
}
}
}
catch {
// Ignore if listing/clearing fails
}
// Check for environment variable
const envVar = getEnvVarName(provider);
const hasEnvKey = !!process.env[envVar];
if (hasEnvKey) {
logger.always("");
logger.always(chalk.yellow(`Note: ${envVar} is still set in your environment or .env file.`));
logger.always(chalk.yellow("You may need to manually remove it from your shell profile or .env file."));
// Offer to remove from .env if it exists
if (fs.existsSync(ENV_FILE_PATH)) {
const { removeFromEnv } = await inquirer.prompt([
{
type: "confirm",
name: "removeFromEnv",
message: `Remove ${envVar} from .env file?`,
default: false,
},
]);
if (removeFromEnv) {
await removeFromEnvFile(envVar);
logger.always(chalk.green(`Removed ${envVar} from .env file`));
}
}
}
logger.always("");
logger.always(chalk.green(`${provider} credentials cleared successfully.`));
}
catch (error) {
if (spinner) {
spinner.fail("Failed to clear credentials");
}
throw error;
}
}
catch (error) {
logger.error(chalk.red("Logout failed:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the status subcommand
* `neurolink auth status [provider]`
*/
export async function handleStatus(argv) {
try {
const provider = argv.provider?.toLowerCase();
// If provider specified, show just that provider
const providersToCheck = provider
? [provider]
: [...SUPPORTED_PROVIDERS];
const results = [];
for (const p of providersToCheck) {
const status = await getAuthStatus(p);
results.push(status);
}
// Output results
if (argv.format === "json") {
logger.always(JSON.stringify(results, null, 2));
}
else {
logger.always(chalk.bold("\nAuthentication Status:\n"));
for (const status of results) {
const providerName = status.provider.charAt(0).toUpperCase() + status.provider.slice(1);
const statusIcon = status.isAuthenticated
? chalk.green("[Authenticated]")
: chalk.yellow("[Not Authenticated]");
logger.always(`${chalk.cyan(providerName)} ${statusIcon}`);
if (status.isAuthenticated) {
logger.always(` Method: ${status.method}`);
if (status.subscriptionTier) {
logger.always(` Subscription: ${chalk.blue(status.subscriptionTier)}`);
}
if (status.method === "oauth") {
if (status.tokenExpiry) {
const isExpired = status.needsRefresh;
const expiryLabel = isExpired
? chalk.red("Expired")
: chalk.green(status.tokenExpiry);
logger.always(` Token Expires: ${expiryLabel}`);
}
if (status.hasRefreshToken) {
logger.always(` Refresh Token: ${chalk.green("Available")}`);
}
else {
logger.always(` Refresh Token: ${chalk.yellow("Not available")}`);
}
if (status.needsRefresh && status.hasRefreshToken) {
logger.always(chalk.yellow(` Run 'neurolink auth refresh ${status.provider}' to refresh tokens`));
}
}
}
else {
logger.always(chalk.blue(` Run 'neurolink auth login ${status.provider}' to authenticate`));
}
logger.always("");
}
}
}
catch (error) {
logger.error(chalk.red("Status check failed:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the refresh subcommand
* `neurolink auth refresh <provider>`
*/
export async function handleRefresh(argv) {
try {
const provider = argv.provider?.toLowerCase();
// Validate provider
if (!SUPPORTED_PROVIDERS.includes(provider)) {
logger.error(chalk.red(`Unsupported provider: ${provider}. Supported: ${SUPPORTED_PROVIDERS.join(", ")}`));
process.exit(1);
}
logger.always(chalk.blue(`\nRefreshing ${provider} OAuth tokens...\n`));
const spinner = argv.quiet ? null : ora("Reading stored tokens...").start();
try {
// Get stored credentials
const credentials = await getStoredCredentials(provider);
if (!credentials || credentials.type !== "oauth") {
if (spinner) {
spinner.fail("No OAuth credentials found");
}
logger.error(chalk.red(`No OAuth authentication found for ${provider}. Use 'neurolink auth login ${provider} --method oauth' first.`));
process.exit(1);
}
if (!credentials.oauth?.refreshToken) {
if (spinner) {
spinner.fail("No refresh token available");
}
logger.error(chalk.red("No refresh token available. Please re-authenticate with OAuth."));
process.exit(1);
}
if (spinner) {
spinner.text = "Refreshing access token...";
}
// Refresh the token with Claude CLI User-Agent
// IMPORTANT: Uses JSON body, not URLSearchParams
const tokenResponse = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": ANTHROPIC_OAUTH_CONFIG.userAgent,
},
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: credentials.oauth.refreshToken,
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
}),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
throw new Error(`Token refresh failed: ${tokenResponse.status} - ${errorText}`);
}
const tokenData = (await tokenResponse.json());
// Update stored tokens
const newTokens = {
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token || credentials.oauth.refreshToken,
expiresAt: tokenData.expires_in
? Date.now() + tokenData.expires_in * 1000
: undefined,
tokenType: tokenData.token_type || "Bearer",
scope: tokenData.scope,
};
await saveStoredCredentials(provider, {
type: "oauth",
oauth: newTokens,
provider,
subscriptionTier: credentials.subscriptionTier,
createdAt: credentials.createdAt,
updatedAt: Date.now(),
});
// Also update the TokenStore pool entries for this provider.
// Update both the bare provider key and compound "provider:label" keys
// so that pooled accounts created via saveAccountToPool() stay current.
try {
const allKeys = await defaultTokenStore.listProviders();
for (const key of allKeys) {
if (key === provider || key.startsWith(`${provider}:`)) {
const existingTokens = await defaultTokenStore.loadTokens(key);
if (existingTokens) {
await defaultTokenStore.saveTokens(key, {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
expiresAt: newTokens.expiresAt ?? Date.now() + 3600 * 1000,
tokenType: newTokens.tokenType || "Bearer",
scope: existingTokens.scope, // preserve existing scope metadata
});
logger.debug(`Updated TokenStore entry: ${key}`);
}
}
}
}
catch (poolErr) {
logger.debug(`[auth] Failed to update TokenStore pool entries: ${poolErr instanceof Error ? poolErr.message : String(poolErr)}`);
}
if (spinner) {
spinner.succeed("Access token refreshed successfully!");
}
logger.always("");
logger.always(chalk.green("Token refresh complete!"));
if (newTokens.expiresAt) {
logger.always(` New expiry: ${new Date(newTokens.expiresAt).toLocaleString()}`);
}
}
catch (error) {
if (spinner) {
spinner.fail("Token refresh failed");
}
throw error;
}
}
catch (error) {
logger.error(chalk.red("Token refresh failed:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the cleanup subcommand
* `neurolink auth cleanup [--force]`
*
* Removes stale accounts from the token store:
* 1. Expired entries with no refresh token (via pruneExpired)
* 2. Permanently disabled entries (after confirmation)
*/
export async function handleCleanup(argv) {
try {
const removed = [];
// Step 1: Prune expired entries that have no refresh token
const pruned = await defaultTokenStore.pruneExpired();
for (const key of pruned) {
removed.push({ key, reason: "expired, no refresh token" });
}
// Step 2: Find disabled entries (pruneExpired already removes disabled
// entries, but in case the user runs cleanup with entries that were
// disabled between the prune call and now, check again)
const disabledKeys = await defaultTokenStore.listDisabled();
if (disabledKeys.length > 0) {
let shouldRemove = false;
if (argv.force || argv.nonInteractive) {
shouldRemove = true;
}
else {
logger.always(chalk.yellow(`\nFound ${disabledKeys.length} disabled account(s):`));
for (const key of disabledKeys) {
logger.always(` - ${key}`);
}
const { confirm } = await inquirer.prompt([
{
name: "confirm",
type: "confirm",
message: "Remove these disabled accounts?",
default: false,
},
]);
shouldRemove = confirm;
}
if (shouldRemove) {
for (const key of disabledKeys) {
await defaultTokenStore.clearTokens(key);
removed.push({ key, reason: "disabled: refresh_failed" });
}
}
}
// Report results
if (removed.length === 0) {
logger.always(chalk.green("No stale accounts found."));
}
else {
logger.always(chalk.green(`\nCleaned up ${removed.length} stale account${removed.length === 1 ? "" : "s"}:`));
for (const entry of removed) {
logger.always(` - ${entry.key} (${entry.reason})`);
}
logger.always("");
}
}
catch (error) {
logger.error(chalk.red("Cleanup failed:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the enable subcommand
* `neurolink auth enable <account>`
*
* Re-enables a previously disabled account so it can be used by the proxy pool again.
*/
export async function handleEnable(argv) {
try {
// Resolve account key from --account option or positional arg
const accountKey = argv.account || (argv._ && argv._[2] ? String(argv._[2]) : undefined);
if (!accountKey) {
logger.error(chalk.red("Missing required argument: <account>"));
logger.always(chalk.blue("\nUsage: neurolink auth enable <account>\n" +
"Run 'neurolink auth list' to see all accounts.\n"));
process.exit(1);
}
// Check if account exists in the token store
const allKeys = await defaultTokenStore.listProviders();
if (!allKeys.includes(accountKey)) {
logger.error(chalk.red(`Account not found: ${accountKey}`));
logger.always(chalk.blue("\nRun 'neurolink auth list' to see all authenticated accounts.\n"));
process.exit(1);
}
await defaultTokenStore.markEnabled(accountKey);
logger.always(chalk.green(`\nRe-enabled account: ${accountKey}\n`));
}
catch (error) {
logger.error(chalk.red("Failed to enable account:"));
logger.error(chalk.red(error instanceof Error ? error.message : "Unknown error"));
process.exit(1);
}
}
// =============================================================================
// PRIMARY ACCOUNT (proxy routing.primaryAccount in YAML)
// =============================================================================
const DEFAULT_PROXY_CONFIG_PATH = path.join(NEUROLINK_CONFIG_DIR, "proxy-config.yaml");
/** Lazy-load js-yaml. Returns undefined if unavailable; callers that need YAML
* output (rather than JSON fallback) should error with install guidance. */
async function tryLoadJsYaml() {
try {
return (await import(/* @vite-ignore */ "js-yaml"));
}
catch {
return undefined;
}
}
function isYamlPath(p) {
const lower = p.toLowerCase();
return lower.endsWith(".yaml") || lower.endsWith(".yml");
}
/** Read and parse a proxy config file. Returns an empty object when the file
* doesn't exist (caller can mutate and write). Detects YAML vs JSON by
* extension; falls back to JSON parsing if js-yaml is unavailable for a YAML
* file (errors loudly if neither parser can read it). */
async function readProxyConfigFile(filePath) {
const yamlExpected = isYamlPath(filePath);
let raw;
try {
raw = fs.readFileSync(filePath, "utf-8");
}
catch (err) {
const code = err.code;
if (code === "ENOENT") {
return {
data: {},
format: yamlExpected ? "yaml" : "json",
hadComments: false,
};
}
throw err;
}
const hadComments = /^\s*#/m.test(raw);
if (yamlExpected) {
const yaml = await tryLoadJsYaml();
if (yaml) {
const parsed = yaml.default?.load?.(raw) ?? yaml.load(raw);
return {
data: parsed && typeof parsed === "object"
? parsed
: {},
format: "yaml",
hadComments,
};
}
// YAML expected but js-yaml absent — try JSON
try {
return { data: JSON.parse(raw), format: "json", hadComments };
}
catch {
throw new Error(`Cannot edit ${filePath}: js-yaml is not installed and the file is ` +
`not valid JSON. Install js-yaml (pnpm add -D js-yaml) or convert ` +
`the file to JSON.`);
}
}
return { data: JSON.parse(raw), format: "json", hadComments };
}
/** Serialize and write a proxy config file. */
async function writeProxyConfigFile(filePath, doc) {
let serialized;
if (doc.format === "yaml") {
const yaml = await tryLoadJsYaml();
if (!yaml) {
throw new Error(`Cannot write ${filePath} as YAML: js-yaml is not installed. ` +
`Install it (pnpm add -D js-yaml) or use a .json config path.`);
}
const dump = yaml.default?.dump ?? yaml.dump;
if (!dump) {
throw new Error(`Cannot write ${filePath} as YAML: js-yaml module does not expose ` +
`a dump function (unexpected version).`);
}
serialized = dump(doc.data, {
lineWidth: 100,
noRefs: true,
});
}
else {
serialized = `${JSON.stringify(doc.data, null, 2)}\n`;
}
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, serialized, "utf-8");
}
function getRoutingObject(data) {
const routing = data.routing;
if (routing && typeof routing === "object" && !Array.isArray(routing)) {
return routing;
}
const fresh = {};
data.routing = fresh;
return fresh;
}
function readPrimaryFromRouting(routing) {
if (!routing) {
return undefined;
}
const kebab = routing["primary-account"];
if (typeof kebab === "string" && kebab.trim() !== "") {
return kebab.trim();
}
const camel = routing.primaryAccount;
if (typeof camel === "string" && camel.trim() !== "") {
return camel.trim();
}
return undefined;
}
/** Best-effort detection of a running proxy. Mirrors `proxy status` semantics
* without importing the proxy module. */
function detectRunningProxyPid() {
try {
const stateFile = path.join(NEUROLINK_CONFIG_DIR, "proxy-state.json");
if (!fs.existsSync(stateFile)) {
return undefined;
}
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
if (!parsed.pid || typeof parsed.pid !== "number") {
return undefined;
}
process.kill(parsed.pid, 0);
return parsed.pid;
}
catch {
return undefined;
}
}
/**
* Handle the set-primary subcommand
* `neurolink auth set-primary <email> [--config <path>]`
*
* Writes routing.primary-account to the proxy config YAML so the proxy
* tries this account first under fill-first/round-robin home semantics.
* Does not touch the token store.
*/
export async function handleSetPrimary(argv) {
const email = argv.email ?? (argv._ && argv._[2] ? String(argv._[2]) : undefined);
if (!email || email.trim() === "") {
logger.error(chalk.red("Missing required argument: <email>"));
logger.always(chalk.blue("\nUsage: neurolink auth set-primary <email> [--config <path>]\n" +
"Run 'neurolink auth list' to see authenticated accounts.\n"));
process.exit(1);
}
const trimmed = email.trim();
const filePath = argv.config ?? DEFAULT_PROXY_CONFIG_PATH;
try {
const doc = await readProxyConfigFile(filePath);
if (doc.hadComments) {
logger.always(chalk.yellow(`⚠ Note: existing YAML comments in ${filePath} will not be preserved.`));
}
const routing = getRoutingObject(doc.data);
delete routing.primaryAccount;
routing["primary-account"] = trimmed;
await writeProxyConfigFile(filePath, doc);
logger.always(chalk.green(`✓ Set primary account → ${trimmed}`));
logger.always(chalk.green(`✓ Saved to ${filePath}`));
// Token-store presence check (non-fatal)
const compoundKey = `anthropic:${trimmed}`;
const known = await defaultTokenStore.listByPrefix("anthropic:");
if (!known.includes(compoundKey)) {
logger.always("");
logger.always(chalk.yellow("⚠ This account is not currently authenticated. Run\n" +
" `neurolink auth login --add` to add it. The proxy will fall\n" +
" back to the first enabled account until then."));
}
// Restart hint
const pid = detectRunningProxyPid();
if (pid) {
logger.always("");
logger.always(chalk.yellow(`⚠ A proxy is currently running (PID ${pid}). Restart it to pick\n` +
" up the change: `neurolink proxy stop && neurolink proxy start`."));
}
}
catch (err) {
logger.error(chalk.red("Failed to set primary account:"));
logger.error(chalk.red(err instanceof Error ? err.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the get-primary subcommand
* `neurolink auth get-primary [--config <path>]`
*/
export async function handleGetPrimary(argv) {
const filePath = argv.config ?? DEFAULT_PROXY_CONFIG_PATH;
try {
if (!fs.existsSync(filePath)) {
logger.always(chalk.blue(`No proxy config file found at ${filePath}.`));
logger.always("Falling back to insertion-order index 0 (no primary configured).");
return;
}
const doc = await readProxyConfigFile(filePath);
const routing = typeof doc.data.routing === "object" && doc.data.routing
? doc.data.routing
: undefined;
const primary = readPrimaryFromRouting(routing);
if (!primary) {
logger.always(chalk.blue(`No primary account configured. Falling back to insertion-order ` +
`index 0.`));
logger.always(`Source: ${filePath} (no \`routing.primaryAccount\` field)`);
return;
}
const compoundKey = `anthropic:${primary}`;
const known = await defaultTokenStore.listByPrefix("anthropic:");
const present = known.includes(compoundKey);
logger.always(chalk.bold(`Configured primary: ${primary}`));
logger.always(`Status: ${present
? chalk.green(`authenticated (${compoundKey} present in token store)`)
: chalk.yellow(`not authenticated (token store has no ${compoundKey})`)}`);
logger.always(`Source: ${filePath}`);
}
catch (err) {
logger.error(chalk.red("Failed to read primary account:"));
logger.error(chalk.red(err instanceof Error ? err.message : "Unknown error"));
process.exit(1);
}
}
/**
* Handle the clear-primary subcommand
* `neurolink auth clear-primary [--config <path>]`
*/
export async function handleClearPrimary(argv) {
const filePath = argv.config ?? DEFAULT_PROXY_CONFIG_PATH;
try {
if (!fs.existsSync(filePath)) {
logger.always(chalk.blue(`No proxy config file found at ${filePath}.`));
logger.always("Nothing to clear.");
return;
}
const doc = await readProxyConfigFile(filePath);
const routing = typeof doc.data.routing === "object" && doc.data.routing
? doc.data.routing
: undefined;
const before = readPrimaryFromRouting(routing);
if (!before || !routing) {
logger.always(chalk.blue("No primary account was configured."));
return;
}
if (doc.hadComments) {
logger.always(chalk.yellow(`⚠ Note: existing YAML comments in ${filePath} will not be preserved.`));
}
delete routing.primaryAccount;
delete routing["primary-account"];
await writeProxyConfigFile(filePath, doc);
logger.always(chalk.green(`✓ Cleared primary account (was: ${before})`));
logger.always(chalk.green(`✓ Saved to ${filePath}`));
const pid = detectRunningProxyPid();
if (pid) {
logger.always("");
logger.always(chalk.yellow(`⚠ A proxy is currently running (PID ${pid}). Restart it to pick\n` +
" up the change: `neurolink proxy stop && neurolink proxy start`."));
}
}
catch (err) {
logger.error(chalk.red("Failed to clear primary account:"));
logger.error(chalk.red(err instanceof Error ? err.message : "Unknown error"));
process.exit(1);
}
}
// =============================================================================
// LEGACY HANDLER (for backward compatibility)
// =============================================================================
/**
* Legacy main auth command handler
* @deprecated Use subcommand handlers instead
*/
export async function handleAuth(argv) {
// Map legacy flags to subcommands
if (argv.status) {
await handleStatus({
provider: argv.provider,
format: "text",
quiet: false,
debug: argv.debug,
});
}
else if (argv.logout) {
await handleLogout({
provider: argv.provider,
format: "text",
quiet: false,
debug: argv.debug,
});
}
else {
await handleLogin({
provider: argv.provider,
method: argv.method,
format: "text",
quiet: false,
nonInteractive: argv.nonInteractive,
debug: argv.debug,
});
}
}
// =============================================================================
// AUTHENTICATION METHODS
// =============================================================================
/**
* Interactive authentication - ask user which method they prefer
*/
async function handleInteractiveAuth(provider) {
logger.always(chalk.blue(`\n${provider.charAt(0).toUpperCase() + provider.slice(1)} Authentication Setup\n`));
const currentStatus = await checkExistingAuth(provider);
if (currentStatus.hasValidAuth) {
logger.always(chalk.green("You already have valid authentication configured."));
logger.always(` Type: ${currentStatus.type}`);
if (currentStatus.type === "api-key") {
logger.always(` Key: ${maskCredential(currentStatus.credential || "")}`);
}
logger.always("");
const { reconfigure } = await inquirer.prompt([
{
type: "confirm",
name: "reconfigure",
message: "Would you like to reconfigure authentication?",
default: false,
},
]);
if (!reconfigure) {
logger.always(chalk.blue("Keeping existing configuration."));
return false;
}
}
// Show authentication method options
const { method } = await inquirer.prompt([
{
type: "list",
name: "method",
message: "Select authentication method:",
choices: [
{
name: "API Key - Traditional authentication with API key (pay-per-use)",
value: "api-key",
},
{
name: "Claude Pro/Max OAuth - Use subscription directly (Recommended for Pro/Max users)",
value: "oauth",
},
{
name: "Create API Key (via OAuth) - Creates a real API key using your account",
value: "create-api-key",
},
],
},
]);
if (method === "api-key") {
return await handleApiKeyAuth(provider, true);
}
else if (method === "create-api-key") {
await handleCreateApiKeyOAuth(provider);
return true;
}
else {
await handleOAuthAuth(provider);
return true;
}
}
/**
* Handle API key authentication
*/
async function handleApiKeyAuth(provider, interactive) {
logger.always(chalk.blue("\nAPI Key Authentication\n"));
if (provider === "anthropic") {
logger.always(chalk.yellow("To get your Anthropic API key:"));
logger.always("1. Visit: https://console.anthropic.com/");
logger.always("2. Sign in to your Anthropic account");
logger.always("3. Go to 'API Keys' section");
logger.always("4. Click 'Create Key' and copy the API key (starts with sk-ant-)");
logger.always("");
}
if (!interactive) {
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
if (envKey) {
await saveStoredCredentials(provider, {
type: "api-key",
apiKey: envKey,
provider,
createdAt: Date.now(),
updatedAt: Date.now(),
});
logger.always(chalk.green("Using ANTHROPIC_API_KEY from environment."));
return true;
}
throw new Error("Non-interactive mode requires ANTHROPIC_API_KEY environment variable when using --method api-key");
}
const { apiKey } = await inquirer.prompt([
{
type: "password",
name: "apiKey",
message: `Enter your ${provider} API key:`,
validate: (input) => {
if (!input.trim()) {
return "API key is required";
}
if (provider === "anthropic" && !input.startsWith("sk-ant-")) {
return "Anthropic API key should start with 'sk-ant-'";
}
if (input.trim().length < 20) {
return "API key seems too short";
}
return true;
},
},
]);
const trimmedKey = apiKey.trim();
// Validate the API key
const spinner = ora("Validating API key...").start();
try {
const isValid = await validateApiKey(provider, trimmedKey);
if (!isValid) {
spinner.fail("API key validation failed");
throw new Error("The API key could not be validated. Please check and try again.");
}
spinner.succeed("API key validated successfully");
}
catch (error) {
spinner.fail("API key validation failed");
throw error instanceof Error
? error
: new Error(String(error) || "Validation error");
}
// Ask where to store the key
const { storageOption } = await inquirer.prompt([
{
type: "list",
name: "storageOption",
message: "Where would you like to store the API key?",
choices: [
{ name: ".env file (project-level)", value: "env" },
{ name: "NeuroLink config (user-level)", value: "config" },
{ name: "Both", value: "both" },
],
},
]);
const spinnerSave = ora("Saving API key...").start();
try {
if (storageOption === "env" || storageOption === "both") {
await saveToEnvFile(provider, trimmedKey);
}
if (storageOption === "config" || storageOption === "both") {
await saveStoredCredentials(provider, {
type: "api-key",
apiKey: trimmedKey,
provider,
createdAt: Date.now(),
updatedAt: Date.now(),
});
}
spinnerSave.succeed("API key saved successfully");
logger.always("");
logger.always(chalk.green("Authenticatio