@zenera/cli 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +239 -0
  3. package/dist/args.d.ts +40 -0
  4. package/dist/args.js +99 -0
  5. package/dist/audit.d.ts +53 -0
  6. package/dist/audit.js +144 -0
  7. package/dist/banner.d.ts +13 -0
  8. package/dist/banner.js +103 -0
  9. package/dist/command.d.ts +14 -0
  10. package/dist/command.js +12 -0
  11. package/dist/commands/check.d.ts +3 -0
  12. package/dist/commands/check.js +287 -0
  13. package/dist/commands/index.d.ts +22 -0
  14. package/dist/commands/index.js +56 -0
  15. package/dist/commands/init.d.ts +3 -0
  16. package/dist/commands/init.js +157 -0
  17. package/dist/commands/inspect.d.ts +3 -0
  18. package/dist/commands/inspect.js +158 -0
  19. package/dist/commands/key.d.ts +3 -0
  20. package/dist/commands/key.js +335 -0
  21. package/dist/commands/list.d.ts +3 -0
  22. package/dist/commands/list.js +101 -0
  23. package/dist/commands/models.d.ts +9 -0
  24. package/dist/commands/models.js +120 -0
  25. package/dist/commands/open.d.ts +9 -0
  26. package/dist/commands/open.js +270 -0
  27. package/dist/commands/run.d.ts +3 -0
  28. package/dist/commands/run.js +167 -0
  29. package/dist/commands/sandbox.d.ts +3 -0
  30. package/dist/commands/sandbox.js +112 -0
  31. package/dist/commands/version.d.ts +6 -0
  32. package/dist/commands/version.js +39 -0
  33. package/dist/engine.d.ts +49 -0
  34. package/dist/engine.js +208 -0
  35. package/dist/external.d.ts +10 -0
  36. package/dist/external.js +56 -0
  37. package/dist/home.d.ts +31 -0
  38. package/dist/home.js +108 -0
  39. package/dist/ids.d.ts +12 -0
  40. package/dist/ids.js +44 -0
  41. package/dist/keys.d.ts +124 -0
  42. package/dist/keys.js +309 -0
  43. package/dist/lib.d.ts +9 -0
  44. package/dist/lib.js +31 -0
  45. package/dist/liveness.d.ts +23 -0
  46. package/dist/liveness.js +221 -0
  47. package/dist/main.d.ts +3 -0
  48. package/dist/main.js +155 -0
  49. package/dist/narrate.d.ts +19 -0
  50. package/dist/narrate.js +124 -0
  51. package/dist/podman.d.ts +46 -0
  52. package/dist/podman.js +254 -0
  53. package/dist/projects.d.ts +70 -0
  54. package/dist/projects.js +232 -0
  55. package/dist/resolve.d.ts +27 -0
  56. package/dist/resolve.js +138 -0
  57. package/dist/sandbox.d.ts +36 -0
  58. package/dist/sandbox.js +104 -0
  59. package/dist/scaffold.d.ts +29 -0
  60. package/dist/scaffold.js +220 -0
  61. package/dist/session.d.ts +77 -0
  62. package/dist/session.js +156 -0
  63. package/dist/term.d.ts +69 -0
  64. package/dist/term.js +242 -0
  65. package/dist/tui/app.d.ts +8 -0
  66. package/dist/tui/app.js +257 -0
  67. package/dist/tui/theme.d.ts +23 -0
  68. package/dist/tui/theme.js +134 -0
  69. package/dist/tui/wrap.d.ts +12 -0
  70. package/dist/tui/wrap.js +62 -0
  71. package/dist/validate.d.ts +145 -0
  72. package/dist/validate.js +959 -0
  73. package/package.json +76 -0
  74. package/templates/.github/copilot-instructions.md +1579 -0
  75. package/templates/.github/prompts/new-agent.prompt.md +38 -0
  76. package/templates/.github/prompts/new-skill.prompt.md +37 -0
  77. package/templates/.github/prompts/review-project.prompt.md +31 -0
  78. package/templates/.github/skills/zen-cli/SKILL.md +110 -0
