@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.
- package/README.md +622 -3
- package/dist/cli.js +395 -0
- package/dist/launcher.js +78 -0
- package/dist/settings-file.js +137 -0
- package/dist/settings.js +257 -0
- package/dist/statusline.js +2304 -0
- package/dist/tokenline.js +293 -0
- package/dist/wizard.js +264 -0
- package/package.json +26 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// The claude-gauge command: sets the bars up in Claude Code's settings,
|
|
4
|
+
// changes their switches, takes them out again, and updates the copy of the
|
|
5
|
+
// scripts that the settings run.
|
|
6
|
+
//
|
|
7
|
+
// claude-gauge setup
|
|
8
|
+
// claude-gauge setup --yes
|
|
9
|
+
// claude-gauge setup --status-line "--show ctx,5h,7d --segments 10" --token-line "--window 1m"
|
|
10
|
+
// claude-gauge configure --no-token-line
|
|
11
|
+
// claude-gauge uninstall
|
|
12
|
+
// claude-gauge update
|
|
13
|
+
//
|
|
14
|
+
// setup and configure with no bar switches ask the questions themselves,
|
|
15
|
+
// with a preview of the status line after each answer (wizard.ts). With
|
|
16
|
+
// switches they ask nothing: the form the plugin's slash commands run once
|
|
17
|
+
// Claude has asked the questions.
|
|
18
|
+
//
|
|
19
|
+
// Everything it keeps lives in the state folder, claude-gauge/ in the Claude
|
|
20
|
+
// config folder ($CLAUDE_CONFIG_DIR, else ~/.claude): the copy of the
|
|
21
|
+
// scripts in runtime/, or, when it runs from the plugin, the launcher in
|
|
22
|
+
// launcher/, and the status line it replaced in .state/.
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
const fs = require("node:fs");
|
|
25
|
+
const os = require("node:os");
|
|
26
|
+
const path = require("node:path");
|
|
27
|
+
const readline = require("node:readline");
|
|
28
|
+
const node_child_process_1 = require("node:child_process");
|
|
29
|
+
const settings_1 = require("./settings");
|
|
30
|
+
const settings_file_1 = require("./settings-file");
|
|
31
|
+
const wizard_1 = require("./wizard");
|
|
32
|
+
const statusline_1 = require("./statusline");
|
|
33
|
+
const USAGE = `Usage: claude-gauge <setup | configure | uninstall | update> [switches]
|
|
34
|
+
|
|
35
|
+
setup add the status line and the token line to Claude Code's settings
|
|
36
|
+
configure change the switches of a bar that setup added, or take it out
|
|
37
|
+
uninstall take claude-gauge out, and put back the status line it replaced
|
|
38
|
+
update refresh the copy of claude-gauge that the settings run
|
|
39
|
+
|
|
40
|
+
Switches for setup and configure:
|
|
41
|
+
--status-line <switches> add the status line, run with these switches ("" for none)
|
|
42
|
+
--no-status-line leave the status line out
|
|
43
|
+
--token-line <switches> add the token line, run with these switches ("" for none)
|
|
44
|
+
--no-token-line leave the token line out
|
|
45
|
+
--replace replace a status line that is not claude-gauge's
|
|
46
|
+
--yes setup only: add each bar not chosen above, with no switches
|
|
47
|
+
|
|
48
|
+
With none of the bar switches above, setup and configure ask which bars and
|
|
49
|
+
parts you want, and show the status line after each answer.
|
|
50
|
+
|
|
51
|
+
The bars' switches are in README.md, under Options. The settings file is
|
|
52
|
+
$CLAUDE_CONFIG_DIR/settings.json, else ~/.claude/settings.json.
|
|
53
|
+
`;
|
|
54
|
+
// A mistake in the command line: exit 2, with the usage.
|
|
55
|
+
class UsageError extends Error {
|
|
56
|
+
}
|
|
57
|
+
const COMMANDS = ['setup', 'configure', 'uninstall', 'update'];
|
|
58
|
+
// Every switch: its names, the commands that take it, and what it sets.
|
|
59
|
+
// --help goes with any command.
|
|
60
|
+
const SWITCHES = [
|
|
61
|
+
{ names: ['--status-line'], commands: ['setup', 'configure'], apply: (args, value) => { args.statusLine = value(); } },
|
|
62
|
+
{ names: ['--no-status-line'], commands: ['setup', 'configure'], apply: (args) => { args.statusLine = null; } },
|
|
63
|
+
{ names: ['--token-line'], commands: ['setup', 'configure'], apply: (args, value) => { args.tokenLine = value(); } },
|
|
64
|
+
{ names: ['--no-token-line'], commands: ['setup', 'configure'], apply: (args) => { args.tokenLine = null; } },
|
|
65
|
+
{ names: ['--replace'], commands: ['setup', 'configure'], apply: (args) => { args.replace = true; } },
|
|
66
|
+
{ names: ['--yes', '-y'], commands: ['setup'], apply: (args) => { args.yes = true; } },
|
|
67
|
+
{ names: ['--help', '-h'], commands: [...COMMANDS], apply: (args) => { args.help = true; } },
|
|
68
|
+
];
|
|
69
|
+
// Unlike the two bars, which ignore what they do not know so that a typo
|
|
70
|
+
// never blanks the status line, the command refuses an unknown word: it
|
|
71
|
+
// writes the user's settings, and a typo there should stop it.
|
|
72
|
+
function parseArgs(argv) {
|
|
73
|
+
const args = { help: false, replace: false, yes: false };
|
|
74
|
+
const seen = [];
|
|
75
|
+
for (let i = 0; i < argv.length; i++) {
|
|
76
|
+
const [name, inline] = argv[i].split(/=(.*)/s);
|
|
77
|
+
if (!name.startsWith('-')) {
|
|
78
|
+
if (args.command)
|
|
79
|
+
throw new UsageError(`Unexpected word: ${name}`);
|
|
80
|
+
if (name === 'help')
|
|
81
|
+
args.help = true;
|
|
82
|
+
else if (COMMANDS.includes(name))
|
|
83
|
+
args.command = name;
|
|
84
|
+
else
|
|
85
|
+
throw new UsageError(`Unknown command: ${name}`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const known = SWITCHES.find((s) => s.names.includes(name));
|
|
89
|
+
if (!known)
|
|
90
|
+
throw new UsageError(`Unknown switch: ${name}`);
|
|
91
|
+
// A value is the next word whatever it holds, since switches start with --.
|
|
92
|
+
known.apply(args, () => {
|
|
93
|
+
const v = inline ?? argv[++i];
|
|
94
|
+
if (v === undefined)
|
|
95
|
+
throw new UsageError(`${name} needs a value: the switches for that bar, or "" for none.`);
|
|
96
|
+
return v;
|
|
97
|
+
});
|
|
98
|
+
seen.push(known);
|
|
99
|
+
}
|
|
100
|
+
const { command } = args;
|
|
101
|
+
const stray = command && seen.find((s) => !s.commands.includes(command));
|
|
102
|
+
if (stray)
|
|
103
|
+
throw new UsageError(`${args.command} takes no ${stray.names[0]}`);
|
|
104
|
+
return args;
|
|
105
|
+
}
|
|
106
|
+
// The folders and files it works with.
|
|
107
|
+
const configDir = () => process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
108
|
+
const settingsFile = () => path.join(configDir(), 'settings.json');
|
|
109
|
+
const stateDir = () => path.join(configDir(), 'claude-gauge');
|
|
110
|
+
const savedStatusLineFile = () => path.join(stateDir(), '.state', 'previous-statusline.json');
|
|
111
|
+
// The package this command runs from: dist/.. of this file.
|
|
112
|
+
const packageRoot = path.resolve(__dirname, '..');
|
|
113
|
+
const RUNTIME = ['statusline.js', 'tokenline.js'];
|
|
114
|
+
// The native realpath also expands a Windows short name, such as RUNNER~1,
|
|
115
|
+
// which Bun has already expanded in __dirname and the JavaScript one keeps.
|
|
116
|
+
function sameFolder(a, b) {
|
|
117
|
+
try {
|
|
118
|
+
const [x, y] = [fs.realpathSync.native(a), fs.realpathSync.native(b)];
|
|
119
|
+
return process.platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// The folder that holds the plugin's installed versions, when this command
|
|
126
|
+
// runs from one of them: Claude Code installs a plugin into
|
|
127
|
+
// <plugins>/cache/<marketplace>/<plugin>/<version>/, with its manifest.
|
|
128
|
+
function pluginVersions() {
|
|
129
|
+
const versions = path.dirname(packageRoot);
|
|
130
|
+
const cache = path.dirname(path.dirname(versions));
|
|
131
|
+
if (path.basename(cache) !== 'cache')
|
|
132
|
+
return undefined;
|
|
133
|
+
if (!fs.existsSync(path.join(packageRoot, '.claude-plugin', 'plugin.json')))
|
|
134
|
+
return undefined;
|
|
135
|
+
return versions;
|
|
136
|
+
}
|
|
137
|
+
function scripts() {
|
|
138
|
+
if (sameFolder(packageRoot, stateDir()))
|
|
139
|
+
return { route: 'clone', dir: path.join(stateDir(), 'dist') };
|
|
140
|
+
const versions = pluginVersions();
|
|
141
|
+
if (versions)
|
|
142
|
+
return { route: 'launcher', dir: path.join(stateDir(), 'launcher'), versions };
|
|
143
|
+
return { route: 'copy', dir: path.join(stateDir(), 'runtime') };
|
|
144
|
+
}
|
|
145
|
+
const version = () => {
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version ?? 'unknown';
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return 'unknown';
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
// Copies the two scripts into `dir`, each replaced whole, so a status line
|
|
154
|
+
// that runs during the copy reads the old script or the new one. The
|
|
155
|
+
// package.json beside them keeps Node reading them as CommonJS, whatever a
|
|
156
|
+
// package.json further up says.
|
|
157
|
+
function copyRuntime(dir) {
|
|
158
|
+
for (const file of RUNTIME)
|
|
159
|
+
(0, settings_file_1.writeAtomic)(path.join(dir, file), fs.readFileSync(path.join(packageRoot, 'dist', file)));
|
|
160
|
+
writePackageJson(dir, 'claude-gauge-runtime');
|
|
161
|
+
}
|
|
162
|
+
function writePackageJson(dir, name) {
|
|
163
|
+
const pkg = { name, version: version(), private: true, type: 'commonjs' };
|
|
164
|
+
(0, settings_file_1.writeAtomic)(path.join(dir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
|
|
165
|
+
}
|
|
166
|
+
// Writes the launcher into `dir`: launch.js, and one entry file per script
|
|
167
|
+
// that runs it from the newest version in `versions`. The entry files keep
|
|
168
|
+
// the scripts' names, so the settings name statusline.js and tokenline.js
|
|
169
|
+
// as on every other route, and claude-gauge knows them as its own.
|
|
170
|
+
function writeLauncher(dir, versions) {
|
|
171
|
+
(0, settings_file_1.writeAtomic)(path.join(dir, 'launch.js'), fs.readFileSync(path.join(packageRoot, 'dist', 'launcher.js')));
|
|
172
|
+
for (const file of RUNTIME) {
|
|
173
|
+
const entry = [
|
|
174
|
+
'#!/usr/bin/env node',
|
|
175
|
+
`// Written by claude-gauge setup: runs ${file} from the newest installed`,
|
|
176
|
+
'// claude-gauge plugin, so a plugin update needs no setup.',
|
|
177
|
+
`require('./launch.js').launch(${JSON.stringify(versions)}, ${JSON.stringify(file)});`,
|
|
178
|
+
'',
|
|
179
|
+
].join('\n');
|
|
180
|
+
(0, settings_file_1.writeAtomic)(path.join(dir, file), entry);
|
|
181
|
+
}
|
|
182
|
+
writePackageJson(dir, 'claude-gauge-launcher');
|
|
183
|
+
}
|
|
184
|
+
// The status line claude-gauge replaced: undefined when none is saved, null
|
|
185
|
+
// when there was none to replace. Only a missing file means none is saved: a
|
|
186
|
+
// file it cannot read or parse stops the command, because going on would
|
|
187
|
+
// drop the only record of the status line to put back.
|
|
188
|
+
function savedStatusLine() {
|
|
189
|
+
const file = savedStatusLineFile();
|
|
190
|
+
let text;
|
|
191
|
+
try {
|
|
192
|
+
text = fs.readFileSync(file, 'utf8');
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
if (err.code === 'ENOENT')
|
|
196
|
+
return undefined;
|
|
197
|
+
throw new Error(`Cannot read ${file}: ${err.message}. Fix or delete it by hand.`);
|
|
198
|
+
}
|
|
199
|
+
let saved;
|
|
200
|
+
try {
|
|
201
|
+
saved = JSON.parse(text);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
saved = undefined;
|
|
205
|
+
}
|
|
206
|
+
const statusLine = saved?.statusLine;
|
|
207
|
+
if (typeof saved !== 'object' || saved === null || !(statusLine === null || (typeof statusLine === 'object' && !Array.isArray(statusLine)))) {
|
|
208
|
+
throw new Error(`${file} does not hold a saved status line, so claude-gauge cannot tell what to put back. Fix or delete it by hand.`);
|
|
209
|
+
}
|
|
210
|
+
return statusLine;
|
|
211
|
+
}
|
|
212
|
+
const say = (...lines) => process.stdout.write(lines.join('\n') + '\n');
|
|
213
|
+
// setup and configure: turns the bars' switches into commands, plans the
|
|
214
|
+
// settings, and writes them.
|
|
215
|
+
function apply({ statusLine, tokenLine }, replace) {
|
|
216
|
+
const file = settingsFile();
|
|
217
|
+
const { settings, text } = (0, settings_file_1.readSettings)(file);
|
|
218
|
+
const where = scripts();
|
|
219
|
+
const { dir } = where;
|
|
220
|
+
const command = (script, switches) => switches === null || switches === undefined ? switches : (0, settings_1.commandFor)(path.join(dir, script), switches);
|
|
221
|
+
const choices = {
|
|
222
|
+
statusLine: command('statusline.js', statusLine),
|
|
223
|
+
tokenLine: command('tokenline.js', tokenLine),
|
|
224
|
+
replace,
|
|
225
|
+
previous: savedStatusLine() ?? null,
|
|
226
|
+
};
|
|
227
|
+
const next = (0, settings_1.plan)(settings, choices);
|
|
228
|
+
if (next.blocked) {
|
|
229
|
+
const current = settings.statusLine.command ?? JSON.stringify(settings.statusLine);
|
|
230
|
+
const whose = next.found === 'claude-hud' ? "claude-hud's status line" : 'another status line';
|
|
231
|
+
throw new Error([
|
|
232
|
+
`${file} already runs ${whose}:`,
|
|
233
|
+
` ${current}`,
|
|
234
|
+
'Run again with --replace to replace it. claude-gauge saves it, and claude-gauge uninstall puts it back.',
|
|
235
|
+
].join('\n'));
|
|
236
|
+
}
|
|
237
|
+
// The scripts go in first, so the settings never name a missing file.
|
|
238
|
+
if (typeof choices.statusLine === 'string' || typeof choices.tokenLine === 'string') {
|
|
239
|
+
if (where.route === 'copy')
|
|
240
|
+
copyRuntime(dir);
|
|
241
|
+
if (where.route === 'launcher')
|
|
242
|
+
writeLauncher(dir, where.versions);
|
|
243
|
+
}
|
|
244
|
+
// The status line to put back is saved before the settings change, so a
|
|
245
|
+
// failed save leaves nothing to restore wrongly.
|
|
246
|
+
if (next.backup)
|
|
247
|
+
(0, settings_file_1.writeAtomic)(savedStatusLineFile(), JSON.stringify(next.backup, null, 2) + '\n');
|
|
248
|
+
const settingsBackup = next.changed ? (0, settings_file_1.writeSettings)(file, next.settings, text) : null;
|
|
249
|
+
if (next.dropBackup)
|
|
250
|
+
fs.rmSync(savedStatusLineFile(), { force: true });
|
|
251
|
+
if (!next.changed) {
|
|
252
|
+
say(`${file} already holds these choices. Nothing to change.`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const ours = (0, settings_1.installed)(next.settings);
|
|
256
|
+
say(`Status line: ${ours.statusLine ?? 'not set up'}`, `Token line: ${ours.tokenLine ?? 'not set up'}`, ...(settingsBackup ? [`Backup of the previous settings: ${settingsBackup}`] : []), 'Start a new Claude Code session to pick up the changes.');
|
|
257
|
+
}
|
|
258
|
+
// The questions, asked on the terminal: each prompt on stdout, each answer a
|
|
259
|
+
// line of stdin. Lines are queued as they arrive, so answers piped in all at
|
|
260
|
+
// once each reach their question.
|
|
261
|
+
function terminalIo() {
|
|
262
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
263
|
+
const lines = rl[Symbol.asyncIterator]();
|
|
264
|
+
return {
|
|
265
|
+
ask: async (question) => {
|
|
266
|
+
process.stdout.write(question);
|
|
267
|
+
const next = await lines.next();
|
|
268
|
+
return next.done ? undefined : next.value;
|
|
269
|
+
},
|
|
270
|
+
write: (text) => process.stdout.write(text),
|
|
271
|
+
close: () => rl.close(),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// Whether gh runs here, and the call that stars the repo with it.
|
|
275
|
+
const hasGh = () => (0, node_child_process_1.spawnSync)('gh', ['--version'], { stdio: 'ignore' }).status === 0;
|
|
276
|
+
const starWithGh = () => (0, node_child_process_1.spawnSync)('gh', ['api', '--method', 'PUT', 'user/starred/jv-k/claude-gauge'], { stdio: ['ignore', 'ignore', 'inherit'] }).status === 0;
|
|
277
|
+
// setup or configure with no bar switches: asks before it replaces another
|
|
278
|
+
// status line, then asks for the bars, and writes them. configure starts the
|
|
279
|
+
// questions from the bars set up, setup from the defaults. setup ends with
|
|
280
|
+
// the star offer.
|
|
281
|
+
async function interactive(command, replace) {
|
|
282
|
+
const io = terminalIo();
|
|
283
|
+
try {
|
|
284
|
+
const file = settingsFile();
|
|
285
|
+
const { settings } = (0, settings_file_1.readSettings)(file);
|
|
286
|
+
const current = settings.statusLine;
|
|
287
|
+
const owner = (0, settings_1.ownerOf)(current);
|
|
288
|
+
if (!replace && (0, settings_1.isForeign)(owner)) {
|
|
289
|
+
io.write(`${file} already runs ${owner === 'claude-hud' ? "claude-hud's status line" : 'another status line'}:\n ${current?.command ?? JSON.stringify(current)}\n`);
|
|
290
|
+
if (!(await (0, wizard_1.confirm)(io, 'Replace it? claude-gauge saves it, and claude-gauge uninstall puts it back.', false))) {
|
|
291
|
+
say('Nothing changed.');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
replace = true;
|
|
295
|
+
}
|
|
296
|
+
const preview = (0, wizard_1.previewer)((0, wizard_1.loadPayload)((0, statusline_1.payloadFile)()));
|
|
297
|
+
const choices = await (0, wizard_1.runWizard)(io, { preview, installed: command === 'configure' ? (0, settings_1.installedSwitches)(settings) : undefined });
|
|
298
|
+
if (!choices) {
|
|
299
|
+
say('Nothing changed.');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
apply(choices, replace);
|
|
303
|
+
if (command === 'setup')
|
|
304
|
+
await (0, wizard_1.offerStar)(io, { hasGh, star: starWithGh });
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
io.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function setup(args) {
|
|
311
|
+
const chosen = args.statusLine !== undefined || args.tokenLine !== undefined;
|
|
312
|
+
if (!chosen && !args.yes)
|
|
313
|
+
return interactive('setup', args.replace);
|
|
314
|
+
const orDefault = (v) => (v === undefined && args.yes ? '' : v);
|
|
315
|
+
apply({ statusLine: orDefault(args.statusLine), tokenLine: orDefault(args.tokenLine) }, args.replace);
|
|
316
|
+
}
|
|
317
|
+
async function configure(args) {
|
|
318
|
+
const file = settingsFile();
|
|
319
|
+
if (!(0, settings_1.installed)((0, settings_file_1.readSettings)(file).settings).any) {
|
|
320
|
+
throw new Error(`claude-gauge is not set up in ${file}. Run claude-gauge setup first.`);
|
|
321
|
+
}
|
|
322
|
+
if (args.statusLine === undefined && args.tokenLine === undefined)
|
|
323
|
+
return interactive('configure', args.replace);
|
|
324
|
+
apply(args, args.replace);
|
|
325
|
+
}
|
|
326
|
+
function uninstall() {
|
|
327
|
+
const file = settingsFile();
|
|
328
|
+
const { settings, text } = (0, settings_file_1.readSettings)(file);
|
|
329
|
+
const saved = savedStatusLine();
|
|
330
|
+
const next = (0, settings_1.plan)(settings, { uninstall: true, previous: saved ?? null });
|
|
331
|
+
// The saved status line goes only once the settings no longer need it, so
|
|
332
|
+
// a failed write leaves it for the next try.
|
|
333
|
+
const settingsBackup = next.changed ? (0, settings_file_1.writeSettings)(file, next.settings, text) : null;
|
|
334
|
+
fs.rmSync(savedStatusLineFile(), { force: true });
|
|
335
|
+
if (!next.changed) {
|
|
336
|
+
say(`claude-gauge is not in ${file}. Nothing to change.`);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const restored = next.found === 'claude-gauge' && saved ? `Put back the previous status line: ${saved.command ?? JSON.stringify(saved)}` : undefined;
|
|
340
|
+
say(`Took claude-gauge out of ${file}.`, ...(restored ? [restored] : []), ...(settingsBackup ? [`Backup of the previous settings: ${settingsBackup}`] : []), `The scripts stay in ${stateDir()}. Delete that folder to remove them too.`, 'Start a new Claude Code session to pick up the changes.');
|
|
341
|
+
}
|
|
342
|
+
function update() {
|
|
343
|
+
const where = scripts();
|
|
344
|
+
const { dir } = where;
|
|
345
|
+
if (where.route === 'clone') {
|
|
346
|
+
say(`This claude-gauge is a git clone in ${stateDir()}. Update it with:`, ` git -C ${stateDir()} pull`);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (where.route === 'launcher') {
|
|
350
|
+
say('This claude-gauge is the Claude Code plugin. Update it with /plugin in Claude Code.', `The settings run ${dir}, which runs the newest installed version, so an update needs no setup.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (!fs.existsSync(path.join(dir, 'statusline.js'))) {
|
|
354
|
+
throw new Error(`There is no copy of claude-gauge in ${dir} to update. Run claude-gauge setup first.`);
|
|
355
|
+
}
|
|
356
|
+
copyRuntime(dir);
|
|
357
|
+
say(`Updated claude-gauge in ${dir} to ${version()}.`);
|
|
358
|
+
}
|
|
359
|
+
async function main(argv) {
|
|
360
|
+
try {
|
|
361
|
+
const args = parseArgs(argv);
|
|
362
|
+
if (args.help) {
|
|
363
|
+
process.stdout.write(USAGE);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
if (!args.command)
|
|
367
|
+
throw new UsageError('Name a command.');
|
|
368
|
+
switch (args.command) {
|
|
369
|
+
case 'setup':
|
|
370
|
+
await setup(args);
|
|
371
|
+
break;
|
|
372
|
+
case 'configure':
|
|
373
|
+
await configure(args);
|
|
374
|
+
break;
|
|
375
|
+
case 'uninstall':
|
|
376
|
+
uninstall();
|
|
377
|
+
break;
|
|
378
|
+
case 'update':
|
|
379
|
+
update();
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
if (err instanceof UsageError) {
|
|
386
|
+
process.stderr.write(`claude-gauge: ${err.message}\n\n${USAGE}`);
|
|
387
|
+
return 2;
|
|
388
|
+
}
|
|
389
|
+
process.stderr.write(`claude-gauge: ${err.message}\n`);
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
main(process.argv.slice(2)).then((code) => {
|
|
394
|
+
process.exitCode = code;
|
|
395
|
+
});
|
package/dist/launcher.js
ADDED
|
@@ -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
|
+
}
|