sprag-cli 3.40.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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +637 -0
  3. package/README.md +758 -0
  4. package/bin/cli.js +801 -0
  5. package/examples/statusline-command.ps1 +43 -0
  6. package/examples/statusline-command.sh +36 -0
  7. package/package.json +62 -0
  8. package/presets/cohesion/cohesion-en.md +26 -0
  9. package/presets/doc2md/convert.py +363 -0
  10. package/presets/korean-style/LICENSE-fluent-korean +21 -0
  11. package/presets/korean-style/fluent-korean.md +52 -0
  12. package/presets/korean-style/supplement.md +93 -0
  13. package/presets/model-rules.json +115 -0
  14. package/presets/ratchet-rules.json +38 -0
  15. package/src/advice.js +564 -0
  16. package/src/agents.js +52 -0
  17. package/src/brief.js +264 -0
  18. package/src/caps-cache.js +84 -0
  19. package/src/cli-args.js +51 -0
  20. package/src/cohesion.js +70 -0
  21. package/src/commands/brief.js +31 -0
  22. package/src/commands/cohesion.js +59 -0
  23. package/src/commands/compact-window.js +93 -0
  24. package/src/commands/doc2md.js +166 -0
  25. package/src/commands/feedback.js +132 -0
  26. package/src/commands/handoff.js +33 -0
  27. package/src/commands/harness.js +459 -0
  28. package/src/commands/history.js +46 -0
  29. package/src/commands/install.js +358 -0
  30. package/src/commands/korean.js +220 -0
  31. package/src/commands/last.js +151 -0
  32. package/src/commands/mode.js +46 -0
  33. package/src/commands/route-scan.js +454 -0
  34. package/src/commands/seed.js +105 -0
  35. package/src/commands/uninstall.js +42 -0
  36. package/src/commands/update-check.js +77 -0
  37. package/src/commands/upgrade.js +68 -0
  38. package/src/compact-window.js +205 -0
  39. package/src/config.js +232 -0
  40. package/src/cost.js +253 -0
  41. package/src/debug.js +29 -0
  42. package/src/demo.js +331 -0
  43. package/src/doc2md-ledger.cjs +227 -0
  44. package/src/doc2md.cjs +997 -0
  45. package/src/fig2md-runner.cjs +21 -0
  46. package/src/fig2md.cjs +191 -0
  47. package/src/first-run-note.js +63 -0
  48. package/src/format-time.js +44 -0
  49. package/src/formatters/csv.js +8 -0
  50. package/src/formatters/json.js +3 -0
  51. package/src/formatters/statusline.js +750 -0
  52. package/src/formatters/table.js +299 -0
  53. package/src/handoff.js +161 -0
  54. package/src/harness-analyzer.cjs +264 -0
  55. package/src/harness-templates.js +153 -0
  56. package/src/harness.js +613 -0
  57. package/src/history.js +383 -0
  58. package/src/hook-manager.js +96 -0
  59. package/src/hook.cjs +196 -0
  60. package/src/installer.js +614 -0
  61. package/src/korean-lint.cjs +303 -0
  62. package/src/korean-style.js +187 -0
  63. package/src/litellm-budget.js +223 -0
  64. package/src/model-alias.js +484 -0
  65. package/src/model-rules.js +527 -0
  66. package/src/month-spend.js +47 -0
  67. package/src/parser.js +330 -0
  68. package/src/paths.js +41 -0
  69. package/src/prompt.js +52 -0
  70. package/src/route-scan.js +832 -0
  71. package/src/savings-ledger.js +137 -0
  72. package/src/seed-rules.js +280 -0
  73. package/src/session-cache.js +160 -0
  74. package/src/session-records.js +188 -0
  75. package/src/stats.js +380 -0
  76. package/src/stdin-payload.js +122 -0
  77. package/src/subagent-records.js +214 -0
  78. package/src/update-check.js +201 -0
  79. package/src/window-labels.js +64 -0
