@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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/bin/ccx.js +182 -7
  3. 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 https://endereco-do-app # autoriza este computador (abre o navegador) e escolhe a conta
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 <url do app> autoriza este computador (abre o navegador) e escolhe a conta
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(`\nAbra no navegador e confira o código:\n\n ${c(1, user_code)}\n\n ${verify_url}\n`)
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, 15).map(l => l.slice(3)).join('\n'))
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 ?? die('Uso: ccx login https://endereco-do-app'))
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 https://endereco-do-app')
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.1.0",
3
+ "version": "0.2.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" },