@nectarcrm/ccx 0.4.2 → 0.5.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 +3 -2
  2. package/bin/ccx.js +118 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,9 +17,10 @@ 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 list # contas que dá pra usar agora, uma por linha (--json: detalhes)
20
21
  ccx doctor # confere se o claude está mesmo usando a conta escolhida (e o que pode atrapalhar)
21
- ccx sync # manda pro app o resumo das sessões (roda sozinho após o login e a cada 5 min)
22
- ccx sair # o claude volta ao seu login normal
22
+ ccx sync # manda pro app o resumo das sessões (sozinho às 12h00 e 17h30; assim, na hora)
23
+ ccx off # o claude volta ao seu login normal
23
24
  ccx logout # sai do app neste computador
24
25
  ```
25
26
 
package/bin/ccx.js CHANGED
@@ -6,9 +6,11 @@
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 list nomes curtos das contas que dá pra usar agora, a mais folgada primeiro
10
+ // (--json: todas, com uso, reset, a atual e a recomendada)
9
11
  // ccx doctor confere se o claude está mesmo usando a conta escolhida
10
12
  // ccx sync manda pro app o resumo das sessões do Claude (totais por projeto e dia; nunca o texto)
11
- // ccx sair tira a conta deste computador (o claude volta ao login normal)
13
+ // ccx off tira a conta deste computador (o claude volta ao login normal)
12
14
  // ccx logout sai do app: tira a conta, a autorização e o vigia
13
15
  // ccx --version versão instalada (atualizar: npm install -g @nectarcrm/ccx@latest)
14
16
  //
@@ -16,7 +18,8 @@
16
18
  // (terminal, VS Code, JetBrains) usa essa conta. Claudes já abertos continuam na anterior.
17
19
  // O vigia roda sozinho a cada 5 min: avisa o app, atualiza o token se o admin trocou
18
20
  // e tira a conta se o acesso for removido. Os tokens gastos vão pro app pela telemetria.
19
- // O resumo dos projetos só vai pro app quando você roda ccx sync.
21
+ // O resumo dos projetos vai sozinho depois das 12h00 e das 17h30; ccx sync manda na hora.
22
+ // Uma vez por dia o vigia confere se há ccx novo no npm e se atualiza.
20
23
 
21
24
  import { execFileSync } from 'node:child_process'
22
25
  import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
@@ -159,7 +162,7 @@ const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: [input
159
162
  const crontabWithout = () => { try { return run('crontab', ['-l']).split('\n').filter(l => l && !l.includes(CRON_TAG)) } catch { return [] } }
160
163
 
161
164
  function installLinuxAgent() {
162
- const cmdLine = `"${process.execPath}" "${SELF}" vigia`
165
+ const cmdLine = `"${process.execPath}" "${SELF}" watch`
163
166
  try {
164
167
  run('systemctl', ['--user', 'show-environment']) // tem systemd do usuário?
165
168
  mkdirSync(UNIT_DIR, { recursive: true })
@@ -198,7 +201,7 @@ function installAgent() {
198
201
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199
202
  <plist version="1.0"><dict>
200
203
  <key>Label</key><string>${AGENT_ID}</string>
201
- <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${SELF}</string><string>vigia</string></array>
204
+ <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${SELF}</string><string>watch</string></array>
202
205
  <key>StartInterval</key><integer>${AGENT_MINUTES * 60}</integer>
203
206
  <key>RunAtLoad</key><true/>
204
207
  <key>StandardErrorPath</key><string>${LOG}</string>
@@ -213,7 +216,7 @@ function installAgent() {
213
216
  if (WIN) {
214
217
  // node.exe é app de console: direto pelo Agendador abre uma janela a cada rodada. O conhost --headless roda sem janela.
215
218
  execFileSync('schtasks', ['/Create', '/F', '/SC', 'MINUTE', '/MO', String(AGENT_MINUTES), '/TN', AGENT_ID,
216
- '/TR', `conhost.exe --headless "${process.execPath}" "${SELF}" vigia`], { stdio: 'ignore' })
219
+ '/TR', `conhost.exe --headless "${process.execPath}" "${SELF}" watch`], { stdio: 'ignore' })
217
220
  return true
218
221
  }
219
222
  return installLinuxAgent()
@@ -388,9 +391,9 @@ function takeLock() {
388
391
  }
389
392
  }
390
393
 
391
- async function sync(app, key) {
392
- const say = msg => process.stderr.write(msg)
393
- if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
394
+ async function sync(app, key, quiet = false) {
395
+ const say = msg => { if (!quiet) process.stderr.write(msg) }
396
+ if (!takeLock()) { say('Outro sync já está rodando. Tente de novo daqui a pouco.\n'); return null }
394
397
  try {
395
398
  let state = readJson(SYNC_STATE, {})
396
399
  if (state.v !== SYNC_VERSION) state = {}
@@ -450,6 +453,81 @@ async function sync(app, key) {
450
453
  finally { rmSync(SYNC_LOCK, { force: true }) }
451
454
  }
452
455
 
456
+ // Sync automático: o watch roda a cada 5 min e manda o resumo depois das 12h00 e das 17h30 (hora do computador).
457
+ // Computador desligado no horário: manda na primeira rodada depois que ligar. Falhou: tenta de novo na próxima.
458
+ const SYNC_SLOTS = [[12, 0], [17, 30]]
459
+ const SYNC_AUTO = join(DIR, 'sync-auto.json')
460
+ function lastSyncSlot(now = new Date()) {
461
+ const slots = [-1, 0].flatMap(d => SYNC_SLOTS.map(([h, m]) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + d, h, m)))
462
+ return slots.filter(t => t <= now).at(-1)
463
+ }
464
+ async function autoSync() {
465
+ const app = readJson(APP, null)
466
+ const key = app && secretGet()
467
+ if (!app || !key) return
468
+ const last = readJson(SYNC_AUTO, {}).at
469
+ if (last && new Date(last) >= lastSyncSlot()) return
470
+ const log = msg => appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`)
471
+ try {
472
+ const sent = await sync(app, key, true)
473
+ if (sent === null) return // outro sync rodando: tenta na próxima
474
+ writeJson(SYNC_AUTO, { at: new Date().toISOString() })
475
+ log(`sync automático: ${sent} ${sent === 1 ? 'resumo' : 'resumos'}`)
476
+ }
477
+ catch (e) { log(`sync automático falhou: ${e.message}`) }
478
+ }
479
+
480
+ // ---------- atualização automática ----------
481
+ // Uma vez por dia o watch confere a versão no npm e, se houver nova, instala no mesmo lugar onde o ccx está.
482
+ // Não conseguiu (npm pede sudo, sem npm): o ccx avisa no terminal até alguém atualizar.
483
+ const UPDATE = join(DIR, 'update.json')
484
+ const PKG = '@nectarcrm/ccx'
485
+ const myVersion = () => readJson(join(dirname(SELF), '..', 'package.json'), {}).version ?? '0'
486
+ const newer = (a, b) => { // a é mais nova que b?
487
+ const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
488
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
489
+ return false
490
+ }
491
+ // Pasta do npm onde o ccx foi instalado (-g): <prefixo>/lib/node_modules/@nectarcrm/ccx (Windows: sem o lib)
492
+ function globalPrefix() {
493
+ const pkgDir = dirname(dirname(SELF))
494
+ const mods = dirname(dirname(pkgDir))
495
+ if (basename(dirname(pkgDir)) !== '@nectarcrm' || basename(mods) !== 'node_modules') return null // rodando de fora do npm -g
496
+ return WIN ? dirname(mods) : dirname(dirname(mods))
497
+ }
498
+ async function autoUpdate() {
499
+ const state = readJson(UPDATE, {})
500
+ if (state.checked_at && Date.now() - Date.parse(state.checked_at) < 86_400_000) return
501
+ const log = msg => appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`)
502
+ const mine = myVersion()
503
+ let latest
504
+ try {
505
+ const r = await fetch(`https://registry.npmjs.org/${PKG.replace('/', '%2f')}/latest`, { signal: AbortSignal.timeout(10_000) })
506
+ latest = (await r.json()).version
507
+ }
508
+ catch { return } // sem internet: tenta na próxima rodada
509
+ const save = extra => writeJson(UPDATE, { checked_at: new Date().toISOString(), latest, ...extra })
510
+ if (!latest || !newer(latest, mine)) return save({})
511
+ const prefix = globalPrefix()
512
+ if (!prefix) return save({ error: 'ccx não foi instalado com npm install -g' })
513
+ try {
514
+ const npm = join(dirname(process.execPath), WIN ? 'npm.cmd' : 'npm')
515
+ execFileSync(existsSync(npm) ? npm : 'npm', ['install', '-g', '--prefix', prefix, `${PKG}@${latest}`], { stdio: 'ignore', timeout: 180_000, shell: WIN })
516
+ log(`ccx atualizado: ${mine} → ${latest}`)
517
+ save({})
518
+ }
519
+ catch (e) {
520
+ const msg = e.message.split('\n')[0]
521
+ log(`não consegui atualizar o ccx pra ${latest}: ${msg}`)
522
+ save({ error: msg })
523
+ }
524
+ }
525
+ // No terminal: avisa quando a atualização automática não deu conta
526
+ function updateNotice() {
527
+ const { latest } = readJson(UPDATE, {})
528
+ if (latest && newer(latest, myVersion())) console.error(c(33, `Tem ccx novo (${latest}). Atualize: npm install -g ${PKG}@latest`))
529
+ }
530
+
453
531
  // ---------- doctor: confere se o claude está mesmo usando a conta escolhida ----------