package/src/parser.js ADDED
@@ -0,0 +1,330 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir, stat } from 'node:fs/promises';
3
+ import { createInterface } from 'node:readline';
4
+ import { join, isAbsolute } from 'node:path';
5
+ import { homedir } from 'node:os';
6
+ import { loadCache, getCached, putCached, saveCache } from './session-cache.js';
7
+ import { resolveModelAlias, isGatewayModelId } from './model-alias.js';
8
+
9
+ const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
10
+
11
+ /**
12
+ * Keys LiteLLM adds when it rewrites a Bedrock response into Anthropic shape.
13
+ * A stock Anthropic `usage` object carries none of them, so their presence is
14
+ * evidence of a gateway even when the model id looks ordinary. It is weak
15
+ * evidence — another gateway may not add them — so it is only consulted after
16
+ * the model id has already failed to answer the question.
17
+ */
18
+ const GATEWAY_USAGE_KEYS = ['inference_geo', 'iterations', 'speed'];
19
+
20
+ function usageLooksGatewayShaped(usage) {
21
+ return GATEWAY_USAGE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(usage, k));
22
+ }
23
+
24
+ /**
25
+ * Parse a single session JSONL file.
26
+ * Deduplicates by requestId (last-write-wins for streaming chunks).
27
+ */
28
+ export async function parseSessionFile(filePath) {
29
+ const requests = new Map();
30
+ let sessionId = null;
31
+ let firstTimestamp = null;
32
+ let lastTimestamp = null;
33
+ let gatewayObserved = false;
34
+
35
+ const rl = createInterface({
36
+ input: createReadStream(filePath, { encoding: 'utf8' }),
37
+ crlfDelay: Infinity,
38
+ });
39
+
40
+ for await (const line of rl) {
41
+ let entry;
42
+ try {
43
+ entry = JSON.parse(line);
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+ const ts = entry.timestamp;
49
+ if (ts) {
50
+ if (!firstTimestamp || ts < firstTimestamp) firstTimestamp = ts;
51
+ if (!lastTimestamp || ts > lastTimestamp) lastTimestamp = ts;
52
+ }
53
+
54
+ if (!sessionId && entry.sessionId) {
55
+ sessionId = entry.sessionId;
56
+ }
57
+
58
+ const msg = entry.message;
59
+ if (!msg?.usage || !msg.id) continue;
60
+
61
+ const usage = msg.usage;
62
+ const cc = usage.cache_creation || {};
63
+ const reqId = entry.requestId || msg.id;
64
+
65
+ // Recorded from the RAW id, before resolveModelAlias() turns the ARN into
66
+ // a plain model name. Downstream this is the only thing that distinguishes
67
+ // "no cache writes yet" from "a gateway that never reports the TTL split",
68
+ // and those two states want opposite countdown defaults.
69
+ if (!gatewayObserved && (isGatewayModelId(msg.model) || usageLooksGatewayShaped(usage))) {
70
+ gatewayObserved = true;
71
+ }
72
+
73
+ requests.set(reqId, {
74
+ requestId: reqId,
75
+ // Per-request timestamp so callers can filter by time window — the
76
+ // mtime-based file filter alone lets a long-lived session drag
77
+ // months-old requests into a narrow --days window.
78
+ ts: ts ? Date.parse(ts) : null,
79
+ model: resolveModelAlias(msg.model),
80
+ inputTokens: usage.input_tokens || 0,
81
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
82
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
83
+ ephemeral5mTokens: cc.ephemeral_5m_input_tokens || 0,
84
+ ephemeral1hTokens: cc.ephemeral_1h_input_tokens || 0,
85
+ outputTokens: usage.output_tokens || 0,
86
+ });
87
+ }
88
+
89
+ const reqs = [...requests.values()];
90
+ let maxContextPerRequest = 0;
91
+ // Representative model: the one that handled the most requests, skipping
92
+ // "<synthetic>" (Claude Code's local error-stub placeholder — no real API
93
+ // call). Taking reqs[0] blindly let an error stub at session start
94
+ // misclassify the whole session (route-scan.js already counts for the same
95
+ // reason). Falls back to '<synthetic>' only when nothing else exists.
96
+ const modelCounts = new Map();
97
+ for (const r of reqs) {
98
+ if (r.model === '<synthetic>') continue;
99
+ modelCounts.set(r.model, (modelCounts.get(r.model) || 0) + 1);
100
+ }
101
+ let model = 'unknown';
102
+ let best = 0;
103
+ for (const [m, n] of modelCounts) {
104
+ if (n > best) { best = n; model = m; }
105
+ }
106
+ if (best === 0 && reqs.length > 0) model = reqs[0].model || 'unknown';
107
+ const totals = reqs.reduce(
108
+ (acc, r) => {
109
+ acc.input += r.inputTokens;
110
+ acc.cacheCreation += r.cacheCreationTokens;
111
+ acc.cacheRead += r.cacheReadTokens;
112
+ acc.ephemeral5m += r.ephemeral5mTokens;
113
+ acc.ephemeral1h += r.ephemeral1hTokens;
114
+ acc.output += r.outputTokens;
115
+ const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
116
+ if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
117
+ return acc;
118
+ },
119
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
120
+ );
121
+
122
+ return {
123
+ sessionId,
124
+ filePath,
125
+ startTime: firstTimestamp ? new Date(firstTimestamp) : null,
126
+ endTime: lastTimestamp ? new Date(lastTimestamp) : null,
127
+ requestCount: reqs.length,
128
+ requests: reqs,
129
+ totals,
130
+ maxContextPerRequest,
131
+ model,
132
+ gatewayObserved,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Find the most recent user-message timestamp in a session JSONL.
138
+ * Used for statusline mode so the agent's own tool calls don't reset the TTL
139
+ * countdown — only the user's actual prompts (type === "user") do.
140
+ *
141
+ * @param {string} filePath absolute path to the session JSONL
142
+ * @returns {Promise<Date|null>}
143
+ */
144
+ export async function getLastUserMessageTime(filePath) {
145
+ let lastUserTs = null;
146
+ try {
147
+ const rl = createInterface({
148
+ input: createReadStream(filePath, { encoding: 'utf8' }),
149
+ crlfDelay: Infinity,
150
+ });
151
+ for await (const line of rl) {
152
+ if (!line) continue;
153
+ try {
154
+ const entry = JSON.parse(line);
155
+ if (entry.type === 'user' && entry.timestamp) {
156
+ lastUserTs = entry.timestamp;
157
+ }
158
+ } catch {
159
+ // ignore malformed lines
160
+ }
161
+ }
162
+ } catch {
163
+ return null;
164
+ }
165
+ return lastUserTs ? new Date(lastUserTs) : null;
166
+ }
167
+
168
+ /**
169
+ * Discover all session JSONL files under ~/.claude/projects/
170
+ */
171
+ export async function discoverSessionFiles(options = {}) {
172
+ const { projectFilter, days = 30, excludeSessionPath } = options;
173
+ const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
174
+ const files = [];
175
+ // Resolve the excluded session to an absolute path so equality checks are exact.
176
+ // Use path.isAbsolute() so Windows paths like C:\... are recognized too.
177
+ const excludeAbs = excludeSessionPath
178
+ ? (isAbsolute(excludeSessionPath) ? excludeSessionPath : join(process.cwd(), excludeSessionPath))
179
+ : null;
180
+
181
+ let projectDirs;
182
+ try {
183
+ projectDirs = await readdir(CLAUDE_DIR);
184
+ } catch {
185
+ return files;
186
+ }
187
+
188
+ for (const projDir of projectDirs) {
189
+ if (projectFilter && !projDir.includes(projectFilter)) continue;
190
+
191
+ const projPath = join(CLAUDE_DIR, projDir);
192
+ let entries;
193
+ try {
194
+ entries = await readdir(projPath);
195
+ } catch {
196
+ continue;
197
+ }
198
+
199
+ for (const entry of entries) {
200
+ if (!entry.endsWith('.jsonl')) continue;
201
+ const fp = join(projPath, entry);
202
+ if (excludeAbs && fp === excludeAbs) continue;
203
+ try {
204
+ const s = await stat(fp);
205
+ if (s.mtimeMs >= cutoff) {
206
+ // `size` pairs with `mtime` as the session-cache key — transcripts
207
+ // are append-only, so the pair identifies a parse result exactly.
208
+ files.push({ path: fp, projectDir: projDir, mtime: s.mtimeMs, size: s.size });
209
+ }
210
+ } catch {
211
+ continue;
212
+ }
213
+ }
214
+ }
215
+
216
+ return files.sort((a, b) => a.mtime - b.mtime);
217
+ }
218
+
219
+ /**
220
+ * Parse all sessions with concurrency control, backed by the (path, mtime,
221
+ * size) session cache so repeated runs — above all the statusline, which
222
+ * re-runs this every few seconds — only touch transcripts that changed.
223
+ *
224
+ * The returned sessions carry the aggregate summary WITHOUT the per-request
225
+ * array: it is an aggregation detail no consumer reads, and omitting it on
226
+ * both the cache-hit and fresh-parse paths keeps the two shapes identical.
227
+ * Call `parseSessionFile` directly if you need the raw requests.
228
+ *
229
+ * @param {object} [options] forwarded to discoverSessionFiles
230
+ * @param {boolean} [options.noCache=false] bypass the cache entirely
231
+ */
232
+ function spansCutoff(session, cutoffMs) {
233
+ return !!(session.startTime && session.startTime.getTime() < cutoffMs);
234
+ }
235
+
236
+ /**
237
+ * Re-aggregate a fully parsed session keeping only requests at or after the
238
+ * cutoff. Requests without a timestamp are kept — dropping data on a missing
239
+ * field would be worse than slight over-counting.
240
+ */
241
+ function trimToCutoff(session, cutoffMs) {
242
+ const reqs = (session.requests || []).filter((r) => r.ts == null || r.ts >= cutoffMs);
243
+ let maxContextPerRequest = 0;
244
+ let firstTs = null;
245
+ let lastTs = null;
246
+ const totals = reqs.reduce(
247
+ (acc, r) => {
248
+ acc.input += r.inputTokens;
249
+ acc.cacheCreation += r.cacheCreationTokens;
250
+ acc.cacheRead += r.cacheReadTokens;
251
+ acc.ephemeral5m += r.ephemeral5mTokens;
252
+ acc.ephemeral1h += r.ephemeral1hTokens;
253
+ acc.output += r.outputTokens;
254
+ const ctx = r.inputTokens + r.cacheCreationTokens + r.cacheReadTokens;
255
+ if (ctx > maxContextPerRequest) maxContextPerRequest = ctx;
256
+ if (r.ts != null) {
257
+ if (firstTs === null || r.ts < firstTs) firstTs = r.ts;
258
+ if (lastTs === null || r.ts > lastTs) lastTs = r.ts;
259
+ }
260
+ return acc;
261
+ },
262
+ { input: 0, cacheCreation: 0, cacheRead: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 },
263
+ );
264
+ return {
265
+ sessionId: session.sessionId,
266
+ filePath: session.filePath,
267
+ projectDir: session.projectDir,
268
+ startTime: firstTs !== null ? new Date(firstTs) : session.startTime,
269
+ endTime: lastTs !== null ? new Date(lastTs) : session.endTime,
270
+ requestCount: reqs.length,
271
+ totals,
272
+ maxContextPerRequest,
273
+ model: session.model,
274
+ gatewayObserved: session.gatewayObserved,
275
+ };
276
+ }
277
+
278
+ export async function parseAllSessions(options = {}) {
279
+ const files = await discoverSessionFiles(options);
280
+ const concurrency = 10;
281
+ const results = [];
282
+ const useCache = !options.noCache;
283
+ const cache = useCache ? loadCache() : { entries: {} };
284
+ let misses = 0;
285
+ // The file-level mtime filter (discoverSessionFiles) is only a cheap
286
+ // pre-selection: a session started months ago but touched today passes it,
287
+ // and its old requests would pollute every aggregate in the window. Any
288
+ // session whose startTime precedes the cutoff is re-aggregated from its
289
+ // per-request timestamps. The trimmed summary is NOT cached — the cache
290
+ // stores the window-independent full parse.
291
+ const days = options.days ?? 30;
292
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
293
+
294
+ for (let i = 0; i < files.length; i += concurrency) {
295
+ const batch = files.slice(i, i + concurrency);
296
+ const parsed = await Promise.all(
297
+ batch.map(async (f) => {
298
+ try {
299
+ if (useCache) {
300
+ const hit = getCached(cache, f);
301
+ if (hit) {
302
+ if (!spansCutoff(hit, cutoffMs)) return hit;
303
+ // Boundary session from cache: the cached summary has no
304
+ // per-request data, so re-read the file to trim it.
305
+ const full = await parseSessionFile(f.path);
306
+ full.projectDir = f.projectDir;
307
+ return trimToCutoff(full, cutoffMs);
308
+ }
309
+ }
310
+ const session = await parseSessionFile(f.path);
311
+ session.projectDir = f.projectDir;
312
+ if (useCache) {
313
+ putCached(cache, f, session);
314
+ misses++;
315
+ }
316
+ if (spansCutoff(session, cutoffMs)) return trimToCutoff(session, cutoffMs);
317
+ const { requests, ...summary } = session;
318
+ return summary;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }),
323
+ );
324
+ results.push(...parsed.filter(Boolean));
325
+ }
326
+
327
+ if (useCache && misses > 0) saveCache(cache);
328
+
329
+ return results.filter((s) => s.requestCount > 0);
330
+ }
package/src/paths.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Cross-platform user-data path resolution.
3
+ *
4
+ * Order of precedence:
5
+ * 1. $XDG_CONFIG_HOME (explicit override, honored on every platform)
6
+ * 2. %APPDATA% on Windows (e.g. C:\Users\foo\AppData\Roaming)
7
+ * 3. ~/Library/Application Support on macOS
8
+ * 4. ~/.config on Linux / fallback
9
+ *
10
+ * All paths are joined via node:path so the OS-correct separator is used
11
+ * automatically. Callers are responsible for `mkdirSync(..., { recursive: true })`
12
+ * before writing — every helper here returns a path string only.
13
+ */
14
+
15
+ import { join } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+
18
+ /**
19
+ * Returns the base directory for this tool's user-level data
20
+ * (config, history, last-chip state).
21
+ */
22
+ export function userDataDir() {
23
+ if (process.env.XDG_CONFIG_HOME) {
24
+ return join(process.env.XDG_CONFIG_HOME, 'claude-token-saver');
25
+ }
26
+ if (process.platform === 'win32' && process.env.APPDATA) {
27
+ return join(process.env.APPDATA, 'claude-token-saver');
28
+ }
29
+ if (process.platform === 'darwin') {
30
+ return join(homedir(), 'Library', 'Application Support', 'claude-token-saver');
31
+ }
32
+ return join(homedir(), '.config', 'claude-token-saver');
33
+ }
34
+
35
+ /**
36
+ * Returns the user's Claude Code config root (~/.claude on every OS Claude
37
+ * Code supports — the CLI itself uses this path on Windows and macOS too).
38
+ */
39
+ export function claudeUserDir() {
40
+ return join(homedir(), '.claude');
41
+ }
package/src/prompt.js ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * prompt — minimal yes/no prompting for the install flow.
3
+ *
4
+ * Everything here exists to answer one question: may we stop and ask, or must
5
+ * we fall back to a default? Most installs of this package run as npm's
6
+ * postinstall, where stdin is not a terminal and a readline prompt would either
7
+ * hang the install or read garbage. So the rule is: ask only when a human is
8
+ * demonstrably on the other end, and otherwise keep the previous
9
+ * decide-it-for-them behavior untouched.
10
+ */
11
+
12
+ import { createInterface } from 'node:readline';
13
+
14
+ /**
15
+ * Whether it is safe to block on a question.
16
+ *
17
+ * `npm_lifecycle_event` is checked in addition to the TTY test because npm can
18
+ * leave a TTY attached while still running the script unattended; CI is
19
+ * checked because build agents deadlock rather than answer.
20
+ */
21
+ export function canPrompt({ env = process.env, stdin = process.stdin, stdout = process.stdout } = {}) {
22
+ if (env.CTS_NO_INPUT === '1') return false;
23
+ if (env.CI && env.CI !== 'false') return false;
24
+ if (env.npm_lifecycle_event === 'postinstall') return false;
25
+ return Boolean(stdin.isTTY && stdout.isTTY);
26
+ }
27
+
28
+ /**
29
+ * Ask a yes/no question and resolve to a boolean.
30
+ *
31
+ * An empty answer takes `defaultValue`, which is also what a closed stream
32
+ * resolves to, so a prompt that somehow runs unattended still terminates with
33
+ * the same choice the non-interactive path would have made.
34
+ */
35
+ export function confirm(question, { defaultValue = true, input = process.stdin, output = process.stdout } = {}) {
36
+ const hint = defaultValue ? '[Y/n]' : '[y/N]';
37
+ return new Promise((resolve) => {
38
+ const rl = createInterface({ input, output });
39
+ let answered = false;
40
+ rl.question(`${question} ${hint} `, (answer) => {
41
+ answered = true;
42
+ rl.close();
43
+ const a = String(answer).trim().toLowerCase();
44
+ if (a === 'y' || a === 'yes') return resolve(true);
45
+ if (a === 'n' || a === 'no') return resolve(false);
46
+ resolve(defaultValue); // empty line, or anything we do not recognize
47
+ });
48
+ // A stream that ends without a line — a closed pipe, Ctrl-D — must still
49
+ // settle the promise, or the install would wait forever.
50
+ rl.on('close', () => { if (!answered) resolve(defaultValue); });
51
+ });
52
+ }