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
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Subcommand: doc2md — convert attached documents to Markdown before the
3
+ * model reads them.
4
+ *
5
+ * claude-token-saver doc2md # status
6
+ * claude-token-saver doc2md on|off # register / remove the Read hook
7
+ * claude-token-saver doc2md <file> # convert one file by hand
8
+ * claude-token-saver doc2md --clean # drop every cached conversion
9
+ * claude-token-saver doc2md --hook # PreToolUse entry point
10
+ */
11
+
12
+ import { createRequire } from 'node:module';
13
+ import { readdirSync, rmSync, existsSync, statSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+
16
+ const require = createRequire(import.meta.url);
17
+
18
+ /**
19
+ * Whether the Read hook is actually in settings.json.
20
+ *
21
+ * Status output that reports only the converter is misleading: a working
22
+ * converter with no hook, and a hook with no converter, both add up to
23
+ * "nothing happens", and the user has no way to tell which half is missing.
24
+ */
25
+ function hookRegistered() {
26
+ try {
27
+ const { homedir } = require('node:os');
28
+ const settings = JSON.parse(
29
+ require('node:fs').readFileSync(join(homedir(), '.claude', 'settings.json'), 'utf8'),
30
+ );
31
+ return (settings?.hooks?.PreToolUse || []).some((m) =>
32
+ (m.hooks || []).some((h) => typeof h.command === 'string' && h.command.includes('doc2md --hook')),
33
+ );
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export async function run({ args, hasFlag }) {
40
+ const doc2md = require('../doc2md.cjs');
41
+ const sub = args[1];
42
+
43
+ // Hook path first and cheap: this runs on every Read of a matching file, so
44
+ // nothing above it may cost a syscall.
45
+ if (hasFlag?.('--hook') || sub === '--hook') {
46
+ const { readStdinJson } = await import('../stdin-payload.js');
47
+ const payload = readStdinJson();
48
+ if (!payload) return;
49
+ let out = null;
50
+ try {
51
+ out = doc2md.formatHookOutput(doc2md.decideForRead(payload) || doc2md.decideForWrite(payload));
52
+ } catch {
53
+ // A converter that throws must not take the Read down with it. Printing
54
+ // nothing leaves Claude Code to run the tool call exactly as before.
55
+ return;
56
+ }
57
+ if (out) console.log(out);
58
+ return;
59
+ }
60
+
61
+ if (sub === 'install-converter') {
62
+ const res = doc2md.installConverter({ onProgress: (m) => console.log(` ${m}`) });
63
+ if (res.ok) {
64
+ console.log(`✓ converter ready: ${res.python}`);
65
+ } else {
66
+ console.error(`✗ ${res.reason}: ${res.detail}`);
67
+ process.exitCode = 1;
68
+ }
69
+ // The .fig parser is a separate, Node-side install. A markitdown failure
70
+ // above must not block it — the two formats fail independently.
71
+ const fig2md = require('../fig2md.cjs');
72
+ const { userDataDir } = await import('../paths.js');
73
+ const figRes = fig2md.installFigParser(userDataDir(), { onProgress: (m) => console.log(` ${m}`) });
74
+ if (figRes.ok) {
75
+ console.log('✓ .fig parser ready (openfig-core)');
76
+ } else {
77
+ console.error(`✗ .fig parser: ${figRes.reason}: ${figRes.detail || ''}`);
78
+ process.exitCode = 1;
79
+ }
80
+ return;
81
+ }
82
+
83
+ // UserPromptSubmit entry point. Separate from `--hook` because the two speak
84
+ // different protocols: PreToolUse answers with a permission decision, this
85
+ // one answers with plain text that becomes context the model can act on.
86
+ if (hasFlag?.('--hook-prompt') || sub === '--hook-prompt') {
87
+ const { readStdinJson } = await import('../stdin-payload.js');
88
+ const payload = readStdinJson();
89
+ if (!payload) return;
90
+ const { userLanguage } = await import('../config.js');
91
+ try {
92
+ const context = doc2md.contextForPrompt(payload, { lang: userLanguage() });
93
+ if (context) console.log(context);
94
+ } catch {
95
+ // Never break a prompt over a conversion. Silence leaves the session
96
+ // exactly as it would have been without this feature.
97
+ }
98
+ return;
99
+ }
100
+
101
+ if (sub === 'on') {
102
+ const { installDoc2mdHook } = await import('../installer.js');
103
+ const res = installDoc2mdHook();
104
+ console.log(res.action === 'skipped'
105
+ ? `✗ ${res.reason}`
106
+ : `✓ Read hook ${res.action} (${res.path})`);
107
+ // A registered hook with no converter behind it does nothing at all, and
108
+ // says nothing about it either, which reads as a broken feature. Offer the
109
+ // one command that closes the gap right where the gap is visible.
110
+ const python = doc2md.findInterpreter();
111
+ console.log(python
112
+ ? ` converter: markitdown via ${python}`
113
+ : ` converter: missing — run \`${doc2md.INSTALL_HINT}\` or the hook will do nothing`);
114
+ return;
115
+ }
116
+
117
+ if (sub === 'off') {
118
+ const { removeDoc2mdHook } = await import('../installer.js');
119
+ const res = removeDoc2mdHook();
120
+ console.log(res.action === 'skipped' ? `✗ ${res.reason}` : `✓ Read hook ${res.action}`);
121
+ return;
122
+ }
123
+
124
+ if (hasFlag?.('--clean') || sub === '--clean') {
125
+ const dir = doc2md.cacheDir();
126
+ let removed = 0;
127
+ if (existsSync(dir)) {
128
+ for (const name of readdirSync(dir)) {
129
+ rmSync(join(dir, name), { force: true });
130
+ removed += 1;
131
+ }
132
+ }
133
+ console.log(`✓ removed ${removed} cached file(s) from ${dir}`);
134
+ return;
135
+ }
136
+
137
+ // A path: convert it now and print where the result landed. This is the
138
+ // diagnostic path — it reports the refusal reason instead of swallowing it,
139
+ // which is how you find out that markitdown is missing rather than guessing.
140
+ if (sub && !sub.startsWith('-')) {
141
+ const result = doc2md.convert(sub);
142
+ if (result.ok) {
143
+ console.log(`✓ ${result.cached ? 'cached' : 'converted'}: ${result.cacheFile}`);
144
+ if (result.meta?.note) console.log(` ${result.meta.note}`);
145
+ if (result.meta?.clipped) console.log(' 결과가 상한을 넘어 뒷부분을 잘랐습니다.');
146
+ console.log(` ${statSync(result.cacheFile).size} bytes`);
147
+ return;
148
+ }
149
+ console.error(`✗ ${result.reason}${result.detail ? `: ${result.detail}` : ''}`);
150
+ if (result.reason === 'no-markitdown') console.error(` ${doc2md.INSTALL_HINT}`);
151
+ process.exitCode = 1;
152
+ return;
153
+ }
154
+
155
+ const python = doc2md.findInterpreter();
156
+ const dir = doc2md.cacheDir();
157
+ const cached = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith('.md')).length : 0;
158
+ console.log('doc2md — attached documents are converted to Markdown before the model reads them.');
159
+ console.log(` formats: ${doc2md.TARGET_EXTENSIONS.join(' ')}`);
160
+ console.log(` converter: ${python ? `markitdown via ${python}` : `not installed — ${doc2md.INSTALL_HINT}`}`);
161
+ console.log(` cache: ${dir} (${cached} file(s))`);
162
+ console.log(` hook: ${hookRegistered() ? 'registered on Read' : 'not registered'}`);
163
+ console.log('');
164
+ if (!python) console.log(`Install the converter: ${doc2md.INSTALL_HINT}`);
165
+ console.log('Enable with: claude-token-saver doc2md on');
166
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Subcommand: feedback — submit a bug report or feature request from the
3
+ * terminal (or from a Claude Code session) without opening a browser.
4
+ *
5
+ * claude-token-saver feedback "statusline이 IntelliJ에서 깨져요"
6
+ * claude-token-saver feedback --title "cache chip" "5m TTL 칩이 안 사라짐"
7
+ *
8
+ * Why this exists: GitHub issues require a logged-in browser session, and
9
+ * corporate networks often block github.com entirely. This command tries
10
+ * three transports in order and reports which one carried the message:
11
+ *
12
+ * 1. `gh` CLI, if installed and authenticated — files a real GitHub issue.
13
+ * 2. Anonymous Google Form POST — no login, no GitHub access needed.
14
+ * (Only when the form endpoint below is configured for this build.)
15
+ * 3. Local fallback — saves the report to a Markdown file and prints a
16
+ * prefilled GitHub new-issue URL to use from an unblocked machine.
17
+ *
18
+ * Metadata (tool version, OS, Node version) is attached automatically so a
19
+ * report is diagnosable without a follow-up round trip.
20
+ */
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { writeFileSync, mkdirSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import os from 'node:os';
26
+ import { debug } from '../debug.js';
27
+
28
+ const REPO = 'rootstudioyaml/claude-token-saver';
29
+ const ISSUES_URL = `https://github.com/${REPO}/issues`;
30
+
31
+ // Anonymous submission endpoint (Google Form). A Google Form's formResponse
32
+ // URL accepts unauthenticated POSTs, which is exactly the property a
33
+ // login-free, GitHub-blocked-network path needs. Responses land in the
34
+ // maintainer's "claude-token-saver 피드백 (Feedback)" form (published
35
+ // 2026-09-13, responder access: anyone with the link).
36
+ // id — the /d/e/<id>/ segment of the form URL
37
+ // message — entry.NNNN field id of the message question
38
+ // meta — entry.NNNN field id of the metadata question
39
+ const FORM = {
40
+ id: '1FAIpQLScISjU8_t9y8xfp-X3jvLV6_RcSjQIJ4aydATC8NazkxcJbgg',
41
+ message: 'entry.79517542',
42
+ meta: 'entry.112678638',
43
+ };
44
+
45
+ function metadata(version) {
46
+ return [
47
+ `version: ${version}`,
48
+ `os: ${process.platform} ${os.release()}`,
49
+ `node: ${process.version}`,
50
+ ].join('\n');
51
+ }
52
+
53
+ function tryGhCli(title, body) {
54
+ try {
55
+ const auth = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8', timeout: 10_000 });
56
+ if (auth.status !== 0) return null;
57
+ const res = spawnSync('gh', ['issue', 'create', '-R', REPO, '--title', title, '--body', body],
58
+ { encoding: 'utf8', timeout: 30_000 });
59
+ if (res.status !== 0) { debug('feedback:gh', res.stderr); return null; }
60
+ const url = String(res.stdout).trim().split('\n').pop();
61
+ return { transport: 'gh', url };
62
+ } catch (e) { debug('feedback:gh', e); return null; }
63
+ }
64
+
65
+ async function tryForm(title, body, meta) {
66
+ if (!FORM) return null;
67
+ try {
68
+ const params = new URLSearchParams();
69
+ params.set(FORM.message, `${title}\n\n${body}`);
70
+ params.set(FORM.meta, meta);
71
+ const res = await fetch(`https://docs.google.com/forms/d/e/${FORM.id}/formResponse`, {
72
+ method: 'POST',
73
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
74
+ body: params.toString(),
75
+ signal: AbortSignal.timeout(15_000),
76
+ });
77
+ if (!res.ok) { debug('feedback:form', `HTTP ${res.status}`); return null; }
78
+ return { transport: 'form' };
79
+ } catch (e) { debug('feedback:form', e); return null; }
80
+ }
81
+
82
+ function saveLocal(title, body, dataDir) {
83
+ const dir = join(dataDir, 'feedback');
84
+ mkdirSync(dir, { recursive: true });
85
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
86
+ const file = join(dir, `${stamp}.md`);
87
+ writeFileSync(file, `# ${title}\n\n${body}\n`);
88
+ const prefilled = `${ISSUES_URL}/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
89
+ return { transport: 'local', file, prefilled };
90
+ }
91
+
92
+ export async function run({ args, getArg, version, dataDir }) {
93
+ // --anonymous skips the gh transport: for people whose gh CLI is signed in
94
+ // with an account they do not want attached to the report.
95
+ const anonymous = args.includes('--anonymous');
96
+ const rest = args.slice(1).filter((a, i, all) => {
97
+ if (a === '--title') return false;
98
+ if (all[i - 1] === '--title') return false;
99
+ return !a.startsWith('--');
100
+ });
101
+ const message = rest.join(' ').trim();
102
+ const explicitTitle = getArg('--title');
103
+
104
+ if (!message) {
105
+ console.log('usage: claude-token-saver feedback [--title "<제목>"] "<내용>"');
106
+ console.log(` (GitHub에서 직접 제보: ${ISSUES_URL})`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+
111
+ const title = explicitTitle || (message.length > 60 ? `${message.slice(0, 57)}...` : message);
112
+ const meta = metadata(version);
113
+ const body = `${message}\n\n---\n${meta}`;
114
+
115
+ const viaGh = anonymous ? null : tryGhCli(title, body);
116
+ if (viaGh) {
117
+ console.log(`feedback: GitHub 이슈로 등록했습니다 — ${viaGh.url}`);
118
+ return;
119
+ }
120
+
121
+ const viaForm = await tryForm(title, message, meta);
122
+ if (viaForm) {
123
+ console.log('feedback: 제출했습니다. 감사합니다. (익명 제출이라 답변 추적은 GitHub 이슈에서만 가능합니다)');
124
+ console.log(` 공개 트래커: ${ISSUES_URL}`);
125
+ return;
126
+ }
127
+
128
+ const local = saveLocal(title, body, dataDir);
129
+ console.log(`feedback: 온라인 제출 경로가 없어 로컬에 저장했습니다: ${local.file}`);
130
+ console.log(' GitHub 접근이 가능한 환경에서 아래 주소를 열면 내용이 채워진 이슈 작성 화면이 나옵니다:');
131
+ console.log(` ${local.prefilled}`);
132
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Subcommand: handoff — write a HANDOFF-YYYY-MM-DD-HHMM.md template in cwd
3
+ * capturing git status + the latest cap snapshot, so a fresh Claude Code
4
+ * session can pick up where this one stopped. Pairs with the cap-warn chip:
5
+ * when statusline shows 🚨 5H 90%+, run this to back up state before the cap
6
+ * hits.
7
+ * claude-token-saver handoff # write to cwd
8
+ * claude-token-saver handoff --cwd PATH # custom directory
9
+ */
10
+
11
+ import { readStdinJson, extractCaps } from '../stdin-payload.js';
12
+ import { debug } from '../debug.js';
13
+
14
+ export async function run({ getArg }) {
15
+ const { writeHandoff } = await import('../handoff.js');
16
+ const { recordHandoff } = await import('../history.js');
17
+ const cwd = getArg('--cwd') || process.cwd();
18
+ // Cap data only flows in via stdin (Claude Code statusline contract).
19
+ // Direct CLI invocations won't have it — that's fine, the template will
20
+ // note the gap.
21
+ const stdinJson = readStdinJson();
22
+ const caps = extractCaps(stdinJson);
23
+ const { path, git } = writeHandoff({ cwd, caps });
24
+ try { recordHandoff(path); } catch (e) { debug('handoff:record', e); }
25
+ console.log(`Handoff written: ${path}`);
26
+ if (git) {
27
+ console.log(` git: ${git.branch}${git.head ? ` @ ${git.head}` : ''}${git.status ? ' (dirty)' : ' (clean)'}`);
28
+ }
29
+ console.log('');
30
+ console.log('Fill in the empty sections, then start a new Claude Code session with:');
31
+ console.log(' Read the most recent HANDOFF-*.md in this directory and continue the work.');
32
+ return;
33
+ }