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.
- package/README.md +65 -1
- package/dist/commands/custom-refresh.js +124 -0
- package/dist/commands/custom.js +280 -0
- package/dist/config.js +150 -6
- package/dist/index.js +52 -1
- package/dist/installer.js +1 -0
- package/dist/parsers/custom-commands.js +232 -0
- package/dist/render/index.js +6 -1
- package/dist/render/line1.js +9 -1
- package/dist/render/line2.js +9 -1
- package/dist/render/line3.js +9 -1
- package/dist/render/line4.js +20 -9
- package/dist/render/minimal.js +8 -2
- package/dist/render/powerline-line1.js +19 -3
- package/dist/render/powerline-line2.js +10 -1
- package/dist/render/powerline-line3.js +15 -3
- package/dist/render/shared.js +62 -1
- package/dist/types.js +19 -0
- package/dist/utils/custom-cache.js +77 -0
- package/dist/utils/exec-bg.js +183 -0
- package/package.json +5 -3
- package/dist/commands/stats.d.ts +0 -71
- package/dist/commands/stats.js.map +0 -1
- package/dist/commands/themes.d.ts +0 -31
- package/dist/commands/themes.js.map +0 -1
- package/dist/config.d.ts +0 -12
- package/dist/config.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.js.map +0 -1
- package/dist/installer-wizard.d.ts +0 -21
- package/dist/installer-wizard.js.map +0 -1
- package/dist/installer.d.ts +0 -17
- package/dist/installer.js.map +0 -1
- package/dist/normalize.d.ts +0 -85
- package/dist/normalize.js.map +0 -1
- package/dist/parsers/config-health.d.ts +0 -8
- package/dist/parsers/config-health.js.map +0 -1
- package/dist/parsers/git.d.ts +0 -5
- package/dist/parsers/git.js.map +0 -1
- package/dist/parsers/gsd.d.ts +0 -24
- package/dist/parsers/gsd.js.map +0 -1
- package/dist/parsers/mcp.d.ts +0 -10
- package/dist/parsers/mcp.js.map +0 -1
- package/dist/parsers/memory.d.ts +0 -9
- package/dist/parsers/memory.js.map +0 -1
- package/dist/parsers/subagents.d.ts +0 -82
- package/dist/parsers/subagents.js.map +0 -1
- package/dist/parsers/token-speed.d.ts +0 -9
- package/dist/parsers/token-speed.js.map +0 -1
- package/dist/parsers/transcript-stats.d.ts +0 -41
- package/dist/parsers/transcript-stats.js.map +0 -1
- package/dist/parsers/transcript.d.ts +0 -11
- package/dist/parsers/transcript.js.map +0 -1
- package/dist/render/api-latency.d.ts +0 -22
- package/dist/render/api-latency.js.map +0 -1
- package/dist/render/colors.d.ts +0 -70
- package/dist/render/colors.js.map +0 -1
- package/dist/render/hyperlink.d.ts +0 -4
- package/dist/render/hyperlink.js.map +0 -1
- package/dist/render/icons.d.ts +0 -46
- package/dist/render/icons.js.map +0 -1
- package/dist/render/index.d.ts +0 -2
- package/dist/render/index.js.map +0 -1
- package/dist/render/line1.d.ts +0 -3
- package/dist/render/line1.js.map +0 -1
- package/dist/render/line2.d.ts +0 -4
- package/dist/render/line2.js.map +0 -1
- package/dist/render/line3.d.ts +0 -3
- package/dist/render/line3.js.map +0 -1
- package/dist/render/line4.d.ts +0 -3
- package/dist/render/line4.js.map +0 -1
- package/dist/render/minimal.d.ts +0 -3
- package/dist/render/minimal.js.map +0 -1
- package/dist/render/pace.d.ts +0 -6
- package/dist/render/pace.js.map +0 -1
- package/dist/render/powerline-line1.d.ts +0 -5
- package/dist/render/powerline-line1.js.map +0 -1
- package/dist/render/powerline-line2.d.ts +0 -5
- package/dist/render/powerline-line2.js.map +0 -1
- package/dist/render/powerline-line3.d.ts +0 -5
- package/dist/render/powerline-line3.js.map +0 -1
- package/dist/render/powerline.d.ts +0 -33
- package/dist/render/powerline.js.map +0 -1
- package/dist/render/quota-projection.d.ts +0 -26
- package/dist/render/quota-projection.js.map +0 -1
- package/dist/render/shared.d.ts +0 -43
- package/dist/render/shared.js.map +0 -1
- package/dist/render/text.d.ts +0 -5
- package/dist/render/text.js.map +0 -1
- package/dist/stdin.d.ts +0 -6
- package/dist/stdin.js.map +0 -1
- package/dist/themes/catppuccin.d.ts +0 -3
- package/dist/themes/catppuccin.js.map +0 -1
- package/dist/themes/dracula.d.ts +0 -3
- package/dist/themes/dracula.js.map +0 -1
- package/dist/themes/gruvbox.d.ts +0 -3
- package/dist/themes/gruvbox.js.map +0 -1
- package/dist/themes/index.d.ts +0 -17
- package/dist/themes/index.js.map +0 -1
- package/dist/themes/monokai.d.ts +0 -3
- package/dist/themes/monokai.js.map +0 -1
- package/dist/themes/nord.d.ts +0 -3
- package/dist/themes/nord.js.map +0 -1
- package/dist/themes/solarized.d.ts +0 -3
- package/dist/themes/solarized.js.map +0 -1
- package/dist/themes/tokyo-night.d.ts +0 -3
- package/dist/themes/tokyo-night.js.map +0 -1
- package/dist/themes/types.d.ts +0 -46
- package/dist/themes/types.js.map +0 -1
- package/dist/themes/util.d.ts +0 -19
- package/dist/themes/util.js.map +0 -1
- package/dist/themes.d.ts +0 -1
- package/dist/themes.js.map +0 -1
- package/dist/tui/banner.d.ts +0 -10
- package/dist/tui/banner.js.map +0 -1
- package/dist/tui/preview.d.ts +0 -18
- package/dist/tui/preview.js.map +0 -1
- package/dist/tui/select.d.ts +0 -32
- package/dist/tui/select.js.map +0 -1
- package/dist/types.d.ts +0 -340
- package/dist/types.js.map +0 -1
- package/dist/utils/cache.d.ts +0 -8
- package/dist/utils/cache.js.map +0 -1
- package/dist/utils/debug.d.ts +0 -26
- package/dist/utils/debug.js.map +0 -1
- package/dist/utils/exec.d.ts +0 -5
- package/dist/utils/exec.js.map +0 -1
- package/dist/utils/format.d.ts +0 -4
- package/dist/utils/format.js.map +0 -1
- package/dist/utils/path.d.ts +0 -34
- package/dist/utils/path.js.map +0 -1
- package/dist/utils/terminal.d.ts +0 -2
- 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
|

|
|
25
25
|

|
|
26
26
|
|
|
27
|
-
>
|
|
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 = {
|
|
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))
|