@haiyangj/ccs 1.0.1

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.
@@ -0,0 +1,126 @@
1
+ # 更新说明 (v1.0.1)
2
+
3
+ ## 主要变更
4
+
5
+ ### 1. ✅ 修复 settings.json 结构
6
+ **问题**: 之前的实现更新的是顶层的 `apiUrl` 和 `apiKey` 字段,但实际 Claude Code 使用的是嵌套的 `env` 对象。
7
+
8
+ **修复内容**:
9
+ - 现在正确更新 `env.ANTHROPIC_BASE_URL`
10
+ - 现在正确更新 `env.ANTHROPIC_AUTH_TOKEN`
11
+ - 保留其他配置项(如 `enabledPlugins`)
12
+
13
+ **实际的 settings.json 格式**:
14
+ ```json
15
+ {
16
+ "env": {
17
+ "ANTHROPIC_AUTH_TOKEN": "your-api-key",
18
+ "ANTHROPIC_BASE_URL": "https://api.url.com"
19
+ },
20
+ "enabledPlugins": {
21
+ "document-skills@anthropic-agent-skills": true,
22
+ "code-review@claude-plugins-official": true,
23
+ "superpowers@superpowers-marketplace": true
24
+ },
25
+ "apiUrl": "",
26
+ "apiKey": ""
27
+ }
28
+ ```
29
+
30
+ ### 2. ✅ 移除 API Key 格式限制
31
+ **问题**: 之前要求 API key 必须以 `sk-ant-` 开头,不支持其他 API 提供商。
32
+
33
+ **修复内容**:
34
+ - 移除 `sk-ant-` 前缀要求
35
+ - 最小长度从 20 字符降低到 10 字符
36
+ - 支持任意格式的 API key
37
+
38
+ **示例**: 现在可以使用以下格式的 key:
39
+ - `sk-ant-xxxxx` (Anthropic 官方)
40
+ - `cr_xxxxx` (其他提供商)
41
+ - 任何自定义格式
42
+
43
+ ## 修改的文件
44
+
45
+ ### 核心代码
46
+ 1. **src/core/config.js**
47
+ - `getApiConfig()` - 从嵌套的 env 对象读取配置
48
+ - `updateApiConfig()` - 更新嵌套的 env 对象
49
+ - `createSettings()` - 创建正确的初始结构
50
+
51
+ 2. **src/core/validator.js**
52
+ - `validateApiKey()` - 放宽验证规则
53
+
54
+ ### 文档
55
+ 3. **DESIGN_SPEC.md** - 更新验证规则和示例
56
+ 4. **README.md** - 更新 settings.json 示例
57
+ 5. **IMPLEMENTATION.md** - 更新数据结构说明
58
+ 6. **CHANGELOG.md** - 记录变更历史
59
+ 7. **package.json** - 版本升级到 1.0.1
60
+
61
+ ## 使用示例
62
+
63
+ ```bash
64
+ # 现在可以添加任意格式的 API key
65
+ ccm add aihezu https://cn.aihezu.dev/api cr_b31cbdea1b3fda0a5bfcd7ae11f3c6e5b01b5bc9702a394c522c45a54a4fc886
66
+
67
+ # 切换配置
68
+ ccm use aihezu
69
+
70
+ # 查看当前配置
71
+ ccm show
72
+ ```
73
+
74
+ ## 验证测试
75
+
76
+ 建议进行以下测试:
77
+ 1. [ ] `ccm init` - 初始化
78
+ 2. [ ] `ccm add` - 添加新配置(使用非 sk-ant- 格式的 key)
79
+ 3. [ ] `ccm use` - 切换配置
80
+ 4. [ ] 检查 settings.json 文件是否正确更新 env 字段
81
+ 5. [ ] 验证其他配置(如 enabledPlugins)是否保留
82
+
83
+ ## 向后兼容性
84
+
85
+ - ✅ 如果 settings.json 不存在,会创建正确格式的文件
86
+ - ✅ 如果 settings.json 已存在但缺少 env 对象,会自动创建
87
+ - ✅ 保留所有其他配置项,不会覆盖
88
+
89
+ ---
90
+
91
+ **更新日期**: 2026-01-16
92
+ **版本**: 1.0.0 → 1.0.1
93
+
94
+ ---
95
+
96
+ ## 包名更新记录
97
+
98
+ ### 最终包名: @haiyangj/ccs
99
+
100
+ **发布命名**: `@haiyangj/ccs` (Haiyangj's Claude Config Switcher)
101
+
102
+ **原因**: 原始包名 `claude-config-manager` 已被其他开发者占用
103
+
104
+ **命名历史**:
105
+ 1. ❌ `claude-config-manager` - 已被占用
106
+ 2. ❌ `claude-profile-switcher` - 已被占用
107
+ 3. ✅ `@haiyangj/ccs` - 最终选择(作用域包)
108
+
109
+ **安装方式**:
110
+ ```bash
111
+ # 全局安装(推荐)
112
+ npm install -g @haiyangj/ccs
113
+
114
+ # 从源码安装
115
+ git clone https://github.com/haiyangj/ccs.git
116
+ cd ccs
117
+ npm install
118
+ npm link
119
+ ```
120
+
121
+ **作用域包的优势**:
122
+ - ✅ 不会与他人的包冲突
123
+ - ✅ 明确的包归属权
124
+ - ✅ 可以发布为私有包(免费)
125
+ - ✅ 更好的组织结构
126
+
package/bin/ccm.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { initCommand } from '../src/commands/init.js';
5
+ import { listCommand } from '../src/commands/list.js';
6
+ import { showCommand } from '../src/commands/show.js';
7
+ import { addCommand } from '../src/commands/add.js';
8
+ import { useCommand } from '../src/commands/use.js';
9
+ import { setCommand } from '../src/commands/set.js';
10
+ import { deleteCommand } from '../src/commands/delete.js';
11
+ import { renameCommand } from '../src/commands/rename.js';
12
+ import { exportCommand } from '../src/commands/export.js';
13
+ import { importCommand } from '../src/commands/import.js';
14
+
15
+ const program = new Command();
16
+
17
+ program
18
+ .name('ccm')
19
+ .description('CLI tool for managing Claude Code configuration profiles')
20
+ .version('1.0.0');
21
+
22
+ // Init command
23
+ program
24
+ .command('init')
25
+ .description('Initialize profile management')
26
+ .action(initCommand);
27
+
28
+ // List command
29
+ program
30
+ .command('list')
31
+ .alias('ls')
32
+ .description('List all profiles')
33
+ .action(listCommand);
34
+
35
+ // Show command
36
+ program
37
+ .command('show')
38
+ .alias('current')
39
+ .description('Show current active profile')
40
+ .action(showCommand);
41
+
42
+ // Add command
43
+ program
44
+ .command('add [name] [url] [key]')
45
+ .description('Add a new profile')
46
+ .option('--no-switch', 'Do not prompt to switch to new profile')
47
+ .action(addCommand);
48
+
49
+ // Use command
50
+ program
51
+ .command('use <name>')
52
+ .description('Switch to a profile')
53
+ .action(useCommand);
54
+
55
+ // Set command
56
+ program
57
+ .command('set [name] [url] [key]')
58
+ .description('Update an existing profile')
59
+ .action(setCommand);
60
+
61
+ // Delete command
62
+ program
63
+ .command('delete <name>')
64
+ .alias('rm')
65
+ .description('Delete a profile')
66
+ .option('-f, --force', 'Skip confirmation prompt')
67
+ .action(deleteCommand);
68
+
69
+ // Rename command
70
+ program
71
+ .command('rename <old-name> <new-name>')
72
+ .alias('mv')
73
+ .description('Rename a profile')
74
+ .action(renameCommand);
75
+
76
+ // Export command
77
+ program
78
+ .command('export [file]')
79
+ .description('Export profiles to a file (default: ./claude-profiles-backup.json)')
80
+ .option('--no-keys', 'Export without API keys')
81
+ .action(exportCommand);
82
+
83
+ // Import command
84
+ program
85
+ .command('import <file>')
86
+ .description('Import profiles from a file')
87
+ .option('--merge', 'Merge with existing profiles (overwrite conflicts)')
88
+ .action(importCommand);
89
+
90
+ // Parse arguments
91
+ program.parse(process.argv);
92
+
93
+ // Show help if no command provided
94
+ if (!process.argv.slice(2).length) {
95
+ program.outputHelp();
96
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@haiyangj/ccs",
3
+ "version": "1.0.1",
4
+ "description": "CLI tool for managing Claude Code configuration profiles",
5
+ "main": "src/index.js",
6
+ "bin": {
7
+ "ccm": "./bin/ccm.js"
8
+ },
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "keywords": [
13
+ "claude",
14
+ "config",
15
+ "cli",
16
+ "profile",
17
+ "manager"
18
+ ],
19
+ "author": "",
20
+ "license": "MIT",
21
+ "type": "module",
22
+ "dependencies": {
23
+ "chalk": "^5.6.2",
24
+ "commander": "^14.0.2",
25
+ "inquirer": "^13.2.0",
26
+ "ora": "^9.0.0"
27
+ }
28
+ }
@@ -0,0 +1,98 @@
1
+ import inquirer from 'inquirer';
2
+ import { addProfile } from '../core/profiles.js';
3
+ import { updateApiConfig } from '../core/config.js';
4
+ import { validateProfile } from '../core/validator.js';
5
+ import { success, error, maskApiKey } from '../utils/logger.js';
6
+
7
+ /**
8
+ * Add a new profile
9
+ */
10
+ export async function addCommand(name, url, key, options = {}) {
11
+ try {
12
+ // Interactive mode if arguments not provided
13
+ if (!name || !url || !key) {
14
+ const answers = await inquirer.prompt([
15
+ {
16
+ type: 'input',
17
+ name: 'name',
18
+ message: 'Profile name:',
19
+ default: name,
20
+ validate: (input) => {
21
+ const result = validateProfile(input, 'https://example.com', 'sk-ant-12345678901234567890');
22
+ if (!result.valid && result.error.includes('name')) {
23
+ return result.error;
24
+ }
25
+ return true;
26
+ }
27
+ },
28
+ {
29
+ type: 'input',
30
+ name: 'url',
31
+ message: 'API URL:',
32
+ default: url || 'https://api.anthropic.com',
33
+ validate: (input) => {
34
+ const result = validateProfile('default', input, 'sk-ant-12345678901234567890');
35
+ if (!result.valid && result.error.includes('URL')) {
36
+ return result.error;
37
+ }
38
+ return true;
39
+ }
40
+ },
41
+ {
42
+ type: 'password',
43
+ name: 'key',
44
+ message: 'API Key:',
45
+ mask: '*',
46
+ default: key,
47
+ validate: (input) => {
48
+ const result = validateProfile('default', 'https://api.anthropic.com', input);
49
+ if (!result.valid && result.error.includes('key')) {
50
+ return result.error;
51
+ }
52
+ return true;
53
+ }
54
+ }
55
+ ]);
56
+
57
+ name = answers.name;
58
+ url = answers.url;
59
+ key = answers.key;
60
+ }
61
+
62
+ // Validate inputs
63
+ const validation = validateProfile(name, url, key);
64
+ if (!validation.valid) {
65
+ error(validation.error);
66
+ process.exit(1);
67
+ }
68
+
69
+ // Add profile
70
+ const profile = addProfile(name, url, key);
71
+
72
+ success(`Profile '${name}' added successfully`);
73
+ console.log(` API URL: ${url}`);
74
+ console.log(` API Key: ${maskApiKey(key)}`);
75
+ console.log();
76
+
77
+ // Ask if user wants to switch to this profile
78
+ if (!options.noSwitch) {
79
+ const { shouldSwitch } = await inquirer.prompt([
80
+ {
81
+ type: 'confirm',
82
+ name: 'shouldSwitch',
83
+ message: 'Switch to this profile now?',
84
+ default: false
85
+ }
86
+ ]);
87
+
88
+ if (shouldSwitch) {
89
+ updateApiConfig(url, key);
90
+ success(`Switched to profile '${name}'`);
91
+ }
92
+ }
93
+
94
+ } catch (err) {
95
+ error(`Failed to add profile: ${err.message}`);
96
+ process.exit(1);
97
+ }
98
+ }
@@ -0,0 +1,50 @@
1
+ import inquirer from 'inquirer';
2
+ import { deleteProfile, getProfile } from '../core/profiles.js';
3
+ import { success, error, info } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Delete a profile
7
+ */
8
+ export async function deleteCommand(name, options = {}) {
9
+ try {
10
+ if (!name) {
11
+ error('Profile name is required');
12
+ info('Usage: ccm delete <name>');
13
+ process.exit(1);
14
+ }
15
+
16
+ // Check if profile exists
17
+ const profile = getProfile(name);
18
+ if (!profile) {
19
+ error(`Profile '${name}' does not exist`);
20
+ info('Use "ccm list" to see available profiles');
21
+ process.exit(1);
22
+ }
23
+
24
+ // Confirmation prompt unless --force flag is used
25
+ if (!options.force) {
26
+ const { confirmed } = await inquirer.prompt([
27
+ {
28
+ type: 'confirm',
29
+ name: 'confirmed',
30
+ message: `Delete profile '${name}'? This cannot be undone.`,
31
+ default: false
32
+ }
33
+ ]);
34
+
35
+ if (!confirmed) {
36
+ info('Deletion cancelled');
37
+ return;
38
+ }
39
+ }
40
+
41
+ // Delete profile (this will throw if it's the current profile)
42
+ deleteProfile(name);
43
+
44
+ success(`Profile '${name}' deleted`);
45
+
46
+ } catch (err) {
47
+ error(`Failed to delete profile: ${err.message}`);
48
+ process.exit(1);
49
+ }
50
+ }
@@ -0,0 +1,46 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { readProfiles } from '../core/profiles.js';
4
+ import { success, error, info } from '../utils/logger.js';
5
+
6
+ /**
7
+ * Export profiles to a file
8
+ */
9
+ export async function exportCommand(file, options = {}) {
10
+ try {
11
+ const profiles = readProfiles();
12
+
13
+ if (Object.keys(profiles.profiles).length === 0) {
14
+ info('No profiles to export');
15
+ return;
16
+ }
17
+
18
+ // Default file path
19
+ const outputFile = file || './claude-profiles-backup.json';
20
+ const outputPath = resolve(outputFile);
21
+
22
+ // Clone profiles to avoid modifying original
23
+ const exportData = JSON.parse(JSON.stringify(profiles));
24
+
25
+ // Remove API keys if --no-keys flag is set
26
+ if (options.noKeys) {
27
+ for (const name in exportData.profiles) {
28
+ delete exportData.profiles[name].apiKey;
29
+ }
30
+ }
31
+
32
+ // Write to file
33
+ writeFileSync(outputPath, JSON.stringify(exportData, null, 2), 'utf-8');
34
+
35
+ const profileCount = Object.keys(profiles.profiles).length;
36
+ success(`Exported ${profileCount} profile${profileCount > 1 ? 's' : ''} to ${outputPath}`);
37
+
38
+ if (options.noKeys) {
39
+ info('API keys were excluded from export');
40
+ }
41
+
42
+ } catch (err) {
43
+ error(`Failed to export profiles: ${err.message}`);
44
+ process.exit(1);
45
+ }
46
+ }
@@ -0,0 +1,99 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { readProfiles, writeProfiles } from '../core/profiles.js';
4
+ import { success, error, warning, info } from '../utils/logger.js';
5
+
6
+ /**
7
+ * Import profiles from a file
8
+ */
9
+ export async function importCommand(file, options = {}) {
10
+ try {
11
+ if (!file) {
12
+ error('Import file path is required');
13
+ info('Usage: ccm import <file>');
14
+ process.exit(1);
15
+ }
16
+
17
+ const inputPath = resolve(file);
18
+
19
+ // Check if file exists
20
+ if (!existsSync(inputPath)) {
21
+ error(`File not found: ${inputPath}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ // Read and parse import file
26
+ let importData;
27
+ try {
28
+ const fileContent = readFileSync(inputPath, 'utf-8');
29
+ importData = JSON.parse(fileContent);
30
+ } catch (err) {
31
+ error(`Failed to parse import file: ${err.message}`);
32
+ process.exit(1);
33
+ }
34
+
35
+ // Validate import data structure
36
+ if (!importData.profiles || typeof importData.profiles !== 'object') {
37
+ error('Invalid import file format: missing profiles object');
38
+ process.exit(1);
39
+ }
40
+
41
+ // Get current profiles
42
+ const currentProfiles = readProfiles();
43
+
44
+ let imported = 0;
45
+ let skipped = 0;
46
+ const conflicts = [];
47
+
48
+ // Import profiles
49
+ for (const [name, profile] of Object.entries(importData.profiles)) {
50
+ // Check for conflicts
51
+ if (currentProfiles.profiles[name] && !options.merge) {
52
+ conflicts.push(name);
53
+ skipped++;
54
+ continue;
55
+ }
56
+
57
+ // Validate required fields
58
+ if (!profile.apiUrl || !profile.apiKey) {
59
+ warning(`Skipping profile '${name}': missing apiUrl or apiKey`);
60
+ skipped++;
61
+ continue;
62
+ }
63
+
64
+ // Add profile
65
+ currentProfiles.profiles[name] = {
66
+ name,
67
+ apiUrl: profile.apiUrl,
68
+ apiKey: profile.apiKey,
69
+ createdAt: profile.createdAt || new Date().toISOString(),
70
+ lastUsed: profile.lastUsed || null
71
+ };
72
+
73
+ imported++;
74
+ }
75
+
76
+ // Write updated profiles
77
+ writeProfiles(currentProfiles);
78
+
79
+ // Report results
80
+ info(`Importing profiles from ${inputPath}...`);
81
+ console.log();
82
+
83
+ if (imported > 0) {
84
+ success(`${imported} profile${imported > 1 ? 's' : ''} imported`);
85
+ }
86
+
87
+ if (conflicts.length > 0) {
88
+ warning(`${conflicts.length} profile${conflicts.length > 1 ? 's' : ''} skipped (name conflict: ${conflicts.join(', ')})`);
89
+ info('Use --merge flag to overwrite existing profiles');
90
+ }
91
+
92
+ console.log();
93
+ info('Use "ccm list" to see all profiles');
94
+
95
+ } catch (err) {
96
+ error(`Failed to import profiles: ${err.message}`);
97
+ process.exit(1);
98
+ }
99
+ }
@@ -0,0 +1,55 @@
1
+ import { readSettings, createSettings, settingsFileExists } from '../core/config.js';
2
+ import { addProfile, hasProfiles, writeProfiles } from '../core/profiles.js';
3
+ import { success, error, warning, info, section } from '../utils/logger.js';
4
+ import { resolveClaudeDir, claudeDirExists } from '../utils/paths.js';
5
+ import { mkdirSync } from 'fs';
6
+
7
+ /**
8
+ * Initialize profile management
9
+ */
10
+ export async function initCommand() {
11
+ try {
12
+ section('Initializing Claude Config Manager...');
13
+
14
+ // Ensure .claude directory exists
15
+ const claudeDir = resolveClaudeDir();
16
+ if (!claudeDirExists()) {
17
+ mkdirSync(claudeDir, { recursive: true, mode: 0o700 });
18
+ success(`Created directory: ${claudeDir}`);
19
+ }
20
+
21
+ // Check if profiles already exist
22
+ if (hasProfiles()) {
23
+ warning('Profile management is already initialized');
24
+ info('Use "ccm list" to see existing profiles');
25
+ return;
26
+ }
27
+
28
+ // Check if settings.json exists
29
+ const settings = readSettings();
30
+
31
+ if (settings && settings.apiUrl && settings.apiKey) {
32
+ // Import existing settings as default profile
33
+ addProfile('default', settings.apiUrl, settings.apiKey);
34
+ success('Found existing settings.json');
35
+ success('Imported current config as "default" profile');
36
+ } else {
37
+ // Create empty profiles structure
38
+ writeProfiles({
39
+ version: '1.0.0',
40
+ currentProfile: null,
41
+ profiles: {}
42
+ });
43
+ success('Initialized empty profile storage');
44
+ info('Use "ccm add <name> <url> <key>" to create your first profile');
45
+ }
46
+
47
+ console.log();
48
+ info('Profile management is ready!');
49
+ info('Run "ccm --help" to see available commands');
50
+
51
+ } catch (err) {
52
+ error(`Initialization failed: ${err.message}`);
53
+ process.exit(1);
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ import { getAllProfiles, getCurrentProfileName } from '../core/profiles.js';
2
+ import { section, formatProfileName, maskApiKey, dim, info } from '../utils/logger.js';
3
+
4
+ /**
5
+ * List all profiles
6
+ */
7
+ export async function listCommand() {
8
+ try {
9
+ const profiles = getAllProfiles();
10
+ const currentProfile = getCurrentProfileName();
11
+
12
+ if (Object.keys(profiles).length === 0) {
13
+ info('No profiles found. Use "ccm add <name> <url> <key>" to create one.');
14
+ return;
15
+ }
16
+
17
+ section('Available profiles:');
18
+
19
+ // Sort profiles: current first, then by last used, then alphabetically
20
+ const sortedEntries = Object.entries(profiles).sort(([nameA, profA], [nameB, profB]) => {
21
+ if (nameA === currentProfile) return -1;
22
+ if (nameB === currentProfile) return 1;
23
+
24
+ if (profA.lastUsed && !profB.lastUsed) return -1;
25
+ if (!profA.lastUsed && profB.lastUsed) return 1;
26
+
27
+ if (profA.lastUsed && profB.lastUsed) {
28
+ return new Date(profB.lastUsed) - new Date(profA.lastUsed);
29
+ }
30
+
31
+ return nameA.localeCompare(nameB);
32
+ });
33
+
34
+ // Calculate max name length for alignment
35
+ const maxNameLength = Math.max(...sortedEntries.map(([name]) => name.length));
36
+ const padding = maxNameLength + 4;
37
+
38
+ for (const [name, profile] of sortedEntries) {
39
+ const isActive = name === currentProfile;
40
+ const displayName = formatProfileName(name, isActive);
41
+ const paddedName = displayName + ' '.repeat(padding - name.length);
42
+ const maskedKey = maskApiKey(profile.apiKey);
43
+ const activeTag = isActive ? '(active)' : '';
44
+
45
+ console.log(`${paddedName} ${profile.apiUrl.padEnd(40)} ${dim(maskedKey)} ${activeTag}`);
46
+ }
47
+
48
+ console.log();
49
+ info('Use "ccm use <name>" to switch profiles');
50
+
51
+ } catch (err) {
52
+ console.error(`Failed to list profiles: ${err.message}`);
53
+ process.exit(1);
54
+ }
55
+ }
@@ -0,0 +1,40 @@
1
+ import { renameProfile, getProfile } from '../core/profiles.js';
2
+ import { validateProfileName } from '../core/validator.js';
3
+ import { success, error, info } from '../utils/logger.js';
4
+
5
+ /**
6
+ * Rename a profile
7
+ */
8
+ export async function renameCommand(oldName, newName) {
9
+ try {
10
+ if (!oldName || !newName) {
11
+ error('Both old and new profile names are required');
12
+ info('Usage: ccm rename <old-name> <new-name>');
13
+ process.exit(1);
14
+ }
15
+
16
+ // Check if old profile exists
17
+ const profile = getProfile(oldName);
18
+ if (!profile) {
19
+ error(`Profile '${oldName}' does not exist`);
20
+ info('Use "ccm list" to see available profiles');
21
+ process.exit(1);
22
+ }
23
+
24
+ // Validate new name
25
+ const validation = validateProfileName(newName);
26
+ if (!validation.valid) {
27
+ error(validation.error);
28
+ process.exit(1);
29
+ }
30
+
31
+ // Rename profile
32
+ renameProfile(oldName, newName);
33
+
34
+ success(`Profile renamed: '${oldName}' → '${newName}'`);
35
+
36
+ } catch (err) {
37
+ error(`Failed to rename profile: ${err.message}`);
38
+ process.exit(1);
39
+ }
40
+ }