@jv-k/claude-gauge 0.0.1 → 1.0.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.
@@ -0,0 +1,257 @@
1
+ "use strict";
2
+ // The settings writer's pure half: plan() takes the settings Claude Code
3
+ // reads and the user's choices, and returns the next settings, the status
4
+ // line to save for uninstall, and what it found. It reads no file and writes
5
+ // none; settings-file.ts and the CLI do that.
6
+ //
7
+ // claude-gauge owns at most three things in settings.json: `statusLine` when
8
+ // it runs claude-gauge's status line, its token line entry in `hooks.Stop`,
9
+ // and the `--instruct` entries in `hooks.SessionStart`. plan() changes only
10
+ // those, and keeps every other key, including the status line's
11
+ // `refreshInterval`.
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.isForeign = exports.scriptOf = void 0;
14
+ exports.plan = plan;
15
+ exports.commandFor = commandFor;
16
+ exports.switchesOf = switchesOf;
17
+ exports.installed = installed;
18
+ exports.installedSwitches = installedSwitches;
19
+ exports.ownerOf = ownerOf;
20
+ // A command that runs one of claude-gauge's scripts, wherever the copy lives:
21
+ // ~/.claude/claude-gauge/dist/, its runtime/ copy, the scripts from before
22
+ // they moved into dist/, or a Windows path to any of them. The folder name
23
+ // must be a whole path component and the script name must end the word, so
24
+ // not-claude-gauge/ or statusline.js.old is someone else's.
25
+ const SCRIPT = /(?<![^\s'"\\/])claude-gauge[\\/](?:[^\s'"]*[\\/])?(statusline|tokenline)\.js(?![^\s'"])/;
26
+ const scriptOf = (command) => (typeof command === 'string' ? SCRIPT.exec(command)?.[1] : undefined);
27
+ exports.scriptOf = scriptOf;
28
+ const isInstruct = (command) => typeof command === 'string' && /(?:^|\s)--instruct\b/.test(command);
29
+ // The token line's Stop hook, as opposed to its --instruct SessionStart one.
30
+ const isTokenLine = (h) => scriptOf(h.command) === 'tokenline' && !isInstruct(h.command);
31
+ const isOurs = (h) => scriptOf(h.command) !== undefined;
32
+ // Whether writing claude-gauge's status line over this owner's replaces
33
+ // someone else's, which needs the user's consent.
34
+ const isForeign = (owner) => owner === 'other' || owner === 'claude-hud';
35
+ exports.isForeign = isForeign;
36
+ function ownerOf(statusLine) {
37
+ if (statusLine === undefined || statusLine === null)
38
+ return 'none';
39
+ const command = statusLine.command;
40
+ if (scriptOf(command) === 'statusline')
41
+ return 'claude-gauge';
42
+ if (typeof command === 'string' && /claude-hud/i.test(command))
43
+ return 'claude-hud';
44
+ return 'other';
45
+ }
46
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
47
+ // The settings' hooks, checked as far as plan() reads them. A shape it does
48
+ // not know stops the plan, because writing over it would lose the user's
49
+ // hooks.
50
+ function readHooks(settings) {
51
+ const hooks = settings.hooks;
52
+ if (hooks === undefined)
53
+ return undefined;
54
+ if (!isObject(hooks))
55
+ throw new Error('settings.json: hooks is not an object, so claude-gauge leaves it alone. Fix it by hand.');
56
+ for (const [event, entries] of Object.entries(hooks)) {
57
+ const readable = (e) => isObject(e) && (e.hooks === undefined || (Array.isArray(e.hooks) && e.hooks.every(isObject)));
58
+ if (!Array.isArray(entries) || !entries.every(readable)) {
59
+ throw new Error(`settings.json: hooks.${event} is not a list of hook entries, so claude-gauge leaves it alone. Fix it by hand.`);
60
+ }
61
+ }
62
+ return hooks;
63
+ }
64
+ // Takes the hook commands that `drop` matches out of `event`, then any entry
65
+ // and event that this left empty. Lists that were empty before stay.
66
+ function removeHooks(hooks, event, drop) {
67
+ const entries = hooks[event];
68
+ if (!entries)
69
+ return;
70
+ let removed = false;
71
+ const kept = [];
72
+ for (const e of entries) {
73
+ const commands = e.hooks ?? [];
74
+ const left = commands.filter((h) => !drop(h));
75
+ if (left.length === commands.length) {
76
+ kept.push(e);
77
+ continue;
78
+ }
79
+ removed = true;
80
+ if (left.length)
81
+ kept.push({ ...e, hooks: left });
82
+ }
83
+ if (!removed)
84
+ return;
85
+ if (kept.length)
86
+ hooks[event] = kept;
87
+ else
88
+ delete hooks[event];
89
+ }
90
+ // Points the token line's Stop hook at `command`: the first existing one is
91
+ // updated in place, any others are removed, and with none an entry is added
92
+ // at the end.
93
+ function setTokenLine(hooks, command) {
94
+ const stop = hooks.Stop ?? [];
95
+ let done = false;
96
+ const next = [];
97
+ for (const e of stop) {
98
+ const commands = e.hooks ?? [];
99
+ if (!commands.some(isTokenLine)) {
100
+ next.push(e);
101
+ continue;
102
+ }
103
+ const left = [];
104
+ for (const h of commands) {
105
+ if (!isTokenLine(h))
106
+ left.push(h);
107
+ else if (!done) {
108
+ left.push({ ...h, type: 'command', command });
109
+ done = true;
110
+ }
111
+ }
112
+ if (left.length)
113
+ next.push({ ...e, hooks: left });
114
+ }
115
+ if (!done)
116
+ next.push({ hooks: [{ type: 'command', command }] });
117
+ hooks.Stop = next;
118
+ }
119
+ function plan(current, choices) {
120
+ const settings = JSON.parse(JSON.stringify(current ?? {}));
121
+ const found = ownerOf(settings.statusLine);
122
+ if (settings.statusLine === null)
123
+ delete settings.statusLine;
124
+ if (settings.statusLine !== undefined && !isObject(settings.statusLine)) {
125
+ throw new Error('settings.json: statusLine is not an object, so claude-gauge leaves it alone. Fix it by hand.');
126
+ }
127
+ const hooks = readHooks(settings);
128
+ const statusChoice = choices.uninstall ? null : choices.statusLine;
129
+ const tokenChoice = choices.uninstall ? null : choices.tokenLine;
130
+ const takeOver = typeof statusChoice === 'string' && isForeign(found);
131
+ if (takeOver && !choices.replace) {
132
+ return { settings, dropBackup: false, found, blocked: true, changed: false };
133
+ }
134
+ let backup;
135
+ if (typeof statusChoice === 'string') {
136
+ if (found !== 'claude-gauge') {
137
+ backup = { statusLine: settings.statusLine ?? null };
138
+ }
139
+ settings.statusLine = { ...settings.statusLine, type: 'command', command: statusChoice };
140
+ }
141
+ else if (statusChoice === null && found === 'claude-gauge') {
142
+ if (choices.previous)
143
+ settings.statusLine = choices.previous;
144
+ else
145
+ delete settings.statusLine;
146
+ }
147
+ if (tokenChoice !== undefined || choices.uninstall) {
148
+ const next = hooks ?? {};
149
+ if (typeof tokenChoice === 'string')
150
+ setTokenLine(next, tokenChoice);
151
+ else if (choices.uninstall)
152
+ for (const event of Object.keys(next))
153
+ removeHooks(next, event, isOurs);
154
+ else
155
+ removeHooks(next, 'Stop', isTokenLine);
156
+ // A hooks object that held only claude-gauge's entries goes with them.
157
+ // One that was absent, or empty already, stays as it was.
158
+ const hadHooks = isObject(current?.hooks) && Object.keys(current.hooks).length > 0;
159
+ if (Object.keys(next).length)
160
+ settings.hooks = next;
161
+ else if (hadHooks)
162
+ delete settings.hooks;
163
+ }
164
+ return {
165
+ settings,
166
+ ...(backup ? { backup } : {}),
167
+ dropBackup: statusChoice === null,
168
+ found,
169
+ blocked: false,
170
+ changed: JSON.stringify(settings) !== JSON.stringify(current ?? {}),
171
+ };
172
+ }
173
+ // claude-gauge's commands in the settings: the status line, the token
174
+ // line's Stop hook, and whether any hook in any event runs one of its
175
+ // scripts. Hooks it cannot read stop it, as they stop plan().
176
+ function installed(settings) {
177
+ const statusLine = ownerOf(settings.statusLine) === 'claude-gauge' ? settings.statusLine.command : undefined;
178
+ const hooks = readHooks(settings) ?? {};
179
+ const commandsIn = (entries) => entries.flatMap((e) => e.hooks ?? []);
180
+ const tokenLine = commandsIn(hooks.Stop ?? []).find(isTokenLine)?.command;
181
+ const anyHook = Object.values(hooks).some((entries) => commandsIn(entries).some(isOurs));
182
+ return { statusLine, tokenLine, any: statusLine !== undefined || anyHook };
183
+ }
184
+ function installedSwitches(settings) {
185
+ const { statusLine, tokenLine } = installed(settings);
186
+ return {
187
+ ...(statusLine === undefined ? {} : { statusLine: switchesOf(statusLine) }),
188
+ ...(tokenLine === undefined ? {} : { tokenLine: switchesOf(tokenLine) }),
189
+ };
190
+ }
191
+ // A shell word for a settings command: as is when plain, in double quotes
192
+ // when it holds only spaces or other characters both bash and cmd.exe read
193
+ // literally there, else in single quotes. Claude Code runs the command
194
+ // through a shell on every OS.
195
+ const shellWord = (s) => /^[\w@%+=:,./~-]+$/.test(s) ? s : /^[^"$`\\!']*$/.test(s) ? `"${s}"` : `'${s.replace(/'/g, `'\\''`)}'`;
196
+ // The command that runs `script` with the switches the user chose: one
197
+ // string split at spaces, `--show ctx,5h,7d --segments 10`, or the words
198
+ // themselves, which keeps a value that holds a space. Each word is quoted on
199
+ // its own, so nothing in it can run as a second command.
200
+ function commandFor(script, switches = '') {
201
+ const words = typeof switches === 'string' ? switches.split(/\s+/).filter(Boolean) : switches;
202
+ return ['node', script.replace(/\\/g, '/'), ...words].map(shellWord).join(' ');
203
+ }
204
+ // The words of a command as the shell splits them, which undoes shellWord:
205
+ // single quotes keep everything, double quotes keep all but an escaped " \ $
206
+ // or `, and a backslash outside quotes keeps the next character. Each word
207
+ // keeps its text as written too, `raw`, where a Windows path still has its
208
+ // backslashes.
209
+ function shellWords(command) {
210
+ const words = [];
211
+ let value = '';
212
+ let start = -1;
213
+ let quote = null;
214
+ for (let i = 0; i < command.length; i++) {
215
+ const c = command[i];
216
+ if (quote === "'") {
217
+ if (c === "'")
218
+ quote = null;
219
+ else
220
+ value += c;
221
+ }
222
+ else if (quote === '"') {
223
+ if (c === '"')
224
+ quote = null;
225
+ else if (c === '\\' && i + 1 < command.length && '"\\$`'.includes(command[i + 1]))
226
+ value += command[++i];
227
+ else
228
+ value += c;
229
+ }
230
+ else if (/\s/.test(c)) {
231
+ if (start >= 0)
232
+ words.push({ value, raw: command.slice(start, i) });
233
+ value = '';
234
+ start = -1;
235
+ }
236
+ else {
237
+ if (start < 0)
238
+ start = i;
239
+ if (c === "'" || c === '"')
240
+ quote = c;
241
+ else if (c === '\\' && i + 1 < command.length)
242
+ value += command[++i];
243
+ else
244
+ value += c;
245
+ }
246
+ }
247
+ if (start >= 0)
248
+ words.push({ value, raw: command.slice(start) });
249
+ return words;
250
+ }
251
+ // The switches a claude-gauge command runs its script with, as words: the
252
+ // inverse of commandFor. None when the command runs no claude-gauge script.
253
+ function switchesOf(command) {
254
+ const words = shellWords(command);
255
+ const at = words.findIndex((w) => scriptOf(w.raw) !== undefined);
256
+ return at < 0 ? [] : words.slice(at + 1).map((w) => w.value);
257
+ }