@aitherium/shell-cli 1.1.0 → 1.11.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.
- package/dist/auth.d.ts +13 -4
- package/dist/auth.js +18 -3
- package/dist/client.d.ts +21 -1
- package/dist/client.js +213 -9
- package/dist/command-registry.d.ts +1 -1
- package/dist/command-registry.js +28 -2
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +2068 -48
- package/dist/completions.js +236 -2
- package/dist/config.d.ts +30 -0
- package/dist/config.js +37 -4
- package/dist/crash-reporter.d.ts +22 -0
- package/dist/crash-reporter.js +275 -0
- package/dist/formatters.d.ts +16 -0
- package/dist/formatters.js +280 -0
- package/dist/interactive.d.ts +45 -0
- package/dist/interactive.js +232 -0
- package/dist/main.js +439 -41
- package/dist/mcp-client.d.ts +58 -0
- package/dist/mcp-client.js +202 -0
- package/dist/relay.d.ts +96 -0
- package/dist/relay.js +234 -0
- package/dist/renderer.d.ts +47 -17
- package/dist/renderer.js +506 -164
- package/dist/repl.js +205 -23
- package/dist/session-store.d.ts +30 -5
- package/dist/session-store.js +56 -0
- package/dist/status-banner.d.ts +59 -0
- package/dist/status-banner.js +239 -0
- package/dist/tui/controller.d.ts +3 -0
- package/dist/tui/controller.js +310 -0
- package/dist/tui/repl-tui.d.ts +3 -0
- package/dist/tui/repl-tui.js +898 -0
- package/dist/tui/screen.d.ts +68 -0
- package/dist/tui/screen.js +736 -0
- package/package.json +9 -2
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SSEEvent } from './client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Format an event as a single TRACE-pane line, or null if it isn't a
|
|
4
|
+
* trace-worthy event (answer/token are OUTPUT; heartbeat/keepalive/debug are
|
|
5
|
+
* STATUS-only or silent; tool_call/tool_result are handled by the controller).
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatTrace(event: SSEEvent): string | null;
|
|
8
|
+
/** Short STATUS-bar fragment for an event (the current stage), or null. */
|
|
9
|
+
export declare function formatStatus(event: SSEEvent): string | null;
|
|
10
|
+
/** Extract {agent, model, effort, tier} updates from an event for the status bar. */
|
|
11
|
+
export declare function statusUpdate(event: SSEEvent): {
|
|
12
|
+
agent?: string;
|
|
13
|
+
model?: string;
|
|
14
|
+
effort?: string;
|
|
15
|
+
tier?: string;
|
|
16
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure event → string formatters shared by the renderers.
|
|
3
|
+
*
|
|
4
|
+
* Field paths mirror the working plain renderer (`renderer.ts`) exactly — the
|
|
5
|
+
* pipeline emits NESTED objects (e.g. `data.intent.type`, `data.effort.level`,
|
|
6
|
+
* `data.model_selection.recommended_model`), so a naive `data.intent` renders as
|
|
7
|
+
* "[object Object]". `scalar()` is a last-line guard against that.
|
|
8
|
+
*
|
|
9
|
+
* blessed renders ANSI SGR codes (what chalk emits) inside box content, so the
|
|
10
|
+
* colored strings work unchanged in the TUI.
|
|
11
|
+
*/
|
|
12
|
+
import chalk from 'chalk';
|
|
13
|
+
const dim = chalk.dim;
|
|
14
|
+
/** Coerce a possibly-nested value to a short scalar string (never "[object Object]"). */
|
|
15
|
+
function scalar(v) {
|
|
16
|
+
if (v == null)
|
|
17
|
+
return '';
|
|
18
|
+
if (typeof v === 'object') {
|
|
19
|
+
return String(v.type ?? v.name ?? v.value ?? v.label ?? v.recommended_model ?? v.level ?? '');
|
|
20
|
+
}
|
|
21
|
+
return String(v);
|
|
22
|
+
}
|
|
23
|
+
const clip = (s, n = 70) => (s.length > n ? s.slice(0, n) + '…' : s);
|
|
24
|
+
/**
|
|
25
|
+
* Format an event as a single TRACE-pane line, or null if it isn't a
|
|
26
|
+
* trace-worthy event (answer/token are OUTPUT; heartbeat/keepalive/debug are
|
|
27
|
+
* STATUS-only or silent; tool_call/tool_result are handled by the controller).
|
|
28
|
+
*/
|
|
29
|
+
export function formatTrace(event) {
|
|
30
|
+
const d = event.data || {};
|
|
31
|
+
switch (event.type) {
|
|
32
|
+
case 'classify': {
|
|
33
|
+
const intent = scalar(d.intent?.type ?? d.intent) || '?';
|
|
34
|
+
const effort = scalar(d.effort?.level ?? d.effort) || '?';
|
|
35
|
+
const tier = scalar(d.effort?.tier ?? d.tier);
|
|
36
|
+
const oneshot = d.effort?.oneshot ? ' ⚡oneshot' : '';
|
|
37
|
+
return chalk.magenta(` 📋 ${intent} · E${effort}${tier ? ' · ' + tier : ''}${oneshot}`);
|
|
38
|
+
}
|
|
39
|
+
case 'classify_update': {
|
|
40
|
+
const intent = scalar(d.intent?.type ?? d.intent) || '?';
|
|
41
|
+
const effort = scalar(d.effort?.level ?? d.effort) || '?';
|
|
42
|
+
return chalk.magenta(` 📋 ${intent} · E${effort}` + (d.reason ? dim(` (${scalar(d.reason)})`) : ''));
|
|
43
|
+
}
|
|
44
|
+
case 'model_select': {
|
|
45
|
+
const m = scalar(d.model_selection?.recommended_model ?? d.model ?? d.recommended) || 'auto';
|
|
46
|
+
const tier = scalar(d.model_selection?.tier ?? d.tier);
|
|
47
|
+
return chalk.cyan(` 🎯 ${m}` + (tier ? dim(` (${tier})`) : ''));
|
|
48
|
+
}
|
|
49
|
+
case 'think_start': {
|
|
50
|
+
const eff = scalar(d.effort_level ?? d.effort) || '?';
|
|
51
|
+
const reasoning = d.use_reasoning ? 'reasoning' : 'standard';
|
|
52
|
+
return dim(` ⚙ think E${eff} ${reasoning}` + (d.model ? ` · ${scalar(d.model)}` : ''));
|
|
53
|
+
}
|
|
54
|
+
case 'context_start': {
|
|
55
|
+
const src = Array.isArray(d.sources) ? d.sources.join(', ') : '';
|
|
56
|
+
return dim(` 📦 gathering${src ? ' ' + clip(src, 40) : ' context'}…`);
|
|
57
|
+
}
|
|
58
|
+
case 'context_stage':
|
|
59
|
+
return dim(` · ${scalar(d.stage ?? d.message ?? d.detail)}`);
|
|
60
|
+
case 'context_done':
|
|
61
|
+
case 'context_assembly': {
|
|
62
|
+
const tok = d.total_tokens ?? d.context_tokens ?? d.system_prompt_tokens ?? 0;
|
|
63
|
+
const ms = d.gather_time_ms ?? d.elapsed_ms ?? 0;
|
|
64
|
+
const neurons = d.neurons_fired ? ` · ${d.neurons_fired} neurons` : '';
|
|
65
|
+
return chalk.green(` 📦 context ${tok} tok${neurons}` + (ms ? dim(` (${ms}ms)`) : ''));
|
|
66
|
+
}
|
|
67
|
+
case 'context_summary': {
|
|
68
|
+
const o = d.original_tokens ?? 0;
|
|
69
|
+
const s = d.summary_tokens ?? 0;
|
|
70
|
+
return o ? dim(` 📝 compressed ${o}→${s} tok`) : null;
|
|
71
|
+
}
|
|
72
|
+
case 'llm_start': {
|
|
73
|
+
const m = scalar(d.model) || 'auto';
|
|
74
|
+
const tok = d.prompt_tokens_est ? ` (${d.prompt_tokens_est} tok${d.has_tools ? ' +tools' : ''})` : '';
|
|
75
|
+
const turn = d.turn ? `turn ${d.turn} · ` : '';
|
|
76
|
+
return chalk.blue(` 🧠 ${turn}LLM ${m}…${tok}`);
|
|
77
|
+
}
|
|
78
|
+
case 'llm_done':
|
|
79
|
+
case 'llm_end': {
|
|
80
|
+
const ms = d.llm_time_ms ?? d.duration_ms ?? 0;
|
|
81
|
+
const tok = d.tokens_used ?? d.tokens ?? 0;
|
|
82
|
+
const m = scalar(d.model_used ?? d.model) || 'default';
|
|
83
|
+
const reason = d.has_thinking ? ' +reasoning' : '';
|
|
84
|
+
const calls = d.has_tool_calls ? ' +tools' : '';
|
|
85
|
+
return chalk.blue(` ⚡ LLM ${m} → ${tok} tok ${dim(`${Math.round(ms)}ms`)}${reason}${calls}`);
|
|
86
|
+
}
|
|
87
|
+
case 'llm_error':
|
|
88
|
+
return chalk.red(` ✗ LLM error ${scalar(d.model)}: ${clip(scalar(d.error ?? d.message))}`);
|
|
89
|
+
case 'tool_selection': {
|
|
90
|
+
const names = Array.isArray(d.tool_names) ? d.tool_names.join(', ')
|
|
91
|
+
: Array.isArray(d.tools) ? d.tools.map((t) => t?.name || t).join(', ')
|
|
92
|
+
: scalar(d.tools);
|
|
93
|
+
return chalk.yellow(` 🔧 tools [${names}]` + (d.strategy ? dim(` ${scalar(d.strategy)}`) : ''));
|
|
94
|
+
}
|
|
95
|
+
case 'plan_ready':
|
|
96
|
+
case 'plan_refined': {
|
|
97
|
+
const n = Array.isArray(d.steps) ? d.steps.length : (d.step_count ?? '');
|
|
98
|
+
return chalk.cyan(` 📋 plan ${n ? n + ' steps' : 'ready'}` +
|
|
99
|
+
(d.method ? dim(` · ${scalar(d.method)}`) : ''));
|
|
100
|
+
}
|
|
101
|
+
case 'plan_start':
|
|
102
|
+
return dim(` 📐 planning ${scalar(d.tier)}…`);
|
|
103
|
+
case 'plan_phase':
|
|
104
|
+
return dim(` 📐 ${scalar(d.phase)}` + (d.elapsed_ms ? ` ${Math.round(d.elapsed_ms)}ms` : ''));
|
|
105
|
+
case 'plan_step': {
|
|
106
|
+
const done = d.status === 'done' || d.done === true;
|
|
107
|
+
return (done ? chalk.green(' ✓') : dim(' →')) + ` ${clip(scalar(d.step ?? d.task ?? d.description))}`;
|
|
108
|
+
}
|
|
109
|
+
case 'plan_complete':
|
|
110
|
+
case 'plan_status': {
|
|
111
|
+
const st = scalar(d.status ?? d.outcome);
|
|
112
|
+
const benign = st === 'timeout' || st === 'no_plan';
|
|
113
|
+
return (benign ? dim(' ○') : chalk.green(' ✓')) + ` ${st || 'plan complete'}`;
|
|
114
|
+
}
|
|
115
|
+
case 'mcts_plan':
|
|
116
|
+
return chalk.cyan(` 🌲 MCTS ${Array.isArray(d.steps) ? d.steps.length + ' steps' : 'plan'}`);
|
|
117
|
+
case 'mcts_iteration':
|
|
118
|
+
return dim(` 🌲 mcts ${d.iteration ?? d.i ?? '?'}/${d.total ?? d.iterations ?? '?'}` +
|
|
119
|
+
(d.best_value != null ? ` best=${scalar(d.best_value)}` : ''));
|
|
120
|
+
case 'reasoning_start':
|
|
121
|
+
case 'reasoning_engage':
|
|
122
|
+
return chalk.magenta(` 🧠 reasoning ${scalar(d.depth)}` +
|
|
123
|
+
(d.reason ? dim(` · ${clip(scalar(d.reason), 50)}`) : ''));
|
|
124
|
+
case 'reasoning_strategy':
|
|
125
|
+
return chalk.magenta(` 🧠 ${scalar(d.name ?? d.strategy)}`);
|
|
126
|
+
case 'reasoning_step':
|
|
127
|
+
case 'reasoning_trace':
|
|
128
|
+
return dim(` 🧠 ${clip(scalar(d.content ?? d.step), 80)}`);
|
|
129
|
+
case 'reasoning_depth':
|
|
130
|
+
return dim(` 🧠 depth ${scalar(d.level ?? d.depth)}`);
|
|
131
|
+
case 'checkpoint': {
|
|
132
|
+
const t = scalar(d.turn);
|
|
133
|
+
const max = scalar(d.max_turns ?? d.max);
|
|
134
|
+
return dim(` ── turn ${t}/${max}` + (d.tool_calls ? ` · ${d.tool_calls} calls` : ''));
|
|
135
|
+
}
|
|
136
|
+
case 'ooda_observe':
|
|
137
|
+
return dim(` 🔍 observe turn ${scalar(d.turn)}/${scalar(d.max_turns ?? d.max)}`);
|
|
138
|
+
case 'ooda_decide':
|
|
139
|
+
return dim(` 🔄 decide ${clip(scalar(d.reason), 50)}`);
|
|
140
|
+
case 'ooda_delegate':
|
|
141
|
+
return chalk.yellow(` 🤝 delegate → ${scalar(d.target)}`);
|
|
142
|
+
case 'facet_start':
|
|
143
|
+
return chalk.cyan(` ── facet ${scalar(d.facet ?? d.type ?? d.index)}`);
|
|
144
|
+
case 'facet_end':
|
|
145
|
+
return chalk.green(` ✓ facet ${scalar(d.facet ?? d.type)} done`);
|
|
146
|
+
case 'facet_crystallize':
|
|
147
|
+
return chalk.yellow(` 💎 ${clip(scalar(d.summary ?? d.findings), 70)}`);
|
|
148
|
+
case 'neuron_fire':
|
|
149
|
+
return dim(` 🧬 neurons ${scalar(d.chunks)} · ${scalar(d.tokens)} tok`);
|
|
150
|
+
case 'escalation':
|
|
151
|
+
return chalk.yellow(` ⬆ escalation ${clip(scalar(d.reason), 50)}`);
|
|
152
|
+
case 'agentic_upgrade':
|
|
153
|
+
case 'agentic_promotion':
|
|
154
|
+
return chalk.cyan(` 🔀 agentic ${clip(scalar(d.reason), 50)}`);
|
|
155
|
+
case 'council':
|
|
156
|
+
case 'council_perspective':
|
|
157
|
+
return chalk.magenta(` 👥 ${scalar(d.agent)}: ${clip(scalar(d.perspective ?? d.content), 50)}`);
|
|
158
|
+
case 'council_review':
|
|
159
|
+
return chalk.magenta(` 👥 council ${scalar(d.verdict)}`);
|
|
160
|
+
case 'agent_message':
|
|
161
|
+
return chalk.blue(` 🤖 ${scalar(d.agent)}: ${clip(scalar(d.content), 50)}`);
|
|
162
|
+
case 'steering':
|
|
163
|
+
case 'steering_guide':
|
|
164
|
+
return chalk.yellow(` 🔄 ${clip(scalar(d.hook ?? d.reason), 50)}`);
|
|
165
|
+
case 'speculative_fire':
|
|
166
|
+
return dim(` 🚀 pre-firing ${scalar(d.tools)}`);
|
|
167
|
+
case 'speculative_result':
|
|
168
|
+
case 'prefire_result':
|
|
169
|
+
return dim(` ✓ pre-fetched ${scalar(d.tool ?? d.tools)}`);
|
|
170
|
+
case 'loop_guard':
|
|
171
|
+
return chalk.red(` ⛔ loop guard ${scalar(d.tool)}: ${clip(scalar(d.reason), 40)}`);
|
|
172
|
+
case 'middleware_progress':
|
|
173
|
+
return dim(` ⚙ ${scalar(d.middleware ?? d.name ?? d.stage)}` +
|
|
174
|
+
(d.detail ? ` ${clip(scalar(d.detail), 30)}` : '') +
|
|
175
|
+
(d.elapsed_ms ? ` ${Math.round(d.elapsed_ms)}ms` : ''));
|
|
176
|
+
case 'image_gen_start':
|
|
177
|
+
return chalk.cyan(' 🎨 generating image…');
|
|
178
|
+
case 'image_gen_failed':
|
|
179
|
+
return chalk.red(` ✗ image gen failed: ${clip(scalar(d.error), 50)}`);
|
|
180
|
+
case 'progress':
|
|
181
|
+
case 'status': {
|
|
182
|
+
const msg = scalar(d.message ?? d.phase ?? d.stage);
|
|
183
|
+
return msg ? dim(` ⚙ ${clip(msg, 60)}`) : null;
|
|
184
|
+
}
|
|
185
|
+
case 'error':
|
|
186
|
+
return chalk.red(` ✗ ${clip(scalar(d.message ?? d.error) || 'error', 60)}`);
|
|
187
|
+
// OUTPUT-pane / status-only / silent — not trace lines (controller owns these).
|
|
188
|
+
case 'token':
|
|
189
|
+
case 'answer':
|
|
190
|
+
case 'answer_segment':
|
|
191
|
+
case 'segment_end':
|
|
192
|
+
case 'message':
|
|
193
|
+
case 'final_answer':
|
|
194
|
+
case 'partial':
|
|
195
|
+
case 'complete':
|
|
196
|
+
case 'done':
|
|
197
|
+
case 'tool_call':
|
|
198
|
+
case 'tool_result':
|
|
199
|
+
case 'artifact_delivered':
|
|
200
|
+
case 'clarification_needed':
|
|
201
|
+
case 'approval_required':
|
|
202
|
+
case 'image_gen_complete':
|
|
203
|
+
case 'heartbeat':
|
|
204
|
+
case 'keepalive':
|
|
205
|
+
case 'debug':
|
|
206
|
+
case 'thinking':
|
|
207
|
+
case 'thinking_end':
|
|
208
|
+
case 'session_start':
|
|
209
|
+
return null;
|
|
210
|
+
default: {
|
|
211
|
+
// Pipeline substages are discriminated by `stage` (wire type is often
|
|
212
|
+
// 'pipeline'); label by stage and prefer a human field, else a compact
|
|
213
|
+
// scalar dump so telemetry (llm_route/thinking_budget/reasoning_summary)
|
|
214
|
+
// is never silently swallowed in the TRACE pane.
|
|
215
|
+
const label = scalar(d.stage) || event.type;
|
|
216
|
+
let msg = scalar(d.message ?? d.content ?? d.detail ?? d.summary ?? d.status);
|
|
217
|
+
if (!msg && (d.stage || event.type === 'pipeline')) {
|
|
218
|
+
msg = Object.entries(d)
|
|
219
|
+
.filter(([k, v]) => k !== 'type' && k !== 'stage' &&
|
|
220
|
+
(typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
|
|
221
|
+
.slice(0, 8)
|
|
222
|
+
.map(([k, v]) => `${k}=${String(v).slice(0, 80)}`)
|
|
223
|
+
.join(' ');
|
|
224
|
+
}
|
|
225
|
+
// The trace pane wraps (screen.ts), so don't pre-bake a `…` here — a low
|
|
226
|
+
// clip was the real reason telemetry lines showed cut off. Keep a generous
|
|
227
|
+
// upper bound only as a runaway guard; wrapping handles the rest.
|
|
228
|
+
return msg ? dim(` [${label}] ${clip(msg, 240)}`) : null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** Short STATUS-bar fragment for an event (the current stage), or null. */
|
|
233
|
+
export function formatStatus(event) {
|
|
234
|
+
const d = event.data || {};
|
|
235
|
+
switch (event.type) {
|
|
236
|
+
case 'heartbeat':
|
|
237
|
+
case 'keepalive': {
|
|
238
|
+
const ms = d.elapsed_ms;
|
|
239
|
+
const stage = scalar(d.stage ?? d.phase);
|
|
240
|
+
return `working ${ms ? Math.round(Number(ms) / 1000) + 's' : ''}${stage ? ' · ' + stage : ''}`.trim();
|
|
241
|
+
}
|
|
242
|
+
case 'llm_start':
|
|
243
|
+
return `LLM ${scalar(d.model) || ''}…`.trim();
|
|
244
|
+
case 'progress':
|
|
245
|
+
case 'status':
|
|
246
|
+
return scalar(d.message ?? d.phase ?? d.stage) || null;
|
|
247
|
+
case 'middleware_progress':
|
|
248
|
+
return scalar(d.middleware ?? d.stage) || null;
|
|
249
|
+
case 'plan_start':
|
|
250
|
+
return 'planning…';
|
|
251
|
+
case 'context_start':
|
|
252
|
+
return 'gathering context…';
|
|
253
|
+
default:
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Extract {agent, model, effort, tier} updates from an event for the status bar. */
|
|
258
|
+
export function statusUpdate(event) {
|
|
259
|
+
const d = event.data || {};
|
|
260
|
+
switch (event.type) {
|
|
261
|
+
case 'session_start':
|
|
262
|
+
return { agent: scalar(d.agent) || undefined };
|
|
263
|
+
case 'classify':
|
|
264
|
+
case 'classify_update':
|
|
265
|
+
return {
|
|
266
|
+
effort: scalar(d.effort?.level ?? d.effort) || undefined,
|
|
267
|
+
tier: scalar(d.effort?.tier ?? d.tier) || undefined,
|
|
268
|
+
};
|
|
269
|
+
case 'model_select':
|
|
270
|
+
return { model: scalar(d.model_selection?.recommended_model ?? d.model) || undefined };
|
|
271
|
+
case 'llm_start':
|
|
272
|
+
case 'llm_done':
|
|
273
|
+
case 'llm_end': {
|
|
274
|
+
const m = scalar(d.model_used ?? d.model);
|
|
275
|
+
return m && m !== 'auto' && m !== 'unknown' ? { model: m } : {};
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
return {};
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interactive.ts — Generic interactive argument collection for slash commands.
|
|
3
|
+
*
|
|
4
|
+
* The shell's command handlers historically printed a static "Usage: ..." block
|
|
5
|
+
* when invoked with missing arguments (e.g. bare `/safety`), forcing the user to
|
|
6
|
+
* retype the whole line. This module turns the EXISTING `SUBCOMMAND_DEFS` arg
|
|
7
|
+
* hints (completions.ts) into real @inquirer prompts so every command with a
|
|
8
|
+
* subcommand table becomes fully interactive — pick a subcommand, then get
|
|
9
|
+
* select/input/confirm prompts for each declared argument and flag.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here is command-specific: it reads the arg-hint grammar already used
|
|
12
|
+
* for tab completion and the `/` picker, so new commands become interactive for
|
|
13
|
+
* free just by having a SUBCOMMAND_DEFS entry.
|
|
14
|
+
*
|
|
15
|
+
* MUST be called only while the shell has released stdin (the TUI's runDetached,
|
|
16
|
+
* or the readline REPL's detach block) so @inquirer owns the keystrokes.
|
|
17
|
+
*
|
|
18
|
+
* Arg-hint grammar (from SUBCOMMAND_DEFS, e.g. `'<a|b|c> [--context <ctx>] [--flag]'`):
|
|
19
|
+
* <name> required positional (text input)
|
|
20
|
+
* <a|b|c> required positional, enum → select menu
|
|
21
|
+
* "<name>" required positional, wrap value in quotes on assembly
|
|
22
|
+
* [name] optional positional
|
|
23
|
+
* --flag <val> optional flag with a value
|
|
24
|
+
* [--flag quick|x] optional flag, enum value → select menu
|
|
25
|
+
* [--flag] optional boolean flag → confirm
|
|
26
|
+
* approve|reject bare enum positional
|
|
27
|
+
* — description everything after an em-dash is help text, stripped
|
|
28
|
+
*/
|
|
29
|
+
export interface ArgSpec {
|
|
30
|
+
name: string;
|
|
31
|
+
required: boolean;
|
|
32
|
+
isFlag: boolean;
|
|
33
|
+
flagName?: string;
|
|
34
|
+
boolean?: boolean;
|
|
35
|
+
options?: string[];
|
|
36
|
+
quoted?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Parse an arg-hint spec string into an ordered list of ArgSpecs. */
|
|
39
|
+
export declare function parseSpec(hint: string): ArgSpec[];
|
|
40
|
+
/**
|
|
41
|
+
* Run the full interactive flow for a command given its SUBCOMMAND_DEFS entries.
|
|
42
|
+
* Returns the argument string to hand the command handler (e.g. `set unrestricted
|
|
43
|
+
* --context coding`), an empty string for a no-arg subcommand, or null if cancelled.
|
|
44
|
+
*/
|
|
45
|
+
export declare function collectArgs(cmdName: string, subDefs: ReadonlyArray<readonly [string, string]>): Promise<string | null>;
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* interactive.ts — Generic interactive argument collection for slash commands.
|
|
3
|
+
*
|
|
4
|
+
* The shell's command handlers historically printed a static "Usage: ..." block
|
|
5
|
+
* when invoked with missing arguments (e.g. bare `/safety`), forcing the user to
|
|
6
|
+
* retype the whole line. This module turns the EXISTING `SUBCOMMAND_DEFS` arg
|
|
7
|
+
* hints (completions.ts) into real @inquirer prompts so every command with a
|
|
8
|
+
* subcommand table becomes fully interactive — pick a subcommand, then get
|
|
9
|
+
* select/input/confirm prompts for each declared argument and flag.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here is command-specific: it reads the arg-hint grammar already used
|
|
12
|
+
* for tab completion and the `/` picker, so new commands become interactive for
|
|
13
|
+
* free just by having a SUBCOMMAND_DEFS entry.
|
|
14
|
+
*
|
|
15
|
+
* MUST be called only while the shell has released stdin (the TUI's runDetached,
|
|
16
|
+
* or the readline REPL's detach block) so @inquirer owns the keystrokes.
|
|
17
|
+
*
|
|
18
|
+
* Arg-hint grammar (from SUBCOMMAND_DEFS, e.g. `'<a|b|c> [--context <ctx>] [--flag]'`):
|
|
19
|
+
* <name> required positional (text input)
|
|
20
|
+
* <a|b|c> required positional, enum → select menu
|
|
21
|
+
* "<name>" required positional, wrap value in quotes on assembly
|
|
22
|
+
* [name] optional positional
|
|
23
|
+
* --flag <val> optional flag with a value
|
|
24
|
+
* [--flag quick|x] optional flag, enum value → select menu
|
|
25
|
+
* [--flag] optional boolean flag → confirm
|
|
26
|
+
* approve|reject bare enum positional
|
|
27
|
+
* — description everything after an em-dash is help text, stripped
|
|
28
|
+
*/
|
|
29
|
+
import chalk from 'chalk';
|
|
30
|
+
import { select, input, confirm } from '@inquirer/prompts';
|
|
31
|
+
/** Split a spec string into top-level tokens, keeping [..], "..", <..> groups intact. */
|
|
32
|
+
function tokenizeSpec(spec) {
|
|
33
|
+
const tokens = [];
|
|
34
|
+
const s = spec.trim();
|
|
35
|
+
let i = 0;
|
|
36
|
+
while (i < s.length) {
|
|
37
|
+
if (s[i] === ' ') {
|
|
38
|
+
i++;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
let depth = 0;
|
|
42
|
+
let inQuote = false;
|
|
43
|
+
let buf = '';
|
|
44
|
+
while (i < s.length) {
|
|
45
|
+
const c = s[i];
|
|
46
|
+
if (c === '"')
|
|
47
|
+
inQuote = !inQuote;
|
|
48
|
+
else if (c === '[')
|
|
49
|
+
depth++;
|
|
50
|
+
else if (c === ']')
|
|
51
|
+
depth = Math.max(0, depth - 1);
|
|
52
|
+
if (c === ' ' && depth === 0 && !inQuote)
|
|
53
|
+
break;
|
|
54
|
+
buf += c;
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
if (buf)
|
|
58
|
+
tokens.push(buf);
|
|
59
|
+
}
|
|
60
|
+
return tokens;
|
|
61
|
+
}
|
|
62
|
+
function cleanName(raw) {
|
|
63
|
+
return raw.replace(/^[["'<]+/, '').replace(/[\]"'>]+$/, '').replace(/^--/, '').trim();
|
|
64
|
+
}
|
|
65
|
+
function extractOptions(raw) {
|
|
66
|
+
const inner = raw.replace(/^[["'<]+/, '').replace(/[\]"'>]+$/, '');
|
|
67
|
+
if (inner.includes('|')) {
|
|
68
|
+
const opts = inner.split('|').map(s => s.trim()).filter(Boolean);
|
|
69
|
+
return opts.length > 1 ? opts : undefined;
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
/** Strip the trailing ` — help text` from an arg hint. */
|
|
74
|
+
function stripDescription(hint) {
|
|
75
|
+
const idx = hint.indexOf('—');
|
|
76
|
+
return (idx >= 0 ? hint.slice(0, idx) : hint).trim();
|
|
77
|
+
}
|
|
78
|
+
function descriptionOf(hint) {
|
|
79
|
+
const idx = hint.indexOf('—');
|
|
80
|
+
return idx >= 0 ? hint.slice(idx + 1).trim() : '';
|
|
81
|
+
}
|
|
82
|
+
/** Parse an arg-hint spec string into an ordered list of ArgSpecs. */
|
|
83
|
+
export function parseSpec(hint) {
|
|
84
|
+
const main = stripDescription(hint);
|
|
85
|
+
if (!main)
|
|
86
|
+
return [];
|
|
87
|
+
const tokens = tokenizeSpec(main);
|
|
88
|
+
const specs = [];
|
|
89
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
90
|
+
const tok = tokens[i];
|
|
91
|
+
const optionalOuter = tok.startsWith('[') && tok.endsWith(']');
|
|
92
|
+
const inner = (optionalOuter ? tok.slice(1, -1) : tok).trim();
|
|
93
|
+
if (!inner)
|
|
94
|
+
continue;
|
|
95
|
+
if (inner.startsWith('--')) {
|
|
96
|
+
const parts = inner.split(/\s+/);
|
|
97
|
+
const flagName = parts[0];
|
|
98
|
+
let valuePart = parts.slice(1).join(' ');
|
|
99
|
+
// `--start <ISO>` (value as a separate, non-bracketed token) — merge it.
|
|
100
|
+
if (!valuePart && !optionalOuter && i + 1 < tokens.length && /^["<]/.test(tokens[i + 1])) {
|
|
101
|
+
valuePart = tokens[++i];
|
|
102
|
+
}
|
|
103
|
+
if (!valuePart) {
|
|
104
|
+
specs.push({ name: cleanName(flagName), required: false, isFlag: true, flagName, boolean: true });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
specs.push({
|
|
108
|
+
name: cleanName(flagName),
|
|
109
|
+
required: false,
|
|
110
|
+
isFlag: true,
|
|
111
|
+
flagName,
|
|
112
|
+
options: extractOptions(valuePart),
|
|
113
|
+
quoted: valuePart.includes('"'),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
specs.push({
|
|
119
|
+
name: cleanName(inner) || 'arg',
|
|
120
|
+
required: !optionalOuter,
|
|
121
|
+
isFlag: false,
|
|
122
|
+
options: extractOptions(inner),
|
|
123
|
+
quoted: tok.includes('"'),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return specs;
|
|
127
|
+
}
|
|
128
|
+
function maybeQuote(value, spec) {
|
|
129
|
+
if (spec.quoted || /\s/.test(value))
|
|
130
|
+
return `"${value.replace(/"/g, '\\"')}"`;
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
/** Thrown by @inquirer on Ctrl+C / Escape — treat as "cancelled". */
|
|
134
|
+
function isCancel(e) {
|
|
135
|
+
const n = e?.name || '';
|
|
136
|
+
return n === 'ExitPromptError' || n === 'AbortPromptError' || /force closed|cancel/i.test(e?.message || '');
|
|
137
|
+
}
|
|
138
|
+
/** Prompt for each ArgSpec in order. Returns the assembled arg string, or null if cancelled. */
|
|
139
|
+
async function promptSpecs(specs) {
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const sp of specs) {
|
|
142
|
+
try {
|
|
143
|
+
if (sp.isFlag) {
|
|
144
|
+
if (sp.boolean) {
|
|
145
|
+
const yes = await confirm({ message: `Include ${chalk.cyan(sp.flagName)}?`, default: false });
|
|
146
|
+
if (yes)
|
|
147
|
+
out.push(sp.flagName);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (sp.options) {
|
|
151
|
+
const val = await select({
|
|
152
|
+
message: `${chalk.cyan(sp.flagName)} ${chalk.dim('(optional)')}`,
|
|
153
|
+
choices: [{ name: chalk.dim('(skip)'), value: '' }, ...sp.options.map(o => ({ name: o, value: o }))],
|
|
154
|
+
});
|
|
155
|
+
if (val)
|
|
156
|
+
out.push(`${sp.flagName} ${maybeQuote(val, sp)}`);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const val = (await input({ message: `${chalk.cyan(sp.flagName)} ${chalk.dim('(optional, blank to skip)')}` })).trim();
|
|
160
|
+
if (val)
|
|
161
|
+
out.push(`${sp.flagName} ${maybeQuote(val, sp)}`);
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// Positional.
|
|
166
|
+
if (sp.options) {
|
|
167
|
+
const choices = sp.options.map(o => ({ name: o, value: o }));
|
|
168
|
+
if (!sp.required)
|
|
169
|
+
choices.unshift({ name: chalk.dim('(skip)'), value: '' });
|
|
170
|
+
// When the positional's "name" is just the enum list (e.g. `a|b|c`), the
|
|
171
|
+
// choices already convey it — ask generically instead of echoing the list.
|
|
172
|
+
const label = sp.name.includes('|') ? 'Select one:' : `Select ${chalk.bold(sp.name)}:`;
|
|
173
|
+
const val = await select({ message: label, choices });
|
|
174
|
+
if (val)
|
|
175
|
+
out.push(maybeQuote(val, sp));
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const label = sp.required
|
|
179
|
+
? `${chalk.bold(sp.name)}:`
|
|
180
|
+
: `${chalk.bold(sp.name)} ${chalk.dim('(optional, blank to skip)')}:`;
|
|
181
|
+
const val = (await input({ message: label })).trim();
|
|
182
|
+
if (val)
|
|
183
|
+
out.push(maybeQuote(val, sp));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
if (isCancel(e))
|
|
188
|
+
return null;
|
|
189
|
+
throw e;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out.join(' ');
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Run the full interactive flow for a command given its SUBCOMMAND_DEFS entries.
|
|
196
|
+
* Returns the argument string to hand the command handler (e.g. `set unrestricted
|
|
197
|
+
* --context coding`), an empty string for a no-arg subcommand, or null if cancelled.
|
|
198
|
+
*/
|
|
199
|
+
export async function collectArgs(cmdName, subDefs) {
|
|
200
|
+
if (!subDefs.length)
|
|
201
|
+
return '';
|
|
202
|
+
// Single entry whose "name" is actually a positional placeholder (e.g. /research
|
|
203
|
+
// → `"<topic>"`) — there's no subcommand to pick; prompt the whole spec directly.
|
|
204
|
+
if (subDefs.length === 1 && /^["<[]/.test(subDefs[0][0])) {
|
|
205
|
+
const specs = parseSpec(`${subDefs[0][0]} ${subDefs[0][1] || ''}`);
|
|
206
|
+
return promptSpecs(specs);
|
|
207
|
+
}
|
|
208
|
+
let sub;
|
|
209
|
+
try {
|
|
210
|
+
sub = await select({
|
|
211
|
+
message: `/${cmdName} ${chalk.dim('—')}`,
|
|
212
|
+
choices: subDefs.map(([name, hint]) => ({
|
|
213
|
+
name: chalk.cyan(name),
|
|
214
|
+
value: name,
|
|
215
|
+
description: descriptionOf(hint) || stripDescription(hint) || undefined,
|
|
216
|
+
})),
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
if (isCancel(e))
|
|
221
|
+
return null;
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
224
|
+
const def = subDefs.find(d => d[0] === sub);
|
|
225
|
+
const specs = def ? parseSpec(def[1] || '') : [];
|
|
226
|
+
if (!specs.length)
|
|
227
|
+
return sub;
|
|
228
|
+
const argStr = await promptSpecs(specs);
|
|
229
|
+
if (argStr === null)
|
|
230
|
+
return null;
|
|
231
|
+
return argStr ? `${sub} ${argStr}` : sub;
|
|
232
|
+
}
|