@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 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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { run } from '../src/launcher.mjs'
4
+
5
+ run(process.argv.slice(2))
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
+ }
@@ -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
@@ -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
+ }