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/lib/i18n.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Chooses the screen language and looks up messages. English fills any gap.
|
|
2
|
+
import { LANGUAGES, LANGUAGE_ENV, META_FILE } from './constants.mjs';
|
|
3
|
+
import { readJsonFile } from './json-fs.mjs';
|
|
4
|
+
import en from '../locales/en.mjs';
|
|
5
|
+
import ko from '../locales/ko.mjs';
|
|
6
|
+
|
|
7
|
+
const MESSAGES = { en, ko };
|
|
8
|
+
export const LANGUAGE_NAMES = { en: 'English', ko: '한국어' };
|
|
9
|
+
|
|
10
|
+
function languageCode(value) {
|
|
11
|
+
const code = String(value ?? '').trim().split(/[-_.@]/)[0].toLowerCase();
|
|
12
|
+
return code || null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Terminal locale first (LC_ALL → LC_MESSAGES → LANG), then the OS locale.
|
|
16
|
+
// A set but unknown locale such as C means English rather than a fallthrough.
|
|
17
|
+
export function systemLanguage(env = process.env, intlLocale) {
|
|
18
|
+
const code = languageCode(env.LC_ALL || env.LC_MESSAGES || env.LANG)
|
|
19
|
+
?? languageCode(intlLocale ?? Intl.DateTimeFormat().resolvedOptions().locale);
|
|
20
|
+
return LANGUAGES.includes(code) ? code : 'en';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function savedLanguage() {
|
|
24
|
+
try {
|
|
25
|
+
const { language } = readJsonFile(META_FILE, {}) ?? {};
|
|
26
|
+
return LANGUAGES.includes(language) ? language : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function detectLanguage({ saved = savedLanguage(), env = process.env, intlLocale } = {}) {
|
|
33
|
+
if (saved) return saved;
|
|
34
|
+
const forced = languageCode(env[LANGUAGE_ENV]);
|
|
35
|
+
if (LANGUAGES.includes(forced)) return forced;
|
|
36
|
+
return systemLanguage(env, intlLocale);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let current;
|
|
40
|
+
|
|
41
|
+
export function language() {
|
|
42
|
+
current ??= detectLanguage();
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function setLanguage(code) {
|
|
47
|
+
current = code;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function t(key, vars = {}) {
|
|
51
|
+
const message = MESSAGES[language()][key] ?? en[key];
|
|
52
|
+
if (message === undefined) throw new Error(`Missing message: ${key}`);
|
|
53
|
+
return message.replace(/\{(\w+)\}/g, (match, name) => (Object.hasOwn(vars, name) ? String(vars[name]) : match));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TIME_UNITS = [['year', 31536000], ['month', 2592000], ['week', 604800], ['day', 86400], ['hour', 3600], ['minute', 60]];
|
|
57
|
+
|
|
58
|
+
export function relativeTime(ms, now = Date.now()) {
|
|
59
|
+
const seconds = Math.round((ms - now) / 1000);
|
|
60
|
+
const format = new Intl.RelativeTimeFormat(language(), { numeric: 'auto' });
|
|
61
|
+
for (const [unit, size] of TIME_UNITS) {
|
|
62
|
+
if (Math.abs(seconds) >= size) return format.format(Math.round(seconds / size), unit);
|
|
63
|
+
}
|
|
64
|
+
return t('time.justNow');
|
|
65
|
+
}
|
package/src/lib/profile.mjs
CHANGED
|
@@ -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(
|
|
35
|
-
warn('
|
|
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 '
|
|
41
|
+
if (!name) return t('profile.empty');
|
|
41
42
|
if (name.length > PROFILE_NAME_MAX_LENGTH) {
|
|
42
|
-
return
|
|
43
|
+
return t('profile.tooLong', { max: PROFILE_NAME_MAX_LENGTH });
|
|
43
44
|
}
|
|
44
|
-
if (RESERVED_NAMES.includes(name)) return
|
|
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
|
|
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))
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
@@ -220,6 +231,37 @@ function validateJson(source) {
|
|
|
220
231
|
}
|
|
221
232
|
}
|
|
222
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
|
+
|
|
223
265
|
function stageEntry(source, destination) {
|
|
224
266
|
if (!pathExists(source)) return;
|
|
225
267
|
if (pathExists(destination)) {
|
|
@@ -282,6 +324,7 @@ function prepareMigration(source, profileName, shareSettings, stage) {
|
|
|
282
324
|
stageEntry(join(source.directory, entry), join(stagedProfile, entry));
|
|
283
325
|
if (!pathExists(join(stagedProfile, entry))) mkdirSync(join(stagedProfile, entry));
|
|
284
326
|
}
|
|
327
|
+
repointPlugins(join(stagedProfile, 'plugins'), source.directory, target);
|
|
285
328
|
for (const entry of [...SHARED_FILES, ...SHARED_DIRS]) {
|
|
286
329
|
const shared = shareSettings ? stagedShared : stagedProfile;
|
|
287
330
|
const from = join(source.directory, entry);
|
package/src/lib/prompt.mjs
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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.
|
|
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('
|
|
69
|
-
const yes = selected ? color.cyan('
|
|
70
|
-
|
|
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,115 @@ export async function confirm(message, defaultVal = true) {
|
|
|
96
100
|
});
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
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,
|
|
102
|
-
let
|
|
111
|
+
let selected = Math.max(0, Math.min(initial, items.length - 1));
|
|
112
|
+
let height = 0;
|
|
113
|
+
let top = 0;
|
|
103
114
|
const cursor = cursorControl(output);
|
|
115
|
+
const rail = color.dim(sym.rail);
|
|
116
|
+
const indent = ' '.repeat(numbered ? 5 : 2);
|
|
117
|
+
const keys = numbered && items.length > 1 && items.length <= 9
|
|
118
|
+
? t(back ? 'prompt.keysNumberedBack' : 'prompt.keysNumbered', { arrows: sym.arrows, max: items.length })
|
|
119
|
+
: t(back ? 'prompt.keysBack' : 'prompt.keys', { arrows: sym.arrows });
|
|
120
|
+
|
|
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) => {
|
|
123
|
+
const lines = [];
|
|
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;
|
|
127
|
+
const number = numbered ? `${i + 1} ` : '';
|
|
128
|
+
const head = i === selected
|
|
129
|
+
? color.cyan(`${sym.cursor} ${number}${item.label}`)
|
|
130
|
+
: ` ${numbered ? `${color.dim(String(i + 1))} ` : ''}${item.label}`;
|
|
131
|
+
lines.push(`${rail} ${head}${item.hint ? ` ${item.hint}` : ''}`);
|
|
132
|
+
if (!detailed(i) || !item.details?.length) return;
|
|
133
|
+
for (const detail of item.details) lines.push(`${rail} ${indent}${detail}`);
|
|
134
|
+
lines.push(rail);
|
|
135
|
+
});
|
|
136
|
+
if (to < items.length) lines.push(`${rail} ${color.dim(t('prompt.moreBelow', { count: items.length - to }))}`);
|
|
137
|
+
lines.push(`${rail} ${color.dim(keys)}`, color.dim(sym.railEnd));
|
|
138
|
+
return lines;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// A line wider than the window wraps, so count the screen rows it really takes.
|
|
142
|
+
const screenRows = lines => lines.reduce((sum, line) => sum
|
|
143
|
+
+ Math.max(1, Math.ceil(displayWidth(line) / (output.columns || Infinity))), 0);
|
|
144
|
+
const header = [`${color.cyan(sym.step)} ${message}`, ...body.map(line => `${rail} ${line}`)];
|
|
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
|
+
};
|
|
104
165
|
|
|
105
166
|
const render = () => {
|
|
106
|
-
if (
|
|
107
|
-
|
|
167
|
+
if (height) output.write(cursor.up(height));
|
|
168
|
+
const lines = layout();
|
|
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);
|
|
172
|
+
height = screenRows(lines);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const finish = (value, summary) => {
|
|
176
|
+
stop();
|
|
177
|
+
if (cursor.enabled) {
|
|
178
|
+
output.write(`${cursor.up(height + screenRows(header))}\r${cursor.clearLine}${summary}\n${cursor.clearDown}`);
|
|
108
179
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
180
|
+
resolve(value);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const pick = () => {
|
|
184
|
+
const item = items[selected];
|
|
185
|
+
const summary = done ? done(item.value) : `${message} ${color.dim(sym.dots)} ${color.cyan(item.label.trim())}`;
|
|
186
|
+
finish(item.value, `${color.green(sym.stepDone)} ${summary}`);
|
|
115
187
|
};
|
|
116
188
|
|
|
117
189
|
if (!process.stdin.isTTY) {
|
|
118
|
-
// Non-interactive: pick
|
|
119
|
-
resolve(
|
|
190
|
+
// Non-interactive: pick the initial choice without rendering the menu
|
|
191
|
+
resolve(items[selected].value);
|
|
120
192
|
return;
|
|
121
193
|
}
|
|
122
194
|
|
|
123
|
-
output.write(`${
|
|
195
|
+
for (const line of header) output.write(`${line}\n`);
|
|
124
196
|
render();
|
|
125
197
|
|
|
126
198
|
const stop = readKeys((character, key) => {
|
|
127
199
|
if (key.name === 'up') {
|
|
128
|
-
selected = (selected - 1 +
|
|
200
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
129
201
|
render();
|
|
130
202
|
} else if (key.name === 'down') {
|
|
131
|
-
selected = (selected + 1) %
|
|
203
|
+
selected = (selected + 1) % items.length;
|
|
132
204
|
render();
|
|
205
|
+
} else if (numbered && /^[1-9]$/.test(character ?? '') && Number(character) <= items.length) {
|
|
206
|
+
selected = Number(character) - 1;
|
|
207
|
+
pick();
|
|
133
208
|
} else if (key.name === 'return' || key.name === 'enter') {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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);
|
|
209
|
+
pick();
|
|
210
|
+
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
211
|
+
finish(null, `${color.dim(sym.stepDone)} ${message} ${color.dim(t('prompt.cancelled'))}`);
|
|
145
212
|
}
|
|
146
213
|
});
|
|
147
214
|
});
|
package/src/lib/shared.mjs
CHANGED
|
@@ -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(
|
|
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('
|
|
124
|
+
if (!meta.shareSettings) throw new Error(t('share.needsSettings'));
|
|
124
125
|
const entries = meta.shared ?? [];
|
|
125
|
-
if (entries.includes(entry)) throw new Error(
|
|
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(
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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}${
|
|
23
|
-
dim: (s) => `${c('2')}${s}${
|
|
24
|
-
green: (s) => `${c('32')}${s}${
|
|
25
|
-
yellow: (s) => `${c('33')}${s}${
|
|
26
|
-
blue: (s) => `${c('34')}${s}${
|
|
27
|
-
cyan: (s) => `${c('36')}${s}${
|
|
28
|
-
red: (s) => `${c('31')}${s}${
|
|
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
|
-
|
|
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
|
+
}
|