@litfamily/lithermes 1.0.18 → 1.0.19
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 +21 -67
- package/README_Ko-KR.md +20 -65
- package/assets/lithermes-plugin/auto_update.py +4 -1
- package/assets/lithermes-plugin/core_routing.py +7 -2
- package/assets/lithermes-plugin/payload-version.json +7 -7
- package/assets/lithermes-plugin/plugin.yaml +1 -1
- package/assets/lithermes-plugin/skills/frontend-ui-ux/references/complete-contract.md +2 -2
- package/assets/lithermes-plugin/skills/visual-qa/references/complete-contract.md +2 -2
- package/package.json +19 -3
- package/readme-assets/badge-version.svg +1 -1
- package/src/cli.js +164 -34
- package/src/lib/check.js +13 -1
- package/src/lib/config.js +94 -36
- package/src/lib/hud.js +33 -2
- package/src/lib/install.js +39 -1
- package/src/lib/skins.js +29 -9
- package/readme-assets/ascii-readme.svg +0 -5
package/src/cli.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
const path = require("node:path");
|
|
2
2
|
const { spawnSync } = require("node:child_process");
|
|
3
3
|
const readline = require("node:readline");
|
|
4
|
-
const { installLitHermes, previewModelRoute, uninstallLitHermes } = require("./lib/install");
|
|
4
|
+
const { installLitHermes, previewModelRoute, previewUninstall, uninstallLitHermes } = require("./lib/install");
|
|
5
5
|
const { promptModelRouteSession, renderInstallBanner, routeFromConfig } = require("./lib/modelPrompt");
|
|
6
6
|
const { checkLitHermes, doctorLitHermes } = require("./lib/check");
|
|
7
7
|
const { createSpinner, renderInstallReceipt, shouldUseSpinner } = require("./lib/spinner");
|
|
8
|
-
const { listHud, applyHud, clearHud, promptAccent, resolveHome } = require("./lib/hud");
|
|
8
|
+
const { listHud, applyHud, clearHud, previewClearHud, previewHud, promptAccent, resolveHome } = require("./lib/hud");
|
|
9
9
|
const { supportsColor, findAccent, skinName } = require("./lib/skins");
|
|
10
10
|
const {
|
|
11
11
|
readConfig,
|
|
@@ -22,29 +22,145 @@ const {
|
|
|
22
22
|
shouldAutoUpdate,
|
|
23
23
|
} = require("./lib/updateNotifier");
|
|
24
24
|
|
|
25
|
+
// Every option the CLI understands. A flag is a boolean switch unless it is listed in VALUE_FLAGS.
|
|
26
|
+
const VALUE_FLAGS = new Set([
|
|
27
|
+
"hermes-home", "hermes-repo", "provider", "model", "effort", "child-provider", "child-model", "child-effort",
|
|
28
|
+
"hud", "style", "installed-version",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// What each command accepts, and how help shows it. A command refuses anything not listed here.
|
|
32
|
+
const HOME = { "hermes-home": "PATH" };
|
|
33
|
+
const COMMAND_SPECS = {
|
|
34
|
+
install: {
|
|
35
|
+
usage: "install",
|
|
36
|
+
positionals: [0, 0],
|
|
37
|
+
flags: {
|
|
38
|
+
yes: "", "dry-run": "", offline: "", json: "", force: "", "reconfigure-model": "",
|
|
39
|
+
provider: "ID", model: "ID", effort: "LEVEL", "child-provider": "ID", "child-model": "ID", "child-effort": "LEVEL",
|
|
40
|
+
spinner: "", "no-spinner": "", hud: "<accent>", "no-hud": "", style: "ID", "no-style": "",
|
|
41
|
+
"patch-installed-hermes": "", "no-patch-installed-hermes": "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
check: {
|
|
45
|
+
usage: "check",
|
|
46
|
+
positionals: [0, 0],
|
|
47
|
+
flags: { offline: "", "gateway-offline": "", json: "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH" },
|
|
48
|
+
},
|
|
49
|
+
doctor: {
|
|
50
|
+
usage: "doctor",
|
|
51
|
+
positionals: [0, 0],
|
|
52
|
+
flags: { offline: "", json: "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH" },
|
|
53
|
+
},
|
|
54
|
+
"motion-runtime": {
|
|
55
|
+
usage: "motion-runtime install|status",
|
|
56
|
+
positionals: [1, 1],
|
|
57
|
+
actions: ["install", "status"],
|
|
58
|
+
flags: { "dry-run": "", audio: "", "word-timing": "", ...HOME },
|
|
59
|
+
note: "Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit ca251e3.",
|
|
60
|
+
},
|
|
61
|
+
uninstall: {
|
|
62
|
+
usage: "uninstall",
|
|
63
|
+
positionals: [0, 0],
|
|
64
|
+
flags: { yes: "", "dry-run": "", "rollback-patches": "", offline: "", "no-auto-update": "", ...HOME, "hermes-repo": "PATH" },
|
|
65
|
+
},
|
|
66
|
+
hud: {
|
|
67
|
+
usage: "hud [<accent>|off]",
|
|
68
|
+
positionals: [0, 1],
|
|
69
|
+
flags: { list: "", "dry-run": "", ...HOME },
|
|
70
|
+
note: "(Hermes HUD skin accents)",
|
|
71
|
+
},
|
|
72
|
+
version: { usage: "version", positionals: [0, 0], flags: {} },
|
|
73
|
+
help: { usage: "help", positionals: [0, 1], flags: {}, hidden: true },
|
|
74
|
+
// Private bridge for the installed plugin's automatic update; deliberately absent from help.
|
|
75
|
+
"__auto-update": { usage: "__auto-update", positionals: [0, 0], flags: { ...HOME, "installed-version": "VERSION" }, hidden: true },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const COMMAND_ALIASES = { "--version": "version", "-v": "version", "--help": "help", "-h": "help" };
|
|
79
|
+
|
|
80
|
+
class UsageError extends Error {
|
|
81
|
+
constructor(message) {
|
|
82
|
+
super(`${message}\nRun \`lithermes help\` for the options each command accepts.`);
|
|
83
|
+
this.exitCode = 2;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
25
87
|
function parseArgs(argv) {
|
|
26
88
|
const flags = {};
|
|
27
89
|
const positionals = [];
|
|
90
|
+
const problems = [];
|
|
91
|
+
let helpRequested = false;
|
|
28
92
|
for (let i = 0; i < argv.length; i += 1) {
|
|
29
93
|
const item = argv[i];
|
|
30
|
-
if (
|
|
94
|
+
if (positionals.length === 0 && COMMAND_ALIASES[item]) {
|
|
95
|
+
positionals.push(COMMAND_ALIASES[item]);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (item === "--help" || item === "-h") {
|
|
99
|
+
helpRequested = true;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!item.startsWith("-") || item === "-") {
|
|
31
103
|
positionals.push(item);
|
|
32
104
|
continue;
|
|
33
105
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
flags[key] = true;
|
|
106
|
+
if (!item.startsWith("--")) {
|
|
107
|
+
problems.push(`unknown option '${item}'`);
|
|
37
108
|
continue;
|
|
38
109
|
}
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
110
|
+
const equals = item.indexOf("=");
|
|
111
|
+
const key = equals === -1 ? item.slice(2) : item.slice(2, equals);
|
|
112
|
+
const inline = equals === -1 ? undefined : item.slice(equals + 1);
|
|
113
|
+
if (VALUE_FLAGS.has(key)) {
|
|
114
|
+
const value = inline ?? argv[i + 1];
|
|
115
|
+
if (value === undefined || value === "" || (inline === undefined && value.startsWith("-"))) {
|
|
116
|
+
problems.push(`option '--${key}' needs a value`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
flags[key] = value;
|
|
120
|
+
if (inline === undefined) i += 1;
|
|
42
121
|
continue;
|
|
43
122
|
}
|
|
44
|
-
flags[key] =
|
|
45
|
-
|
|
123
|
+
if (inline === undefined || inline === "true") flags[key] = true;
|
|
124
|
+
else if (inline !== "false") problems.push(`option '--${key}' expects true or false, not '${inline}'`);
|
|
46
125
|
}
|
|
47
|
-
return { command: positionals[0] || "help", flags, positionals };
|
|
126
|
+
return { command: positionals[0] || "help", flags, positionals, problems, helpRequested };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Refuse before anything runs: unknown options, options another command owns, and stray words.
|
|
130
|
+
function validateArgs({ command, flags, positionals, problems }) {
|
|
131
|
+
const spec = COMMAND_SPECS[command];
|
|
132
|
+
if (!spec) throw new UsageError(`lithermes: unknown command '${command}'.`);
|
|
133
|
+
const issues = [...problems];
|
|
134
|
+
for (const key of Object.keys(flags)) {
|
|
135
|
+
if (!Object.prototype.hasOwnProperty.call(spec.flags, key)) issues.push(`unknown option '--${key}'`);
|
|
136
|
+
}
|
|
137
|
+
const extra = positionals.slice(1);
|
|
138
|
+
const [min, max] = spec.positionals;
|
|
139
|
+
if (extra.length > max) issues.push(`unexpected argument '${extra[max]}'`);
|
|
140
|
+
else if (extra.length < min) issues.push(spec.actions ? `missing action (${spec.actions.join(" or ")})` : "missing argument");
|
|
141
|
+
else if (spec.actions && !spec.actions.includes(extra[0])) issues.push(`unexpected argument '${extra[0]}' (expected ${spec.actions.join(" or ")})`);
|
|
142
|
+
if (issues.length) throw new UsageError(`lithermes ${command}: ${issues.join("; ")}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function helpText() {
|
|
146
|
+
const lines = ["lithermes commands:"];
|
|
147
|
+
for (const spec of Object.values(COMMAND_SPECS)) {
|
|
148
|
+
if (spec.hidden) continue;
|
|
149
|
+
const options = Object.entries(spec.flags).map(([key, value]) => `[--${key}${value ? ` ${value}` : ""}]`);
|
|
150
|
+
const rows = [];
|
|
151
|
+
let row = ` ${spec.usage}`;
|
|
152
|
+
for (const option of options) {
|
|
153
|
+
if (row.length + option.length + 1 > 100) {
|
|
154
|
+
rows.push(row);
|
|
155
|
+
row = `${" ".repeat(spec.usage.length + 2)} ${option}`;
|
|
156
|
+
} else row += ` ${option}`;
|
|
157
|
+
}
|
|
158
|
+
rows.push(spec.note && row.length + spec.note.length < 100 && !spec.note.includes("MIT") ? `${row} ${spec.note}` : row);
|
|
159
|
+
if (spec.note && spec.note.includes("MIT")) rows.push(` ${spec.note}`);
|
|
160
|
+
lines.push(...rows);
|
|
161
|
+
}
|
|
162
|
+
lines.push(" --dry-run (or --dry-run=true) shows what a command would change and changes nothing; --dry-run=false runs it normally.");
|
|
163
|
+
return lines.join("\n");
|
|
48
164
|
}
|
|
49
165
|
|
|
50
166
|
function packageVersion() {
|
|
@@ -67,6 +183,27 @@ function motionRuntime(action, flags = {}) {
|
|
|
67
183
|
};
|
|
68
184
|
}
|
|
69
185
|
|
|
186
|
+
// Dry run of `motion-runtime install`: the runtime module only reads its pins and the cache here.
|
|
187
|
+
async function motionRuntimePlan(flags = {}) {
|
|
188
|
+
const { pathToFileURL } = require("node:url");
|
|
189
|
+
const script = path.join(__dirname, "..", "assets", "lithermes-plugin", "skills", "lit-typographic-motion", "bin", "runtime.mjs");
|
|
190
|
+
const runtime = await import(pathToFileURL(script).href);
|
|
191
|
+
const env = { ...process.env, HERMES_HOME: resolveHome(flags) };
|
|
192
|
+
const ready = runtime.findCache(env);
|
|
193
|
+
const fonts = runtime.fontState(env);
|
|
194
|
+
const lines = ["DRY RUN: would install the motion runtime", `cache: ${ready || runtime.cacheRoots(env)[0]}`];
|
|
195
|
+
if (flags["word-timing"]) lines.push(`word-timing: would be refused (fail-closed): ${runtime.wordTimingPlan().reason}`);
|
|
196
|
+
else if (ready && fonts.ok) lines.push("node packages and fonts: already READY; nothing to install");
|
|
197
|
+
else lines.push(`would run npm ci for the pinned node packages and fetch ${runtime.FONT_PINS.length} pinned fonts`);
|
|
198
|
+
if (flags.audio && !flags["word-timing"]) {
|
|
199
|
+
lines.push(runtime.audioState(env).state === "ready"
|
|
200
|
+
? "audio venv: already READY"
|
|
201
|
+
: "would create the audio venv and pip-install the hash-pinned requirements.lock");
|
|
202
|
+
}
|
|
203
|
+
lines.push("Nothing was downloaded or written.");
|
|
204
|
+
return lines.join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
70
207
|
function printCommandBanner(command, flags, runtime = {}) {
|
|
71
208
|
// Keep machine-oriented routes byte-stable: their stdout is consumed as a
|
|
72
209
|
// version line or JSON receipt rather than a human-facing command report.
|
|
@@ -269,9 +406,15 @@ async function maybePickHudAccent(flags, runtime = {}, { promptForAccent = false
|
|
|
269
406
|
}
|
|
270
407
|
|
|
271
408
|
async function main(argv, runtime = {}) {
|
|
272
|
-
const
|
|
409
|
+
const parsed = parseArgs(argv);
|
|
410
|
+
const { command, flags, positionals } = parsed;
|
|
411
|
+
if (parsed.helpRequested) {
|
|
412
|
+
console.log(helpText());
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
validateArgs(parsed);
|
|
273
416
|
printCommandBanner(command, flags, runtime);
|
|
274
|
-
if (command === "version"
|
|
417
|
+
if (command === "version") {
|
|
275
418
|
console.log(packageVersion());
|
|
276
419
|
return;
|
|
277
420
|
}
|
|
@@ -282,11 +425,11 @@ async function main(argv, runtime = {}) {
|
|
|
282
425
|
return;
|
|
283
426
|
}
|
|
284
427
|
if (accent === "off" || accent === "clear" || accent === "none") {
|
|
285
|
-
console.log(clearHud(flags));
|
|
428
|
+
console.log(flags["dry-run"] ? previewClearHud(flags) : clearHud(flags));
|
|
286
429
|
return;
|
|
287
430
|
}
|
|
288
431
|
try {
|
|
289
|
-
console.log(applyHud(accent, flags));
|
|
432
|
+
console.log(flags["dry-run"] ? previewHud(accent, flags) : applyHud(accent, flags));
|
|
290
433
|
} catch (error) {
|
|
291
434
|
console.error(error.message);
|
|
292
435
|
process.exitCode = error.exitCode || 1;
|
|
@@ -295,9 +438,8 @@ async function main(argv, runtime = {}) {
|
|
|
295
438
|
}
|
|
296
439
|
if (command === "motion-runtime") {
|
|
297
440
|
const action = positionals[1];
|
|
298
|
-
if (
|
|
299
|
-
console.
|
|
300
|
-
process.exitCode = 2;
|
|
441
|
+
if (action === "install" && flags["dry-run"]) {
|
|
442
|
+
console.log(await motionRuntimePlan(flags));
|
|
301
443
|
return;
|
|
302
444
|
}
|
|
303
445
|
const result = motionRuntime(action, flags);
|
|
@@ -358,7 +500,7 @@ async function main(argv, runtime = {}) {
|
|
|
358
500
|
return;
|
|
359
501
|
}
|
|
360
502
|
if (command === "uninstall") {
|
|
361
|
-
const result = uninstallLitHermes(flags);
|
|
503
|
+
const result = flags["dry-run"] ? previewUninstall(flags) : uninstallLitHermes(flags);
|
|
362
504
|
console.log(result.message);
|
|
363
505
|
return;
|
|
364
506
|
}
|
|
@@ -407,19 +549,7 @@ async function main(argv, runtime = {}) {
|
|
|
407
549
|
if (result.ok !== false) scheduleUpdateSafely(command, flags, runtime);
|
|
408
550
|
return;
|
|
409
551
|
}
|
|
410
|
-
console.log(
|
|
411
|
-
"lithermes commands:",
|
|
412
|
-
" install [--yes] [--dry-run] [--reconfigure-model] [--provider ID] [--model ID] [--effort LEVEL]",
|
|
413
|
-
" [--child-provider ID] [--child-model ID] [--child-effort LEVEL]",
|
|
414
|
-
" [--spinner] [--no-spinner] [--hud <accent>] [--no-hud] [--no-auto-update] [--hermes-home PATH]",
|
|
415
|
-
" check [--offline] [--gateway-offline] [--hermes-home PATH]",
|
|
416
|
-
" doctor [--offline] [--hermes-home PATH] [--hermes-repo PATH]",
|
|
417
|
-
" motion-runtime install|status [--audio] [--word-timing]",
|
|
418
|
-
" Typographic-motion engine adapted from mexicat/pdoom-video (MIT, Giacomo Magnanini), commit ca251e3.",
|
|
419
|
-
" uninstall [--yes] [--hermes-home PATH]",
|
|
420
|
-
" hud [<accent>|off|--list] [--hermes-home PATH] (Hermes HUD skin accents)",
|
|
421
|
-
" version",
|
|
422
|
-
].join("\n"));
|
|
552
|
+
console.log(helpText());
|
|
423
553
|
}
|
|
424
554
|
|
|
425
|
-
module.exports = { main, parseArgs };
|
|
555
|
+
module.exports = { COMMAND_SPECS, helpText, main, parseArgs, validateArgs };
|
package/src/lib/check.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fs = require("node:fs");
|
|
2
2
|
const path = require("node:path");
|
|
3
3
|
const { spawnSync } = require("node:child_process");
|
|
4
|
-
const { configHasLitHermes, formatConcurrencyCapability, inspectHermesCapabilities, readConfig, readExternalSkillDirsConfig } = require("./config");
|
|
4
|
+
const { configHasLitHermes, formatConcurrencyCapability, inspectHermesCapabilities, readConfig, readExternalSkillDirsConfig, readStatusBarFields, suggestStatusBarFields } = require("./config");
|
|
5
5
|
const { detectHermesRuntimeRepo, detectHermesVersion, ensureHermesHome, expandHome, inspectHermesHostCapabilities, LitHermesError } = require("./hermesDiscovery");
|
|
6
6
|
const { formatManagedRoute } = require("./modelRoutePolicy");
|
|
7
7
|
const { inspectPythonCache } = require("./files");
|
|
@@ -223,6 +223,17 @@ function motionProbeLines(pluginPath, hermesHome) {
|
|
|
223
223
|
];
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
function formatStatusBarFields(configText) {
|
|
227
|
+
const suggested = suggestStatusBarFields(configText);
|
|
228
|
+
if (suggested) {
|
|
229
|
+
return `status bar fields: DEFAULT (suggest display.status_bar.fields: [${suggested.join(", ")}]; LitHermes does not write it)`;
|
|
230
|
+
}
|
|
231
|
+
const fields = readStatusBarFields(configText);
|
|
232
|
+
return fields === null
|
|
233
|
+
? "status bar fields: UNKNOWN (config.yaml did not parse)"
|
|
234
|
+
: `status bar fields: CUSTOM (${fields.length} ${fields.length === 1 ? "field" : "fields"}, left as configured)`;
|
|
235
|
+
}
|
|
236
|
+
|
|
226
237
|
function doctorLitHermes(flags = {}) {
|
|
227
238
|
const bundledBytecodeEntries = inspectPythonCache(assetRoot);
|
|
228
239
|
const { hermesHome, hermesRepo } = ensureHermesHome(flags);
|
|
@@ -290,6 +301,7 @@ function doctorLitHermes(flags = {}) {
|
|
|
290
301
|
`loaded plugin: ${loaded.status} (${loaded.reason})`,
|
|
291
302
|
`skill shadow check: ${skillShadows.length ? "WARNING" : "PASS"}`,
|
|
292
303
|
...skillShadows.map(formatSkillShadowWarning),
|
|
304
|
+
formatStatusBarFields(configText),
|
|
293
305
|
`auto-compaction: ${auto.status}${auto.reason ? ` (${auto.reason})` : ""}`,
|
|
294
306
|
`concurrency: ${formatConcurrencyCapability(concurrency)}`,
|
|
295
307
|
`recursion: ${recursion.status}${recursion.status === "hard" ? recursion.flatBy === "depth" ? ` (depth ${recursion.limit}, flat)` : ` (configured depth ${recursion.limit}, flat via orchestrator kill switch)` : ` (${recursion.reason})`}`,
|
package/src/lib/config.js
CHANGED
|
@@ -14,16 +14,8 @@ function readConfig(hermesHome) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function findPluginsBlock(lines) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
let end = lines.length;
|
|
20
|
-
for (let i = start + 1; i < lines.length; i += 1) {
|
|
21
|
-
if (/^\S/.test(lines[i]) && !/^---\s*$/.test(lines[i])) {
|
|
22
|
-
end = i;
|
|
23
|
-
break;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return { start, end, lines: lines.slice(start, end) };
|
|
17
|
+
const block = findTopBlock(lines, "plugins");
|
|
18
|
+
return block && { ...block, lines: lines.slice(block.start, block.end) };
|
|
27
19
|
}
|
|
28
20
|
|
|
29
21
|
function configHasLitHermes(text) {
|
|
@@ -31,7 +23,7 @@ function configHasLitHermes(text) {
|
|
|
31
23
|
if (!block) return false;
|
|
32
24
|
return block.lines.some((line, index) => {
|
|
33
25
|
if (/^\s*enabled:\s*\[[^\]]*lithermes[^\]]*\]/.test(line)) return true;
|
|
34
|
-
if (!/^\s*enabled
|
|
26
|
+
if (!/^\s*enabled:(\s+#.*)?\s*$/.test(line)) return false;
|
|
35
27
|
for (let i = index + 1; i < block.lines.length; i += 1) {
|
|
36
28
|
const child = block.lines[i];
|
|
37
29
|
if (/^\s{2}\S/.test(child) && !/^\s{2,}-/.test(child)) return false;
|
|
@@ -46,11 +38,13 @@ function enableLitHermesConfig(text) {
|
|
|
46
38
|
if (!text.trim()) {
|
|
47
39
|
return "plugins:\n enabled:\n - lithermes\n";
|
|
48
40
|
}
|
|
41
|
+
const eol = eolOf(text);
|
|
49
42
|
const trailingNewline = text.endsWith("\n");
|
|
50
43
|
const lines = text.split(/\r?\n/);
|
|
51
|
-
if (
|
|
44
|
+
if (trailingNewline) lines.pop();
|
|
52
45
|
const block = findPluginsBlock(lines);
|
|
53
46
|
if (block) {
|
|
47
|
+
openBlock(lines, block);
|
|
54
48
|
for (let i = block.start + 1; i < block.end; i += 1) {
|
|
55
49
|
const line = lines[i];
|
|
56
50
|
if (/^\s{2}enabled:\s*\[[^\]]*\]/.test(line)) {
|
|
@@ -59,43 +53,53 @@ function enableLitHermesConfig(text) {
|
|
|
59
53
|
values.push("lithermes");
|
|
60
54
|
return `[${values.join(", ")}]`;
|
|
61
55
|
});
|
|
62
|
-
return `${lines.join(
|
|
56
|
+
return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
|
|
63
57
|
}
|
|
64
|
-
if (/^\s{2}enabled
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
if (/^\s{2}enabled:(\s+#.*)?\s*$/.test(line)) {
|
|
59
|
+
// Match the list's own indentation; Hermes writes indentless sequences.
|
|
60
|
+
const next = lines.slice(i + 1, block.end).find((item) => item.trim() && !/^\s*#/.test(item));
|
|
61
|
+
const indent = /^(\s*)-(\s|$)/.exec(next || "")?.[1] ?? " ";
|
|
62
|
+
lines.splice(i + 1, 0, `${indent}- lithermes`);
|
|
63
|
+
return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
|
|
67
64
|
}
|
|
68
65
|
}
|
|
69
66
|
lines.splice(block.start + 1, 0, " enabled:", " - lithermes");
|
|
70
|
-
return `${lines.join(
|
|
67
|
+
return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
|
|
71
68
|
}
|
|
72
|
-
const suffix =
|
|
73
|
-
return `${text}${suffix}plugins
|
|
69
|
+
const suffix = trailingNewline ? "" : eol;
|
|
70
|
+
return `${text}${suffix}${["plugins:", " enabled:", " - lithermes", ""].join(eol)}`;
|
|
74
71
|
}
|
|
75
72
|
|
|
76
73
|
function disableLitHermesConfig(text) {
|
|
74
|
+
const eol = eolOf(text);
|
|
77
75
|
const trailingNewline = text.endsWith("\n");
|
|
78
76
|
const lines = text.split(/\r?\n/);
|
|
77
|
+
if (trailingNewline) lines.pop();
|
|
79
78
|
const block = findPluginsBlock(lines);
|
|
80
79
|
if (!block) return text;
|
|
80
|
+
let inEnabled = false;
|
|
81
81
|
for (let i = block.start + 1; i < block.end; i += 1) {
|
|
82
82
|
const line = lines[i];
|
|
83
|
+
// Only plugins.enabled items are removed; a `disabled:` entry belongs to the user.
|
|
84
|
+
if (/^\s+[^\s#-]/.test(line)) inEnabled = /^\s+enabled:(\s+#.*)?\s*$/.test(line);
|
|
83
85
|
if (/^\s{2}enabled:\s*\[[^\]]*lithermes[^\]]*\]/.test(line)) {
|
|
84
86
|
const inner = line.match(/\[([^\]]*)\]/)?.[1] || "";
|
|
85
87
|
const values = inner.split(",").map((item) => item.trim()).filter((item) => item && item !== "lithermes");
|
|
86
88
|
lines[i] = ` enabled: [${values.join(", ")}]`;
|
|
87
89
|
}
|
|
88
|
-
if (/^\s*-\s*lithermes\s*$/.test(line)) {
|
|
90
|
+
if (inEnabled && /^\s*-\s*lithermes\s*$/.test(line)) {
|
|
89
91
|
lines.splice(i, 1);
|
|
90
92
|
i -= 1;
|
|
91
93
|
block.end -= 1;
|
|
92
94
|
}
|
|
93
95
|
}
|
|
94
|
-
return `${lines.join(
|
|
96
|
+
return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// A top-level `key:` opens a block even with a trailing comment or an empty
|
|
100
|
+
// flow map `{}`; openBlock() drops the `{}` only when a child is being added.
|
|
97
101
|
function findTopBlock(lines, key) {
|
|
98
|
-
const re = new RegExp(`^${key}
|
|
102
|
+
const re = new RegExp(`^${key}:(\\s*\\{\\s*\\})?(\\s+#.*)?\\s*$`);
|
|
99
103
|
const start = lines.findIndex((line) => re.test(line));
|
|
100
104
|
if (start === -1) return null;
|
|
101
105
|
let end = lines.length;
|
|
@@ -108,6 +112,18 @@ function findTopBlock(lines, key) {
|
|
|
108
112
|
return { start, end };
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
const eolOf = (text) => (String(text || "").includes("\r\n") ? "\r\n" : "\n");
|
|
116
|
+
|
|
117
|
+
// Rejoin in the file's own line ending; trailing blank lines collapse to one ending.
|
|
118
|
+
function joinLines(lines, eol, extra = []) {
|
|
119
|
+
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
|
120
|
+
return [...lines, ...extra, ""].join(eol);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openBlock(lines, block) {
|
|
124
|
+
lines[block.start] = lines[block.start].replace(/\s*\{\s*\}/, "");
|
|
125
|
+
}
|
|
126
|
+
|
|
111
127
|
function readDisplaySkinConfig(text) {
|
|
112
128
|
const lines = String(text || "").split(/\r?\n/);
|
|
113
129
|
const block = findTopBlock(lines, "display");
|
|
@@ -122,23 +138,21 @@ function readDisplaySkinConfig(text) {
|
|
|
122
138
|
function setDisplaySkinConfig(text, skinName) {
|
|
123
139
|
const value = String(skinName || "").trim();
|
|
124
140
|
if (!value) return text;
|
|
125
|
-
const
|
|
141
|
+
const eol = eolOf(text);
|
|
126
142
|
const lines = String(text || "").split(/\r?\n/);
|
|
127
|
-
if (!trailingNewline && lines[lines.length - 1] === "") lines.pop();
|
|
128
143
|
const block = findTopBlock(lines, "display");
|
|
129
144
|
if (block) {
|
|
130
145
|
for (let i = block.start + 1; i < block.end; i += 1) {
|
|
131
146
|
if (/^\s{2}skin:\s*/.test(lines[i])) {
|
|
132
147
|
lines[i] = ` skin: ${value}`;
|
|
133
|
-
return
|
|
148
|
+
return joinLines(lines, eol);
|
|
134
149
|
}
|
|
135
150
|
}
|
|
151
|
+
openBlock(lines, block);
|
|
136
152
|
lines.splice(block.start + 1, 0, ` skin: ${value}`);
|
|
137
|
-
return
|
|
153
|
+
return joinLines(lines, eol);
|
|
138
154
|
}
|
|
139
|
-
|
|
140
|
-
const prefix = body ? `${body}\n` : "";
|
|
141
|
-
return `${prefix}display:\n skin: ${value}\n`;
|
|
155
|
+
return joinLines(lines, eol, ["display:", ` skin: ${value}`]);
|
|
142
156
|
}
|
|
143
157
|
|
|
144
158
|
function readTuiAgentsNudgeConfig(text) {
|
|
@@ -156,17 +170,15 @@ function readTuiAgentsNudgeConfig(text) {
|
|
|
156
170
|
// Set only when absent: an explicit user value, true or false, is preserved.
|
|
157
171
|
function setTuiAgentsNudgeConfig(text) {
|
|
158
172
|
if (readTuiAgentsNudgeConfig(text) !== null) return text;
|
|
159
|
-
const
|
|
173
|
+
const eol = eolOf(text);
|
|
160
174
|
const lines = String(text || "").split(/\r?\n/);
|
|
161
|
-
if (!trailingNewline && lines[lines.length - 1] === "") lines.pop();
|
|
162
175
|
const block = findTopBlock(lines, "display");
|
|
163
176
|
if (block) {
|
|
177
|
+
openBlock(lines, block);
|
|
164
178
|
lines.splice(block.start + 1, 0, " tui_agents_nudge: true");
|
|
165
|
-
return
|
|
179
|
+
return joinLines(lines, eol);
|
|
166
180
|
}
|
|
167
|
-
|
|
168
|
-
const prefix = body ? `${body}\n` : "";
|
|
169
|
-
return `${prefix}display:\n tui_agents_nudge: true\n`;
|
|
181
|
+
return joinLines(lines, eol, ["display:", " tui_agents_nudge: true"]);
|
|
170
182
|
}
|
|
171
183
|
|
|
172
184
|
function clearDisplaySkinConfig(text) {
|
|
@@ -179,7 +191,7 @@ function clearDisplaySkinConfig(text) {
|
|
|
179
191
|
break;
|
|
180
192
|
}
|
|
181
193
|
}
|
|
182
|
-
return
|
|
194
|
+
return joinLines(lines, eolOf(text));
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
function readOutputStyleConfig(text) {
|
|
@@ -227,13 +239,56 @@ function readExternalSkillDirsConfig(text) {
|
|
|
227
239
|
}
|
|
228
240
|
}
|
|
229
241
|
|
|
242
|
+
// Hermes `display.status_bar.fields` only chooses which built-in segments show
|
|
243
|
+
// (the order is fixed). Empty means the host default, which leaves out the
|
|
244
|
+
// opt-in git branch. Doctor suggests this set and never writes it.
|
|
245
|
+
const RECOMMENDED_STATUS_BAR_FIELDS = Object.freeze([
|
|
246
|
+
"model", "context_pct", "goal", "bg_subagents", "git_branch", "prompt_elapsed", "yolo",
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
// The configured field list ([] when unset); null when config.yaml does not parse.
|
|
250
|
+
function readStatusBarFields(text) {
|
|
251
|
+
const str = String(text || "");
|
|
252
|
+
if (!str.trim()) return [];
|
|
253
|
+
try {
|
|
254
|
+
const document = parseDocument(str, { strict: true, uniqueKeys: true });
|
|
255
|
+
if (document.errors.length) return null;
|
|
256
|
+
const fields = document.toJS()?.display?.status_bar?.fields;
|
|
257
|
+
return Array.isArray(fields) ? fields : [];
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// null = the user already chose fields, or the YAML is malformed (no guessing).
|
|
264
|
+
function suggestStatusBarFields(text) {
|
|
265
|
+
const fields = readStatusBarFields(text);
|
|
266
|
+
return fields && fields.length === 0 ? RECOMMENDED_STATUS_BAR_FIELDS : null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function yamlError(text) {
|
|
270
|
+
if (!String(text || "").trim()) return null;
|
|
271
|
+
const document = parseDocument(text, { strict: true, uniqueKeys: true });
|
|
272
|
+
return document.errors.length ? document.errors[0].message.split("\n")[0].replace(/:$/, "") : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Fail closed when an edit would break a config that parses under the installer's
|
|
276
|
+
// strict check; an already-malformed file keeps the append-only fallback.
|
|
277
|
+
function assertConfigEdit(hermesHome, before, after, hint = "Edit config.yaml by hand.") {
|
|
278
|
+
const error = yamlError(after);
|
|
279
|
+
if (!error || yamlError(before)) return;
|
|
280
|
+
throw new Error(`${configPath(hermesHome)} left unchanged: the LitHermes edit would not parse as YAML (${error}). ${hint}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
230
283
|
function writeConfig(hermesHome, text) {
|
|
284
|
+
assertConfigEdit(hermesHome, readConfig(hermesHome), text);
|
|
231
285
|
fs.mkdirSync(hermesHome, { recursive: true });
|
|
232
286
|
writeFileAtomic(configPath(hermesHome), text, "utf8");
|
|
233
287
|
}
|
|
234
288
|
|
|
235
289
|
module.exports = {
|
|
236
290
|
...modelConfig,
|
|
291
|
+
assertConfigEdit,
|
|
237
292
|
clearDisplaySkinConfig,
|
|
238
293
|
clearOutputStyleConfig,
|
|
239
294
|
configHasLitHermes,
|
|
@@ -243,6 +298,9 @@ module.exports = {
|
|
|
243
298
|
readConfig,
|
|
244
299
|
readDisplaySkinConfig,
|
|
245
300
|
readExternalSkillDirsConfig,
|
|
301
|
+
readStatusBarFields,
|
|
302
|
+
RECOMMENDED_STATUS_BAR_FIELDS,
|
|
303
|
+
suggestStatusBarFields,
|
|
246
304
|
readOutputStyleConfig,
|
|
247
305
|
readTuiAgentsNudgeConfig,
|
|
248
306
|
setDisplaySkinConfig,
|
package/src/lib/hud.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
1
2
|
const os = require("node:os");
|
|
2
3
|
const path = require("node:path");
|
|
3
4
|
const readline = require("node:readline");
|
|
@@ -76,7 +77,7 @@ function listHud({ color = false, hermesHome = null } = {}) {
|
|
|
76
77
|
return lines.join("\n");
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
function
|
|
80
|
+
function accentOrThrow(token) {
|
|
80
81
|
const entry = skins.findAccent(token);
|
|
81
82
|
if (!entry) {
|
|
82
83
|
const names = [
|
|
@@ -88,6 +89,11 @@ function applyHud(token, flags = {}) {
|
|
|
88
89
|
err.exitCode = 4;
|
|
89
90
|
throw err;
|
|
90
91
|
}
|
|
92
|
+
return entry;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applyHud(token, flags = {}) {
|
|
96
|
+
const entry = accentOrThrow(token);
|
|
91
97
|
const home = resolveHome(flags);
|
|
92
98
|
const written = skins.installSkins(home);
|
|
93
99
|
const name = skins.skinName(entry.accent);
|
|
@@ -110,6 +116,31 @@ function clearHud(flags = {}) {
|
|
|
110
116
|
return "LitHermes HUD cleared (display.skin removed). Restart Hermes to revert to the default skin.";
|
|
111
117
|
}
|
|
112
118
|
|
|
119
|
+
// Dry runs of `hud <accent>` and `hud off`: read the config and skins folder, write nothing.
|
|
120
|
+
function previewHud(token, flags = {}) {
|
|
121
|
+
const entry = accentOrThrow(token);
|
|
122
|
+
const home = resolveHome(flags);
|
|
123
|
+
const name = skins.skinName(entry.accent);
|
|
124
|
+
const dir = skins.skinsDir(home);
|
|
125
|
+
const missing = [...skins.HUD_ACCENTS, skins.IGNITION, ...skins.NAMED_THEMES]
|
|
126
|
+
.filter((item) => !fs.existsSync(path.join(dir, `${skins.skinName(item.accent)}.yaml`)));
|
|
127
|
+
const before = readConfig(home);
|
|
128
|
+
return [
|
|
129
|
+
`DRY RUN: would set display.skin: ${name} (${entry.label})`,
|
|
130
|
+
`skins: would add ${missing.length} skin files -> ${dir}`,
|
|
131
|
+
`config: ${setDisplaySkinConfig(before, name) === before ? "already set; no change" : `would update ${path.join(home, "config.yaml")}`}`,
|
|
132
|
+
"Nothing was changed.",
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function previewClearHud(flags = {}) {
|
|
137
|
+
const home = resolveHome(flags);
|
|
138
|
+
const before = readConfig(home);
|
|
139
|
+
return clearDisplaySkinConfig(before) === before
|
|
140
|
+
? "DRY RUN: display.skin is not set, so `hud off` would change nothing.\nNothing was changed."
|
|
141
|
+
: `DRY RUN: would remove display.skin from ${path.join(home, "config.yaml")}\nNothing was changed.`;
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
// Interactive accent picker used during `lithermes install` on a TTY.
|
|
114
145
|
// Returns the chosen accent entry, or null to skip (empty / unknown answer).
|
|
115
146
|
function promptAccent({ input = process.stdin, output = process.stdout, color = false } = {}) {
|
|
@@ -132,4 +163,4 @@ function promptAccent({ input = process.stdin, output = process.stdout, color =
|
|
|
132
163
|
});
|
|
133
164
|
}
|
|
134
165
|
|
|
135
|
-
module.exports = { resolveHome, listHud, applyHud, clearHud, promptAccent };
|
|
166
|
+
module.exports = { resolveHome, listHud, applyHud, clearHud, previewClearHud, previewHud, promptAccent };
|