@nectarcrm/ccx 0.4.1 → 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.
- package/README.md +3 -2
- package/bin/ccx.js +119 -21
- 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 (
|
|
22
|
-
ccx
|
|
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
|
|
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
|
|
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}"
|
|
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>
|
|
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>
|
|
@@ -211,8 +214,9 @@ function installAgent() {
|
|
|
211
214
|
return true
|
|
212
215
|
}
|
|
213
216
|
if (WIN) {
|
|
217
|
+
// node.exe é app de console: direto pelo Agendador abre uma janela a cada rodada. O conhost --headless roda sem janela.
|
|
214
218
|
execFileSync('schtasks', ['/Create', '/F', '/SC', 'MINUTE', '/MO', String(AGENT_MINUTES), '/TN', AGENT_ID,
|
|
215
|
-
'/TR', `"${process.execPath}" "${SELF}"
|
|
219
|
+
'/TR', `conhost.exe --headless "${process.execPath}" "${SELF}" watch`], { stdio: 'ignore' })
|
|
216
220
|
return true
|
|
217
221
|
}
|
|
218
222
|
return installLinuxAgent()
|
|
@@ -243,8 +247,6 @@ async function agentOnce() {
|
|
|
243
247
|
writeJson(APP, removeAccount(app))
|
|
244
248
|
return log('login vencido: conta tirada deste computador')
|
|
245
249
|
}
|
|
246
|
-
try { await sync(app, key, true) }
|
|
247
|
-
catch (e) { log(`sync falhou: ${e.message}`) }
|
|
248
250
|
const r = await api(app, key, 'POST', '/api/cli/heartbeat', { account_id: app.account?.id ?? null }, 20_000)
|
|
249
251
|
if (r.status === 401) {
|
|
250
252
|
writeJson(APP, removeAccount(app))
|
|
@@ -378,7 +380,7 @@ function addToDay(days, e) {
|
|
|
378
380
|
return key
|
|
379
381
|
}
|
|
380
382
|
|
|
381
|
-
// Só um sync por vez (
|
|
383
|
+
// Só um sync por vez (dois ccx sync ao mesmo tempo). Trava velha (sync que caiu) é ignorada.
|
|
382
384
|
function takeLock() {
|
|
383
385
|
mkdirSync(DIR, { recursive: true })
|
|
384
386
|
try { closeSync(openSync(SYNC_LOCK, 'wx')); return true }
|
|
@@ -391,7 +393,7 @@ function takeLock() {
|
|
|
391
393
|
|
|
392
394
|
async function sync(app, key, quiet = false) {
|
|
393
395
|
const say = msg => { if (!quiet) process.stderr.write(msg) }
|
|
394
|
-
if (!takeLock())
|
|
396
|
+
if (!takeLock()) { say('Outro sync já está rodando. Tente de novo daqui a pouco.\n'); return null }
|
|
395
397
|
try {
|
|
396
398
|
let state = readJson(SYNC_STATE, {})
|
|
397
399
|
if (state.v !== SYNC_VERSION) state = {}
|
|
@@ -451,6 +453,81 @@ async function sync(app, key, quiet = false) {
|
|
|
451
453
|
finally { rmSync(SYNC_LOCK, { force: true }) }
|
|
452
454
|
}
|
|
453
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
|
+
|
|
454
531
|
// ---------- doctor: confere se o claude está mesmo usando a conta escolhida ----------
|
|
455
532
|
// Nunca mostra token: compara só a impressão digital (fingerprint).
|
|
456
533
|
const AUTH_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']
|
|
@@ -563,7 +640,7 @@ async function doctor() {
|
|
|
563
640
|
}
|
|
564
641
|
else ok('Telemetria ligada (tokens vão pro painel)')
|
|
565
642
|
if (agentInstalled()) ok(`Vigia instalado (roda a cada ${AGENT_MINUTES} min)`)
|
|
566
|
-
else aviso('O vigia não está instalado: token novo e
|
|
643
|
+
else aviso('O vigia não está instalado: token novo e acesso removido não chegam sozinhos', 'ccx (escolha a conta de novo)')
|
|
567
644
|
// Só o que deu errado nas últimas 24 h (o log tem a data no começo de cada linha)
|
|
568
645
|
const dayAgo = new Date(Date.now() - 86_400_000).toISOString()
|
|
569
646
|
const lastErr = existsSync(LOG) && readFileSync(LOG, 'utf8').trim().split('\n').filter(l => l >= dayAgo && /falhou|removid|vencid/.test(l)).pop()
|
|
@@ -678,14 +755,19 @@ async function useAccount(app, key, wanted) {
|
|
|
678
755
|
if (process.env.ANTHROPIC_API_KEY) console.error(c(33, 'Aviso: ANTHROPIC_API_KEY está definida e tem prioridade sobre a conta escolhida.'))
|
|
679
756
|
console.error(`\n${c(32, '✓')} Este computador agora usa a conta ${c(1, s.data.account.label)}.`)
|
|
680
757
|
console.error(' Todo claude aberto a partir de agora usa essa conta. Os que já estão abertos continuam na anterior.')
|
|
681
|
-
if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx
|
|
758
|
+
if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx watch" de vez em quando.'))
|
|
682
759
|
}
|
|
683
760
|
|
|
684
761
|
// ---------- comandos ----------
|
|
685
762
|
const [cmd, ...rest] = process.argv.slice(2)
|
|
686
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
|
+
|
|
687
769
|
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
688
|
-
console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0,
|
|
770
|
+
console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 21).map(l => l.slice(3)).join('\n'))
|
|
689
771
|
process.exit(0)
|
|
690
772
|
}
|
|
691
773
|
|
|
@@ -694,8 +776,10 @@ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
|
|
|
694
776
|
process.exit(0)
|
|
695
777
|
}
|
|
696
778
|
|
|
697
|
-
if (cmd === 'vigia') {
|
|
779
|
+
if (cmd === 'watch' || cmd === 'vigia') { // vigia: os agendamentos já instalados ainda chamam assim
|
|
698
780
|
await agentOnce()
|
|
781
|
+
await autoSync()
|
|
782
|
+
await autoUpdate()
|
|
699
783
|
process.exit(0)
|
|
700
784
|
}
|
|
701
785
|
|
|
@@ -716,7 +800,7 @@ if (cmd === 'logout') {
|
|
|
716
800
|
process.exit(0)
|
|
717
801
|
}
|
|
718
802
|
|
|
719
|
-
if (cmd === 'sair') {
|
|
803
|
+
if (cmd === 'off' || cmd === 'sair') {
|
|
720
804
|
writeJson(APP, removeAccount(app))
|
|
721
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')
|
|
722
806
|
process.exit(0)
|
|
@@ -738,12 +822,26 @@ if (cmd === 'status') {
|
|
|
738
822
|
process.exit(0)
|
|
739
823
|
}
|
|
740
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
|
+
|
|
741
846
|
// `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
|
|
742
847
|
await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
|
|
743
|
-
|
|
744
|
-
// Logo depois do login, manda o resumo das sessões (as próximas vão pelo vigia)
|
|
745
|
-
if (cmd === 'login') {
|
|
746
|
-
console.error('')
|
|
747
|
-
try { await sync(app, key) }
|
|
748
|
-
catch (e) { console.error(c(33, `\nO sync falhou (${e.message}). Tente: ccx sync`)) }
|
|
749
|
-
}
|