454
532
  // Nunca mostra token: compara só a impressão digital (fingerprint).
455
533
  const AUTH_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']
@@ -677,14 +755,19 @@ async function useAccount(app, key, wanted) {
677
755
  if (process.env.ANTHROPIC_API_KEY) console.error(c(33, 'Aviso: ANTHROPIC_API_KEY está definida e tem prioridade sobre a conta escolhida.'))
678
756
  console.error(`\n${c(32, '✓')} Este computador agora usa a conta ${c(1, s.data.account.label)}.`)
679
757
  console.error(' Todo claude aberto a partir de agora usa essa conta. Os que já estão abertos continuam na anterior.')
680
- if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx vigia" de vez em quando.'))
758
+ if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx watch" de vez em quando.'))
681
759
  }
682
760
 
683
761
  // ---------- comandos ----------
684
762
  const [cmd, ...rest] = process.argv.slice(2)
685
763
 
764
+ // Nomes antigos em português: ainda funcionam, mas avisam (só no terminal; o agendamento chama vigia sem ninguém ver)
765
+ const DEPRECATED = { sair: 'off', vigia: 'watch' }
766
+ if (DEPRECATED[cmd] && isTTY) console.error(c(33, `Aviso: "ccx ${cmd}" vai deixar de existir. Use "ccx ${DEPRECATED[cmd]}".`))
767
+ if (isTTY && cmd !== 'watch' && cmd !== 'vigia') updateNotice()
768
+
686
769
  if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
