@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.
- package/README.md +191 -3
- package/dist/cli.js +408 -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/dist/wordmark.js +34 -0
- package/package.json +26 -4
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
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -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
|
+
}
|