@jv-k/claude-gauge 0.0.1 → 1.1.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,78 @@
1
+ "use strict";
2
+ // The plugin route's launcher: runs a script from the newest installed
3
+ // version of the claude-gauge plugin.
4
+ //
5
+ // Claude Code keeps each installed plugin version in its own folder,
6
+ // <plugins>/cache/<marketplace>/<plugin>/<version>/, and deletes an old one
7
+ // some days after an update. Settings that named a version folder would run
8
+ // an old version after an update, and nothing once it is deleted. So setup,
9
+ // run from the plugin, copies this file into the state folder as
10
+ // launcher/launch.js, beside a statusline.js and a tokenline.js that each
11
+ // call launch() with the folder that holds the versions, and points the
12
+ // settings at those two. A plugin update then needs no setup.
13
+ //
14
+ // The launcher never changes, so it reads nothing of the plugin but the
15
+ // script it runs, and it prints nothing when no version is installed: a
16
+ // status line that has lost its plugin goes blank rather than showing an
17
+ // error on every refresh.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.newest = newest;
20
+ exports.launch = launch;
21
+ const node_child_process_1 = require("node:child_process");
22
+ const fs = require("node:fs");
23
+ const path = require("node:path");
24
+ const VERSION = /^v?(\d+)\.(\d+)\.(\d+)/;
25
+ // Newest first: a version with a version number, by that number, then the
26
+ // most recently installed folder.
27
+ function compare(a, b) {
28
+ if (!a.version !== !b.version)
29
+ return a.version ? -1 : 1;
30
+ if (a.version && b.version) {
31
+ for (let i = 0; i < a.version.length; i++) {
32
+ if (a.version[i] !== b.version[i])
33
+ return b.version[i] - a.version[i];
34
+ }
35
+ }
36
+ return b.time - a.time;
37
+ }
38
+ // The path of `script` in the newest version under `versions`, or undefined
39
+ // when no installed version holds it. Claude Code writes .orphaned_at into
40
+ // the folder of a version that an update or an uninstall replaced, and
41
+ // deletes the folder some days later, so a marked folder is not installed.
42
+ function newest(versions, script) {
43
+ let names;
44
+ try {
45
+ names = fs.readdirSync(versions);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ const found = [];
51
+ for (const name of names) {
52
+ const folder = path.join(versions, name);
53
+ const file = path.join(folder, 'dist', script);
54
+ try {
55
+ if (!fs.statSync(file).isFile() || fs.existsSync(path.join(folder, '.orphaned_at')))
56
+ continue;
57
+ const m = VERSION.exec(name);
58
+ found.push({
59
+ file,
60
+ version: m ? m.slice(1).map(Number) : undefined,
61
+ time: fs.statSync(folder).mtimeMs,
62
+ });
63
+ }
64
+ catch {
65
+ // A version folder that cannot be read is skipped.
66
+ }
67
+ }
68
+ return found.sort(compare)[0]?.file;
69
+ }
70
+ // Runs `script` from the newest version with this process's switches, input
71
+ // and output, and exits as it does.
72
+ function launch(versions, script) {
73
+ const file = newest(versions, script);
74
+ if (!file)
75
+ return;
76
+ const r = (0, node_child_process_1.spawnSync)(process.execPath, [file, ...process.argv.slice(2)], { stdio: 'inherit' });
77
+ process.exitCode = r.status ?? 1;
78
+ }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ // The settings writer's file half: reads settings.json, and writes it back
3
+ // atomically, through a symlink to its target, after a timestamped backup.
4
+ // What to write is plan()'s job, in settings.ts.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readSettings = readSettings;
7
+ exports.writeSettings = writeSettings;
8
+ exports.writeAtomic = writeAtomic;
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ // Reads settings.json. A missing or empty file reads as {}. A file that is
12
+ // not a JSON object stops the write: claude-gauge never guesses at a file it
13
+ // cannot read.
14
+ function readSettings(file) {
15
+ let text;
16
+ try {
17
+ text = fs.readFileSync(file, 'utf8');
18
+ }
19
+ catch (err) {
20
+ if (err.code === 'ENOENT')
21
+ return { settings: {}, text: null };
22
+ throw err;
23
+ }
24
+ if (!text.trim())
25
+ return { settings: {}, text };
26
+ let settings;
27
+ try {
28
+ settings = JSON.parse(text);
29
+ }
30
+ catch (err) {
31
+ throw new Error(`${file} is not valid JSON (${err.message}), so claude-gauge leaves it alone. Fix it by hand.`);
32
+ }
33
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
34
+ throw new Error(`${file} does not hold a JSON object, so claude-gauge leaves it alone. Fix it by hand.`);
35
+ }
36
+ return { settings: settings, text };
37
+ }
38
+ // The file a write to `file` should replace: the target of a symlink, even
39
+ // one whose target does not exist yet, or the file itself. Replacing the
40
+ // link instead would cut it from a dotfiles repository.
41
+ function targetOf(file) {
42
+ try {
43
+ return fs.realpathSync(file);
44
+ }
45
+ catch {
46
+ try {
47
+ return path.resolve(path.dirname(file), fs.readlinkSync(file));
48
+ }
49
+ catch {
50
+ return file;
51
+ }
52
+ }
53
+ }
54
+ // Windows refuses a rename over a file that another program has open for a
55
+ // moment, so a rename there gets a few short retries.
56
+ const BUSY = new Set(['EPERM', 'EACCES', 'EBUSY']);
57
+ const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
58
+ function renameWithRetry(from, to) {
59
+ for (let attempt = 1;; attempt++) {
60
+ try {
61
+ fs.renameSync(from, to);
62
+ return;
63
+ }
64
+ catch (err) {
65
+ if (process.platform !== 'win32' || attempt >= 5 || !BUSY.has(err.code ?? ''))
66
+ throw err;
67
+ pause(50 * attempt);
68
+ }
69
+ }
70
+ }
71
+ // Writes `text` to `file` so that a reader sees the old file or the new one,
72
+ // never part of either: a temporary file beside the target, flushed, then
73
+ // renamed over it. The target keeps its permissions.
74
+ function writeAtomic(file, text) {
75
+ const target = targetOf(file);
76
+ fs.mkdirSync(path.dirname(target), { recursive: true });
77
+ const tmp = path.join(path.dirname(target), `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`);
78
+ let mode;
79
+ try {
80
+ mode = fs.statSync(target).mode & 0o777;
81
+ }
82
+ catch {
83
+ /* a new file takes the default permissions */
84
+ }
85
+ try {
86
+ const fd = fs.openSync(tmp, 'wx', mode ?? 0o666);
87
+ try {
88
+ fs.writeFileSync(fd, text);
89
+ fs.fsyncSync(fd);
90
+ }
91
+ finally {
92
+ fs.closeSync(fd);
93
+ }
94
+ if (mode !== undefined)
95
+ fs.chmodSync(tmp, mode);
96
+ renameWithRetry(tmp, target);
97
+ }
98
+ catch (err) {
99
+ fs.rmSync(tmp, { force: true });
100
+ throw err;
101
+ }
102
+ }
103
+ // 2026-10-09T00-07-18-123Z: an ISO time with no colons, which Windows
104
+ // forbids in file names.
105
+ const stamp = (d) => d.toISOString().replace(/:/g, '-').replace('.', '-');
106
+ // Copies `file` to a timestamped backup beside it, beside the link when it is
107
+ // one, and returns the backup's path; null when there is no file to back up.
108
+ function backUp(file, now = new Date()) {
109
+ if (!fs.existsSync(file))
110
+ return null;
111
+ const base = `${file}.claude-gauge-${stamp(now)}`;
112
+ for (let n = 0;; n++) {
113
+ const backup = n ? `${base}-${n}.bak` : `${base}.bak`;
114
+ try {
115
+ fs.copyFileSync(file, backup, fs.constants.COPYFILE_EXCL);
116
+ return backup;
117
+ }
118
+ catch (err) {
119
+ if (err.code !== 'EEXIST')
120
+ throw err;
121
+ }
122
+ }
123
+ }
124
+ // The new file's text in the old file's layout: its indent and line ends,
125
+ // else two spaces and \n.
126
+ function format(settings, previous) {
127
+ const indent = (previous && /^[ \t]+(?=")/m.exec(previous)?.[0]) || 2;
128
+ const text = JSON.stringify(settings, null, indent) + '\n';
129
+ return previous?.includes('\r\n') ? text.replace(/\n/g, '\r\n') : text;
130
+ }
131
+ // Backs settings.json up, then writes `settings` over it. Returns the
132
+ // backup's path, or null when there was no file before.
133
+ function writeSettings(file, settings, previous) {
134
+ const backup = backUp(file);
135
+ writeAtomic(file, format(settings, previous));
136
+ return backup;
137
+ }
@@ -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
+ }