@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.
- package/bin/ccx.js +73 -25
- 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
|
-
|
|
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)
|
|
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('[
|
|
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)
|
|
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
|
|
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,
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (e.
|
|
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
|
-
|
|
353
|
-
for (const k of
|
|
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
|
-
|
|
374
|
-
|
|
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)
|
|
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
|
-
|
|
393
|
-
|
|
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:
|
|
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: ${
|
|
401
|
-
return
|
|
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
|
}
|