Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -3261,6 +3261,8 @@ To register the native stdio agent in Zed, JetBrains IDEs, JetBrains Air, or ano
| `--cc, --context-compact` | Enable context compaction (default: on) |
| `--no-cc, --no-context-compact` | Disable context compaction |

Supported display languages: `en`, `pt-br`, `es`, `ko`, `ja`, `kk` (Kazakh), `ru`, `zh-cn`, `zh-tw`, `fr`, `de`, `it`, `tr`, `pl`, `cs`, `hu`, `hi` and `id`. Autohand picks the language from `--display-language`, then `ui.locale` in the config, then `AUTOHAND_LOCALE`, `LC_ALL`, `LC_MESSAGES` or `LANG` (for example `LANG=kk_KZ.UTF-8` selects Kazakh), then the operating system. Change it any time with `/language`.

### Browser Integration

| Flag | Description |
Expand Down
37 changes: 19 additions & 18 deletions src/auth/ensureAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Mandatory authentication gate for CLI startup
*/
import chalk from 'chalk';
import { t } from '../i18n/index.js';
import { createElement } from 'react';
import { AuthClient } from './AuthClient.js';
import { isDurableAuthCredential } from './credentialType.js';
Expand Down Expand Up @@ -47,8 +48,8 @@ async function getGitCommit(): Promise<string> {
*/
async function runUpgrade(): Promise<void> {
const { command, args, hint } = getUpdateCommand(packageJson.version.includes('-alpha.') ? 'alpha' : 'stable');
console.log(chalk.gray('Upgrading Autohand...'));
console.log(chalk.gray(`Running: ${command} ${args.join(' ')}`));
console.log(chalk.gray(t('startup.auth.upgrading')));
console.log(chalk.gray(t('startup.auth.running', { command: `${command} ${args.join(' ')}` })));
console.log();

return new Promise((resolve, reject) => {
Expand All @@ -62,22 +63,22 @@ async function runUpgrade(): Promise<void> {
child.on('close', (code) => {
if (code === 0) {
console.log();
console.log(chalk.green('Upgrade completed successfully!'));
console.log(chalk.gray('Please restart Autohand to use the new version.'));
console.log(chalk.green(t('startup.auth.upgradeDone')));
console.log(chalk.gray(t('startup.auth.restart')));
resolve();
} else {
console.log();
console.log(chalk.red('Upgrade failed.'));
console.log(chalk.gray('You can try manually:'));
console.log(chalk.red(t('startup.auth.upgradeFailed')));
console.log(chalk.gray(t('startup.auth.tryManually')));
console.log(chalk.gray(` ${hint}`));
reject(new Error(`Upgrade failed with exit code ${code}`));
}
});

child.on('error', (error) => {
console.log();
console.log(chalk.red('Upgrade failed.'));
console.log(chalk.gray(`Error: ${error.message}`));
console.log(chalk.red(t('startup.auth.upgradeFailed')));
console.log(chalk.gray(t('startup.auth.error', { message: error.message })));
reject(error);
});
});
Expand All @@ -101,7 +102,7 @@ export async function ensureAuthenticated(
if (options.bare) {
const token = resolveBareModeApiKey(config);
if (!token) {
console.error(chalk.red('Bare mode requires AUTOHAND_API_KEY or auth.apiKeyHelper in --settings/config.'));
console.error(chalk.red(t('startup.auth.bareRequiresKey')));
process.exit(1);
}
return {
Expand Down Expand Up @@ -202,9 +203,9 @@ function isTokenExpiredLocally(config: LoadedConfig): boolean {
async function promptLogin(config: LoadedConfig, onLoginPromptShown?: () => void): Promise<LoadedConfig> {
const ci = process.env.CI?.trim().toLowerCase();
if (!process.stdin.isTTY || !process.stdout.isTTY || (ci && ci !== 'false' && ci !== '0')) {
const status = config.auth?.token ? 'Your Autohand sign-in has expired.' : 'Not signed in.';
console.error(chalk.red(`${status} Run \`autohand login\`, or in CI set AUTOHAND_API_KEY and add --bare.`));
console.error('CI sign-in: https://docs.autohand.ai/guides/ci-cd-automation');
const status = config.auth?.token ? t('startup.auth.expired') : t('startup.auth.notSignedIn');
console.error(chalk.red(`${status} ${t('startup.auth.nonInteractiveHint')}`));
console.error(t('startup.auth.ciDocs', { url: 'https://docs.autohand.ai/guides/ci-cd-automation' }));
process.exit(1);
}
// Show modal with logo and login/exit options
Expand All @@ -228,24 +229,24 @@ async function promptLogin(config: LoadedConfig, onLoginPromptShown?: () => void
}

const options = [
{ label: 'Login', value: 'login' },
{ label: t('startup.auth.login'), value: 'login' },
];

if (updateAvailable && latestVersion) {
options.push({
label: `Upgrade (v${latestVersion} available)`,
label: t('startup.auth.upgradeAvailable', { version: latestVersion }),
value: 'upgrade',
});
}

options.push({ label: 'Exit', value: 'exit' });
options.push({ label: t('startup.auth.exit'), value: 'exit' });

const selected = await showModal({
header: ({ availableRows }: { availableRows: number }) => createElement(AutohandBrand, { version: versionStr, availableRows }),
skipAltScreen: true,
title: updateAvailable
? chalk.yellow('New version available!')
: chalk.white('Sign in to continue.'),
? chalk.yellow(t('startup.auth.newVersion'))
: chalk.white(t('startup.auth.signInToContinue')),
options,
});
onLoginPromptShown?.();
Expand All @@ -272,7 +273,7 @@ async function promptLogin(config: LoadedConfig, onLoginPromptShown?: () => void
const refreshed = await loadConfig(config.configPath, config.overlayWorkspaceRoot);

if (!refreshed.auth?.token) {
console.log(chalk.red('Login failed. Autohand requires authentication to run.'));
console.log(chalk.red(t('startup.auth.loginRequired')));
process.exit(1);
}

Expand Down
2 changes: 1 addition & 1 deletion src/commands/language.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export async function language(ctx: LanguageContext): Promise<string | null> {
})();

if (!result) {
console.log(chalk.gray('\nLanguage selection cancelled.'));
console.log(chalk.gray(`\n${t('commands.language.cancelled')}`));
return null;
}

Expand Down
64 changes: 32 additions & 32 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function maybeOfferAutohandAISwitch(deps: AutohandSwitchOfferDeps):
// One-time: record it as shown regardless of the answer, so it never nags again.
let next: LoadedConfig = { ...config, autohandaiSwitchPromptShown: true };
const accepted = await deps.confirm(
`Your ${deps.providerLabel} hit a rate limit. Try Autohand's Fantail model instead?`,
t('commands.login.switchToFantail', { provider: deps.providerLabel }),
);
if (accepted) {
next = applyAutohandAIProviderDefaults(next, token, 'fantail');
Expand All @@ -147,7 +147,7 @@ type LoginContext = Pick<SlashCommandContext, 'config'> & {
export async function openBrowser(url: string): Promise<boolean> {
// CI, SSH sessions and terminal tests have no browser to hand the URL to.
if (process.env.AUTOHAND_NO_BROWSER === '1') {
console.log(`\nPlease open this URL manually:\n${url}\n`);
console.log(`\n${t('commands.login.openUrlManually')}\n${url}\n`);
return false;
}
try {
Expand Down Expand Up @@ -181,10 +181,10 @@ export async function openBrowser(url: string): Promise<boolean> {
}

// If all openers fail, print the URL for manual opening
console.log(`\nPlease open this URL manually:\n${url}\n`);
console.log(`\n${t('commands.login.openUrlManually')}\n${url}\n`);
return false;
} catch {
console.log(`\nPlease open this URL manually:\n${url}\n`);
console.log(`\n${t('commands.login.openUrlManually')}\n${url}\n`);
return false;
}
}
Expand All @@ -208,7 +208,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
const result = await safePrompt<{ continueLogin: boolean }>({
type: 'confirm',
name: 'continueLogin',
message: `Already logged in as ${chalk.cyan(config.auth.user.email)}. Log in with a different account?`,
message: t('commands.login.alreadyLoggedIn', { email: chalk.cyan(config.auth.user.email) }),
initial: false,
});

Expand All @@ -222,21 +222,21 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
const authClient = getAuthClient();

// Step 1: Initiate device authorization
console.log(chalk.gray('Initiating authentication...'));
console.log(chalk.gray(t('commands.login.initiating')));
const initResult = await authClient.initiateDeviceAuth();

if (!initResult.success || !initResult.deviceCode || !initResult.userCode) {
reportProgramStatusFailure('Authentication could not start');
console.log(chalk.red(t('commands.login.failed', { error: initResult.error || 'Unknown error' })));
console.log(chalk.red(t('commands.login.failed', { error: initResult.error || t('commands.login.unknownError') })));
return null;
}

// Step 2: Display user code and open browser
console.log();
console.log(chalk.bold('To sign in, visit:'));
console.log(chalk.bold(t('commands.login.visit')));
console.log(chalk.cyan.underline(initResult.verificationUriComplete || `${AUTH_CONFIG.authorizationUrl}?code=${initResult.userCode}`));
console.log();
console.log(chalk.gray('Or enter this code manually:'));
console.log(chalk.gray(t('commands.login.enterCodeManually')));
console.log(chalk.bold.yellow(` ${initResult.userCode}`));
console.log();

Expand All @@ -246,14 +246,14 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
);

if (browserOpened) {
console.log(chalk.gray('Browser opened. Complete the login in your browser.'));
console.log(chalk.gray(t('commands.login.browserOpened')));
} else {
console.log(chalk.yellow('Could not open browser automatically. Please visit the URL above.'));
console.log(chalk.yellow(t('commands.login.browserFailed')));
}

console.log();
console.log(chalk.gray('Waiting for authorization...'));
console.log(chalk.gray('(Press Ctrl+C to cancel)'));
console.log(chalk.gray(t('commands.login.waiting')));
console.log(chalk.gray(t('commands.login.pressCtrlCToCancel')));

// Step 3: Poll for authorization
const startTime = Date.now();
Expand All @@ -265,7 +265,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {

while (Date.now() - startTime < timeout) {
// Show progress
process.stdout.write(`\r${chalk.gray('Waiting' + '.'.repeat(dots + 1) + ' '.repeat(maxDots - dots))}`);
process.stdout.write(`\r${chalk.gray(t('commands.login.waitingShort') + '.'.repeat(dots + 1) + ' '.repeat(maxDots - dots))}`);
dots = (dots + 1) % (maxDots + 1);

await sleep(pollInterval);
Expand Down Expand Up @@ -297,7 +297,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
try {
await applyTraceAuthenticationChange(updatedConfig);
} catch {
console.log(chalk.yellow('Signed in, but the trace companion could not refresh its account. It will retry on the next Autohand command.'));
console.log(chalk.yellow(t('commands.login.traceRefreshFailed')));
}

// Profiles are independent from generic file sync. A user who chooses a
Expand All @@ -307,7 +307,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
const { applyDefaultCodingAgentSettingsProfileOnLogin } = await import('../sync/CodingAgentControlPlane.js');
const profile = await applyDefaultCodingAgentSettingsProfileOnLogin(updatedConfig, pollResult.token);
if (profile) {
console.log(chalk.dim(`Applied Coding Agent settings profile: ${profile.name}`));
console.log(chalk.dim(t('commands.login.profileApplied', { name: profile.name })));
}
} catch {
// Login remains successful when the optional control plane is offline.
Expand All @@ -322,7 +322,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
const entitlement = await getAuthClient().fetchEntitlement(pollResult.token);
const plan = formatPlan(planSummaryFromEntitlement(entitlement));
if (plan) {
console.log(chalk.dim(`Plan: ${plan}`));
console.log(chalk.dim(t('commands.login.plan', { plan })));
}
} catch {
// The plan is a courtesy; never let it block a successful sign-in.
Expand All @@ -341,14 +341,14 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
if (pollResult.status === 'expired') {
reportProgramStatusFailure('Authorization code expired');
process.stdout.write('\r' + ' '.repeat(20) + '\r');
console.log(chalk.red('Authorization code expired. Please try again.'));
console.log(chalk.red(t('commands.login.codeExpired')));
return null;
}

if (pollResult.status === 'cancelled') {
reportProgramStatusCancellation();
process.stdout.write('\r' + ' '.repeat(20) + '\r');
console.log(chalk.yellow('Authentication cancelled. Run /login again when you are ready.'));
console.log(chalk.yellow(t('commands.login.authCancelled')));
return null;
}

Expand All @@ -358,7 +358,7 @@ async function performLogin(ctx: LoginContext): Promise<string | null> {
// Timeout
reportProgramStatusFailure('Authorization timed out');
process.stdout.write('\r' + ' '.repeat(20) + '\r');
console.log(chalk.red('Authorization timed out. Please try again.'));
console.log(chalk.red(t('commands.login.timedOut')));
return null;
}

Expand Down Expand Up @@ -409,43 +409,43 @@ async function checkAndRestoreSyncData(
const totalSize = consentRequiredFiles.reduce((sum, f) => sum + f.size, 0);
const sizeStr = formatSize(totalSize);

console.log(chalk.cyan(`Found cloud sync data (${fileCount} files, ${sizeStr})`));
console.log(chalk.gray('This includes your settings, agents, skills, sessions, and hooks.'));
console.log(chalk.cyan(t('commands.login.syncFound', { count: fileCount, size: sizeStr })));
console.log(chalk.gray(t('commands.login.syncIncludes')));
console.log();

const result = await safePrompt<{ restore: boolean }>({
type: 'confirm',
name: 'restore',
message: 'Would you like to restore your settings from the cloud?',
message: t('commands.login.syncRestorePrompt'),
initial: true,
});

if (!result || !result.restore) {
console.log(chalk.gray('Skipped sync restore. You can sync later with /sync.'));
console.log(chalk.gray(t('commands.login.syncSkipped')));
return;
}

// Restore from cloud with ESC/Ctrl+C cancellation support
console.log(chalk.gray('Restoring settings from cloud...'));
console.log(chalk.gray('(Press ESC or Ctrl+C to cancel)'));
console.log(chalk.gray(t('commands.login.syncRestoring')));
console.log(chalk.gray(t('commands.login.pressEscToCancel')));

const syncResult = await cancellableOperation(
() => syncService.forceDownload(),
);

if (!syncResult) {
console.log(chalk.yellow('\nSync restore cancelled.'));
console.log(chalk.gray('You can sync later with /sync.'));
console.log(chalk.yellow(`\n${t('commands.login.syncCancelled')}`));
console.log(chalk.gray(t('commands.login.syncLater')));
} else if (syncResult.success) {
console.log(chalk.green(`Restored ${syncResult.downloaded} files from cloud.`));
console.log(chalk.green(t('commands.login.syncRestored', { count: syncResult.downloaded })));
for (const line of formatSyncSkippedFiles(syncResult.remoteSkipped, 'Cloud')) console.log(chalk.gray(line));
} else {
console.log(chalk.yellow(`Sync restore failed: ${syncResult.error}`));
console.log(chalk.gray('You can try again later with /sync.'));
console.log(chalk.yellow(t('commands.login.syncFailed', { error: String(syncResult.error) })));
console.log(chalk.gray(t('commands.login.syncRetryLater')));
}
} catch {
// Silently fail - sync is not critical for login
console.log(chalk.gray('Could not check cloud sync data.'));
console.log(chalk.gray(t('commands.login.syncCheckFailed')));
}
}

Expand Down
38 changes: 37 additions & 1 deletion src/core/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,42 @@ import * as readinessCmd from '../commands/readiness.js';
import * as discoveryCmd from '../commands/discovery.js';

import type { SlashCommand } from './slashCommandTypes.js';
import { t } from '../i18n/index.js';
export type { SlashCommand } from './slashCommandTypes.js';

/** i18n key holding the user-facing description of a slash command, e.g. `slashCommands.login`. */
export function slashCommandDescriptionKey(command: string): string {
return `slashCommands.${command.replace(/^\//, '')}`;
}

const LOCALIZED_DESCRIPTION = Symbol.for('autohand.slashCommand.localizedDescription');

/**
* Command metadata is evaluated at import time, before the display language is
* known, so descriptions are resolved lazily on read. The metadata object keeps
* its identity; the registered English text stays the fallback when a key is
* missing from every locale.
*/
function localizeDescription(cmd: SlashCommand): SlashCommand {
const target = cmd as SlashCommand & { [LOCALIZED_DESCRIPTION]?: true };
if (target[LOCALIZED_DESCRIPTION] || Object.isFrozen(cmd)) return cmd;
let fallback = cmd.description;
const key = slashCommandDescriptionKey(cmd.command);
Object.defineProperty(cmd, 'description', {
enumerable: true,
configurable: true,
get() {
const value = t(key);
return value === key ? fallback : value;
},
set(next: string) {
fallback = next;
},
});
Object.defineProperty(cmd, LOCALIZED_DESCRIPTION, { value: true, enumerable: false });
return cmd;
}

export const SLASH_COMMANDS: SlashCommand[] = ([
quit.metadata,
quit.exitMetadata,
Expand Down Expand Up @@ -184,4 +218,6 @@ export const SLASH_COMMANDS: SlashCommand[] = ([
whatsnewCmd.metadata,
changelogCmd.metadata,
readinessCmd.metadata,
] as (SlashCommand | undefined)[]).filter((cmd): cmd is SlashCommand => cmd != null && typeof cmd.command === 'string');
] as (SlashCommand | undefined)[])
.filter((cmd): cmd is SlashCommand => cmd != null && typeof cmd.command === 'string')
.map(localizeDescription);
Loading
Loading