ai-profile-switch 2.2.0 → 2.2.2

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, refreshUsage } 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,91 @@ 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
+ const reset = key => (usage.reset?.[key] ? ` ${color.dim(t('account.reset'))}` : '');
122
+ if (usage.fiveHour !== null) parts.push(`${t('account.fiveHour')} ${meter(usage.fiveHour)}${reset('fiveHour')}`);
123
+ if (usage.weekly !== null) parts.push(`${t('account.weekly')} ${meter(usage.weekly)}${reset('weekly')}`);
124
+ if (usage.savedAt) parts.push(color.dim(t('account.savedAt', { time: relativeTime(usage.savedAt) })));
125
+ return parts.join(' ');
126
+ }
127
+
128
+ // One picker row per profile: name, in use or last used, then account and usage lines.
129
+ export function profileChoices(profiles, active) {
130
+ const width = Math.max(...profiles.map(name => name.length));
131
+ return profiles.map(name => profileChoice(name, active, width));
132
+ }
133
+
134
+ function profileChoice(name, active, width) {
135
+ const account = accountInfo(name);
136
+ const hint = name === active
137
+ ? color.green(`${sym.bulletOn} ${t('picker.inUse')}`)
138
+ : account.lastUsed ? color.dim(relativeTime(account.lastUsed)) : '';
139
+ const summary = accountSummary(account);
140
+ const details = summary
141
+ ? [color.dim(summary), usageLine(account)]
142
+ : [color.dim(t('account.notSignedIn'))];
143
+ return { label: name.padEnd(width), value: name, hint, details, summary };
144
+ }
145
+
146
+ async function selectProfile({ launching = false } = {}) {
112
147
  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.');
148
+ if (!profiles.length) {
149
+ throw userError(t('profiles.none'), [
150
+ { label: t('hint.add'), command: 'cpf add work' },
151
+ { label: t('hint.setup'), command: 'npx ai-profile-switch@latest init' },
152
+ ]);
153
+ }
114
154
  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) });
155
+ const output = process.stderr;
156
+ colorFor(output);
157
+ if (!process.stdin.isTTY || profiles.length === 1) {
158
+ const selected = active ?? profiles[0];
159
+ assertRegisteredProfile(selected);
160
+ if (launching && output.isTTY) {
161
+ const summary = accountSummary(accountInfo(selected));
162
+ output.write(`${color.green(sym.stepDone)} ${t('picker.starting', { profile: color.bold(selected) })}${summary ? ` ${color.dim(summary)}` : ''}\n`);
163
+ }
164
+ return selected;
165
+ }
166
+ await refreshUsage(profiles);
167
+ const choices = profileChoices(profiles, active);
168
+ intro('ai-profile-switch', '', output);
169
+ const selected = await prompt.select(t('picker.question'), choices, {
170
+ output, initial: profiles.indexOf(active), numbered: true,
171
+ done: name => {
172
+ const { summary } = choices.find(choice => choice.value === name);
173
+ const message = t(launching ? 'picker.starting' : 'picker.switched', { profile: color.bold(name) });
174
+ return `${message}${summary ? ` ${color.dim(summary)}` : ''}`;
175
+ },
176
+ });
177
+ outro(selected && launching ? t('launch.opening') : '', output);
118
178
  if (selected) assertRegisteredProfile(selected);
119
179
  return selected;
120
180
  }
121
181
 
