sprag-cli 3.42.8 → 3.44.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/README.ko.md +53 -570
- package/README.md +103 -1257
- package/bin/cli.js +19 -7
- package/package.json +1 -1
- package/presets/ratchet-rules.json +2 -2
- package/src/brief.js +1 -1
- package/src/commands/brief.js +1 -1
- package/src/commands/route-scan.js +3 -3
- package/src/commands/upgrade.js +27 -3
- package/src/formatters/statusline.js +9 -5
- package/src/harness-templates.js +5 -4
- package/src/litellm-budget.js +148 -24
- package/src/route-scan.js +1 -1
- package/src/seed-rules.js +4 -0
- package/src/update-check.js +20 -3
package/bin/cli.js
CHANGED
|
@@ -209,7 +209,7 @@ async function main() {
|
|
|
209
209
|
// sprag route-scan --hook # SessionStart hook mode (context injection)
|
|
210
210
|
// sprag route-scan dismiss <N> # mute candidate R<N>
|
|
211
211
|
// Promote a candidate to a ratchet rule (scope is always explicit):
|
|
212
|
-
// sprag harness promote R<N> --project
|
|
212
|
+
// sprag harness promote R<N> --global|--project
|
|
213
213
|
// brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
|
|
214
214
|
// briefing of state the statusline can only chip (ctx tier crossings,
|
|
215
215
|
// mid-session route/rule-health changes). Silent when nothing changed.
|
|
@@ -292,10 +292,12 @@ async function main() {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
// Subcommand: litellm-budget · LiteLLM 게이트웨이 키의 max_budget/spend 조회.
|
|
295
|
-
// sprag litellm-budget # 캐시된
|
|
295
|
+
// sprag litellm-budget # 캐시된 예산을 게이지·금액으로 출력
|
|
296
|
+
// sprag litellm-budget --json # 캐시 원본 JSON 출력
|
|
296
297
|
// sprag litellm-budget --refresh # 지금 프록시에 물어봄 (detached 자식이 사용)
|
|
297
298
|
if (args[0] === 'litellm-budget') {
|
|
298
|
-
const {
|
|
299
|
+
const { gatewayBase, readBudgetState, refreshBudgetState, formatBudgetReport } =
|
|
300
|
+
await import('../src/litellm-budget.js');
|
|
299
301
|
const quiet = hasFlag('--quiet');
|
|
300
302
|
if (hasFlag('--refresh')) {
|
|
301
303
|
try {
|
|
@@ -308,12 +310,22 @@ async function main() {
|
|
|
308
310
|
}
|
|
309
311
|
return;
|
|
310
312
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
// 읽기 경로는 캐시만 보므로 키를 요구하지 않습니다 (apiKeyHelper 환경 배려).
|
|
314
|
+
const base = gatewayBase();
|
|
315
|
+
if (!base) {
|
|
316
|
+
console.log('게이트웨이가 감지되지 않았습니다 (ANTHROPIC_BASE_URL 필요).');
|
|
314
317
|
return;
|
|
315
318
|
}
|
|
316
|
-
|
|
319
|
+
const state = readBudgetState();
|
|
320
|
+
if (hasFlag('--json')) {
|
|
321
|
+
console.log(JSON.stringify(state, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (state.base !== base || state.maxBudget === undefined) {
|
|
325
|
+
console.log('예산 캐시가 비어 있습니다. `sprag litellm-budget --refresh` 로 먼저 조회하십시오.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
for (const line of formatBudgetReport(state)) console.log(line);
|
|
317
329
|
return;
|
|
318
330
|
}
|
|
319
331
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.44.0",
|
|
4
4
|
"description": "Sprag - a quality ratchet harness for AI coding agents: model-fitting delegation, ratchet rules, doc2md, Korean style gate. Same tool as claude-token-saver, under its new name.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"en": "When a web fetch/search is blocked by robots or user-agent checks ('unable to fetch' and similar) → retry with curl carrying a browser UA header (Mozilla/5.0 ... Chrome/...). Sites like reddit also answer on their .json endpoints with that UA"
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
|
-
"ko": "ratchet 룰 promote 호출 직전 사용자에게 scope(project
|
|
19
|
-
"en": "Ask the user for the scope (
|
|
18
|
+
"ko": "ratchet 룰 promote 호출 직전 사용자에게 scope(global/project)를 먼저 묻는다 — Bash 환경은 non-TTY라 CLI readline 프롬프트가 안 뜨므로, 호출자(LLM)가 대신 묻고 --global/--project 플래그를 명시해 실행",
|
|
19
|
+
"en": "Ask the user for the scope (global or project) before calling ratchet promote — the Bash environment is non-TTY, so the CLI's readline prompt never appears; the caller (the model) has to ask and pass --global/--project explicitly"
|
|
20
20
|
},
|
|
21
21
|
{
|
|
22
22
|
"ko": "자동화 도구가 grep·정규식으로 찾는 마커 텍스트(헤더·앵커·키워드 라인)는 리팩터링·압축·수정 금지 — 정확 매칭 검출은 한 글자만 바뀌어도 깨짐",
|
package/src/brief.js
CHANGED
|
@@ -226,7 +226,7 @@ export async function runBrief({ sessionId, transcriptPath, now = Date.now() })
|
|
|
226
226
|
}
|
|
227
227
|
for (const [kind, x] of fresh) {
|
|
228
228
|
items.push(kind === 'route'
|
|
229
|
-
? `새 위임 후보가 감지되었습니다 — "${x.label}" 유형 ${x.count}회 반복(statusline의 route? R${x.id} 칩). 등록: sprag harness promote R${x.id} --project
|
|
229
|
+
? `새 위임 후보가 감지되었습니다 — "${x.label}" 유형 ${x.count}회 반복(statusline의 route? R${x.id} 칩). 등록: sprag harness promote R${x.id} --global|--project (적용 범위는 사용자에게 확인) / 무시: route-scan dismiss ${x.id}`
|
|
230
230
|
: `승인된 위임 룰의 최근 에러율이 기준(20%)을 넘었습니다 — "${x.label}" (${x.healthSource === 'delegated' ? `위임 실행 ${x.delegatedRuns}건 중 에러율 ${Math.round((x.delegatedErrRate || 0) * 100)}%` : `유사 에피소드 기준 에러율 ${Math.round((x.errRate || 0) * 100)}%`}, statusline의 rule-health 칩). 조건 좁히기/제거를 사용자와 상의하세요: sprag route-scan rules`);
|
|
231
231
|
}
|
|
232
232
|
s.briefed = [...briefed];
|
package/src/commands/brief.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* sprag route-scan --hook # SessionStart hook mode (context injection)
|
|
8
8
|
* sprag route-scan dismiss <N> # mute candidate R<N>
|
|
9
9
|
* Promote a candidate to a ratchet rule (scope is always explicit):
|
|
10
|
-
* sprag harness promote R<N> --project
|
|
10
|
+
* sprag harness promote R<N> --global|--project
|
|
11
11
|
* brief --hook — UserPromptSubmit hook mode: per-session, change-triggered
|
|
12
12
|
* briefing of state the statusline can only chip (ctx tier crossings,
|
|
13
13
|
* mid-session route/rule-health changes). Silent when nothing changed.
|
|
@@ -356,11 +356,11 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
356
356
|
}
|
|
357
357
|
if (lang === 'ko') {
|
|
358
358
|
lines.push('등록하면 다음 세션부터 자동 위임됩니다. 사용자에게 등록 여부를 물을 때 위 룰 원문을 그대로 보여주고, 적용 범위까지 확인한 뒤 실행하세요:');
|
|
359
|
-
lines.push(' sprag harness promote R<N> --project
|
|
359
|
+
lines.push(' sprag harness promote R<N> --global|--project # 적용 범위는 반드시 사용자에게 확인');
|
|
360
360
|
lines.push(' sprag route-scan dismiss <N> # 사용자가 원치 않으면');
|
|
361
361
|
} else {
|
|
362
362
|
lines.push('Once registered, delegation happens automatically from the next session. Show the user the rule text verbatim, confirm the scope with them, then run:');
|
|
363
|
-
lines.push(' sprag harness promote R<N> --project
|
|
363
|
+
lines.push(' sprag harness promote R<N> --global|--project # ALWAYS confirm the scope with the user first');
|
|
364
364
|
lines.push(' sprag route-scan dismiss <N> # if they do not want it');
|
|
365
365
|
}
|
|
366
366
|
}
|
|
@@ -446,7 +446,7 @@ export async function run({ args, hasFlag, numArg }) {
|
|
|
446
446
|
}
|
|
447
447
|
console.log('');
|
|
448
448
|
console.log(lang === 'ko' ? '등록 / 무시:' : 'Promote / dismiss:');
|
|
449
|
-
console.log(' sprag harness promote R<N> --project
|
|
449
|
+
console.log(' sprag harness promote R<N> --global|--project');
|
|
450
450
|
console.log(' sprag route-scan dismiss <N>');
|
|
451
451
|
// 최초 1회만 — 매번 찍으면 도구가 광고판이 된다 (CTS_NO_NOTE=1 로 끔)
|
|
452
452
|
(await import('../first-run-note.js')).printOnce('route-scan', lang);
|
package/src/commands/upgrade.js
CHANGED
|
@@ -25,7 +25,8 @@ function runCommand(cmd) {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export async function run({ hasFlag, version }) {
|
|
28
|
-
const { updateStatus, refreshUpdateState, upgradeCommand } =
|
|
28
|
+
const { updateStatus, refreshUpdateState, upgradeCommand, INSTALLED_UNDER_LEGACY_NAME } =
|
|
29
|
+
await import('../update-check.js');
|
|
29
30
|
const { userLanguage } = await import('../config.js');
|
|
30
31
|
const lang = userLanguage();
|
|
31
32
|
const cmd = upgradeCommand();
|
|
@@ -40,6 +41,25 @@ export async function run({ hasFlag, version }) {
|
|
|
40
41
|
// network round trip is fine here.
|
|
41
42
|
await refreshUpdateState(version);
|
|
42
43
|
const s = updateStatus(version);
|
|
44
|
+
// A legacy-name copy has somewhere to go even on the newest version: the
|
|
45
|
+
// package was renamed, and only the new name keeps receiving releases.
|
|
46
|
+
if (!s.available && INSTALLED_UNDER_LEGACY_NAME) {
|
|
47
|
+
console.log(lang === 'ko'
|
|
48
|
+
? `패키지 이름이 claude-token-saver 에서 sprag-cli 로 바뀌었습니다. 버전은 최신(v${version})이지만, 앞으로의 릴리스를 계속 받으려면 새 이름으로 옮겨야 합니다.`
|
|
49
|
+
: `The package was renamed from claude-token-saver to sprag-cli. You are on the latest version (v${version}), but future releases only ship under the new name.`);
|
|
50
|
+
console.log(`$ ${cmd}`);
|
|
51
|
+
const migrateCode = await runCommand(cmd);
|
|
52
|
+
if (migrateCode !== 0) {
|
|
53
|
+
console.error(lang === 'ko'
|
|
54
|
+
? `이전 명령이 종료 코드 ${migrateCode}로 실패했습니다. 위 출력을 확인하십시오.`
|
|
55
|
+
: `Migration command failed with exit code ${migrateCode}. Check the output above.`);
|
|
56
|
+
process.exit(migrateCode);
|
|
57
|
+
}
|
|
58
|
+
console.log(lang === 'ko'
|
|
59
|
+
? '이전이 끝났습니다. 새 셸에서 sprag --version 으로 확인하십시오.'
|
|
60
|
+
: 'Migration done. Confirm with sprag --version in a fresh shell.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
43
63
|
if (!s.available) {
|
|
44
64
|
console.log(lang === 'ko'
|
|
45
65
|
? `이미 최신 버전입니다 (v${version}). 설치할 것이 없습니다.`
|
|
@@ -48,8 +68,12 @@ export async function run({ hasFlag, version }) {
|
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
console.log(lang === 'ko'
|
|
51
|
-
?
|
|
52
|
-
|
|
71
|
+
? (INSTALLED_UNDER_LEGACY_NAME
|
|
72
|
+
? `v${version} → ${s.latest} 로 업그레이드하면서 새 패키지 이름(sprag-cli)으로 함께 옮깁니다.`
|
|
73
|
+
: `v${version} → ${s.latest} 로 업그레이드합니다.`)
|
|
74
|
+
: (INSTALLED_UNDER_LEGACY_NAME
|
|
75
|
+
? `Upgrading v${version} → ${s.latest} and moving to the new package name (sprag-cli).`
|
|
76
|
+
: `Upgrading v${version} → ${s.latest}.`));
|
|
53
77
|
console.log(`$ ${cmd}`);
|
|
54
78
|
const code = await runCommand(cmd);
|
|
55
79
|
if (code !== 0) {
|
|
@@ -50,7 +50,7 @@ const MAGENTA = fg(167, 139, 250, '\x1b[35m');
|
|
|
50
50
|
const GRAY = fg(100, 116, 139, '\x1b[90m');
|
|
51
51
|
const BOLD = '\x1b[1m';
|
|
52
52
|
|
|
53
|
-
function formatMoney(usd) {
|
|
53
|
+
export function formatMoney(usd) {
|
|
54
54
|
// Boundaries sit at 999.5/999500 so a value that would round up into the
|
|
55
55
|
// next band's width ($999.9 → "$1000") jumps to that band's unit instead —
|
|
56
56
|
// otherwise "$1000" renders visually larger than "$1.0K".
|
|
@@ -81,7 +81,7 @@ function formatPct(v) {
|
|
|
81
81
|
* for ~4% effective precision around the fill edge. Stable monospace width
|
|
82
82
|
* across all terminal fonts that ship Block Elements (U+2580–U+259F).
|
|
83
83
|
*/
|
|
84
|
-
function gaugeBar(pct) {
|
|
84
|
+
export function gaugeBar(pct) {
|
|
85
85
|
const cells = 6;
|
|
86
86
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
87
87
|
const filled = (clamped / 100) * cells; // e.g. 4.32 cells filled
|
|
@@ -640,15 +640,19 @@ export function formatReport(data, { color = true, verbose = false, timer = true
|
|
|
640
640
|
Number.isFinite(info.maxBudget) && info.maxBudget > 0
|
|
641
641
|
? ` ${formatMoney(Number(info.spend) || 0)}/${formatMoney(info.maxBudget)}`
|
|
642
642
|
: '';
|
|
643
|
+
// LiteLLM 의 internal user 한도는 차단 판정에 쓰이는 값이 아니라서, 팀
|
|
644
|
+
// 멤버십 한도와 수백 배 어긋나는 배포가 있습니다. 그 한도로 그린 게이지는
|
|
645
|
+
// 출처를 밝혀 두어야 사용자가 정확한 값으로 오해하지 않습니다.
|
|
646
|
+
const src = info.source === 'user' ? ' (user)' : '';
|
|
643
647
|
if (isIcon) {
|
|
644
|
-
const labelPart = labels.usageLabel ? `${labels.usageLabel} ` : '';
|
|
648
|
+
const labelPart = labels.usageLabel ? `${labels.usageLabel}${src} ` : '';
|
|
645
649
|
const bar = gaugeBar(pct);
|
|
646
650
|
return `${c(tone)}${labels.icon} ${labelPart}${bar} ${pct}%${money}${tail}${c(RESET)}`;
|
|
647
651
|
}
|
|
648
652
|
if (verbose) {
|
|
649
|
-
return `${c(tone)}${labels.short} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
653
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}% used${money}${tail}${c(RESET)}`;
|
|
650
654
|
}
|
|
651
|
-
return `${c(tone)}${labels.short} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
655
|
+
return `${c(tone)}${labels.short}${src} cap ${pct}%${money}${tail}${c(RESET)}`;
|
|
652
656
|
}
|
|
653
657
|
// Color tone: green <70%, yellow 70-89%, red 90+% (a 90+% window only
|
|
654
658
|
// renders here when a *different* window won the cap-warn chip slot).
|
package/src/harness-templates.js
CHANGED
|
@@ -60,9 +60,10 @@ ${HARNESS_SECTIONS[0].heading}
|
|
|
60
60
|
- 같은 에러·오해·반복 작업이 한 번 더 발생하면 즉시 \`.claude/ratchet.md\`에
|
|
61
61
|
"조건 → 행동" 한 줄로 룰 추가.
|
|
62
62
|
- sprag가 후보를 감지하면 statusline에 \`🅷⚠ ratchet?\`로 알림.
|
|
63
|
-
\`sprag harness promote "<rule>" --project
|
|
64
|
-
- **scope는 항상 사용자에게 먼저 물어볼
|
|
65
|
-
도구·환경 일반 룰이면 \`--global\`(\`~/.claude/ratchet.md\`)
|
|
63
|
+
\`sprag harness promote "<rule>" --global|--project\`로 승인.
|
|
64
|
+
- **scope는 항상 사용자에게 먼저 물어볼 것.** 선택지는 글로벌을 먼저 제시한다.
|
|
65
|
+
도구·환경 일반 룰이면 \`--global\`(\`~/.claude/ratchet.md\`), 프로젝트 한정이면
|
|
66
|
+
\`--project\`. Bash 환경은
|
|
66
67
|
non-TTY라 CLI의 readline 프롬프트가 안 뜨므로, 호출자(LLM)가 직접 묻고
|
|
67
68
|
플래그를 명시해야 함. 묻지 않고 기본값으로 등록하지 말 것.
|
|
68
69
|
- 승인된 룰은 이 블록 맨 아래 \`@\` import로 매 세션 로드된다 — 그 import 라인을
|
|
@@ -109,7 +110,7 @@ ${HARNESS_SECTIONS[4].heading}
|
|
|
109
110
|
|
|
110
111
|
📌 운영:
|
|
111
112
|
- \`sprag harness check\` — 현재 셋업 점수
|
|
112
|
-
- \`sprag harness promote "<룰>" --project
|
|
113
|
+
- \`sprag harness promote "<룰>" --global|--project\` — ratchet에 룰 추가 (scope는 사용자에게 먼저 물어볼 것)
|
|
113
114
|
- \`sprag harness off\` — statusline 표시 끄기
|
|
114
115
|
|
|
115
116
|
---
|
package/src/litellm-budget.js
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
21
|
-
import { spawn } from 'node:child_process';
|
|
21
|
+
import { spawn, execSync } from 'node:child_process';
|
|
22
22
|
import { join } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
23
24
|
import { userDataDir } from './paths.js';
|
|
25
|
+
import { gaugeBar, formatMoney } from './formatters/statusline.js';
|
|
26
|
+
import { formatResetClock } from './format-time.js';
|
|
24
27
|
import { cliEntryPath } from './update-check.js';
|
|
25
28
|
import { debug } from './debug.js';
|
|
26
29
|
|
|
@@ -28,22 +31,79 @@ import { debug } from './debug.js';
|
|
|
28
31
|
// 통계선 렌더(수 초 간격)가 프록시를 두들기지 않습니다.
|
|
29
32
|
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
30
33
|
const FETCH_TIMEOUT_MS = 5000;
|
|
34
|
+
const HELPER_TIMEOUT_MS = 10000;
|
|
31
35
|
|
|
32
36
|
export function budgetStatePath() {
|
|
33
37
|
return join(userDataDir(), 'litellm-budget.json');
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 게이트웨이 주소만 판정합니다. 캐시만 읽는 경로는 키가 필요하지 않으므로,
|
|
42
|
+
* 키 확보와 주소 판정을 분리해 둡니다. apiKeyHelper 로만 인증하는 환경에는
|
|
43
|
+
* 환경변수에 토큰이 없어서, 키를 함께 요구하면 렌더 경로가 통째로 탈락합니다.
|
|
44
|
+
*/
|
|
45
|
+
export function gatewayBase(env = process.env) {
|
|
38
46
|
const base = (env.ANTHROPIC_BASE_URL || '').trim().replace(/\/+$/, '');
|
|
39
47
|
if (!base) return null;
|
|
40
48
|
// 공식 엔드포인트를 그대로 가리키면 게이트웨이가 아닙니다.
|
|
41
49
|
if (/^https?:\/\/api\.anthropic\.com/i.test(base)) return null;
|
|
42
|
-
|
|
50
|
+
return base;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 게이트웨이 주소와 키를 함께 돌려줍니다. 둘 중 하나라도 없으면 null. */
|
|
54
|
+
export function gatewayEnv(env = process.env) {
|
|
55
|
+
const base = gatewayBase(env);
|
|
56
|
+
if (!base) return null;
|
|
57
|
+
const key = resolveKey(env);
|
|
43
58
|
if (!key) return null;
|
|
44
59
|
return { base, key };
|
|
45
60
|
}
|
|
46
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Claude Code 와 같은 방식으로 apiKeyHelper 를 실행해 토큰을 얻습니다.
|
|
64
|
+
* 환경변수에 토큰을 두지 않고 헬퍼 스크립트로 매번 발급받는 구성이 공식 인증
|
|
65
|
+
* 방식 가운데 하나이며, 그 구성에서는 statusline 자식 프로세스의 process.env
|
|
66
|
+
* 안에 토큰이 존재하지 않습니다.
|
|
67
|
+
*
|
|
68
|
+
* 헬퍼 실행은 비용이 있으므로 갱신 경로(5분에 한 번 뜨는 detached 자식)에서만
|
|
69
|
+
* 부릅니다. 수 초 간격으로 도는 렌더 경로에서 부르면 통계선이 그만큼 느려집니다.
|
|
70
|
+
*/
|
|
71
|
+
export function keyFromApiKeyHelper(cwd = process.cwd()) {
|
|
72
|
+
const candidates = [
|
|
73
|
+
join(cwd, '.claude', 'settings.local.json'),
|
|
74
|
+
join(cwd, '.claude', 'settings.json'),
|
|
75
|
+
join(homedir(), '.claude', 'settings.json'),
|
|
76
|
+
];
|
|
77
|
+
for (const p of candidates) {
|
|
78
|
+
let helper;
|
|
79
|
+
try {
|
|
80
|
+
helper = JSON.parse(readFileSync(p, 'utf8'))?.apiKeyHelper;
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (typeof helper !== 'string' || !helper.trim()) continue;
|
|
85
|
+
try {
|
|
86
|
+
const out = execSync(helper, {
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
timeout: HELPER_TIMEOUT_MS,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
90
|
+
});
|
|
91
|
+
// 진행 로그를 함께 출력하는 헬퍼가 있으므로 마지막 비어 있지 않은 줄을 취합니다.
|
|
92
|
+
const token = out.trim().split(/\r?\n/).filter(Boolean).pop();
|
|
93
|
+
if (token) return token.trim();
|
|
94
|
+
} catch (e) {
|
|
95
|
+
debug('litellm-budget:helper', e);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 환경변수 토큰을 먼저 보고, 없으면 apiKeyHelper 로 내려갑니다. */
|
|
102
|
+
export function resolveKey(env = process.env) {
|
|
103
|
+
const direct = (env.ANTHROPIC_AUTH_TOKEN || env.ANTHROPIC_API_KEY || '').trim();
|
|
104
|
+
return direct || keyFromApiKeyHelper();
|
|
105
|
+
}
|
|
106
|
+
|
|
47
107
|
export function readBudgetState() {
|
|
48
108
|
try {
|
|
49
109
|
const s = JSON.parse(readFileSync(budgetStatePath(), 'utf8'));
|
|
@@ -67,11 +127,11 @@ function writeBudgetState(next) {
|
|
|
67
127
|
* spend:number, maxBudget:number}|null}
|
|
68
128
|
*/
|
|
69
129
|
export function budgetWindow(env = process.env) {
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
130
|
+
const base = gatewayBase(env);
|
|
131
|
+
if (!base) return null;
|
|
72
132
|
const s = readBudgetState();
|
|
73
133
|
// 다른 프록시의 캐시를 재사용하지 않도록 base 단위로 격리합니다.
|
|
74
|
-
if (s.base !==
|
|
134
|
+
if (s.base !== base) return null;
|
|
75
135
|
const max = Number(s.maxBudget);
|
|
76
136
|
const spend = Number(s.spend);
|
|
77
137
|
if (!Number.isFinite(max) || max <= 0 || !Number.isFinite(spend)) return null;
|
|
@@ -84,19 +144,66 @@ export function budgetWindow(env = process.env) {
|
|
|
84
144
|
resetsAt: resetMs ? Math.round(resetMs / 1000) : null,
|
|
85
145
|
spend,
|
|
86
146
|
maxBudget: max,
|
|
147
|
+
// 렌더러가 출처를 함께 보여 줄 수 있도록 싣습니다. internal user 한도는
|
|
148
|
+
// LiteLLM 이 차단 판정에 쓰는 값이 아니라서 그대로 보여 주면 오해를 부릅니다.
|
|
149
|
+
source: typeof s.source === 'string' ? s.source : null,
|
|
87
150
|
};
|
|
88
151
|
}
|
|
89
152
|
|
|
153
|
+
/**
|
|
154
|
+
* `sprag litellm-budget` 이 출력하는 사람용 보고서입니다. 통계선의 cap 게이지와
|
|
155
|
+
* 같은 모양(비주얼 바 + 백분율 + 금액)을 쓰되, 통계선에서는 폭이 부족해 생략하는
|
|
156
|
+
* 잔여 금액과 예산 출처, 캐시 신선도를 함께 적습니다.
|
|
157
|
+
*
|
|
158
|
+
* @param {object} state - readBudgetState() 가 돌려준 캐시 상태
|
|
159
|
+
* @param {Date} [now]
|
|
160
|
+
* @returns {string[]} 출력할 줄들
|
|
161
|
+
*/
|
|
162
|
+
export function formatBudgetReport(state, now = new Date()) {
|
|
163
|
+
const max = Number(state?.maxBudget);
|
|
164
|
+
const spend = Number(state?.spend);
|
|
165
|
+
if (!Number.isFinite(max) || max <= 0) {
|
|
166
|
+
const spent = Number.isFinite(spend) ? formatMoney(spend) : '알 수 없음';
|
|
167
|
+
return [`예산 한도가 설정되지 않은 키입니다 (무제한). 누적 지출: ${spent}`];
|
|
168
|
+
}
|
|
169
|
+
const used = Number.isFinite(spend) ? Math.max(0, spend) : 0;
|
|
170
|
+
const left = Math.max(0, max - used);
|
|
171
|
+
const pct = Math.min(100, (used / max) * 100);
|
|
172
|
+
const lines = [
|
|
173
|
+
`🔑 budget ${gaugeBar(pct)} ${Math.round(pct)}% ${formatMoney(used)}/${formatMoney(max)}`,
|
|
174
|
+
` 사용 ${formatMoney(used)} · 잔여 ${formatMoney(left)} (${(100 - pct).toFixed(1)}%)`,
|
|
175
|
+
];
|
|
176
|
+
const resetMs = Number(state?.budgetResetAt);
|
|
177
|
+
if (Number.isFinite(resetMs) && resetMs > 0) {
|
|
178
|
+
const clock = formatResetClock(Math.round(resetMs / 1000), now);
|
|
179
|
+
if (clock) lines.push(` 리셋 ${clock}`);
|
|
180
|
+
}
|
|
181
|
+
const labels = { team: '팀 멤버십 예산', key: '키 예산', user: '사용자 예산' };
|
|
182
|
+
if (state?.source) lines.push(` 출처 ${labels[state.source] || state.source}`);
|
|
183
|
+
if (state?.source === 'user') {
|
|
184
|
+
// LiteLLM 은 팀 멤버십 한도로 차단 여부를 판정하므로, 사용자 한도만 잡힌
|
|
185
|
+
// 상태에서는 실제 한도와 크게 어긋날 수 있습니다.
|
|
186
|
+
lines.push(' 주의: 사용자 한도는 차단 판정 기준과 다를 수 있습니다.');
|
|
187
|
+
}
|
|
188
|
+
const checkedAt = Number(state?.checkedAt);
|
|
189
|
+
if (Number.isFinite(checkedAt) && checkedAt > 0) {
|
|
190
|
+
const ageMin = Math.max(0, Math.round((now.getTime() - checkedAt) / 60000));
|
|
191
|
+
lines.push(` 조회 ${ageMin}분 전`);
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
90
196
|
/** 캐시가 오래됐으면 detached 자식으로 갱신을 예약합니다. 즉시 반환. */
|
|
91
197
|
export function maybeSpawnBudgetCheck(env = process.env) {
|
|
92
|
-
|
|
93
|
-
|
|
198
|
+
// 키 확보는 자식이 담당합니다. 렌더 경로에서 헬퍼를 돌리면 통계선이 느려집니다.
|
|
199
|
+
const base = gatewayBase(env);
|
|
200
|
+
if (!base) return false;
|
|
94
201
|
const s = readBudgetState();
|
|
95
202
|
const age = Date.now() - (Number(s.checkedAt) || 0);
|
|
96
|
-
if (s.base ===
|
|
203
|
+
if (s.base === base && age < CHECK_INTERVAL_MS) return false;
|
|
97
204
|
// 오프라인/오류 시 렌더마다 자식을 다시 띄우지 않도록 시도 시각을 먼저 기록합니다.
|
|
98
205
|
try {
|
|
99
|
-
writeBudgetState({ ...s, base
|
|
206
|
+
writeBudgetState({ ...s, base, checkedAt: Date.now() });
|
|
100
207
|
} catch (e) {
|
|
101
208
|
debug('litellm-budget:stamp', e);
|
|
102
209
|
return false;
|
|
@@ -135,7 +242,10 @@ function parseResetMs(v) {
|
|
|
135
242
|
* budgetResetAt:number|null}|null}
|
|
136
243
|
*/
|
|
137
244
|
export function pickBudgetSource(keyInfo, userInfo) {
|
|
138
|
-
|
|
245
|
+
// /key/info 가 404 인 게이트웨이(커스텀 인증)에서는 keyInfo 가 비므로,
|
|
246
|
+
// /user/info 가 함께 주는 내 user_id 를 폴백으로 씁니다. 이 폴백이 없으면
|
|
247
|
+
// 팀 전체 멤버십을 돌려주는 배포에서 남의 예산을 집을 수 있습니다.
|
|
248
|
+
const userId = keyInfo?.user_id ?? userInfo?.user_info?.user_id ?? null;
|
|
139
249
|
const teamId = keyInfo?.team_id ?? null;
|
|
140
250
|
// ① 팀 멤버십 예산: 키의 team_id 에 해당하는 팀에서 내 user_id 의 멤버십을 찾는다.
|
|
141
251
|
const teams = Array.isArray(userInfo?.teams) ? userInfo.teams : [];
|
|
@@ -184,34 +294,48 @@ export function pickBudgetSource(keyInfo, userInfo) {
|
|
|
184
294
|
* /key/info(키·소속 식별)와 /user/info(팀 멤버십 예산)를 함께 조회합니다.
|
|
185
295
|
*/
|
|
186
296
|
export async function refreshBudgetState(env = process.env, fetchImpl = fetch) {
|
|
187
|
-
const
|
|
188
|
-
if (!
|
|
297
|
+
const base = gatewayBase(env);
|
|
298
|
+
if (!base) return null;
|
|
299
|
+
const key = resolveKey(env);
|
|
300
|
+
if (!key) return null;
|
|
189
301
|
const controller = new AbortController();
|
|
190
302
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
191
303
|
const headers = {
|
|
192
304
|
accept: 'application/json',
|
|
193
|
-
authorization: `Bearer ${
|
|
305
|
+
authorization: `Bearer ${key}`,
|
|
194
306
|
};
|
|
195
307
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
308
|
+
// /key/info 는 커스텀 인증(JWT)을 쓰는 배포에서 404 가 정상입니다. 발급된
|
|
309
|
+
// 토큰에 해당하는 행이 LiteLLM_VerificationToken 에 없기 때문입니다. 예산
|
|
310
|
+
// 자체는 /user/info 에 있으므로, 여기서 중단하면 안 됩니다.
|
|
311
|
+
let keyInfo = null;
|
|
312
|
+
try {
|
|
313
|
+
const keyRes = await fetchImpl(`${base}/key/info`, { signal: controller.signal, headers });
|
|
314
|
+
if (keyRes.ok) {
|
|
315
|
+
const keyBody = await keyRes.json();
|
|
316
|
+
if (keyBody && typeof keyBody.info === 'object') keyInfo = keyBody.info;
|
|
317
|
+
} else {
|
|
318
|
+
debug('litellm-budget:key-info', new Error(`LiteLLM responded ${keyRes.status}`));
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {
|
|
321
|
+
debug('litellm-budget:key-info', e);
|
|
322
|
+
}
|
|
202
323
|
let userInfo = null;
|
|
203
324
|
try {
|
|
204
|
-
const userRes = await fetchImpl(`${
|
|
325
|
+
const userRes = await fetchImpl(`${base}/user/info`, { signal: controller.signal, headers });
|
|
205
326
|
if (userRes.ok) userInfo = await userRes.json();
|
|
327
|
+
else debug('litellm-budget:user-info', new Error(`LiteLLM responded ${userRes.status}`));
|
|
206
328
|
} catch (e) {
|
|
207
329
|
debug('litellm-budget:user-info', e);
|
|
208
330
|
}
|
|
331
|
+
// 둘 다 받지 못했을 때만 실패로 봅니다.
|
|
332
|
+
if (!keyInfo && !userInfo) throw new Error('neither key/info nor user/info returned data');
|
|
209
333
|
const picked = pickBudgetSource(keyInfo, userInfo);
|
|
210
334
|
const next = {
|
|
211
|
-
base
|
|
335
|
+
base,
|
|
212
336
|
checkedAt: Date.now(),
|
|
213
337
|
source: picked ? picked.source : null,
|
|
214
|
-
spend: picked ? picked.spend : (numOrNull(keyInfo
|
|
338
|
+
spend: picked ? picked.spend : (numOrNull(keyInfo?.spend) ?? 0),
|
|
215
339
|
maxBudget: picked ? picked.maxBudget : null,
|
|
216
340
|
budgetResetAt: picked ? picked.budgetResetAt : null,
|
|
217
341
|
};
|
package/src/route-scan.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Pipeline position (per design discussion): this scan is NOT a real-time
|
|
16
16
|
* router — it is a session-boundary calibrator that feeds the existing
|
|
17
|
-
* ratchet promote flow (`harness promote R<N> --project
|
|
17
|
+
* ratchet promote flow (`harness promote R<N> --global|--project`).
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync } from 'node:fs';
|
package/src/seed-rules.js
CHANGED
|
@@ -243,7 +243,9 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
243
243
|
const lines = [];
|
|
244
244
|
if (lang === 'ko') {
|
|
245
245
|
lines.push(`[claude-token-saver seed] 아직 등록되지 않은 추천 룰이 ${pending.length}건 있습니다 (패키지 동봉 프리셋).`);
|
|
246
|
+
lines.push('**이 세션의 첫 응답에서 다른 작업보다 먼저 1번 룰의 등록 여부를 물어보십시오.** 사용자가 먼저 꺼낸 용건이 있으면 그 답변을 마친 직후에 이어서 묻고, 용건이 끝나기를 기다리지 마십시오. 사용자가 이 안내 문구에 대해 질문할 때까지 미루면 안 됩니다.');
|
|
246
247
|
lines.push('사용자에게 **한 건씩 순서대로** 보여 주고 등록할지 물어보십시오. 한 번에 전체 등록을 권하지 말고, 답을 받은 뒤 바로 아래 명령을 실행하십시오.');
|
|
248
|
+
lines.push('적용 범위를 물을 때는 선택지를 **글로벌(모든 프로젝트)을 먼저, 이 프로젝트만을 그다음**으로 제시하십시오.');
|
|
247
249
|
if (models.length > 0) {
|
|
248
250
|
lines.push(` · 모델 피팅 룰 ${models.length}건 — 등록하면 해당 유형 작업이 더 싼 서브에이전트로 위임됩니다.`);
|
|
249
251
|
for (const s of models) {
|
|
@@ -260,7 +262,9 @@ export function seedOfferBlock({ lang = userLanguage(), root = findProjectRoot()
|
|
|
260
262
|
lines.push(' (사용자가 "전부 등록"이라고 답하면 `seed accept all --global` 을 쓸 수 있습니다)');
|
|
261
263
|
} else {
|
|
262
264
|
lines.push(`[claude-token-saver seed] ${pending.length} recommended rule(s) from the bundled presets are not registered yet.`);
|
|
265
|
+
lines.push('**Ask about rule 1 in your very first reply of this session, before anything else.** If the user opened with their own request, answer it and then ask right away; do not wait for their task to finish, and never wait until they ask about this notice.');
|
|
263
266
|
lines.push('Walk the user through them **one at a time** and ask about each. Do not push the whole set at once; run the matching command as soon as they answer.');
|
|
267
|
+
lines.push('When you ask about the scope, offer **global (all projects) first, this project only second**.');
|
|
264
268
|
if (models.length > 0) {
|
|
265
269
|
lines.push(` · ${models.length} model-fitting rule(s) — once registered, that kind of work goes to a cheaper subagent.`);
|
|
266
270
|
for (const s of models) {
|
package/src/update-check.js
CHANGED
|
@@ -23,10 +23,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
23
23
|
import { userDataDir } from './paths.js';
|
|
24
24
|
import { debug } from './debug.js';
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// This copy may be installed under the canonical name or under the legacy one.
|
|
27
|
+
// Releases go out under the canonical name, so that is the entry the registry
|
|
28
|
+
// check consults either way — a legacy copy must keep hearing about new
|
|
29
|
+
// versions even after the legacy name stops receiving publishes.
|
|
28
30
|
import { createRequire } from 'node:module';
|
|
29
31
|
const PKG_NAME = createRequire(import.meta.url)('../package.json').name;
|
|
32
|
+
const CANONICAL_NAME = 'sprag-cli';
|
|
33
|
+
const LEGACY_NAME = 'claude-token-saver';
|
|
34
|
+
const IS_LEGACY = PKG_NAME === LEGACY_NAME;
|
|
30
35
|
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h — the registry is not a health endpoint
|
|
31
36
|
const FETCH_TIMEOUT_MS = 5000;
|
|
32
37
|
|
|
@@ -148,7 +153,7 @@ export async function refreshUpdateState(currentVersion) {
|
|
|
148
153
|
try {
|
|
149
154
|
// The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
|
|
150
155
|
// full packument which is megabytes for a package with this many releases.
|
|
151
|
-
const res = await fetch(`https://registry.npmjs.org/${
|
|
156
|
+
const res = await fetch(`https://registry.npmjs.org/${CANONICAL_NAME}/latest`, {
|
|
152
157
|
signal: controller.signal,
|
|
153
158
|
headers: { accept: 'application/json' },
|
|
154
159
|
});
|
|
@@ -194,6 +199,15 @@ export function dismissUpdate(version) {
|
|
|
194
199
|
*/
|
|
195
200
|
export function upgradeCommand() {
|
|
196
201
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
202
|
+
// A legacy copy upgrades by moving to the canonical name. Installing the new
|
|
203
|
+
// name alone would leave two copies fighting over the same `sprag` binary,
|
|
204
|
+
// so the old one comes off first.
|
|
205
|
+
if (IS_LEGACY) {
|
|
206
|
+
if (here.includes('/pnpm/')) return `pnpm remove -g ${LEGACY_NAME} && pnpm add -g ${CANONICAL_NAME}@latest`;
|
|
207
|
+
if (here.includes('/.bun/')) return `bun remove -g ${LEGACY_NAME} && bun add -g ${CANONICAL_NAME}@latest`;
|
|
208
|
+
if (here.includes('/.yarn/')) return `yarn global remove ${LEGACY_NAME} && yarn global add ${CANONICAL_NAME}@latest`;
|
|
209
|
+
return `npm uninstall -g ${LEGACY_NAME} && npm install -g ${CANONICAL_NAME}@latest`;
|
|
210
|
+
}
|
|
197
211
|
if (here.includes('/pnpm/')) return `pnpm add -g ${PKG_NAME}@latest`;
|
|
198
212
|
if (here.includes('/.bun/')) return `bun add -g ${PKG_NAME}@latest`;
|
|
199
213
|
if (here.includes('/.yarn/')) return `yarn global add ${PKG_NAME}@latest`;
|
|
@@ -202,3 +216,6 @@ export function upgradeCommand() {
|
|
|
202
216
|
|
|
203
217
|
export const UPDATE_CHECK_INTERVAL_MS = CHECK_INTERVAL_MS;
|
|
204
218
|
export const PACKAGE_NAME = PKG_NAME;
|
|
219
|
+
export const CANONICAL_PACKAGE_NAME = CANONICAL_NAME;
|
|
220
|
+
/** True when this copy was installed under the legacy package name. */
|
|
221
|
+
export const INSTALLED_UNDER_LEGACY_NAME = IS_LEGACY;
|