@x-all-in-one/coding-helper 0.1.1 → 0.3.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/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  CLI 工具,用于配置 AI 编程助手(Claude Code、OpenCode、Codex)的 API 端点和模型。
4
4
 
5
+ ## 功能
6
+
7
+ - 统一管理多个 AI 编程工具的配置
8
+ - 交互式配置向导
9
+ - 支持自定义 API 端点和模型
10
+ - 多语言支持(中文/英文)
11
+
5
12
  ## 安装
6
13
 
7
14
  ```bash
@@ -23,101 +30,11 @@ chelper --help
23
30
  chelper lang
24
31
  ```
25
32
 
26
- ## 开发
27
-
28
- ### 环境要求
29
-
30
- - Node.js >= 18
31
- - pnpm >= 10
32
-
33
- ### 常用命令
34
-
35
- ```bash
36
- pnpm install # 安装依赖
37
- pnpm dev # 开发模式(热重载)
38
- pnpm build # 构建项目
39
- pnpm lint # 代码检查
40
- pnpm lint:fix # 自动修复
41
- ```
42
-
43
- ### 目录结构
44
-
45
- ```
46
- src/
47
- ├── cli.ts # 入口文件
48
- ├── lib/
49
- │ ├── config.ts # 配置管理 (~/.chelper/config.yaml)
50
- │ ├── i18n.ts # 国际化
51
- │ ├── tool-manager.ts # 工具注册管理
52
- │ ├── wizard/ # 交互式 TUI
53
- │ ├── tools/ # 工具实现 (Claude Code, OpenCode, Codex)
54
- │ └── plugins/ # 插件实现
55
- └── locales/ # 语言文件
56
- ```
57
-
58
- ## 发版流程
59
-
60
- 本项目使用 [Changeset](https://github.com/changesets/changesets) 进行版本管理。
61
-
62
- ### 1. 创建 Changeset
63
-
64
- 开发完成后,运行以下命令声明版本变更:
65
-
66
- ```bash
67
- pnpm changeset
68
- ```
69
-
70
- 交互式选择:
71
- - **patch** - 修复 bug (0.0.x)
72
- - **minor** - 新功能 (0.x.0)
73
- - **major** - 破坏性变更 (x.0.0)
74
-
75
- 这会在 `.changeset/` 目录下生成一个 markdown 文件。
76
-
77
- ### 2. 提交代码
78
-
79
- ```bash
80
- git add .
81
- git commit -m "feat: 添加新功能"
82
- git push origin feature/xxx
83
- ```
84
-
85
- ### 3. 创建 MR 并合并
86
-
87
- 创建 Merge Request 合并到 `main` 分支。
88
-
89
- ### 4. 自动发版
90
-
91
- 合并后,GitLab CI 会自动:
92
- 1. 执行 `changeset version` 更新版本号和 CHANGELOG
93
- 2. 执行 `changeset publish` 发布到 npm
94
- 3. 提交版本变更并推送
95
-
96
- ## Commit 规范
97
-
98
- 使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
99
-
100
- | 类型 | 说明 |
101
- |------|------|
102
- | `feat` | 新功能 |
103
- | `fix` | 修复 bug |
104
- | `docs` | 文档变更 |
105
- | `style` | 代码格式(不影响功能) |
106
- | `refactor` | 重构(不是新功能也不是修复) |
107
- | `perf` | 性能优化 |
108
- | `test` | 测试相关 |
109
- | `chore` | 构建/工具变更 |
110
-
111
- ## GitLab CI
112
-
113
- 流水线包含以下阶段:
33
+ ## 支持的工具
114
34
 
115
- | 阶段 | 说明 |
116
- |------|------|
117
- | `install` | 安装依赖 |
118
- | `lint` | 代码检查 |
119
- | `build` | 构建项目 |
120
- | `release` | 发版(仅 main 分支) |
35
+ - [Claude Code](https://claude.ai/code) - Anthropic 官方 CLI
36
+ - [OpenCode](https://github.com/opencode-ai/opencode) - 开源 AI 编程助手
37
+ - [Codex](https://github.com/openai/codex) - OpenAI CLI
121
38
 
122
39
  ## License
123
40
 
@@ -1,22 +1,21 @@
1
1
  import { BaseTool } from './base-tool.js';
2
+ export interface ModelProviderInfo {
3
+ name: string;
4
+ base_url?: string;
5
+ env_key?: string;
6
+ wire_api?: 'chat' | 'responses';
7
+ }
2
8
  export interface CodexConfig {
3
9
  model?: string;
4
- provider?: {
5
- name?: string;
6
- baseURL?: string;
7
- apiKey?: string;
8
- };
9
- [key: string]: any;
10
+ model_provider?: string;
11
+ model_providers?: Record<string, ModelProviderInfo>;
12
+ [key: string]: unknown;
10
13
  }
11
14
  export interface CodexModelConfig {
12
15
  apiKey: string;
13
16
  model: string;
14
17
  baseUrl?: string;
15
18
  }
16
- /**
17
- * Codex 工具实现
18
- * 实现 ITool 接口,管理 Codex 的配置和生命周期
19
- */
20
19
  export declare class CodexTool extends BaseTool {
21
20
  private static instance;
22
21
  readonly name = "codex";
@@ -26,33 +25,15 @@ export declare class CodexTool extends BaseTool {
26
25
  readonly configPath: string;
27
26
  private constructor();
28
27
  static getInstance(): CodexTool;
29
- /**
30
- * Ensure config directory exists
31
- */
32
28
  private ensureDir;
33
- /**
34
- * Read codex config
35
- */
36
29
  getConfig(): CodexConfig;
37
- /**
38
- * Save codex config
39
- */
40
- saveConfig(config: unknown): void;
41
- /**
42
- * Fetch available models from API
43
- */
30
+ saveConfig(config: CodexConfig): void;
44
31
  fetchAvailableModels(apiKey: string): Promise<string[]>;
45
- /**
46
- * Save model config to Codex
47
- */
48
32
  saveModelConfig(config: unknown): Promise<void>;
49
- /**
50
- * Get current model config
51
- */
33
+ private setApiKeyEnv;
34
+ private getShellRcPath;
35
+ private getEnvValue;
52
36
  getModelConfig(): CodexModelConfig | null;
53
- /**
54
- * Clear model config
55
- */
56
37
  clearModelConfig(): void;
57
38
  }
58
39
  export declare const codexTool: CodexTool;
@@ -1,13 +1,11 @@
1
+ import { execSync } from 'node:child_process';
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
3
  import { homedir } from 'node:os';
3
4
  import { dirname, join } from 'node:path';
5
+ import { parse, stringify } from 'smol-toml';
4
6
  import { BaseTool } from './base-tool.js';
5
- // 使用相同的 X-AIO API 端点
6
7
  const DEFAULT_BASE_URL = 'https://code-api.x-aio.com/v1';
7
- /**
8
- * Codex 工具实现
9
- * 实现 ITool 接口,管理 Codex 的配置和生命周期
10
- */
8
+ const X_AIO_CODE_API_KEY_ENV = 'X_AIO_CODE_API_KEY';
11
9
  export class CodexTool extends BaseTool {
12
10
  static instance;
13
11
  name = 'codex';
@@ -17,10 +15,7 @@ export class CodexTool extends BaseTool {
17
15
  configPath;
18
16
  constructor() {
19
17
  super();
20
- // Codex config file path (cross-platform support)
21
- // - macOS/Linux: ~/.codex/config.json
22
- // - Windows: %USERPROFILE%\.codex\config.json
23
- this.configPath = join(homedir(), '.codex', 'config.json');
18
+ this.configPath = join(homedir(), '.codex', 'config.toml');
24
19
  }
25
20
  static getInstance() {
26
21
  if (!CodexTool.instance) {
@@ -28,24 +23,17 @@ export class CodexTool extends BaseTool {
28
23
  }
29
24
  return CodexTool.instance;
30
25
  }
31
- /**
32
- * Ensure config directory exists
33
- */
34
26
  ensureDir(filePath) {
35
27
  const dir = dirname(filePath);
36
28
  if (!existsSync(dir)) {
37
29
  mkdirSync(dir, { recursive: true });
38
30
  }
39
31
  }
40
- // ==================== ITool 接口实现 ====================
41
- /**
42
- * Read codex config
43
- */
44
32
  getConfig() {
45
33
  try {
46
34
  if (existsSync(this.configPath)) {
47
35
  const content = readFileSync(this.configPath, 'utf-8');
48
- return JSON.parse(content);
36
+ return parse(content);
49
37
  }
50
38
  }
51
39
  catch (error) {
@@ -53,21 +41,15 @@ export class CodexTool extends BaseTool {
53
41
  }
54
42
  return {};
55
43
  }
56
- /**
57
- * Save codex config
58
- */
59
44
  saveConfig(config) {
60
45
  try {
61
46
  this.ensureDir(this.configPath);
62
- writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf-8');
47
+ writeFileSync(this.configPath, stringify(config), 'utf-8');
63
48
  }
64
49
  catch (error) {
65
50
  throw new Error(`Failed to save Codex config: ${error}`);
66
51
  }
67
52
  }
68
- /**
69
- * Fetch available models from API
70
- */
71
53
  async fetchAvailableModels(apiKey) {
72
54
  try {
73
55
  const controller = new AbortController();
@@ -95,48 +77,145 @@ export class CodexTool extends BaseTool {
95
77
  return [];
96
78
  }
97
79
  }
98
- /**
99
- * Save model config to Codex
100
- */
101
80
  async saveModelConfig(config) {
102
81
  const modelConfig = config;
103
- // Read current config (preserve user's other settings)
104
82
  const currentConfig = this.getConfig();
105
- // Build new config
83
+ const providerKey = 'x-aio';
106
84
  const newConfig = {
107
85
  ...currentConfig,
108
86
  model: modelConfig.model,
109
- provider: {
110
- name: 'X-AIO',
111
- baseURL: modelConfig.baseUrl || DEFAULT_BASE_URL,
112
- apiKey: modelConfig.apiKey,
87
+ model_provider: providerKey,
88
+ model_providers: {
89
+ ...currentConfig.model_providers,
90
+ [providerKey]: {
91
+ name: 'X-AIO',
92
+ base_url: modelConfig.baseUrl || DEFAULT_BASE_URL,
93
+ env_key: X_AIO_CODE_API_KEY_ENV,
94
+ wire_api: 'responses',
95
+ },
113
96
  },
114
97
  };
115
98
  this.saveConfig(newConfig);
99
+ this.setApiKeyEnv(modelConfig.apiKey);
100
+ }
101
+ setApiKeyEnv(apiKey) {
102
+ const isWindows = process.platform === 'win32';
103
+ if (isWindows) {
104
+ const escapedKey = apiKey.replace(/'/g, '\'\'');
105
+ const psCommand = `[Environment]::SetEnvironmentVariable('${X_AIO_CODE_API_KEY_ENV}', '${escapedKey}', 'User')`;
106
+ try {
107
+ execSync(`powershell -Command "${psCommand}"`, { stdio: 'ignore' });
108
+ }
109
+ catch {
110
+ console.warn('Failed to set Windows environment variable');
111
+ }
112
+ }
113
+ else {
114
+ const shellRcPath = this.getShellRcPath();
115
+ if (shellRcPath) {
116
+ let content = existsSync(shellRcPath) ? readFileSync(shellRcPath, 'utf-8') : '';
117
+ const exportLine = `export ${X_AIO_CODE_API_KEY_ENV}="${apiKey}"`;
118
+ const regex = new RegExp(`^export ${X_AIO_CODE_API_KEY_ENV}=.*$`, 'm');
119
+ if (regex.test(content)) {
120
+ content = content.replace(regex, exportLine);
121
+ }
122
+ else {
123
+ content = content ? `${content}\n${exportLine}\n` : `${exportLine}\n`;
124
+ }
125
+ writeFileSync(shellRcPath, content, 'utf-8');
126
+ }
127
+ }
128
+ process.env[X_AIO_CODE_API_KEY_ENV] = apiKey;
129
+ }
130
+ getShellRcPath() {
131
+ const home = homedir();
132
+ const shell = process.env.SHELL || '';
133
+ if (shell.includes('zsh')) {
134
+ const zshrc = join(home, '.zshrc');
135
+ const zprofile = join(home, '.zprofile');
136
+ if (existsSync(zshrc))
137
+ return zshrc;
138
+ if (existsSync(zprofile))
139
+ return zprofile;
140
+ return zshrc;
141
+ }
142
+ if (shell.includes('bash')) {
143
+ const bashrc = join(home, '.bashrc');
144
+ const bashProfile = join(home, '.bash_profile');
145
+ if (existsSync(bashrc))
146
+ return bashrc;
147
+ if (existsSync(bashProfile))
148
+ return bashProfile;
149
+ return bashrc;
150
+ }
151
+ return join(home, '.profile');
152
+ }
153
+ getEnvValue(envKey) {
154
+ if (process.env[envKey]) {
155
+ return process.env[envKey];
156
+ }
157
+ if (process.platform === 'win32') {
158
+ try {
159
+ const result = execSync(`powershell -Command "[Environment]::GetEnvironmentVariable('${envKey}', 'User')"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
160
+ if (result && result !== '') {
161
+ return result;
162
+ }
163
+ }
164
+ catch {
165
+ // Ignore errors
166
+ }
167
+ }
168
+ else {
169
+ try {
170
+ const shellRcPath = this.getShellRcPath();
171
+ if (existsSync(shellRcPath)) {
172
+ const content = readFileSync(shellRcPath, 'utf-8');
173
+ const regex = new RegExp(`^export ${envKey}=["']?([^"'\\n]+)["']?`, 'm');
174
+ const match = content.match(regex);
175
+ if (match && match[1]) {
176
+ return match[1];
177
+ }
178
+ }
179
+ }
180
+ catch {
181
+ // Ignore errors
182
+ }
183
+ }
184
+ return undefined;
116
185
  }
117
- /**
118
- * Get current model config
119
- */
120
186
  getModelConfig() {
121
187
  const config = this.getConfig();
122
- if (!config.provider?.apiKey) {
188
+ const providerKey = config.model_provider;
189
+ if (!providerKey || !config.model_providers?.[providerKey]) {
190
+ return null;
191
+ }
192
+ const provider = config.model_providers[providerKey];
193
+ const envKey = provider.env_key;
194
+ const apiKey = envKey ? this.getEnvValue(envKey) : undefined;
195
+ if (!apiKey) {
123
196
  return null;
124
197
  }
125
198
  return {
126
- apiKey: config.provider.apiKey,
199
+ apiKey,
127
200
  model: config.model || '',
128
- baseUrl: config.provider.baseURL || '',
201
+ baseUrl: provider.base_url || '',
129
202
  };
130
203
  }
131
- /**
132
- * Clear model config
133
- */
134
204
  clearModelConfig() {
135
205
  const currentConfig = this.getConfig();
136
- delete currentConfig.model;
137
- delete currentConfig.provider;
206
+ const providerKey = 'x-aio';
207
+ if (currentConfig.model_provider === providerKey) {
208
+ delete currentConfig.model;
209
+ delete currentConfig.model_provider;
210
+ }
211
+ if (currentConfig.model_providers?.[providerKey]) {
212
+ delete currentConfig.model_providers[providerKey];
213
+ if (Object.keys(currentConfig.model_providers).length === 0) {
214
+ delete currentConfig.model_providers;
215
+ }
216
+ }
138
217
  this.saveConfig(currentConfig);
218
+ delete process.env[X_AIO_CODE_API_KEY_ENV];
139
219
  }
140
220
  }
141
- // 单例导出
142
221
  export const codexTool = CodexTool.getInstance();
@@ -116,6 +116,7 @@ export declare class OpenCodeTool extends BaseTool {
116
116
  /**
117
117
  * 同步模型列表到配置(不改变当前选择的模型)
118
118
  * 仅更新 provider.xaio.models 部分
119
+ * 如果 provider 不存在但有 apiKey,则创建新的 provider
119
120
  */
120
121
  syncModelsToConfig(modelInfos: ModelInfo[]): void;
121
122
  /**
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
+ import { configManager } from '../config.js';
4
5
  import { BaseTool } from './base-tool.js';
5
6
  // X-AIO Provider ID
6
7
  const XAIO_PROVIDER_ID = 'xaio';
@@ -268,15 +269,35 @@ export class OpenCodeTool extends BaseTool {
268
269
  /**
269
270
  * 同步模型列表到配置(不改变当前选择的模型)
270
271
  * 仅更新 provider.xaio.models 部分
272
+ * 如果 provider 不存在但有 apiKey,则创建新的 provider
271
273
  */
272
274
  syncModelsToConfig(modelInfos) {
273
275
  const currentConfig = this.getConfig();
274
- const xaioProvider = currentConfig.provider?.[XAIO_PROVIDER_ID];
276
+ let xaioProvider = currentConfig.provider?.[XAIO_PROVIDER_ID];
275
277
  if (!xaioProvider) {
276
- // 如果还没有配置过,不做任何操作
277
- return;
278
+ // 如果还没有配置过,尝试从 configManager 获取 apiKey 创建 provider
279
+ const apiKey = configManager.getApiKey();
280
+ if (!apiKey) {
281
+ // 没有 apiKey,无法创建 provider
282
+ return;
283
+ }
284
+ // 创建新的 provider
285
+ xaioProvider = {
286
+ npm: '@ai-sdk/openai-compatible',
287
+ name: OPENCODE_DEFAULT_CONFIG.PROVIDER_NAME,
288
+ options: {
289
+ baseURL: OPENCODE_DEFAULT_CONFIG.BASE_URL,
290
+ apiKey,
291
+ },
292
+ models: {},
293
+ };
294
+ // 初始化 provider 对象
295
+ if (!currentConfig.provider) {
296
+ currentConfig.provider = {};
297
+ }
298
+ currentConfig.provider[XAIO_PROVIDER_ID] = xaioProvider;
278
299
  }
279
- // 只更新 models 部分
300
+ // 更新 models 部分
280
301
  const modelsConfig = this.buildModelsConfig(modelInfos);
281
302
  xaioProvider.models = modelsConfig;
282
303
  this.saveConfig(currentConfig);
@@ -14,6 +14,10 @@ export declare class PluginMenu {
14
14
  * 安装插件
15
15
  */
16
16
  private installPlugin;
17
+ /**
18
+ * 提示用户刷新模型列表
19
+ */
20
+ private promptRefreshModels;
17
21
  /**
18
22
  * 装载插件
19
23
  */
@@ -2,6 +2,7 @@ import chalk from 'chalk';
2
2
  import inquirer from 'inquirer';
3
3
  import ora from 'ora';
4
4
  import { i18n } from '../../i18n.js';
5
+ import { modelService } from '../../model-service.js';
5
6
  import { promptHelper } from '../ui/prompt-helper.js';
6
7
  import { uiRenderer } from '../ui/ui-renderer.js';
7
8
  /**
@@ -83,6 +84,10 @@ export class PluginMenu {
83
84
  try {
84
85
  await plugin.install();
85
86
  console.log(chalk.green(`\n✓ ${i18n.t('wizard.plugin_installed', { plugin: plugin.displayName })}`));
87
+ // oh-my-opencode 安装后提示刷新模型列表
88
+ if (plugin.name === 'oh-my-opencode') {
89
+ await this.promptRefreshModels();
90
+ }
86
91
  }
87
92
  catch (error) {
88
93
  console.log(chalk.red(`\n✗ ${i18n.t('wizard.plugin_install_failed', { plugin: plugin.displayName })}`));
@@ -90,6 +95,33 @@ export class PluginMenu {
90
95
  }
91
96
  await promptHelper.pressEnterToContinue();
92
97
  }
98
+ /**
99
+ * 提示用户刷新模型列表
100
+ */
101
+ async promptRefreshModels() {
102
+ console.log('');
103
+ const { shouldRefresh } = await promptHelper.promptWithHints([
104
+ {
105
+ type: 'confirm',
106
+ name: 'shouldRefresh',
107
+ message: i18n.t('wizard.prompt_refresh_models'),
108
+ default: true,
109
+ },
110
+ ]);
111
+ if (shouldRefresh) {
112
+ const spinner = ora({
113
+ text: i18n.t('wizard.refreshing_models'),
114
+ spinner: 'star2',
115
+ }).start();
116
+ const result = await modelService.refreshAndSyncToOpenCode();
117
+ if (result.success) {
118
+ spinner.succeed(chalk.green(i18n.t('wizard.models_refreshed', { count: String(result.count) })));
119
+ }
120
+ else {
121
+ spinner.fail(i18n.t('wizard.refresh_models_failed'));
122
+ }
123
+ }
124
+ }
93
125
  /**
94
126
  * 装载插件
95
127
  */
@@ -358,6 +358,9 @@ export class ToolMenu {
358
358
  }
359
359
  await new Promise(resolve => setTimeout(resolve, 800));
360
360
  spinner.succeed(chalk.green(i18n.t('wizard.config_loaded', { tool: toolRegistry.getTool(toolName)?.displayName })));
361
+ if (toolName === 'codex' && process.platform === 'win32') {
362
+ console.log(chalk.yellow(` ${i18n.t('wizard.restart_terminal_hint')}`));
363
+ }
361
364
  await new Promise(resolve => setTimeout(resolve, 2000));
362
365
  }
363
366
  catch (error) {
@@ -198,9 +198,11 @@
198
198
  "tool_started": "Tool started",
199
199
  "tool_start_failed": "Failed to start tool",
200
200
  "action_refresh_models": "Refresh Model List - (Update available models to config)",
201
+ "prompt_refresh_models": "Refresh model list now? (Update available models to config)",
201
202
  "refreshing_models": "Refreshing model list...",
202
203
  "models_refreshed": "Model list refreshed, {{count}} models synced",
203
- "refresh_models_failed": "Failed to refresh model list"
204
+ "refresh_models_failed": "Failed to refresh model list",
205
+ "restart_terminal_hint": "Environment variable set. Please restart your terminal before using Codex"
204
206
  },
205
207
  "doctor": {
206
208
  "checking": "Running health check...",
@@ -198,9 +198,11 @@
198
198
  "tool_started": "工具已启动",
199
199
  "tool_start_failed": "启动工具失败",
200
200
  "action_refresh_models": "刷新模型列表 - (更新可用模型到配置)",
201
+ "prompt_refresh_models": "是否立即刷新模型列表?(更新可用模型到配置)",
201
202
  "refreshing_models": "正在刷新模型列表...",
202
203
  "models_refreshed": "模型列表已刷新,共同步 {{count}} 个模型",
203
- "refresh_models_failed": "刷新模型列表失败"
204
+ "refresh_models_failed": "刷新模型列表失败",
205
+ "restart_terminal_hint": "环境变量已设置,请重新打开终端后再使用 Codex"
204
206
  },
205
207
  "doctor": {
206
208
  "checking": "正在进行健康检查...",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x-all-in-one/coding-helper",
3
3
  "type": "module",
4
- "version": "0.1.1",
4
+ "version": "0.3.0",
5
5
  "description": "X All In One Coding Helper",
6
6
  "author": "X.AIO",
7
7
  "homepage": "https://docs.x-aio.com/zh/docs",
@@ -37,6 +37,7 @@
37
37
  "js-yaml": "^4.1.0",
38
38
  "open": "^11.0.0",
39
39
  "ora": "^8.0.1",
40
+ "smol-toml": "^1.6.0",
40
41
  "terminal-link": "^5.0.0"
41
42
  },
42
43
  "devDependencies": {