Skip to content

Commit 792c0f8

Browse files
committed
feat(setup): add interactive/options mode with backup and dry-run
1 parent f736b33 commit 792c0f8

10 files changed

Lines changed: 224 additions & 14 deletions

File tree

src/commands/setup.ts

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,158 @@
11
import pc from "picocolors";
2-
import { getApiKey, maskKey } from "../lib/config.js";
2+
import { existsSync } from "node:fs";
3+
import { copyFile, mkdir } from "node:fs/promises";
4+
import { basename, dirname, join } from "node:path";
5+
import { ask } from "../lib/prompt.js";
6+
import { getApiKey, getPreferences, maskKey, savePreferences } from "../lib/config.js";
37
import { API_BASE, API_BASE_OPENAI } from "../lib/constants.js";
48
import { SUPPORTED_TOOLS, getImplementedAdapter } from "../lib/tools.js";
9+
import type { ToolAdapter } from "../tools/types.js";
510

6-
export async function setupCommand(): Promise<void> {
11+
type SetupOptions = {
12+
interactive?: boolean;
13+
tools?: string;
14+
model?: string;
15+
dryRun?: boolean;
16+
backup?: boolean;
17+
};
18+
19+
const DEFAULT_MODEL = "claude-sonnet-4-5";
20+
const MODEL_CHOICES = ["claude-sonnet-4-5", "claude-opus-4-5", "gpt-5.4", "gpt-5"];
21+
22+
function parseToolIds(input: string, validIds: string[]): string[] {
23+
const raw = input
24+
.split(",")
25+
.map((s) => s.trim())
26+
.filter(Boolean);
27+
if (raw.length === 0) return [];
28+
if (raw.includes("all")) return validIds;
29+
const unknown = raw.filter((id) => !validIds.includes(id));
30+
if (unknown.length > 0) {
31+
throw new Error(`无效工具 ID: ${unknown.join(", ")}`);
32+
}
33+
return [...new Set(raw)];
34+
}
35+
36+
function getImplementedTools(): Array<{ id: string; name: string; adapter: ToolAdapter }> {
37+
return SUPPORTED_TOOLS.map((tool) => {
38+
const adapter = getImplementedAdapter(tool.id);
39+
return adapter ? { id: tool.id, name: tool.name, adapter } : null;
40+
}).filter((item): item is { id: string; name: string; adapter: ToolAdapter } => item !== null);
41+
}
42+
43+
async function maybeBackupFiles(files: string[]): Promise<string[]> {
44+
const backedUp: string[] = [];
45+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
46+
for (const file of files) {
47+
if (!existsSync(file)) continue;
48+
const backupDir = join(dirname(file), ".fishxcode-backups");
49+
await mkdir(backupDir, { recursive: true });
50+
const backupFile = join(backupDir, `${basename(file)}.${timestamp}.bak`);
51+
await copyFile(file, backupFile);
52+
backedUp.push(backupFile);
53+
}
54+
return backedUp;
55+
}
56+
57+
async function resolveInteractiveOptions(
58+
initial: SetupOptions,
59+
validToolIds: string[],
60+
preferenceModel: string,
61+
): Promise<Required<Pick<SetupOptions, "interactive" | "tools" | "model" | "dryRun" | "backup">>> {
62+
const toolsHint = validToolIds.join(",");
63+
const defaultTools = initial.tools ?? "all";
64+
const defaultModel = initial.model ?? preferenceModel;
65+
const toolAnswer = (
66+
await ask(`选择工具 ID(逗号分隔,默认 all,可选 ${toolsHint}): `)
67+
).trim();
68+
const modelAnswer = (await ask(`模型(默认 ${defaultModel}): `)).trim();
69+
const dryRunAnswer = (await ask("仅预览不写入?(y/N): ")).trim().toLowerCase();
70+
const backupAnswer = (await ask("写入前备份配置?(y/N): ")).trim().toLowerCase();
71+
72+
return {
73+
interactive: true,
74+
tools: toolAnswer || defaultTools,
75+
model: modelAnswer || defaultModel,
76+
dryRun: dryRunAnswer === "y" || dryRunAnswer === "yes",
77+
backup: backupAnswer === "y" || backupAnswer === "yes",
78+
};
79+
}
80+
81+
export async function setupCommand(options: SetupOptions = {}): Promise<void> {
782
const key = await getApiKey();
883
if (!key) {
984
throw new Error("未检测到 API Key,请先执行 fishx login");
1085
}
1186

87+
const preferences = await getPreferences();
88+
const implementedTools = getImplementedTools();
89+
const validToolIds = implementedTools.map((t) => t.id);
90+
const preferenceModel = preferences.defaultModel ?? DEFAULT_MODEL;
91+
92+
let effectiveOptions: SetupOptions = {
93+
interactive: options.interactive ?? preferences.interactive ?? false,
94+
tools: options.tools ?? preferences.defaultTools?.join(",") ?? "all",
95+
model: options.model ?? preferenceModel,
96+
dryRun: options.dryRun ?? false,
97+
backup: options.backup ?? preferences.backup ?? false,
98+
};
99+
100+
if (effectiveOptions.interactive) {
101+
effectiveOptions = await resolveInteractiveOptions(effectiveOptions, validToolIds, preferenceModel);
102+
}
103+
104+
const selectedToolIds = parseToolIds(effectiveOptions.tools ?? "all", validToolIds);
105+
const selectedModel = effectiveOptions.model?.trim() || DEFAULT_MODEL;
106+
const dryRun = !!effectiveOptions.dryRun;
107+
const backup = !!effectiveOptions.backup;
108+
109+
await savePreferences({
110+
defaultTools: selectedToolIds,
111+
defaultModel: selectedModel,
112+
interactive: !!effectiveOptions.interactive,
113+
backup,
114+
});
115+
12116
console.log(pc.bold("开始配置 FishXCode 工具"));
13117
console.log(`- API Key: ${maskKey(key)}`);
14118
console.log(`- Anthropic Base URL: ${API_BASE}`);
15119
console.log(`- OpenAI Base URL: ${API_BASE_OPENAI}`);
120+
console.log(`- 模型: ${selectedModel}`);
121+
console.log(`- 模式: ${dryRun ? "预览 (dry-run)" : "写入"}`);
122+
if (backup) {
123+
console.log("- 备份: 已启用");
124+
}
16125

17126
const applied: string[] = [];
18127
const skipped: string[] = [];
128+
const backups: string[] = [];
19129

20-
for (const tool of SUPPORTED_TOOLS) {
130+
for (const tool of SUPPORTED_TOOLS.filter((t) => selectedToolIds.includes(t.id))) {
21131
const adapter = getImplementedAdapter(tool.id);
22132
if (!adapter) {
23133
skipped.push(tool.name);
24134
continue;
25135
}
26136

27137
try {
138+
const targetFiles = adapter.getTargetFiles?.() ?? [];
139+
if (dryRun) {
140+
const previewFile = targetFiles[0] ?? "(未知路径)";
141+
applied.push(`${tool.name} -> ${previewFile}`);
142+
console.log(pc.cyan(`~ ${tool.name}`), pc.gray(previewFile), pc.gray("[dry-run]"));
143+
continue;
144+
}
145+
146+
if (backup && targetFiles.length > 0) {
147+
const backupFiles = await maybeBackupFiles(targetFiles);
148+
backups.push(...backupFiles);
149+
}
150+
28151
const result = await adapter.configure({
29152
apiKey: key,
30153
baseAnthropic: API_BASE,
31154
baseOpenAI: API_BASE_OPENAI,
155+
model: selectedModel,
32156
});
33157
applied.push(`${tool.name} -> ${result.file}`);
34158
console.log(pc.green(`✓ ${tool.name}`), pc.gray(result.file));
@@ -45,4 +169,8 @@ export async function setupCommand(): Promise<void> {
45169

46170
console.log(`- 未实装: ${skipped.length}`);
47171
for (const item of skipped) console.log(` ${pc.yellow("•")} ${item}`);
172+
if (backups.length > 0) {
173+
console.log(`- 备份文件: ${backups.length}`);
174+
for (const file of backups) console.log(` ${pc.blue("•")} ${file}`);
175+
}
48176
}

src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,17 @@ program
2828

2929
program.command("whoami").description("查看当前登录状态").action(whoamiCommand);
3030
program.command("logout").description("清除本地 API Key").action(logoutCommand);
31-
program.command("setup").description("执行工具配置流程(迁移版)").action(setupCommand);
31+
program
32+
.command("setup")
33+
.description("执行工具配置流程(迁移版)")
34+
.option("-i, --interactive", "交互式选择配置项")
35+
.option("--tools <ids>", "仅配置指定工具(逗号分隔),如 codex,aider")
36+
.option("--model <model>", "覆盖默认模型,如 claude-opus-4-5")
37+
.option("--dry-run", "仅预览将要修改的配置,不写文件")
38+
.option("--backup", "写入前备份目标配置文件")
39+
.action(async (opts) => {
40+
await setupCommand(opts);
41+
});
3242
program.command("doctor").description("检查环境与配置状态").action(doctorCommand);
3343
program.command("tools").description("列出支持工具").action(toolsCommand);
3444
program.command("reset").description("重置本地配置").action(resetCommand);

src/lib/config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { CONFIG_DIRNAME } from "./constants.js";
77
export type AppConfig = {
88
apiKey?: string;
99
savedAt?: string;
10+
preferences?: AppPreferences;
11+
};
12+
13+
export type AppPreferences = {
14+
defaultTools?: string[];
15+
defaultModel?: string;
16+
interactive?: boolean;
17+
backup?: boolean;
1018
};
1119

1220
export const configDir = join(homedir(), CONFIG_DIRNAME);
@@ -41,6 +49,17 @@ export async function getApiKey(): Promise<string> {
4149
return cfg.apiKey ?? process.env.FISHXCODE_API_KEY ?? "";
4250
}
4351

52+
export async function getPreferences(): Promise<AppPreferences> {
53+
const cfg = await loadConfig();
54+
return cfg.preferences ?? {};
55+
}
56+
57+
export async function savePreferences(patch: AppPreferences): Promise<AppConfig> {
58+
const current = await loadConfig();
59+
const nextPreferences = { ...(current.preferences ?? {}), ...patch };
60+
return saveConfig({ preferences: nextPreferences });
61+
}
62+
4463
export function maskKey(key: string): string {
4564
if (!key || key.length < 10) return "****";
4665
return `${key.slice(0, 8)}...${key.slice(-4)}`;

src/tools/aider.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ function removeManagedBlock(content: string): string {
3030
return kept.join("\n").trim();
3131
}
3232

33+
function resolveAiderModel(model?: string): string {
34+
if (!model) return "openai/claude-sonnet-4-5";
35+
if (model.includes("/")) return model;
36+
return `openai/${model}`;
37+
}
38+
3339
export const aiderAdapter: ToolAdapter = {
3440
id: "aider",
3541
name: "Aider",
@@ -38,6 +44,9 @@ export const aiderAdapter: ToolAdapter = {
3844
checkInstalled() {
3945
return commandExists("aider");
4046
},
47+
getTargetFiles() {
48+
return [aiderFile];
49+
},
4150
isConfigured() {
4251
if (!existsSync(aiderFile)) return false;
4352
const content = readFileSync(aiderFile, "utf8");
@@ -50,7 +59,7 @@ export const aiderAdapter: ToolAdapter = {
5059
`${marker} — https://fishxcode.com`,
5160
`openai-api-key: ${ctx.apiKey}`,
5261
`openai-api-base: ${ctx.baseOpenAI}`,
53-
"model: openai/claude-sonnet-4-5",
62+
`model: ${resolveAiderModel(ctx.model)}`,
5463
"",
5564
].join("\n");
5665
const next = [clean, block].filter(Boolean).join("\n\n").trim();

src/tools/claude-code.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export const claudeCodeAdapter: ToolAdapter = {
2929
checkInstalled() {
3030
return commandExists("claude");
3131
},
32+
getTargetFiles() {
33+
return [settingsFile];
34+
},
3235
isConfigured() {
3336
const s = readSettings();
3437
return !!s.env?.ANTHROPIC_AUTH_TOKEN;

src/tools/codex.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,21 @@ export const codexAdapter: ToolAdapter = {
3131
checkInstalled() {
3232
return commandExists("codex");
3333
},
34+
getTargetFiles() {
35+
return [tomlFile, jsonFile];
36+
},
3437
isConfigured() {
3538
if (!existsSync(tomlFile)) return false;
3639
const content = readFileSync(tomlFile, "utf8");
3740
return content.includes('model_provider = "fishxcode"') && content.includes("fishxcode.com");
3841
},
3942
async configure(ctx) {
43+
const model = ctx.model ?? "gpt-5.4";
4044
await mkdir(codexDir, { recursive: true });
4145
const current = await readToml();
4246
const cleaned = removeFishxcodeBlocks(current).trim();
4347
const next = [
44-
'model = "gpt-5.4"',
48+
`model = "${model}"`,
4549
'model_provider = "fishxcode"',
4650
"",
4751
cleaned,
@@ -61,7 +65,7 @@ export const codexAdapter: ToolAdapter = {
6165
try {
6266
const raw = await readFile(jsonFile, "utf8");
6367
const json = JSON.parse(raw) as Record<string, unknown>;
64-
json.model = "gpt-5.4";
68+
json.model = model;
6569
json.provider = "fishxcode";
6670
const providers = (json.providers ?? {}) as Record<string, unknown>;
6771
providers.fishxcode = {

src/tools/continue.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ const continueDir = join(toolHome, ".continue");
99
const yamlFile = join(continueDir, "config.yaml");
1010
const jsonFile = join(continueDir, "config.json");
1111

12-
const FISHXCODE_MODELS = [
12+
const DEFAULT_MODELS = [
1313
{ name: "FishXCode — Claude Sonnet", model: "claude-sonnet-4-5" },
1414
{ name: "FishXCode — Claude Opus", model: "claude-opus-4-5" },
1515
];
1616

17+
function getFishxcodeModels(model?: string): Array<{ name: string; model: string }> {
18+
if (!model) return DEFAULT_MODELS;
19+
const custom = { name: `FishXCode — ${model}`, model };
20+
const rest = DEFAULT_MODELS.filter((item) => item.model !== model);
21+
return [custom, ...rest];
22+
}
23+
1724
function getConfigFormat(): "yaml" | "json" {
1825
if (existsSync(yamlFile)) return "yaml";
1926
if (existsSync(jsonFile)) return "json";
@@ -29,15 +36,19 @@ function removeFishxcodeYamlBlocks(content: string): string {
2936
return content.replace(/ {2}- name: FishXCode[^\n]*\n( {4}[^\n]*\n)*/g, "");
3037
}
3138

32-
async function writeYamlConfig(apiKey: string, baseUrl: string): Promise<void> {
39+
async function writeYamlConfig(
40+
apiKey: string,
41+
baseUrl: string,
42+
models: Array<{ name: string; model: string }>,
43+
): Promise<void> {
3344
await mkdir(continueDir, { recursive: true });
3445

3546
let content = removeFishxcodeYamlBlocks(readYaml());
3647
if (!content.includes("models:")) {
3748
content = `models:\n${content}`;
3849
}
3950

40-
const modelBlocks = FISHXCODE_MODELS.map((m) => {
51+
const modelBlocks = models.map((m) => {
4152
return [
4253
` - name: ${m.name}`,
4354
" provider: openai",
@@ -82,6 +93,9 @@ export const continueAdapter: ToolAdapter = {
8293
checkInstalled() {
8394
return existsSync(continueDir);
8495
},
96+
getTargetFiles() {
97+
return [yamlFile, jsonFile];
98+
},
8599
isConfigured() {
86100
if (existsSync(yamlFile)) {
87101
return readYaml().includes("fishxcode.com");
@@ -90,9 +104,10 @@ export const continueAdapter: ToolAdapter = {
90104
return (cfg.models ?? []).some((m) => String(m.apiBase ?? "").includes("fishxcode.com"));
91105
},
92106
async configure(ctx) {
107+
const models = getFishxcodeModels(ctx.model);
93108
const format = getConfigFormat();
94109
if (format === "yaml") {
95-
await writeYamlConfig(ctx.apiKey, ctx.baseOpenAI);
110+
await writeYamlConfig(ctx.apiKey, ctx.baseOpenAI, models);
96111
return { file: yamlFile, hot: true };
97112
}
98113

@@ -101,7 +116,7 @@ export const continueAdapter: ToolAdapter = {
101116
(m) => !String(m.apiBase ?? "").includes("fishxcode.com"),
102117
);
103118
cfg.models = [
104-
...FISHXCODE_MODELS.map((m) => ({
119+
...models.map((m) => ({
105120
title: m.name,
106121
provider: "openai",
107122
model: m.model,

0 commit comments

Comments
 (0)