sprag-cli 3.69.0 → 3.70.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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * team-sync — the opt-in team layer's client core.
3
+ *
4
+ * sprag is local-only by default. Nothing here runs unless team.json holds a
5
+ * member key, which only `sprag team join` (or SPRAG_TEAM_TOKEN auto-join)
6
+ * writes. Once joined, the CLI uploads DAILY AGGREGATES ONLY — spend, savings,
7
+ * delegation counts and model-family share per local calendar day — and pulls
8
+ * the team's approved rules. No prompts, paths or transcripts leave the machine.
9
+ *
10
+ * Files in the user data dir:
11
+ * team.json membership state (mode 0600; holds the member key)
12
+ * team-rules.json last pulled team rules
13
+ *
14
+ * Opt out of all network traffic with CTS_NO_TEAM_SYNC=1.
15
+ */
16
+
17
+ import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { userInfo } from 'node:os';
20
+ import { createRequire } from 'node:module';
21
+ import { userDataDir } from './paths.js';
22
+ import { writeStateFile } from './state-file.js';
23
+ import { debug } from './debug.js';
24
+ import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
25
+ import { sessionCost, pricedParts } from './claude-price.js';
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ export const TEAM_API_URL = 'https://hiycfjwswtkixyoqkznm.supabase.co';
30
+ // Public anon key (role=anon). Access is gated by the RPCs, not by this value.
31
+ export const TEAM_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhpeWNmandzd3RraXh5b3Frem5tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTIyNTc4MzYsImV4cCI6MjEwNzgzMzgzNn0.gyYcvGD70CEX64aGV-dsmZgmmOBdvhJlzIpx9Qo2l3w';
32
+ export const TEAM_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
+ export const TEAM_STATE_VERSION = 1;
34
+
35
+ const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
36
+ const MAX_ROWS = 400;
37
+
38
+ export function teamStatePath(dir = userDataDir()) { return join(dir, 'team.json'); }
39
+ export function teamRulesCachePath(dir = userDataDir()) { return join(dir, 'team-rules.json'); }
40
+
41
+ export function loadTeamState(dir = userDataDir()) {
42
+ try {
43
+ const s = JSON.parse(readFileSync(teamStatePath(dir), 'utf8'));
44
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ export function saveTeamState(state, dir = userDataDir()) {
51
+ const file = teamStatePath(dir);
52
+ writeStateFile(file, JSON.stringify(state, null, 2) + '\n');
53
+ if (process.platform !== 'win32') {
54
+ try { chmodSync(file, 0o600); } catch (e) { debug('team-sync', e); }
55
+ }
56
+ }
57
+
58
+ export function teamSyncDisabled(env = process.env) { return env.CTS_NO_TEAM_SYNC === '1'; }
59
+ export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
60
+
61
+ export function teamSyncDue(state, now = Date.now()) {
62
+ return isJoined(state) && (!state.lastSyncAt || now - state.lastSyncAt >= TEAM_SYNC_INTERVAL_MS);
63
+ }
64
+
65
+ export async function rpc(fn, params, {
66
+ fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY,
67
+ } = {}) {
68
+ const ctl = new AbortController();
69
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
70
+ try {
71
+ const res = await fetch(`${url}/rest/v1/rpc/${fn}`, {
72
+ method: 'POST',
73
+ headers: { apikey: key, Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
74
+ body: JSON.stringify(params || {}),
75
+ signal: ctl.signal,
76
+ });
77
+ let body = null;
78
+ try { body = await res.json(); } catch { /* empty or non-JSON body */ }
79
+ if (!res.ok) {
80
+ const msg = (body && (body.message || body.hint)) || `HTTP ${res.status}`;
81
+ throw new Error(`${fn}: ${msg}`);
82
+ }
83
+ return body;
84
+ } catch (e) {
85
+ if (e && e.name === 'AbortError') throw new Error(`${fn}: timeout`);
86
+ throw e;
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * One JSON request against the Supabase project, for the owner sign-in flow
94
+ * (GoTrue email code + the teams table). Same injectable fetch and 8s timeout
95
+ * as rpc(). Errors carry the server's own message. The access token only ever
96
+ * lives in memory: it is never written to disk or printed.
97
+ */
98
+ async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY, prefer } = {}) {
99
+ const ctl = new AbortController();
100
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
101
+ try {
102
+ const headers = { apikey: key, 'Content-Type': 'application/json' };
103
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
104
+ if (prefer) headers.Prefer = prefer;
105
+ const res = await fetch(`${url}${path}`, { method: 'POST', headers, body: JSON.stringify(body || {}), signal: ctl.signal });
106
+ let data = null;
107
+ try { data = await res.json(); } catch { /* empty or non-JSON body */ }
108
+ if (!res.ok) {
109
+ const msg = (data && (data.msg || data.error_description || data.message)) || `HTTP ${res.status}`;
110
+ throw new Error(`${label}: ${msg}`);
111
+ }
112
+ return data;
113
+ } catch (e) {
114
+ if (e && e.name === 'AbortError') throw new Error(`${label}: timeout`);
115
+ throw e;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ /** Email a 6-digit sign-in code (valid 15 minutes). Creates the user if new. */
122
+ export async function requestSignInCode({ email, fetch } = {}) {
123
+ await apiJson('/auth/v1/otp', { body: { email, create_user: true }, label: 'sign-in', fetch });
124
+ }
125
+
126
+ /** Exchange the emailed code for a session. Returns { accessToken, userId }. */
127
+ export async function verifySignInCode({ email, code, fetch } = {}) {
128
+ const r = await apiJson('/auth/v1/verify', { body: { type: 'email', email, token: code }, label: 'verify', fetch });
129
+ if (!r || !r.access_token || !r.user || !r.user.id) throw new Error('verify: no session returned');
130
+ return { accessToken: r.access_token, userId: r.user.id };
131
+ }
132
+
133
+ /** Insert a team owned by the signed-in user (RLS allows the owner insert). */
134
+ export async function createTeam({ name, accessToken, userId, fetch } = {}) {
135
+ const r = await apiJson('/rest/v1/teams', {
136
+ body: { name, owner_uid: userId }, accessToken, prefer: 'return=representation', label: 'create team', fetch,
137
+ });
138
+ const row = Array.isArray(r) ? r[0] : r;
139
+ if (!row || !row.invite_token) throw new Error('create team: no invite token returned');
140
+ return { id: row.id, name: row.name, inviteToken: row.invite_token, plan: row.plan };
141
+ }
142
+
143
+ /** Local calendar day, same key as stats.js. */
144
+ function dayKey(ms) {
145
+ const d = new Date(ms);
146
+ const pad = (n) => String(n).padStart(2, '0');
147
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
148
+ }
149
+
150
+ const round4 = (n) => Math.round(n * 10000) / 10000;
151
+
152
+ /**
153
+ * Per-day rows, oldest first, only days with activity inside the window.
154
+ * saved_usd honours the `sprag saved reset` marks (events before a mark are
155
+ * skipped), the same way the ledger totals do. delegated_errors is always null:
156
+ * the ledger carries no error flag.
157
+ */
158
+ export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), days = 30, sessions = null } = {}) {
159
+ const { readResetMarks, isBeforeReset } = require('./saved-reset.cjs');
160
+ const doc2md = require('./doc2md-ledger.cjs');
161
+ const cutoff = now - days * 24 * 3600 * 1000;
162
+ const byDay = new Map();
163
+ const get = (day) => {
164
+ if (!byDay.has(day)) byDay.set(day, { spent: 0, saved: 0, runs: 0, fam: new Map() });
165
+ return byDay.get(day);
166
+ };
167
+ let marks = {};
168
+ try { marks = readResetMarks(dir) || {}; } catch (e) { debug('team-sync', e); }
169
+
170
+ try {
171
+ for (const e of Object.values(loadLedger().events)) {
172
+ if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
173
+ if (isBeforeReset(e.ts, marks.routing)) continue;
174
+ const d = get(dayKey(e.ts));
175
+ const usd = Number(e.usd);
176
+ if (Number.isFinite(usd)) d.saved += usd;
177
+ d.runs += 1;
178
+ const fam = modelFamily(e.to);
179
+ d.fam.set(fam, (d.fam.get(fam) || 0) + 1);
180
+ }
181
+ } catch (e) { debug('team-sync', e); }
182
+
183
+ try {
184
+ for (const e of Object.values(doc2md.loadLedger(dir).events)) {
185
+ if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
186
+ if (isBeforeReset(e.ts, marks.doc2md)) continue;
187
+ get(dayKey(e.ts)).saved += Number(e.usd) || 0;
188
+ }
189
+ } catch (e) { debug('team-sync', e); }
190
+
191
+ try {
192
+ let list = sessions;
193
+ if (!list) {
194
+ const { parseAllSessions } = await import('./parser.js');
195
+ list = await parseAllSessions({ days });
196
+ }
197
+ for (const s of list || []) {
198
+ if (!s || !s.startTime || !s.totals) continue;
199
+ const t = new Date(s.startTime).getTime();
200
+ if (!Number.isFinite(t) || t < cutoff || t > now) continue;
201
+ let cost = 0;
202
+ for (const part of pricedParts(s)) {
203
+ try {
204
+ const c = sessionCost(part.totals, part.model);
205
+ if (c) cost += c.actual;
206
+ } catch (e) { debug('team-sync', e); }
207
+ }
208
+ get(dayKey(t)).spent += cost;
209
+ }
210
+ } catch (e) { debug('team-sync', e); }
211
+
212
+ return [...byDay.entries()]
213
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
214
+ .filter(([, d]) => d.spent || d.saved || d.runs)
215
+ .map(([day, d]) => ({
216
+ day,
217
+ spent_usd: round4(d.spent),
218
+ saved_usd: round4(d.saved),
219
+ delegated_runs: d.runs,
220
+ delegated_errors: null,
221
+ family_share: Object.fromEntries(
222
+ [...d.fam.entries()].map(([f, n]) => [f, Math.round((n / d.runs) * 1000) / 1000]),
223
+ ),
224
+ }));
225
+ }
226
+
227
+ export async function joinTeam({
228
+ token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(),
229
+ } = {}) {
230
+ if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
231
+ const name = String(displayName || '').trim() || 'member';
232
+ const r = await rpc('team_join', {
233
+ invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined,
234
+ }, { fetch });
235
+ if (!r || !r.member_key) throw new Error('team_join: no member key returned');
236
+ const state = {
237
+ version: TEAM_STATE_VERSION,
238
+ teamId: r.team_id,
239
+ teamName: r.team_name,
240
+ memberId: r.member_id,
241
+ memberKey: r.member_key,
242
+ plan: r.plan,
243
+ promptCapture: !!r.prompt_capture,
244
+ displayName: name,
245
+ agent,
246
+ joinedAt: now,
247
+ autoJoined: !!autoJoined,
248
+ autoJoinNoticePending: !!autoJoined,
249
+ lastSyncAt: null,
250
+ lastSyncRows: 0,
251
+ };
252
+ saveTeamState(state, dir);
253
+ return state;
254
+ }
255
+
256
+ export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
257
+ const prev = loadTeamState(dir);
258
+ let lastError;
259
+ if (prev.memberKey) {
260
+ try { await rpc('team_leave', { member_key: prev.memberKey }, { fetch }); }
261
+ catch (e) { debug('team-sync', e); lastError = e.message; }
262
+ }
263
+ const next = { version: TEAM_STATE_VERSION, left: true, leftAt: now, teamName: prev.teamName, displayName: prev.displayName };
264
+ if (lastError) next.lastError = lastError;
265
+ saveTeamState(next, dir);
266
+ try { rmSync(teamRulesCachePath(dir), { force: true }); } catch (e) { debug('team-sync', e); }
267
+ return next;
268
+ }
269
+
270
+ export async function syncTeam({
271
+ now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions,
272
+ } = {}) {
273
+ if (teamSyncDisabled(env)) return { skipped: 'disabled' };
274
+ const state = loadTeamState(dir);
275
+ if (state.left) return { skipped: 'left' };
276
+ if (!isJoined(state)) return { skipped: 'not-joined' };
277
+ if (!force && !teamSyncDue(state, now)) return { skipped: 'not-due' };
278
+ try {
279
+ const rows = (await aggregateDaily({ now, dir, sessions })).slice(-MAX_ROWS);
280
+ await rpc('ledger_upsert', { member_key: state.memberKey, rows, sprag_version: version || '' }, { fetch });
281
+ const r = (await rpc('team_rules_get', { member_key: state.memberKey }, { fetch })) || {};
282
+ const rules = Array.isArray(r.rules) ? r.rules : [];
283
+ writeStateFile(teamRulesCachePath(dir), JSON.stringify({
284
+ version: 1, fetchedAt: now, teamId: r.team_id, teamName: r.team_name, plan: r.plan,
285
+ promptCapture: !!r.prompt_capture, rules, removed: Array.isArray(r.removed) ? r.removed : [],
286
+ }, null, 2) + '\n');
287
+ const next = { ...state, lastSyncAt: now, lastSyncRows: rows.length };
288
+ if (r.team_name) next.teamName = r.team_name;
289
+ if (r.plan) next.plan = r.plan;
290
+ if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
291
+ delete next.lastError;
292
+ saveTeamState(next, dir);
293
+ return { uploaded: rows.length, rules: rules.length };
294
+ } catch (e) {
295
+ debug('team-sync', e);
296
+ // Stamp lastSyncAt too, so an offline machine backs off for the interval.
297
+ try { saveTeamState({ ...state, lastSyncAt: now, lastError: e.message }, dir); } catch (e2) { debug('team-sync', e2); }
298
+ return { error: e.message };
299
+ }
300
+ }
301
+
302
+ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(), version, now = Date.now() } = {}) {
303
+ const token = env.SPRAG_TEAM_TOKEN;
304
+ if (!token || !TOKEN_RE.test(token)) return null;
305
+ const state = loadTeamState(dir);
306
+ // A state with `left: true` never auto-rejoins: leaving must beat the
307
+ // environment, or `sprag team leave` would be undone by the next session.
308
+ if (isJoined(state) || state.left) return null;
309
+ let name = 'member';
310
+ try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
311
+ try {
312
+ return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now });
313
+ } catch (e) {
314
+ debug('team-sync', e);
315
+ return null;
316
+ }
317
+ }
318
+
319
+ export function autoJoinNotice(state, lang = 'en') {
320
+ if (!state || !state.autoJoinNoticePending) return null;
321
+ state.autoJoinNoticePending = false;
322
+ const name = state.teamName || '';
323
+ return lang === 'ko'
324
+ ? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
325
+ : `Sprag: this machine was enrolled in team "${name}" through SPRAG_TEAM_TOKEN. It sends daily aggregates only (spend, savings, delegation counts). See what is sent with \`sprag team status --show-payload\`; stop with \`sprag team leave\`.`;
326
+ }