ai-profile-switch 2.2.1 → 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 CHANGED
@@ -42,6 +42,7 @@ npx ai-profile-switch@latest init
42
42
  - 전환은 다음 실행부터 적용됩니다. 이미 켜진 세션은 원래 계정을 그대로 씁니다.
43
43
  - 스크립트에서는 `claude` 가 묻지 않고 지금 프로필로 실행합니다.
44
44
  - Bash·zsh 프롬프트에서 `command claude` 를 치면 프로필 없이 원래 Claude 가 실행됩니다.
45
+ - IDE 확장(VS Code, JetBrains)과 데스크톱 앱은 셸을 거치지 않아 기본 `~/.claude` 를 계속 씁니다.
45
46
 
46
47
  ## 프로필끼리 설정 나누기
47
48
 
@@ -89,4 +90,8 @@ npx ai-profile-switch@latest install-shell # 셸 명령 고치기·새로 깔
89
90
  - 플랫폼별 안내: [macOS](docs/setup-macos.ko.md) · [Linux](docs/setup-linux.ko.md) · [Windows](docs/setup-windows.ko.md)
90
91
  - 이 소스를 직접 쓰려면 레포 폴더에서 `node bin/cli.mjs install-shell` 을 실행합니다.
91
92
 
93
+ ## 기여하기
94
+
95
+ 버그 신고, 수정, 문서, 다른 AI 코딩 CLI 지원 모두 환영합니다. [CONTRIBUTING.md](CONTRIBUTING.md)(영어)를 먼저 읽고, [good first issue](https://github.com/ftery0/ai-profile-switch/labels/good%20first%20issue) 나 [new tool](https://github.com/ftery0/ai-profile-switch/labels/new%20tool) 라벨이 붙은 이슈에서 골라 주세요. 이슈와 PR 은 영어로 씁니다. 보안 문제는 공개 이슈 대신 [SECURITY.md](SECURITY.md) 에 적힌 대로 알려 주세요.
96
+
92
97
  [MIT](./LICENSE)
package/README.md CHANGED
@@ -42,6 +42,7 @@ Run these at the terminal prompt. If you are in the Claude chat, enter `/exit` f
42
42
  - Switching applies to the next launch. Running sessions keep their account.
43
43
  - In scripts, `claude` uses the active profile without asking.
44
44
  - At a Bash or zsh prompt, `command claude` runs the original Claude without a profile.
45
+ - IDE extensions (VS Code, JetBrains) and the desktop app don't go through the shell, so they keep using the default `~/.claude`.
45
46
 
46
47
  ## Share settings across profiles
47
48
 
@@ -89,4 +90,8 @@ Coming from `claude-account-switch` or 1.x? Run `npx ai-profile-switch@latest in
89
90
  - Platform guides: [macOS](docs/setup-macos.md) · [Linux](docs/setup-linux.md) · [Windows](docs/setup-windows.md)
90
91
  - To use this source checkout, run `node bin/cli.mjs install-shell` from the repository root.
91
92
 
93
+ ## Contributing
94
+
95
+ Bug reports, fixes, docs, and support for more AI coding CLIs are all welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md), then pick an issue labeled [good first issue](https://github.com/ftery0/ai-profile-switch/labels/good%20first%20issue) or [new tool](https://github.com/ftery0/ai-profile-switch/labels/new%20tool). Please report security problems privately, as described in [SECURITY.md](SECURITY.md).
96
+
92
97
  [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-profile-switch",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Profile switching for AI coding CLIs, starting with Claude Code (formerly claude-account-switch)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "scripts": {
18
18
  "test": "node --test",
19
+ "test:e2e": "bash test/e2e/single-user/run.sh",
19
20
  "docs:dev": "vitepress dev website",
20
21
  "docs:build": "vitepress build website",
21
22
  "docs:preview": "vitepress preview website"
@@ -34,6 +35,10 @@
34
35
  "type": "git",
35
36
  "url": "git+https://github.com/ftery0/ai-profile-switch.git"
36
37
  },
38
+ "bugs": {
39
+ "url": "https://github.com/ftery0/ai-profile-switch/issues"
40
+ },
41
+ "homepage": "https://ftery0.github.io/ai-profile-switch/",
37
42
  "devDependencies": {
38
43
  "vitepress": "^1.6.4"
39
44
  },
@@ -1,7 +1,7 @@
1
1
  import * as prompt from '../lib/prompt.mjs';
2
2
  import { readMeta } from '../lib/config.mjs';
3
3
  import { color, displayWidth, error, intro, sym, warn } from '../lib/ui.mjs';
4
- import { accountInfo } from '../lib/account.mjs';
4
+ import { accountInfo, refreshUsage } from '../lib/account.mjs';
5
5
  import { HOME } from '../lib/constants.mjs';
6
6
  import { LANGUAGE_NAMES, language as currentLanguage, relativeTime, t } from '../lib/i18n.mjs';
7
7
  import { checkRename, profileDir } from '../lib/profile.mjs';
@@ -74,7 +74,8 @@ export async function manage(args = []) {
74
74
  }
75
75
  }
76
76
 
77
- function pickProfile(question, meta) {
77
+ async function pickProfile(question, meta) {
78
+ await refreshUsage(meta.profiles);
78
79
  return prompt.select(question, profileChoices(meta.profiles, meta.activeProfile), {
79
80
  initial: Math.max(0, meta.profiles.indexOf(meta.activeProfile)), numbered: true, back: true,
80
81
  });
@@ -7,7 +7,7 @@ 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
9
  import { color, colorFor, intro, meter, outro, success, sym, userError, warn } from '../lib/ui.mjs';
10
- import { accountInfo } from '../lib/account.mjs';
10
+ import { accountInfo, refreshUsage } from '../lib/account.mjs';
11
11
  import { relativeTime, t } from '../lib/i18n.mjs';
12
12
  import { syncShared } from '../lib/shared.mjs';
13
13
  import { reportConflicts } from './share.mjs';
@@ -118,8 +118,9 @@ export function accountSummary({ email, plan }) {
118
118
  export function usageLine({ usage }) {
119
119
  if (!usage) return color.dim(t('account.noUsage'));
120
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)}`);
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')}`);
123
124
  if (usage.savedAt) parts.push(color.dim(t('account.savedAt', { time: relativeTime(usage.savedAt) })));
