@nectarcrm/ccx 0.4.2 → 0.5.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 +3 -2
- package/bin/ccx.js +128 -14
- 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 resumo dos projetos
|
|
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>
|
|
@@ -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}"
|
|
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())
|
|
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
|
|
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,
|
|
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
|
|
|
@@ -702,8 +787,16 @@ if (cmd === 'doctor') await doctor()
|
|
|
702
787
|
|
|
703
788
|
if (cmd === 'login') await login(rest[0] ?? readJson(APP, null)?.url ?? DEFAULT_URL)
|
|
704
789
|
|
|
705
|
-
|
|
706
|
-
|
|
790
|
+
let app = readJson(APP, null)
|
|
791
|
+
let key = app && secretGet()
|
|
792
|
+
// `ccx` sozinho num terminal sem login: faz o login e segue pra escolha da conta.
|
|
793
|
+
// Os outros comandos (e scripts, sem terminal) só avisam: nunca abrem o navegador sem a pessoa pedir.
|
|
794
|
+
if ((!app || !key) && !cmd && process.stdin.isTTY) {
|
|
795
|
+
console.error('Este computador ainda não está conectado. Vamos fazer o login.\n')
|
|
796
|
+
await login(app?.url ?? DEFAULT_URL)
|
|
797
|
+
app = readJson(APP, null)
|
|
798
|
+
key = app && secretGet()
|
|
799
|
+
}
|
|
707
800
|
if (!app || !key) die('Este computador ainda não está conectado. Rode: ccx login')
|
|
708
801
|
|
|
709
802
|
if (cmd === 'logout') {
|
|
@@ -715,7 +808,7 @@ if (cmd === 'logout') {
|
|
|
715
808
|
process.exit(0)
|
|
716
809
|
}
|
|
717
810
|
|
|
718
|
-
if (cmd === 'sair') {
|
|
811
|
+
if (cmd === 'off' || cmd === 'sair') {
|
|
719
812
|
writeJson(APP, removeAccount(app))
|
|
720
813
|
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
814
|
process.exit(0)
|
|
@@ -737,5 +830,26 @@ if (cmd === 'status') {
|
|
|
737
830
|
process.exit(0)
|
|
738
831
|
}
|
|
739
832
|
|
|
833
|
+
// Pra scripts e agentes: uma conta por linha, pronta pra `ccx <conta>`
|
|
834
|
+
if (cmd === 'list') {
|
|
835
|
+
const r = await api(app, key, 'GET', '/api/cli/status?fresh=1')
|
|
836
|
+
if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
|
|
837
|
+
const peak = a => Math.max(a.five_hour_pct ?? 0, a.weekly_pct ?? 0)
|
|
838
|
+
const list = [...r.data.accounts].sort((a, b) => b.available - a.available || peak(a) - peak(b) || a.slug.localeCompare(b.slug))
|
|
839
|
+
if (rest.includes('--json')) {
|
|
840
|
+
console.log(JSON.stringify({
|
|
841
|
+
current: app.account ? (r.data.accounts.find(a => a.id === app.account.id)?.slug ?? null) : null,
|
|
842
|
+
recommended: r.data.recommended,
|
|
843
|
+
accounts: list.map(a => ({
|
|
844
|
+
slug: a.slug, label: a.label, available: a.available, status: a.status,
|
|
845
|
+
five_hour_pct: a.five_hour_pct, five_hour_resets_at: a.five_hour_resets_at,
|
|
846
|
+
weekly_pct: a.weekly_pct, weekly_resets_at: a.weekly_resets_at,
|
|
847
|
+
})),
|
|
848
|
+
}, null, 2))
|
|
849
|
+
}
|
|
850
|
+
else for (const a of list.filter(a => a.available)) console.log(a.slug)
|
|
851
|
+
process.exit(0)
|
|
852
|
+
}
|
|
853
|
+
|
|
740
854
|
// `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
|
|
741
855
|
await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
|