sprag-cli 3.55.0 → 3.57.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 (59) hide show
  1. package/README.ko.md +5 -1
  2. package/README.md +5 -1
  3. package/bin/cli.js +60 -19
  4. package/bin/postinstall.js +22 -0
  5. package/docs/CODEX.ko.md +44 -5
  6. package/docs/CODEX.md +74 -12
  7. package/package.json +2 -2
  8. package/src/agents.js +16 -2
  9. package/src/brief.js +17 -8
  10. package/src/claude-price.js +49 -0
  11. package/src/codex-brief.js +55 -2
  12. package/src/codex-cache-policy.js +26 -41
  13. package/src/codex-delegation.js +56 -8
  14. package/src/codex-doctor.js +1 -1
  15. package/src/codex-harness.js +63 -18
  16. package/src/codex-hooks.js +129 -20
  17. package/src/codex-ledger.js +150 -16
  18. package/src/codex-panel.js +51 -26
  19. package/src/codex-parser.js +41 -4
  20. package/src/codex-route-scan.js +14 -7
  21. package/src/codex-rule-health.js +41 -0
  22. package/src/commands/brief.js +14 -3
  23. package/src/commands/codex-delegate.js +17 -5
  24. package/src/commands/codex.js +20 -10
  25. package/src/commands/harness.js +6 -0
  26. package/src/commands/route-scan.js +4 -3
  27. package/src/compact-window.js +12 -4
  28. package/src/config.js +3 -3
  29. package/src/cost.js +62 -5
  30. package/src/ctx-window-cache.js +61 -0
  31. package/src/delegation-guard.js +15 -9
  32. package/src/doc2md.cjs +22 -8
  33. package/src/fig2md.cjs +7 -1
  34. package/src/formatters/statusline.js +22 -11
  35. package/src/formatters/table.js +19 -10
  36. package/src/gateway-auth.js +31 -3
  37. package/src/gateway-prices.js +39 -0
  38. package/src/harness.js +43 -12
  39. package/src/history.js +6 -2
  40. package/src/hook-manager.js +54 -32
  41. package/src/installer.js +286 -153
  42. package/src/korean-lint.cjs +40 -7
  43. package/src/litellm-budget.js +10 -1
  44. package/src/litellm-models.js +72 -10
  45. package/src/model-rules.js +90 -42
  46. package/src/money.js +9 -0
  47. package/src/month-spend.js +23 -12
  48. package/src/openai-prices.js +33 -0
  49. package/src/parser.js +232 -56
  50. package/src/postinstall.js +32 -0
  51. package/src/route-caps.js +74 -0
  52. package/src/route-inject.js +31 -7
  53. package/src/route-scan.js +93 -33
  54. package/src/savings-ledger.js +43 -11
  55. package/src/seed-rules.js +3 -4
  56. package/src/session-cache.js +35 -1
  57. package/src/state-file.js +43 -0
  58. package/src/stats.js +20 -5
  59. package/src/stdin-payload.js +18 -4
@@ -9,21 +9,26 @@
9
9
  * removes the copy, the staleness, and the orphan in one move.
10
10
  */
11
11
 
12
- import { readFile, writeFile, rm } from 'node:fs/promises';
12
+ import { rm } from 'node:fs/promises';
13
+ import { mkdirSync } from 'node:fs';
13
14
  import { join } from 'node:path';
14
- import { homedir } from 'node:os';
15
15
  import { CLI_NAME } from './cli-name.js';
16
+ import { claudeUserDir } from './paths.js';
17
+ import { isOurSubcommand, isLegacyCacheMonitorCommand, readSettings, writeSettings } from './installer.js';
16
18
 
17
- const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
19
+ // Resolved per call, not at import: the paths follow the current home directory.
20
+ const settingsPath = () => join(claudeUserDir(), 'settings.json');
18
21
  // Legacy copied-file location — removed on install/uninstall so machines that
19
22
  // installed an older version don't keep a dead hook script around.
20
- const LEGACY_HOOK_DEST = join(homedir(), '.claude', 'cache-monitor-hook.cjs');
21
- const HOOK_MARKER = 'cache-monitor-hook';
23
+ const legacyHookDest = () => join(claudeUserDir(), 'cache-monitor-hook.cjs');
22
24
 
25
+ // Ours means the current subcommand form (`<cli> --hook-run ...`) or the exact
26
+ // legacy copied-file form. A command that only mentions either string, such as
27
+ // `echo "sprag --hook-run"`, belongs to somebody else.
23
28
  function isCacheMonitorHook(nh) {
24
29
  const cmd = nh?.command;
25
30
  if (typeof cmd !== 'string') return false;
26
- return cmd.includes(HOOK_MARKER) || cmd.includes('--hook-run');
31
+ return isOurSubcommand(cmd, '--hook-run') || isLegacyCacheMonitorCommand(cmd);
27
32
  }
28
33
 
