@daylenjeez/ccm-switch 1.2.0

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/src/index.ts ADDED
@@ -0,0 +1,949 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from "commander";
4
+ import chalk from "chalk";
5
+ import { readRc, writeRc, getStore } from "./utils.js";
6
+ import { ccSwitchExists } from "./store/cc-switch.js";
7
+ import { readClaudeSettings, applyProfile, getSettingsPath } from "./claude.js";
8
+ import { createInterface } from "readline";
9
+ import { spawnSync } from "child_process";
10
+ import { writeFileSync, readFileSync, unlinkSync } from "fs";
11
+ import { tmpdir } from "os";
12
+ import { join } from "path";
13
+ import { t, setLocale } from "./i18n/index.js";
14
+ import * as clack from "@clack/prompts";
15
+
16
+ const program = new Command();
17
+
18
+ program
19
+ .name("ccm")
20
+ .description(t("program.description"))
21
+ .version("1.0.0");
22
+
23
+ // Helper: prompt user for input
24
+ function ask(question: string): Promise<string> {
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ return new Promise((resolve) => {
27
+ rl.question(question, (answer) => {
28
+ rl.close();
29
+ resolve(answer.trim());
30
+ });
31
+ });
32
+ }
33
+
34
+ // Helper: ensure initialized
35
+ function ensureStore() {
36
+ const store = getStore();
37
+ if (!store) {
38
+ console.log(chalk.yellow(t("common.not_init")));
39
+ process.exit(1);
40
+ }
41
+ return store;
42
+ }
43
+
44
+ // Helper: format env for display
45
+ function formatEnv(env: Record<string, string>): string {
46
+ const lines: string[] = [];
47
+ const order = [
48
+ "ANTHROPIC_BASE_URL",
49
+ "ANTHROPIC_MODEL",
50
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
51
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
52
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
53
+ ];
54
+ for (const key of order) {
55
+ if (key in env) {
56
+ lines.push(` ${chalk.gray(key)}: ${env[key]}`);
57
+ }
58
+ }
59
+ // Show remaining keys (skip token for security)
60
+ for (const [key, val] of Object.entries(env)) {
61
+ if (!order.includes(key) && key !== "ANTHROPIC_AUTH_TOKEN") {
62
+ lines.push(` ${chalk.gray(key)}: ${val}`);
63
+ }
64
+ }
65
+ if ("ANTHROPIC_AUTH_TOKEN" in env) {
66
+ const token = env["ANTHROPIC_AUTH_TOKEN"];
67
+ const masked = token.slice(0, 8) + "..." + token.slice(-4);
68
+ lines.push(` ${chalk.gray("ANTHROPIC_AUTH_TOKEN")}: ${masked}`);
69
+ }
70
+ return lines.join("\n");
71
+ }
72
+
73
+ // Helper: Levenshtein distance
74
+ function levenshtein(a: string, b: string): number {
75
+ const la = a.length, lb = b.length;
76
+ const dp: number[][] = Array.from({ length: la + 1 }, (_, i) =>
77
+ Array.from({ length: lb + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
78
+ );
79
+ for (let i = 1; i <= la; i++) {
80
+ for (let j = 1; j <= lb; j++) {
81
+ dp[i][j] = a[i - 1] === b[j - 1]
82
+ ? dp[i - 1][j - 1]
83
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
84
+ }
85
+ }
86
+ return dp[la][lb];
87
+ }
88
+
89
+ // Helper: find suggestions for a mistyped name
90
+ function findSuggestions(input: string, names: string[]): string[] {
91
+ const lower = input.toLowerCase();
92
+
93
+ // 1. exact case-insensitive match
94
+ const exact = names.find((n) => n.toLowerCase() === lower);
95
+ if (exact) return [exact];
96
+
97
+ // 2. substring match (input is part of name, or name is part of input)
98
+ const substring = names.filter(
99
+ (n) => n.toLowerCase().includes(lower) || lower.includes(n.toLowerCase())
100
+ );
101
+ if (substring.length > 0) return substring;
102
+
103
+ // 3. Levenshtein distance <= 3
104
+ const fuzzy = names
105
+ .map((n) => ({ name: n, dist: levenshtein(lower, n.toLowerCase()) }))
106
+ .filter((x) => x.dist <= 3)
107
+ .sort((a, b) => a.dist - b.dist)
108
+ .map((x) => x.name);
109
+
110
+ return fuzzy;
111
+ }
112
+
113
+ // Helper: get alias target if exists
114
+ function getAliasTarget(input: string): string | undefined {
115
+ const rc = readRc();
116
+ return rc?.aliases?.[input];
117
+ }
118
+
119
+ // Helper: resolve name with alias conflict handling, returns profile or null
120
+ async function resolveProfile(store: ReturnType<typeof ensureStore>, input: string) {
121
+ const aliasTarget = getAliasTarget(input);
122
+ const directProfile = store.get(input);
123
+
124
+ // Both alias and config name exist → ask
125
+ if (aliasTarget && directProfile && aliasTarget !== input) {
126
+ console.log(chalk.yellow(t("alias.conflict", { name: input, target: aliasTarget })));
127
+ console.log(` ${chalk.cyan("1)")} ${t("alias.conflict_alias", { target: aliasTarget })}`);
128
+ console.log(` ${chalk.cyan("2)")} ${t("alias.conflict_config", { name: input })}`);
129
+ const choice = await ask(t("alias.choose_conflict"));
130
+ if (choice === "1") {
131
+ const profile = store.get(aliasTarget);
132
+ if (!profile) {
133
+ console.log(chalk.red(t("error.alias_target_missing", { alias: input, target: aliasTarget })));
134
+ return null;
135
+ }
136
+ return profile;
137
+ }
138
+ return directProfile;
139
+ }
140
+
141
+ // Alias exists → resolve
142
+ if (aliasTarget) {
143
+ const profile = store.get(aliasTarget);
144
+ if (profile) return profile;
145
+ console.log(chalk.red(t("error.alias_target_missing", { alias: input, target: aliasTarget })));
146
+ return null;
147
+ }
148
+
149
+ // Direct match
150
+ if (directProfile) return directProfile;
151
+
152
+ // Fuzzy matching
153
+ const allNames = store.list().map((p) => p.name);
154
+ const suggestions = findSuggestions(input, allNames);
155
+
156
+ console.log(chalk.red(t("error.not_found", { name: input })));
157
+ if (suggestions.length === 1) {
158
+ console.log(chalk.yellow(t("suggest.did_you_mean", { name: chalk.bold(suggestions[0]) })));
159
+ } else if (suggestions.length > 1) {
160
+ console.log(chalk.yellow(t("suggest.did_you_mean_header")));
161
+ for (const s of suggestions) {
162
+ console.log(` - ${chalk.bold(s)}`);
163
+ }
164
+ } else {
165
+ console.log(chalk.gray(t("suggest.use_list")));
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // ccm init
171
+ program
172
+ .command("init")
173
+ .description(t("init.description"))
174
+ .action(async () => {
175
+ if (ccSwitchExists()) {
176
+ const use = await ask(t("init.cc_switch_found"));
177
+ if (use.toLowerCase() !== "n") {
178
+ writeRc({ mode: "cc-switch" });
179
+ const { CcSwitchStore } = await import("./store/cc-switch.js");
180
+ const store = new CcSwitchStore();
181
+ const profiles = store.list();
182
+ const current = store.getCurrent();
183
+ console.log(chalk.green(t("init.done_cc_switch")));
184
+ console.log(chalk.green(t("init.imported", { count: String(profiles.length) })));
185
+ if (current) {
186
+ console.log(chalk.gray(t("init.current", { name: current })));
187
+ } else {
188
+ console.log(chalk.gray(t("init.no_current")));
189
+ }
190
+ return;
191
+ }
192
+ }
193
+
194
+ writeRc({ mode: "standalone" });
195
+ console.log(chalk.green(t("init.done_standalone")));
196
+ });
197
+
198
+ // ccm config
199
+ program
200
+ .command("config")
201
+ .description(t("config.description"))
202
+ .action(async () => {
203
+ const rc = readRc();
204
+ if (!rc) {
205
+ console.log(chalk.yellow(t("common.not_init")));
206
+ return;
207
+ }
208
+ console.log(t("config.current_mode", { mode: chalk.cyan(rc.mode) }));
209
+ const confirm = await ask(t("config.switch_confirm"));
210
+ if (confirm.toLowerCase() === "y") {
211
+ const newMode = rc.mode === "cc-switch" ? "standalone" : "cc-switch";
212
+ if (newMode === "cc-switch" && !ccSwitchExists()) {
213
+ console.log(chalk.red(t("config.cc_switch_not_installed")));
214
+ return;
215
+ }
216
+ writeRc({ mode: newMode });
217
+ console.log(chalk.green(t("config.switched", { mode: newMode })));
218
+ }
219
+ });
220
+
221
+ // ccm list
222
+ program
223
+ .command("list")
224
+ .alias("ls")
225
+ .description(t("list.description"))
226
+ .action(async () => {
227
+ const store = ensureStore();
228
+ const profiles = store.list();
229
+ const current = store.getCurrent();
230
+
231
+ if (profiles.length === 0) {
232
+ console.log(chalk.yellow(t("list.empty")));
233
+ return;
234
+ }
235
+
236
+ // Helper: apply selected profile
237
+ const switchTo = (name: string) => {
238
+ if (name === current) return;
239
+ const profile = store.get(name)!;
240
+ applyProfile(profile.settingsConfig);
241
+ store.setCurrent(profile.name);
242
+ const env = (profile.settingsConfig.env || {}) as Record<string, string>;
243
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
244
+ console.log(chalk.green(t("use.done", { name: chalk.bold(profile.name) })));
245
+ console.log(` ${t("common.model")}: ${chalk.cyan(model)}`);
246
+ console.log(chalk.gray(` ${t("use.restart")}`));
247
+ };
248
+
249
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
250
+
251
+ if (isInteractive) {
252
+ const options = profiles.map((p) => {
253
+ const isCurrent = p.name === current;
254
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
255
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
256
+ const baseUrl = env["ANTHROPIC_BASE_URL"] || "default";
257
+ const tag = isCurrent ? ` ${t("list.current_marker")}` : "";
258
+ return {
259
+ label: `${p.name}${tag}`,
260
+ hint: `${t("common.model")}: ${model} ${t("common.source")}: ${baseUrl}`,
261
+ value: p.name,
262
+ };
263
+ });
264
+
265
+ const initial = profiles.findIndex((p) => p.name === current);
266
+ const selected = await clack.select({
267
+ message: "",
268
+ options,
269
+ initialValue: initial >= 0 ? profiles[initial].name : profiles[0].name,
270
+ });
271
+
272
+ if (clack.isCancel(selected)) {
273
+ clack.cancel(t("list.cancelled"));
274
+ return;
275
+ }
276
+ switchTo(selected as string);
277
+ } else {
278
+ // Fallback: numbered list + type to select
279
+ console.log(chalk.bold(`\n${t("list.header")}\n`));
280
+ profiles.forEach((p, i) => {
281
+ const isCurrent = p.name === current;
282
+ const marker = isCurrent ? chalk.green("● ") : " ";
283
+ const name = isCurrent ? chalk.green.bold(p.name) : p.name;
284
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
285
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
286
+ const baseUrl = env["ANTHROPIC_BASE_URL"] || "default";
287
+ console.log(`${marker}${chalk.gray(`${i + 1}.`)} ${name}`);
288
+ console.log(` ${t("common.model")}: ${chalk.cyan(model)} ${t("common.source")}: ${chalk.gray(baseUrl)}`);
289
+ });
290
+ console.log();
291
+ const input = await ask(t("list.choose_number"));
292
+ if (!input) return;
293
+ const idx = parseInt(input, 10) - 1;
294
+ if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
295
+ console.log(chalk.red(t("error.invalid_choice")));
296
+ return;
297
+ }
298
+ switchTo(profiles[idx].name);
299
+ }
300
+ });
301
+
302
+ // ccm current
303
+ program
304
+ .command("current")
305
+ .description(t("current.description"))
306
+ .action(() => {
307
+ const store = ensureStore();
308
+ const currentName = store.getCurrent();
309
+
310
+ if (!currentName) {
311
+ console.log(chalk.yellow(t("current.none")));
312
+ console.log(chalk.gray(`\n${t("current.settings_header")}`));
313
+ const settings = readClaudeSettings();
314
+ const env = (settings.env || {}) as Record<string, string>;
315
+ console.log(formatEnv(env));
316
+ return;
317
+ }
318
+
319
+ const profile = store.get(currentName);
320
+ if (!profile) {
321
+ console.log(chalk.yellow(t("current.not_exist", { name: currentName })));
322
+ return;
323
+ }
324
+
325
+ console.log(`\n${t("current.header", { name: chalk.green.bold(profile.name) })}\n`);
326
+ const env = (profile.settingsConfig.env || {}) as Record<string, string>;
327
+ console.log(formatEnv(env));
328
+ if (profile.settingsConfig.model) {
329
+ console.log(` ${chalk.gray("model")}: ${profile.settingsConfig.model}`);
330
+ }
331
+ console.log();
332
+ });
333
+
334
+ // ccm use <name>
335
+ program
336
+ .command("use <name>")
337
+ .description(t("use.description"))
338
+ .action(async (name: string) => {
339
+ const store = ensureStore();
340
+ const profile = await resolveProfile(store, name);
341
+ if (!profile) return;
342
+
343
+ applyProfile(profile.settingsConfig);
344
+ store.setCurrent(profile.name);
345
+
346
+ const env = (profile.settingsConfig.env || {}) as Record<string, string>;
347
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
348
+ console.log(chalk.green(t("use.done", { name: chalk.bold(profile.name) })));
349
+ console.log(` ${t("common.model")}: ${chalk.cyan(model)}`);
350
+ console.log(chalk.gray(` ${t("use.restart")}`));
351
+ });
352
+
353
+ // ccm save <name>
354
+ program
355
+ .command("save <name>")
356
+ .description(t("save.description"))
357
+ .action((name: string) => {
358
+ const store = ensureStore();
359
+ const existing = store.get(name);
360
+
361
+ if (existing) {
362
+ console.log(chalk.yellow(t("save.overwrite", { name })));
363
+ }
364
+
365
+ const settings = readClaudeSettings();
366
+ const settingsConfig: Record<string, unknown> = {};
367
+ if (settings.env) settingsConfig.env = settings.env;
368
+ if (settings.model) settingsConfig.model = settings.model;
369
+ if (settings.hooks) settingsConfig.hooks = settings.hooks;
370
+ if (settings.statusLine) settingsConfig.statusLine = settings.statusLine;
371
+
372
+ store.save(name, settingsConfig);
373
+ store.setCurrent(name);
374
+
375
+ console.log(chalk.green(t("save.done", { name })));
376
+ });
377
+
378
+ // Helper: open editor with content, return parsed JSON or null
379
+ function openEditor(name: string, content: Record<string, unknown>): Record<string, unknown> | null {
380
+ const tmpFile = join(tmpdir(), `ccm-${name}-${Date.now()}.json`);
381
+ writeFileSync(tmpFile, JSON.stringify(content, null, 2));
382
+
383
+ const editor = process.env.EDITOR || "vi";
384
+ const result = spawnSync(editor, [tmpFile], { stdio: "inherit" });
385
+
386
+ let parsed: Record<string, unknown> | null = null;
387
+ if (result.status === 0) {
388
+ try {
389
+ parsed = JSON.parse(readFileSync(tmpFile, "utf-8"));
390
+ } catch {
391
+ console.log(chalk.red(t("add.json_parse_error")));
392
+ }
393
+ }
394
+
395
+ try { unlinkSync(tmpFile); } catch { /* ignore */ }
396
+ return parsed;
397
+ }
398
+
399
+ // Helper: save and optionally switch after add
400
+ async function saveAndSwitch(store: ReturnType<typeof ensureStore>, name: string, settingsConfig: Record<string, unknown>) {
401
+ store.save(name, settingsConfig);
402
+ console.log(chalk.green(t("add.done", { name })));
403
+
404
+ const switchChoice = await ask(t("add.switch_confirm"));
405
+ if (switchChoice.toLowerCase() !== "n") {
406
+ applyProfile(settingsConfig);
407
+ store.setCurrent(name);
408
+ console.log(chalk.green(t("use.done", { name: chalk.bold(name) })));
409
+ console.log(chalk.gray(` ${t("use.restart")}`));
410
+ }
411
+ }
412
+
413
+ // ccm add
414
+ program
415
+ .command("add")
416
+ .alias("new")
417
+ .description(t("add.description"))
418
+ .action(async () => {
419
+ const store = ensureStore();
420
+
421
+ // 1. Ask name first
422
+ const name = await ask(t("add.prompt_name"));
423
+ if (!name) {
424
+ console.log(chalk.red(t("add.name_required")));
425
+ return;
426
+ }
427
+
428
+ // Check if exists
429
+ const existing = store.get(name);
430
+ if (existing) {
431
+ const overwrite = await ask(t("add.already_exists", { name }));
432
+ if (overwrite.toLowerCase() !== "y") {
433
+ console.log(chalk.gray(t("add.cancelled")));
434
+ return;
435
+ }
436
+ }
437
+
438
+ // 2. Choose mode
439
+ console.log(`\n${chalk.bold(t("add.mode_select"))}\n`);
440
+ console.log(` ${chalk.cyan("1)")} ${t("add.mode_interactive")}`);
441
+ console.log(` ${chalk.cyan("2)")} ${t("add.mode_json")}\n`);
442
+ const mode = await ask(t("add.mode_choose"));
443
+
444
+ if (mode === "2") {
445
+ // JSON mode: open editor with template
446
+ const template: Record<string, unknown> = {
447
+ env: {
448
+ ANTHROPIC_BASE_URL: "",
449
+ ANTHROPIC_AUTH_TOKEN: "",
450
+ ANTHROPIC_MODEL: "",
451
+ ANTHROPIC_DEFAULT_OPUS_MODEL: "",
452
+ ANTHROPIC_DEFAULT_SONNET_MODEL: "",
453
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: "",
454
+ },
455
+ };
456
+
457
+ console.log(chalk.gray(t("add.json_template_hint")));
458
+ const edited = openEditor(name, template);
459
+ if (!edited) return;
460
+
461
+ await saveAndSwitch(store, name, edited);
462
+ return;
463
+ }
464
+
465
+ // Interactive mode with step-based back support
466
+ interface Step { key: string; prompt: string; required: boolean; }
467
+ const steps: Step[] = [
468
+ { key: "ANTHROPIC_BASE_URL", prompt: t("add.prompt_base_url"), required: true },
469
+ { key: "ANTHROPIC_AUTH_TOKEN", prompt: t("add.prompt_auth_token"), required: true },
470
+ { key: "ANTHROPIC_MODEL", prompt: t("add.prompt_model"), required: true },
471
+ { key: "ANTHROPIC_DEFAULT_OPUS_MODEL", prompt: t("add.prompt_default_opus"), required: false },
472
+ { key: "ANTHROPIC_DEFAULT_SONNET_MODEL", prompt: t("add.prompt_default_sonnet"), required: false },
473
+ { key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", prompt: t("add.prompt_default_haiku"), required: false },
474
+ ];
475
+
476
+ console.log(chalk.gray(t("add.back_hint")));
477
+ const values: Record<string, string> = {};
478
+ let i = 0;
479
+ while (i < steps.length) {
480
+ const step = steps[i];
481
+ const input = await ask(step.prompt);
482
+
483
+ if (input === "<") {
484
+ if (i > 0) i--;
485
+ continue;
486
+ }
487
+
488
+ if (step.required && !input) {
489
+ console.log(chalk.red(t("add.field_required", { field: step.key })));
490
+ continue;
491
+ }
492
+
493
+ if (input) values[step.key] = input;
494
+ else delete values[step.key];
495
+ i++;
496
+ }
497
+
498
+ // Build config
499
+ const env: Record<string, string> = {};
500
+ for (const [k, v] of Object.entries(values)) {
501
+ env[k] = v;
502
+ }
503
+
504
+ let settingsConfig: Record<string, unknown> = { env };
505
+
506
+ // Preview + optional edit
507
+ console.log(`\n${chalk.bold(t("add.preview_header"))}\n`);
508
+ console.log(JSON.stringify(settingsConfig, null, 2));
509
+ console.log();
510
+
511
+ const editChoice = await ask(t("add.edit_confirm"));
512
+ if (editChoice.toLowerCase() === "y") {
513
+ const edited = openEditor(name, settingsConfig);
514
+ if (edited) settingsConfig = edited;
515
+ }
516
+
517
+ await saveAndSwitch(store, name, settingsConfig);
518
+ });
519
+
520
+ // ccm show <name>
521
+ program
522
+ .command("show [name]")
523
+ .description(t("show.description"))
524
+ .action(async (name?: string) => {
525
+ const store = ensureStore();
526
+
527
+ if (!name) {
528
+ const currentName = store.getCurrent();
529
+ if (!currentName) {
530
+ console.log(chalk.yellow(t("show.no_current")));
531
+ return;
532
+ }
533
+ name = currentName;
534
+ }
535
+
536
+ const profile = await resolveProfile(store, name);
537
+ if (!profile) return;
538
+
539
+ console.log(`\n${chalk.bold(profile.name)}\n`);
540
+ const env = (profile.settingsConfig.env || {}) as Record<string, string>;
541
+ console.log(formatEnv(env));
542
+ if (profile.settingsConfig.model) {
543
+ console.log(` ${chalk.gray("model")}: ${profile.settingsConfig.model}`);
544
+ }
545
+ console.log();
546
+ });
547
+
548
+ // ccm modify [name]
549
+ program
550
+ .command("modify [name]")
551
+ .alias("edit")
552
+ .description(t("modify.description"))
553
+ .action(async (name?: string) => {
554
+ const store = ensureStore();
555
+ const profiles = store.list();
556
+ const current = store.getCurrent();
557
+
558
+ // 1. Select profile
559
+ if (!name) {
560
+ if (profiles.length === 0) {
561
+ console.log(chalk.yellow(t("list.empty")));
562
+ return;
563
+ }
564
+
565
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
566
+
567
+ if (isInteractive) {
568
+ const options = profiles.map((p) => {
569
+ const isCurrent = p.name === current;
570
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
571
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
572
+ const tag = isCurrent ? ` ${t("list.current_marker")}` : "";
573
+ return {
574
+ label: `${p.name}${tag}`,
575
+ hint: `${t("common.model")}: ${model}`,
576
+ value: p.name,
577
+ };
578
+ });
579
+
580
+ const selected = await clack.select({
581
+ message: "",
582
+ options,
583
+ });
584
+
585
+ if (clack.isCancel(selected)) {
586
+ clack.cancel(t("list.cancelled"));
587
+ return;
588
+ }
589
+ name = selected as string;
590
+ } else {
591
+ console.log(chalk.bold(`\n${t("list.header")}\n`));
592
+ profiles.forEach((p, i) => {
593
+ const isCurrent = p.name === current;
594
+ const marker = isCurrent ? chalk.green("● ") : " ";
595
+ const label = isCurrent ? chalk.green.bold(p.name) : p.name;
596
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
597
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
598
+ console.log(`${marker}${chalk.gray(`${i + 1}.`)} ${label}`);
599
+ console.log(` ${t("common.model")}: ${chalk.cyan(model)}`);
600
+ });
601
+ console.log();
602
+ const input = await ask(t("list.choose_number"));
603
+ if (!input) return;
604
+ const idx = parseInt(input, 10) - 1;
605
+ if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
606
+ console.log(chalk.red(t("error.invalid_choice")));
607
+ return;
608
+ }
609
+ name = profiles[idx].name;
610
+ }
611
+ }
612
+
613
+ const profile = await resolveProfile(store, name);
614
+ if (!profile) return;
615
+
616
+ const currentEnv = (profile.settingsConfig.env || {}) as Record<string, string>;
617
+
618
+ // 2. Choose mode
619
+ console.log(`\n${chalk.bold(t("add.mode_select"))}\n`);
620
+ console.log(` ${chalk.cyan("1)")} ${t("add.mode_interactive")}`);
621
+ console.log(` ${chalk.cyan("2)")} ${t("add.mode_json")}\n`);
622
+ const mode = await ask(t("add.mode_choose"));
623
+
624
+ let settingsConfig: Record<string, unknown>;
625
+
626
+ if (mode === "2") {
627
+ // JSON mode
628
+ const edited = openEditor(profile.name, profile.settingsConfig);
629
+ if (!edited) return;
630
+ settingsConfig = edited;
631
+ } else {
632
+ // Step-by-step mode with current values as defaults
633
+ interface Step { key: string; prompt: string; required: boolean; }
634
+ const steps: Step[] = [
635
+ { key: "ANTHROPIC_BASE_URL", prompt: "ANTHROPIC_BASE_URL", required: true },
636
+ { key: "ANTHROPIC_AUTH_TOKEN", prompt: "ANTHROPIC_AUTH_TOKEN", required: true },
637
+ { key: "ANTHROPIC_MODEL", prompt: "ANTHROPIC_MODEL", required: true },
638
+ { key: "ANTHROPIC_DEFAULT_OPUS_MODEL", prompt: "ANTHROPIC_DEFAULT_OPUS_MODEL", required: false },
639
+ { key: "ANTHROPIC_DEFAULT_SONNET_MODEL", prompt: "ANTHROPIC_DEFAULT_SONNET_MODEL", required: false },
640
+ { key: "ANTHROPIC_DEFAULT_HAIKU_MODEL", prompt: "ANTHROPIC_DEFAULT_HAIKU_MODEL", required: false },
641
+ ];
642
+
643
+ console.log(chalk.gray(t("add.back_hint")));
644
+ const values: Record<string, string> = { ...currentEnv };
645
+ let i = 0;
646
+ while (i < steps.length) {
647
+ const step = steps[i];
648
+ const cur = currentEnv[step.key] || "";
649
+ const hint = cur ? chalk.gray(` [${cur}]`) : "";
650
+ const input = await ask(`${step.prompt}${hint}: `);
651
+
652
+ if (input === "<") {
653
+ if (i > 0) i--;
654
+ continue;
655
+ }
656
+
657
+ if (input) {
658
+ values[step.key] = input;
659
+ } else if (step.required && !cur) {
660
+ console.log(chalk.red(t("add.field_required", { field: step.key })));
661
+ continue;
662
+ }
663
+ // empty input + has current value → keep current (already in values)
664
+ i++;
665
+ }
666
+
667
+ const env: Record<string, string> = {};
668
+ for (const [k, v] of Object.entries(values)) {
669
+ if (v) env[k] = v;
670
+ }
671
+ settingsConfig = { ...profile.settingsConfig, env };
672
+ }
673
+
674
+ // 3. Preview
675
+ console.log(`\n${chalk.bold(t("add.preview_header"))}\n`);
676
+ console.log(JSON.stringify(settingsConfig, null, 2));
677
+ console.log();
678
+
679
+ // 4. Optional editor (only for step mode)
680
+ if (mode !== "2") {
681
+ const editChoice = await ask(t("add.edit_confirm"));
682
+ if (editChoice.toLowerCase() === "y") {
683
+ const edited = openEditor(profile.name, settingsConfig);
684
+ if (edited) settingsConfig = edited;
685
+ }
686
+ }
687
+
688
+ // 5. Save
689
+ store.save(profile.name, settingsConfig);
690
+ console.log(chalk.green(t("modify.done", { name: profile.name })));
691
+
692
+ // 6. Switch if not current
693
+ if (profile.name !== current) {
694
+ const switchChoice = await ask(t("add.switch_confirm"));
695
+ if (switchChoice.toLowerCase() !== "n") {
696
+ applyProfile(settingsConfig);
697
+ store.setCurrent(profile.name);
698
+ console.log(chalk.green(t("use.done", { name: chalk.bold(profile.name) })));
699
+ console.log(chalk.gray(` ${t("use.restart")}`));
700
+ }
701
+ } else {
702
+ applyProfile(settingsConfig);
703
+ console.log(chalk.gray(` ${t("use.restart")}`));
704
+ }
705
+ });
706
+
707
+ // ccm remove [name]
708
+ program
709
+ .command("remove [name]")
710
+ .alias("rm")
711
+ .description(t("remove.description"))
712
+ .action(async (name?: string) => {
713
+ const store = ensureStore();
714
+ const profiles = store.list();
715
+ const current = store.getCurrent();
716
+
717
+ if (!name) {
718
+ if (profiles.length === 0) {
719
+ console.log(chalk.yellow(t("list.empty")));
720
+ return;
721
+ }
722
+
723
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
724
+
725
+ if (isInteractive) {
726
+ const options = profiles.map((p) => {
727
+ const isCurrent = p.name === current;
728
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
729
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
730
+ const tag = isCurrent ? ` ${t("list.current_marker")}` : "";
731
+ return {
732
+ label: `${p.name}${tag}`,
733
+ hint: `${t("common.model")}: ${model}`,
734
+ value: p.name,
735
+ };
736
+ });
737
+
738
+ const selected = await clack.select({
739
+ message: "",
740
+ options,
741
+ });
742
+
743
+ if (clack.isCancel(selected)) {
744
+ clack.cancel(t("list.cancelled"));
745
+ return;
746
+ }
747
+ name = selected as string;
748
+ } else {
749
+ console.log(chalk.bold(`\n${t("list.header")}\n`));
750
+ profiles.forEach((p, i) => {
751
+ const isCurrent = p.name === current;
752
+ const marker = isCurrent ? chalk.green("● ") : " ";
753
+ const label = isCurrent ? chalk.green.bold(p.name) : p.name;
754
+ const env = (p.settingsConfig.env || {}) as Record<string, string>;
755
+ const model = env["ANTHROPIC_MODEL"] || t("common.model_default");
756
+ console.log(`${marker}${chalk.gray(`${i + 1}.`)} ${label}`);
757
+ console.log(` ${t("common.model")}: ${chalk.cyan(model)}`);
758
+ });
759
+ console.log();
760
+ const input = await ask(t("list.choose_number"));
761
+ if (!input) return;
762
+ const idx = parseInt(input, 10) - 1;
763
+ if (isNaN(idx) || idx < 0 || idx >= profiles.length) {
764
+ console.log(chalk.red(t("error.invalid_choice")));
765
+ return;
766
+ }
767
+ name = profiles[idx].name;
768
+ }
769
+ }
770
+
771
+ // Check if name is an alias
772
+ const aliasTarget = getAliasTarget(name);
773
+ if (aliasTarget) {
774
+ console.log(chalk.yellow(t("alias.is_alias", { name, target: aliasTarget })));
775
+ console.log(`\n${t("alias.rm_which")}\n`);
776
+ console.log(` ${chalk.cyan("1)")} ${t("alias.rm_alias", { name })}`);
777
+ console.log(` ${chalk.cyan("2)")} ${t("alias.rm_config", { target: aliasTarget })}`);
778
+ const choice = await ask(t("alias.rm_choose"));
779
+ if (choice === "1") {
780
+ const rc = readRc()!;
781
+ delete rc.aliases![name];
782
+ writeRc(rc);
783
+ console.log(chalk.green(t("alias.rm_done", { short: name })));
784
+ return;
785
+ }
786
+ // choice === "2" → delete the config
787
+ name = aliasTarget;
788
+ }
789
+
790
+ const profile = await resolveProfile(store, name);
791
+ if (!profile) return;
792
+
793
+ const confirm = await ask(t("remove.confirm", { name: profile.name }));
794
+ if (confirm.toLowerCase() !== "y") return;
795
+
796
+ store.remove(profile.name);
797
+ console.log(chalk.green(t("remove.done", { name: profile.name })));
798
+ });
799
+
800
+ // ccm alias
801
+ const aliasCmd = program
802
+ .command("alias")
803
+ .description(t("alias.description"));
804
+
805
+ aliasCmd
806
+ .command("set <short> <name>")
807
+ .description(t("alias.set_description"))
808
+ .action((short: string, name: string) => {
809
+ const store = ensureStore();
810
+ if (!store.get(name)) {
811
+ const allNames = store.list().map((p) => p.name);
812
+ const suggestions = findSuggestions(name, allNames);
813
+ console.log(chalk.red(t("error.not_found", { name })));
814
+ if (suggestions.length > 0) {
815
+ console.log(chalk.yellow(t("suggest.did_you_mean", { name: suggestions.join(", ") })));
816
+ }
817
+ return;
818
+ }
819
+
820
+ const rc = readRc()!;
821
+ rc.aliases = rc.aliases || {};
822
+ rc.aliases[short] = name;
823
+ writeRc(rc);
824
+ console.log(chalk.green(t("alias.set_done", { short: chalk.bold(short), name })));
825
+ });
826
+
827
+ aliasCmd
828
+ .command("rm <short>")
829
+ .description(t("alias.rm_description"))
830
+ .action((short: string) => {
831
+ const rc = readRc();
832
+ if (!rc?.aliases?.[short]) {
833
+ console.log(chalk.red(t("alias.rm_not_found", { short })));
834
+ return;
835
+ }
836
+ delete rc.aliases![short];
837
+ writeRc(rc);
838
+ console.log(chalk.green(t("alias.rm_done", { short })));
839
+ });
840
+
841
+ aliasCmd
842
+ .command("list")
843
+ .alias("ls")
844
+ .description(t("alias.list_description"))
845
+ .action(() => {
846
+ const rc = readRc();
847
+ const aliases = rc?.aliases || {};
848
+ const entries = Object.entries(aliases);
849
+
850
+ if (entries.length === 0) {
851
+ console.log(chalk.yellow(t("alias.list_empty")));
852
+ return;
853
+ }
854
+
855
+ console.log(chalk.bold(`\n${t("alias.list_header")}\n`));
856
+ for (const [short, name] of entries) {
857
+ console.log(` ${chalk.cyan.bold(short)} → ${name}`);
858
+ }
859
+ console.log();
860
+ });
861
+
862
+ // Default: ccm alias (no subcommand) → show list
863
+ aliasCmd.action(() => {
864
+ aliasCmd.commands.find((c) => c.name() === "list")!.parseAsync([]);
865
+ });
866
+
867
+ // ccm locale
868
+ const localeCmd = program
869
+ .command("locale")
870
+ .description(t("locale.description"));
871
+
872
+ localeCmd
873
+ .command("set <lang>")
874
+ .description(t("locale.set_description"))
875
+ .action((lang: string) => {
876
+ if (lang !== "zh" && lang !== "en") {
877
+ console.log(chalk.red(t("locale.set_invalid", { locale: lang })));
878
+ return;
879
+ }
880
+ switchLocale(lang);
881
+ });
882
+
883
+ const SUPPORTED_LOCALES: Array<{ code: string; label: string }> = [
884
+ { code: "zh", label: "中文" },
885
+ { code: "en", label: "English" },
886
+ ];
887
+
888
+ const switchLocale = (code: string) => {
889
+ const rc = readRc();
890
+ if (!rc) { console.log(chalk.yellow(t("common.not_init"))); return; }
891
+ rc.locale = code as "zh" | "en";
892
+ writeRc(rc);
893
+ setLocale(code as "zh" | "en");
894
+ console.log(chalk.green(t("locale.set_done", { locale: code })));
895
+ };
896
+
897
+ localeCmd
898
+ .command("list")
899
+ .alias("ls")
900
+ .description(t("locale.list_description"))
901
+ .action(async () => {
902
+ const rc = readRc();
903
+ const current = rc?.locale || "zh";
904
+ const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
905
+
906
+ if (isInteractive) {
907
+ const options = SUPPORTED_LOCALES.map(({ code, label }) => {
908
+ const isCurrent = code === current;
909
+ const tag = isCurrent ? ` ${t("locale.list_current_marker")}` : "";
910
+ return { label: `${code} - ${label}${tag}`, value: code };
911
+ });
912
+
913
+ const selected = await clack.select({
914
+ message: "",
915
+ options,
916
+ initialValue: current,
917
+ });
918
+
919
+ if (clack.isCancel(selected) || selected === current) return;
920
+ switchLocale(selected as string);
921
+ } else {
922
+ console.log(chalk.bold(`\n${t("locale.list_header")}\n`));
923
+ SUPPORTED_LOCALES.forEach(({ code, label }, i) => {
924
+ const isCurrent = code === current;
925
+ const marker = isCurrent ? chalk.green("● ") : " ";
926
+ const name = isCurrent ? chalk.green.bold(`${code} - ${label}`) : `${code} - ${label}`;
927
+ const tag = isCurrent ? chalk.gray(` ${t("locale.list_current_marker")}`) : "";
928
+ console.log(`${marker}${chalk.gray(`${i + 1}.`)} ${name}${tag}`);
929
+ });
930
+ console.log();
931
+ const input = await ask(t("locale.choose_number"));
932
+ if (!input) return;
933
+ const idx = parseInt(input, 10) - 1;
934
+ if (isNaN(idx) || idx < 0 || idx >= SUPPORTED_LOCALES.length) {
935
+ console.log(chalk.red(t("error.invalid_choice")));
936
+ return;
937
+ }
938
+ const selected = SUPPORTED_LOCALES[idx].code;
939
+ if (selected === current) return;
940
+ switchLocale(selected);
941
+ }
942
+ });
943
+
944
+ // Default: ccm locale (no subcommand) → show list
945
+ localeCmd.action(() => {
946
+ localeCmd.commands.find((c) => c.name() === "list")!.parseAsync([]);
947
+ });
948
+
949
+ program.parse();