@@ -0,0 +1,257 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { TextInput } from '@inkjs/ui';
3
+ import { Box, Static, Text, useApp, useInput, useStdout } from 'ink';
4
+ import { pathToFileURL } from 'node:url';
5
+ import React, { useCallback, useContext, useRef, useState } from 'react';
6
+ import { isCheckpoint, turns, zeroUsage } from '@zenera/neo';
7
+ import * as Engine from "../engine.js";
8
+ import { format } from "../narrate.js";
9
+ import { display } from "../session.js";
10
+ import { CliError } from "../term.js";
11
+ import { resolveTheme, THEMES } from "./theme.js";
12
+ import { windowOf } from "./wrap.js";
13
+ const BANNER = { key: 'banner' };
14
+ const isBanner = (item) => item.key === 'banner';
15
+ const MARK = {
16
+ you: '›',
17
+ agent: ' ',
18
+ tool: '·',
19
+ note: ' ',
20
+ error: '!',
21
+ };
22
+ // The theme is decided once, before the first frame, and never changes while
23
+ // the app is up — a terminal does not repaint its own scheme underneath us.
24
+ // A context rather than props only because every part of the view wants it.
25
+ const ThemeContext = React.createContext(THEMES.dark);
26
+ const useTheme = () => useContext(ThemeContext);
27
+ function Row({ line }) {
28
+ const style = useTheme().line[line.kind];
29
+ return (_jsxs(Box, { flexDirection: "row", marginTop: line.kind === 'you' ? 1 : 0, children: [_jsxs(Text, { color: style.color, dimColor: style.dim, children: [MARK[line.kind], ' '] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: style.color, dimColor: style.dim, bold: line.kind === 'you', children: line.text }), line.detail ? _jsx(Text, { dimColor: true, children: line.detail }) : null] })] }));
30
+ }
31
+ function App({ engine, options, theme }) {
32
+ const { exit } = useApp();
33
+ const { stdout } = useStdout();
34
+ const [lines, setLines] = useState([]);
35
+ const [live, setLive] = useState('');
36
+ const [thinking, setThinking] = useState('');
37
+ const [busy, setBusy] = useState(false);
38
+ const [agent, setAgent] = useState(engine.state?.agentName ?? engine.project.entry);
39
+ const [stats, setStats] = useState({
40
+ session: engine.state?.usage ?? zeroUsage(),
41
+ calls: engine.state ? turns(engine.state) : 0,
42
+ });
43
+ const [tool, setTool] = useState(undefined);
44
+ const stopping = useRef(undefined);
45
+ const seq = useRef(0);
46
+ // Read during render so a resize, which re-renders the root, resizes the
47
+ // windows below with it. The two streaming blocks share one budget: what
48
+ // is left of the terminal once the chrome has had its rows.
49
+ const rows = stdout?.rows ?? 24;
50
+ const columns = stdout?.columns ?? 80;
51
+ const budget = Math.max(2, rows - CHROME_ROWS);
52
+ const thinkingRows = thinking ? Math.min(THINKING_ROWS, Math.max(1, budget - 2)) : 0;
53
+ const liveRows = Math.max(1, budget - thinkingRows);
54
+ const push = useCallback((kind, text, detail) => {
55
+ setLines((prev) => [...prev, { key: `${seq.current++}`, kind, text, detail }]);
56
+ }, []);
57
+ // Deltas arrive far faster than a terminal can usefully redraw, so text is
58
+ // accumulated in one string and React coalesces the repaints. The finished
59
+ // answer replaces it in one piece when the turn lands.
60
+ //
61
+ // Reasoning is accumulated the same way but never enters `lines`: it is a
62
+ // progress indicator, not part of the conversation. The full chain is in
63
+ // the trajectory (`LlmCallNode.thinking`) and the run's report, so nothing
64
+ // is lost when it is cleared at the start of the next model call.
65
+ const onEvent = useCallback((event) => {
66
+ if (!isCheckpoint(event)) {
67
+ if (event.type === 'text_delta') {
68
+ setLive((prev) => prev + event.delta);
69
+ }
70
+ else if (event.type === 'thinking_delta') {
71
+ setThinking((prev) => prev + event.delta);
72
+ }
73
+ return;
74
+ }
75
+ switch (event.type) {
76
+ case 'before_llm_call':
77
+ setThinking('');
78
+ break;
79
+ case 'before_tool_call':
80
+ setTool(event.call.name);
81
+ break;
82
+ case 'after_tool_call':
83
+ setTool(undefined);
84
+ push('tool', event.node.name, event.node.isError ? 'failed' : durationOf(event.node.durationMs));
85
+ break;
86
+ case 'handoff':
87
+ setAgent(event.to);
88
+ push('note', `→ ${event.to}`, `handed off from ${event.from}`);
89
+ break;
90
+ case 'before_fork':
91
+ push('note', `⑂ ${event.node.branches.map((b) => b.name).join(', ')}`);
92
+ break;
93
+ case 'branch_finished':
94
+ push('tool', `⑂ ${event.child.name}`, event.status);
95
+ break;
96
+ default:
97
+ break;
98
+ }
99
+ }, [push]);
100
+ const submit = useCallback((value) => {
101
+ const text = value.trim();
102
+ if (!text || busy) {
103
+ return;
104
+ }
105
+ if (text === '/exit' || text === '/quit') {
106
+ exit();
107
+ return;
108
+ }
109
+ if (text === '/clear') {
110
+ setLines([]);
111
+ stdout?.write('\u001b[2J\u001b[H');
112
+ return;
113
+ }
114
+ push('you', text);
115
+ setBusy(true);
116
+ setLive('');
117
+ setThinking('');
118
+ const controller = new AbortController();
119
+ stopping.current = controller;
120
+ void (async () => {
121
+ try {
122
+ const outcome = await Engine.run(engine, text, onEvent, controller.signal);
123
+ push('agent', outcome.text);
124
+ // The previous total is the only thing that can say what
125
+ // this turn cost, and reading it out of the updater is what
126
+ // makes that true regardless of when the turn lands.
127
+ setStats((prev) => ({
128
+ session: outcome.result.usage,
129
+ turn: since(prev.session, outcome.result.usage),
130
+ calls: turns(outcome.result.state),
131
+ durationMs: outcome.durationMs,
132
+ }));
133
+ setAgent(outcome.result.agent);
134
+ if (outcome.result.stopReason === 'aborted') {
135
+ push('note', 'stopped');
136
+ }
137
+ if (outcome.report) {
138
+ push('note', `↗ report ${pathToFileURL(outcome.report).href}`);
139
+ }
140
+ }
141
+ catch (err) {
142
+ const hint = err instanceof CliError ? err.hint : undefined;
143
+ push('error', err instanceof Error ? err.message : String(err), hint);
144
+ }
145
+ finally {
146
+ setBusy(false);
147
+ setLive('');
148
+ setThinking('');
149
+ setTool(undefined);
150
+ stopping.current = undefined;
151
+ }
152
+ })();
153
+ }, [busy, engine, exit, onEvent, push, stdout]);
154
+ // Escape stops the turn; ctrl-c leaves. They are different things, and a
155
+ // run that is asked to stop still writes its state, so the session survives
156
+ // either one.
157
+ useInput((_input, keys) => {
158
+ if (keys.escape && stopping.current) {
159
+ stopping.current.abort();
160
+ }
161
+ if (keys.ctrl && _input === 'c') {
162
+ stopping.current?.abort();
163
+ exit();
164
+ }
165
+ });
166
+ return (_jsx(ThemeContext.Provider, { value: theme, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: [BANNER, ...lines], children: (item) => isBanner(item) ? (_jsx(Header, { engine: engine, readOnly: options.readOnly }, item.key)) : (_jsx(Row, { line: item }, item.key)) }), thinking ? (_jsx(Thinking, { text: thinking, columns: columns, rows: thinkingRows })) : null, live ? _jsx(Live, { text: live, columns: columns, rows: liveRows }) : null, _jsx(Footer, { agent: agent, busy: busy, tool: tool, stats: stats, thinking: Boolean(thinking) }), busy ? null : (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: "\u203A " }), _jsx(TextInput, { placeholder: "Ask something\u2026 (/exit to leave)", onSubmit: submit })] }))] }) }));
167
+ }
168
+ function Header({ engine, readOnly, }) {
169
+ const theme = useTheme();
170
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: engine.name }), _jsxs(Text, { dimColor: true, children: [" ", engine.session.id] }), readOnly ? _jsx(Text, { color: theme.warn, children: " read-only" }) : null] }), _jsx(Text, { dimColor: true, children: display(engine.workspace) })] }));
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // The repainting frame
174
+ //
175
+ // Everything below `Static` is redrawn on every event, and it has one hard
176
+ // constraint: **it must never be taller than the terminal.** Ink erases the
177
+ // previous frame by moving the cursor up over it, which only works while that
178
+ // frame is still on screen. A frame that outgrows the viewport scrolls its own
179
+ // top away, the erase falls short, and every repaint strands another copy of
180
+ // its first line in the scrollback — the same line, over and over, with the
181
+ // text creeping sideways as the stream advances.
182
+ //
183
+ // The unit that matters here is the **row the terminal draws**, not the line
184
+ // the model wrote. A reasoning stream is one enormous paragraph with almost no
185
+ // newlines in it, so counting `\n` says "six lines" while the terminal draws
186
+ // sixty. So `windowOf` wraps the text itself, to a width it knows, and takes
187
+ // the last N wrapped rows — and then the same number is given again as an
188
+ // explicit `height` with `overflow="hidden"`, so a miscount clips instead of
189
+ // corrupting.
190
+ //
191
+ // Nothing is lost by any of it: the finished answer lands in `Static` whole,
192
+ // and the full reasoning chain is in the trajectory and the run's report.
193
+ // ---------------------------------------------------------------------------
194
+ /** How much of the reasoning stream is worth showing. It is a progress bar. */
195
+ const THINKING_ROWS = 6;
196
+ /** The two footer rows, its margin, the prompt, and a row in hand. */
197
+ const CHROME_ROWS = 6;
198
+ /** The gutter every streaming block is indented behind. */
199
+ const GUTTER = 2;
200
+ function Thinking({ text, columns, rows }) {
201
+ const theme = useTheme();
202
+ const shown = windowOf(text, columns - GUTTER, rows);
203
+ return (_jsxs(Box, { flexDirection: "row", height: shown.length, overflow: "hidden", children: [_jsx(Box, { flexDirection: "column", width: GUTTER, children: shown.map((_, i) => (_jsx(Text, { color: theme.rule, dimColor: true, children: i === 0 ? '◇ ' : ' ' }, i))) }), _jsx(Box, { flexDirection: "column", children: shown.map((row, i) => (_jsx(Text, { dimColor: true, italic: true, wrap: "truncate-end", children: row }, i))) })] }));
204
+ }
205
+ function Live({ text, columns, rows }) {
206
+ const shown = windowOf(text, columns - GUTTER, rows);
207
+ return (_jsx(Box, { flexDirection: "column", paddingLeft: GUTTER, height: shown.length, overflow: "hidden", children: shown.map((row, i) => (_jsx(Text, { wrap: "truncate-end", children: row }, i))) }));
208
+ }
209
+ function Footer({ agent, busy, tool, stats, thinking, }) {
210
+ const what = tool ? `running ${tool}` : thinking ? 'reasoning' : 'thinking';
211
+ const theme = useTheme();
212
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { children: [busy ? _jsxs(Text, { color: theme.warn, children: [what, "\u2026 "] }) : null, _jsx(Text, { color: theme.accent, dimColor: true, children: agent }), stats.turn ? (_jsxs(Text, { dimColor: true, children: [' turn ', tokens(stats.turn), stats.durationMs === undefined
213
+ ? ''
214
+ : ` · ${durationOf(stats.durationMs) ?? ''}`] })) : null, busy ? _jsx(Text, { dimColor: true, children: ' esc to stop' }) : null] }), _jsxs(Text, { dimColor: true, children: ['session ', tokens(stats.session), stats.calls ? ` · ${stats.calls} ${stats.calls === 1 ? 'call' : 'calls'}` : ''] })] }));
215
+ }
216
+ /**
217
+ * Cache and reasoning are subsets of the numbers beside them, not additions to
218
+ * them, and they are only worth the width when a provider actually reports one
219
+ * — most do not, and a row of zeroes teaches nobody anything.
220
+ */
221
+ function tokens(usage) {
222
+ const parts = [`${format(usage.inputTokens)} in`];
223
+ if (usage.cachedInputTokens) {
224
+ parts.push(`${format(usage.cachedInputTokens)} cached`);
225
+ }
226
+ parts.push(`${format(usage.outputTokens)} out`);
227
+ if (usage.reasoningTokens) {
228
+ parts.push(`${format(usage.reasoningTokens)} thinking`);
229
+ }
230
+ return parts.join(' · ');
231
+ }
232
+ /** What the last turn added. Usage only ever grows, so a subtraction is safe. */
233
+ function since(before, after) {
234
+ return {
235
+ inputTokens: after.inputTokens - before.inputTokens,
236
+ cachedInputTokens: after.cachedInputTokens - before.cachedInputTokens,
237
+ outputTokens: after.outputTokens - before.outputTokens,
238
+ reasoningTokens: after.reasoningTokens - before.reasoningTokens,
239
+ };
240
+ }
241
+ function durationOf(ms) {
242
+ return ms === undefined ? undefined : ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
243
+ }
244
+ // ---------------------------------------------------------------------------
245
+ export async function start(engine, options) {
246
+ // Asked before Ink takes the terminal: the query talks to stdin directly,
247
+ // and there is exactly one moment when nothing else is holding it.
248
+ const theme = await resolveTheme(options.theme);
249
+ const { render } = await import('ink');
250
+ // Ctrl-C is handled above so an in-flight turn can be aborted and recorded
251
+ // rather than the process simply vanishing mid-write.
252
+ const instance = render(_jsx(App, { engine: engine, options: options, theme: theme }), {
253
+ exitOnCtrlC: false,
254
+ });
255
+ await instance.waitUntilExit();
256
+ }
257
+ //# sourceMappingURL=app.js.map
@@ -0,0 +1,23 @@
1
+ export type Appearance = 'dark' | 'light';
2
+ /** The roles a line can play in the transcript. */
3
+ export type Kind = 'you' | 'agent' | 'tool' | 'note' | 'error';
4
+ export interface LineStyle {
5
+ /** `undefined` means the terminal's own foreground. */
6
+ readonly color?: string;
7
+ readonly dim?: boolean;
8
+ }
9
+ export interface Theme {
10
+ readonly appearance: Appearance;
11
+ readonly line: Record<Kind, LineStyle>;
12
+ /** Agent name, prompt caret — the one colour the eye is trained to find. */
13
+ readonly accent: string;
14
+ /** Read-only badge, busy label. */
15
+ readonly warn: string;
16
+ /** Gutters and marks: structure, not content. Always drawn dim. */
17
+ readonly rule?: string;
18
+ }
19
+ export declare const THEMES: Record<Appearance, Theme>;
20
+ export type ThemeChoice = Appearance | 'auto';
21
+ export declare function parseChoice(value: string | undefined): ThemeChoice | undefined;
22
+ export declare function resolveTheme(choice?: string): Promise<Theme>;
23
+ //# sourceMappingURL=theme.d.ts.map
@@ -0,0 +1,134 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Light and dark
3
+ //
4
+ // A terminal already has a colour scheme, and it is not ours to replace. The
5
+ // rule here is to name as few colours as possible and to name them by role:
6
+ // the answer is drawn in the terminal's *own* foreground (no colour at all),
7
+ // asides are drawn dim, and only the few things that must stand out —
8
+ // the person's own turn, the agent name, a warning, an error — take a colour.
9
+ //
10
+ // That alone fixes most of it. `white` was the bug: it is legible on exactly
11
+ // one kind of background, and half the world runs the other kind. What is left
12
+ // is the handful of accents that ANSI *does* let a light theme get wrong —
13
+ // `cyan` on paper, `gray` on paper — so those swap.
14
+ //
15
+ // Note what is *not* here: no hex, no 256-colour ramps, no attempt at a brand.
16
+ // A palette that ignores the user's scheme is worse on both schemes than one
17
+ // that mostly defers to it.
18
+ // ---------------------------------------------------------------------------
19
+ const DARK = {
20
+ appearance: 'dark',
21
+ line: {
22
+ you: { color: 'cyan' },
23
+ agent: {},
24
+ tool: { color: 'gray', dim: true },
25
+ note: { color: 'cyan', dim: true },
26
+ error: { color: 'red' },
27
+ },
28
+ accent: 'cyan',
29
+ warn: 'yellow',
30
+ rule: 'gray',
31
+ };
32
+ // On a light background `gray` is bright black — pale grey on white — and
33
+ // `cyan` and `yellow` are barely darker than the paper. Dimmed default
34
+ // foreground and `blue`/`magenta` are the same information, still legible.
35
+ const LIGHT = {
36
+ appearance: 'light',
37
+ line: {
38
+ you: { color: 'blue' },
39
+ agent: {},
40
+ tool: { dim: true },
41
+ note: { color: 'blue', dim: true },
42
+ error: { color: 'red' },
43
+ },
44
+ accent: 'blue',
45
+ warn: 'magenta',
46
+ };
47
+ export const THEMES = { dark: DARK, light: LIGHT };
48
+ export function parseChoice(value) {
49
+ const v = value?.trim().toLowerCase();
50
+ return v === 'dark' || v === 'light' || v === 'auto' ? v : undefined;
51
+ }
52
+ export async function resolveTheme(choice) {
53
+ const asked = parseChoice(choice) ?? parseChoice(process.env['ZENERA_THEME']) ?? 'auto';
54
+ if (asked !== 'auto') {
55
+ return THEMES[asked];
56
+ }
57
+ return THEMES[(await queryBackground()) ?? fromColorFgBg() ?? 'dark'];
58
+ }
59
+ /**
60
+ * `COLORFGBG` is `fg;bg` or `fg;<something>;bg`; the background is the last
61
+ * field. Anything non-numeric (notably `default`) tells us nothing.
62
+ */
63
+ function fromColorFgBg() {
64
+ const parts = process.env['COLORFGBG']?.split(';');
65
+ const bg = parts?.[parts.length - 1];
66
+ if (bg === undefined || !/^\d+$/.test(bg)) {
67
+ return undefined;
68
+ }
69
+ const n = Number(bg);
70
+ return n === 7 || n >= 9 ? 'light' : 'dark';
71
+ }
72
+ const QUERY = '\u001b]11;?\u0007';
73
+ const REPLY = /\u001b\]11;rgb:([\da-f]{1,4})\/([\da-f]{1,4})\/([\da-f]{1,4})/i;
74
+ /**
75
+ * Ask the terminal for its background colour and read the answer off stdin.
76
+ *
77
+ * This is a conversation with a program that may not be listening, so it is
78
+ * bounded on every axis: raw mode is taken and given back, the listener is
79
+ * removed either way, and a terminal that does not answer costs one timeout
80
+ * and nothing else. Anything the user typed in that window is put back, so
81
+ * the first keystroke of a fast start is not eaten by the handshake.
82
+ */
83
+ async function queryBackground(timeoutMs = 120) {
84
+ const { stdin, stdout } = process;
85
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== 'function') {
86
+ return undefined;
87
+ }
88
+ const wasRaw = stdin.isRaw;
89
+ let timer;
90
+ let settled = false;
91
+ let seen = '';
92
+ return await new Promise((resolve) => {
93
+ const finish = (result) => {
94
+ if (settled) {
95
+ return;
96
+ }
97
+ settled = true;
98
+ clearTimeout(timer);
99
+ stdin.off('data', onData);
100
+ stdin.setRawMode(wasRaw);
101
+ if (!wasRaw) {
102
+ stdin.pause();
103
+ }
104
+ const typed = seen.replace(REPLY, '').replace(/\u001b\\|\u0007/g, '');
105
+ if (typed) {
106
+ stdin.unshift(Buffer.from(typed, 'latin1'));
107
+ }
108
+ resolve(result);
109
+ };
110
+ const onData = (chunk) => {
111
+ seen += chunk.toString('latin1');
112
+ const m = REPLY.exec(seen);
113
+ if (m) {
114
+ finish(appearanceOf(m[1], m[2], m[3]));
115
+ }
116
+ else if (seen.length > 256) {
117
+ finish(undefined);
118
+ }
119
+ };
120
+ stdin.setRawMode(true);
121
+ stdin.resume();
122
+ stdin.on('data', onData);
123
+ timer = setTimeout(() => finish(undefined), timeoutMs);
124
+ timer.unref?.();
125
+ stdout.write(QUERY);
126
+ });
127
+ }
128
+ /** Components come back as 1–4 hex digits, so each is scaled by its own width. */
129
+ function appearanceOf(r, g, b) {
130
+ const channel = (hex) => parseInt(hex, 16) / (16 ** hex.length - 1);
131
+ const luminance = 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
132
+ return luminance > 0.5 ? 'light' : 'dark';
133
+ }
134
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The last `rows` rows of `text` once wrapped to `width`, each one short enough
3
+ * that the terminal will not wrap it again.
4
+ *
5
+ * Only the tail is ever wanted, so only the tail is wrapped: `width * rows * 2`
6
+ * characters is more than enough to fill the window whatever the wrapping does,
7
+ * and bounds the work per keystroke on a stream that never stops growing.
8
+ */
9
+ export declare function windowOf(text: string, width: number, rows: number): string[];
10
+ /** Word wrap. Every returned row is at most `width` columns wide. */
11
+ export declare function wrap(text: string, width: number): string[];
12
+ //# sourceMappingURL=wrap.d.ts.map
@@ -0,0 +1,62 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Measuring in rows
3
+ //
4
+ // The unit that matters in a repainting terminal frame is the **row the
5
+ // terminal draws**, not the line the model wrote. A reasoning stream is one
6
+ // enormous paragraph with almost no newlines in it, so counting `\n` says
7
+ // "six lines" while the terminal draws sixty — and a frame that outgrows the
8
+ // viewport is the one thing Ink cannot erase (see tui/app.tsx).
9
+ //
10
+ // So the text is wrapped here, to a width we know, and measured in what comes
11
+ // out. Plain text only: nothing that reaches these two functions has style
12
+ // codes in it, so there is no need to carry the machinery for counting around
13
+ // them.
14
+ // ---------------------------------------------------------------------------
15
+ /**
16
+ * The last `rows` rows of `text` once wrapped to `width`, each one short enough
17
+ * that the terminal will not wrap it again.
18
+ *
19
+ * Only the tail is ever wanted, so only the tail is wrapped: `width * rows * 2`
20
+ * characters is more than enough to fill the window whatever the wrapping does,
21
+ * and bounds the work per keystroke on a stream that never stops growing.
22
+ */
23
+ export function windowOf(text, width, rows) {
24
+ const w = Math.max(8, width);
25
+ const n = Math.max(1, rows);
26
+ const wrapped = wrap(text.slice(-w * n * 2).replace(/\n{2,}/g, '\n'), w);
27
+ return wrapped.slice(-n);
28
+ }
29
+ /** Word wrap. Every returned row is at most `width` columns wide. */
30
+ export function wrap(text, width) {
31
+ const w = Math.max(1, width);
32
+ const out = [];
33
+ for (const paragraph of text.split('\n')) {
34
+ let line = '';
35
+ for (const word of paragraph.split(' ')) {
36
+ // A word wider than the terminal has to be broken somewhere, and
37
+ // anywhere is as good as anywhere else.
38
+ let rest = word;
39
+ while (rest.length > w) {
40
+ if (line) {
41
+ out.push(line);
42
+ line = '';
43
+ }
44
+ out.push(rest.slice(0, w));
45
+ rest = rest.slice(w);
46
+ }
47
+ if (!line) {
48
+ line = rest;
49
+ }
50
+ else if (line.length + 1 + rest.length <= w) {
51
+ line += ` ${rest}`;
52
+ }
53
+ else {
54
+ out.push(line);
55
+ line = rest;
56
+ }
57
+ }
58
+ out.push(line);
59
+ }
60
+ return out;
61
+ }
62
+ //# sourceMappingURL=wrap.js.map
@@ -0,0 +1,145 @@
1
+ import { type AnyTool, type ProjectConfig } from '@zenera/neo';
2
+ import { type DeclaredRole } from './audit.ts';
3
+ import { type KeyStore } from './keys.ts';
4
+ /**
5
+ * `error` — the project will not load, or will not run.
6
+ * `warning` — it loads, and something about it is probably not what was meant.
7
+ * `note` — worth knowing, wrong in no sense at all.
8
+ */
9
+ export type Severity = 'error' | 'warning' | 'note';
10
+ export interface Finding {
11
+ severity: Severity;
12
+ /** stable identifier, e.g. `prompt.missing` — safe to match on */
13
+ code: string;
14
+ /** the config key or path this is about, e.g. `agents.triage.system` */
15
+ where: string;
16
+ message: string;
17
+ /** what to do about it, naming a file or a command */
18
+ fix?: string;
19
+ }
20
+ export interface FileCheck {
21
+ /** relative to the project root */
22
+ path: string;
23
+ /** what the file is for, in words */
24
+ role: string;
25
+ kind: 'file' | 'directory';
26
+ exists: boolean;
27
+ /** the project does not load without it */
28
+ required: boolean;
29
+ bytes?: number;
30
+ /** the config key that named it, when a key did rather than a convention */
31
+ from?: string;
32
+ }
33
+ export interface AgentReport {
34
+ name: string;
35
+ /** the agent a bare `zen run` starts on */
36
+ entry: boolean;
37
+ description?: string;
38
+ /** the model as written, before aliases are resolved */
39
+ model?: string;
40
+ /** where that value came from */
41
+ modelSource: 'agent' | 'project' | 'none';
42
+ /** prompt files, in the order they are concatenated */
43
+ instructions: string[];
44
+ /** `tools:` as written */
45
+ toolSelectors: string[];
46
+ /** what those selectors resolve to */
47
+ tools: string[];
48
+ handoffs: string[];
49
+ skills?: {
50
+ provider: string;
51
+ discovery: string;
52
+ allow?: string[];
53
+ preload?: string[];
54
+ };
55
+ fork?: {
56
+ agents?: string[];
57
+ maxBranches?: number;
58
+ };
59
+ /** true when the agent overrides the project's container */
60
+ ownSandbox: boolean;
61
+ }
62
+ export interface SkillReport {
63
+ name: string;
64
+ description: string;
65
+ /** the SKILL.md, relative to the project root */
66
+ path: string;
67
+ tools?: string[];
68
+ /** other files in the skill folder, which the agent gets as resources */
69
+ resources?: string[];
70
+ /** agents whose binding can see it */
71
+ usedBy: string[];
72
+ }
73
+ export interface ModelReport {
74
+ /** the alias it is declared under, or the reference itself */
75
+ name: string;
76
+ /** what the config declared it for */
77
+ role: DeclaredRole;
78
+ /** the provider it resolves to */
79
+ provider?: string;
80
+ kind?: string;
81
+ /** the variable that would carry the credential */
82
+ env?: string;
83
+ credential: 'present' | 'missing' | 'rejected' | 'unknown';
84
+ detail?: string;
85
+ /** agents that would use it */
86
+ usedBy: string[];
87
+ }
88
+ export interface Report {
89
+ /** no errors — the project loads */
90
+ ok: boolean;
91
+ project: {
92
+ name: string | null;
93
+ root: string;
94
+ /** whether the registry knows this path, i.e. whether `zen list` shows it */
95
+ registered: boolean;
96
+ /** the config file that wins, relative to the root */
97
+ config: string | null;
98
+ /** other config files present, which the loader will ignore */
99
+ shadowed: string[];
100
+ version: number | null;
101
+ entry: string | null;
102
+ };
103
+ files: FileCheck[];
104
+ agents: AgentReport[];
105
+ skills: {
106
+ /** the directories the catalog is built from */
107
+ dirs: string[];
108
+ entries: SkillReport[];
109
+ };
110
+ providers: string[];
111
+ models: ModelReport[];
112
+ sandbox: {
113
+ image: string | null;
114
+ declared: boolean;
115
+ used: boolean;
116
+ };
117
+ findings: Finding[];
118
+ counts: {
119
+ errors: number;
120
+ warnings: number;
121
+ notes: number;
122
+ };
123
+ }
124
+ export interface ValidateOptions {
125
+ /** the project directory */
126
+ dir: string;
127
+ /** the registered name, when there is one */
128
+ name?: string;
129
+ /**
130
+ * Whether the registry knows this path. Passed in rather than looked up,
131
+ * because nothing in this check may depend on `$HOME` being readable: the
132
+ * machine that is not set up yet is the one that most needs the report.
133
+ */
134
+ registered?: boolean;
135
+ /**
136
+ * The keyring, materialised. Given, every model the project names is
137
+ * checked for a credential; omitted, that section says `unknown` and no
138
+ * finding is raised — a project is not invalid because this laptop cannot
139
+ * pay for it.
140
+ */
141
+ keys?: KeyStore;
142
+ }
143
+ export declare function validateProject(opts: ValidateOptions): Promise<Report>;
144
+ export declare function availableTools(root: string, config: ProjectConfig): AnyTool<unknown>[];
145
+ //# sourceMappingURL=validate.d.ts.map