@twoer/ccx 0.1.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/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/ccx.mjs +5 -0
- package/package.json +29 -0
- package/src/commands.mjs +228 -0
- package/src/config.mjs +44 -0
- package/src/launcher.mjs +218 -0
- package/src/providers/cc-switch.mjs +35 -0
- package/src/providers/index.mjs +20 -0
- package/src/providers/json-file.mjs +28 -0
- package/src/providers/manager.mjs +52 -0
- package/src/terminals/index.mjs +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 twoer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# ccx
|
|
2
|
+
|
|
3
|
+
**C**laude **C**ode e**X**ecutor — 轻松切换 Provider 和模型。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ ccx
|
|
7
|
+
|
|
8
|
+
┌ ⚡ ccx — Claude Code eXecutor
|
|
9
|
+
│
|
|
10
|
+
│ 5 providers from cc-switch · v0.1.0
|
|
11
|
+
│ ccx add Add provider ccx edit Edit provider
|
|
12
|
+
│ ccx list List providers ccx rm Remove provider
|
|
13
|
+
│ ccx -n New window ccx help Show help
|
|
14
|
+
│
|
|
15
|
+
◆ Select provider
|
|
16
|
+
│ ● Zhipu GLM-5.1 glm-5.1
|
|
17
|
+
│ ○ Zhipu GLM-5 Turbo glm-5-turbo
|
|
18
|
+
│ ○ Claude Official
|
|
19
|
+
└
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# 克隆并链接
|
|
26
|
+
git clone https://github.com/twoer/ccx.git ~/.ccx
|
|
27
|
+
cd ~/.ccx && npm install
|
|
28
|
+
ln -s ~/.ccx/bin/ccx.mjs /usr/local/bin/ccx
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 环境要求
|
|
32
|
+
|
|
33
|
+
- macOS
|
|
34
|
+
- Node.js >= 18
|
|
35
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code)
|
|
36
|
+
|
|
37
|
+
## 使用
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 交互选择,在当前终端运行
|
|
41
|
+
ccx
|
|
42
|
+
|
|
43
|
+
# 模糊匹配 provider 名称
|
|
44
|
+
ccx glm
|
|
45
|
+
|
|
46
|
+
# 在新终端窗口中打开
|
|
47
|
+
ccx --new
|
|
48
|
+
ccx -n glm
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 命令
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 管理 Provider
|
|
55
|
+
ccx add # 交互式添加 provider
|
|
56
|
+
ccx list # 列出所有 provider
|
|
57
|
+
ccx edit # 编辑 provider
|
|
58
|
+
ccx rm # 删除 provider
|
|
59
|
+
|
|
60
|
+
# 其他
|
|
61
|
+
ccx help # 显示帮助
|
|
62
|
+
ccx --reset # 重置所有配置
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Provider 数据源
|
|
66
|
+
|
|
67
|
+
ccx 会自动检测可用的数据源:
|
|
68
|
+
|
|
69
|
+
### 1. cc-switch(自动检测)
|
|
70
|
+
|
|
71
|
+
如果你使用 [cc-switch](https://github.com/nicepkg/cc-switch),ccx 会直接读取 `~/.cc-switch/cc-switch.db`,无需额外配置。
|
|
72
|
+
|
|
73
|
+
### 2. JSON 文件(手动配置)
|
|
74
|
+
|
|
75
|
+
手动创建 `~/.config/ccx/providers.json`,或使用 `ccx add` 交互式创建:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{
|
|
79
|
+
"providers": [
|
|
80
|
+
{
|
|
81
|
+
"name": "My Provider",
|
|
82
|
+
"model": "model-name",
|
|
83
|
+
"env": {
|
|
84
|
+
"ANTHROPIC_BASE_URL": "https://...",
|
|
85
|
+
"ANTHROPIC_AUTH_TOKEN": "sk-...",
|
|
86
|
+
"ANTHROPIC_MODEL": "model-name"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 终端支持
|
|
94
|
+
|
|
95
|
+
使用 `ccx --new` 时,首次运行会自动检测已安装的终端:
|
|
96
|
+
|
|
97
|
+
- Ghostty
|
|
98
|
+
- iTerm2
|
|
99
|
+
- Warp
|
|
100
|
+
- kitty
|
|
101
|
+
- Terminal.app
|
|
102
|
+
|
|
103
|
+
配置文件:`~/.config/ccx/config.json`
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
package/bin/ccx.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@twoer/ccx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code launcher - switch providers and models with ease",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ccx": "./bin/ccx.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "node bin/ccx.mjs"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"claude",
|
|
18
|
+
"claude-code",
|
|
19
|
+
"cli",
|
|
20
|
+
"launcher"
|
|
21
|
+
],
|
|
22
|
+
"author": "twoer",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@clack/prompts": "^0.10.0",
|
|
26
|
+
"better-sqlite3": "^11.8.0",
|
|
27
|
+
"picocolors": "^1.1.1"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/commands.mjs
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { intro, outro, select, text, confirm, cancel, isCancel, log, note } from '@clack/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import * as manager from './providers/manager.mjs'
|
|
4
|
+
import { loadProviders } from './providers/index.mjs'
|
|
5
|
+
|
|
6
|
+
function guard(result) {
|
|
7
|
+
if (isCancel(result)) {
|
|
8
|
+
cancel('Cancelled')
|
|
9
|
+
process.exit(0)
|
|
10
|
+
}
|
|
11
|
+
return result
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ── ccx list ────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export async function list() {
|
|
17
|
+
intro(pc.cyan(pc.bold('⚡ ccx list')))
|
|
18
|
+
|
|
19
|
+
const { providers, source } = await loadProviders()
|
|
20
|
+
|
|
21
|
+
if (providers.length === 0) {
|
|
22
|
+
log.warn('No providers found')
|
|
23
|
+
outro(pc.dim('Run `ccx add` to add one'))
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
log.message(pc.dim(`${providers.length} providers from ${source}`))
|
|
28
|
+
|
|
29
|
+
const lines = providers.map((p, i) => {
|
|
30
|
+
const idx = pc.dim(`${String(i + 1).padStart(2)}.`)
|
|
31
|
+
const name = pc.bold(p.name)
|
|
32
|
+
const model = pc.dim(p.model)
|
|
33
|
+
const url = pc.dim(p.env?.ANTHROPIC_BASE_URL || '')
|
|
34
|
+
return `${idx} ${name} ${model}\n ${url}`
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
note(lines.join('\n'), 'Providers')
|
|
38
|
+
outro(pc.dim(`Config: ${manager.filePath}`))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── ccx add ─────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export async function add() {
|
|
44
|
+
intro(pc.cyan(pc.bold('⚡ ccx add')))
|
|
45
|
+
|
|
46
|
+
const name = guard(await text({
|
|
47
|
+
message: 'Provider name',
|
|
48
|
+
placeholder: 'e.g. Zhipu GLM-5.1',
|
|
49
|
+
validate: (v) => v.trim() ? undefined : 'Name is required',
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
const baseUrl = guard(await text({
|
|
53
|
+
message: 'API base URL',
|
|
54
|
+
placeholder: 'e.g. https://open.bigmodel.cn/api/anthropic',
|
|
55
|
+
validate: (v) => v.trim() ? undefined : 'URL is required',
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
const authToken = guard(await text({
|
|
59
|
+
message: 'API key / Auth token',
|
|
60
|
+
placeholder: 'sk-xxx or your-api-key',
|
|
61
|
+
validate: (v) => v.trim() ? undefined : 'Token is required',
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
const model = guard(await text({
|
|
65
|
+
message: 'Model name',
|
|
66
|
+
placeholder: 'e.g. glm-5.1, claude-sonnet-4-20250514',
|
|
67
|
+
validate: (v) => v.trim() ? undefined : 'Model is required',
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
const fillAll = guard(await confirm({
|
|
71
|
+
message: 'Set this model for all roles (Sonnet/Opus/Haiku)?',
|
|
72
|
+
initialValue: true,
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
const env = {
|
|
76
|
+
ANTHROPIC_BASE_URL: baseUrl.trim(),
|
|
77
|
+
ANTHROPIC_AUTH_TOKEN: authToken.trim(),
|
|
78
|
+
ANTHROPIC_MODEL: model.trim(),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (fillAll) {
|
|
82
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model.trim()
|
|
83
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model.trim()
|
|
84
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model.trim()
|
|
85
|
+
env.ANTHROPIC_REASONING_MODEL = model.trim()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const provider = {
|
|
89
|
+
name: name.trim(),
|
|
90
|
+
model: model.trim(),
|
|
91
|
+
env,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
note(
|
|
95
|
+
[
|
|
96
|
+
`${pc.bold('Name:')} ${provider.name}`,
|
|
97
|
+
`${pc.bold('Model:')} ${provider.model}`,
|
|
98
|
+
`${pc.bold('URL:')} ${env.ANTHROPIC_BASE_URL}`,
|
|
99
|
+
`${pc.bold('Key:')} ${env.ANTHROPIC_AUTH_TOKEN.slice(0, 8)}${'*'.repeat(8)}`,
|
|
100
|
+
].join('\n'),
|
|
101
|
+
'Review',
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const ok = guard(await confirm({ message: 'Add this provider?' }))
|
|
105
|
+
|
|
106
|
+
if (!ok) {
|
|
107
|
+
cancel('Cancelled')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
manager.add(provider)
|
|
112
|
+
outro(pc.green('✔') + ` Added ${pc.bold(provider.name)}`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── ccx rm ──────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
export async function rm() {
|
|
118
|
+
intro(pc.cyan(pc.bold('⚡ ccx rm')))
|
|
119
|
+
|
|
120
|
+
const providers = manager.getAll()
|
|
121
|
+
|
|
122
|
+
if (providers.length === 0) {
|
|
123
|
+
log.warn('No providers in JSON config')
|
|
124
|
+
outro(pc.dim('Nothing to remove'))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = guard(await select({
|
|
129
|
+
message: 'Remove which provider?',
|
|
130
|
+
options: providers.map((p, i) => ({
|
|
131
|
+
value: i,
|
|
132
|
+
label: p.name,
|
|
133
|
+
hint: pc.dim(p.model || p.env?.ANTHROPIC_MODEL || ''),
|
|
134
|
+
})),
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
const target = providers[result]
|
|
138
|
+
|
|
139
|
+
const ok = guard(await confirm({
|
|
140
|
+
message: `Remove ${pc.bold(target.name)}?`,
|
|
141
|
+
initialValue: false,
|
|
142
|
+
}))
|
|
143
|
+
|
|
144
|
+
if (!ok) {
|
|
145
|
+
cancel('Cancelled')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
manager.remove(result)
|
|
150
|
+
outro(pc.green('✔') + ` Removed ${pc.bold(target.name)}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── ccx edit ────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
export async function edit() {
|
|
156
|
+
intro(pc.cyan(pc.bold('⚡ ccx edit')))
|
|
157
|
+
|
|
158
|
+
const providers = manager.getAll()
|
|
159
|
+
|
|
160
|
+
if (providers.length === 0) {
|
|
161
|
+
log.warn('No providers in JSON config')
|
|
162
|
+
outro(pc.dim('Run `ccx add` to add one'))
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const index = guard(await select({
|
|
167
|
+
message: 'Edit which provider?',
|
|
168
|
+
options: providers.map((p, i) => ({
|
|
169
|
+
value: i,
|
|
170
|
+
label: p.name,
|
|
171
|
+
hint: pc.dim(p.model || p.env?.ANTHROPIC_MODEL || ''),
|
|
172
|
+
})),
|
|
173
|
+
}))
|
|
174
|
+
|
|
175
|
+
const current = providers[index]
|
|
176
|
+
const env = current.env || {}
|
|
177
|
+
|
|
178
|
+
const name = guard(await text({
|
|
179
|
+
message: 'Provider name',
|
|
180
|
+
initialValue: current.name,
|
|
181
|
+
validate: (v) => v.trim() ? undefined : 'Name is required',
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
const baseUrl = guard(await text({
|
|
185
|
+
message: 'API base URL',
|
|
186
|
+
initialValue: env.ANTHROPIC_BASE_URL || '',
|
|
187
|
+
validate: (v) => v.trim() ? undefined : 'URL is required',
|
|
188
|
+
}))
|
|
189
|
+
|
|
190
|
+
const authToken = guard(await text({
|
|
191
|
+
message: 'API key / Auth token',
|
|
192
|
+
initialValue: env.ANTHROPIC_AUTH_TOKEN || '',
|
|
193
|
+
validate: (v) => v.trim() ? undefined : 'Token is required',
|
|
194
|
+
}))
|
|
195
|
+
|
|
196
|
+
const model = guard(await text({
|
|
197
|
+
message: 'Model name',
|
|
198
|
+
initialValue: env.ANTHROPIC_MODEL || current.model || '',
|
|
199
|
+
validate: (v) => v.trim() ? undefined : 'Model is required',
|
|
200
|
+
}))
|
|
201
|
+
|
|
202
|
+
const fillAll = guard(await confirm({
|
|
203
|
+
message: 'Set this model for all roles (Sonnet/Opus/Haiku)?',
|
|
204
|
+
initialValue: true,
|
|
205
|
+
}))
|
|
206
|
+
|
|
207
|
+
const newEnv = {
|
|
208
|
+
ANTHROPIC_BASE_URL: baseUrl.trim(),
|
|
209
|
+
ANTHROPIC_AUTH_TOKEN: authToken.trim(),
|
|
210
|
+
ANTHROPIC_MODEL: model.trim(),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (fillAll) {
|
|
214
|
+
newEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = model.trim()
|
|
215
|
+
newEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = model.trim()
|
|
216
|
+
newEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = model.trim()
|
|
217
|
+
newEnv.ANTHROPIC_REASONING_MODEL = model.trim()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const updated = {
|
|
221
|
+
name: name.trim(),
|
|
222
|
+
model: model.trim(),
|
|
223
|
+
env: newEnv,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
manager.update(index, updated)
|
|
227
|
+
outro(pc.green('✔') + ` Updated ${pc.bold(updated.name)}`)
|
|
228
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'ccx')
|
|
6
|
+
const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
|
|
7
|
+
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
10
|
+
mkdirSync(CONFIG_DIR, { recursive: true })
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function load() {
|
|
15
|
+
ensureDir()
|
|
16
|
+
if (!existsSync(CONFIG_FILE)) return {}
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'))
|
|
19
|
+
} catch {
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function save(config) {
|
|
25
|
+
ensureDir()
|
|
26
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function get(key) {
|
|
30
|
+
return load()[key]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function set(key, value) {
|
|
34
|
+
const config = load()
|
|
35
|
+
config[key] = value
|
|
36
|
+
save(config)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function reset() {
|
|
40
|
+
save({})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const configDir = CONFIG_DIR
|
|
44
|
+
export const configFile = CONFIG_FILE
|
package/src/launcher.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { intro, outro, select, cancel, isCancel, log } from '@clack/prompts'
|
|
2
|
+
import pc from 'picocolors'
|
|
3
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { tmpdir } from 'node:os'
|
|
6
|
+
import { spawn } from 'node:child_process'
|
|
7
|
+
import { loadProviders } from './providers/index.mjs'
|
|
8
|
+
import { detectTerminals, getTerminal } from './terminals/index.mjs'
|
|
9
|
+
import * as config from './config.mjs'
|
|
10
|
+
import * as commands from './commands.mjs'
|
|
11
|
+
|
|
12
|
+
const VERSION = '0.1.0'
|
|
13
|
+
|
|
14
|
+
const SUBCOMMANDS = ['list', 'ls', 'add', 'rm', 'remove', 'edit', 'help']
|
|
15
|
+
|
|
16
|
+
function parseArgs(argv) {
|
|
17
|
+
const flags = { newWindow: false, help: false, version: false, reset: false }
|
|
18
|
+
let command = null
|
|
19
|
+
let query = ''
|
|
20
|
+
|
|
21
|
+
for (const arg of argv) {
|
|
22
|
+
switch (arg) {
|
|
23
|
+
case '--new': case '-n': flags.newWindow = true; break
|
|
24
|
+
case '--help': case '-h': flags.help = true; break
|
|
25
|
+
case '--version': case '-v': flags.version = true; break
|
|
26
|
+
case '--reset': flags.reset = true; break
|
|
27
|
+
default:
|
|
28
|
+
if (arg.startsWith('-')) {
|
|
29
|
+
console.error(`Unknown option: ${arg}`)
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
if (!command && SUBCOMMANDS.includes(arg)) {
|
|
33
|
+
command = arg
|
|
34
|
+
} else {
|
|
35
|
+
query = arg
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { flags, command, query }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function showHelp() {
|
|
44
|
+
console.log(`
|
|
45
|
+
${pc.cyan(pc.bold('⚡ ccx'))} ${pc.dim(`v${VERSION}`)}
|
|
46
|
+
${pc.dim('Claude Code launcher')}
|
|
47
|
+
|
|
48
|
+
${pc.bold('Usage:')} ccx [command] [options] [provider-name]
|
|
49
|
+
|
|
50
|
+
${pc.bold('Commands:')}
|
|
51
|
+
${pc.cyan('list')}, ${pc.cyan('ls')} List all providers
|
|
52
|
+
${pc.cyan('add')} Add a new provider
|
|
53
|
+
${pc.cyan('edit')} Edit an existing provider
|
|
54
|
+
${pc.cyan('rm')} Remove a provider
|
|
55
|
+
|
|
56
|
+
${pc.bold('Options:')}
|
|
57
|
+
${pc.cyan('-n')}, ${pc.cyan('--new')} Open in a new terminal window
|
|
58
|
+
${pc.cyan('-h')}, ${pc.cyan('--help')} Show this help
|
|
59
|
+
${pc.cyan('-v')}, ${pc.cyan('--version')} Show version
|
|
60
|
+
${pc.cyan('--reset')} Reset all configuration
|
|
61
|
+
|
|
62
|
+
${pc.bold('Examples:')}
|
|
63
|
+
${pc.dim('$')} ccx ${pc.dim('# Interactive select, current terminal')}
|
|
64
|
+
${pc.dim('$')} ccx glm ${pc.dim('# Fuzzy match provider name')}
|
|
65
|
+
${pc.dim('$')} ccx --new ${pc.dim('# Interactive select, new window')}
|
|
66
|
+
${pc.dim('$')} ccx add ${pc.dim('# Add a new provider')}
|
|
67
|
+
${pc.dim('$')} ccx list ${pc.dim('# List all providers')}
|
|
68
|
+
${pc.dim('$')} ccx edit ${pc.dim('# Edit a provider')}
|
|
69
|
+
${pc.dim('$')} ccx rm ${pc.dim('# Remove a provider')}
|
|
70
|
+
|
|
71
|
+
${pc.bold('Providers:')}
|
|
72
|
+
${pc.cyan('cc-switch')} ${pc.dim('auto-detected from ~/.cc-switch/cc-switch.db')}
|
|
73
|
+
${pc.cyan('JSON file')} ${pc.dim('configure at ~/.config/ccx/providers.json')}
|
|
74
|
+
|
|
75
|
+
${pc.bold('Config:')} ${pc.dim('~/.config/ccx/config.json')}
|
|
76
|
+
`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function writeTempSettings(env) {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), 'ccx-'))
|
|
81
|
+
const file = join(dir, 'settings.json')
|
|
82
|
+
writeFileSync(file, JSON.stringify({ env }, null, 2))
|
|
83
|
+
return file
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function selectTerminal() {
|
|
87
|
+
const saved = config.get('terminal')
|
|
88
|
+
if (saved) {
|
|
89
|
+
const t = getTerminal(saved)
|
|
90
|
+
if (t) return t
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const available = detectTerminals()
|
|
94
|
+
if (available.length === 1) {
|
|
95
|
+
config.set('terminal', available[0].name)
|
|
96
|
+
return available[0]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const result = await select({
|
|
100
|
+
message: 'Select default terminal for new windows',
|
|
101
|
+
options: available.map(t => ({ value: t.name, label: t.name })),
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (isCancel(result)) {
|
|
105
|
+
cancel('Cancelled')
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
config.set('terminal', result)
|
|
110
|
+
return getTerminal(result)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function run(argv) {
|
|
114
|
+
const { flags, command, query } = parseArgs(argv)
|
|
115
|
+
|
|
116
|
+
if (flags.version) {
|
|
117
|
+
console.log(`ccx ${VERSION}`)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (flags.help) {
|
|
122
|
+
showHelp()
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (flags.reset) {
|
|
127
|
+
config.reset()
|
|
128
|
+
log.success('Config reset')
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Handle subcommands
|
|
133
|
+
if (command) {
|
|
134
|
+
switch (command) {
|
|
135
|
+
case 'help': showHelp(); return
|
|
136
|
+
case 'list': case 'ls': return commands.list()
|
|
137
|
+
case 'add': return commands.add()
|
|
138
|
+
case 'rm': case 'remove': return commands.rm()
|
|
139
|
+
case 'edit': return commands.edit()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Default: launch claude
|
|
144
|
+
intro(`${pc.cyan(pc.bold('⚡ ccx'))} ${pc.dim('— Claude Code eXecutor')}`)
|
|
145
|
+
const { providers, source } = await loadProviders()
|
|
146
|
+
log.message(
|
|
147
|
+
pc.dim(`${providers.length} providers from ${source || 'none'} · v${VERSION}\n`) +
|
|
148
|
+
pc.dim(` ccx ${pc.cyan('add')} Add provider ccx ${pc.cyan('edit')} Edit provider\n`) +
|
|
149
|
+
pc.dim(` ccx ${pc.cyan('list')} List providers ccx ${pc.cyan('rm')} Remove provider\n`) +
|
|
150
|
+
pc.dim(` ccx ${pc.cyan('-n')} New window ccx ${pc.cyan('help')} Show help`),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if (providers.length === 0) {
|
|
154
|
+
log.error('No providers found')
|
|
155
|
+
log.message('')
|
|
156
|
+
log.message(` ${pc.cyan('1.')} Run ${pc.bold('ccx add')} to add a provider`)
|
|
157
|
+
log.message(` ${pc.cyan('2.')} Or install ${pc.bold('cc-switch')} for auto-detection`)
|
|
158
|
+
log.message('')
|
|
159
|
+
cancel('Setup a provider first')
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Select provider
|
|
164
|
+
let selected
|
|
165
|
+
|
|
166
|
+
if (query) {
|
|
167
|
+
const lowerQuery = query.toLowerCase()
|
|
168
|
+
selected = providers.find(p =>
|
|
169
|
+
p.name.toLowerCase().includes(lowerQuery) ||
|
|
170
|
+
p.model.toLowerCase().includes(lowerQuery),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if (!selected) {
|
|
174
|
+
log.warn(`No match for "${query}"`)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!selected) {
|
|
179
|
+
const result = await select({
|
|
180
|
+
message: 'Select provider',
|
|
181
|
+
options: providers.map(p => ({
|
|
182
|
+
value: p.id,
|
|
183
|
+
label: p.name,
|
|
184
|
+
hint: pc.dim(p.model),
|
|
185
|
+
})),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
if (isCancel(result)) {
|
|
189
|
+
cancel('Cancelled')
|
|
190
|
+
process.exit(0)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
selected = providers.find(p => p.id === result)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Write temp settings
|
|
197
|
+
const settingsFile = writeTempSettings(selected.env)
|
|
198
|
+
|
|
199
|
+
if (flags.newWindow) {
|
|
200
|
+
const terminal = await selectTerminal()
|
|
201
|
+
const cwd = process.cwd()
|
|
202
|
+
const cmd = `cd '${cwd}'; echo '=== Claude Code [${selected.name}] ==='; echo; claude --settings '${settingsFile}'; rm -f '${settingsFile}'; exec bash`
|
|
203
|
+
terminal.open(cmd)
|
|
204
|
+
outro(`${pc.green('⚡')} ${selected.name} ${pc.dim(`(${selected.model})`)} → ${pc.dim(terminal.name)}`)
|
|
205
|
+
} else {
|
|
206
|
+
outro(`${pc.green('⚡')} ${selected.name} ${pc.dim(`(${selected.model})`)}`)
|
|
207
|
+
|
|
208
|
+
const child = spawn('claude', ['--settings', settingsFile], {
|
|
209
|
+
stdio: 'inherit',
|
|
210
|
+
env: { ...process.env },
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
child.on('exit', (code) => {
|
|
214
|
+
try { rmSync(settingsFile, { force: true }) } catch {}
|
|
215
|
+
process.exit(code ?? 0)
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const DB_PATH = join(homedir(), '.cc-switch', 'cc-switch.db')
|
|
6
|
+
|
|
7
|
+
export function detect() {
|
|
8
|
+
return existsSync(DB_PATH)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function list() {
|
|
12
|
+
const Database = (await import('better-sqlite3')).default
|
|
13
|
+
const db = new Database(DB_PATH, { readonly: true })
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const rows = db.prepare(`
|
|
17
|
+
SELECT id, name, json_extract(settings_config, '$.env.ANTHROPIC_MODEL') as model,
|
|
18
|
+
json_extract(settings_config, '$.env') as env
|
|
19
|
+
FROM providers
|
|
20
|
+
WHERE app_type = 'claude' AND settings_config LIKE '%"env"%'
|
|
21
|
+
ORDER BY sort_index
|
|
22
|
+
`).all()
|
|
23
|
+
|
|
24
|
+
return rows.map(row => ({
|
|
25
|
+
id: row.id,
|
|
26
|
+
name: row.name,
|
|
27
|
+
model: row.model || 'unknown',
|
|
28
|
+
env: JSON.parse(row.env || '{}'),
|
|
29
|
+
}))
|
|
30
|
+
} finally {
|
|
31
|
+
db.close()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const source = 'cc-switch'
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as ccSwitch from './cc-switch.mjs'
|
|
2
|
+
import * as jsonFile from './json-file.mjs'
|
|
3
|
+
|
|
4
|
+
const sources = [ccSwitch, jsonFile]
|
|
5
|
+
|
|
6
|
+
export async function loadProviders() {
|
|
7
|
+
for (const source of sources) {
|
|
8
|
+
if (source.detect()) {
|
|
9
|
+
try {
|
|
10
|
+
const providers = await source.list()
|
|
11
|
+
if (providers.length > 0) {
|
|
12
|
+
return { providers, source: source.source }
|
|
13
|
+
}
|
|
14
|
+
} catch {
|
|
15
|
+
// Try next source
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return { providers: [], source: null }
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const PROVIDERS_FILE = join(
|
|
6
|
+
process.env.XDG_CONFIG_HOME || join(homedir(), '.config'),
|
|
7
|
+
'ccx',
|
|
8
|
+
'providers.json',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
export function detect() {
|
|
12
|
+
return existsSync(PROVIDERS_FILE)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function list() {
|
|
16
|
+
const data = JSON.parse(readFileSync(PROVIDERS_FILE, 'utf-8'))
|
|
17
|
+
const providers = data.providers || []
|
|
18
|
+
|
|
19
|
+
return providers.map((p, i) => ({
|
|
20
|
+
id: `json:${i}`,
|
|
21
|
+
name: p.name,
|
|
22
|
+
model: p.model || p.env?.ANTHROPIC_MODEL || 'unknown',
|
|
23
|
+
env: p.env || {},
|
|
24
|
+
}))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const source = 'json'
|
|
28
|
+
export const filePath = PROVIDERS_FILE
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, dirname } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const PROVIDERS_FILE = join(
|
|
6
|
+
process.env.XDG_CONFIG_HOME || join(homedir(), '.config'),
|
|
7
|
+
'ccx',
|
|
8
|
+
'providers.json',
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
function ensureFile() {
|
|
12
|
+
const dir = dirname(PROVIDERS_FILE)
|
|
13
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
14
|
+
if (!existsSync(PROVIDERS_FILE)) {
|
|
15
|
+
writeFileSync(PROVIDERS_FILE, JSON.stringify({ providers: [] }, null, 2) + '\n')
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function load() {
|
|
20
|
+
ensureFile()
|
|
21
|
+
return JSON.parse(readFileSync(PROVIDERS_FILE, 'utf-8'))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function save(data) {
|
|
25
|
+
ensureFile()
|
|
26
|
+
writeFileSync(PROVIDERS_FILE, JSON.stringify(data, null, 2) + '\n')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getAll() {
|
|
30
|
+
return load().providers || []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function add(provider) {
|
|
34
|
+
const data = load()
|
|
35
|
+
data.providers = data.providers || []
|
|
36
|
+
data.providers.push(provider)
|
|
37
|
+
save(data)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function remove(index) {
|
|
41
|
+
const data = load()
|
|
42
|
+
data.providers.splice(index, 1)
|
|
43
|
+
save(data)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function update(index, provider) {
|
|
47
|
+
const data = load()
|
|
48
|
+
data.providers[index] = provider
|
|
49
|
+
save(data)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const filePath = PROVIDERS_FILE
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { execSync } from 'node:child_process'
|
|
3
|
+
|
|
4
|
+
const terminals = [
|
|
5
|
+
{
|
|
6
|
+
name: 'Ghostty',
|
|
7
|
+
detect: () => existsSync('/Applications/Ghostty.app'),
|
|
8
|
+
open: (cmd) => execSync(`open -na Ghostty.app --args -e bash -c "${cmd}"`),
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
name: 'iTerm2',
|
|
12
|
+
detect: () => existsSync('/Applications/iTerm.app'),
|
|
13
|
+
open: (cmd) => execSync(`osascript -e 'tell application "iTerm" to create window with default profile command "bash -c \\"${cmd}\\""'`),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: 'Warp',
|
|
17
|
+
detect: () => existsSync('/Applications/Warp.app'),
|
|
18
|
+
open: (cmd) => execSync(`open -na Warp.app --args bash -c "${cmd}"`),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'kitty',
|
|
22
|
+
detect: () => existsSync('/Applications/kitty.app'),
|
|
23
|
+
open: (cmd) => execSync(`/Applications/kitty.app/Contents/MacOS/kitty bash -c "${cmd}" &`),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: 'Terminal',
|
|
27
|
+
detect: () => true,
|
|
28
|
+
open: (cmd) => execSync(`osascript -e 'tell application "Terminal" to do script "${cmd}"'`),
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
export function detectTerminals() {
|
|
33
|
+
return terminals.filter(t => t.detect())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getTerminal(name) {
|
|
37
|
+
return terminals.find(t => t.name === name)
|
|
38
|
+
}
|