29
34
  /**
@@ -51,21 +56,31 @@ function countCacheMonitorHooks(groups) {
51
56
  return n;
52
57
  }
53
58
 
59
+ function fail(message) {
60
+ console.log(message);
61
+ process.exitCode = 1;
62
+ return false;
63
+ }
64
+
54
65
  export async function installHook({ threshold = 0.7 } = {}) {
55
- let settings;
56
- try {
57
- const raw = await readFile(SETTINGS_PATH, 'utf8');
58
- settings = JSON.parse(raw);
59
- } catch {
60
- settings = {};
66
+ const SETTINGS_PATH = settingsPath();
67
+ // Only a missing file starts from an empty object. A file that exists but
68
+ // cannot be parsed (or is not an object) is the user's configuration;
69
+ // replacing it with a fresh object would wipe everything in it.
70
+ const read = readSettings(SETTINGS_PATH);
71
+ if (read.state === 'unusable') {
72
+ return fail(`✗ ${SETTINGS_PATH} is ${read.reason} — hook not installed, file left untouched.`);
61
73
  }
74
+ const settings = read.settings;
62
75
 
63
- if (!settings.hooks) settings.hooks = {};
76
+ if (settings.hooks === undefined || settings.hooks === null) settings.hooks = {};
77
+ if (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
78
+ return fail(`✗ hooks in ${SETTINGS_PATH} is not an object — hook not installed, file left untouched.`);
79
+ }
64
80
  if (settings.hooks.PostToolUse !== undefined && !Array.isArray(settings.hooks.PostToolUse)) {
65
81
  // Someone else's data in a shape we do not understand; overwriting it with
66
82
  // [] would discard it. Leave the file alone and say so.
67
- console.log(`⚠ hooks.PostToolUse in ${SETTINGS_PATH} is not an array — hook not installed.`);
68
- return;
83
+ return fail(`✗ hooks.PostToolUse in ${SETTINGS_PATH} is not an array — hook not installed, file left untouched.`);
69
84
  }
70
85
  if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
71
86
 
@@ -84,10 +99,12 @@ export async function installHook({ threshold = 0.7 } = {}) {
84
99
  ],
85
100
  });
86
101
 
87
- await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
102
+ mkdirSync(claudeUserDir(), { recursive: true });
103
+ const writeProblem = writeSettings(SETTINGS_PATH, settings);
104
+ if (writeProblem) return fail(`✗ Hook not installed: ${writeProblem}`);
88
105
 
89
106
  // Clean up the legacy copy left by older versions.
90
- await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
107
+ await rm(legacyHookDest(), { force: true }).catch(() => {});
91
108
 
92
109
  console.log(`✓ Hook installed (PostToolUse → ${CLI_NAME} --hook-run)`);
93
110
  console.log(` Settings updated: ${SETTINGS_PATH}`);
@@ -96,29 +113,34 @@ export async function installHook({ threshold = 0.7 } = {}) {
96
113
  }
97
114
 
98
115
  export async function uninstallHook() {
99
- let settings;
100
- try {
101
- const raw = await readFile(SETTINGS_PATH, 'utf8');
102
- settings = JSON.parse(raw);
103
- } catch {
116
+ const SETTINGS_PATH = settingsPath();
117
+ const read = readSettings(SETTINGS_PATH);
118
+ if (read.state === 'absent') {
104
119
  console.log('No settings.json found, nothing to uninstall.');
105
120
  return;
106
121
  }
122
+ if (read.state === 'unusable') {
123
+ return fail(`✗ ${SETTINGS_PATH} is ${read.reason} — nothing removed, file left untouched.`);
124
+ }
125
+ const settings = read.settings;
107
126
 
108
127
  if (Array.isArray(settings.hooks?.PostToolUse)) {
109
- const before = countCacheMonitorHooks(settings.hooks.PostToolUse);
110
- settings.hooks.PostToolUse = withoutCacheMonitorHooks(settings.hooks.PostToolUse);
111
- const removed = before;
112
-
113
- if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
114
- if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
115
-
116
- await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', 'utf8');
117
- console.log(`✓ Removed ${removed} hook(s) from settings.json`);
128
+ const removed = countCacheMonitorHooks(settings.hooks.PostToolUse);
129
+ if (removed > 0) {
130
+ settings.hooks.PostToolUse = withoutCacheMonitorHooks(settings.hooks.PostToolUse);
131
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
132
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
133
+
134
+ const writeProblem = writeSettings(SETTINGS_PATH, settings);
135
+ if (writeProblem) return fail(`✗ Hook not removed: ${writeProblem}`);
136
+ console.log(`✓ Removed ${removed} hook(s) from settings.json`);
137
+ } else {
138
+ console.log('No cache-monitor hook found in settings.');
139
+ }
118
140
  } else {
119
141
  console.log('No cache-monitor hook found in settings.');
120
142
  }
121
143
 
122
144
  // The legacy copied hook file is dead weight either way.
123
- await rm(LEGACY_HOOK_DEST, { force: true }).catch(() => {});
145
+ await rm(legacyHookDest(), { force: true }).catch(() => {});
124
146
  }