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.
@@ -11,7 +11,8 @@ import {
11
11
  } from './constants.mjs';
12
12
  import { createHash } from 'node:crypto';
13
13
  import { addProfileToMeta, removeProfileFromMeta, readMeta, writeMeta } from './config.mjs';
14
- import { warn } from './ui.mjs';
14
+ import { userError, warn } from './ui.mjs';
15
+ import { t } from './i18n.mjs';
15
16
 
16
17
  export function pathExists(path) {
17
18
  try { lstatSync(path); return true; } catch (err) {
@@ -31,23 +32,21 @@ export function createLink(target, link, isDir, copySource = resolve(dirname(lin
31
32
  } catch (err) {
32
33
  if (!IS_WINDOWS || isDir || !['EPERM', 'ENOTSUP'].includes(err.code)) throw err;
33
34
  copyFileSync(copySource, link);
34
- warn(`Symlink unavailable: copied shared file instead (${link}).`);
35
- warn('Enable Windows Developer Mode for full shared-settings sync.');
35
+ warn(t('profile.copiedInstead', { link }));
36
+ warn(t('profile.devMode'));
36
37
  }
37
38
  }
38
39
 
39
40
  export function validateProfileName(name) {
40
- if (!name) return 'Profile name cannot be empty';
41
+ if (!name) return t('profile.empty');
41
42
  if (name.length > PROFILE_NAME_MAX_LENGTH) {
42
- return `Profile name must be ${PROFILE_NAME_MAX_LENGTH} characters or fewer`;
43
+ return t('profile.tooLong', { max: PROFILE_NAME_MAX_LENGTH });
43
44
  }
44
- if (RESERVED_NAMES.includes(name)) return `"${name}" is a reserved name`;
45
+ if (RESERVED_NAMES.includes(name)) return t('profile.reserved', { name });
45
46
  if (IS_WINDOWS && /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(name)) {
46
- return `"${name}" is a reserved Windows device name`;
47
- }
48
- if (!PROFILE_NAME_REGEX.test(name)) {
49
- return 'Only lowercase letters, numbers, and hyphens allowed (must start/end with letter or number)';
47
+ return t('profile.reservedWindows', { name });
50
48
  }
49
+ if (!PROFILE_NAME_REGEX.test(name)) return t('profile.invalidName');
51
50
  return null;
52
51
  }
53
52
 
@@ -87,13 +86,16 @@ export function assertRegisteredProfile(name) {
87
86
  const error = validateProfileName(name);
88
87
  if (error) throw new Error(error);
89
88
  const meta = readMeta();
90
- if (!meta.profiles.includes(name)) throw new Error(`Profile "${name}" is not registered`);
89
+ if (!meta.profiles.includes(name)) {
90
+ throw userError(t('profile.notFound', { name }), [
91
+ ...(meta.profiles.length ? [{ label: t('hint.profiles'), text: meta.profiles.join(', ') }] : []),
92
+ { label: t('hint.create'), command: `cpf add ${name}` },
93
+ ]);
94
+ }
91
95
  const dir = profileDir(name);
92
- if (!pathExists(dir)) throw new Error(`Profile "${name}" directory is missing`);
96
+ if (!pathExists(dir)) throw new Error(t('profile.missingDir', { name }));
93
97
  const entry = lstatSync(dir);
94
- if (entry.isSymbolicLink() || !entry.isDirectory()) {
95
- throw new Error(`Profile "${name}" must be a directory, not a symbolic link or file`);
96
- }
98
+ if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(t('profile.notDirectory', { name }));
97
99
  return meta;
98
100
  }
99
101
 
@@ -103,16 +105,25 @@ export function removeProfile(name) {
103
105
  removeProfileFromMeta(name);
104
106
  }
105
107
 
106
- export function renameProfile(oldName, newName) {
108
+ // Checks a rename without changing anything, so callers can warn only when it can go ahead.
109
+ export function checkRename(oldName, newName) {
107
110
  const meta = assertRegisteredProfile(oldName);
108
111
  const error = validateProfileName(newName);
109
112
  if (error) throw new Error(error);
113
+ if (oldName !== newName && (meta.profiles.includes(newName) || pathExists(profileDir(newName)))) {
114
+ throw userError(t('profile.exists', { name: newName }), [
115
+ { label: t('hint.profiles'), text: meta.profiles.join(', ') },
116
+ { label: t('hint.otherName'), command: `cpf rename ${oldName} ${newName}-2` },
117
+ ]);
118
+ }
119
+ return meta;
120
+ }
121
+
122
+ export function renameProfile(oldName, newName) {
123
+ const meta = checkRename(oldName, newName);
110
124
  if (oldName === newName) return false;
111
125
  const oldDir = profileDir(oldName);
112
126
  const newDir = profileDir(newName);
113
- if (meta.profiles.includes(newName) || pathExists(newDir)) {
114
- throw new Error(`Profile "${newName}" already exists`);
115
- }
116
127
 
117
128
  renameSync(oldDir, newDir);
118
129
  try {
@@ -1,6 +1,7 @@
1
1
  import { createInterface, emitKeypressEvents } from 'node:readline';
2
2
  import { PassThrough } from 'node:stream';
3
- import { color, cursorControl, sym } from './ui.mjs';
3
+ import { color, cursorControl, displayWidth, sym } from './ui.mjs';
4
+ import { t } from './i18n.mjs';
4
5
 
5
6
  function rl() {
6
7
  return createInterface({ input: process.stdin, output: process.stdout });
@@ -29,17 +30,18 @@ function readKeys(onKey) {
29
30
  };
30
31
  }
31
32
 
32
- export async function text(message, defaultVal) {
33
- const suffix = defaultVal !== undefined ? color.dim(` (${defaultVal})`) : '';
33
+ // hint is shown only while asking, such as "now: side · empty to cancel".
34
+ export async function text(message, defaultVal, hint) {
35
+ const suffix = hint ? ` ${color.dim(hint)}` : defaultVal !== undefined ? color.dim(` (${defaultVal})`) : '';
34
36
  const r = rl();
35
37
  const cursor = cursorControl();
36
38
  return new Promise((resolve) => {
37
- r.question(`${color.cyan('?')} ${message}${suffix}\n ${color.dim(sym.arrow)} `, (answer) => {
39
+ r.question(`${color.cyan(sym.step)} ${message}${suffix}\n${color.dim(sym.rail)} ${color.dim(sym.arrow)} `, (answer) => {
38
40
  r.close();
39
41
  const result = answer.trim() || defaultVal || '';
40
- // Replace 2-line prompt with single completed line: ✓ message … answer
42
+ // Replace 2-line prompt with single completed line: ◇ message … answer
41
43
  if (cursor.enabled) {
42
- process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.check)} ${message} ${color.dim(sym.dots)} ${color.cyan(result)}\n${cursor.clearDown}`);
44
+ process.stdout.write(`${cursor.up(2)}\r${cursor.clearLine}${color.green(sym.stepDone)} ${message} ${color.dim(sym.dots)} ${result ? color.cyan(result) : color.dim(t('prompt.cancelled'))}\n${cursor.clearDown}`);
43
45
  }
44
46
  resolve(result);
45
47
  });
@@ -63,11 +65,12 @@ export async function confirm(message, defaultVal = true) {
63
65
  const cursor = cursorControl();
64
66
 
65
67
  const render = (final = false) => {
66
- const prefix = final ? color.green(sym.check) : color.cyan('?');
68
+ const prefix = final ? color.green(sym.stepDone) : color.cyan(sym.step);
67
69
  const sep = final ? color.dim(` ${sym.dots} `) : color.dim(` ${sym.arrow} `);
68
- const no = !selected ? color.cyan('No') : color.dim('No');
69
- const yes = selected ? color.cyan('Yes') : color.dim('Yes');
70
- process.stdout.write(`\r${cursor.clearLine}${prefix} ${message}${sep}${no} ${color.dim('/')} ${yes}`);
70
+ const no = !selected ? color.cyan(t('prompt.no')) : color.dim(t('prompt.no'));
71
+ const yes = selected ? color.cyan(t('prompt.yes')) : color.dim(t('prompt.yes'));
72
+ const answer = final ? (selected ? yes : no) : `${no} ${color.dim('/')} ${yes}`;
73
+ process.stdout.write(`\r${cursor.clearLine}${prefix} ${message}${sep}${answer}`);
71
74
  };
72
75
 
73
76
  render();
@@ -82,7 +85,8 @@ export async function confirm(message, defaultVal = true) {
82
85
  } else if (character === 'n' || character === 'N') {
83
86
  selected = false;
84
87
  render();
85
- } else if (key.name === 'return' || key.name === 'enter') {
88
+ } else if (key.name === 'return' || key.name === 'enter' || key.name === 'escape') {
89
+ if (key.name === 'escape') selected = false;
86
90
  stop();
87
91
  render(true);
88
92
  process.stdout.write('\n');
@@ -96,52 +100,87 @@ export async function confirm(message, defaultVal = true) {
96
100
  });
97
101
  }
98
102
 
99
- export async function select(message, choices, { output = process.stdout, initial = 0 } = {}) {
103
+ // Choices are strings or { label, value, hint, details }. hint sits after the label,
104
+ // details are extra dim lines under it. numbered adds 1-9 keys that pick at once.
105
+ // body lines sit between the question and the choices; back says Esc goes back.
106
+ export async function select(message, choices, {
107
+ output = process.stdout, initial = 0, numbered = false, done, body = [], back = false,
108
+ } = {}) {
109
+ const items = choices.map(choice => (typeof choice === 'string' ? { label: choice, value: choice } : choice));
100
110
  return new Promise((resolve) => {
101
- let selected = Math.max(0, Math.min(initial, choices.length - 1));
102
- let rendered = false;
111
+ let selected = Math.max(0, Math.min(initial, items.length - 1));
112
+ let height = 0;
103
113
  const cursor = cursorControl(output);
114
+ const rail = color.dim(sym.rail);
115
+ const indent = ' '.repeat(numbered ? 5 : 2);
116
+ const keys = numbered && items.length > 1 && items.length <= 9
117
+ ? t(back ? 'prompt.keysNumberedBack' : 'prompt.keysNumbered', { arrows: sym.arrows, max: items.length })
118
+ : t(back ? 'prompt.keysBack' : 'prompt.keys', { arrows: sym.arrows });
119
+
120
+ const rows = () => {
121
+ const lines = [];
122
+ items.forEach((item, i) => {
123
+ const number = numbered ? `${i + 1} ` : '';
124
+ const head = i === selected
125
+ ? color.cyan(`${sym.cursor} ${number}${item.label}`)
126
+ : ` ${numbered ? `${color.dim(String(i + 1))} ` : ''}${item.label}`;
127
+ 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);
130
+ });
131
+ lines.push(`${rail} ${color.dim(keys)}`, color.dim(sym.railEnd));
132
+ return lines;
133
+ };
134
+
135
+ // A line wider than the window wraps, so count the screen rows it really takes.
136
+ const screenRows = lines => lines.reduce((sum, line) => sum
137
+ + Math.max(1, Math.ceil(displayWidth(line) / (output.columns || Infinity))), 0);
138
+ const header = [`${color.cyan(sym.step)} ${message}`, ...body.map(line => `${rail} ${line}`)];
104
139
 
105
140
  const render = () => {
106
- if (rendered) {
107
- output.write(cursor.up(choices.length));
141
+ if (height) output.write(cursor.up(height));
142
+ const lines = rows();
143
+ for (const line of lines) output.write(`${cursor.clearLine}${line}\n`);
144
+ height = screenRows(lines);
145
+ };
146
+
147
+ const finish = (value, summary) => {
148
+ stop();
149
+ if (cursor.enabled) {
150
+ output.write(`${cursor.up(height + screenRows(header))}\r${cursor.clearLine}${summary}\n${cursor.clearDown}`);
108
151
  }
109
- choices.forEach((choice, i) => {
110
- const label = typeof choice === 'string' ? choice : choice.label;
111
- const prefix = i === selected ? color.cyan(` ${sym.cursor} `) : ' ';
112
- output.write(`${cursor.clearLine}${prefix}${label}\n`);
113
- });
114
- rendered = true;
152
+ resolve(value);
153
+ };
154
+
155
+ const pick = () => {
156
+ const item = items[selected];
157
+ const summary = done ? done(item.value) : `${message} ${color.dim(sym.dots)} ${color.cyan(item.label.trim())}`;
158
+ finish(item.value, `${color.green(sym.stepDone)} ${summary}`);
115
159
  };
116
160
 
117
161
  if (!process.stdin.isTTY) {
118
- // Non-interactive: pick first choice without rendering menu
119
- resolve(typeof choices[selected] === 'string' ? choices[selected] : choices[selected].value);
162
+ // Non-interactive: pick the initial choice without rendering the menu
163
+ resolve(items[selected].value);
120
164
  return;
121
165
  }
122
166
 
123
- output.write(`${color.cyan('?')} ${message}\n`);
167
+ for (const line of header) output.write(`${line}\n`);
124
168
  render();
125
169
 
126
170
  const stop = readKeys((character, key) => {
127
171
  if (key.name === 'up') {
128
- selected = (selected - 1 + choices.length) % choices.length;
172
+ selected = (selected - 1 + items.length) % items.length;
129
173
  render();
130
174
  } else if (key.name === 'down') {
131
- selected = (selected + 1) % choices.length;
175
+ selected = (selected + 1) % items.length;
132
176
  render();
177
+ } else if (numbered && /^[1-9]$/.test(character ?? '') && Number(character) <= items.length) {
178
+ selected = Number(character) - 1;
179
+ pick();
133
180
  } else if (key.name === 'return' || key.name === 'enter') {
134
- stop();
135
- const choice = choices[selected];
136
- const value = typeof choice === 'string' ? choice : choice.value;
137
- if (cursor.enabled) {
138
- output.write(`${cursor.up(choices.length + 1)}\r${cursor.clearLine}${color.green(sym.check)} ${message} ${color.dim(sym.dots)} ${color.cyan(value)}\n${cursor.clearDown}`);
139
- }
140
- resolve(value);
141
- } else if (key.ctrl && key.name === 'c') {
142
- stop();
143
- output.write('\n');
144
- resolve(null);
181
+ pick();
182
+ } else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
183
+ finish(null, `${color.dim(sym.stepDone)} ${message} ${color.dim(t('prompt.cancelled'))}`);
145
184
  }
146
185
  });
147
186
  });
@@ -6,6 +6,7 @@ import {
6
6
  SHARED_DIR, SHAREABLE_ENTRIES, SHAREABLE_FILES, ACCOUNT_SKILL_ENTRIES,
7
7
  } from './constants.mjs';
8
8
  import { readMeta, getActiveProfile, getSharedEntries, setSharedEntries } from './config.mjs';
9
+ import { t } from './i18n.mjs';
9
10
  import {
10
11
  pathExists, createLink, copyEntry, identicalEntries, emptyEntry, linkTarget, canonicalPath,
11
12
  profileDir, listProfiles,
@@ -93,7 +94,7 @@ export function syncShared(names = listProfiles(), options) {
93
94
 
94
95
  function validateEntry(entry) {
95
96
  if (!SHAREABLE_ENTRIES.includes(entry)) {
96
- throw new Error(`"${entry}" cannot be shared. Choose one of: ${SHAREABLE_ENTRIES.join(', ')}`);
97
+ throw new Error(t('share.cannot', { entry, list: SHAREABLE_ENTRIES.join(', ') }));
97
98
  }
98
99
  }
99
100
 
@@ -120,9 +121,9 @@ function seedShared(entry, from) {
120
121
  export function shareEntry(entry, from = getActiveProfile()) {
121
122
  validateEntry(entry);
122
123
  const meta = readMeta();
123
- if (!meta.shareSettings) throw new Error('Sharing needs shared settings. This setup keeps each profile separate.');
124
+ if (!meta.shareSettings) throw new Error(t('share.needsSettings'));
124
125
  const entries = meta.shared ?? [];
125
- if (entries.includes(entry)) throw new Error(`"${entry}" is already shared`);
126
+ if (entries.includes(entry)) throw new Error(t('share.already', { entry }));
126
127
  mkdirSync(SHARED_DIR, { recursive: true });
127
128
  if (!pathExists(join(SHARED_DIR, entry))) seedShared(entry, from);
128
129
  setSharedEntries([...entries, entry]);
@@ -133,7 +134,7 @@ export function shareEntry(entry, from = getActiveProfile()) {
133
134
  export function unshareEntry(entry) {
134
135
  validateEntry(entry);
135
136
  const entries = getSharedEntries();
136
- if (!entries.includes(entry)) throw new Error(`"${entry}" is not shared`);
137
+ if (!entries.includes(entry)) throw new Error(t('share.notShared', { entry }));
137
138
  const sharedPath = join(SHARED_DIR, entry);
138
139
  for (const name of listProfiles()) {
139
140
  if (entry === 'skills') {
package/src/lib/ui.mjs CHANGED
@@ -1,9 +1,13 @@
1
- // ANSI colors, terminal capability detection, and box-drawing helpers.
1
+ // ANSI colors, terminal capability detection, and the left-rail layout helpers.
2
2
  // Single source for any module that writes to stdout/stderr.
3
3
 
4
- export const hasColor = Boolean(process.stdout.isTTY || process.env.FORCE_COLOR)
5
- && !process.env.NO_COLOR
6
- && process.env.TERM !== 'dumb';
4
+ const colorAllowed = !process.env.NO_COLOR && process.env.TERM !== 'dumb';
5
+ export let hasColor = Boolean(process.stdout.isTTY || process.env.FORCE_COLOR) && colorAllowed;
6
+
7
+ // The claude picker draws on stderr while its stdout is captured by the shell.
8
+ export function colorFor(stream) {
9
+ hasColor = Boolean(stream.isTTY || process.env.FORCE_COLOR) && colorAllowed;
10
+ }
7
11
 
8
12
  // On legacy Windows consoles (cmd.exe without UTF-8 codepage), Unicode box
9
13
  // characters may not render. Detect via the WT_SESSION env var (set by
@@ -15,17 +19,16 @@ export const supportsUnicode = Boolean(
15
19
  );
16
20
 
17
21
  const ESC = '\x1b[';
18
- const RESET = hasColor ? `${ESC}0m` : '';
19
22
  const c = (code) => hasColor ? `${ESC}${code}m` : '';
20
23
 
21
24
  export const color = {
22
- bold: (s) => `${c('1')}${s}${RESET}`,
23
- dim: (s) => `${c('2')}${s}${RESET}`,
24
- green: (s) => `${c('32')}${s}${RESET}`,
25
- yellow: (s) => `${c('33')}${s}${RESET}`,
26
- blue: (s) => `${c('34')}${s}${RESET}`,
27
- cyan: (s) => `${c('36')}${s}${RESET}`,
28
- red: (s) => `${c('31')}${s}${RESET}`,
25
+ bold: (s) => `${c('1')}${s}${c('0')}`,
26
+ dim: (s) => `${c('2')}${s}${c('0')}`,
27
+ green: (s) => `${c('32')}${s}${c('0')}`,
28
+ yellow: (s) => `${c('33')}${s}${c('0')}`,
29
+ blue: (s) => `${c('34')}${s}${c('0')}`,
30
+ cyan: (s) => `${c('36')}${s}${c('0')}`,
31
+ red: (s) => `${c('31')}${s}${c('0')}`,
29
32
  };
30
33
 
31
34
  export function cursorControl(output = process.stdout) {
@@ -50,24 +53,17 @@ export const sym = {
50
53
  bulletOn: supportsUnicode ? '●' : '*',
51
54
  bulletOff: supportsUnicode ? '○' : 'o',
52
55
  dash: supportsUnicode ? '──' : '--',
56
+ arrows: supportsUnicode ? '↑↓' : 'Up/Down',
57
+ // Left rail that groups one flow: ┌ start, │ body, ◆ question, ◇ answered, └ end.
58
+ railTop: supportsUnicode ? '┌' : '+',
59
+ rail: supportsUnicode ? '│' : '|',
60
+ railEnd: supportsUnicode ? '└' : '`',
61
+ step: supportsUnicode ? '◆' : '*',
62
+ stepDone: supportsUnicode ? '◇' : 'o',
63
+ barOn: supportsUnicode ? '▰' : '#',
64
+ barOff: supportsUnicode ? '▱' : '-',
53
65
  };
54
66
 
55
- const BOX_TL = supportsUnicode ? '╭' : '+';
56
- const BOX_TR = supportsUnicode ? '╮' : '+';
57
- const BOX_BL = supportsUnicode ? '╰' : '+';
58
- const BOX_BR = supportsUnicode ? '╯' : '+';
59
- const BOX_H = supportsUnicode ? '─' : '-';
60
- const BOX_V = supportsUnicode ? '│' : '|';
61
-
62
- export function box(lines) {
63
- const maxLen = Math.max(...lines.map(stripAnsi).map(l => l.length));
64
- const pad = (s) => s + ' '.repeat(maxLen - stripAnsi(s).length);
65
- const top = ` ${BOX_TL}${BOX_H.repeat(maxLen + 2)}${BOX_TR}`;
66
- const bottom = ` ${BOX_BL}${BOX_H.repeat(maxLen + 2)}${BOX_BR}`;
67
- const body = lines.map(l => ` ${BOX_V} ${pad(l)} ${BOX_V}`).join('\n');
68
- return `${top}\n${body}\n${bottom}`;
69
- }
70
-
71
67
  export function success(msg) {
72
68
  process.stdout.write(` ${color.green(sym.check)} ${msg}\n`);
73
69
  }
@@ -76,8 +72,31 @@ export function warn(msg) {
76
72
  process.stdout.write(` ${color.yellow(sym.warn)} ${msg}\n`);
77
73
  }
78
74
 
79
- export function error(msg) {
75
+ // Hints are { label, text } or { label, command } lines telling what to do next.
76
+ export function error(msg, hints = []) {
80
77
  process.stderr.write(` ${color.red(sym.cross)} ${msg}\n`);
78
+ for (const hint of hints) {
79
+ process.stderr.write(` ${color.dim(hint.label)} ${hint.command ? color.cyan(hint.command) : hint.text}\n`);
80
+ }
81
+ }
82
+
83
+ export function userError(message, hints = []) {
84
+ return Object.assign(new Error(message), { hints });
85
+ }
86
+
87
+ export function intro(title, note = '', output = process.stdout) {
88
+ output.write(`${color.dim(sym.railTop)} ${color.bold(title)}${note ? ` ${color.dim(note)}` : ''}\n${color.dim(sym.rail)}\n`);
89
+ }
90
+
91
+ export function outro(message, output = process.stdout) {
92
+ output.write(`${color.dim(sym.railEnd)} ${message}\n`);
93
+ }
94
+
95
+ // The number is always printed so the bar never relies on color alone.
96
+ export function meter(percent, width = 10) {
97
+ const filled = Math.round(percent / 100 * width);
98
+ const paint = percent >= 80 ? color.red : percent >= 50 ? color.yellow : color.green;
99
+ return `${paint(sym.barOn.repeat(filled))}${color.dim(sym.barOff.repeat(width - filled))} ${String(percent).padStart(3)}%`;
81
100
  }
82
101
 
83
102
  export function info(msg) {
@@ -87,3 +106,9 @@ export function info(msg) {
87
106
  export function stripAnsi(s) {
88
107
  return s.replace(/\x1b\[[0-9;]*m/g, '');
89
108
  }
109
+
110
+ // Hangul and other wide characters take two terminal columns.
111
+ export function displayWidth(text) {
112
+ return [...stripAnsi(text)].reduce((width, char) => width
113
+ + (/[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\uff00-\uff60]/.test(char) ? 2 : 1), 0);
114
+ }