@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/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 (!item.startsWith("--")) {
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
- const key = item.slice(2);
35
- if (["yes", "offline", "json", "dry-run", "spinner", "no-spinner", "gateway-offline", "force", "patch-installed-hermes", "no-patch-installed-hermes", "rollback-patches", "list", "no-hud", "no-style", "no-auto-update", "reconfigure-model", "audio", "word-timing"].includes(key)) {
36
- flags[key] = true;
106
+ if (!item.startsWith("--")) {
107
+ problems.push(`unknown option '${item}'`);
37
108
  continue;
38
109
  }
39
- const value = argv[i + 1];
40
- if (!value || value.startsWith("--")) {
41
- flags[key] = "";
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] = value;
45
- i += 1;
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 { command, flags, positionals } = parseArgs(argv);
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" || command === "--version" || command === "-v") {
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 (!['install', 'status'].includes(action)) {
299
- console.error('usage: lithermes motion-runtime install|status [--audio] [--word-timing]');
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 start = lines.findIndex((line) => /^plugins:\s*$/.test(line));
18
- if (start === -1) return null;
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:\s*$/.test(line)) return false;
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 (!trailingNewline && lines[lines.length - 1] === "") lines.pop();
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("\n")}${trailingNewline ? "\n" : ""}`;
56
+ return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
63
57
  }
64
- if (/^\s{2}enabled:\s*$/.test(line)) {
65
- lines.splice(i + 1, 0, " - lithermes");
66
- return `${lines.join("\n")}${trailingNewline ? "\n" : ""}`;
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("\n")}${trailingNewline ? "\n" : ""}`;
67
+ return `${lines.join(eol)}${trailingNewline ? eol : ""}`;
71
68
  }
72
- const suffix = text.endsWith("\n") ? "" : "\n";
73
- return `${text}${suffix}plugins:\n enabled:\n - lithermes\n`;
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("\n")}${trailingNewline ? "\n" : ""}`;
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}:\\s*$`);
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 trailingNewline = !text || text.endsWith("\n");
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 `${lines.join("\n").replace(/\n*$/, "")}\n`;
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 `${lines.join("\n").replace(/\n*$/, "")}\n`;
153
+ return joinLines(lines, eol);
138
154
  }
139
- const body = lines.join("\n").replace(/\n*$/, "");
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 trailingNewline = !text || text.endsWith("\n");
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 `${lines.join("\n").replace(/\n*$/, "")}\n`;
179
+ return joinLines(lines, eol);
166
180
  }
167
- const body = lines.join("\n").replace(/\n*$/, "");
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 `${lines.join("\n").replace(/\n*$/, "")}\n`;
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 applyHud(token, flags = {}) {
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 };