@nectarcrm/ccx 0.2.0 → 0.4.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.
Files changed (3) hide show
  1. package/README.md +1 -0
  2. package/bin/ccx.js +193 -23
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,6 +17,7 @@ ccx login # autoriza este computador (abre o navegador
17
17
  ccx # mostra suas contas e troca a conta deste computador
18
18
  ccx claude-infra # troca direto
19
19
  ccx status # conta atual e uso de cada conta
20
+ ccx doctor # confere se o claude está mesmo usando a conta escolhida (e o que pode atrapalhar)
20
21
  ccx sync # manda pro app o resumo das sessões (roda sozinho após o login e a cada 5 min)
21
22
  ccx sair # o claude volta ao seu login normal
22
23
  ccx logout # sai do app neste computador
package/bin/ccx.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // ccx mostra suas contas e troca a conta deste computador
7
7
  // ccx <conta> troca direto (nome curto, ex.: claude-infra)
8
8
  // ccx status conta atual e uso das contas liberadas pra você
9
+ // ccx doctor confere se o claude está mesmo usando a conta escolhida
9
10
  // ccx sync manda pro app o resumo das sessões do Claude (totais por projeto e dia; nunca o texto)
10
11
  // ccx sair tira a conta deste computador (o claude volta ao login normal)
11
12
  // ccx logout sai do app: tira a conta, a autorização e o vigia
@@ -100,6 +101,14 @@ function writeSettings(s) {
100
101
  writeJson(SETTINGS, s)
101
102
  if (!WIN) chmodSync(SETTINGS, 0o600) // agora guarda um token
102
103
  }
104
+ // Quando cada conta passou a valer neste computador (o sync usa pra saber a conta de cada sessão)
105
+ const HISTORY_DAYS = 45
106
+ function withHistory(app, id) {
107
+ const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString()
108
+ const history = (app?.history ?? []).filter(h => h.at >= since)
109
+ if (history.at(-1)?.id !== id) history.push({ at: new Date().toISOString(), id })
110
+ return history
111
+ }
103
112
  function applyAccount(app, key, account, token) {
104
113
  const s = readSettings()
105
114
  const env = { ...s.env }
@@ -116,7 +125,7 @@ function applyAccount(app, key, account, token) {
116
125
  // Métricas não são usadas; desliga só se a pessoa não configurou as dela
117
126
  if (!('OTEL_METRICS_EXPORTER' in env) || app.owned?.includes('OTEL_METRICS_EXPORTER')) vars.OTEL_METRICS_EXPORTER = 'none'
118
127
  writeSettings({ ...s, env: { ...env, ...vars } })
119
- return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars) }
128
+ return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars), history: withHistory(app, account.id) }
120
129
  }
