@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.
Files changed (3) hide show
  1. package/README.md +33 -2
  2. package/bin/ccx.js +395 -0
  3. package/package.json +11 -4
package/README.md CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # ccx
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }