lumira 1.5.0 → 1.6.1

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.
Files changed (133) hide show
  1. package/README.md +65 -1
  2. package/dist/commands/custom-refresh.js +124 -0
  3. package/dist/commands/custom.js +280 -0
  4. package/dist/config.js +150 -6
  5. package/dist/index.js +52 -1
  6. package/dist/installer.js +1 -0
  7. package/dist/parsers/custom-commands.js +232 -0
  8. package/dist/render/index.js +6 -1
  9. package/dist/render/line1.js +9 -1
  10. package/dist/render/line2.js +9 -1
  11. package/dist/render/line3.js +9 -1
  12. package/dist/render/line4.js +20 -9
  13. package/dist/render/minimal.js +8 -2
  14. package/dist/render/powerline-line1.js +19 -3
  15. package/dist/render/powerline-line2.js +10 -1
  16. package/dist/render/powerline-line3.js +15 -3
  17. package/dist/render/shared.js +62 -1
  18. package/dist/types.js +19 -0
  19. package/dist/utils/custom-cache.js +77 -0
  20. package/dist/utils/exec-bg.js +183 -0
  21. package/package.json +5 -3
  22. package/dist/commands/stats.d.ts +0 -71
  23. package/dist/commands/stats.js.map +0 -1
  24. package/dist/commands/themes.d.ts +0 -31
  25. package/dist/commands/themes.js.map +0 -1
  26. package/dist/config.d.ts +0 -12
  27. package/dist/config.js.map +0 -1
  28. package/dist/index.d.ts +0 -3
  29. package/dist/index.js.map +0 -1
  30. package/dist/installer-wizard.d.ts +0 -21
  31. package/dist/installer-wizard.js.map +0 -1
  32. package/dist/installer.d.ts +0 -17
  33. package/dist/installer.js.map +0 -1
  34. package/dist/normalize.d.ts +0 -85
  35. package/dist/normalize.js.map +0 -1
  36. package/dist/parsers/config-health.d.ts +0 -8
  37. package/dist/parsers/config-health.js.map +0 -1
  38. package/dist/parsers/git.d.ts +0 -5
  39. package/dist/parsers/git.js.map +0 -1
  40. package/dist/parsers/gsd.d.ts +0 -24
  41. package/dist/parsers/gsd.js.map +0 -1
  42. package/dist/parsers/mcp.d.ts +0 -10
  43. package/dist/parsers/mcp.js.map +0 -1
  44. package/dist/parsers/memory.d.ts +0 -9
  45. package/dist/parsers/memory.js.map +0 -1
  46. package/dist/parsers/subagents.d.ts +0 -82
  47. package/dist/parsers/subagents.js.map +0 -1
  48. package/dist/parsers/token-speed.d.ts +0 -9
  49. package/dist/parsers/token-speed.js.map +0 -1
  50. package/dist/parsers/transcript-stats.d.ts +0 -41
  51. package/dist/parsers/transcript-stats.js.map +0 -1
  52. package/dist/parsers/transcript.d.ts +0 -11
  53. package/dist/parsers/transcript.js.map +0 -1
  54. package/dist/render/api-latency.d.ts +0 -22
  55. package/dist/render/api-latency.js.map +0 -1
  56. package/dist/render/colors.d.ts +0 -70
  57. package/dist/render/colors.js.map +0 -1
  58. package/dist/render/hyperlink.d.ts +0 -4
  59. package/dist/render/hyperlink.js.map +0 -1
  60. package/dist/render/icons.d.ts +0 -46
  61. package/dist/render/icons.js.map +0 -1
  62. package/dist/render/index.d.ts +0 -2
  63. package/dist/render/index.js.map +0 -1
  64. package/dist/render/line1.d.ts +0 -3
  65. package/dist/render/line1.js.map +0 -1
  66. package/dist/render/line2.d.ts +0 -4
  67. package/dist/render/line2.js.map +0 -1
  68. package/dist/render/line3.d.ts +0 -3
  69. package/dist/render/line3.js.map +0 -1
  70. package/dist/render/line4.d.ts +0 -3
  71. package/dist/render/line4.js.map +0 -1
  72. package/dist/render/minimal.d.ts +0 -3
  73. package/dist/render/minimal.js.map +0 -1
  74. package/dist/render/pace.d.ts +0 -6
  75. package/dist/render/pace.js.map +0 -1
  76. package/dist/render/powerline-line1.d.ts +0 -5
  77. package/dist/render/powerline-line1.js.map +0 -1
  78. package/dist/render/powerline-line2.d.ts +0 -5
  79. package/dist/render/powerline-line2.js.map +0 -1
  80. package/dist/render/powerline-line3.d.ts +0 -5
  81. package/dist/render/powerline-line3.js.map +0 -1
  82. package/dist/render/powerline.d.ts +0 -33
  83. package/dist/render/powerline.js.map +0 -1
  84. package/dist/render/quota-projection.d.ts +0 -26
  85. package/dist/render/quota-projection.js.map +0 -1
  86. package/dist/render/shared.d.ts +0 -43
  87. package/dist/render/shared.js.map +0 -1
  88. package/dist/render/text.d.ts +0 -5
  89. package/dist/render/text.js.map +0 -1
  90. package/dist/stdin.d.ts +0 -6
  91. package/dist/stdin.js.map +0 -1
  92. package/dist/themes/catppuccin.d.ts +0 -3
  93. package/dist/themes/catppuccin.js.map +0 -1
  94. package/dist/themes/dracula.d.ts +0 -3
  95. package/dist/themes/dracula.js.map +0 -1
  96. package/dist/themes/gruvbox.d.ts +0 -3
  97. package/dist/themes/gruvbox.js.map +0 -1
  98. package/dist/themes/index.d.ts +0 -17
  99. package/dist/themes/index.js.map +0 -1
  100. package/dist/themes/monokai.d.ts +0 -3
  101. package/dist/themes/monokai.js.map +0 -1
  102. package/dist/themes/nord.d.ts +0 -3
  103. package/dist/themes/nord.js.map +0 -1
  104. package/dist/themes/solarized.d.ts +0 -3
  105. package/dist/themes/solarized.js.map +0 -1
  106. package/dist/themes/tokyo-night.d.ts +0 -3
  107. package/dist/themes/tokyo-night.js.map +0 -1
  108. package/dist/themes/types.d.ts +0 -46
  109. package/dist/themes/types.js.map +0 -1
  110. package/dist/themes/util.d.ts +0 -19
  111. package/dist/themes/util.js.map +0 -1
  112. package/dist/themes.d.ts +0 -1
  113. package/dist/themes.js.map +0 -1
  114. package/dist/tui/banner.d.ts +0 -10
  115. package/dist/tui/banner.js.map +0 -1
  116. package/dist/tui/preview.d.ts +0 -18
  117. package/dist/tui/preview.js.map +0 -1
  118. package/dist/tui/select.d.ts +0 -32
  119. package/dist/tui/select.js.map +0 -1
  120. package/dist/types.d.ts +0 -340
  121. package/dist/types.js.map +0 -1
  122. package/dist/utils/cache.d.ts +0 -8
  123. package/dist/utils/cache.js.map +0 -1
  124. package/dist/utils/debug.d.ts +0 -26
  125. package/dist/utils/debug.js.map +0 -1
  126. package/dist/utils/exec.d.ts +0 -5
  127. package/dist/utils/exec.js.map +0 -1
  128. package/dist/utils/format.d.ts +0 -4
  129. package/dist/utils/format.js.map +0 -1
  130. package/dist/utils/path.d.ts +0 -34
  131. package/dist/utils/path.js.map +0 -1
  132. package/dist/utils/terminal.d.ts +0 -2
  133. package/dist/utils/terminal.js.map +0 -1
package/README.md CHANGED
@@ -24,7 +24,9 @@ Interactive wizard — preset, theme, icons — previewed live before write.
24
24
  ![Claude Code](https://img.shields.io/badge/Claude_Code-compatible-2d3748?logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCI+PHBhdGggZD0iTTY0IDEyOEMzNS44IDEyOCAxMyAxMDUuMiAxMyA3N0MxMyA0OC44IDM1LjggMjYgNjQgMjZjMjguMiAwIDUxIDIyLjggNTEgNTFzLTIyLjggNTEtNTEgNTF6IiBmaWxsPSIjMjQyNTJGIi8+PC9zdmc+)
25
25
  ![Qwen Code](https://img.shields.io/badge/Qwen_Code-compatible-6156FF)
26
26
 
27
- > 1,700+ downloads in the first month[share your setup](https://github.com/cativo23/lumira/discussions) in Discussions.
27
+ > 3,400+ monthly downloads, zero marketing. Try it for one session `npx lumira install`.
28
+
29
+ > **What's new in v1.5:** the [`lumira stats` CLI](#stats-cli) prints post-session analytics (cost, tokens, cache hit, tool frequency, burn rate). Combined with the `API N%` latency widget (v1.4.0), 7-day quota projection warning (v1.3.0), and auto-compact proximity glyph ⚠ (v1.4.1), recent releases add several diagnostic signals to the session.
28
30
 
29
31
  ## Table of contents
30
32
 
@@ -310,6 +312,68 @@ lumira --icons=nerd|emoji|none # Override icon set
310
312
  lumira --preset=full|balanced|minimal
311
313
  ```
312
314
 
315
+ ## Custom Commands
316
+
317
+ User-defined shell commands rendered as statusline segments on any of the 4 lines. Disabled by default.
318
+
319
+ ### Enable
320
+
321
+ ```bash
322
+ lumira custom enable
323
+ ```
324
+
325
+ ### Configure
326
+
327
+ Add a `customCommands` block to `~/.config/lumira/config.json`:
328
+
329
+ ```json
330
+ {
331
+ "customCommands": {
332
+ "enabled": true,
333
+ "commands": [
334
+ {
335
+ "id": "git-status",
336
+ "command": ["git", "status", "--short"],
337
+ "label": "",
338
+ "line": 1,
339
+ "refreshMs": 5000,
340
+ "onError": "hide"
341
+ }
342
+ ]
343
+ }
344
+ }
345
+ ```
346
+
347
+ **Key fields:**
348
+
349
+ | Field | Description |
350
+ |---|---|
351
+ | `id` | Unique identifier for the command |
352
+ | `command` | Argv array — no shell expansion, pipes, or redirects |
353
+ | `line` | Statusline line to render on (`1`–`4`) |
354
+ | `refreshMs` | Refresh interval in milliseconds (default: `5000`) |
355
+ | `label` | Optional prefix shown before the command output |
356
+ | `color` | Optional color override for the segment |
357
+ | `onError` | What to show on non-zero exit: `hide` (default), `placeholder`, `output`, or `stale` |
358
+ | `onTimeout` | What to show on timeout: same options as `onError`, defaults to `hide` |
359
+ | `timeoutMs` | Max execution time in ms (clamped to 2000) |
360
+ | `maxBytes` | Max stdout bytes captured (clamped to 4096) |
361
+ | `ansi` | Set `true` to pass through ANSI escape sequences from the command |
362
+
363
+ `command` must be an argv array (`["git", "status", "--short"]`). Shell strings with pipes or redirects are not supported — wrap them in a script if needed.
364
+
365
+ Output is cached with a TTL and refreshed in the background, so the hot render path never blocks on subprocess execution.
366
+
367
+ ### CLI subcommands
368
+
369
+ ```bash
370
+ lumira custom list # list configured commands and their status
371
+ lumira custom enable # enable the custom commands feature
372
+ lumira custom disable # disable the custom commands feature
373
+ lumira custom test <id> # run a command immediately and print its output
374
+ lumira custom logs # show recent execution logs
375
+ ```
376
+
313
377
  ## Architecture
314
378
 
315
379
  ```text
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Internal helper for the Custom Command widget (#143). Runs ONE custom
3
+ * command and writes the result into the cache file. Intended to be
4
+ * launched by the renderer as a detached child process so the renderer
5
+ * itself can exit immediately without waiting on the spawned command
6
+ * (B1: the original "fire and forget" via void async-IIFE still kept
7
+ * the Node event loop refed because data listeners hold stdin/stdout
8
+ * streams open until child exit + cache write).
9
+ *
10
+ * Wire protocol: a single JSON object on stdin describing the spawn
11
+ * spec, cache path, and (optional) stdin envelope to forward to the
12
+ * user command:
13
+ *
14
+ * {
15
+ * "id": "k8s-context",
16
+ * "command": ["kubectl", "config", "current-context"],
17
+ * "timeoutMs": 1500,
18
+ * "maxBytes": 256,
19
+ * "env": { ... }, // optional
20
+ * "cwd": "/abs/path", // optional
21
+ * "onError": "hide", // 'hide' | 'placeholder' | 'output' | 'stale'
22
+ * "cachePath": "/abs/path/to/custom-commands.json",
23
+ * "stdin": "{}" // optional
24
+ * }
25
+ *
26
+ * Errors are swallowed — this process must NEVER print to stdout or
27
+ * crash visibly. The renderer doesn't read this process's exit code.
28
+ */
29
+ import { execBg } from '../utils/exec-bg.js';
30
+ import { isAbsolute } from 'node:path';
31
+ import { readCacheFile, writeCacheFile } from '../utils/custom-cache.js';
32
+ function isValidSpec(raw) {
33
+ if (!raw || typeof raw !== 'object')
34
+ return false;
35
+ const s = raw;
36
+ if (typeof s.id !== 'string' || s.id.length === 0)
37
+ return false;
38
+ if (!Array.isArray(s.command) || s.command.length === 0)
39
+ return false;
40
+ if (!s.command.every((x) => typeof x === 'string' && x.length > 0))
41
+ return false;
42
+ if (typeof s.timeoutMs !== 'number' || !Number.isFinite(s.timeoutMs) || s.timeoutMs > 2000)
43
+ return false;
44
+ if (typeof s.maxBytes !== 'number' || !Number.isFinite(s.maxBytes) || s.maxBytes > 4096)
45
+ return false;
46
+ if (typeof s.onError !== 'string')
47
+ return false;
48
+ if (typeof s.cachePath !== 'string' || s.cachePath.length === 0 || !isAbsolute(s.cachePath))
49
+ return false;
50
+ return true;
51
+ }
52
+ /**
53
+ * Read the JSON spec from stdin, run the command via execBg, persist the
54
+ * result. Never throws — every failure path returns silently.
55
+ */
56
+ export async function runCustomRefresh(stdinPayload) {
57
+ let spec;
58
+ try {
59
+ const parsed = JSON.parse(stdinPayload);
60
+ if (!isValidSpec(parsed))
61
+ return;
62
+ spec = parsed;
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ try {
68
+ const result = await execBg({
69
+ command: spec.command,
70
+ timeoutMs: spec.timeoutMs,
71
+ maxBytes: spec.maxBytes,
72
+ env: spec.env,
73
+ cwd: spec.cwd,
74
+ stdin: spec.stdin,
75
+ });
76
+ const now = Date.now();
77
+ let entry;
78
+ switch (result.kind) {
79
+ case 'ok':
80
+ entry = { text: result.stdout, capturedAt: now, state: 'ok' };
81
+ break;
82
+ case 'timeout':
83
+ entry = { text: result.stdout, capturedAt: now, state: 'timeout' };
84
+ break;
85
+ case 'nonzero': {
86
+ // For onError:'output' the renderer shows entry.text directly, so
87
+ // store stdout (partial output before the command failed). For all
88
+ // other strategies the text is not displayed — store stdout anyway
89
+ // so the entry is useful if the strategy is later changed.
90
+ entry = { text: result.stdout, capturedAt: now, state: 'nonzero' };
91
+ break;
92
+ }
93
+ case 'spawn-error':
94
+ entry = { text: '', capturedAt: now, state: 'nonzero' };
95
+ break;
96
+ }
97
+ const current = readCacheFile(spec.cachePath);
98
+ current[spec.id] = entry;
99
+ writeCacheFile(spec.cachePath, current);
100
+ }
101
+ catch {
102
+ /* swallow — helper must never crash visibly */
103
+ }
104
+ }
105
+ /**
106
+ * CLI entrypoint used by the renderer. Reads stdin to EOF, then runs.
107
+ * Wrapped in a Promise so the caller can await before exiting.
108
+ */
109
+ export async function runCustomRefreshFromStdin() {
110
+ const chunks = [];
111
+ try {
112
+ await new Promise((resolve) => {
113
+ process.stdin.on('data', (chunk) => chunks.push(chunk));
114
+ process.stdin.on('end', () => resolve());
115
+ process.stdin.on('error', () => resolve());
116
+ });
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ const payload = Buffer.concat(chunks).toString('utf8');
122
+ await runCustomRefresh(payload);
123
+ }
124
+ //# sourceMappingURL=custom-refresh.js.map
@@ -0,0 +1,280 @@
1
+ /**
2
+ * `lumira custom` subcommand (issue #143 phase 4).
3
+ *
4
+ * Provides a CLI interface for managing the custom commands feature:
5
+ *
6
+ * lumira custom list List configured commands from config file
7
+ * lumira custom enable Set enabled:true in config file
8
+ * lumira custom disable Set enabled:false in config file
9
+ * lumira custom test <id> Run a command once, print output + timing
10
+ * lumira custom logs Show cached outputs from the cache file
11
+ *
12
+ * Design constraints:
13
+ * - No runtime deps beyond Node built-ins.
14
+ * - All FS reads in try/catch — graceful errors, exit 1 on failure.
15
+ * - Color: process.stdout.isTTY ? 'named' : 'none'.
16
+ * - Return type: Promise<{ output: string; exitCode: number }> so the
17
+ * dispatcher can set process.exitCode.
18
+ */
19
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { join, dirname } from 'node:path';
22
+ import { execBg } from '../utils/exec-bg.js';
23
+ import { createColors } from '../render/colors.js';
24
+ import { loadConfig } from '../config.js';
25
+ import { readCacheFile } from '../utils/custom-cache.js';
26
+ // ── constants ──────────────────────────────────────────────────────────────
27
+ const CONFIG_FILE = 'config.json';
28
+ const CONFIG_DIR = join('.config', 'lumira');
29
+ const CACHE_FILE = 'custom-commands.json';
30
+ const CACHE_DIR = join('.cache', 'lumira');
31
+ // ── helpers ────────────────────────────────────────────────────────────────
32
+ function configPath() {
33
+ return join(homedir(), CONFIG_DIR, CONFIG_FILE);
34
+ }
35
+ function cachePath() {
36
+ return join(homedir(), CACHE_DIR, CACHE_FILE);
37
+ }
38
+ function ok(output) {
39
+ return { output, exitCode: 0 };
40
+ }
41
+ function fail(output) {
42
+ return { output, exitCode: 1 };
43
+ }
44
+ /**
45
+ * Read the raw config JSON from disk. Returns an empty object `{}` if the
46
+ * file doesn't exist, throws on malformed JSON so the caller can surface a
47
+ * useful error.
48
+ */
49
+ function readConfigRaw() {
50
+ const p = configPath();
51
+ if (!existsSync(p))
52
+ return {};
53
+ const raw = readFileSync(p, 'utf8');
54
+ const parsed = JSON.parse(raw);
55
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
56
+ return {};
57
+ return parsed;
58
+ }
59
+ /**
60
+ * Write (or create) the config file. Creates the parent directory if needed.
61
+ * The value is always pretty-printed with 2-space indents.
62
+ */
63
+ function writeConfigRaw(value) {
64
+ const p = configPath();
65
+ mkdirSync(dirname(p), { recursive: true });
66
+ writeFileSync(p, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
67
+ }
68
+ /**
69
+ * Extract the `customCommands` block from the raw config. Returns a minimal
70
+ * default when the block is missing or malformed.
71
+ */
72
+ function readCustomCommandsBlock(raw) {
73
+ const cc = raw.customCommands;
74
+ if (!cc || typeof cc !== 'object' || Array.isArray(cc)) {
75
+ return { enabled: false, commands: [] };
76
+ }
77
+ const obj = cc;
78
+ const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : false;
79
+ const commands = Array.isArray(obj.commands) ? obj.commands : [];
80
+ return { enabled, commands };
81
+ }
82
+ // ── color ──────────────────────────────────────────────────────────────────
83
+ /**
84
+ * Only use color when stdout is a real TTY and NO_COLOR is not set.
85
+ * In pipe/test contexts or when NO_COLOR is in env, produces no escape
86
+ * sequences, keeping output clean for programmatic use.
87
+ */
88
+ function makeColors() {
89
+ const noColor = 'NO_COLOR' in process.env || process.env.TERM === 'dumb';
90
+ if (noColor || !process.stdout.isTTY)
91
+ return null;
92
+ return createColors('named');
93
+ }
94
+ // ── subcommands ────────────────────────────────────────────────────────────
95
+ async function cmdEnable() {
96
+ try {
97
+ const raw = readConfigRaw();
98
+ const cc = readCustomCommandsBlock(raw);
99
+ const updated = {
100
+ ...raw,
101
+ customCommands: {
102
+ ...cc,
103
+ enabled: true,
104
+ },
105
+ };
106
+ writeConfigRaw(updated);
107
+ return ok(`Custom commands enabled.\nConfig written to: ${configPath()}\n`);
108
+ }
109
+ catch (e) {
110
+ const msg = e instanceof Error ? e.message : String(e);
111
+ return fail(`lumira custom enable: ${msg}\n`);
112
+ }
113
+ }
114
+ async function cmdDisable() {
115
+ try {
116
+ const raw = readConfigRaw();
117
+ const cc = readCustomCommandsBlock(raw);
118
+ const updated = {
119
+ ...raw,
120
+ customCommands: {
121
+ ...cc,
122
+ enabled: false,
123
+ },
124
+ };
125
+ writeConfigRaw(updated);
126
+ return ok(`Custom commands disabled.\nConfig written to: ${configPath()}\n`);
127
+ }
128
+ catch (e) {
129
+ const msg = e instanceof Error ? e.message : String(e);
130
+ return fail(`lumira custom disable: ${msg}\n`);
131
+ }
132
+ }
133
+ async function cmdList() {
134
+ const c = makeColors();
135
+ let enabled = false;
136
+ let commands = [];
137
+ try {
138
+ const cfg = loadConfig();
139
+ enabled = cfg.customCommands.enabled;
140
+ commands = cfg.customCommands.commands;
141
+ }
142
+ catch {
143
+ // config unreadable — fall through with empty defaults
144
+ }
145
+ const statusLine = enabled
146
+ ? `Custom commands: ${c ? c.green('enabled') : 'enabled'}\n`
147
+ : `Custom commands: ${c ? c.yellow('disabled') : 'disabled'}\n`;
148
+ if (commands.length === 0) {
149
+ return ok(statusLine
150
+ + '\nNo custom commands configured.\n'
151
+ + `Add commands to ${configPath()} under customCommands.commands.\n`);
152
+ }
153
+ // Table: id | line | refresh | cmd
154
+ const header = `${'id'.padEnd(20)} ${'line'.padEnd(6)} ${'refresh'.padEnd(10)} cmd`;
155
+ const sep = '-'.repeat(header.length);
156
+ const rows = commands.map(cmd => {
157
+ const id = cmd.id.padEnd(20);
158
+ const line = String(cmd.line).padEnd(6);
159
+ const refresh = `${cmd.refreshMs}ms`.padEnd(10);
160
+ const cmdStr = cmd.command.join(' ');
161
+ return `${id} ${line} ${refresh} ${cmdStr}`;
162
+ });
163
+ const table = [header, sep, ...rows].join('\n');
164
+ return ok(`${statusLine}\n${table}\n`);
165
+ }
166
+ async function cmdTest(id) {
167
+ if (!id) {
168
+ return fail('lumira custom test: missing command id.\n\n'
169
+ + 'Usage: lumira custom test <id>\n'
170
+ + "Use 'lumira custom list' to see configured command ids.\n");
171
+ }
172
+ let commands;
173
+ try {
174
+ commands = loadConfig().customCommands.commands;
175
+ }
176
+ catch (e) {
177
+ const msg = e instanceof Error ? e.message : String(e);
178
+ return fail(`lumira custom test: could not read config: ${msg}\n`);
179
+ }
180
+ const cmd = commands.find(c => c.id === id);
181
+ if (!cmd) {
182
+ const knownIds = commands.map(c => c.id).join(', ');
183
+ return fail(`lumira custom test: command id "${id}" not found.\n`
184
+ + (knownIds ? `Known ids: ${knownIds}\n` : 'No commands configured.\n'));
185
+ }
186
+ const result = await execBg({
187
+ command: cmd.command,
188
+ timeoutMs: cmd.timeoutMs,
189
+ maxBytes: cmd.maxBytes,
190
+ });
191
+ const lines = [
192
+ `Command: ${cmd.command.join(' ')}`,
193
+ `Duration: ${result.durationMs}ms`,
194
+ ];
195
+ if (result.kind === 'ok') {
196
+ lines.push(`Exit: 0 (ok)`);
197
+ lines.push(`Output:\n${result.stdout || '(empty)'}`);
198
+ }
199
+ else if (result.kind === 'nonzero') {
200
+ lines.push(`Exit: ${result.exitCode} (nonzero)`);
201
+ if (result.stdout)
202
+ lines.push(`Stdout:\n${result.stdout}`);
203
+ if (result.stderr)
204
+ lines.push(`Stderr:\n${result.stderr}`);
205
+ }
206
+ else if (result.kind === 'timeout') {
207
+ lines.push(`Exit: timeout (killed after ${cmd.timeoutMs}ms)`);
208
+ if (result.stdout)
209
+ lines.push(`Stdout (partial):\n${result.stdout}`);
210
+ }
211
+ else {
212
+ // spawn-error
213
+ lines.push(`Exit: spawn-error — ${result.message}`);
214
+ }
215
+ return ok(lines.join('\n') + '\n');
216
+ }
217
+ async function cmdLogs() {
218
+ const p = cachePath();
219
+ const cacheData = readCacheFile(p);
220
+ const entries = Object.entries(cacheData);
221
+ if (entries.length === 0) {
222
+ return ok(`No cache file found at ${p}.\n`
223
+ + "Run lumira once with custom commands enabled to populate the cache.\n");
224
+ }
225
+ const lines = [`Cache: ${p}`, ''];
226
+ for (const [id, entry] of entries) {
227
+ const { text, capturedAt, state } = entry;
228
+ const dateStr = capturedAt > 0
229
+ ? new Date(capturedAt).toLocaleString()
230
+ : 'unknown';
231
+ const truncated = text.length > 100 ? text.slice(0, 100) + '…' : text;
232
+ lines.push(`id: ${id}`);
233
+ lines.push(` state: ${state}`);
234
+ lines.push(` capturedAt: ${dateStr}`);
235
+ lines.push(` text: ${truncated || '(empty)'}`);
236
+ lines.push('');
237
+ }
238
+ return ok(lines.join('\n'));
239
+ }
240
+ function helpText() {
241
+ return [
242
+ 'Usage: lumira custom <subcommand>',
243
+ '',
244
+ 'Subcommands:',
245
+ ' list List configured custom commands',
246
+ ' enable Enable custom commands in config',
247
+ ' disable Disable custom commands in config',
248
+ ' test <id> Run a command once and print output + timing',
249
+ ' logs Show cached command outputs',
250
+ '',
251
+ ].join('\n');
252
+ }
253
+ // ── entry point ────────────────────────────────────────────────────────────
254
+ /**
255
+ * Execute `lumira custom [subcommand] [...args]`.
256
+ *
257
+ * argv is the full process.argv; 'custom' starts at argv[2], the subcommand
258
+ * at argv[3], additional arguments from argv[4] onward.
259
+ *
260
+ * Returns `{ output, exitCode }` — the dispatcher writes output to stdout and
261
+ * sets process.exitCode from the returned value.
262
+ */
263
+ export async function runCustomCommand(argv) {
264
+ const sub = argv[3];
265
+ switch (sub) {
266
+ case 'enable':
267
+ return cmdEnable();
268
+ case 'disable':
269
+ return cmdDisable();
270
+ case 'list':
271
+ return cmdList();
272
+ case 'test':
273
+ return cmdTest(argv[4]);
274
+ case 'logs':
275
+ return cmdLogs();
276
+ default:
277
+ return fail(helpText());
278
+ }
279
+ }
280
+ //# sourceMappingURL=custom.js.map
package/dist/config.js CHANGED
@@ -1,7 +1,42 @@
1
1
  import { readFileSync, existsSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
2
+ import { join, dirname, isAbsolute } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { DEFAULT_CONFIG, DEFAULT_DISPLAY, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_CONTEXT_CRITICAL_THRESHOLD, POWERLINE_STYLE_NAMES } from './types.js';
4
+ import { DEFAULT_CONFIG, DEFAULT_DISPLAY, DEFAULT_CONTEXT_WARNING_THRESHOLD, DEFAULT_CONTEXT_CRITICAL_THRESHOLD, POWERLINE_STYLE_NAMES, CUSTOM_COMMAND_MAX_TIMEOUT_MS, CUSTOM_COMMAND_MAX_BYTES, CUSTOM_COMMAND_MAX_ENV_ENTRIES, CUSTOM_COMMAND_MIN_REFRESH_MS, CUSTOM_COMMAND_MAX_REFRESH_MS, CUSTOM_COMMAND_VALID_LINES, CUSTOM_COMMAND_ERROR_BEHAVIORS, CUSTOM_COMMAND_COLORS, } from './types.js';
5
+ /**
6
+ * Ids we refuse to accept on user-supplied custom commands. Object.prototype
7
+ * lookalikes prevent prototype-pollution-style attacks via the cache map
8
+ * (cache entries are keyed by id; if an attacker can name an entry
9
+ * `__proto__` or `constructor`, lookups against arbitrary objects later in
10
+ * the pipeline could become surprising).
11
+ */
12
+ const RESERVED_ID_NAMES = new Set([
13
+ '__proto__',
14
+ 'prototype',
15
+ 'constructor',
16
+ 'hasOwnProperty',
17
+ 'toString',
18
+ 'valueOf',
19
+ 'isPrototypeOf',
20
+ 'propertyIsEnumerable',
21
+ 'toLocaleString',
22
+ ]);
23
+ /**
24
+ * Reject ids containing path separators or ASCII control characters. Slash
25
+ * and backslash would break cache-map lookups by id (entries are keyed under
26
+ * a single object; nesting via paths is not supported). Control chars in id
27
+ * could corrupt log output / status lines downstream.
28
+ */
29
+ // eslint-disable-next-line no-control-regex
30
+ const DANGEROUS_ID_CHARS = /[\x00-\x1f/\\]/;
31
+ function isValidCustomCommandId(id) {
32
+ if (id.length === 0 || id.length > 64)
33
+ return false;
34
+ if (RESERVED_ID_NAMES.has(id))
35
+ return false;
36
+ if (DANGEROUS_ID_CHARS.test(id))
37
+ return false;
38
+ return true;
39
+ }
5
40
  // Module-level flag: fires the qwen→minimal deprecation warning once per
6
41
  // Node process. Process-scoped by design — tests must run in forked workers
7
42
  // (see vitest.config.ts `pool: 'forks'`). Issue #20.
@@ -10,6 +45,109 @@ let thresholdWarningShown = false;
10
45
  /** Test-only — resets the process-scoped qwenWarningShown flag. Do not call in production. */
11
46
  export function _resetMigrationFlags() { qwenWarningShown = false; thresholdWarningShown = false; }
12
47
  const clampPct = (n) => Math.max(0, Math.min(100, n));
48
+ const clampInt = (n, min, max) => {
49
+ const i = Math.trunc(n);
50
+ return Math.max(min, Math.min(max, i));
51
+ };
52
+ /**
53
+ * Parse and validate the `customCommands` config block (issue #143).
54
+ * Drops invalid commands silently, clamps numerics to documented bounds,
55
+ * defaults missing optional fields, and preserves first-occurrence on
56
+ * duplicate `id`. Always returns a fresh object (no shared references).
57
+ */
58
+ function parseCustomCommands(raw) {
59
+ const empty = { enabled: false, commands: [] };
60
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
61
+ return empty;
62
+ const obj = raw;
63
+ const enabled = typeof obj.enabled === 'boolean' ? obj.enabled : false;
64
+ if (!Array.isArray(obj.commands))
65
+ return { enabled, commands: [] };
66
+ const seenIds = new Set();
67
+ const commands = [];
68
+ for (const entry of obj.commands) {
69
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry))
70
+ continue;
71
+ const e = entry;
72
+ // id — non-empty string, unique, no path separators / control chars /
73
+ // reserved Object.prototype names. Caps length at 64 to prevent absurd
74
+ // ids from blowing up log lines or cache files.
75
+ if (typeof e.id !== 'string' || !isValidCustomCommandId(e.id))
76
+ continue;
77
+ if (seenIds.has(e.id))
78
+ continue;
79
+ // command — non-empty array of non-empty strings (no shell-string form)
80
+ if (!Array.isArray(e.command) || e.command.length === 0)
81
+ continue;
82
+ if (!e.command.every((s) => typeof s === 'string' && s.length > 0))
83
+ continue;
84
+ // line — must be one of {1,2,3,4}
85
+ if (typeof e.line !== 'number' || !CUSTOM_COMMAND_VALID_LINES.includes(e.line))
86
+ continue;
87
+ // From here on the entry is valid; default optional fields.
88
+ const refreshMs = typeof e.refreshMs === 'number' && Number.isFinite(e.refreshMs)
89
+ ? clampInt(e.refreshMs, CUSTOM_COMMAND_MIN_REFRESH_MS, CUSTOM_COMMAND_MAX_REFRESH_MS)
90
+ : 5000;
91
+ const timeoutMs = typeof e.timeoutMs === 'number' && Number.isFinite(e.timeoutMs)
92
+ ? clampInt(e.timeoutMs, 100, CUSTOM_COMMAND_MAX_TIMEOUT_MS)
93
+ : 1500;
94
+ const maxBytes = typeof e.maxBytes === 'number' && Number.isFinite(e.maxBytes)
95
+ ? clampInt(e.maxBytes, 16, CUSTOM_COMMAND_MAX_BYTES)
96
+ : 256;
97
+ // Cast AFTER the membership guard, not before — casting unknown→typed
98
+ // up front inverts the type-narrowing the guard exists to provide.
99
+ const rawOnError = e.onError;
100
+ const onError = typeof rawOnError === 'string' && CUSTOM_COMMAND_ERROR_BEHAVIORS.includes(rawOnError)
101
+ ? rawOnError
102
+ : 'hide';
103
+ const rawOnTimeout = e.onTimeout;
104
+ const onTimeout = typeof rawOnTimeout === 'string' && CUSTOM_COMMAND_ERROR_BEHAVIORS.includes(rawOnTimeout)
105
+ ? rawOnTimeout
106
+ : 'stale';
107
+ const ansi = typeof e.ansi === 'boolean' ? e.ansi : false;
108
+ const cmd = {
109
+ id: e.id,
110
+ command: e.command.slice(),
111
+ line: e.line,
112
+ refreshMs,
113
+ timeoutMs,
114
+ maxBytes,
115
+ onError,
116
+ onTimeout,
117
+ ansi,
118
+ };
119
+ if (typeof e.label === 'string')
120
+ cmd.label = e.label;
121
+ // cwd — must be an absolute path. Relative paths like '../../../etc'
122
+ // would silently escape the renderer's cwd; drop them to fall back to
123
+ // process.cwd() instead of accepting hostile relative input.
124
+ if (typeof e.cwd === 'string' && isAbsolute(e.cwd))
125
+ cmd.cwd = e.cwd;
126
+ if (typeof e.color === 'string' && CUSTOM_COMMAND_COLORS.includes(e.color)) {
127
+ cmd.color = e.color;
128
+ }
129
+ // env — record of string→string, truncated to CUSTOM_COMMAND_MAX_ENV_ENTRIES
130
+ if (e.env && typeof e.env === 'object' && !Array.isArray(e.env)) {
131
+ const envOut = {};
132
+ let count = 0;
133
+ for (const [k, v] of Object.entries(e.env)) {
134
+ if (count >= CUSTOM_COMMAND_MAX_ENV_ENTRIES)
135
+ break;
136
+ if (typeof k !== 'string' || k.length === 0)
137
+ continue;
138
+ if (typeof v !== 'string')
139
+ continue;
140
+ envOut[k] = v;
141
+ count++;
142
+ }
143
+ if (count > 0)
144
+ cmd.env = envOut;
145
+ }
146
+ seenIds.add(cmd.id);
147
+ commands.push(cmd);
148
+ }
149
+ return { enabled, commands };
150
+ }
13
151
  /**
14
152
  * Validate context-bar threshold pair. Clamps each to [0, 100]. If `warning`
15
153
  * is not strictly less than `critical` after clamping, emits a one-shot warn
@@ -37,15 +175,15 @@ function resolveThresholds(rawWarn, rawCrit) {
37
175
  export function loadConfig(configDir = join(homedir(), '.config', 'lumira')) {
38
176
  const p = join(configDir, 'config.json');
39
177
  if (!existsSync(p))
40
- return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY } };
178
+ return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY }, customCommands: { enabled: false, commands: [] } };
41
179
  try {
42
180
  const raw = JSON.parse(readFileSync(p, 'utf8'));
43
181
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
44
- return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY } };
182
+ return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY }, customCommands: { enabled: false, commands: [] } };
45
183
  return mergeConfig(raw);
46
184
  }
47
185
  catch {
48
- return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY } };
186
+ return { ...DEFAULT_CONFIG, display: { ...DEFAULT_DISPLAY }, customCommands: { enabled: false, commands: [] } };
49
187
  }
50
188
  }
51
189
  function mergeConfig(rawIn) {
@@ -64,7 +202,13 @@ function mergeConfig(rawIn) {
64
202
  if (['auto', 'named', '256', 'truecolor'].includes(m))
65
203
  colors.mode = m;
66
204
  }
67
- const result = { layout, gsd: typeof raw.gsd === 'boolean' ? raw.gsd : DEFAULT_CONFIG.gsd, display: { ...DEFAULT_DISPLAY }, colors };
205
+ const result = {
206
+ layout,
207
+ gsd: typeof raw.gsd === 'boolean' ? raw.gsd : DEFAULT_CONFIG.gsd,
208
+ display: { ...DEFAULT_DISPLAY },
209
+ colors,
210
+ customCommands: parseCustomCommands(raw.customCommands),
211
+ };
68
212
  // Apply preset FIRST (sets layout + display defaults)
69
213
  const validPresets = ['full', 'balanced', 'minimal'];
70
214
  if (validPresets.includes(raw.preset))