182
+ // Declared below; the Windows resolver test runs that part on its own.
183
+ export { resolveClaudeBinary };
184
+
122
185
  function executable(file) {
123
186
  try {
124
187
  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,122 @@
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 { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { readFileSync, statSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { PROFILES_DIR } from './constants.mjs';
9
+
10
+ const PLANS = { claude_max: 'Max', claude_pro: 'Pro', claude_team: 'Team', claude_enterprise: 'Enterprise' };
11
+
12
+ function readConfig(dir) {
13
+ try {
14
+ const config = JSON.parse(readFileSync(join(dir, '.claude.json'), 'utf8'));
15
+ return config && typeof config === 'object' ? config : {};
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ function percent(window) {
22
+ const value = window?.utilization;
23
+ return typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : null;
24
+ }
25
+
26
+ // A window whose reset time has passed starts again from zero.
27
+ function hasReset(window, now) {
28
+ const resetsAt = typeof window?.resets_at === 'string' ? Date.parse(window.resets_at) : NaN;
29
+ return Number.isFinite(resetsAt) && resetsAt <= now;
30
+ }
31
+
32
+ // Claude Code keeps usage in an undocumented cache; it may change or disappear.
33
+ function usage(cached, account, now = Date.now()) {
34
+ if (!cached || typeof cached !== 'object') return null;
35
+ if (typeof cached.accountUuid === 'string' && typeof account.accountUuid === 'string'
36
+ && cached.accountUuid !== account.accountUuid) return null;
37
+ const windows = { fiveHour: cached.utilization?.five_hour, weekly: cached.utilization?.seven_day };
38
+ const reset = { fiveHour: false, weekly: false };
39
+ const values = {};
40
+ for (const [key, window] of Object.entries(windows)) {
41
+ values[key] = percent(window);
42
+ if (values[key] !== null && hasReset(window, now)) [values[key], reset[key]] = [0, true];
43
+ }
44
+ if (values.fiveHour === null && values.weekly === null) return null;
45
+ const savedAt = Number.isFinite(cached.fetchedAtMs) ? cached.fetchedAtMs : null;
46
+ return { fiveHour: values.fiveHour, weekly: values.weekly, savedAt, reset };
47
+ }
48
+
49
+ // Usage fetched in this run, by profile name. It wins over the saved cache.
50
+ const live = new Map();
51
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
52
+ const FRESH_MS = 60_000;
53
+
54
+ // Claude Code stores each config folder's sign-in under a keychain item named
55
+ // after the folder path; elsewhere, or without a keychain, in .credentials.json.
56
+ function keychainService(dir) {
57
+ return `Claude Code-credentials-${createHash('sha256').update(dir).digest('hex').slice(0, 8)}`;
58
+ }
59
+
60
+ function readKeychain(dir) {
61
+ return new Promise(resolve => {
62
+ execFile('security', ['find-generic-password', '-s', keychainService(dir), '-w'], { timeout: 1000 },
63
+ (err, stdout) => resolve(err ? null : stdout));
64
+ });
65
+ }
66
+
67
+ async function readCredentials(dir) {
68
+ const fromKeychain = process.platform === 'darwin' ? await readKeychain(dir) : null;
69
+ return fromKeychain ?? readFile(join(dir, '.credentials.json'), 'utf8').catch(() => null);
70
+ }
71
+
72
+ // An expired token is skipped, not refreshed: refreshing could sign Claude Code out.
73
+ function accessToken(raw, now) {
74
+ try {
75
+ const oauth = JSON.parse(raw)?.claudeAiOauth;
76
+ if (typeof oauth?.accessToken !== 'string' || !oauth.accessToken) return null;
77
+ if (Number.isFinite(oauth.expiresAt) && oauth.expiresAt <= now + FRESH_MS) return null;
78
+ return oauth.accessToken;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ // Asks the usage API for each signed-in profile at once. Anything slow or failing
85
+ // keeps the saved value, so the screen never waits more than timeoutMs.
86
+ export async function refreshUsage(names, { read = readCredentials, request = fetch, timeoutMs = 1500 } = {}) {
87
+ const now = Date.now();
88
+ const signal = AbortSignal.timeout(timeoutMs);
89
+ const due = names.filter(name => !(now - (live.get(name)?.fetchedAtMs ?? 0) < FRESH_MS)
90
+ && readConfig(join(PROFILES_DIR, name)).oauthAccount);
91
+ const fetchOne = async name => {
92
+ try {
93
+ const token = accessToken(await read(join(PROFILES_DIR, name)), now);
94
+ if (!token || signal.aborted) return;
95
+ const response = await request(USAGE_URL, {
96
+ headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' }, signal,
97
+ });
98
+ const utilization = response.ok ? await response.json() : null;
99
+ if (utilization && typeof utilization === 'object' && !signal.aborted) {
100
+ live.set(name, { utilization, fetchedAtMs: Date.now() });
101
+ }
102
+ } catch { /* keep the saved value */ }
103
+ };
104
+ let timer;
105
+ const deadline = new Promise(resolve => { timer = setTimeout(resolve, timeoutMs); });
106
+ await Promise.race([Promise.all(due.map(fetchOne)), deadline]);
107
+ clearTimeout(timer);
108
+ }
109
+
110
+ export function accountInfo(name) {
111
+ const dir = join(PROFILES_DIR, name);
112
+ const config = readConfig(dir);
113
+ const account = config.oauthAccount && typeof config.oauthAccount === 'object' ? config.oauthAccount : {};
114
+ let lastUsed = null;
115
+ try { lastUsed = statSync(join(dir, 'history.jsonl')).mtimeMs; } catch { /* never used */ }
116
+ return {
117
+ email: typeof account.emailAddress === 'string' && account.emailAddress ? account.emailAddress : null,
118
+ plan: Object.hasOwn(PLANS, account.organizationType) ? PLANS[account.organizationType] : null,
119
+ usage: usage(live.get(name) ?? config.cachedUsageUtilization, account),
120
+ lastUsed,
121
+ };
122
+ }
@@ -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';