687
- console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 18).map(l => l.slice(3)).join('\n'))
770
+ console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 21).map(l => l.slice(3)).join('\n'))
688
771
  process.exit(0)
689
772
  }
690
773
 
@@ -693,8 +776,10 @@ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
693
776
  process.exit(0)
694
777
  }
695
778
 
696
- if (cmd === 'vigia') {
779
+ if (cmd === 'watch' || cmd === 'vigia') { // vigia: os agendamentos já instalados ainda chamam assim
697
780
  await agentOnce()
781
+ await autoSync()
782
+ await autoUpdate()
698
783
  process.exit(0)
699
784
  }
700
785
 
@@ -715,7 +800,7 @@ if (cmd === 'logout') {
715
800
  process.exit(0)
716
801
  }
717
802
 
718
- if (cmd === 'sair') {
803
+ if (cmd === 'off' || cmd === 'sair') {
719
804
  writeJson(APP, removeAccount(app))
720
805
  console.error('Pronto: este computador não usa mais conta do app. O claude volta ao seu login normal. Pra escolher de novo: ccx')
721
806
  process.exit(0)
@@ -737,5 +822,26 @@ if (cmd === 'status') {
737
822
  process.exit(0)
738
823
  }
739
824
 
825
+ // Pra scripts e agentes: uma conta por linha, pronta pra `ccx <conta>`
826
+ if (cmd === 'list') {
827
+ const r = await api(app, key, 'GET', '/api/cli/status?fresh=1')
828
+ if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
829
+ const peak = a => Math.max(a.five_hour_pct ?? 0, a.weekly_pct ?? 0)
830
+ const list = [...r.data.accounts].sort((a, b) => b.available - a.available || peak(a) - peak(b) || a.slug.localeCompare(b.slug))
831
+ if (rest.includes('--json')) {
832
+ console.log(JSON.stringify({
833
+ current: app.account ? (r.data.accounts.find(a => a.id === app.account.id)?.slug ?? null) : null,
834
+ recommended: r.data.recommended,
835
+ accounts: list.map(a => ({
836
+ slug: a.slug, label: a.label, available: a.available, status: a.status,
837
+ five_hour_pct: a.five_hour_pct, five_hour_resets_at: a.five_hour_resets_at,
838
+ weekly_pct: a.weekly_pct, weekly_resets_at: a.weekly_resets_at,
839
+ })),
840
+ }, null, 2))
841
+ }
842
+ else for (const a of list.filter(a => a.available)) console.log(a.slug)
843
+ process.exit(0)
844
+ }
845
+
740
846
  // `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
741
847
  await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.4.2",
3
+ "version": "0.5.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" },