token-trace-manager 0.7.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/lib/db.mjs ADDED
@@ -0,0 +1,444 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ export function payloadsDir() {
6
+ const dir = path.join(dataDir(), 'payloads')
7
+ fs.mkdirSync(dir, { recursive: true })
8
+ return dir
9
+ }
10
+
11
+ export function payloadPath(id) {
12
+ return path.join(payloadsDir(), `${id}.json`)
13
+ }
14
+
15
+ function unlinkPayloads(ids) {
16
+ for (const id of ids) {
17
+ try {
18
+ fs.unlinkSync(payloadPath(id))
19
+ } catch {
20
+ /* sem payload */
21
+ }
22
+ }
23
+ }
24
+
25
+ export function dataDir() {
26
+ const dir =
27
+ process.env.TTM_DATA_DIR ||
28
+ path.join(os.homedir(), '.claude', 'plugins', 'data', 'token-trace-manager')
29
+ fs.mkdirSync(dir, { recursive: true })
30
+ return dir
31
+ }
32
+
33
+ const SCHEMA = `
34
+ CREATE TABLE IF NOT EXISTS usage_events (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ ts TEXT NOT NULL,
37
+ label TEXT,
38
+ target TEXT NOT NULL,
39
+ protocol TEXT NOT NULL,
40
+ model TEXT,
41
+ input_tokens INTEGER DEFAULT 0,
42
+ cache_create_tokens INTEGER DEFAULT 0,
43
+ cache_create_1h_tokens INTEGER DEFAULT 0,
44
+ cache_read_tokens INTEGER DEFAULT 0,
45
+ output_tokens INTEGER DEFAULT 0,
46
+ reasoning_tokens INTEGER DEFAULT 0,
47
+ cost_usd REAL,
48
+ rates TEXT,
49
+ provider_id TEXT,
50
+ repo TEXT,
51
+ branch TEXT,
52
+ status INTEGER,
53
+ duration_ms INTEGER,
54
+ client TEXT,
55
+ payload INTEGER DEFAULT 0,
56
+ usage_fields TEXT,
57
+ usage_unknown INTEGER DEFAULT 0
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_usage_events_ts ON usage_events(ts);
60
+ CREATE INDEX IF NOT EXISTS idx_usage_events_label ON usage_events(label);
61
+
62
+ CREATE TABLE IF NOT EXISTS kv (
63
+ key TEXT PRIMARY KEY,
64
+ value TEXT
65
+ );
66
+
67
+ -- Histórico permanente por tarefa: quando um trace expira (retenção), ele é
68
+ -- somado aqui ANTES de ser apagado — o detalhe morre, a soma sobrevive.
69
+ CREATE TABLE IF NOT EXISTS daily_rollups (
70
+ day TEXT NOT NULL,
71
+ label TEXT NOT NULL DEFAULT '',
72
+ target TEXT NOT NULL,
73
+ model TEXT NOT NULL DEFAULT '',
74
+ client TEXT NOT NULL DEFAULT '',
75
+ input_tokens INTEGER DEFAULT 0,
76
+ cache_create_tokens INTEGER DEFAULT 0,
77
+ cache_create_1h_tokens INTEGER DEFAULT 0,
78
+ cache_read_tokens INTEGER DEFAULT 0,
79
+ output_tokens INTEGER DEFAULT 0,
80
+ reasoning_tokens INTEGER DEFAULT 0,
81
+ cost_usd REAL DEFAULT 0,
82
+ cost_known INTEGER DEFAULT 1,
83
+ events INTEGER DEFAULT 0,
84
+ PRIMARY KEY (day, label, target, model, client)
85
+ );
86
+ `
87
+
88
+ // Migração aditiva: bases criadas por versões antigas ganham as colunas novas.
89
+ // (CREATE TABLE IF NOT EXISTS não altera tabela existente.) O índice de
90
+ // provider_id vive AQUI, nunca no SCHEMA: num banco antigo ele só pode ser
91
+ // criado depois do ALTER TABLE adicionar a coluna.
92
+ const MIGRATION_COLUMNS = [
93
+ ['cache_create_1h_tokens', 'INTEGER DEFAULT 0'],
94
+ ['rates', 'TEXT'],
95
+ ['provider_id', 'TEXT'],
96
+ ['repo', 'TEXT'],
97
+ ['branch', 'TEXT'],
98
+ ['payload', 'INTEGER DEFAULT 0'],
99
+ ['usage_fields', 'TEXT'],
100
+ ['usage_unknown', 'INTEGER DEFAULT 0'],
101
+ ]
102
+
103
+ function migrate(db) {
104
+ const existing = new Set(db.prepare('PRAGMA table_info(usage_events)').all().map(c => c.name))
105
+ for (const [col, type] of MIGRATION_COLUMNS) {
106
+ if (!existing.has(col)) db.exec(`ALTER TABLE usage_events ADD COLUMN ${col} ${type}`)
107
+ }
108
+ db.exec('CREATE INDEX IF NOT EXISTS idx_usage_events_provider_id ON usage_events(provider_id)')
109
+ }
110
+
111
+ let backend = null // { kind: 'sqlite', db } | { kind: 'json', file }
112
+
113
+ async function getBackend() {
114
+ if (backend) return backend
115
+ try {
116
+ const { DatabaseSync } = await import('node:sqlite')
117
+ const db = new DatabaseSync(path.join(dataDir(), 'ttm.sqlite'))
118
+ db.exec(SCHEMA)
119
+ migrate(db)
120
+ backend = { kind: 'sqlite', db }
121
+ } catch (err) {
122
+ // Fallback JSON existe para Node < 22.5 (sem node:sqlite). Qualquer outro
123
+ // erro (schema/migração) precisa aparecer — nunca trocar de backend mudo.
124
+ if (err?.code !== 'ERR_UNKNOWN_BUILTIN_MODULE' && !/node:sqlite/.test(err?.message || '')) {
125
+ process.stderr.write(`ttm: sqlite indisponível (${err.message}) — usando fallback JSON\n`)
126
+ }
127
+ const file = path.join(dataDir(), 'ttm-events.json')
128
+ if (!fs.existsSync(file)) {
129
+ fs.writeFileSync(file, JSON.stringify({ nextId: 1, events: [], kv: {} }, null, 2))
130
+ }
131
+ backend = { kind: 'json', file }
132
+ }
133
+ return backend
134
+ }
135
+
136
+ function readJson(file) {
137
+ try {
138
+ return JSON.parse(fs.readFileSync(file, 'utf8'))
139
+ } catch {
140
+ return { nextId: 1, events: [], kv: {} }
141
+ }
142
+ }
143
+
144
+ function writeJson(file, data) {
145
+ fs.writeFileSync(file, JSON.stringify(data))
146
+ }
147
+
148
+ export async function insertUsageEvent(ev) {
149
+ const b = await getBackend()
150
+ const row = {
151
+ ts: ev.ts ?? new Date().toISOString(),
152
+ label: ev.label ?? null,
153
+ target: ev.target,
154
+ protocol: ev.protocol,
155
+ model: ev.model ?? null,
156
+ input_tokens: ev.inputTokens | 0,
157
+ cache_create_tokens: ev.cacheCreateTokens | 0,
158
+ cache_create_1h_tokens: ev.cacheCreate1hTokens | 0,
159
+ cache_read_tokens: ev.cacheReadTokens | 0,
160
+ output_tokens: ev.outputTokens | 0,
161
+ reasoning_tokens: ev.reasoningTokens | 0,
162
+ cost_usd: ev.costUsd ?? null,
163
+ rates: ev.rates ? JSON.stringify(ev.rates) : null,
164
+ provider_id: ev.providerId ?? null,
165
+ repo: ev.repo ?? null,
166
+ branch: ev.branch ?? null,
167
+ status: ev.status ?? null,
168
+ duration_ms: ev.durationMs ?? null,
169
+ client: ev.client ?? null,
170
+ payload: ev.payload ? 1 : 0,
171
+ usage_fields: ev.usageFields && ev.usageFields.length ? JSON.stringify(ev.usageFields) : null,
172
+ usage_unknown: ev.usageUnknown ? 1 : 0,
173
+ }
174
+ if (b.kind === 'sqlite') {
175
+ const r = b.db
176
+ .prepare(
177
+ `INSERT INTO usage_events (ts, label, target, protocol, model, input_tokens, cache_create_tokens,
178
+ cache_create_1h_tokens, cache_read_tokens, output_tokens, reasoning_tokens, cost_usd, rates,
179
+ provider_id, repo, branch, status, duration_ms, client, payload, usage_fields, usage_unknown)
180
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
181
+ )
182
+ .run(
183
+ row.ts,
184
+ row.label,
185
+ row.target,
186
+ row.protocol,
187
+ row.model,
188
+ row.input_tokens,
189
+ row.cache_create_tokens,
190
+ row.cache_create_1h_tokens,
191
+ row.cache_read_tokens,
192
+ row.output_tokens,
193
+ row.reasoning_tokens,
194
+ row.cost_usd,
195
+ row.rates,
196
+ row.provider_id,
197
+ row.repo,
198
+ row.branch,
199
+ row.status,
200
+ row.duration_ms,
201
+ row.client,
202
+ row.payload,
203
+ row.usage_fields,
204
+ row.usage_unknown,
205
+ )
206
+ return Number(r.lastInsertRowid)
207
+ }
208
+ const data = readJson(b.file)
209
+ const id = data.nextId++
210
+ data.events.push({ id, ...row })
211
+ writeJson(b.file, data)
212
+ return id
213
+ }
214
+
215
+ export async function listUsageEvents({ since, label, target, limit } = {}) {
216
+ const b = await getBackend()
217
+ let rows
218
+ if (b.kind === 'sqlite') {
219
+ const where = []
220
+ const params = []
221
+ if (since) {
222
+ where.push('ts >= ?')
223
+ params.push(since)
224
+ }
225
+ if (label) {
226
+ where.push('label = ?')
227
+ params.push(label)
228
+ }
229
+ if (target) {
230
+ where.push('target = ?')
231
+ params.push(target)
232
+ }
233
+ const sql =
234
+ 'SELECT * FROM usage_events' +
235
+ (where.length ? ' WHERE ' + where.join(' AND ') : '') +
236
+ ' ORDER BY ts DESC' +
237
+ (limit ? ` LIMIT ${limit | 0}` : '')
238
+ rows = b.db.prepare(sql).all(...params)
239
+ } else {
240
+ rows = readJson(b.file).events.slice().reverse()
241
+ if (since) rows = rows.filter(r => r.ts >= since)
242
+ if (label) rows = rows.filter(r => r.label === label)
243
+ if (target) rows = rows.filter(r => r.target === target)
244
+ if (limit) rows = rows.slice(0, limit)
245
+ }
246
+ return rows
247
+ }
248
+
249
+ const clientShort = c => (c || '').split('/')[0].split(' ')[0]
250
+
251
+ // Expira traces mais antigos que cutoffIso: agrega em daily_rollups e apaga.
252
+ export async function purgeExpired(cutoffIso) {
253
+ const b = await getBackend()
254
+ const groups = new Map()
255
+ const keyOf = r =>
256
+ `${(r.ts || '').slice(0, 10)}|${r.label ?? ''}|${r.target}|${r.model ?? ''}|${clientShort(r.client)}`
257
+ const addTo = r => {
258
+ const k = keyOf(r)
259
+ let g = groups.get(k)
260
+ if (!g) {
261
+ g = {
262
+ day: (r.ts || '').slice(0, 10),
263
+ label: r.label ?? '',
264
+ target: r.target,
265
+ model: r.model ?? '',
266
+ client: clientShort(r.client),
267
+ input_tokens: 0,
268
+ cache_create_tokens: 0,
269
+ cache_create_1h_tokens: 0,
270
+ cache_read_tokens: 0,
271
+ output_tokens: 0,
272
+ reasoning_tokens: 0,
273
+ cost_usd: 0,
274
+ cost_known: 1,
275
+ events: 0,
276
+ }
277
+ groups.set(k, g)
278
+ }
279
+ g.input_tokens += r.input_tokens || 0
280
+ g.cache_create_tokens += r.cache_create_tokens || 0
281
+ g.cache_create_1h_tokens += r.cache_create_1h_tokens || 0
282
+ g.cache_read_tokens += r.cache_read_tokens || 0
283
+ g.output_tokens += r.output_tokens || 0
284
+ g.reasoning_tokens += r.reasoning_tokens || 0
285
+ g.cost_usd += r.cost_usd || 0
286
+ if (r.cost_usd === null || r.cost_usd === undefined) g.cost_known = 0
287
+ g.events += 1
288
+ }
289
+
290
+ if (b.kind === 'sqlite') {
291
+ const expiring = b.db.prepare('SELECT * FROM usage_events WHERE ts < ?').all(cutoffIso)
292
+ if (!expiring.length) return { purged: 0 }
293
+ for (const r of expiring) addTo(r)
294
+ unlinkPayloads(expiring.map(r => r.id))
295
+ // TRANSAÇÃO: agregar + apagar precisam ser tudo-ou-nada — um crash entre
296
+ // os dois deixaria os eventos contados DUAS vezes no próximo purge.
297
+ b.db.exec('BEGIN IMMEDIATE')
298
+ const upsert = b.db.prepare(
299
+ `INSERT INTO daily_rollups (day, label, target, model, client, input_tokens, cache_create_tokens,
300
+ cache_create_1h_tokens, cache_read_tokens, output_tokens, reasoning_tokens, cost_usd, cost_known, events)
301
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
302
+ ON CONFLICT(day, label, target, model, client) DO UPDATE SET
303
+ input_tokens = input_tokens + excluded.input_tokens,
304
+ cache_create_tokens = cache_create_tokens + excluded.cache_create_tokens,
305
+ cache_create_1h_tokens = cache_create_1h_tokens + excluded.cache_create_1h_tokens,
306
+ cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
307
+ output_tokens = output_tokens + excluded.output_tokens,
308
+ reasoning_tokens = reasoning_tokens + excluded.reasoning_tokens,
309
+ cost_usd = cost_usd + excluded.cost_usd,
310
+ cost_known = MIN(cost_known, excluded.cost_known),
311
+ events = events + excluded.events`,
312
+ )
313
+ try {
314
+ for (const g of groups.values()) {
315
+ upsert.run(
316
+ g.day, g.label, g.target, g.model, g.client,
317
+ g.input_tokens, g.cache_create_tokens, g.cache_create_1h_tokens, g.cache_read_tokens,
318
+ g.output_tokens, g.reasoning_tokens, g.cost_usd, g.cost_known, g.events,
319
+ )
320
+ }
321
+ b.db.prepare('DELETE FROM usage_events WHERE ts < ?').run(cutoffIso)
322
+ b.db.exec('COMMIT')
323
+ } catch (err) {
324
+ try {
325
+ b.db.exec('ROLLBACK')
326
+ } catch {
327
+ /* rollback já implícito */
328
+ }
329
+ throw err
330
+ }
331
+ return { purged: expiring.length }
332
+ }
333
+
334
+ const data = readJson(b.file)
335
+ const expiring = data.events.filter(e => e.ts < cutoffIso)
336
+ if (!expiring.length) return { purged: 0 }
337
+ for (const r of expiring) addTo(r)
338
+ unlinkPayloads(expiring.map(r => r.id))
339
+ data.rollups = data.rollups || {}
340
+ for (const [k, g] of groups) {
341
+ const prev = data.rollups[k]
342
+ if (!prev) data.rollups[k] = g
343
+ else {
344
+ for (const f of ['input_tokens','cache_create_tokens','cache_create_1h_tokens','cache_read_tokens','output_tokens','reasoning_tokens','cost_usd','events']) prev[f] += g[f]
345
+ prev.cost_known = Math.min(prev.cost_known, g.cost_known)
346
+ }
347
+ }
348
+ data.events = data.events.filter(e => e.ts >= cutoffIso)
349
+ writeJson(b.file, data)
350
+ return { purged: expiring.length }
351
+ }
352
+
353
+ export async function listRollups({ sinceDay, label } = {}) {
354
+ const b = await getBackend()
355
+ let rows
356
+ if (b.kind === 'sqlite') {
357
+ rows = b.db.prepare('SELECT * FROM daily_rollups').all()
358
+ } else {
359
+ rows = Object.values(readJson(b.file).rollups || {})
360
+ }
361
+ if (sinceDay) rows = rows.filter(r => r.day >= sinceDay)
362
+ if (label) rows = rows.filter(r => (r.label || '') === label) // '' interno = sem label; filtro '' não chega aqui (guard truthy)
363
+ return rows
364
+ }
365
+
366
+ // "Poder de deletar": traces, ou tudo (traces + histórico agregado).
367
+ export async function deleteData({ mode = 'events', label = null } = {}) {
368
+ const b = await getBackend()
369
+ if (b.kind === 'sqlite') {
370
+ let deleted = 0
371
+ if (label) {
372
+ unlinkPayloads(b.db.prepare('SELECT id FROM usage_events WHERE label = ?').all(label).map(r => r.id))
373
+ deleted = b.db.prepare('DELETE FROM usage_events WHERE label = ?').run(label).changes
374
+ if (mode === 'everything') {
375
+ deleted += b.db.prepare('DELETE FROM daily_rollups WHERE label = ?').run(label).changes
376
+ }
377
+ } else {
378
+ unlinkPayloads(b.db.prepare('SELECT id FROM usage_events').all().map(r => r.id))
379
+ deleted = b.db.prepare('DELETE FROM usage_events').run().changes
380
+ if (mode === 'everything') deleted += b.db.prepare('DELETE FROM daily_rollups').run().changes
381
+ }
382
+ return { deleted }
383
+ }
384
+ const data = readJson(b.file)
385
+ const before = data.events.length + Object.keys(data.rollups || {}).length
386
+ unlinkPayloads((label ? data.events.filter(e => e.label === label) : data.events).map(e => e.id))
387
+ data.events = label ? data.events.filter(e => e.label !== label) : []
388
+ if (mode === 'everything') {
389
+ if (label) {
390
+ for (const k of Object.keys(data.rollups || {})) if ((data.rollups[k].label || '') === label) delete data.rollups[k]
391
+ } else data.rollups = {}
392
+ }
393
+ writeJson(b.file, data)
394
+ return { deleted: before - (data.events.length + Object.keys(data.rollups || {}).length) }
395
+ }
396
+
397
+ // Relabel retroativo: o usuário começou a trabalhar antes de criar o card e
398
+ // ativou o ttm depois — as mensagens SEM label da janela recebem a tarefa.
399
+ export async function relabelUnlabeled({ label, sinceIso }) {
400
+ const b = await getBackend()
401
+ if (b.kind === 'sqlite') {
402
+ const r = b.db
403
+ .prepare('UPDATE usage_events SET label = ? WHERE label IS NULL AND ts >= ?')
404
+ .run(label, sinceIso)
405
+ return { relabeled: r.changes }
406
+ }
407
+ const data = readJson(b.file)
408
+ let n = 0
409
+ for (const e of data.events) {
410
+ if (e.label === null && e.ts >= sinceIso) {
411
+ e.label = label
412
+ n++
413
+ }
414
+ }
415
+ writeJson(b.file, data)
416
+ return { relabeled: n }
417
+ }
418
+
419
+ export async function getKv(key) {
420
+ const b = await getBackend()
421
+ if (b.kind === 'sqlite') {
422
+ const row = b.db.prepare('SELECT value FROM kv WHERE key = ?').get(key)
423
+ return row ? row.value : null
424
+ }
425
+ return readJson(b.file).kv[key] ?? null
426
+ }
427
+
428
+ export async function setKv(key, value) {
429
+ const b = await getBackend()
430
+ if (b.kind === 'sqlite') {
431
+ if (value === null) {
432
+ b.db.prepare('DELETE FROM kv WHERE key = ?').run(key)
433
+ } else {
434
+ b.db
435
+ .prepare('INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')
436
+ .run(key, value)
437
+ }
438
+ } else {
439
+ const data = readJson(b.file)
440
+ if (value === null) delete data.kv[key]
441
+ else data.kv[key] = value
442
+ writeJson(b.file, data)
443
+ }
444
+ }
package/lib/link.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // Orquestração do `ttm link <tui>` / `ttm unlink <tui>`.
2
+ //
3
+ // Fluxo do link:
4
+ // 1. descobre a base URL efetiva do TUI (env var ou arquivo de config)
5
+ // 2. registra um target no ttm apontando pra esse upstream (preserva o
6
+ // original em links.<tui> p/ unlink + detecção de loop)
7
+ // 3. reescreve a config do TUI pra apontar pro ttm
8
+ // 4. (garante daemon rodando; seta label default se --label)
9
+ //
10
+ // Idempotente: se o TUI já aponta pro ttm, não re-descobre (evita loop).
11
+ // Re-link pós troca de upstream exige --force (detecção via links.<tui>).
12
+
13
+ import { readConfig, setTarget, removeTarget, setLink } from './config.mjs'
14
+ import { discoverTui, writeTuiBaseUrl, restoreTuiBaseUrl, AUTOLINKABLE_TUIS } from './tui-config.mjs'
15
+ import { ensureDaemon, fetchDaemon } from './daemon-client.mjs'
16
+
17
+ export async function linkTui(tui, { label, repo, force, cwd } = {}) {
18
+ if (!AUTOLINKABLE_TUIS.includes(tui)) {
19
+ return { supported: false, tui }
20
+ }
21
+
22
+ const cfg = readConfig()
23
+ const existing = cfg.links?.[tui]
24
+ const discover = discoverTui(tui, { cwd: cwd ?? process.cwd() })
25
+ const proxyUrl = cfg.proxyUrl
26
+ const expectedProxyBase = `${proxyUrl}/${tui}`
27
+
28
+ // já linkado?
29
+ if (existing) {
30
+ const pointingAtTtm = discover.baseUrl && discover.baseUrl.startsWith(proxyUrl)
31
+ if (pointingAtTtm) {
32
+ // idempotente — só garante label/daemon
33
+ if (label) {
34
+ await ensureDaemon()
35
+ await fetchDaemon('/api/label', { method: 'POST', body: { label } })
36
+ }
37
+ return { alreadyLinked: true, tui, upstream: existing.upstream, proxyBaseUrl: expectedProxyBase, label }
38
+ }
39
+ // usuário mudou o upstream manualmente desde o último link
40
+ if (!force) {
41
+ return {
42
+ needsForce: true,
43
+ tui,
44
+ currentBaseUrl: discover.baseUrl,
45
+ existingUpstream: existing.upstream,
46
+ hint: `você já está linkado (upstream registrado: ${existing.upstream}). Se mudou o upstream, rode com --force p/ relinkar a partir de ${discover.baseUrl}`,
47
+ }
48
+ }
49
+ // --force: re-linka usando o baseUrl atual como novo upstream
50
+ }
51
+
52
+ // determina o upstream
53
+ let upstream = discover.baseUrl
54
+ // loop guard: se o baseUrl atual já aponta pro ttm (ex: link re-rodado sem
55
+ // ter mudado nada mas o discovery via settings retornou o proxy), usa o
56
+ // original guardado ou o default do TUI
57
+ if (!upstream || upstream.startsWith(proxyUrl)) {
58
+ upstream = existing?.upstream ?? discover.defaultUpstream
59
+ }
60
+ if (!upstream) {
61
+ return { error: `não consegui determinar o upstream para ${tui} (sem redirect prévio e sem default)` }
62
+ }
63
+
64
+ // registra o target no ttm
65
+ const spec = { targetUrl: upstream, protocol: discover.protocol }
66
+ if (discover.envVar && discover.source === 'env') {
67
+ // env-following: segue a env var em runtime (se o usuário trocar de
68
+ // redirect só mudando a env, o ttm acompanha)
69
+ spec.targetUrlEnv = discover.envVar
70
+ spec.targetUrl = upstream
71
+ }
72
+ setTarget(tui, spec)
73
+
74
+ // garante daemon de pé (link não funciona sem proxy ouvindo)
75
+ await ensureDaemon()
76
+
77
+ // seta label default se pedido
78
+ if (label) {
79
+ await fetchDaemon('/api/label', { method: 'POST', body: { label } })
80
+ }
81
+
82
+ // reescreve a config do TUI
83
+ let rewrite
84
+ if (discover.canWriteBack) {
85
+ const writeTarget = repo && discover.repoWriteTarget ? discover.repoWriteTarget : discover.writeTarget
86
+ writeTuiBaseUrl(tui, expectedProxyBase, { writeTarget, cwd })
87
+ rewrite = { done: true, writeTarget, source: discover.source }
88
+ } else {
89
+ // TUI só-env (gemini-cli, ou claude-code quando redirect via env):
90
+ // ttm não consegue persistir — instrui o usuário a fazer o export
91
+ rewrite = {
92
+ done: false,
93
+ envVar: discover.envVar,
94
+ exportLine: `export ${discover.envVar}=${expectedProxyBase}`,
95
+ note: `o ${tui} lê a base URL de uma env var que o ttm não pode alterar — rode o export acima no seu shell e reinicie o TUI`,
96
+ }
97
+ }
98
+
99
+ // guarda o estado do link p/ unlink + detecção de loop/idempotência
100
+ setLink(tui, {
101
+ originalBaseUrl: discover.baseUrl,
102
+ source: discover.source,
103
+ writeTarget: discover.writeTarget,
104
+ repoWriteTarget: discover.repoWriteTarget ?? null,
105
+ envVar: discover.envVar ?? null,
106
+ upstream,
107
+ protocol: discover.protocol,
108
+ targetUrlEnv: spec.targetUrlEnv ?? null,
109
+ linkedAt: new Date().toISOString(),
110
+ })
111
+
112
+ return {
113
+ linked: true,
114
+ tui,
115
+ upstream,
116
+ protocol: discover.protocol,
117
+ proxyBaseUrl: expectedProxyBase,
118
+ rewrite,
119
+ label,
120
+ }
121
+ }
122
+
123
+ export async function unlinkTui(tui, { cwd } = {}) {
124
+ const cfg = readConfig()
125
+ const info = cfg.links?.[tui]
126
+ if (!info) return { notLinked: true, tui }
127
+
128
+ let restore
129
+ if (info.source === 'env' || !info.writeTarget) {
130
+ // fonte só-env: só dá pra instruir o usuário
131
+ const target = info.originalBaseUrl
132
+ ? `export ${info.envVar}=${info.originalBaseUrl}`
133
+ : `unset ${info.envVar}`
134
+ restore = { done: false, envVar: info.envVar, exportLine: target, note: 'fonte só-env — rode o export/unset no seu shell' }
135
+ } else {
136
+ try {
137
+ restoreTuiBaseUrl(tui, {
138
+ original: info.originalBaseUrl,
139
+ source: info.source,
140
+ writeTarget: info.writeTarget,
141
+ cwd,
142
+ })
143
+ restore = { done: true, writeTarget: info.writeTarget }
144
+ } catch (err) {
145
+ restore = { done: false, error: err.message }
146
+ }
147
+ }
148
+
149
+ removeTarget(tui)
150
+ setLink(tui, null)
151
+ return { unlinked: true, tui, restore }
152
+ }