@nectarcrm/ccx 0.0.0-stage → 0.1.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.
- package/README.md +33 -2
- package/bin/ccx.js +395 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ccx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Escolhe qual conta Claude este computador usa. Funciona junto com o app **Contas Claude** da Nectar.
|
|
4
|
+
|
|
5
|
+
## Instalar
|
|
6
|
+
|
|
7
|
+
Precisa de Node 18.17 ou mais novo. Funciona no macOS, Windows e Linux.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install -g @nectarcrm/ccx
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usar
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
ccx login # autoriza este computador (abre o navegador) e escolhe a conta
|
|
17
|
+
ccx # mostra suas contas e troca a conta deste computador
|
|
18
|
+
ccx claude-infra # troca direto
|
|
19
|
+
ccx status # conta atual e uso de cada conta
|
|
20
|
+
ccx sair # o claude volta ao seu login normal
|
|
21
|
+
ccx logout # sai do app neste computador
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Depois de escolher, **qualquer `claude`** (terminal, VS Code, JetBrains) usa a conta escolhida.
|
|
25
|
+
Claudes que já estavam abertos continuam na conta anterior até serem fechados.
|
|
26
|
+
|
|
27
|
+
## O que o ccx faz no seu computador
|
|
28
|
+
|
|
29
|
+
- Grava o token da conta e a telemetria (tokens gastos vão pro app) no `env` do `~/.claude/settings.json`.
|
|
30
|
+
Ele só mexe nas chaves que ele mesmo escreveu.
|
|
31
|
+
- Guarda a chave do computador no Keychain (macOS), protegida pelo Windows (DPAPI) ou, no Linux,
|
|
32
|
+
no cofre do sistema (`secret-tool`) — sem ele, num arquivo que só o seu usuário lê.
|
|
33
|
+
- Liga um vigia que roda a cada 5 minutos (launchd no macOS, Agendador de Tarefas no Windows, timer do systemd ou crontab no Linux):
|
|
34
|
+
avisa o app, atualiza o token se o admin trocou e tira a conta se o acesso for removido.
|
package/bin/ccx.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ccx — escolhe qual conta Claude este computador usa. Conectado ao app Contas Claude.
|
|
3
|
+
//
|
|
4
|
+
// ccx login autoriza este computador (abre o navegador) e escolhe a conta
|
|
5
|
+
// (outro app: ccx login https://endereco)
|
|
6
|
+
// ccx mostra suas contas e troca a conta deste computador
|
|
7
|
+
// ccx <conta> troca direto (nome curto, ex.: claude-infra)
|
|
8
|
+
// ccx status conta atual e uso das contas liberadas pra você
|
|
9
|
+
// ccx sair tira a conta deste computador (o claude volta ao login normal)
|
|
10
|
+
// ccx logout sai do app: tira a conta, a autorização e o vigia
|
|
11
|
+
// ccx --version versão instalada (atualizar: npm install -g @nectarcrm/ccx@latest)
|
|
12
|
+
//
|
|
13
|
+
// A conta escolhida vai pro settings.json do Claude Code, então qualquer `claude` aberto
|
|
14
|
+
// (terminal, VS Code, JetBrains) usa essa conta. Claudes já abertos continuam na anterior.
|
|
15
|
+
// O vigia roda sozinho a cada 5 min: avisa o app, atualiza o token se o admin trocou
|
|
16
|
+
// e tira a conta se o acesso for removido. Os tokens gastos vão pro app pela telemetria.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process'
|
|
19
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
20
|
+
import { createHash } from 'node:crypto'
|
|
21
|
+
import { homedir, hostname } from 'node:os'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
const DIR = join(homedir(), '.config', 'ccx')
|
|
26
|
+
const APP = join(DIR, 'app.json')
|
|
27
|
+
const LOG = join(DIR, 'vigia.log')
|
|
28
|
+
const SETTINGS = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json')
|
|
29
|
+
const WIN = process.platform === 'win32'
|
|
30
|
+
const MAC = process.platform === 'darwin'
|
|
31
|
+
const DEFAULT_URL = 'https://ccx.nectarcrm.com.br'
|
|
32
|
+
const SERVICE = 'ccx-app' // Keychain: guarda a chave do computador
|
|
33
|
+
const AGENT_MINUTES = 5
|
|
34
|
+
const AGENT_ID = 'br.com.nectarcrm.ccx' // launchd (Mac) / nome da tarefa (Windows)
|
|
35
|
+
const SELF = fileURLToPath(import.meta.url)
|
|
36
|
+
|
|
37
|
+
const readJson = (p, fallback) => (existsSync(p) ? JSON.parse(readFileSync(p, 'utf8')) : fallback)
|
|
38
|
+
const writeJson = (p, v) => { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(v, null, 2) + '\n') }
|
|
39
|
+
const fingerprint = t => createHash('sha256').update(t).digest('hex').slice(0, 16)
|
|
40
|
+
|
|
41
|
+
// ---------- segredo (chave do computador) ----------
|
|
42
|
+
// macOS: Keychain. Windows: DPAPI (só o usuário do Windows abre). Linux: cofre do sistema
|
|
43
|
+
// (secret-tool/libsecret) ou, sem ele (servidor, WSL), arquivo que só o usuário lê.
|
|
44
|
+
// O segredo sempre passa por stdin, nunca pela linha de comando.
|
|
45
|
+
const ps = (script, input) => execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { input, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
|
|
46
|
+
const KEY_FILE = join(DIR, 'chave') // Windows (DPAPI) e Linux sem cofre
|
|
47
|
+
const SECRET_TOOL = ['service', SERVICE, 'account', 'chave']
|
|
48
|
+
|
|
49
|
+
function secretGet() {
|
|
50
|
+
try {
|
|
51
|
+
if (WIN) return ps('$s = [Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', readFileSync(KEY_FILE, 'utf8'))
|
|
52
|
+
if (MAC) return execFileSync('security', ['find-generic-password', '-s', SERVICE, '-a', 'chave', '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
53
|
+
if (existsSync(KEY_FILE)) return readFileSync(KEY_FILE, 'utf8').trim()
|
|
54
|
+
return execFileSync('secret-tool', ['lookup', ...SECRET_TOOL], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null
|
|
55
|
+
}
|
|
56
|
+
catch { return null }
|
|
57
|
+
}
|
|
58
|
+
function secretSet(value) {
|
|
59
|
+
if (WIN) {
|
|
60
|
+
mkdirSync(DIR, { recursive: true })
|
|
61
|
+
writeFileSync(KEY_FILE, ps('[Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString', value))
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
if (MAC) {
|
|
65
|
+
// Via `security -i` (stdin) para o segredo não aparecer na lista de processos
|
|
66
|
+
execFileSync('security', ['-i'], { input: `add-generic-password -U -s ${SERVICE} -a chave -w ${value}\n`, stdio: ['pipe', 'ignore', 'inherit'] })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
execFileSync('secret-tool', ['store', '--label=ccx', ...SECRET_TOOL], { input: value, stdio: ['pipe', 'ignore', 'ignore'] })
|
|
71
|
+
rmSync(KEY_FILE, { force: true })
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
mkdirSync(DIR, { recursive: true })
|
|
75
|
+
writeFileSync(KEY_FILE, value, { mode: 0o600 })
|
|
76
|
+
chmodSync(KEY_FILE, 0o600)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function secretDelete() {
|
|
80
|
+
if (WIN) return rmSync(KEY_FILE, { force: true })
|
|
81
|
+
if (MAC) {
|
|
82
|
+
try { execFileSync('security', ['delete-generic-password', '-s', SERVICE, '-a', 'chave'], { stdio: 'ignore' }) }
|
|
83
|
+
catch {}
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
rmSync(KEY_FILE, { force: true })
|
|
87
|
+
try { execFileSync('secret-tool', ['clear', ...SECRET_TOOL], { stdio: 'ignore' }) }
|
|
88
|
+
catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------- settings.json do Claude Code ----------
|
|
92
|
+
// O ccx só mexe nas chaves de `env` que ele mesmo escreveu (lista guardada em app.json).
|
|
93
|
+
function readSettings() {
|
|
94
|
+
try { return readJson(SETTINGS, {}) }
|
|
95
|
+
catch { die(`Não consegui ler ${SETTINGS} (JSON inválido). Corrija o arquivo e rode o ccx de novo.`) }
|
|
96
|
+
}
|
|
97
|
+
function writeSettings(s) {
|
|
98
|
+
writeJson(SETTINGS, s)
|
|
99
|
+
if (!WIN) chmodSync(SETTINGS, 0o600) // agora guarda um token
|
|
100
|
+
}
|
|
101
|
+
function applyAccount(app, key, account, token) {
|
|
102
|
+
const s = readSettings()
|
|
103
|
+
const env = { ...s.env }
|
|
104
|
+
const vars = {
|
|
105
|
+
CLAUDE_CODE_OAUTH_TOKEN: token,
|
|
106
|
+
// Telemetria: cada resposta de qualquer claude manda os tokens pro app, marcada com a conta
|
|
107
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: '1',
|
|
108
|
+
OTEL_LOGS_EXPORTER: 'otlp',
|
|
109
|
+
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: 'http/json',
|
|
110
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: `${app.url}/api/cli/otel/v1/logs`,
|
|
111
|
+
OTEL_EXPORTER_OTLP_LOGS_HEADERS: `Authorization=Bearer ${key}`,
|
|
112
|
+
OTEL_RESOURCE_ATTRIBUTES: `ccx.account_id=${account.id}`,
|
|
113
|
+
}
|
|
114
|
+
// Métricas não são usadas; desliga só se a pessoa não configurou as dela
|
|
115
|
+
if (!('OTEL_METRICS_EXPORTER' in env) || app.owned?.includes('OTEL_METRICS_EXPORTER')) vars.OTEL_METRICS_EXPORTER = 'none'
|
|
116
|
+
writeSettings({ ...s, env: { ...env, ...vars } })
|
|
117
|
+
return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars) }
|
|
118
|
+
}
|
|
119
|
+
function removeAccount(app) {
|
|
120
|
+
if (app?.owned?.length && existsSync(SETTINGS)) {
|
|
121
|
+
const s = readSettings()
|
|
122
|
+
const env = { ...s.env }
|
|
123
|
+
for (const k of app.owned) delete env[k]
|
|
124
|
+
if (Object.keys(env).length) s.env = env
|
|
125
|
+
else delete s.env
|
|
126
|
+
writeSettings(s)
|
|
127
|
+
}
|
|
128
|
+
return { ...app, account: null, token_fp: null, owned: [] }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------- vigia em segundo plano ----------
|
|
132
|
+
// macOS: launchd. Windows: Agendador de Tarefas. Linux: timer do systemd do usuário ou, sem ele, crontab.
|
|
133
|
+
const PLIST = join(homedir(), 'Library', 'LaunchAgents', `${AGENT_ID}.plist`)
|
|
134
|
+
const UNIT_DIR = join(homedir(), '.config', 'systemd', 'user')
|
|
135
|
+
const CRON_TAG = '# ccx-vigia'
|
|
136
|
+
const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'ignore'] }).toString()
|
|
137
|
+
const crontabWithout = () => { try { return run('crontab', ['-l']).split('\n').filter(l => l && !l.includes(CRON_TAG)) } catch { return [] } }
|
|
138
|
+
|
|
139
|
+
function installLinuxAgent() {
|
|
140
|
+
const cmdLine = `"${process.execPath}" "${SELF}" vigia`
|
|
141
|
+
try {
|
|
142
|
+
run('systemctl', ['--user', 'show-environment']) // tem systemd do usuário?
|
|
143
|
+
mkdirSync(UNIT_DIR, { recursive: true })
|
|
144
|
+
writeFileSync(join(UNIT_DIR, 'ccx-vigia.service'), `[Unit]\nDescription=ccx: vigia da conta Claude\n\n[Service]\nType=oneshot\nExecStart=${cmdLine}\n`)
|
|
145
|
+
writeFileSync(join(UNIT_DIR, 'ccx-vigia.timer'), `[Unit]\nDescription=ccx: vigia a cada ${AGENT_MINUTES} min\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec=${AGENT_MINUTES}min\n\n[Install]\nWantedBy=timers.target\n`)
|
|
146
|
+
run('systemctl', ['--user', 'daemon-reload'])
|
|
147
|
+
run('systemctl', ['--user', 'enable', '--now', 'ccx-vigia.timer'])
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
catch {}
|
|
151
|
+
// Sem systemd do usuário: crontab
|
|
152
|
+
run('crontab', ['-'], [...crontabWithout(), `*/${AGENT_MINUTES} * * * * ${cmdLine} 2>>"${LOG}" ${CRON_TAG}`, ''].join('\n'))
|
|
153
|
+
return true
|
|
154
|
+
}
|
|
155
|
+
function uninstallLinuxAgent() {
|
|
156
|
+
try {
|
|
157
|
+
run('systemctl', ['--user', 'disable', '--now', 'ccx-vigia.timer'])
|
|
158
|
+
}
|
|
159
|
+
catch {}
|
|
160
|
+
rmSync(join(UNIT_DIR, 'ccx-vigia.service'), { force: true })
|
|
161
|
+
rmSync(join(UNIT_DIR, 'ccx-vigia.timer'), { force: true })
|
|
162
|
+
try { run('systemctl', ['--user', 'daemon-reload']) }
|
|
163
|
+
catch {}
|
|
164
|
+
try {
|
|
165
|
+
const lines = crontabWithout()
|
|
166
|
+
if (lines.length) run('crontab', ['-'], [...lines, ''].join('\n'))
|
|
167
|
+
else run('crontab', ['-r'])
|
|
168
|
+
}
|
|
169
|
+
catch {}
|
|
170
|
+
}
|
|
171
|
+
function installAgent() {
|
|
172
|
+
try {
|
|
173
|
+
if (MAC) {
|
|
174
|
+
mkdirSync(dirname(PLIST), { recursive: true })
|
|
175
|
+
writeFileSync(PLIST, `<?xml version="1.0" encoding="UTF-8"?>
|
|
176
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
177
|
+
<plist version="1.0"><dict>
|
|
178
|
+
<key>Label</key><string>${AGENT_ID}</string>
|
|
179
|
+
<key>ProgramArguments</key><array><string>${process.execPath}</string><string>${SELF}</string><string>vigia</string></array>
|
|
180
|
+
<key>StartInterval</key><integer>${AGENT_MINUTES * 60}</integer>
|
|
181
|
+
<key>RunAtLoad</key><true/>
|
|
182
|
+
<key>StandardErrorPath</key><string>${LOG}</string>
|
|
183
|
+
</dict></plist>
|
|
184
|
+
`)
|
|
185
|
+
const domain = `gui/${process.getuid()}`
|
|
186
|
+
try { execFileSync('launchctl', ['bootout', domain, PLIST], { stdio: 'ignore' }) }
|
|
187
|
+
catch {}
|
|
188
|
+
execFileSync('launchctl', ['bootstrap', domain, PLIST], { stdio: 'ignore' })
|
|
189
|
+
return true
|
|
190
|
+
}
|
|
191
|
+
if (WIN) {
|
|
192
|
+
execFileSync('schtasks', ['/Create', '/F', '/SC', 'MINUTE', '/MO', String(AGENT_MINUTES), '/TN', AGENT_ID,
|
|
193
|
+
'/TR', `"${process.execPath}" "${SELF}" vigia`], { stdio: 'ignore' })
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
return installLinuxAgent()
|
|
197
|
+
}
|
|
198
|
+
catch (e) { console.error(c(33, `Não consegui ligar o vigia automático (${e.message}).`)) }
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
function uninstallAgent() {
|
|
202
|
+
try {
|
|
203
|
+
if (MAC) {
|
|
204
|
+
try { execFileSync('launchctl', ['bootout', `gui/${process.getuid()}`, PLIST], { stdio: 'ignore' }) }
|
|
205
|
+
catch {}
|
|
206
|
+
rmSync(PLIST, { force: true })
|
|
207
|
+
}
|
|
208
|
+
else if (WIN) execFileSync('schtasks', ['/Delete', '/F', '/TN', AGENT_ID], { stdio: 'ignore' })
|
|
209
|
+
else uninstallLinuxAgent()
|
|
210
|
+
}
|
|
211
|
+
catch {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Uma passada do vigia: avisa o app, atualiza o token ou tira a conta
|
|
215
|
+
async function agentOnce() {
|
|
216
|
+
const app = readJson(APP, null)
|
|
217
|
+
const key = app && secretGet()
|
|
218
|
+
if (!app || !key) return
|
|
219
|
+
const log = msg => appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`)
|
|
220
|
+
if (new Date(app.expires_at) < new Date()) {
|
|
221
|
+
writeJson(APP, removeAccount(app))
|
|
222
|
+
return log('login vencido: conta tirada deste computador')
|
|
223
|
+
}
|
|
224
|
+
const r = await api(app, key, 'POST', '/api/cli/heartbeat', { account_id: app.account?.id ?? null }, 20_000)
|
|
225
|
+
if (r.status === 401) {
|
|
226
|
+
writeJson(APP, removeAccount(app))
|
|
227
|
+
secretDelete()
|
|
228
|
+
uninstallAgent()
|
|
229
|
+
return log(`acesso removido (${r.data.message ?? '401'}): conta e autorização tiradas deste computador`)
|
|
230
|
+
}
|
|
231
|
+
if (r.status !== 200 || !app.account) return
|
|
232
|
+
if (!r.data.allowed) {
|
|
233
|
+
writeJson(APP, removeAccount(app))
|
|
234
|
+
return log(`conta ${app.account.label} não está mais liberada: tirada deste computador`)
|
|
235
|
+
}
|
|
236
|
+
if (r.data.token_fp !== app.token_fp) {
|
|
237
|
+
const s = await api(app, key, 'POST', '/api/cli/session', { account: app.account.slug }, 30_000)
|
|
238
|
+
if (s.status === 200) {
|
|
239
|
+
writeJson(APP, applyAccount(app, key, s.data.account, s.data.token))
|
|
240
|
+
log(`token da conta ${app.account.label} atualizado`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- saída ----------
|
|
246
|
+
const isTTY = process.stderr.isTTY
|
|
247
|
+
const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s)
|
|
248
|
+
const colorPct = p => (p >= 95 ? c(31, `${p}%`) : p >= 80 ? c(33, `${p}%`) : c(32, `${p}%`))
|
|
249
|
+
function when(d) {
|
|
250
|
+
if (!d) return ''
|
|
251
|
+
const mins = Math.round((d - Date.now()) / 60_000)
|
|
252
|
+
if (mins <= 0) return 'agora'
|
|
253
|
+
const dd = Math.floor(mins / 1440), hh = Math.floor((mins % 1440) / 60), mm = mins % 60
|
|
254
|
+
return dd ? `${dd}d ${hh}h` : hh ? `${hh}h${String(mm).padStart(2, '0')}` : `${mm}min`
|
|
255
|
+
}
|
|
256
|
+
function line(a) {
|
|
257
|
+
const name = a.slug.padEnd(14)
|
|
258
|
+
if (a.five_hour_pct === null && a.weekly_pct === null) return `${name} ${c(90, a.status === 'ok' ? 'sem leitura ainda' : 'desconectada — avise um admin')}`
|
|
259
|
+
const at = s => s && new Date(s)
|
|
260
|
+
return `${name} 5h ${colorPct(a.five_hour_pct ?? 0).padEnd(isTTY ? 13 : 4)} (reinicia em ${when(at(a.five_hour_resets_at))}) semana ${colorPct(a.weekly_pct ?? 0).padEnd(isTTY ? 13 : 4)} (reinicia em ${when(at(a.weekly_resets_at))})`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------- app ----------
|
|
264
|
+
function die(msg) { console.error(msg); process.exit(1) }
|
|
265
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
|
266
|
+
|
|
267
|
+
async function api(app, key, method, path, body, timeoutMs = 60_000) {
|
|
268
|
+
let res
|
|
269
|
+
try {
|
|
270
|
+
res = await fetch(app.url + path, {
|
|
271
|
+
method,
|
|
272
|
+
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
273
|
+
body: body && JSON.stringify(body),
|
|
274
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
275
|
+
})
|
|
276
|
+
}
|
|
277
|
+
catch (e) { return { status: 0, data: { message: `sem conexão com ${app.url} (${e.message})` } } }
|
|
278
|
+
const data = await res.json().catch(() => ({}))
|
|
279
|
+
return { status: res.status, data }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function openBrowser(url) {
|
|
283
|
+
const [cmd, args] = WIN ? ['cmd', ['/c', 'start', '""', url]] : MAC ? ['open', [url]] : ['xdg-open', [url]]
|
|
284
|
+
try { execFileSync(cmd, args, { stdio: 'ignore' }) }
|
|
285
|
+
catch {}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function login(url) {
|
|
289
|
+
const target = { url: url.replace(/\/+$/, '') }
|
|
290
|
+
if (!/^https?:\/\//.test(target.url)) die('Uso: ccx login https://endereco-do-app')
|
|
291
|
+
const start = await api(target, null, 'POST', '/api/cli/device', { hostname: hostname(), platform: process.platform })
|
|
292
|
+
if (start.status !== 200) die(`Não consegui falar com o app: ${start.data.message ?? `HTTP ${start.status}`}`)
|
|
293
|
+
const { device_code, user_code, verify_url, expires_in, interval } = start.data
|
|
294
|
+
console.error(`\nNo navegador, digite este código:\n\n ${c(1, user_code)}\n\n ${verify_url}\n`)
|
|
295
|
+
openBrowser(verify_url)
|
|
296
|
+
process.stderr.write('Esperando a aprovação')
|
|
297
|
+
const deadline = Date.now() + expires_in * 1000
|
|
298
|
+
while (Date.now() < deadline) {
|
|
299
|
+
await sleep(interval * 1000)
|
|
300
|
+
const r = await api(target, null, 'POST', '/api/cli/device/token', { device_code })
|
|
301
|
+
if (r.status === 202 || r.status === 0) { process.stderr.write('.'); continue }
|
|
302
|
+
if (r.status !== 200) die(`\n${r.data.message ?? `Falhou (HTTP ${r.status})`}`)
|
|
303
|
+
try { secretSet(r.data.key) }
|
|
304
|
+
catch { die('\nNão consegui guardar a chave no cofre do sistema. Rode ccx login de novo e permita o acesso.') }
|
|
305
|
+
// Login de novo: tira a conta anterior (a telemetria dela usava a chave antiga)
|
|
306
|
+
writeJson(APP, { ...removeAccount(readJson(APP, null)), url: target.url, email: r.data.email, expires_at: r.data.expires_at })
|
|
307
|
+
console.error(`\n\nPronto! Este computador está autorizado para ${r.data.email} até ${new Date(r.data.expires_at).toLocaleDateString('pt-BR')}.\n`)
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
die('\nO código venceu. Rode ccx login de novo.')
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Mostra as contas e devolve o nome curto escolhido (Enter = recomendada)
|
|
314
|
+
async function chooseAccount(app, key) {
|
|
315
|
+
const r = await api(app, key, 'GET', '/api/cli/status?fresh=1')
|
|
316
|
+
if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
|
|
317
|
+
const { accounts: list, recommended } = r.data
|
|
318
|
+
if (!list.length) die('Nenhuma conta liberada pra você. Peça a um admin.')
|
|
319
|
+
if (list.length === 1) return list[0].slug
|
|
320
|
+
if (!process.stdin.isTTY) return recommended ?? die('Nenhuma conta disponível agora.')
|
|
321
|
+
console.error('Qual conta este computador deve usar?\n')
|
|
322
|
+
list.forEach((a, i) => console.error(` ${c(1, String(i + 1))} ${line(a)}${a.slug === recommended ? c(36, ' ← recomendada') : ''}${a.id === app.account?.id ? c(90, ' (atual)') : ''}`))
|
|
323
|
+
const { createInterface } = await import('node:readline/promises')
|
|
324
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
325
|
+
for (;;) {
|
|
326
|
+
const ans = (await rl.question(`\nNúmero${recommended ? ' (Enter = recomendada)' : ''}: `).catch(() => die('\nCancelado.'))).trim()
|
|
327
|
+
if (!ans && recommended) { rl.close(); return recommended }
|
|
328
|
+
const n = Number(ans)
|
|
329
|
+
if (Number.isInteger(n) && n >= 1 && n <= list.length) { rl.close(); return list[n - 1].slug }
|
|
330
|
+
console.error('Digite um dos números da lista.')
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function useAccount(app, key, wanted) {
|
|
335
|
+
const s = await api(app, key, 'POST', '/api/cli/session', { account: wanted })
|
|
336
|
+
if (s.status !== 200) die(s.data.message ?? `Falhou (HTTP ${s.status})`)
|
|
337
|
+
writeJson(APP, applyAccount(app, key, s.data.account, s.data.token))
|
|
338
|
+
const agent = installAgent()
|
|
339
|
+
if (process.env.ANTHROPIC_API_KEY) console.error(c(33, 'Aviso: ANTHROPIC_API_KEY está definida e tem prioridade sobre a conta escolhida.'))
|
|
340
|
+
console.error(`\n${c(32, '✓')} Este computador agora usa a conta ${c(1, s.data.account.label)}.`)
|
|
341
|
+
console.error(' Todo claude aberto a partir de agora usa essa conta. Os que já estão abertos continuam na anterior.')
|
|
342
|
+
if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx vigia" de vez em quando.'))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------- comandos ----------
|
|
346
|
+
const [cmd, ...rest] = process.argv.slice(2)
|
|
347
|
+
|
|
348
|
+
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
349
|
+
console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 15).map(l => l.slice(3)).join('\n'))
|
|
350
|
+
process.exit(0)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
354
|
+
console.log(readJson(join(dirname(SELF), '..', 'package.json'), {}).version ?? '?')
|
|
355
|
+
process.exit(0)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (cmd === 'vigia') {
|
|
359
|
+
await agentOnce()
|
|
360
|
+
process.exit(0)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (cmd === 'login') await login(rest[0] ?? readJson(APP, null)?.url ?? DEFAULT_URL)
|
|
364
|
+
|
|
365
|
+
const app = readJson(APP, null)
|
|
366
|
+
const key = app && secretGet()
|
|
367
|
+
if (!app || !key) die('Este computador ainda não está conectado. Rode: ccx login')
|
|
368
|
+
|
|
369
|
+
if (cmd === 'logout') {
|
|
370
|
+
removeAccount(app)
|
|
371
|
+
secretDelete()
|
|
372
|
+
rmSync(APP, { force: true })
|
|
373
|
+
uninstallAgent()
|
|
374
|
+
console.error('Pronto: este computador saiu do app. O claude volta ao seu login normal.')
|
|
375
|
+
process.exit(0)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (cmd === 'sair') {
|
|
379
|
+
writeJson(APP, removeAccount(app))
|
|
380
|
+
console.error('Pronto: este computador não usa mais conta do app. O claude volta ao seu login normal. Pra escolher de novo: ccx')
|
|
381
|
+
process.exit(0)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (cmd === 'status') {
|
|
385
|
+
const r = await api(app, key, 'GET', '/api/cli/status')
|
|
386
|
+
if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
|
|
387
|
+
console.error(c(90, `${r.data.email} · login vale até ${new Date(r.data.expires_at).toLocaleDateString('pt-BR')} · ${app.url}`))
|
|
388
|
+
console.error(`Conta deste computador: ${app.account ? c(1, app.account.label) : c(33, 'nenhuma (rode ccx)')}\n`)
|
|
389
|
+
for (const a of r.data.accounts) console.log(`${a.id === app.account?.id ? c(36, '●') : ' '} ${line(a)}`)
|
|
390
|
+
if (!r.data.accounts.length) console.log('(nenhuma conta liberada pra você — peça a um admin)')
|
|
391
|
+
process.exit(0)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
|
|
395
|
+
await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
|
package/package.json
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nectarcrm/ccx",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Escolhe qual conta Claude este computador usa, conectado ao app Contas Claude da Nectar.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "ccx": "bin/ccx.js" },
|
|
7
|
+
"files": ["bin"],
|
|
8
|
+
"engines": { "node": ">=18.17" },
|
|
9
|
+
"os": ["darwin", "win32", "linux"],
|
|
10
|
+
"keywords": ["claude", "claude-code", "nectar"],
|
|
11
|
+
"license": "UNLICENSED",
|
|
12
|
+
"publishConfig": { "access": "public" }
|
|
13
|
+
}
|