@nectarcrm/ccx 0.1.0 → 0.2.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 +2 -1
- package/bin/ccx.js +182 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,10 +13,11 @@ npm install -g @nectarcrm/ccx
|
|
|
13
13
|
## Usar
|
|
14
14
|
|
|
15
15
|
```
|
|
16
|
-
ccx login
|
|
16
|
+
ccx login # autoriza este computador (abre o navegador) e escolhe a conta
|
|
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 sync # manda pro app o resumo das sessões (roda sozinho após o login e a cada 5 min)
|
|
20
21
|
ccx sair # o claude volta ao seu login normal
|
|
21
22
|
ccx logout # sai do app neste computador
|
|
22
23
|
```
|
package/bin/ccx.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ccx — escolhe qual conta Claude este computador usa. Conectado ao app Contas Claude.
|
|
3
3
|
//
|
|
4
|
-
// ccx login
|
|
4
|
+
// ccx login autoriza este computador (abre o navegador) e escolhe a conta
|
|
5
|
+
// (outro app: ccx login https://endereco)
|
|
5
6
|
// ccx mostra suas contas e troca a conta deste computador
|
|
6
7
|
// ccx <conta> troca direto (nome curto, ex.: claude-infra)
|
|
7
8
|
// ccx status conta atual e uso das contas liberadas pra você
|
|
9
|
+
// ccx sync manda pro app o resumo das sessões do Claude (totais por projeto e dia; nunca o texto)
|
|
8
10
|
// ccx sair tira a conta deste computador (o claude volta ao login normal)
|
|
9
11
|
// ccx logout sai do app: tira a conta, a autorização e o vigia
|
|
10
12
|
// ccx --version versão instalada (atualizar: npm install -g @nectarcrm/ccx@latest)
|
|
@@ -13,12 +15,13 @@
|
|
|
13
15
|
// (terminal, VS Code, JetBrains) usa essa conta. Claudes já abertos continuam na anterior.
|
|
14
16
|
// O vigia roda sozinho a cada 5 min: avisa o app, atualiza o token se o admin trocou
|
|
15
17
|
// e tira a conta se o acesso for removido. Os tokens gastos vão pro app pela telemetria.
|
|
18
|
+
// O vigia também roda o sync, então o resumo dos projetos chega sozinho.
|
|
16
19
|
|
|
17
20
|
import { execFileSync } from 'node:child_process'
|
|
18
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
19
22
|
import { createHash } from 'node:crypto'
|
|
20
23
|
import { homedir, hostname } from 'node:os'
|
|
21
|
-
import { dirname, join } from 'node:path'
|
|
24
|
+
import { basename, dirname, join } from 'node:path'
|
|
22
25
|
import { fileURLToPath } from 'node:url'
|
|
23
26
|
|
|
24
27
|
const DIR = join(homedir(), '.config', 'ccx')
|
|
@@ -27,6 +30,7 @@ const LOG = join(DIR, 'vigia.log')
|
|
|
27
30
|
const SETTINGS = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json')
|
|
28
31
|
const WIN = process.platform === 'win32'
|
|
29
32
|
const MAC = process.platform === 'darwin'
|
|
33
|
+
const DEFAULT_URL = 'https://ccx.nectarcrm.com.br'
|
|
30
34
|
const SERVICE = 'ccx-app' // Keychain: guarda a chave do computador
|
|
31
35
|
const AGENT_MINUTES = 5
|
|
32
36
|
const AGENT_ID = 'br.com.nectarcrm.ccx' // launchd (Mac) / nome da tarefa (Windows)
|
|
@@ -219,6 +223,8 @@ async function agentOnce() {
|
|
|
219
223
|
writeJson(APP, removeAccount(app))
|
|
220
224
|
return log('login vencido: conta tirada deste computador')
|
|
221
225
|
}
|
|
226
|
+
try { await sync(app, key, true) }
|
|
227
|
+
catch (e) { log(`sync falhou: ${e.message}`) }
|
|
222
228
|
const r = await api(app, key, 'POST', '/api/cli/heartbeat', { account_id: app.account?.id ?? null }, 20_000)
|
|
223
229
|
if (r.status === 401) {
|
|
224
230
|
writeJson(APP, removeAccount(app))
|
|
@@ -240,6 +246,162 @@ async function agentOnce() {
|
|
|
240
246
|
}
|
|
241
247
|
}
|
|
242
248
|
|
|
249
|
+
// ---------- 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.
|
|
252
|
+
// Os arquivos só crescem: guardamos até onde lemos cada um e os totais, e mandamos só o que mudou.
|
|
253
|
+
const PROJECTS = join(dirname(SETTINGS), 'projects')
|
|
254
|
+
const SYNC_STATE = join(DIR, 'sync.json')
|
|
255
|
+
const SYNC_LOCK = join(DIR, 'sync.lock')
|
|
256
|
+
const SYNC_BATCH = 2000
|
|
257
|
+
const SYNC_CHUNK = 1 << 20
|
|
258
|
+
const SYNC_KEEP_DAYS = 40 // o Claude apaga as sessões com 30 dias
|
|
259
|
+
const brtDay = ts => new Date(Date.parse(ts) - 3 * 3600_000).toISOString().slice(0, 10)
|
|
260
|
+
|
|
261
|
+
function jsonlFiles(dir) {
|
|
262
|
+
const out = []
|
|
263
|
+
let list = []
|
|
264
|
+
try { list = readdirSync(dir, { withFileTypes: true }) }
|
|
265
|
+
catch { return out }
|
|
266
|
+
for (const e of list) {
|
|
267
|
+
const p = join(dir, e.name)
|
|
268
|
+
if (e.isDirectory()) out.push(...jsonlFiles(p))
|
|
269
|
+
else if (e.name.endsWith('.jsonl')) out.push(p)
|
|
270
|
+
}
|
|
271
|
+
return out
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Nome do projeto: o repositório do git (vale pra worktree também); sem git, o nome da pasta
|
|
275
|
+
const projectNames = new Map()
|
|
276
|
+
function projectOf(cwd) {
|
|
277
|
+
if (!projectNames.has(cwd)) {
|
|
278
|
+
let name = basename(cwd) || cwd
|
|
279
|
+
try {
|
|
280
|
+
const url = execFileSync('git', ['-C', cwd, 'remote', 'get-url', 'origin'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
|
|
281
|
+
name = basename(url.replace(/[/:]+$/, '')).replace(/\.git$/, '') || name
|
|
282
|
+
}
|
|
283
|
+
catch {}
|
|
284
|
+
projectNames.set(cwd, name)
|
|
285
|
+
}
|
|
286
|
+
return projectNames.get(cwd)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Lê só as linhas completas a partir de `from`. Devolve as respostas e pedidos novos e até onde leu.
|
|
290
|
+
function readNewLines(file, from) {
|
|
291
|
+
const events = new Map()
|
|
292
|
+
const fd = openSync(file, 'r')
|
|
293
|
+
const buf = Buffer.alloc(SYNC_CHUNK)
|
|
294
|
+
let pos = from, rest = Buffer.alloc(0)
|
|
295
|
+
try {
|
|
296
|
+
for (;;) {
|
|
297
|
+
const n = readSync(fd, buf, 0, SYNC_CHUNK, pos + rest.length)
|
|
298
|
+
if (!n) break
|
|
299
|
+
let data = Buffer.concat([rest, buf.subarray(0, n)])
|
|
300
|
+
let nl
|
|
301
|
+
while ((nl = data.indexOf(10)) !== -1) {
|
|
302
|
+
const lineBuf = data.subarray(0, nl)
|
|
303
|
+
data = data.subarray(nl + 1)
|
|
304
|
+
pos += nl + 1
|
|
305
|
+
// Filtro barato antes de ler o JSON (as linhas com resultado de ferramenta são enormes)
|
|
306
|
+
const assistant = lineBuf.includes('"type":"assistant"') && lineBuf.includes('"usage"')
|
|
307
|
+
const prompt = !assistant && lineBuf.includes('"type":"user"') && !lineBuf.includes('"tool_result"')
|
|
308
|
+
if (assistant || prompt) addLine(events, lineBuf.toString())
|
|
309
|
+
}
|
|
310
|
+
rest = Buffer.from(data)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
finally { closeSync(fd) }
|
|
314
|
+
return { events: [...events.values()], offset: pos }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function addLine(events, text) {
|
|
318
|
+
let o
|
|
319
|
+
try { o = JSON.parse(text) }
|
|
320
|
+
catch { return }
|
|
321
|
+
if (!o.cwd || !o.sessionId || !o.timestamp || Number.isNaN(Date.parse(o.timestamp))) return
|
|
322
|
+
const base = { session_id: o.sessionId, project: projectOf(o.cwd), branch: o.gitBranch || null, at: o.timestamp }
|
|
323
|
+
if (o.type === 'assistant' && o.requestId && o.message?.usage) {
|
|
324
|
+
// Uma resposta vem em várias linhas (uma por bloco), com o mesmo requestId
|
|
325
|
+
const u = o.message.usage
|
|
326
|
+
const tools = (events.get(o.requestId)?.tools ?? 0) + (o.message.content ?? []).filter(b => b?.type === 'tool_use').length
|
|
327
|
+
events.set(o.requestId, {
|
|
328
|
+
...base, kind: 'resposta', tools,
|
|
329
|
+
input_tokens: u.input_tokens ?? 0, output_tokens: u.output_tokens ?? 0,
|
|
330
|
+
cache_read_tokens: u.cache_read_input_tokens ?? 0, cache_creation_tokens: u.cache_creation_input_tokens ?? 0,
|
|
331
|
+
})
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
// Pedido digitado pela pessoa (não conta subagente, comando interno nem mensagem do sistema)
|
|
335
|
+
const content = o.message?.content
|
|
336
|
+
const typed = typeof content === 'string' ? content : Array.isArray(content) ? content.find(b => b?.type === 'text')?.text : null
|
|
337
|
+
if (o.type === 'user' && o.uuid && !o.isSidechain && !o.isMeta && typed && !typed.trimStart().startsWith('<')) {
|
|
338
|
+
events.set(`p:${o.uuid}`, { ...base, kind: 'pedido' })
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Soma um evento no total da sessão, projeto e dia
|
|
343
|
+
function addToDay(days, e) {
|
|
344
|
+
const day = brtDay(e.at)
|
|
345
|
+
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++
|
|
350
|
+
else {
|
|
351
|
+
d.responses++
|
|
352
|
+
for (const k of ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens']) d[k] += e[k]
|
|
353
|
+
}
|
|
354
|
+
return key
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Só um sync por vez (o vigia pode rodar junto com o login). Trava velha (sync que caiu) é ignorada.
|
|
358
|
+
function takeLock() {
|
|
359
|
+
mkdirSync(DIR, { recursive: true })
|
|
360
|
+
try { closeSync(openSync(SYNC_LOCK, 'wx')); return true }
|
|
361
|
+
catch {
|
|
362
|
+
try { if (Date.now() - statSync(SYNC_LOCK).mtimeMs < 30 * 60_000) return false } catch {}
|
|
363
|
+
rmSync(SYNC_LOCK, { force: true })
|
|
364
|
+
try { closeSync(openSync(SYNC_LOCK, 'wx')); return true } catch { return false }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function sync(app, key, quiet = false) {
|
|
369
|
+
const say = msg => { if (!quiet) process.stderr.write(msg) }
|
|
370
|
+
if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
|
|
371
|
+
try {
|
|
372
|
+
const state = readJson(SYNC_STATE, {})
|
|
373
|
+
const offsets = state.files ?? {}, days = state.days ?? {}
|
|
374
|
+
const changed = new Set()
|
|
375
|
+
const files = jsonlFiles(PROJECTS)
|
|
376
|
+
say(`Lendo as sessões do Claude Code em ${PROJECTS}`)
|
|
377
|
+
for (const file of files) {
|
|
378
|
+
const size = statSync(file).size
|
|
379
|
+
const from = offsets[file] ?? 0
|
|
380
|
+
if (from >= size) continue // nada novo (ou arquivo trocado: não soma de novo)
|
|
381
|
+
const { events, offset } = readNewLines(file, from)
|
|
382
|
+
for (const e of events) changed.add(addToDay(days, e))
|
|
383
|
+
offsets[file] = offset
|
|
384
|
+
}
|
|
385
|
+
// Esquece arquivo apagado e dia antigo
|
|
386
|
+
const alive = new Set(files)
|
|
387
|
+
for (const f of Object.keys(offsets)) if (!alive.has(f)) delete offsets[f]
|
|
388
|
+
const oldest = brtDay(new Date(Date.now() - SYNC_KEEP_DAYS * 86_400_000).toISOString())
|
|
389
|
+
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) {
|
|
393
|
+
say('.')
|
|
394
|
+
const r = await api(app, key, 'POST', '/api/cli/sync', { items: items.slice(i, i + SYNC_BATCH) })
|
|
395
|
+
if (r.status !== 200) throw new Error(r.data.message ?? `HTTP ${r.status}`)
|
|
396
|
+
}
|
|
397
|
+
// 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
|
|
401
|
+
}
|
|
402
|
+
finally { rmSync(SYNC_LOCK, { force: true }) }
|
|
403
|
+
}
|
|
404
|
+
|
|
243
405
|
// ---------- saída ----------
|
|
244
406
|
const isTTY = process.stderr.isTTY
|
|
245
407
|
const c = (code, s) => (isTTY ? `\x1b[${code}m${s}\x1b[0m` : s)
|
|
@@ -289,7 +451,7 @@ async function login(url) {
|
|
|
289
451
|
const start = await api(target, null, 'POST', '/api/cli/device', { hostname: hostname(), platform: process.platform })
|
|
290
452
|
if (start.status !== 200) die(`Não consegui falar com o app: ${start.data.message ?? `HTTP ${start.status}`}`)
|
|
291
453
|
const { device_code, user_code, verify_url, expires_in, interval } = start.data
|
|
292
|
-
console.error(`\
|
|
454
|
+
console.error(`\nNo navegador, digite este código:\n\n ${c(1, user_code)}\n\n ${verify_url}\n`)
|
|
293
455
|
openBrowser(verify_url)
|
|
294
456
|
process.stderr.write('Esperando a aprovação')
|
|
295
457
|
const deadline = Date.now() + expires_in * 1000
|
|
@@ -344,7 +506,7 @@ async function useAccount(app, key, wanted) {
|
|
|
344
506
|
const [cmd, ...rest] = process.argv.slice(2)
|
|
345
507
|
|
|
346
508
|
if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
|
|
347
|
-
console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0,
|
|
509
|
+
console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 17).map(l => l.slice(3)).join('\n'))
|
|
348
510
|
process.exit(0)
|
|
349
511
|
}
|
|
350
512
|
|
|
@@ -358,11 +520,11 @@ if (cmd === 'vigia') {
|
|
|
358
520
|
process.exit(0)
|
|
359
521
|
}
|
|
360
522
|
|
|
361
|
-
if (cmd === 'login') await login(rest[0] ?? readJson(APP, null)?.url ??
|
|
523
|
+
if (cmd === 'login') await login(rest[0] ?? readJson(APP, null)?.url ?? DEFAULT_URL)
|
|
362
524
|
|
|
363
525
|
const app = readJson(APP, null)
|
|
364
526
|
const key = app && secretGet()
|
|
365
|
-
if (!app || !key) die('Este computador ainda não está conectado. Rode: ccx login
|
|
527
|
+
if (!app || !key) die('Este computador ainda não está conectado. Rode: ccx login')
|
|
366
528
|
|
|
367
529
|
if (cmd === 'logout') {
|
|
368
530
|
removeAccount(app)
|
|
@@ -379,6 +541,12 @@ if (cmd === 'sair') {
|
|
|
379
541
|
process.exit(0)
|
|
380
542
|
}
|
|
381
543
|
|
|
544
|
+
if (cmd === 'sync') {
|
|
545
|
+
try { await sync(app, key) }
|
|
546
|
+
catch (e) { die(`\nO sync falhou: ${e.message}`) }
|
|
547
|
+
process.exit(0)
|
|
548
|
+
}
|
|
549
|
+
|
|
382
550
|
if (cmd === 'status') {
|
|
383
551
|
const r = await api(app, key, 'GET', '/api/cli/status')
|
|
384
552
|
if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
|
|
@@ -391,3 +559,10 @@ if (cmd === 'status') {
|
|
|
391
559
|
|
|
392
560
|
// `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
|
|
393
561
|
await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
|
|
562
|
+
|
|
563
|
+
// Logo depois do login, manda o resumo das sessões (as próximas vão pelo vigia)
|
|
564
|
+
if (cmd === 'login') {
|
|
565
|
+
console.error('')
|
|
566
|
+
try { await sync(app, key) }
|
|
567
|
+
catch (e) { console.error(c(33, `\nO sync falhou (${e.message}). Tente: ccx sync`)) }
|
|
568
|
+
}
|