@nectarcrm/ccx 0.3.0 → 0.4.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.
Files changed (2) hide show
  1. package/bin/ccx.js +73 -25
  2. package/package.json +1 -1
package/bin/ccx.js CHANGED
@@ -45,13 +45,24 @@ const fingerprint = t => createHash('sha256').update(t).digest('hex').slice(0, 1
45
45
  // macOS: Keychain. Windows: DPAPI (só o usuário do Windows abre). Linux: cofre do sistema
46
46
  // (secret-tool/libsecret) ou, sem ele (servidor, WSL), arquivo que só o usuário lê.
47
47
  // O segredo sempre passa por stdin, nunca pela linha de comando.
48
- const ps = (script, input) => execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { input, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
48
+ // PSModulePath herdado de um PowerShell 7 impede o 5.1 de carregar módulos (ConvertTo-SecureString some),
49
+ // então o DPAPI é chamado direto pelo .NET e o PSModulePath é descartado.
50
+ const ps = (script, input) => {
51
+ const { PSModulePath, ...env } = process.env
52
+ return execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], { input, env, stdio: ['pipe', 'pipe', 'ignore'] }).toString().trim()
53
+ }
54
+ const DPAPI = "Add-Type -AssemblyName System.Security; $v = [Console]::In.ReadToEnd().Trim(); "
55
+ const DPAPI_PREFIX = 'dpapi:'
49
56
  const KEY_FILE = join(DIR, 'chave') // Windows (DPAPI) e Linux sem cofre
50
57
  const SECRET_TOOL = ['service', SERVICE, 'account', 'chave']
51
58
 
