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.
- package/LICENSE +21 -0
- package/README.ko.md +637 -0
- package/README.md +758 -0
- package/bin/cli.js +801 -0
- package/examples/statusline-command.ps1 +43 -0
- package/examples/statusline-command.sh +36 -0
- package/package.json +62 -0
- package/presets/cohesion/cohesion-en.md +26 -0
- package/presets/doc2md/convert.py +363 -0
- package/presets/korean-style/LICENSE-fluent-korean +21 -0
- package/presets/korean-style/fluent-korean.md +52 -0
- package/presets/korean-style/supplement.md +93 -0
- package/presets/model-rules.json +115 -0
- package/presets/ratchet-rules.json +38 -0
- package/src/advice.js +564 -0
- package/src/agents.js +52 -0
- package/src/brief.js +264 -0
- package/src/caps-cache.js +84 -0
- package/src/cli-args.js +51 -0
- package/src/cohesion.js +70 -0
- package/src/commands/brief.js +31 -0
- package/src/commands/cohesion.js +59 -0
- package/src/commands/compact-window.js +93 -0
- package/src/commands/doc2md.js +166 -0
- package/src/commands/feedback.js +132 -0
- package/src/commands/handoff.js +33 -0
- package/src/commands/harness.js +459 -0
- package/src/commands/history.js +46 -0
- package/src/commands/install.js +358 -0
- package/src/commands/korean.js +220 -0
- package/src/commands/last.js +151 -0
- package/src/commands/mode.js +46 -0
- package/src/commands/route-scan.js +454 -0
- package/src/commands/seed.js +105 -0
- package/src/commands/uninstall.js +42 -0
- package/src/commands/update-check.js +77 -0
- package/src/commands/upgrade.js +68 -0
- package/src/compact-window.js +205 -0
- package/src/config.js +232 -0
- package/src/cost.js +253 -0
- package/src/debug.js +29 -0
- package/src/demo.js +331 -0
- package/src/doc2md-ledger.cjs +227 -0
- package/src/doc2md.cjs +997 -0
- package/src/fig2md-runner.cjs +21 -0
- package/src/fig2md.cjs +191 -0
- package/src/first-run-note.js +63 -0
- package/src/format-time.js +44 -0
- package/src/formatters/csv.js +8 -0
- package/src/formatters/json.js +3 -0
- package/src/formatters/statusline.js +750 -0
- package/src/formatters/table.js +299 -0
- package/src/handoff.js +161 -0
- package/src/harness-analyzer.cjs +264 -0
- package/src/harness-templates.js +153 -0
- package/src/harness.js +613 -0
- package/src/history.js +383 -0
- package/src/hook-manager.js +96 -0
- package/src/hook.cjs +196 -0
- package/src/installer.js +614 -0
- package/src/korean-lint.cjs +303 -0
- package/src/korean-style.js +187 -0
- package/src/litellm-budget.js +223 -0
- package/src/model-alias.js +484 -0
- package/src/model-rules.js +527 -0
- package/src/month-spend.js +47 -0
- package/src/parser.js +330 -0
- package/src/paths.js +41 -0
- package/src/prompt.js +52 -0
- package/src/route-scan.js +832 -0
- package/src/savings-ledger.js +137 -0
- package/src/seed-rules.js +280 -0
- package/src/session-cache.js +160 -0
- package/src/session-records.js +188 -0
- package/src/stats.js +380 -0
- package/src/stdin-payload.js +122 -0
- package/src/subagent-records.js +214 -0
- package/src/update-check.js +201 -0
- 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
|
+
}
|