ai-profile-switch 2.2.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,19 @@
1
- import { info, success, warn } from '../lib/ui.mjs';
2
- import { renameProfile } from '../lib/profile.mjs';
1
+ import { color, info, success, warn } from '../lib/ui.mjs';
2
+ import { checkRename, renameProfile } from '../lib/profile.mjs';
3
+ import { t } from '../lib/i18n.mjs';
3
4
 
4
- export async function rename(oldName, newName) {
5
+ // warned: the caller already asked to close sessions, so do not say it twice.
6
+ export async function rename(oldName, newName, { warned = false } = {}) {
5
7
  if (!oldName || !newName) throw new Error('Usage: cpf rename <old-name> <new-name>');
6
- if (oldName !== newName) {
7
- warn(`Close running Claude sessions using "${oldName}" before renaming; they still use the old configuration path.`);
8
- if (process.platform === 'darwin') {
9
- warn('macOS may require signing in again after the configuration path changes.');
10
- }
8
+ checkRename(oldName, newName);
9
+ if (oldName === newName) {
10
+ info(t('rename.same', { name: oldName }));
11
+ return;
11
12
  }
12
- if (renameProfile(oldName, newName)) success(`Renamed profile: ${oldName} → ${newName}`);
13
- else info(`"${oldName}" already has that name`);
13
+ if (!warned) warn(t('rename.closeSessions', { name: oldName }));
14
+ renameProfile(oldName, newName);
15
+ success(t('rename.done', { old: oldName, new: color.bold(newName) }));
16
+ const kept = process.platform === 'darwin' ? `${t('rename.kept')} ${t('rename.macSignIn')}` : t('rename.kept');
17
+ console.log(` ${color.dim(kept)}`);
18
+ console.log(` ${t('rename.next', { command: color.cyan('claude'), name: color.bold(newName) })}`);
14
19
  }
@@ -2,13 +2,14 @@ import { color, info, success, warn } from '../lib/ui.mjs';
2
2
  import { SHAREABLE_ENTRIES, SHARED_FILES, SHARED_DIRS } from '../lib/constants.mjs';
3
3
  import { readMeta } from '../lib/config.mjs';
4
4
  import { shareEntry, syncShared, unshareEntry } from '../lib/shared.mjs';
5
+ import { t } from '../lib/i18n.mjs';
5
6
 
6
7
  export function reportConflicts(results) {
7
8
  for (const { name, conflicts, missing } of results) {
8
9
  for (const entry of conflicts) {
9
- warn(`${name}: kept its own ${entry} because it differs from the shared copy. Move or delete it, then run cpf share sync.`);
10
+ warn(t('share.keptOwn', { name, entry }));
10
11
  }
11
- for (const entry of missing) warn(`${name}: shared ${entry} is missing from _shared.`);
12
+ for (const entry of missing) warn(t('share.missing', { name, entry }));
12
13
  }
13
14
  }
14
15
 
@@ -16,43 +17,44 @@ function status() {
16
17
  const meta = readMeta();
17
18
  const shared = meta.shared ?? [];
18
19
  console.log();
19
- console.log(` ${color.bold('Shared by every profile:')} ${[...SHARED_FILES, ...SHARED_DIRS, ...shared].join(', ')}`);
20
- console.log(` ${color.dim(`Can also share: ${SHAREABLE_ENTRIES.filter(entry => !shared.includes(entry)).join(', ') || 'nothing left'}`)}`);
20
+ console.log(` ${color.bold(t('share.title'))} ${[...SHARED_FILES, ...SHARED_DIRS, ...shared].join(', ')}`);
21
+ const more = SHAREABLE_ENTRIES.filter(entry => !shared.includes(entry)).join(', ') || t('share.nothingLeft');
22
+ console.log(` ${color.dim(t('share.canAlso', { list: more }))}`);
21
23
  console.log();
22
24
  for (const result of syncShared(meta.profiles, { dryRun: true })) {
23
25
  const problems = [
24
- ...result.conflicts.map(entry => color.yellow(`own ${entry}`)),
25
- ...result.linked.map(entry => `${entry} not linked yet`),
26
- ...result.missing.map(entry => color.red(`${entry} missing`)),
26
+ ...result.conflicts.map(entry => color.yellow(t('share.own', { entry }))),
27
+ ...result.linked.map(entry => t('share.notLinked', { entry })),
28
+ ...result.missing.map(entry => color.red(t('share.missingShort', { entry }))),
27
29
  ];
28
- console.log(` ${result.name.padEnd(12)} ${problems.length ? problems.join(', ') : color.green('in sync')}`);
30
+ console.log(` ${result.name.padEnd(12)} ${problems.length ? problems.join(', ') : color.green(t('share.inSync'))}`);
29
31
  }
30
- if (!shared.length) info('Nothing extra is shared yet. Example: cpf share add CLAUDE.md');
32
+ if (!shared.length) info(t('share.nothingExtra'));
31
33
  console.log();
32
34
  }
33
35
 
34
36
  export async function share(args = []) {
35
37
  const [command, entry, ...rest] = args;
36
38
  if (rest.length || (['add', 'remove'].includes(command) ? !entry : entry !== undefined)) {
37
- throw new Error('Usage: cpf share [list | add <entry> | remove <entry> | sync]');
39
+ throw new Error(t('share.usage'));
38
40
  }
39
41
  if (!command || command === 'list') return status();
40
42
  if (command === 'sync') {
41
43
  const results = syncShared();
42
44
  reportConflicts(results);
43
- success(`Linked ${results.reduce((count, result) => count + result.linked.length, 0)} entries.`);
45
+ success(t('share.linked', { count: results.reduce((count, result) => count + result.linked.length, 0) }));
44
46
  return;
45
47
  }
46
48
  if (command === 'add') {
47
49
  const results = shareEntry(entry);
48
50
  reportConflicts(results);
49
- success(`Sharing ${entry} through ~/.claude-profiles/_shared/${entry}`);
51
+ success(t('share.added', { entry }));
50
52
  return;
51
53
  }
52
54
  if (command === 'remove') {
53
55
  unshareEntry(entry);
54
- success(`Stopped sharing ${entry}. Each profile kept its own copy.`);
56
+ success(t('share.removed', { entry }));
55
57
  return;
56
58
  }
57
- throw new Error('Usage: cpf share [list | add <entry> | remove <entry> | sync]');
59
+ throw new Error(t('share.usage'));
58
60
  }
@@ -6,7 +6,9 @@ import { getActiveProfile, setActiveProfile } from '../lib/config.mjs';
6
6
  import { HOME, IS_WINDOWS } from '../lib/constants.mjs';
7
7
  import { installAllShells } from '../lib/shell.mjs';
8
8
  import { listProfiles, profileDir, assertRegisteredProfile } from '../lib/profile.mjs';
9
- import { success, warn } from '../lib/ui.mjs';
9
+ import { color, colorFor, intro, meter, outro, success, sym, userError, warn } from '../lib/ui.mjs';
10
+ import { accountInfo } from '../lib/account.mjs';
11
+ import { relativeTime, t } from '../lib/i18n.mjs';
10
12
  import { syncShared } from '../lib/shared.mjs';
11
13
  import { reportConflicts } from './share.mjs';
12
14
  import * as prompt from '../lib/prompt.mjs';
@@ -37,7 +39,7 @@ export async function shell(argv = []) {
37
39
  case 'refresh':
38
40
  if (args.length) throw new Error('Usage: shell refresh');
39
41
  installAllShells();
40
- success('Shell integration refreshed.');
42
+ success(t('shell.refreshed'));
41
43
  return;
42
44
  default:
43
45
  throw new Error('Usage: shell <launch|use|pick|refresh>');
@@ -45,7 +47,7 @@ export async function shell(argv = []) {
45
47
  }
46
48
 
47
49
  async function launch(args, selected) {
48
- const profile = selected ?? await selectProfile();
50
+ const profile = selected ?? await selectProfile({ launching: true });
49
51
  if (!profile) {
50
52
  process.exitCode = 130;
51
53
  return;
@@ -53,15 +55,16 @@ async function launch(args, selected) {
53
55
  assertRegisteredProfile(profile);
54
56
  const bin = resolveClaudeBinary();
55
57
  if (!bin) {
56
- console.error('Claude Code executable not found. See https://code.claude.com/docs/en/setup');
58
+ console.error(`${t('launch.notFound')} ${t('launch.install')} https://code.claude.com/docs/en/setup`);
57
59
  process.exitCode = 127;
58
60
  return;
59
61
  }
60
62
  scheduleSelfUpdate();
61
63
  if (getActiveProfile() !== profile) setActiveProfile(profile);
62
64
  // Sharing is a convenience; a broken link must not stop Claude from starting.
63
- try { reportConflicts(syncShared([profile])); } catch (err) { warn(`Shared items not linked: ${err.message}`); }
64
- process.stderr.write(`[ai-profile-switch] Profile: ${profile}\n`);
65
+ try { reportConflicts(syncShared([profile])); } catch (err) { warn(t('launch.sharedFailed', { message: err.message })); }
66
+ // The picker already told a person which account starts; logs still get a plain line.
67
+ if (!process.stderr.isTTY) process.stderr.write(`[ai-profile-switch] Profile: ${profile}\n`);
65
68
  const cmd = bin.endsWith('.js') ? process.execPath : bin;
66
69
  const childArgs = bin.endsWith('.js') ? [bin, ...args] : args;
67
70
  const result = await new Promise(resolve => {
@@ -80,7 +83,7 @@ async function launch(args, selected) {
80
83
  };
81
84
  child.once('error', err => {
82
85
  cleanup();
83
- console.error(`Could not start Claude Code: ${err.message}`);
86
+ console.error(t('launch.startFailed', { message: err.message }));
84
87
  resolve(1);
85
88
  });
86
89
  child.once('close', (code, signal) => {
@@ -94,31 +97,89 @@ async function launch(args, selected) {
94
97
  function useProfile(name) {
95
98
  assertRegisteredProfile(name);
96
99
  if (getActiveProfile() !== name) setActiveProfile(name);
97
- success(`Switched to profile: ${name}`);
100
+ success(t('use.switched', { profile: name }));
98
101
  }
99
102
 
103
+ // The claude shell function runs `pick --print` and then launches the printed profile.
100
104
  async function pickProfile(printOnly) {
101
- const profile = await selectProfile();
105
+ const profile = await selectProfile({ launching: printOnly });
102
106
  if (!profile) {
103
107
  process.exitCode = 130;
104
108
  return;
105
109
  }
106
110
  if (getActiveProfile() !== profile) setActiveProfile(profile);
107
111
  if (printOnly) process.stdout.write(profile + '\n');
108
- else success(`Switched to profile: ${profile}`);
109
112
  }
110
113
 
111
- async function selectProfile() {
114
+ export function accountSummary({ email, plan }) {
115
+ return [email, plan].filter(Boolean).join(' · ');
116
+ }
117
+
118
+ export function usageLine({ usage }) {
119
+ if (!usage) return color.dim(t('account.noUsage'));
120
+ const parts = [];
121
+ if (usage.fiveHour !== null) parts.push(`${t('account.fiveHour')} ${meter(usage.fiveHour)}`);
122
+ if (usage.weekly !== null) parts.push(`${t('account.weekly')} ${meter(usage.weekly)}`);
123
+ if (usage.savedAt) parts.push(color.dim(t('account.savedAt', { time: relativeTime(usage.savedAt) })));
124
+ return parts.join(' ');
125
+ }
126
+
127
+ // One picker row per profile: name, in use or last used, then account and usage lines.
128
+ export function profileChoices(profiles, active) {
129
+ const width = Math.max(...profiles.map(name => name.length));
130
+ return profiles.map(name => profileChoice(name, active, width));
131
+ }
132
+
133
+ function profileChoice(name, active, width) {
134
+ const account = accountInfo(name);
135
+ const hint = name === active
136
+ ? color.green(`${sym.bulletOn} ${t('picker.inUse')}`)
137
+ : account.lastUsed ? color.dim(relativeTime(account.lastUsed)) : '';
138
+ const summary = accountSummary(account);
139
+ const details = summary
140
+ ? [color.dim(summary), usageLine(account)]
141
+ : [color.dim(t('account.notSignedIn'))];
142
+ return { label: name.padEnd(width), value: name, hint, details, summary };
143
+ }
144
+
145
+ async function selectProfile({ launching = false } = {}) {
112
146
  const profiles = listProfiles();
113
- if (!profiles.length) throw new Error('No profiles found. Run cpf to add one, or npx ai-profile-switch@latest init to import existing Claude settings.');
147
+ if (!profiles.length) {
148
+ throw userError(t('profiles.none'), [
149
+ { label: t('hint.add'), command: 'cpf add work' },
150
+ { label: t('hint.setup'), command: 'npx ai-profile-switch@latest init' },
151
+ ]);
152
+ }
114
153
  const active = getActiveProfile();
115
- const selected = !process.stdin.isTTY || profiles.length === 1
116
- ? (active ?? profiles[0])
117
- : await prompt.select('Select a profile:', profiles, { output: process.stderr, initial: profiles.indexOf(active) });
154
+ const output = process.stderr;
155
+ colorFor(output);
156
+ if (!process.stdin.isTTY || profiles.length === 1) {
157
+ const selected = active ?? profiles[0];
158
+ assertRegisteredProfile(selected);
159
+ if (launching && output.isTTY) {
160
+ const summary = accountSummary(accountInfo(selected));
161
+ output.write(`${color.green(sym.stepDone)} ${t('picker.starting', { profile: color.bold(selected) })}${summary ? ` ${color.dim(summary)}` : ''}\n`);
162
+ }
163
+ return selected;
164
+ }
165
+ const choices = profileChoices(profiles, active);
166
+ intro('ai-profile-switch', '', output);
167
+ const selected = await prompt.select(t('picker.question'), choices, {
168
+ output, initial: profiles.indexOf(active), numbered: true,
169
+ done: name => {
170
+ const { summary } = choices.find(choice => choice.value === name);
171
+ const message = t(launching ? 'picker.starting' : 'picker.switched', { profile: color.bold(name) });
172
+ return `${message}${summary ? ` ${color.dim(summary)}` : ''}`;
173
+ },
174
+ });
175
+ outro(selected && launching ? t('launch.opening') : '', output);
118
176
  if (selected) assertRegisteredProfile(selected);
119
177
  return selected;
120
178
  }
121
179
 
180
+ // Declared below; the Windows resolver test runs that part on its own.
181
+ export { resolveClaudeBinary };
182
+
122
183
  function executable(file) {
123
184
  try {
124
185
  accessSync(file, IS_WINDOWS ? constants.F_OK : constants.X_OK);
@@ -3,7 +3,8 @@ import { computeUpdatePlan, compareSemver } from '../lib/updater.mjs';
3
3
  import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { installedRuntimeDirectory, runSelfUpdate } from '../lib/self-update.mjs';
6
- import { info, warn, error } from '../lib/ui.mjs';
6
+ import { displayWidth, info, warn, error } from '../lib/ui.mjs';
7
+ import { t } from '../lib/i18n.mjs';
7
8
 
8
9
  export async function update(args = []) {
9
10
  const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
@@ -14,10 +15,10 @@ export async function update(args = []) {
14
15
  } });
15
16
  if (positionals.length) throw new Error('Usage: update [--check] [--self]');
16
17
  if (values['claude-code']) {
17
- info('Claude Code manages its own updates. Run claude update (or your package manager).');
18
- info('See https://code.claude.com/docs/en/setup');
18
+ info(t('update.claudeManaged'));
19
+ info(t('update.seeSetup'));
19
20
  if (values.check) {
20
- warn('Claude Code update status was not checked.');
21
+ warn(t('update.notChecked'));
21
22
  process.exitCode = 2;
22
23
  }
23
24
  if (!values.self) return;
@@ -35,16 +36,18 @@ export async function update(args = []) {
35
36
  process.exitCode = 2;
36
37
  return;
37
38
  }
38
- console.log('Package Installed Latest');
39
- console.log(`${self.pkg} ${self.installed} ${self.latest}`);
40
- if (!self.hasUpdate) info('This package is up to date.');
41
- else if (values.check) info('Run npx ai-profile-switch@latest update to install.');
39
+ const table = [[t('update.package'), t('update.installed'), t('update.latest')], [self.pkg, self.installed, self.latest]];
40
+ const widths = [0, 1].map(column => Math.max(...table.map(row => displayWidth(row[column]))) + 3);
41
+ for (const row of table) console.log(row.map((cell, column) => cell + ' '.repeat(Math.max(0, (widths[column] ?? 0) - displayWidth(cell)))).join('').trimEnd());
42
+ if (!self.hasUpdate) info(t('update.upToDate'));
43
+ else if (values.check) info(t('update.runToInstall'));
42
44
  else {
43
45
  const result = await runSelfUpdate({ force: true, computePlan: async () => ({ self }) });
44
- if (result.action === 'updated') info(`Updated to ${result.version}. New launches use this version; active sessions keep their files.`);
45
- else if (result.reason === 'not-installed') info('Local runtime is not installed; no installation was run. Run: npx ai-profile-switch@latest install-shell');
46
- else if (result.reason === 'busy') { warn('An update is already running.'); process.exitCode = 2; }
47
- else if (result.action === 'failed') { error(`Update failed; existing runtime retained: ${result.error}`); process.exitCode = 2; }
46
+ if (result.action === 'updated') info(t('update.updated', { version: result.version }));
47
+ else if (result.reason === 'not-installed') info(t('update.notInstalled'));
48
+ else if (result.reason === 'busy') { warn(t('update.busy')); process.exitCode = 2; }
49
+ else if (result.action === 'failed') { error(t('update.failed', { error: result.error })); process.exitCode = 2; }
48
50
  }
49
51
  if (values.check && !values['claude-code']) process.exitCode = self.hasUpdate ? 1 : 0;
50
52
  }
53
+
@@ -1,14 +1,16 @@
1
- import { success, info } from '../lib/ui.mjs';
1
+ import { color, success, info } from '../lib/ui.mjs';
2
2
  import { assertRegisteredProfile } from '../lib/profile.mjs';
3
3
  import { setActiveProfile, getActiveProfile } from '../lib/config.mjs';
4
+ import { t } from '../lib/i18n.mjs';
4
5
 
5
6
  export async function use(name) {
6
7
  assertRegisteredProfile(name);
7
8
  const current = getActiveProfile();
8
9
  if (current === name) {
9
- info(`"${name}" is already the active profile`);
10
+ info(t('use.already', { profile: name }));
10
11
  return;
11
12
  }
12
13
  setActiveProfile(name);
13
- success(`Switched to profile: ${name}`);
14
+ success(t('use.switched', { profile: name }));
15
+ console.log(` ${color.dim(t('use.note'))}`);
14
16
  }
package/src/index.mjs CHANGED
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { error, color } from './lib/ui.mjs';
5
+ import { t } from './lib/i18n.mjs';
5
6
 
6
7
  const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
7
8
 
@@ -10,12 +11,14 @@ const COMMANDS = {
10
11
  add: args => import('./commands/add.mjs').then(m => m.add(args[0])),
11
12
  remove: args => import('./commands/remove.mjs').then(m => m.remove(args[0])),
12
13
  rename: args => import('./commands/rename.mjs').then(m => m.rename(...args)),
14
+ login: args => import('./commands/login.mjs').then(m => m.login(args[0])),
13
15
  list: () => import('./commands/list.mjs').then(m => m.list()),
14
16
  use: args => import('./commands/use.mjs').then(m => m.use(args[0])),
15
17
  manage: args => import('./commands/manage.mjs').then(m => m.manage(args)),
16
18
  'install-shell': () => import('./commands/install-shell.mjs').then(m => m.installShell()),
17
19
  migrate: args => import('./commands/migrate.mjs').then(m => m.migrate(undefined, args)),
18
20
  share: args => import('./commands/share.mjs').then(m => m.share(args)),
21
+ language: args => import('./commands/language.mjs').then(m => m.language(args)),
19
22
  mcp: args => import('./commands/mcp/index.mjs').then(m => m.mcp(args)),
20
23
  update: args => import('./commands/update.mjs').then(m => m.update(args)),
21
24
  shell: args => import('./commands/shell.mjs').then(m => m.shell(args)),
@@ -26,54 +29,29 @@ const USAGE = {
26
29
  add: 'add <name>',
27
30
  remove: 'remove <name>',
28
31
  rename: 'rename <old-name> <new-name>',
32
+ login: 'login <name>',
29
33
  list: 'list',
30
34
  use: 'use <name>',
31
35
  manage: 'manage [profile | add <name> | rename <old-name> <new-name> | remove <name> | --list]',
32
36
  'install-shell': 'install-shell',
33
37
  migrate: 'migrate [profile] [--from <path>]',
34
38
  share: 'share [list | add <entry> | remove <entry> | sync]',
39
+ language: 'language [auto | en | ko]',
35
40
  mcp: 'mcp [list]',
36
41
  update: 'update [--check] [--self] [--claude-code] [--yes]',
37
42
  shell: 'shell <launch|use|pick|refresh>',
38
43
  };
39
- const POSITIONAL_COUNTS = { init: 0, add: 1, remove: 1, rename: 2, list: 0, use: 1, 'install-shell': 0 };
44
+ const POSITIONAL_COUNTS = { init: 0, add: 1, remove: 1, rename: 2, login: 1, list: 0, use: 1, 'install-shell': 0 };
45
+
46
+ const COMMAND_NOTES = { login: 'index.helpLogin', rename: 'index.helpRename', remove: 'index.helpRemove', add: 'index.helpAdd', use: 'index.helpUse' };
40
47
 
41
48
  function showHelp(command) {
42
49
  if (command) {
43
- console.log(`Usage: npx ai-profile-switch@latest ${USAGE[command]}`);
44
- if (command === 'rename') console.log('Close running sessions for this profile first. Its history and settings are kept; macOS may require sign-in again.');
45
- if (command === 'remove') console.log('Deletes a registered profile after confirmation. This cannot be undone.');
46
- if (command === 'add') console.log('Creates a profile. Run claude, choose it, and sign in to the intended account.');
47
- if (command === 'use') console.log('Changes the active profile for future launches; existing sessions keep their profile.');
50
+ console.log(t('index.usage', { usage: USAGE[command] }));
51
+ if (Object.hasOwn(COMMAND_NOTES, command)) console.log(t(COMMAND_NOTES[command]));
48
52
  return;
49
53
  }
50
- console.log(`
51
- ${color.bold('ai-profile-switch')} — Profile switching for Claude Code
52
-
53
- Everyday commands (in your terminal, after shell setup):
54
- claude Select a profile and launch Claude Code
55
- cpf Open the profile management menu
56
- cpf work Switch to an existing profile
57
- cpf --help Show short management commands
58
-
59
- Usage: npx ai-profile-switch@latest <command> [options]
60
-
61
- Commands:
62
- init First setup; show existing setup on repeated runs
63
- add <name> Create a profile
64
- remove <name> Remove a profile after confirmation
65
- rename <old> <new> Rename a profile, keeping its data
66
- list List profiles
67
- use <name> Change the active profile
68
- manage Open the same menu as cpf
69
- install-shell Install or repair shell integration
70
- migrate [name] Import existing settings (--from <path>)
71
- update [--check] Update this tool; --check only checks
72
- share [add|remove] Share CLAUDE.md, skills, agents, … across profiles
73
- mcp [list] Show legacy MCP migration guidance
74
-
75
- Run any command with --help for its usage.
76
- `);
54
+ console.log(t('index.help', { title: color.bold('ai-profile-switch') }));
77
55
  }
78
56
 
79
57
  export async function run(argv) {
@@ -88,7 +66,7 @@ export async function run(argv) {
88
66
  return;
89
67
  }
90
68
  if (!Object.hasOwn(COMMANDS, command)) {
91
- error(`Unknown command: ${command}`);
69
+ error(t('index.unknown', { command }));
92
70
  showHelp();
93
71
  process.exitCode = 1;
94
72
  return;
@@ -104,11 +82,11 @@ export async function run(argv) {
104
82
  }
105
83
  if (Object.hasOwn(POSITIONAL_COUNTS, command)
106
84
  && (args.length !== POSITIONAL_COUNTS[command] || args.some(arg => arg.startsWith('-')))) {
107
- throw new Error(`Usage: npx ai-profile-switch@latest ${USAGE[command]}`);
85
+ throw new Error(t('index.usage', { usage: USAGE[command] }));
108
86
  }
109
87
  await COMMANDS[command](args);
110
88
  } catch (err) {
111
- error(err.message);
89
+ error(err.message, err.hints);
112
90
  process.exitCode = 1;
113
91
  }
114
92
  }
@@ -0,0 +1,47 @@
1
+ // Reads what Claude Code saved in a profile, for display only. Nothing is written,
2
+ // and any field with an unexpected shape is left out instead of failing.
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { PROFILES_DIR } from './constants.mjs';
6
+
7
+ const PLANS = { claude_max: 'Max', claude_pro: 'Pro', claude_team: 'Team', claude_enterprise: 'Enterprise' };
8
+
9
+ function readConfig(dir) {
10
+ try {
11
+ const config = JSON.parse(readFileSync(join(dir, '.claude.json'), 'utf8'));
12
+ return config && typeof config === 'object' ? config : {};
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ function percent(window) {
19
+ const value = window?.utilization;
20
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : null;
21
+ }
22
+
23
+ // Claude Code keeps usage in an undocumented cache; it may change or disappear.
24
+ function usage(cached, account) {
25
+ if (!cached || typeof cached !== 'object') return null;
26
+ if (typeof cached.accountUuid === 'string' && typeof account.accountUuid === 'string'
27
+ && cached.accountUuid !== account.accountUuid) return null;
28
+ const fiveHour = percent(cached.utilization?.five_hour);
29
+ const weekly = percent(cached.utilization?.seven_day);
30
+ if (fiveHour === null && weekly === null) return null;
31
+ const savedAt = Number.isFinite(cached.fetchedAtMs) ? cached.fetchedAtMs : null;
32
+ return { fiveHour, weekly, savedAt };
33
+ }
34
+
35
+ export function accountInfo(name) {
36
+ const dir = join(PROFILES_DIR, name);
37
+ const config = readConfig(dir);
38
+ const account = config.oauthAccount && typeof config.oauthAccount === 'object' ? config.oauthAccount : {};
39
+ let lastUsed = null;
40
+ try { lastUsed = statSync(join(dir, 'history.jsonl')).mtimeMs; } catch { /* never used */ }
41
+ return {
42
+ email: typeof account.emailAddress === 'string' && account.emailAddress ? account.emailAddress : null,
43
+ plan: Object.hasOwn(PLANS, account.organizationType) ? PLANS[account.organizationType] : null,
44
+ usage: usage(config.cachedUsageUtilization, account),
45
+ lastUsed,
46
+ };
47
+ }
@@ -1,5 +1,5 @@
1
1
  import {
2
- META_FILE, PROFILE_NAME_REGEX, PROFILE_NAME_MAX_LENGTH, RESERVED_NAMES, SHAREABLE_ENTRIES,
2
+ LANGUAGES, META_FILE, PROFILE_NAME_REGEX, PROFILE_NAME_MAX_LENGTH, RESERVED_NAMES, SHAREABLE_ENTRIES,
3
3
  } from './constants.mjs';
4
4
  import { readJsonFile, writeJsonFile } from './json-fs.mjs';
5
5
 
@@ -19,6 +19,7 @@ export function readMeta() {
19
19
  || new Set(meta.profiles).size !== meta.profiles.length
20
20
  || (meta.activeProfile !== null && !meta.profiles.includes(meta.activeProfile))
21
21
  || typeof meta.shareSettings !== 'boolean'
22
+ || (meta.language !== undefined && !LANGUAGES.includes(meta.language))
22
23
  || (meta.shared !== undefined && (!Array.isArray(meta.shared)
23
24
  || meta.shared.some(entry => !SHAREABLE_ENTRIES.includes(entry))
24
25
  || new Set(meta.shared).size !== meta.shared.length))) {
@@ -61,6 +62,12 @@ export function setSharedEntries(entries) {
61
62
  writeMeta({ ...meta, shared: entries });
62
63
  }
63
64
 
65
+ // null clears the choice so the system language is used again.
66
+ export function setLanguagePreference(language) {
67
+ const { language: _previous, ...meta } = readMeta();
68
+ writeMeta(language ? { ...meta, language } : meta);
69
+ }
70
+
64
71
  export function removeProfileFromMeta(name) {
65
72
  const meta = readMeta();
66
73
  meta.profiles = meta.profiles.filter(p => p !== name);
@@ -38,3 +38,7 @@ export const SHAREABLE_FILES = ['CLAUDE.md', 'keybindings.json'];
38
38
  export const ACCOUNT_SKILL_ENTRIES = ['synced'];
39
39
 
40
40
  export const SELF_PKG = 'ai-profile-switch';
41
+
42
+ // Screen languages; English is the fallback for anything missing.
43
+ export const LANGUAGES = ['en', 'ko'];
44
+ export const LANGUAGE_ENV = 'AI_PROFILE_SWITCH_LANG';
@@ -0,0 +1,65 @@
1
+ // Chooses the screen language and looks up messages. English fills any gap.
2
+ import { LANGUAGES, LANGUAGE_ENV, META_FILE } from './constants.mjs';
3
+ import { readJsonFile } from './json-fs.mjs';
4
+ import en from '../locales/en.mjs';
5
+ import ko from '../locales/ko.mjs';
6
+
7
+ const MESSAGES = { en, ko };
8
+ export const LANGUAGE_NAMES = { en: 'English', ko: '한국어' };
9
+
10
+ function languageCode(value) {
11
+ const code = String(value ?? '').trim().split(/[-_.@]/)[0].toLowerCase();
12
+ return code || null;
13
+ }
14
+
15
+ // Terminal locale first (LC_ALL → LC_MESSAGES → LANG), then the OS locale.
16
+ // A set but unknown locale such as C means English rather than a fallthrough.
17
+ export function systemLanguage(env = process.env, intlLocale) {
18
+ const code = languageCode(env.LC_ALL || env.LC_MESSAGES || env.LANG)
19
+ ?? languageCode(intlLocale ?? Intl.DateTimeFormat().resolvedOptions().locale);
20
+ return LANGUAGES.includes(code) ? code : 'en';
21
+ }
22
+
23
+ export function savedLanguage() {
24
+ try {
25
+ const { language } = readJsonFile(META_FILE, {}) ?? {};
26
+ return LANGUAGES.includes(language) ? language : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ export function detectLanguage({ saved = savedLanguage(), env = process.env, intlLocale } = {}) {
33
+ if (saved) return saved;
34
+ const forced = languageCode(env[LANGUAGE_ENV]);
35
+ if (LANGUAGES.includes(forced)) return forced;
36
+ return systemLanguage(env, intlLocale);
37
+ }
38
+
39
+ let current;
40
+
41
+ export function language() {
42
+ current ??= detectLanguage();
43
+ return current;
44
+ }
45
+
46
+ export function setLanguage(code) {
47
+ current = code;
48
+ }
49
+
50
+ export function t(key, vars = {}) {
51
+ const message = MESSAGES[language()][key] ?? en[key];
52
+ if (message === undefined) throw new Error(`Missing message: ${key}`);
53
+ return message.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : match));
54
+ }
55
+
56
+ const TIME_UNITS = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
57
+
58
+ export function relativeTime(ms, now = Date.now()) {
59
+ const seconds = Math.round((ms - now) / 1000);
60
+ const format = new Intl.RelativeTimeFormat(language(), { numeric: 'auto' });
61
+ for (const [unit, size] of TIME_UNITS) {
62
+ if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit);
63
+ }
64
+ return t('time.justNow');
65
+ }