52
59
  function secretGet() {
53
60
  try {
54
- if (WIN) return ps('$s = [Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', readFileSync(KEY_FILE, 'utf8'))
61
+ if (WIN) {
62
+ const stored = readFileSync(KEY_FILE, 'utf8').trim()
63
+ if (!stored.startsWith(DPAPI_PREFIX)) return ps('$s = [Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString; [Runtime.InteropServices.Marshal]::PtrToStringBSTR([Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', stored) // formato antigo
64
+ return ps(DPAPI + '[Text.Encoding]::UTF8.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($v), $null, "CurrentUser"))', stored.slice(DPAPI_PREFIX.length))
65
+ }
55
66
  if (MAC) return execFileSync('security', ['find-generic-password', '-s', SERVICE, '-a', 'chave', '-w'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim()
56
67
  if (existsSync(KEY_FILE)) return readFileSync(KEY_FILE, 'utf8').trim()
57
68
  return execFileSync('secret-tool', ['lookup', ...SECRET_TOOL], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim() || null
@@ -61,7 +72,7 @@ function secretGet() {
61
72
  function secretSet(value) {
62
73
  if (WIN) {
63
74
  mkdirSync(DIR, { recursive: true })
64
- writeFileSync(KEY_FILE, ps('[Console]::In.ReadToEnd().Trim() | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString', value))
75
+ writeFileSync(KEY_FILE, DPAPI_PREFIX + ps(DPAPI + '[Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($v), $null, "CurrentUser"))', value))
65
76
  return
66
77
  }
67
78
  if (MAC) {
@@ -101,6 +112,14 @@ function writeSettings(s) {
101
112
  writeJson(SETTINGS, s)
102
113
  if (!WIN) chmodSync(SETTINGS, 0o600) // agora guarda um token
103
114
  }
115
+ // Quando cada conta passou a valer neste computador (o sync usa pra saber a conta de cada sessão)
116
+ const HISTORY_DAYS = 45
117
+ function withHistory(app, id) {
118
+ const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString()
119
+ const history = (app?.history ?? []).filter(h => h.at >= since)
120
+ if (history.at(-1)?.id !== id) history.push({ at: new Date().toISOString(), id })
121
+ return history
122
+ }
104
123
  function applyAccount(app, key, account, token) {
105
124
  const s = readSettings()
106
125
  const env = { ...s.env }
@@ -117,7 +136,7 @@ function applyAccount(app, key, account, token) {
117
136
  // Métricas não são usadas; desliga só se a pessoa não configurou as dela
118
137
  if (!('OTEL_METRICS_EXPORTER' in env) || app.owned?.includes('OTEL_METRICS_EXPORTER')) vars.OTEL_METRICS_EXPORTER = 'none'
119
138
  writeSettings({ ...s, env: { ...env, ...vars } })
120
- return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars) }
139
+ return { ...app, account: { id: account.id, slug: account.slug, label: account.label }, token_fp: fingerprint(token), owned: Object.keys(vars), history: withHistory(app, account.id) }
121
140
  }
122
141
  function removeAccount(app) {
123
142
  if (app?.owned?.length && existsSync(SETTINGS)) {
@@ -128,7 +147,7 @@ function removeAccount(app) {
128
147
  else delete s.env
129
148
  writeSettings(s)
130
149
  }
131
- return { ...app, account: null, token_fp: null, owned: [] }
150
+ return { ...app, account: null, token_fp: null, owned: [], history: withHistory(app, null) }
132
151
  }
133
152
 
134
153
  // ---------- vigia em segundo plano ----------
@@ -248,8 +267,8 @@ async function agentOnce() {
248
267
  }
249
268
 
250
269
  // ---------- sync: resumo das sessões do Claude Code ----------
251
- // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília) e manda só os
252
- // totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
270
+ // Lê ~/.claude/projects/**/*.jsonl, soma por sessão, projeto e dia (de Brasília), separando branch e modelo,
271
+ // e manda só os totais: pedidos, respostas, ferramentas e tokens. Nunca o texto da conversa.
253
272
  // Os arquivos só crescem: guardamos até onde lemos cada um e os totais, e mandamos só o que mudou.
254
273
  const PROJECTS = join(dirname(SETTINGS), 'projects')
255
274
  const SYNC_STATE = join(DIR, 'sync.json')
@@ -257,6 +276,7 @@ const SYNC_LOCK = join(DIR, 'sync.lock')
257
276
  const SYNC_BATCH = 2000
258
277
  const SYNC_CHUNK = 1 << 20
259
278
  const SYNC_KEEP_DAYS = 40 // o Claude apaga as sessões com 30 dias
279
+ const SYNC_VERSION = 2 // mudou o formato do sync.json: lê tudo de novo
260
280
  const brtDay = ts => new Date(Date.parse(ts) - 3 * 3600_000).toISOString().slice(0, 10)
261
281
 
262
282
  function jsonlFiles(dir) {
@@ -326,9 +346,10 @@ function addLine(events, text) {
326
346
  const u = o.message.usage
327
347
  const tools = (events.get(o.requestId)?.tools ?? 0) + (o.message.content ?? []).filter(b => b?.type === 'tool_use').length
328
348
  events.set(o.requestId, {
329
- ...base, kind: 'resposta', tools,
349
+ ...base, kind: 'resposta', tools, model: typeof o.message.model === 'string' ? o.message.model : '',
330
350
  input_tokens: u.input_tokens ?? 0, output_tokens: u.output_tokens ?? 0,
331
351
  cache_read_tokens: u.cache_read_input_tokens ?? 0, cache_creation_tokens: u.cache_creation_input_tokens ?? 0,
352
+ cache_1h_tokens: u.cache_creation?.ephemeral_1h_input_tokens ?? 0,
332
353
  })
333
354
  return
334
355
  }
@@ -340,17 +361,19 @@ function addLine(events, text) {
340
361
  }
341
362
  }
342
363
 
343
- // Soma um evento no total da sessão, projeto e dia
364
+ // Soma um evento no dia da sessão e projeto, na parte da branch e do modelo dele
365
+ const SUMS = ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens', 'cache_1h_tokens']
344
366
  function addToDay(days, e) {
345
367
  const day = brtDay(e.at)
346
368
  const key = `${e.session_id}\t${e.project}\t${day}`
347
- const d = days[key] ??= { session_id: e.session_id, project: e.project, day, branch: null, last_at: e.at,
348
- prompts: 0, responses: 0, tools: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_creation_tokens: 0 }
349
- if (e.at >= d.last_at) { d.last_at = e.at; d.branch = e.branch ?? d.branch }
350
- if (e.kind === 'pedido') d.prompts++
369
+ const d = days[key] ??= { session_id: e.session_id, project: e.project, day, parts: {} }
370
+ const branch = e.branch ?? '', model = e.model ?? ''
371
+ const p = d.parts[`${branch}\t${model}`] ??= { branch, model, last_at: e.at, prompts: 0, responses: 0, ...Object.fromEntries(SUMS.map(k => [k, 0])) }
372
+ if (e.at > p.last_at) p.last_at = e.at
373
+ if (e.kind === 'pedido') p.prompts++
351
374
  else {
352
- d.responses++
353
- for (const k of ['tools', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_creation_tokens']) d[k] += e[k]
375
+ p.responses++
376
+ for (const k of SUMS) p[k] += e[k]
354
377
  }
355
378
  return key
356
379
  }
@@ -370,9 +393,16 @@ async function sync(app, key, quiet = false) {
370
393
  const say = msg => { if (!quiet) process.stderr.write(msg) }
371
394
  if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
372
395
  try {
373
- const state = readJson(SYNC_STATE, {})
374
- const offsets = state.files ?? {}, days = state.days ?? {}
396
+ let state = readJson(SYNC_STATE, {})
397
+ if (state.v !== SYNC_VERSION) state = {}
398
+ const offsets = state.files ?? {}, days = state.days ?? {}, sessions = state.sessions ?? {}
375
399
  const changed = new Set()
400
+ // Modo oculto: da conta marcada pelo superadmin, só os projetos da lista saem daqui. App antigo: nenhuma.
401
+ const h = await api(app, key, 'GET', '/api/cli/hidden', null, 20_000)
402
+ if (h.status !== 200 && h.status !== 404) throw new Error(h.data.message ?? `HTTP ${h.status}`) // na dúvida, não manda nada
403
+ const hidden = h.status === 200 ? h.data.accounts ?? {} : {}
404
+ const accountAt = at => (app.history ?? []).filter(x => x.at <= at).at(-1)?.id ?? null
405
+ const excluded = new Set()
376
406
  const files = jsonlFiles(PROJECTS)
377
407
  say(`Lendo as sessões do Claude Code em ${PROJECTS}`)
378
408
  for (const file of files) {
@@ -380,7 +410,13 @@ async function sync(app, key, quiet = false) {
380
410
  const from = offsets[file] ?? 0
381
411
  if (from >= size) continue // nada novo (ou arquivo trocado: não soma de novo)
382
412
  const { events, offset } = readNewLines(file, from)
383
- for (const e of events) changed.add(addToDay(days, e))
413
+ for (const e of events) {
414
+ // A conta da sessão é a que valia quando ela começou (claude aberto não troca de conta)
415
+ const s = sessions[e.session_id] ??= { id: accountAt(e.at), at: e.at }
416
+ const only = s.id === null ? null : hidden[s.id]
417
+ if (only && !only.includes(e.project)) { excluded.add(e.session_id); continue }
418
+ changed.add(addToDay(days, e))
419
+ }
384
420
  offsets[file] = offset
385
421
  }
386
422
  // Esquece arquivo apagado e dia antigo
@@ -388,17 +424,29 @@ async function sync(app, key, quiet = false) {
388
424
  for (const f of Object.keys(offsets)) if (!alive.has(f)) delete offsets[f]
389
425
  const oldest = brtDay(new Date(Date.now() - SYNC_KEEP_DAYS * 86_400_000).toISOString())
390
426
  for (const [k, d] of Object.entries(days)) if (d.day < oldest) delete days[k]
391
-
392
- const items = [...changed].map(k => days[k]).filter(Boolean)
393
- for (let i = 0; i < items.length; i += SYNC_BATCH) {
427
+ for (const [k, s] of Object.entries(sessions)) if (brtDay(s.at) < oldest) delete sessions[k]
428
+
429
+ // O app troca o dia inteiro da sessão: as partes de um dia vão sempre no mesmo lote
430
+ const sent = [...changed].map(k => days[k]).filter(Boolean)
431
+ const items = sent.map(({ parts, ...d }) => Object.values(parts).map(p => ({ ...d, ...p })))
432
+ let batch = [], exclude = [...excluded]
433
+ const send = async () => {
434
+ if (!batch.length && !exclude.length) return
394
435
  say('.')
395
- const r = await api(app, key, 'POST', '/api/cli/sync', { items: items.slice(i, i + SYNC_BATCH) })
436
+ const r = await api(app, key, 'POST', '/api/cli/sync', { items: batch, ...(exclude.length && { exclude_sessions: exclude }) })
396
437
  if (r.status !== 200) throw new Error(r.data.message ?? `HTTP ${r.status}`)
438
+ batch = []
439
+ exclude = []
440
+ }
441
+ for (const day of items) {
442
+ if (batch.length + day.length > SYNC_BATCH) await send()
443
+ batch.push(...day.slice(0, SYNC_BATCH))
397
444
  }
445
+ await send()
398
446
  // Só grava depois de enviar: se falhar, a próxima vez lê e manda de novo
399
- writeJson(SYNC_STATE, { files: offsets, days })
400
- say(`\n${c(32, '✓')} Enviado: ${items.length} ${items.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
401
- return items.length
447
+ writeJson(SYNC_STATE, { v: SYNC_VERSION, files: offsets, days, sessions })
448
+ say(`\n${c(32, '✓')} Enviado: ${sent.length} ${sent.length === 1 ? 'resumo' : 'resumos'} (sessão, projeto e dia).\n`)
449
+ return sent.length
402
450
  }
403
451
  finally { rmSync(SYNC_LOCK, { force: true }) }
404
452
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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" },