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.
- package/README.ko.md +54 -119
- package/README.md +50 -115
- package/package.json +6 -1
- package/src/commands/add.mjs +19 -6
- package/src/commands/init.mjs +44 -45
- package/src/commands/install-shell.mjs +4 -3
- package/src/commands/language.mjs +38 -0
- package/src/commands/list.mjs +13 -6
- package/src/commands/login.mjs +43 -0
- package/src/commands/manage.mjs +108 -51
- package/src/commands/mcp/index.mjs +7 -6
- package/src/commands/migrate.mjs +19 -18
- package/src/commands/remove.mjs +7 -6
- package/src/commands/rename.mjs +15 -10
- package/src/commands/share.mjs +16 -14
- package/src/commands/shell.mjs +78 -15
- package/src/commands/update.mjs +15 -12
- package/src/commands/use.mjs +5 -3
- package/src/index.mjs +14 -36
- package/src/lib/account.mjs +122 -0
- package/src/lib/config.mjs +8 -1
- package/src/lib/constants.mjs +4 -0
- package/src/lib/i18n.mjs +65 -0
- package/src/lib/profile.mjs +62 -19
- package/src/lib/prompt.mjs +105 -38
- package/src/lib/shared.mjs +5 -4
- package/src/lib/ui.mjs +54 -29
- package/src/locales/en.mjs +269 -0
- package/src/locales/ko.mjs +269 -0
package/src/commands/rename.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
8
|
+
checkRename(oldName, newName);
|
|
9
|
+
if (oldName === newName) {
|
|
10
|
+
info(t('rename.same', { name: oldName }));
|
|
11
|
+
return;
|
|
11
12
|
}
|
|
12
|
-
if (
|
|
13
|
-
|
|
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
|
}
|
package/src/commands/share.mjs
CHANGED
|
@@ -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(
|
|
10
|
+
warn(t('share.keptOwn', { name, entry }));
|
|
10
11
|
}
|
|
11
|
-
for (const entry of missing) warn(
|
|
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('
|
|
20
|
-
|
|
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(
|
|
25
|
-
...result.linked.map(entry =>
|
|
26
|
-
...result.missing.map(entry => color.red(
|
|
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('
|
|
30
|
+
console.log(` ${result.name.padEnd(12)} ${problems.length ? problems.join(', ') : color.green(t('share.inSync'))}`);
|
|
29
31
|
}
|
|
30
|
-
if (!shared.length) info('
|
|
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('
|
|
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(
|
|
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(
|
|
51
|
+
success(t('share.added', { entry }));
|
|
50
52
|
return;
|
|
51
53
|
}
|
|
52
54
|
if (command === 'remove') {
|
|
53
55
|
unshareEntry(entry);
|
|
54
|
-
success(
|
|
56
|
+
success(t('share.removed', { entry }));
|
|
55
57
|
return;
|
|
56
58
|
}
|
|
57
|
-
throw new Error('
|
|
59
|
+
throw new Error(t('share.usage'));
|
|
58
60
|
}
|
package/src/commands/shell.mjs
CHANGED
|
@@ -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('
|
|
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('
|
|
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(
|
|
64
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
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);
|
package/src/commands/update.mjs
CHANGED
|
@@ -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('
|
|
18
|
-
info('
|
|
18
|
+
info(t('update.claudeManaged'));
|
|
19
|
+
info(t('update.seeSetup'));
|
|
19
20
|
if (values.check) {
|
|
20
|
-
warn('
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
45
|
-
else if (result.reason === 'not-installed') info('
|
|
46
|
-
else if (result.reason === 'busy') { warn('
|
|
47
|
-
else if (result.action === 'failed') { error(
|
|
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
|
+
|
package/src/commands/use.mjs
CHANGED
|
@@ -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(
|
|
10
|
+
info(t('use.already', { profile: name }));
|
|
10
11
|
return;
|
|
11
12
|
}
|
|
12
13
|
setActiveProfile(name);
|
|
13
|
-
success(
|
|
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(
|
|
44
|
-
if (command
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -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);
|
package/src/lib/constants.mjs
CHANGED
|
@@ -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';
|