Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,7 @@ dev/
# Test files
test-*.json
*.test.json

# .claude
.claude/**
.gitnexus
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!-- gitnexus:start -->
# GitNexus MCP

This project is indexed by GitNexus as **switch-claude-cli** (317 symbols, 785 relationships, 25 execution flows).

## Always Start Here

1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**

> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.

## Skills

| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |

<!-- gitnexus:end -->
25 changes: 25 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!-- gitnexus:start -->
# GitNexus MCP

This project is indexed by GitNexus as **switch-claude-cli** (317 symbols, 785 relationships, 25 execution flows).

## Always Start Here

1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
2. **Match your task to a skill below** and **read that skill file**
3. **Follow the skill's workflow and checklist**

> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.

## Skills

| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |

<!-- gitnexus:end -->
69 changes: 43 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
npm install -g switch-claude-cli
```


### 从源码安装

```bash
Expand Down Expand Up @@ -117,6 +116,7 @@ nano ~/.switch-claude/providers.json
```

**代理配置说明** 🌐:

- 如果某个 API 提供方需要通过代理访问(如 VPN),可以在配置中添加 `proxy` 字段
- `proxy` 格式:`http://代理地址:端口`(例如:`http://127.0.0.1:7897`)
- 未配置 `proxy` 字段的 Provider 会直接连接,不使用代理
Expand All @@ -142,6 +142,7 @@ switch-claude --list <==> scl --list <==> ccc --list
```

**别名说明**:

- `switch-claude` - 完整命令,语义清晰
- `scl` - Switch CLaude 首字母缩写
- `ccc` - Choose Claude CLI 缩写
Expand All @@ -159,13 +160,29 @@ ccc
# 直接选择编号为 1 的 provider
scl 1

# 只设置环境变量,不启动 claude
# 仅 Claude 模式:只设置环境变量,不启动 Claude Code
scl -e 1

# 查看版本并检查更新
scl --version
```

### Codex 模式

```bash
# 交互式选择 Codex provider
switch-claude --codex

# 直接选择 Codex provider,并写入 ~/.codex/config.toml 后启动 codex
switch-claude --codex 1
```

**说明**:

- Codex 模式不再通过环境变量注入 `baseUrl` 和 `key`
- 工具会直接更新 `~/.codex/config.toml` 中 `[model_providers.x]` 下的 `name`、`base_url` 与 `experimental_bearer_token`
- `--env-only` 仅适用于 Claude 模式,不适用于 `--codex`

### 检测和缓存

```bash
Expand Down Expand Up @@ -270,6 +287,7 @@ switch-claude --reset-stats
- 🔄 **数据重置**:支持清空所有统计数据

**统计数据存储**:

- 统计数据存储在 `~/.switch-claude/usage-stats.json`
- 数据会自动保存,无需手动操作
- 重装或升级时统计数据会保留
Expand All @@ -283,27 +301,27 @@ switch-claude --help

## 🔧 命令行选项

| 选项 | 简写 | 描述 |
| ---------------------- | ---- | --------------------------------------- |
| `--help` | `-h` | 显示帮助信息 |
| `--version` | `-V` | 显示版本信息并检查更新 |
| `--refresh` | `-r` | 强制刷新缓存,重新检测所有 provider |
| `--verbose` | `-v` | 显示详细的调试信息 |
| `--list` | `-l` | 只列出 providers 不启动 claude |
| `--env-only` | `-e` | 只设置环境变量,不启动 claude |
| `--add` | | 添加新的 provider |
| `--remove <编号>` | | 删除指定编号的 provider |
| `--set-default <编号>` | | 设置指定编号的 provider 为默认 |
| `--clear-default` | | 清除默认 provider(每次都需要手动选择) |
| `--check-update` | | 手动检查版本更新 |
| `--export [文件名]` | | 导出配置到文件 |
| `--import <文件名>` | | 从文件导入配置 |
| `--merge` | | 与 --import 配合使用,合并而不是替换 |
| `--backup` | | 备份当前配置到系统目录 |
| `--list-backups` | | 列出所有备份文件 |
| `--stats` | | 显示使用统计信息 |
| `--export-stats [文件名]` | | 导出统计数据到文件 |
| `--reset-stats` | | 重置所有统计数据 |
| 选项 | 简写 | 描述 |
| ------------------------- | ---- | -------------------------------------------------- |
| `--help` | `-h` | 显示帮助信息 |
| `--version` | `-V` | 显示版本信息并检查更新 |
| `--refresh` | `-r` | 强制刷新缓存,重新检测所有 provider |
| `--verbose` | `-v` | 显示详细的调试信息 |
| `--list` | `-l` | 只列出 providers 不启动应用 |
| `--env-only` | `-e` | 仅 Claude 模式:只设置环境变量,不启动 Claude Code |
| `--add` | | 添加新的 provider |
| `--remove <编号>` | | 删除指定编号的 provider |
| `--set-default <编号>` | | 设置指定编号的 provider 为默认 |
| `--clear-default` | | 清除默认 provider(每次都需要手动选择) |
| `--check-update` | | 手动检查版本更新 |
| `--export [文件名]` | | 导出配置到文件 |
| `--import <文件名>` | | 从文件导入配置 |
| `--merge` | | 与 --import 配合使用,合并而不是替换 |
| `--backup` | | 备份当前配置到系统目录 |
| `--list-backups` | | 列出所有备份文件 |
| `--stats` | | 显示使用统计信息 |
| `--export-stats [文件名]` | | 导出统计数据到文件 |
| `--reset-stats` | | 重置所有统计数据 |

## 📁 配置文件位置

Expand Down Expand Up @@ -389,7 +407,7 @@ switch-claude
switch-claude -v --refresh
```

2. 只设置环境变量,手动运行 claude
2. 只设置环境变量,手动运行 Claude Code

```bash
switch-claude -e 1
Expand Down Expand Up @@ -421,7 +439,7 @@ claude

1. **检查安装**:确保 Claude Code 已正确安装
2. **检查 PATH**:确保 claude 命令在系统 PATH 中
3. **使用 --env-only**:
3. **使用 --env-only(仅 Claude 模式)**:

```bash
switch-claude -e 1
Expand Down Expand Up @@ -529,7 +547,6 @@ A: 工具会自动提醒你更新!你也可以:

A: 可以。删除 `cache.json` 不会影响功能,只是下次运行会重新检测。


---

**项目地址**: [GitHub](https://github.com/yak33/switch-claude-cli)
Expand Down
58 changes: 58 additions & 0 deletions codextest/codex-config-file-write.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { afterEach, describe, expect, it } from 'vitest';

import { CODEX_PROVIDER_ID, writeCodexProviderConfig } from '../src/utils/codex-config-utils.js';

const createdDirs: string[] = [];

afterEach(() => {
for (const dir of createdDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe('codextest: codex-config 文件写入', () => {
it('会把 name/baseUrl/key 分别写入 name/base_url/experimental_bearer_token', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switch-claude-codex-'));
createdDirs.push(tempDir);

const configPath = path.join(tempDir, '.codex', 'config.toml');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
[
'model_provider = "x"',
'',
'[model_providers.x]',
'name = "legacy-name"',
'base_url = "https://legacy.example.com/v1"',
'experimental_bearer_token = "legacy-token"',
'wire_api = "responses"',
].join('\n'),
'utf-8'
);

const result = writeCodexProviderConfig(
{
name: '写入后的 Provider 名称',
baseUrl: 'https://written.example.com/v1',
key: 'sk-written-token',
},
CODEX_PROVIDER_ID,
configPath
);

const updated = fs.readFileSync(configPath, 'utf-8');

expect(result.configPath).toBe(configPath);
expect(result.providerId).toBe(CODEX_PROVIDER_ID);
expect(result.createdSection).toBe(false);
expect(updated).toContain('name = "写入后的 Provider 名称"');
expect(updated).toContain('base_url = "https://written.example.com/v1"');
expect(updated).toContain('experimental_bearer_token = "sk-written-token"');
expect(updated).toContain('wire_api = "responses"');
});
});
46 changes: 46 additions & 0 deletions codextest/codex-config-write-regression.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';

import {
CODEX_PROVIDER_ID as DIST_CODEX_PROVIDER_ID,
updateCodexConfigToml as updateDistCodexConfigToml,
} from '../dist/utils/codex-config-utils.js';
import {
CODEX_PROVIDER_ID as SRC_CODEX_PROVIDER_ID,
updateCodexConfigToml as updateSrcCodexConfigToml,
} from '../src/utils/codex-config-utils.js';

describe('codextest: codex-config 写入回归', () => {
it.each([
{
label: 'src',
providerId: SRC_CODEX_PROVIDER_ID,
updateCodexConfigToml: updateSrcCodexConfigToml,
},
{
label: 'dist',
providerId: DIST_CODEX_PROVIDER_ID,
updateCodexConfigToml: updateDistCodexConfigToml,
},
])('$label 会把 name/baseUrl/key 分别写入正确的 Codex 字段', ({ providerId, updateCodexConfigToml }) => {
const toml = [
'model_provider = "x"',
'',
'[model_providers.x]',
'name = "legacy-name"',
'base_url = "https://legacy.example.com/v1"',
'experimental_bearer_token = "legacy-token"',
'wire_api = "responses"',
].join('\n');

const updated = updateCodexConfigToml(toml, providerId, {
name: '目标 Provider',
baseUrl: 'https://target.example.com/v1',
key: 'sk-test-token',
});

expect(updated).toContain('name = "目标 Provider"');
expect(updated).toContain('base_url = "https://target.example.com/v1"');
expect(updated).toContain('experimental_bearer_token = "sk-test-token"');
expect(updated).toContain('wire_api = "responses"');
});
});
82 changes: 82 additions & 0 deletions codextest/codex-launch-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { afterEach, describe, expect, it, vi } from 'vitest';

import { CommandExecutor } from '../src/commands/command-executor.js';
import { StatsManager } from '../src/core/stats-manager.js';

const createdDirs: string[] = [];

afterEach(() => {
vi.restoreAllMocks();

for (const dir of createdDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});

describe('codextest: codex 启动写入链路', () => {
it('会在启动 codex 前把 name/baseUrl/key 直接写入 ~/.codex/config.toml', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switch-claude-codex-launch-'));
createdDirs.push(tempDir);

vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
vi.spyOn(StatsManager, 'recordProviderUse').mockImplementation(() => undefined);

const configPath = path.join(tempDir, '.codex', 'config.toml');
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(
configPath,
[
'model_provider = "x"',
'',
'[model_providers.x]',
'name = "legacy-name"',
'base_url = "https://legacy.example.com/v1"',
'experimental_bearer_token = "legacy-token"',
'wire_api = "responses"',
].join('\n'),
'utf-8'
);

const executor = new CommandExecutor() as any;
const launchCommandSpy = vi.fn().mockResolvedValue({ success: true, exitCode: 0 });
executor.launchCommand = launchCommandSpy;

const result = await executor.launchCodexApp(
{
name: 'Codex 测试 Provider',
baseUrl: 'https://selected.example.com/v1',
key: 'sk-selected-token',
proxy: '127.0.0.1:7897',
isCodex: true,
},
false
);

const updated = fs.readFileSync(configPath, 'utf-8');

expect(result).toEqual({ success: true, exitCode: 0 });
expect(launchCommandSpy).toHaveBeenCalledTimes(1);
expect(launchCommandSpy).toHaveBeenCalledWith(
expect.objectContaining({
appName: 'Codex',
commandName: 'codex',
launchEnvEntries: expect.arrayContaining([
{ key: 'HTTP_PROXY', value: 'http://127.0.0.1:7897' },
{ key: 'HTTPS_PROXY', value: 'http://127.0.0.1:7897' },
{ key: 'ALL_PROXY', value: 'http://127.0.0.1:7897' },
{ key: 'http_proxy', value: 'http://127.0.0.1:7897' },
{ key: 'https_proxy', value: 'http://127.0.0.1:7897' },
{ key: 'all_proxy', value: 'http://127.0.0.1:7897' },
]),
})
);
expect(updated).toContain('name = "Codex 测试 Provider"');
expect(updated).toContain('base_url = "https://selected.example.com/v1"');
expect(updated).toContain('experimental_bearer_token = "sk-selected-token"');
expect(updated).toContain('wire_api = "responses"');
});
});
Loading