124
125
  return parts.join(' ');
125
126
  }
@@ -162,6 +163,7 @@ async function selectProfile({ launching = false } = {}) {
162
163
  }
163
164
  return selected;
164
165
  }
166
+ await refreshUsage(profiles);
165
167
  const choices = profileChoices(profiles, active);
166
168
  intro('ai-profile-switch', '', output);
167
169
  const selected = await prompt.select(t('picker.question'), choices, {
@@ -1,6 +1,9 @@
1
1
  // Reads what Claude Code saved in a profile, for display only. Nothing is written,
2
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';
3
5
  import { readFileSync, statSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
4
7
  import { join } from 'node:path';
5
8
  import { PROFILES_DIR } from './constants.mjs';
6
9
 
@@ -20,16 +23,88 @@ function percent(window) {
20
23
  return typeof value === 'number' && Number.isFinite(value) ? Math.min(100, Math.max(0, Math.round(value))) : null;
21
24
  }
22
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
+
23
32
  // Claude Code keeps usage in an undocumented cache; it may change or disappear.
24
- function usage(cached, account) {
33
+ function usage(cached, account, now = Date.now()) {
25
34
  if (!cached || typeof cached !== 'object') return null;
26
35
  if (typeof cached.accountUuid === 'string' && typeof account.accountUuid === 'string'
27
36
  && 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;
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;
31
45
  const savedAt = Number.isFinite(cached.fetchedAtMs) ? cached.fetchedAtMs : null;
32
- return { fiveHour, weekly, savedAt };
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);
33
108
  }
34
109
 
35
110
  export function accountInfo(name) {
@@ -41,7 +116,7 @@ export function accountInfo(name) {
41
116
  return {
42
117
  email: typeof account.emailAddress === 'string' && account.emailAddress ? account.emailAddress : null,
43
118
  plan: Object.hasOwn(PLANS, account.organizationType) ? PLANS[account.organizationType] : null,
44
- usage: usage(config.cachedUsageUtilization, account),
119
+ usage: usage(live.get(name) ?? config.cachedUsageUtilization, account),
45
120
  lastUsed,
46
121
  };
47
122
  }
@@ -231,6 +231,37 @@ function validateJson(source) {
231
231
  }
232
232
  }
233
233
 
234
+ // Claude Code saves absolute paths in its plugin indexes. Copied as they are, a profile would keep
235
+ // loading plugins from the source folder and break once that folder is removed.
236
+ const PLUGIN_INDEXES = ['installed_plugins.json', 'known_marketplaces.json'];
237
+
238
+ function repointPlugins(plugins, sourceDir, targetDir) {
239
+ if (!pathExists(plugins) || !lstatSync(plugins).isDirectory()) return;
240
+ const to = join(targetDir, 'plugins');
241
+ const prefixes = [...new Set([join(sourceDir, 'plugins'), join(canonicalPath(sourceDir), 'plugins')])];
242
+ const move = value => {
243
+ if (typeof value === 'string') {
244
+ const from = prefixes.find(prefix => value === prefix
245
+ || value.startsWith(prefix + sep) || value.startsWith(prefix + '/'));
246
+ return from ? to + value.slice(from.length) : value;
247
+ }
248
+ if (Array.isArray(value)) return value.map(move);
249
+ if (value && typeof value === 'object') {
250
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, move(item)]));
251
+ }
252
+ return value;
253
+ };
254
+ for (const name of PLUGIN_INDEXES) {
255
+ const path = join(plugins, name);
256
+ if (!pathExists(path) || !lstatSync(path).isFile()) continue;
257
+ const text = readFileSync(path, 'utf8');
258
+ let index;
259
+ try { index = JSON.parse(text); } catch { continue; }
260
+ const moved = JSON.stringify(move(index), null, 2);
261
+ if (moved !== JSON.stringify(index, null, 2)) writeFileSync(path, moved + '\n');
262
+ }
263
+ }
264
+
234
265
  function stageEntry(source, destination) {
235
266
  if (!pathExists(source)) return;
236
267
  if (pathExists(destination)) {
@@ -293,6 +324,7 @@ function prepareMigration(source, profileName, shareSettings, stage) {
293
324
  stageEntry(join(source.directory, entry), join(stagedProfile, entry));
294
325
  if (!pathExists(join(stagedProfile, entry))) mkdirSync(join(stagedProfile, entry));
295
326
  }
327
+ repointPlugins(join(stagedProfile, 'plugins'), source.directory, target);
296
328
  for (const entry of [...SHARED_FILES, ...SHARED_DIRS]) {
297
329
  const shared = shareSettings ? stagedShared : stagedProfile;
298
330
  const from = join(source.directory, entry);
@@ -110,6 +110,7 @@ export async function select(message, choices, {
110
110
  return new Promise((resolve) => {
111
111
  let selected = Math.max(0, Math.min(initial, items.length - 1));
112
112
  let height = 0;
113
+ let top = 0;
113
114
  const cursor = cursorControl(output);
114
115
  const rail = color.dim(sym.rail);
115
116
  const indent = ' '.repeat(numbered ? 5 : 2);
@@ -117,17 +118,22 @@ export async function select(message, choices, {
117
118
  ? t(back ? 'prompt.keysNumberedBack' : 'prompt.keysNumbered', { arrows: sym.arrows, max: items.length })
118
119
  : t(back ? 'prompt.keysBack' : 'prompt.keys', { arrows: sym.arrows });
119
120
 
120
- const rows = () => {
121
+ // The choices from index from up to to, with details under the ones detailed(i) picks.
122
+ const rows = (detailed = () => true, from = 0, to = items.length) => {
121
123
  const lines = [];
122
- items.forEach((item, i) => {
124
+ if (from > 0) lines.push(`${rail} ${color.dim(t('prompt.moreAbove', { count: from }))}`);
125
+ items.slice(from, to).forEach((item, offset) => {
126
+ const i = from + offset;
123
127
  const number = numbered ? `${i + 1} ` : '';
124
128
  const head = i === selected
125
129
  ? color.cyan(`${sym.cursor} ${number}${item.label}`)
126
130
  : ` ${numbered ? `${color.dim(String(i + 1))} ` : ''}${item.label}`;
127
131
  lines.push(`${rail} ${head}${item.hint ? ` ${item.hint}` : ''}`);
128
- for (const detail of item.details ?? []) lines.push(`${rail} ${indent}${detail}`);
129
- if (item.details?.length) lines.push(rail);
132
+ if (!detailed(i) || !item.details?.length) return;
133
+ for (const detail of item.details) lines.push(`${rail} ${indent}${detail}`);
134
+ lines.push(rail);
130
135
  });
136
+ if (to < items.length) lines.push(`${rail} ${color.dim(t('prompt.moreBelow', { count: items.length - to }))}`);
131
137
  lines.push(`${rail} ${color.dim(keys)}`, color.dim(sym.railEnd));
132
138
  return lines;
133
139
  };
@@ -137,10 +143,32 @@ export async function select(message, choices, {
137
143
  + Math.max(1, Math.ceil(displayWidth(line) / (output.columns || Infinity))), 0);
138
144
  const header = [`${color.cyan(sym.step)} ${message}`, ...body.map(line => `${rail} ${line}`)];
139
145
 
146
+ // The cursor can't move up past the top of the window, so a menu taller than the window
147
+ // can't be redrawn in place: every redraw would push another copy of its first rows into
148
+ // the scrollback. It shrinks to fit instead: details only under the selected choice, then
149
+ // only the choices around it. The row under the menu is where the cursor waits.
150
+ const fits = lines => screenRows(header) + screenRows(lines) < (output.rows || Infinity);
151
+ const layout = () => {
152
+ const full = rows();
153
+ if (fits(full)) return full;
154
+ const shown = (from, to) => rows(i => i === selected, from, to);
155
+ if (fits(shown(0, items.length))) return shown(0, items.length);
156
+ const end = from => {
157
+ let to = Math.max(from, selected) + 1;
158
+ while (to < items.length && fits(shown(from, to + 1))) to++;
159
+ return to;
160
+ };
161
+ top = Math.min(top, selected);
162
+ while (top < selected && !fits(shown(top, end(top)))) top++;
163
+ return shown(top, end(top));
164
+ };
165
+
140
166
  const render = () => {
141
167
  if (height) output.write(cursor.up(height));
142
- const lines = rows();
168
+ const lines = layout();
143
169
  for (const line of lines) output.write(`${cursor.clearLine}${line}\n`);
170
+ // A shorter menu than last time leaves old rows below it.
171
+ output.write(cursor.clearDown);
144
172
  height = screenRows(lines);
145
173
  };
146
174
 
@@ -6,6 +6,8 @@ export default {
6
6
  'prompt.keysNumbered': '{arrows} move · 1-{max} pick · Enter select · Esc cancel',
7
7
  'prompt.keysBack': '{arrows} move · Enter select · Esc back',
8
8
  'prompt.keysNumberedBack': '{arrows} move · 1-{max} pick · Enter select · Esc back',
9
+ 'prompt.moreAbove': '{count} more above',
10
+ 'prompt.moreBelow': '{count} more below',
9
11
  'prompt.cancelled': 'cancelled',
10
12
  'prompt.yes': 'Yes',
11
13
  'prompt.no': 'No',
@@ -26,6 +28,7 @@ export default {
26
28
  'account.weekly': 'Weekly',
27
29
  'account.noUsage': 'No usage saved yet',
28
30
  'account.savedAt': 'as of {time}',
31
+ 'account.reset': 'reset',
29
32
  'account.notSignedIn': 'Not signed in yet',
30
33
 
31
34
  'profile.empty': 'Profile name cannot be empty',
@@ -259,6 +262,7 @@ Without the shell commands, use: npx ai-profile-switch@latest manage [arguments]
259
262
  update [--check] Update this tool; --check only checks
260
263
  share [add|remove] Share CLAUDE.md, skills, agents, … across profiles
261
264
  mcp [list] Show where old MCP settings went
265
+ --version, -v Show the installed version
262
266
 
263
267
  Run any command with --help for its usage.
264
268
  `,
@@ -6,6 +6,8 @@ export default {
6
6
  'prompt.keysNumbered': '{arrows} 이동 · 1-{max} 바로 선택 · Enter 선택 · Esc 취소',
7
7
  'prompt.keysBack': '{arrows} 이동 · Enter 선택 · Esc 뒤로',
8
8
  'prompt.keysNumberedBack': '{arrows} 이동 · 1-{max} 바로 선택 · Enter 선택 · Esc 뒤로',
9
+ 'prompt.moreAbove': '위에 {count}개 더',
10
+ 'prompt.moreBelow': '아래에 {count}개 더',
9
11
  'prompt.cancelled': '취소했어요',
10
12
  'prompt.yes': '예',
11
13
  'prompt.no': '아니요',
@@ -26,6 +28,7 @@ export default {
26
28
  'account.weekly': '주간',
27
29
  'account.noUsage': '아직 저장된 사용량 없음',
28
30
  'account.savedAt': '{time} 기준',
31
+ 'account.reset': '초기화됨',
29
32
  'account.notSignedIn': '아직 로그인하지 않았어요',
30
33
 
31
34
  'profile.empty': '프로필 이름을 적어 주세요',
@@ -259,6 +262,7 @@ claude 를 실행하면 프로필을 골라 Claude Code 를 시작해요.
259
262
  update [--check] 이 도구 업데이트 (--check 는 확인만)
260
263
  share [add|remove] CLAUDE.md·skills·agents 등을 같이 쓰기
261
264
  mcp [list] 예전 MCP 설정이 어디로 갔는지 보기
265
+ --version, -v 설치된 버전 보기
262
266
 
263
267
  명령 뒤에 --help 를 붙이면 사용법이 나와요.
264
268
  `,