121
130
  function removeAccount(app) {
122
131
  if (app?.owned?.length && existsSync(SETTINGS)) {
@@ -127,7 +136,7 @@ function removeAccount(app) {
127
136
  else delete s.env
128
137
  writeSettings(s)
129
138
  }
130
- return { ...app, account: null, token_fp: null, owned: [] }
139
+ return { ...app, account: null, token_fp: null, owned: [], history: withHistory(app, null) }
131
140
  }
132
141
 
133
142
  // ---------- vigia em segundo plano ----------
@@ -247,8 +256,8 @@ async function agentOnce() {
247
256
  }
248
257
 
249
258
  // ---------- sync: resumo das sessões do Claude Code ----------
250
- // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília) e manda só os
251
- // totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
259
+ // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília), separando branch e modelo,
260
+ // e manda só os totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
252
261
  // Os arquivos só crescem: guardamos até onde lemos cada um e os totais, e mandamos só o que mudou.
253
262
  const PROJECTS = join(dirname(SETTINGS), 'projects')
254
263
  const SYNC_STATE = join(DIR, 'sync.json')
@@ -256,6 +265,7 @@ const SYNC_LOCK = join(DIR, 'sync.lock')
256
265
  const SYNC_BATCH = 2000
257
266
  const SYNC_CHUNK = 1 << 20
258
267
  const SYNC_KEEP_DAYS = 40 // o Claude apaga as sessões com 30 dias
268
+ const SYNC_VERSION = 2 // mudou o formato do sync.json: lê tudo de novo
259
269
  const brtDay = ts => new Date(Date.parse(ts) - 3 * 3600_000).toISOString().slice(0, 10)
260
270
 
261
271
  function jsonlFiles(dir) {
@@ -325,9 +335,10 @@ function addLine(events, text) {
325
335
  const u = o.message.usage
326
336
  const tools = (events.get(o.requestId)?.tools ?? 0) + (o.message.content ?? []).filter(b => b?.type === 'tool_use').length
327
337
  events.set(o.requestId, {
328
- ...base, kind: 'resposta', tools,
338
+ ...base, kind: 'resposta', tools, model: typeof o.message.model === 'string' ? o.message.model : '',
329
339
  input_tokens: u.input_tokens ?? 0, output_tokens: u.output_tokens ?? 0,
330
340
  cache_read_tokens: u.cache_read_input_tokens ?? 0, cache_creation_tokens: u.cache_creation_input_tokens ?? 0,
341
+ cache_1h_tokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0,
331
342
  })
332
343
  return
333
344
  }
@@ -339,17 +350,19 @@ function addLine(events, text) {
339
350
  }
340
351
  }
341
352
 
342
- // Soma um evento no total da sessão, projeto e dia
353
+ // Soma um evento no dia da sessão e projeto, na parte da branch e do modelo dele
354
+ const SUMS = ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'cache_1h_tokens']
343
355
  function addToDay(days, e) {
344
356
  const day = brtDay(e.at)
345
357
  const key = `${e.session_id}\t${e.project}\t${day}`
346
- const d = days[key] ??= { session_id: e.session_id, project: e.project, day, branch: null, last_at: e.at,
347
- prompts: 0, responses: 0, tools: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }
348
- if (e.at >= d.last_at) { d.last_at = e.at; d.branch = e.branch ?? d.branch }
349
- if (e.kind === 'pedido') d.prompts++
358
+ const d = days[key] ??= { session_id: e.session_id, project: e.project, day, parts: {} }
359
+ const branch = e.branch ?? '', model = e.model ?? ''
360
+ const p = d.parts[`${branch}\t${model}`] ??= { branch, model, last_at: e.at, prompts: 0, responses: 0, ...Object.fromEntries(SUMS.map(k => [k, 0])) }
361
+ if (e.at > p.last_at) p.last_at = e.at
362
+ if (e.kind === 'pedido') p.prompts++
350
363
  else {
351
- d.responses++
352
- for (const k of ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens']) d[k] += e[k]
364
+ p.responses++
365
+ for (const k of SUMS) p[k] += e[k]
353
366
  }
354
367
  return key
355
368
  }
@@ -369,9 +382,16 @@ async function sync(app, key, quiet = false) {
369
382
  const say = msg => { if (!quiet) process.stderr.write(msg) }
370
383
  if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
371
384
  try {
372
- const state = readJson(SYNC_STATE, {})
373
- const offsets = state.files ?? {}, days = state.days ?? {}
385
+ let state = readJson(SYNC_STATE, {})
386
+ if (state.v !== SYNC_VERSION) state = {}
387
+ const offsets = state.files ?? {}, days = state.days ?? {}, sessions = state.sessions ?? {}
374
388
  const changed = new Set()
389
+ // Modo oculto: da conta marcada pelo superadmin, só os projetos da lista saem daqui. App antigo: nenhuma.
390
+ const h = await api(app, key, 'GET', '/api/cli/hidden', null, 20_000)
391
+ if (h.status !== 200 && h.status !== 404) throw new Error(h.data.message ?? `HTTP ${h.status}`) // na dúvida, não manda nada
392
+ const hidden = h.status === 200 ? h.data.accounts ?? {} : {}
393
+ const accountAt = at => (app.history ?? []).filter(x => x.at <= at).at(-1)?.id ?? null
394
+ const excluded = new Set()
375
395
  const files = jsonlFiles(PROJECTS)
376
396
  say(`Lendo as sessões do Claude Code em ${PROJECTS}`)
377
397
  for (const file of files) {
@@ -379,7 +399,13 @@ async function sync(app, key, quiet = false) {
379
399
  const from = offsets[file] ?? 0
380
400
  if (from >= size) continue // nada novo (ou arquivo trocado: não soma de novo)
381
401
  const { events, offset } = readNewLines(file, from)
382
- for (const e of events) changed.add(addToDay(days, e))
402
+ for (const e of events) {
403
+ // A conta da sessão é a que valia quando ela começou (claude aberto não troca de conta)
404
+ const s = sessions[e.session_id] ??= { id: accountAt(e.at), at: e.at }
405
+ const only = s.id === null ? null : hidden[s.id]
406
+ if (only && !only.includes(e.project)) { excluded.add(e.session_id); continue }
407
+ changed.add(addToDay(days, e))
408
+ }
383
409
  offsets[file] = offset
384
410
  }
385
411
  // Esquece arquivo apagado e dia antigo
@@ -387,21 +413,163 @@ async function sync(app, key, quiet = false) {
387
413
  for (const f of Object.keys(offsets)) if (!alive.has(f)) delete offsets[f]
388
414
  const oldest = brtDay(new Date(Date.now() - SYNC_KEEP_DAYS * 86_400_000).toISOString())
389
415
  for (const [k, d] of Object.entries(days)) if (d.day < oldest) delete days[k]
390
-
391
- const items = [...changed].map(k => days[k]).filter(Boolean)
392
- for (let i = 0; i < items.length; i += SYNC_BATCH) {
416
+ for (const [k, s] of Object.entries(sessions)) if (brtDay(s.at) < oldest) delete sessions[k]
417
+
418
+ // O app troca o dia inteiro da sessão: as partes de um dia vão sempre no mesmo lote
419
+ const sent = [...changed].map(k => days[k]).filter(Boolean)
420
+ const items = sent.map(({ parts, ...d }) => Object.values(parts).map(p => ({ ...d, ...p })))
421
+ let batch = [], exclude = [...excluded]
422
+ const send = async () => {
423
+ if (!batch.length && !exclude.length) return
393
424
  say('.')
394
- const r = await api(app, key, 'POST', '/api/cli/sync', { items: items.slice(i, i + SYNC_BATCH) })
425
+ const r = await api(app, key, 'POST', '/api/cli/sync', { items: batch, ...(exclude.length && { exclude_sessions: exclude }) })
395
426
  if (r.status !== 200) throw new Error(r.data.message ?? `HTTP ${r.status}`)
427
+ batch = []
428
+ exclude = []
429
+ }
430
+ for (const day of items) {
431
+ if (batch.length + day.length > SYNC_BATCH) await send()
432
+ batch.push(...day.slice(0, SYNC_BATCH))
396
433
  }
434
+ await send()
397
435
  // Só grava depois de enviar: se falhar, a próxima vez lê e manda de novo
398
- writeJson(SYNC_STATE, { files: offsets, days })
399
- say(`\n${c(32, '✓')} Enviado: ${items.length} ${items.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
400
- return items.length
436
+ writeJson(SYNC_STATE, { v: SYNC_VERSION, files: offsets, days, sessions })
437
+ say(`\n${c(32, '✓')} Enviado: ${sent.length} ${sent.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
438
+ return sent.length
401
439
  }
402
440
  finally { rmSync(SYNC_LOCK, { force: true }) }
403
441
  }
404
442
 
443
+ // ---------- doctor: confere se o claude está mesmo usando a conta escolhida ----------
444
+ // Nunca mostra token: compara só a impressão digital (fingerprint).
445
+ const AUTH_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']
446
+ const MANAGED = WIN ? ['C:\\Program Files\\ClaudeCode\\managed-settings.json', 'C:\\ProgramData\\ClaudeCode\\managed-settings.json']
447
+ : MAC ? ['/Library/Application Support/ClaudeCode/managed-settings.json'] : ['/etc/claude-code/managed-settings.json']
448
+
449
+ function winUserEnv(name) {
450
+ try { return ps(`[Environment]::GetEnvironmentVariable('${name}','User'); [Environment]::GetEnvironmentVariable('${name}','Machine')`).split(/\r?\n/).find(Boolean) || null }
451
+ catch { return null }
452
+ }
453
+ function agentInstalled() {
454
+ try {
455
+ if (MAC) return existsSync(PLIST)
456
+ if (WIN) { run('schtasks', ['/Query', '/TN', AGENT_ID]); return true }
457
+ if (existsSync(join(UNIT_DIR, 'ccx-vigia.timer'))) return true
458
+ return run('crontab', ['-l']).includes(CRON_TAG)
459
+ }
460
+ catch { return false }
461
+ }
462
+
463
+ async function doctor() {
464
+ let bad = 0, warn = 0
465
+ const ok = msg => console.log(`${c(32, '✓')} ${msg}`)
466
+ const aviso = (msg, fix) => { warn++; console.log(`${c(33, '!')} ${msg}${fix ? `\n ${c(90, '→ ' + fix)}` : ''}`) }
467
+ const erro = (msg, fix) => { bad++; console.log(`${c(31, '✗')} ${msg}${fix ? `\n ${c(90, '→ ' + fix)}` : ''}`) }
468
+ const mine = readJson(join(dirname(SELF), '..', 'package.json'), {}).version
469
+
470
+ console.log(c(1, 'ccx doctor\n'))
471
+ // Versões
472
+ const [major, minor] = process.versions.node.split('.').map(Number)
473
+ if (major > 18 || (major === 18 && minor >= 17)) ok(`Node ${process.versions.node}`)
474
+ else erro(`Node ${process.versions.node} é velho demais`, 'instale o Node 18.17 ou mais novo')
475
+ try {
476
+ const r = await fetch('https://registry.npmjs.org/@nectarcrm%2fccx/latest', { signal: AbortSignal.timeout(8000) })
477
+ const latest = (await r.json()).version
478
+ if (latest && latest !== mine) aviso(`ccx ${mine} (a mais nova é ${latest})`, 'npm install -g @nectarcrm/ccx@latest')
479
+ else ok(`ccx ${mine} (a mais nova)`)
480
+ }
481
+ catch { ok(`ccx ${mine} (não deu pra ver se há versão nova)`) }
482
+ try { ok(`Claude Code ${execFileSync('claude', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'], shell: WIN }).toString().trim()}`) }
483
+ catch { aviso('Não achei o comando claude neste terminal', 'instale o Claude Code ou abra um terminal novo') }
484
+
485
+ // Login no app
486
+ const app = readJson(APP, null)
487
+ const key = app && secretGet()
488
+ if (!app || !key) {
489
+ erro('Este computador não está conectado ao app', 'ccx login')
490
+ return finish()
491
+ }
492
+ if (new Date(app.expires_at) < new Date()) erro(`O login venceu em ${new Date(app.expires_at).toLocaleDateString('pt-BR')}`, 'ccx login')
493
+ else ok(`Conectado como ${app.email} em ${app.url} (vale até ${new Date(app.expires_at).toLocaleDateString('pt-BR')})`)
494
+
495
+ const st = await api(app, key, 'GET', '/api/cli/status', null, 20_000)
496
+ if (st.status !== 200) {
497
+ erro(`O app não respondeu: ${st.data.message ?? `HTTP ${st.status}`}`, st.status === 401 ? 'ccx login' : 'confira a internet ou fale com um admin')
498
+ return finish()
499
+ }
500
+ ok('O app respondeu e a chave deste computador vale')
501
+
502
+ // Conta escolhida
503
+ if (!app.account) {
504
+ erro('Nenhuma conta escolhida neste computador', 'ccx')
505
+ return finish()
506
+ }
507
+ const hb = await api(app, key, 'POST', '/api/cli/heartbeat', { account_id: app.account.id }, 20_000)
508
+ if (hb.status !== 200 || !hb.data.allowed) erro(`A conta ${app.account.label} não está mais liberada pra você`, 'ccx (e escolha outra) ou peça a um admin')
509
+ else ok(`Conta escolhida: ${app.account.label}, liberada`)
510
+
511
+ // Token no settings.json do Claude Code
512
+ let settings = null
513
+ try { settings = readJson(SETTINGS, {}) }
514
+ catch { erro(`${SETTINGS} não é um JSON válido`, 'corrija o arquivo e rode ccx de novo') }
515
+ const token = settings?.env?.CLAUDE_CODE_OAUTH_TOKEN
516
+ if (process.env.CLAUDE_CONFIG_DIR) aviso(`CLAUDE_CONFIG_DIR=${process.env.CLAUDE_CONFIG_DIR}: o claude só usa a conta se também tiver essa variável`)
517
+ if (settings && !token) erro(`O token da conta não está em ${SETTINGS}`, 'ccx (escolha a conta de novo)')
518
+ else if (token && fingerprint(token) !== app.token_fp) erro('O token no settings.json não é o que o ccx gravou (alguém mexeu no arquivo)', 'ccx (escolha a conta de novo)')
519
+ else if (token && hb.data.token_fp && fingerprint(token) !== hb.data.token_fp) aviso('O admin trocou o token desta conta; o vigia atualiza em até 5 min', 'ou rode: ccx (escolha a conta de novo)')
520
+ else if (token) ok(`Token da conta ${app.account.label} gravado em ${SETTINGS}`)
521
+
522
+ // O que passa por cima do token
523
+ const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
524
+ if (envToken && envToken !== token) erro('Este terminal tem outro CLAUDE_CODE_OAUTH_TOKEN, que pode passar por cima da conta do ccx', WIN ? 'apague a variável (comando abaixo) e abra um terminal novo' : 'tire o export do seu ~/.zshrc ou ~/.bashrc e abra um terminal novo')
525
+ if (WIN) {
526
+ const winToken = winUserEnv('CLAUDE_CODE_OAUTH_TOKEN')
527
+ if (winToken && winToken !== token) erro('O Windows tem um CLAUDE_CODE_OAUTH_TOKEN antigo guardado', `PowerShell: [Environment]::SetEnvironmentVariable('CLAUDE_CODE_OAUTH_TOKEN', $null, 'User') e abra um terminal novo`)
528
+ }
529
+ for (const v of AUTH_VARS) {
530
+ const val = process.env[v] || (WIN ? winUserEnv(v) : null) || settings?.env?.[v]
531
+ if (val) erro(`${v} está definida: ela passa por cima da conta do ccx`, `tire ${v} do terminal${WIN ? ', das variáveis do Windows' : ''} e do settings.json`)
532
+ }
533
+ if (settings?.apiKeyHelper) erro('O settings.json tem apiKeyHelper, que passa por cima da conta do ccx', `tire "apiKeyHelper" de ${SETTINGS}`)
534
+
535
+ // Settings de projeto (nesta pasta) e da empresa também podem trocar a conta
536
+ const overrides = [join(process.cwd(), '.claude', 'settings.json'), join(process.cwd(), '.claude', 'settings.local.json'), ...MANAGED]
537
+ for (const f of overrides) {
538
+ if (!existsSync(f) || f === SETTINGS) continue
539
+ let s
540
+ try { s = JSON.parse(readFileSync(f, 'utf8')) }
541
+ catch { continue }
542
+ const keys = ['CLAUDE_CODE_OAUTH_TOKEN', ...AUTH_VARS].filter(k => s?.env?.[k])
543
+ if (s?.apiKeyHelper) keys.push('apiKeyHelper')
544
+ if (keys.length) erro(`${f} define ${keys.join(', ')}: passa por cima da conta do ccx nesta pasta`, 'tire essas chaves do arquivo')
545
+ }
546
+ if (!bad) ok('Nada passando por cima da conta do ccx')
547
+
548
+ // Telemetria e vigia
549
+ const env = settings?.env ?? {}
550
+ if (env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT !== `${app.url}/api/cli/otel/v1/logs` || env.OTEL_EXPORTER_OTLP_LOGS_HEADERS !== `Authorization=Bearer ${key}`) {
551
+ aviso('A telemetria não está apontando pro app com a chave atual (os tokens não aparecem no painel)', 'ccx (escolha a conta de novo)')
552
+ }
553
+ else ok('Telemetria ligada (tokens vão pro painel)')
554
+ if (agentInstalled()) ok(`Vigia instalado (roda a cada ${AGENT_MINUTES} min)`)
555
+ else aviso('O vigia não está instalado: token novo e sync não chegam sozinhos', 'ccx (escolha a conta de novo)')
556
+ // Só o que deu errado nas últimas 24 h (o log tem a data no começo de cada linha)
557
+ const dayAgo = new Date(Date.now() - 86_400_000).toISOString()
558
+ const lastErr = existsSync(LOG) && readFileSync(LOG, 'utf8').trim().split('\n').filter(l => l >= dayAgo && /falhou|removid|vencid/.test(l)).pop()
559
+ if (lastErr) aviso(`Problema recente do vigia: ${lastErr}`)
560
+ const sync = existsSync(SYNC_STATE) && statSync(SYNC_STATE).mtime
561
+ if (sync) ok(`Último sync: ${sync.toLocaleString('pt-BR')}`)
562
+ else aviso('O sync nunca rodou neste computador', 'ccx sync')
563
+
564
+ return finish()
565
+
566
+ function finish() {
567
+ console.log(`\n${bad ? c(31, `${bad} problema(s)`) : c(32, 'Tudo certo')}${warn ? c(33, ` · ${warn} aviso(s)`) : ''}.`)
568
+ if (!bad && app?.account) console.log(c(90, `Todo claude aberto a partir de agora usa a conta ${app.account.label}. Os que já estavam abertos continuam na anterior até serem fechados.`))
569
+ process.exit(bad ? 1 : 0)
570
+ }
571
+ }
572
+
405
573
  // ---------- saída ----------
406
574
  const isTTY = process.stderr.isTTY
407
575
  const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s)
@@ -506,7 +674,7 @@ async function useAccount(app, key, wanted) {
506
674
  const [cmd, ...rest] = process.argv.slice(2)
507
675
 
508
676
  if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
509
- console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 17).map(l => l.slice(3)).join('\n'))
677
+ console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 18).map(l => l.slice(3)).join('\n'))
510
678
  process.exit(0)
511
679
  }
512
680
 
@@ -520,6 +688,8 @@ if (cmd === 'vigia') {
520
688
  process.exit(0)
521
689
  }
522
690
 
691
+ if (cmd === 'doctor') await doctor()
692
+
523
693
  if (cmd === 'login') await login(rest[0] ?? readJson(APP, null)?.url ?? DEFAULT_URL)
524
694
 
525
695
  const app = readJson(APP, null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Escolhe qual conta Claude este computador usa, conectado ao app Contas Claude da Nectar.",
5
5
  "type": "module",
6
6
  "bin": { "ccx": "bin/ccx.js" },