airlock-bot 0.2.15 → 0.2.17

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.
@@ -0,0 +1,868 @@
1
+ import { parseArgs } from 'util';
2
+ import { writeFileSync, copyFileSync, readFileSync, openSync } from 'fs';
3
+ import { ReadStream } from 'tty';
4
+ import { execSync } from 'child_process';
5
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
6
+ import { discoverCliCommands } from '../discover/index.js';
7
+ import { createCompletionSession } from '../discover/strategies/completion.js';
8
+ // ── ANSI helpers ─────────────────────────────────────────────────────────────
9
+ const ESC = '\x1b[';
10
+ const RESET = `${ESC}0m`;
11
+ const BOLD = `${ESC}1m`;
12
+ const DIM = `${ESC}2m`;
13
+ const GREEN = `${ESC}32m`;
14
+ const YELLOW = `${ESC}33m`;
15
+ const RED = `${ESC}31m`;
16
+ const CYAN = `${ESC}36m`;
17
+ const BLUE = `${ESC}34m`;
18
+ function visibleLength(s) {
19
+ // eslint-disable-next-line no-control-regex
20
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
21
+ }
22
+ function terminalRows(line, termCols) {
23
+ const len = visibleLength(line);
24
+ if (len === 0)
25
+ return 1;
26
+ return Math.ceil(len / termCols);
27
+ }
28
+ // ── Global flag detection ────────────────────────────────────────────────────
29
+ /** Flags that are noise for AI agents — always stripped. */
30
+ const NOISE_FLAGS = new Set(['help', 'version']);
31
+ function detectGlobalFlags(commands) {
32
+ const commandNames = Object.keys(commands);
33
+ if (commandNames.length === 0)
34
+ return { universal: new Set(), noise: new Set() };
35
+ // Count how many commands each param appears in
36
+ const paramCounts = new Map();
37
+ for (const cmd of Object.values(commands)) {
38
+ for (const paramName of Object.keys(cmd.params ?? {})) {
39
+ paramCounts.set(paramName, (paramCounts.get(paramName) ?? 0) + 1);
40
+ }
41
+ }
42
+ const total = commandNames.length;
43
+ const universal = new Set();
44
+ for (const [param, count] of paramCounts) {
45
+ if (count === total)
46
+ universal.add(param);
47
+ }
48
+ return { universal, noise: NOISE_FLAGS };
49
+ }
50
+ function collectCommands(groups) {
51
+ const commands = {};
52
+ for (const group of groups) {
53
+ for (const entry of group.entries) {
54
+ commands[entry.name] = entry.config;
55
+ }
56
+ }
57
+ return commands;
58
+ }
59
+ function detectGlobalFlagsFromGroups(groups) {
60
+ return detectGlobalFlags(collectCommands(groups));
61
+ }
62
+ // ── Command grouping ─────────────────────────────────────────────────────────
63
+ function groupCommands(commands, tool) {
64
+ const groups = new Map();
65
+ // Group by the first subcommand from the exec field.
66
+ // e.g. exec "gog gmail send" → group "gmail", exec "gog ls" → group "(root)"
67
+ for (const [name, config] of Object.entries(commands)) {
68
+ const execParts = (config.exec ?? name).split(/\s+/);
69
+ // Strip the tool name prefix to get subcommand parts
70
+ // exec: "gog gmail send" → ["gog", "gmail", "send"] → subcommand parts: ["gmail", "send"]
71
+ const subParts = execParts.slice(1); // remove tool name
72
+ let groupName;
73
+ if (subParts.length > 1) {
74
+ // Has at least one level of nesting — group by first subcommand
75
+ groupName = subParts[0];
76
+ }
77
+ else {
78
+ groupName = '(root)';
79
+ }
80
+ if (!groups.has(groupName))
81
+ groups.set(groupName, []);
82
+ groups.get(groupName).push({ name, config, enabled: true });
83
+ }
84
+ // Move (root) entries that match a group name into that group.
85
+ // e.g. if "gmail" is in (root) and a "gmail" group exists, move it there.
86
+ const rootEntries = groups.get('(root)');
87
+ if (rootEntries) {
88
+ const toMove = [];
89
+ for (const entry of rootEntries) {
90
+ if (entry.name !== tool && groups.has(entry.name) && entry.name !== '(root)') {
91
+ toMove.push(entry);
92
+ }
93
+ }
94
+ for (const entry of toMove) {
95
+ rootEntries.splice(rootEntries.indexOf(entry), 1);
96
+ groups.get(entry.name).unshift(entry); // put parent first in the group
97
+ }
98
+ if (rootEntries.length === 0)
99
+ groups.delete('(root)');
100
+ }
101
+ // Sort: (root) first, then alphabetical
102
+ const sorted = [...groups.entries()].sort(([a], [b]) => {
103
+ if (a === '(root)')
104
+ return -1;
105
+ if (b === '(root)')
106
+ return 1;
107
+ return a.localeCompare(b);
108
+ });
109
+ return sorted.map(([name, entries]) => ({
110
+ name,
111
+ path: name === '(root)' ? [] : [name],
112
+ entries: entries.sort((a, b) => a.name.localeCompare(b.name)),
113
+ loaded: true,
114
+ loading: false,
115
+ pendingEnabled: true,
116
+ }));
117
+ }
118
+ function createLazyGroups(tool, topLevelSubcommands, rootCommand) {
119
+ const groups = [];
120
+ if (rootCommand) {
121
+ groups.push({
122
+ name: '(root)',
123
+ path: [],
124
+ entries: [{ name: tool, config: rootCommand, enabled: true }],
125
+ loaded: true,
126
+ loading: false,
127
+ pendingEnabled: true,
128
+ });
129
+ }
130
+ for (const name of [...topLevelSubcommands].sort((a, b) => a.localeCompare(b))) {
131
+ groups.push({
132
+ name,
133
+ path: [name],
134
+ entries: [],
135
+ loaded: false,
136
+ loading: false,
137
+ pendingEnabled: true,
138
+ });
139
+ }
140
+ return groups;
141
+ }
142
+ // ── Compact YAML output ──────────────────────────────────────────────────────
143
+ function buildCompactConfig(tool, groups, globalFlags, stripGlobal) {
144
+ const commands = {};
145
+ for (const group of groups) {
146
+ for (const entry of group.entries) {
147
+ if (!entry.enabled)
148
+ continue;
149
+ const cmd = {
150
+ exec: entry.config.exec,
151
+ };
152
+ if (entry.config.description) {
153
+ cmd.description = entry.config.description;
154
+ }
155
+ // Build compact params — strip globals and noise, omit defaults
156
+ const params = {};
157
+ for (const [paramName, paramConfig] of Object.entries(entry.config.params ?? {})) {
158
+ if (globalFlags.noise.has(paramName))
159
+ continue;
160
+ if (stripGlobal && globalFlags.universal.has(paramName))
161
+ continue;
162
+ const compact = { type: paramConfig.type };
163
+ if (paramConfig.flag)
164
+ compact.flag = paramConfig.flag;
165
+ if (paramConfig.positional)
166
+ compact.positional = true;
167
+ if (paramConfig.required)
168
+ compact.required = true;
169
+ if (paramConfig.description)
170
+ compact.description = paramConfig.description;
171
+ // Omit default, positional: false, required: false — they're defaults
172
+ params[paramName] = compact;
173
+ }
174
+ if (Object.keys(params).length > 0)
175
+ cmd.params = params;
176
+ cmd.timeout = entry.config.timeout ?? 30;
177
+ commands[entry.name] = cmd;
178
+ }
179
+ }
180
+ return {
181
+ clis: {
182
+ [tool]: { commands },
183
+ },
184
+ };
185
+ }
186
+ function serializeCompact(data, tool, strategy) {
187
+ const header = [
188
+ '# Auto-discovered by Airlock (interactive)',
189
+ `# Tool: ${tool}`,
190
+ `# Strategy: ${strategy}`,
191
+ `# Generated: ${new Date().toISOString()}`,
192
+ '#',
193
+ '# Only selected commands are included. Global/noise flags stripped.',
194
+ '',
195
+ ].join('\n');
196
+ return header + stringifyYaml(data, { lineWidth: 120 });
197
+ }
198
+ function matchesSearch(value, query) {
199
+ return value.toLowerCase().includes(query.trim().toLowerCase());
200
+ }
201
+ function buildVisibleRows(groups, viewLevel, activeGroupIdx, searchTerm) {
202
+ const query = searchTerm.trim();
203
+ const rows = viewLevel === 'groups'
204
+ ? buildGroupRows(groups)
205
+ : buildCommandRows(groups[activeGroupIdx], activeGroupIdx);
206
+ if (!query)
207
+ return rows;
208
+ return rows.filter((row) => {
209
+ if (row.type === 'group-header') {
210
+ return matchesSearch(row.group.name, query);
211
+ }
212
+ const haystack = [row.entry.name, row.entry.config.exec, row.entry.config.description ?? '']
213
+ .join(' ')
214
+ .trim();
215
+ return matchesSearch(haystack, query);
216
+ });
217
+ }
218
+ function buildGroupRows(groups) {
219
+ return groups.map((group, i) => ({
220
+ type: 'group-header',
221
+ group,
222
+ groupIdx: i,
223
+ }));
224
+ }
225
+ function buildCommandRows(group, groupIdx) {
226
+ return group.entries.map((entry, i) => ({
227
+ type: 'command',
228
+ entry,
229
+ groupIdx,
230
+ entryIdx: i,
231
+ }));
232
+ }
233
+ // ── TUI rendering ────────────────────────────────────────────────────────────
234
+ function renderGroupRow(row, isSel) {
235
+ const { group } = row;
236
+ const enabled = group.entries.filter((e) => e.enabled).length;
237
+ const total = group.entries.length;
238
+ const allOn = group.loaded ? enabled === total : group.pendingEnabled;
239
+ const allOff = group.loaded ? enabled === 0 : !group.pendingEnabled;
240
+ const check = allOn
241
+ ? `${GREEN}[✓]${RESET}`
242
+ : allOff
243
+ ? `${DIM}[ ]${RESET}`
244
+ : `${YELLOW}[~]${RESET}`;
245
+ const selMark = isSel ? `${BOLD}${YELLOW}▸ ` : ` `;
246
+ const nameStr = `${isSel ? BOLD : ''}${group.name}${RESET}`;
247
+ const countStr = group.loaded
248
+ ? `${DIM}(${enabled}/${total} commands)${RESET}`
249
+ : group.loading
250
+ ? `${YELLOW}(loading...)${RESET}`
251
+ : `${DIM}(not loaded yet)${RESET}`;
252
+ const lines = [];
253
+ lines.push(` ${selMark}${check} ${nameStr} ${countStr}`);
254
+ if (isSel) {
255
+ const hint = group.loaded
256
+ ? `${DIM} ↳ [space] toggle all [enter] drill down [a] all on [n] all off${RESET}`
257
+ : `${DIM} ↳ [enter] load group [space] queue selection before load${RESET}`;
258
+ lines.push(hint);
259
+ if (group.error)
260
+ lines.push(` ${RED}${group.error}${RESET}`);
261
+ }
262
+ return lines;
263
+ }
264
+ function renderCommandRow(row, isSel, globalFlags, stripGlobal) {
265
+ const { entry } = row;
266
+ const check = entry.enabled ? `${GREEN}[✓]${RESET}` : `${DIM}[ ]${RESET}`;
267
+ const selMark = isSel ? `${BOLD}${YELLOW} ▸ ` : ` `;
268
+ const nameStr = `${isSel ? BOLD : ''}${entry.name}${RESET}`;
269
+ // Count non-global params
270
+ const totalParams = Object.keys(entry.config.params ?? {}).length;
271
+ const globalCount = Object.keys(entry.config.params ?? {}).filter((p) => globalFlags.noise.has(p) || (stripGlobal && globalFlags.universal.has(p))).length;
272
+ const effectiveParams = totalParams - globalCount;
273
+ const paramStr = effectiveParams > 0 ? `${DIM}(${effectiveParams} params)${RESET}` : '';
274
+ const lines = [];
275
+ lines.push(`${selMark}${check} ${nameStr} ${paramStr}`);
276
+ if (isSel) {
277
+ const desc = entry.config.description ?? '';
278
+ if (desc) {
279
+ const termCols = process.stdout.columns || 80;
280
+ const maxDesc = Math.max(20, termCols - 10);
281
+ const flat = desc.replace(/\s*\n\s*/g, ' ');
282
+ const truncated = flat.length > maxDesc ? flat.slice(0, maxDesc) + '…' : flat;
283
+ lines.push(` ${DIM}${truncated}${RESET}`);
284
+ }
285
+ lines.push(` ${DIM}exec: ${entry.config.exec}${RESET}`);
286
+ }
287
+ return lines;
288
+ }
289
+ function render(groups, viewLevel, activeGroupIdx, rowIdx, tool, globalFlags, stripGlobal, searchTerm = '', searchMode = false) {
290
+ const out = process.stdout;
291
+ const termHeight = process.stdout.rows || 40;
292
+ const termCols = process.stdout.columns || 80;
293
+ out.write(`${ESC}H${ESC}2J`);
294
+ // Summary counts
295
+ const totalCommands = groups.reduce((s, g) => s + g.entries.length, 0);
296
+ const enabledCommands = groups.reduce((s, g) => s + g.entries.filter((e) => e.enabled).length, 0);
297
+ const loadedGroups = groups.filter((group) => group.loaded).length;
298
+ // Build header
299
+ const breadcrumb = viewLevel === 'commands' ? ` > ${groups[activeGroupIdx].name}` : '';
300
+ const headerLines = [
301
+ '',
302
+ `${BOLD}${CYAN} Airlock — Configure CLI: ${tool}${breadcrumb}${RESET}`,
303
+ `${DIM} ${'─'.repeat(Math.min(70, termCols - 4))}${RESET}`,
304
+ ];
305
+ if (viewLevel === 'groups') {
306
+ headerLines.push(`${DIM} [space] toggle [l/enter] drill down [/] search [a] all on [n] all off [g] global flags [w] done [q] quit${RESET}`);
307
+ headerLines.push(`${DIM} global flags: ${stripGlobal ? `${GREEN}stripped${RESET}` : `${YELLOW}included${RESET}`}${DIM} (${globalFlags.universal.size} detected across loaded commands)${RESET}`);
308
+ }
309
+ else {
310
+ headerLines.push(`${DIM} [space] toggle [l] inspect [h/esc] back [/] search [a] all on [n] all off [q] quit${RESET}`);
311
+ }
312
+ const searchLabel = searchMode
313
+ ? `${YELLOW}/${searchTerm}${RESET}`
314
+ : searchTerm
315
+ ? `${CYAN}/${searchTerm}${RESET}`
316
+ : `${DIM}/ search${RESET}`;
317
+ headerLines.push(`${DIM} filter: ${RESET}${searchLabel}`);
318
+ headerLines.push(`${DIM} ${'─'.repeat(Math.min(70, termCols - 4))}${RESET}`);
319
+ // Build rows
320
+ const rows = buildVisibleRows(groups, viewLevel, activeGroupIdx, searchTerm);
321
+ // Render all rows
322
+ const allRendered = [];
323
+ for (let i = 0; i < rows.length; i++) {
324
+ const row = rows[i];
325
+ const isSel = i === rowIdx;
326
+ let lines;
327
+ if (row.type === 'group-header') {
328
+ lines = renderGroupRow(row, isSel);
329
+ }
330
+ else {
331
+ lines = renderCommandRow(row, isSel, globalFlags, stripGlobal);
332
+ }
333
+ const screenRows = lines.map((l) => terminalRows(l, termCols));
334
+ allRendered.push({ lines, screenRows });
335
+ }
336
+ // Flatten for viewport calculation
337
+ const flatLines = [];
338
+ for (const rendered of allRendered) {
339
+ for (let j = 0; j < rendered.lines.length; j++) {
340
+ flatLines.push({ line: rendered.lines[j], cost: rendered.screenRows[j] });
341
+ }
342
+ }
343
+ const headerScreenRows = headerLines.reduce((sum, l) => sum + terminalRows(l, termCols), 0);
344
+ const footerScreenRows = 2;
345
+ const contentHeight = termHeight - headerScreenRows - footerScreenRows;
346
+ const totalScreenRows = flatLines.reduce((sum, f) => sum + f.cost, 0);
347
+ // Find selected row's midpoint for centering
348
+ let selectedStart = 0;
349
+ for (let i = 0; i < rowIdx && i < allRendered.length; i++) {
350
+ selectedStart += allRendered[i].screenRows.reduce((a, b) => a + b, 0);
351
+ }
352
+ const selectedCost = rowIdx < allRendered.length ? allRendered[rowIdx].screenRows.reduce((a, b) => a + b, 0) : 0;
353
+ const selectedMid = selectedStart + Math.floor(selectedCost / 2);
354
+ let scrollCost = Math.max(0, selectedMid - Math.floor(contentHeight / 2));
355
+ scrollCost = Math.max(0, Math.min(scrollCost, totalScreenRows - contentHeight));
356
+ // Collect viewport lines
357
+ const viewportLines = [];
358
+ let consumed = 0;
359
+ let usedScreenRows = 0;
360
+ let linesAbove = 0;
361
+ let linesBelow = 0;
362
+ for (const flat of flatLines) {
363
+ if (consumed + flat.cost <= scrollCost) {
364
+ consumed += flat.cost;
365
+ linesAbove++;
366
+ }
367
+ else if (usedScreenRows + flat.cost <= contentHeight) {
368
+ viewportLines.push(flat.line);
369
+ usedScreenRows += flat.cost;
370
+ consumed += flat.cost;
371
+ }
372
+ else {
373
+ linesBelow++;
374
+ }
375
+ }
376
+ // Scroll indicator
377
+ const scrollParts = [];
378
+ if (linesAbove > 0)
379
+ scrollParts.push(`↑ ${linesAbove} above`);
380
+ if (linesBelow > 0)
381
+ scrollParts.push(`↓ ${linesBelow} below`);
382
+ const scrollLine = scrollParts.length ? `${DIM} ${scrollParts.join(' ')}${RESET}` : '';
383
+ // Pad viewport
384
+ while (usedScreenRows < contentHeight) {
385
+ viewportLines.push('');
386
+ usedScreenRows++;
387
+ }
388
+ // Summary line
389
+ const summaryLine = `${DIM} ${enabledCommands}/${totalCommands} loaded commands selected | ${loadedGroups}/${groups.length} groups loaded | ${rows.length} visible${RESET}`;
390
+ // Write
391
+ for (const line of headerLines)
392
+ out.write(line + '\n');
393
+ for (const line of viewportLines)
394
+ out.write(line + '\n');
395
+ out.write(scrollLine + '\n');
396
+ out.write(summaryLine);
397
+ }
398
+ // ── Inspect mode ─────────────────────────────────────────────────────────────
399
+ function renderInspect(entry, globalFlags, stripGlobal, inspectScroll) {
400
+ const out = process.stdout;
401
+ out.write(`${ESC}H${ESC}2J`);
402
+ const check = entry.enabled ? `${GREEN}enabled${RESET}` : `${RED}disabled${RESET}`;
403
+ out.write(`\n${BOLD}${CYAN} Inspect: ${entry.name}${RESET} ${check}\n`);
404
+ out.write(`${DIM} ${'─'.repeat(70)}${RESET}\n`);
405
+ out.write(`${DIM} [j/k] scroll [h/esc] back [space] toggle${RESET}\n`);
406
+ out.write(`${DIM} ${'─'.repeat(70)}${RESET}\n\n`);
407
+ const lines = [];
408
+ lines.push(` ${BOLD}exec:${RESET} ${entry.config.exec}`);
409
+ if (entry.config.description) {
410
+ lines.push(` ${BOLD}description:${RESET} ${entry.config.description}`);
411
+ }
412
+ lines.push('');
413
+ lines.push(` ${BOLD}Parameters:${RESET}`);
414
+ for (const [paramName, param] of Object.entries(entry.config.params ?? {})) {
415
+ const isGlobal = globalFlags.universal.has(paramName);
416
+ const isNoise = globalFlags.noise.has(paramName);
417
+ const stripped = isNoise || (stripGlobal && isGlobal);
418
+ const tag = stripped ? `${DIM}[stripped]${RESET}` : isGlobal ? `${YELLOW}[global]${RESET}` : '';
419
+ lines.push(` ${stripped ? DIM : ''}${paramName}${RESET} ${param.type} ${param.flag ?? ''} ${tag}`);
420
+ if (param.description) {
421
+ lines.push(` ${DIM}${param.description}${RESET}`);
422
+ }
423
+ }
424
+ // Scrollable viewport
425
+ const termRows = process.stdout.rows || 40;
426
+ const viewportHeight = termRows - 10;
427
+ const maxScroll = Math.max(0, lines.length - viewportHeight);
428
+ const scroll = Math.min(inspectScroll, maxScroll);
429
+ const visible = lines.slice(scroll, scroll + viewportHeight);
430
+ for (const line of visible) {
431
+ out.write(`${line}\n`);
432
+ }
433
+ if (lines.length > viewportHeight) {
434
+ const parts = [];
435
+ if (scroll > 0)
436
+ parts.push(`↑ ${scroll} above`);
437
+ const below = lines.length - scroll - viewportHeight;
438
+ if (below > 0)
439
+ parts.push(`↓ ${below} below`);
440
+ out.write(`\n${DIM} ${parts.join(' ')}${RESET}\n`);
441
+ }
442
+ }
443
+ // ── Help ──────────────────────────────────────────────────────────────────────
444
+ const HELP = `
445
+ airlock configure-cli — interactively select and configure CLI tool commands
446
+
447
+ Usage:
448
+ airlock configure-cli <tool> [options]
449
+
450
+ Options:
451
+ --fig Try Fig autocomplete specs first
452
+ --max-depth <n> Max subcommand recursion depth (default: 2)
453
+ --output, -o <path> Write YAML to file instead of action picker
454
+ -h, --help Show this help
455
+
456
+ Examples:
457
+ airlock configure-cli gog
458
+ airlock configure-cli docker --fig
459
+ airlock configure-cli git --max-depth 3 -o git-commands.yaml
460
+ `;
461
+ // ── Entry point ───────────────────────────────────────────────────────────────
462
+ export async function runConfigureCli(argv) {
463
+ const { values, positionals } = parseArgs({
464
+ args: argv,
465
+ options: {
466
+ output: { type: 'string', short: 'o' },
467
+ fig: { type: 'boolean', default: false },
468
+ 'max-depth': { type: 'string' },
469
+ help: { type: 'boolean', short: 'h', default: false },
470
+ },
471
+ allowPositionals: true,
472
+ });
473
+ if (values.help || positionals.length === 0) {
474
+ console.log(HELP);
475
+ process.exit(positionals.length === 0 ? 1 : 0);
476
+ }
477
+ const tool = positionals[0];
478
+ const maxDepth = values['max-depth'] ? parseInt(values['max-depth'], 10) : undefined;
479
+ // ── Discovery phase ──────────────────────────────────────────────────────
480
+ process.stdout.write(`\n${BOLD}Discovering commands for ${CYAN}${tool}${RESET}${BOLD}…${RESET}\n`);
481
+ let strategy;
482
+ const completionSession = !values.fig ? createCompletionSession(tool) : null;
483
+ let groups;
484
+ if (values.output || !completionSession) {
485
+ const discovery = await discoverCliCommands({
486
+ tool,
487
+ fromFig: values.fig,
488
+ maxDepth,
489
+ });
490
+ const commands = discovery.commands;
491
+ strategy = discovery.strategy;
492
+ if (strategy.startsWith('completion:')) {
493
+ process.stdout.write(`${DIM} detected ${strategy.replace('completion:', '')} completion support${RESET}\n`);
494
+ }
495
+ else if (strategy === 'help-text') {
496
+ process.stdout.write(`${DIM} falling back to --help parsing${RESET}\n`);
497
+ }
498
+ const commandCount = Object.keys(commands).length;
499
+ if (commandCount === 0) {
500
+ console.error(`No commands discovered for "${tool}".`);
501
+ process.exit(1);
502
+ }
503
+ groups = groupCommands(commands, tool);
504
+ const globalFlags = detectGlobalFlagsFromGroups(groups);
505
+ let stripGlobal = true;
506
+ process.stdout.write(`${GREEN}Found ${commandCount} commands${RESET}` +
507
+ ` ${DIM}(${globalFlags.universal.size} global flags detected: ${[...globalFlags.universal].join(', ')})${RESET}\n`);
508
+ process.stdout.write(`${DIM}Grouped into ${groups.length} sections.${RESET}\n\n`);
509
+ if (values.output) {
510
+ const data = buildCompactConfig(tool, groups, globalFlags, stripGlobal);
511
+ const yaml = serializeCompact(data, tool, strategy);
512
+ writeFileSync(values.output, yaml);
513
+ console.log(`Written to ${values.output}`);
514
+ process.exit(0);
515
+ }
516
+ await runInteractiveConfigurator(tool, strategy, groups, stripGlobal, undefined, maxDepth);
517
+ return;
518
+ }
519
+ strategy = `completion:${completionSession.adapterId}`;
520
+ process.stdout.write(`${DIM} detected ${strategy.replace('completion:', '')} completion support${RESET}\n`);
521
+ const rootCommand = completionSession.loadCommand([], tool);
522
+ const topLevelSubcommands = completionSession.listTopLevelSubcommands();
523
+ groups = createLazyGroups(tool, topLevelSubcommands, rootCommand);
524
+ if (groups.length === 0) {
525
+ console.error(`No commands discovered for "${tool}".`);
526
+ process.exit(1);
527
+ }
528
+ const initialGlobalFlags = detectGlobalFlagsFromGroups(groups);
529
+ process.stdout.write(`${GREEN}Loaded ${groups.filter((group) => group.loaded).length} section(s) immediately${RESET}` +
530
+ ` ${DIM}(${topLevelSubcommands.length} more available to load on demand)${RESET}\n`);
531
+ process.stdout.write(`${DIM}Global flags currently based on loaded commands: ${[...initialGlobalFlags.universal].join(', ') || 'none'}${RESET}\n\n`);
532
+ await runInteractiveConfigurator(tool, strategy, groups, true, completionSession, maxDepth);
533
+ return;
534
+ }
535
+ async function runInteractiveConfigurator(tool, strategy, groups, initialStripGlobal, completionSession, maxDepth) {
536
+ process.stdout.write(`${BOLD}Press any key to start the configurator…${RESET}`);
537
+ const ttyFd = openSync('/dev/tty', 'r+');
538
+ const tty = new ReadStream(ttyFd);
539
+ tty.setEncoding('utf8');
540
+ await new Promise((resolve) => {
541
+ tty.setRawMode(true);
542
+ tty.once('data', () => resolve());
543
+ tty.resume();
544
+ });
545
+ process.stdout.write('\x1b[?1049h');
546
+ let viewLevel = 'groups';
547
+ let activeGroupIdx = 0;
548
+ let rowIdx = 0;
549
+ let done = false;
550
+ let quit = false;
551
+ let inspectMode = false;
552
+ let inspectScroll = 0;
553
+ let stripGlobal = initialStripGlobal;
554
+ let searchTerm = '';
555
+ let searchMode = false;
556
+ function currentGlobalFlags() {
557
+ return detectGlobalFlagsFromGroups(groups);
558
+ }
559
+ function getMaxIdx() {
560
+ return Math.max(0, buildVisibleRows(groups, viewLevel, activeGroupIdx, searchTerm).length - 1);
561
+ }
562
+ function getVisibleRows() {
563
+ return buildVisibleRows(groups, viewLevel, activeGroupIdx, searchTerm);
564
+ }
565
+ function getSelectedEntry() {
566
+ const row = getVisibleRows()[rowIdx];
567
+ return row?.type === 'command' ? row.entry : null;
568
+ }
569
+ function renderCurrent() {
570
+ const visibleRows = getVisibleRows();
571
+ if (rowIdx > Math.max(0, visibleRows.length - 1))
572
+ rowIdx = Math.max(0, visibleRows.length - 1);
573
+ render(groups, viewLevel, activeGroupIdx, rowIdx, tool, currentGlobalFlags(), stripGlobal, searchTerm, searchMode);
574
+ }
575
+ function ensureGroupLoaded(group, renderWhileLoading = true) {
576
+ if (group.loaded || group.loading || !completionSession || group.path.length === 0)
577
+ return;
578
+ group.loading = true;
579
+ group.error = undefined;
580
+ if (renderWhileLoading)
581
+ renderCurrent();
582
+ try {
583
+ const remainingDepth = Math.max(0, (maxDepth ?? 2) - group.path.length);
584
+ const discovered = completionSession.loadPath(group.path, { maxDepth: remainingDepth });
585
+ group.entries = Object.entries(discovered)
586
+ .map(([name, config]) => ({ name, config, enabled: group.pendingEnabled }))
587
+ .sort((a, b) => a.name.localeCompare(b.name));
588
+ group.loaded = true;
589
+ }
590
+ catch (error) {
591
+ group.error = error instanceof Error ? error.message : 'Failed to load group';
592
+ group.entries = [];
593
+ }
594
+ finally {
595
+ group.loading = false;
596
+ }
597
+ }
598
+ function ensureSelectedGroupsLoadedForOutput() {
599
+ if (!completionSession)
600
+ return;
601
+ process.stdout.write('\x1b[?1049l');
602
+ process.stdout.write(`\n${DIM}Loading selected groups…${RESET}\n`);
603
+ for (const group of groups) {
604
+ if (!group.loaded && group.pendingEnabled) {
605
+ ensureGroupLoaded(group, false);
606
+ }
607
+ }
608
+ }
609
+ renderCurrent();
610
+ await new Promise((resolve) => {
611
+ tty.on('data', (key) => {
612
+ if (done)
613
+ return;
614
+ if (inspectMode) {
615
+ const entry = getSelectedEntry();
616
+ if (!entry) {
617
+ inspectMode = false;
618
+ renderCurrent();
619
+ return;
620
+ }
621
+ if (key === 'i' || key === 'h' || key === '\x1b' || key === '\x03') {
622
+ inspectMode = false;
623
+ renderCurrent();
624
+ return;
625
+ }
626
+ if (key === 'j' || key === `${ESC}B`)
627
+ inspectScroll++;
628
+ if (key === 'k' || key === `${ESC}A`)
629
+ inspectScroll = Math.max(0, inspectScroll - 1);
630
+ if (key === ' ')
631
+ entry.enabled = !entry.enabled;
632
+ renderInspect(entry, currentGlobalFlags(), stripGlobal, inspectScroll);
633
+ return;
634
+ }
635
+ if (searchMode) {
636
+ if (key === '\r' || key === '\n' || key === '\x1b') {
637
+ searchMode = false;
638
+ rowIdx = 0;
639
+ renderCurrent();
640
+ return;
641
+ }
642
+ if (key === '\x7f') {
643
+ searchTerm = searchTerm.slice(0, -1);
644
+ rowIdx = 0;
645
+ renderCurrent();
646
+ return;
647
+ }
648
+ if (key >= ' ' && key <= '~') {
649
+ searchTerm += key;
650
+ rowIdx = 0;
651
+ renderCurrent();
652
+ return;
653
+ }
654
+ return;
655
+ }
656
+ if (key === '\x03' || key === 'q') {
657
+ quit = true;
658
+ done = true;
659
+ process.stdout.write('\x1b[?1049l');
660
+ tty.setRawMode(false);
661
+ tty.destroy();
662
+ resolve();
663
+ return;
664
+ }
665
+ if (viewLevel === 'groups' && (key === 'l' || key === '\r' || key === '\n')) {
666
+ const row = getVisibleRows()[rowIdx];
667
+ if (row?.type === 'group-header') {
668
+ const group = row.group;
669
+ ensureGroupLoaded(group);
670
+ activeGroupIdx = row.groupIdx;
671
+ viewLevel = 'commands';
672
+ rowIdx = 0;
673
+ renderCurrent();
674
+ return;
675
+ }
676
+ }
677
+ if (viewLevel === 'commands' && key === 'l') {
678
+ const entry = getSelectedEntry();
679
+ if (entry) {
680
+ inspectMode = true;
681
+ inspectScroll = 0;
682
+ renderInspect(entry, currentGlobalFlags(), stripGlobal, inspectScroll);
683
+ return;
684
+ }
685
+ }
686
+ if (viewLevel === 'groups' && key === 'w') {
687
+ done = true;
688
+ process.stdout.write('\x1b[?1049l');
689
+ tty.setRawMode(false);
690
+ tty.destroy();
691
+ resolve();
692
+ return;
693
+ }
694
+ if (viewLevel === 'commands' &&
695
+ (key === 'h' || key === '\x1b' || key === '\r' || key === '\n')) {
696
+ viewLevel = 'groups';
697
+ rowIdx = Math.max(0, getVisibleRows().findIndex((row) => row.type === 'group-header' && row.groupIdx === activeGroupIdx));
698
+ renderCurrent();
699
+ return;
700
+ }
701
+ if (key === '/') {
702
+ searchMode = true;
703
+ searchTerm = '';
704
+ rowIdx = 0;
705
+ renderCurrent();
706
+ return;
707
+ }
708
+ const maxIdx = getMaxIdx();
709
+ if (key === 'j' || key === `${ESC}B`)
710
+ rowIdx = Math.min(rowIdx + 1, maxIdx);
711
+ if (key === 'k' || key === `${ESC}A`)
712
+ rowIdx = Math.max(rowIdx - 1, 0);
713
+ if (key === ' ') {
714
+ if (viewLevel === 'groups') {
715
+ const row = getVisibleRows()[rowIdx];
716
+ if (row?.type !== 'group-header') {
717
+ renderCurrent();
718
+ return;
719
+ }
720
+ const group = row.group;
721
+ if (!group.loaded) {
722
+ group.pendingEnabled = !group.pendingEnabled;
723
+ }
724
+ else {
725
+ const allEnabled = group.entries.every((entry) => entry.enabled);
726
+ for (const entry of group.entries)
727
+ entry.enabled = !allEnabled;
728
+ group.pendingEnabled = !allEnabled;
729
+ }
730
+ }
731
+ else {
732
+ const row = getVisibleRows()[rowIdx];
733
+ const entry = row?.type === 'command' ? row.entry : null;
734
+ if (entry)
735
+ entry.enabled = !entry.enabled;
736
+ }
737
+ }
738
+ if (key === 'a') {
739
+ if (viewLevel === 'groups') {
740
+ for (const group of groups) {
741
+ group.pendingEnabled = true;
742
+ for (const entry of group.entries)
743
+ entry.enabled = true;
744
+ }
745
+ }
746
+ else {
747
+ const group = groups[activeGroupIdx];
748
+ group.pendingEnabled = true;
749
+ for (const entry of group.entries)
750
+ entry.enabled = true;
751
+ }
752
+ }
753
+ if (key === 'n') {
754
+ if (viewLevel === 'groups') {
755
+ for (const group of groups) {
756
+ group.pendingEnabled = false;
757
+ for (const entry of group.entries)
758
+ entry.enabled = false;
759
+ }
760
+ }
761
+ else {
762
+ const group = groups[activeGroupIdx];
763
+ group.pendingEnabled = false;
764
+ for (const entry of group.entries)
765
+ entry.enabled = false;
766
+ }
767
+ }
768
+ if (key === 'g' && viewLevel === 'groups') {
769
+ stripGlobal = !stripGlobal;
770
+ }
771
+ if (key === 'i' && viewLevel === 'commands') {
772
+ const entry = getSelectedEntry();
773
+ if (entry) {
774
+ inspectMode = true;
775
+ inspectScroll = 0;
776
+ renderInspect(entry, currentGlobalFlags(), stripGlobal, inspectScroll);
777
+ return;
778
+ }
779
+ }
780
+ renderCurrent();
781
+ });
782
+ });
783
+ if (quit) {
784
+ console.log('Aborted.');
785
+ process.exit(0);
786
+ }
787
+ ensureSelectedGroupsLoadedForOutput();
788
+ const globalFlags = currentGlobalFlags();
789
+ const enabledCount = groups.reduce((sum, group) => sum + group.entries.filter((entry) => entry.enabled).length, 0);
790
+ if (enabledCount === 0) {
791
+ console.log('No commands selected. Nothing to output.');
792
+ process.exit(0);
793
+ }
794
+ const data = buildCompactConfig(tool, groups, globalFlags, stripGlobal);
795
+ const yaml = serializeCompact(data, tool, strategy);
796
+ process.stdout.write(`\n${BOLD}${CYAN}${enabledCount} commands selected.${RESET} What would you like to do?\n`);
797
+ process.stdout.write(` ${BOLD}[e]${RESET} edit airlock.yaml directly ${DIM}(backs up original to .bak)${RESET}\n`);
798
+ process.stdout.write(` ${BOLD}[c]${RESET} copy to clipboard\n`);
799
+ process.stdout.write(` ${BOLD}[p]${RESET} print to stdout\n`);
800
+ process.stdout.write(` ${BOLD}[o]${RESET} write to file\n`);
801
+ process.stdout.write(` ${BOLD}[q]${RESET} abort\n\n`);
802
+ process.stdout.write(`> `);
803
+ const ttyFd2 = openSync('/dev/tty', 'r+');
804
+ const tty2 = new ReadStream(ttyFd2);
805
+ tty2.setEncoding('utf8');
806
+ const action = await new Promise((resolve) => {
807
+ tty2.setRawMode(true);
808
+ tty2.resume();
809
+ tty2.once('data', (key) => {
810
+ tty2.setRawMode(false);
811
+ tty2.destroy();
812
+ resolve(key);
813
+ });
814
+ });
815
+ process.stdout.write('\n');
816
+ if (action === 'q' || action === '\x03') {
817
+ console.log('Aborted.');
818
+ process.exit(0);
819
+ }
820
+ if (action === 'p') {
821
+ console.log(`\n${BOLD}${CYAN}# Paste into your airlock.yaml:${RESET}\n`);
822
+ console.log(yaml);
823
+ console.log();
824
+ process.exit(0);
825
+ }
826
+ if (action === 'c') {
827
+ const clipCmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip -selection clipboard';
828
+ try {
829
+ execSync(clipCmd, { input: yaml });
830
+ console.log(`${GREEN}✓ Copied to clipboard.${RESET}`);
831
+ }
832
+ catch {
833
+ console.error(`Could not copy to clipboard. Here is the YAML:\n`);
834
+ console.log(yaml);
835
+ }
836
+ process.exit(0);
837
+ }
838
+ if (action === 'o') {
839
+ const outputPath = `${tool}-commands.yaml`;
840
+ writeFileSync(outputPath, yaml);
841
+ console.log(`${GREEN}✓ Written to ${outputPath}${RESET}`);
842
+ process.exit(0);
843
+ }
844
+ if (action === 'e') {
845
+ const configPath = './airlock.yaml';
846
+ try {
847
+ const raw = readFileSync(configPath, 'utf8');
848
+ const backupPath = configPath.replace(/\.ya?ml$/i, '') + '.bak';
849
+ copyFileSync(configPath, backupPath);
850
+ const doc = parseYaml(raw);
851
+ const existingClis = (doc.clis ?? {});
852
+ const newClis = data.clis;
853
+ doc.clis = { ...existingClis, ...newClis };
854
+ writeFileSync(configPath, stringifyYaml(doc));
855
+ console.log(`${GREEN}✓ Updated ${configPath}${RESET}`);
856
+ console.log(`${DIM} Original backed up to ${backupPath}${RESET}`);
857
+ }
858
+ catch {
859
+ console.error(`Could not find ${configPath}. Printing YAML instead:\n`);
860
+ console.log(yaml);
861
+ }
862
+ process.exit(0);
863
+ }
864
+ console.log(`\n${BOLD}${CYAN}# Paste into your airlock.yaml:${RESET}\n`);
865
+ console.log(yaml);
866
+ console.log();
867
+ }
868
+ //# sourceMappingURL=cli.js.map