singleton-pipeline 0.4.0-beta.11 → 0.4.0-beta.13
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/CHANGELOG.md +49 -0
- package/README.md +170 -129
- package/docs/reference.md +63 -18
- package/package.json +2 -1
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/new.js +455 -109
- package/packages/cli/src/commands/repl.js +86 -89
- package/packages/cli/src/executor/debug-loop.js +587 -0
- package/packages/cli/src/executor/inputs.js +202 -0
- package/packages/cli/src/executor/outputs.js +140 -0
- package/packages/cli/src/executor/preflight.js +459 -0
- package/packages/cli/src/executor/replay-loop.js +172 -0
- package/packages/cli/src/executor/run-report.js +189 -0
- package/packages/cli/src/executor/run-setup.js +93 -0
- package/packages/cli/src/executor/security-review.js +108 -0
- package/packages/cli/src/executor/snapshot-manager.js +335 -0
- package/packages/cli/src/executor/step-runner.js +266 -0
- package/packages/cli/src/executor.js +233 -2233
- package/packages/cli/src/index.js +1 -1
- package/packages/cli/src/runners/copilot.js +20 -22
- package/packages/cli/src/shell.js +244 -54
- package/packages/cli/src/timeline.js +54 -20
- package/packages/server/package.json +1 -1
- package/packages/web/package.json +1 -1
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { input, search, select, confirm } from '@inquirer/prompts';
|
|
4
4
|
import { style, line } from '../theme.js';
|
|
5
5
|
import { scanAgents } from '../scanner.js';
|
|
6
|
+
import { S } from '../shell.js';
|
|
6
7
|
|
|
7
8
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
8
9
|
const CHOICE_NONE = { name: '(none)', value: '' };
|
|
@@ -133,16 +134,6 @@ async function writeAgentDraft({ root, draft, askOverwrite }) {
|
|
|
133
134
|
return targetFile;
|
|
134
135
|
}
|
|
135
136
|
|
|
136
|
-
async function askShellValue(shell, message, { defaultValue = '', validate = null, normalize = (v) => v } = {}) {
|
|
137
|
-
while (true) {
|
|
138
|
-
const answer = await shell.prompt(defaultValue ? `${message} (default: ${defaultValue})` : message);
|
|
139
|
-
const value = answer.trim() || defaultValue;
|
|
140
|
-
const verdict = validate ? validate(value) : true;
|
|
141
|
-
if (verdict === true) return normalize(value);
|
|
142
|
-
shell.log(`{red-fg}✕{/} ${verdict}`);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
137
|
function modelChoicesForProvider(provider) {
|
|
147
138
|
if (provider === 'codex') return CODEX_MODELS;
|
|
148
139
|
if (provider === 'copilot') return COPILOT_MODELS;
|
|
@@ -150,25 +141,6 @@ function modelChoicesForProvider(provider) {
|
|
|
150
141
|
return CLAUDE_MODELS;
|
|
151
142
|
}
|
|
152
143
|
|
|
153
|
-
async function askShellChoice(shell, message, choices, defaultValue) {
|
|
154
|
-
shell.log('');
|
|
155
|
-
shell.log(`{bold}${message}{/}`);
|
|
156
|
-
choices.forEach((choice, index) => {
|
|
157
|
-
const marker = choice.value === defaultValue ? 'default' : 'option';
|
|
158
|
-
shell.log(` {#797C81-fg}${index + 1}.{/} ${choice.name}${choice.value === defaultValue ? ` {#797C81-fg}(${marker}){/}` : ''}`);
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
while (true) {
|
|
162
|
-
const answer = (await shell.prompt(`${message} (number or exact value)`)).trim();
|
|
163
|
-
const raw = answer || defaultValue;
|
|
164
|
-
const byIndex = /^\d+$/.test(raw) ? choices[Number(raw) - 1] : null;
|
|
165
|
-
const byValue = choices.find((choice) => choice.value === raw);
|
|
166
|
-
const selected = byIndex || byValue;
|
|
167
|
-
if (selected) return selected.value;
|
|
168
|
-
shell.log(`{red-fg}✕{/} Choose a value from the list.`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
144
|
const DONE = '__DONE__';
|
|
173
145
|
const UNDO = '__UNDO__';
|
|
174
146
|
const SLUG_ITEM_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
@@ -321,101 +293,475 @@ export async function newAgentCommand(opts) {
|
|
|
321
293
|
console.log(line.success(path.relative(root, targetFile)));
|
|
322
294
|
}
|
|
323
295
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
296
|
+
// ── Shell flow (TUI) ──────────────────────────────────────────────────────
|
|
297
|
+
//
|
|
298
|
+
// Form-style multi-section view rendered in the content widget. The user
|
|
299
|
+
// answers fields one by one in the prompt bar; the form above always
|
|
300
|
+
// reflects current draft state. `:back` jumps to the previous field,
|
|
301
|
+
// `:cancel` aborts.
|
|
302
|
+
|
|
303
|
+
const ESC = '__SINGLETON_ESC__';
|
|
304
|
+
|
|
305
|
+
const SECTIONS = [
|
|
306
|
+
{
|
|
307
|
+
title: 'identity',
|
|
308
|
+
hint: 'Who this agent is and what it does.',
|
|
309
|
+
fields: ['id', 'title', 'description'],
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
title: 'schema',
|
|
313
|
+
hint: 'What it consumes and produces.',
|
|
314
|
+
fields: ['inputs', 'outputs', 'tags'],
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
title: 'runtime',
|
|
318
|
+
hint: 'How it executes and which model it uses.',
|
|
319
|
+
fields: ['provider', 'model', 'permissionMode', 'runnerAgent'],
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
title: 'meta',
|
|
323
|
+
hint: 'File location and token budget.',
|
|
324
|
+
fields: ['estimatedTokens', 'file'],
|
|
325
|
+
},
|
|
326
|
+
];
|
|
328
327
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
const FIELD_LABELS = {
|
|
329
|
+
id: 'id',
|
|
330
|
+
title: 'title',
|
|
331
|
+
description: 'description',
|
|
332
|
+
inputs: 'inputs',
|
|
333
|
+
outputs: 'outputs',
|
|
334
|
+
tags: 'tags',
|
|
335
|
+
provider: 'provider',
|
|
336
|
+
model: 'model',
|
|
337
|
+
permissionMode: 'permission mode',
|
|
338
|
+
runnerAgent: 'runner agent',
|
|
339
|
+
estimatedTokens: 'estimated tokens',
|
|
340
|
+
file: 'file',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
function isFieldVisible(name, draft) {
|
|
344
|
+
if (name === 'permissionMode') return draft.provider === 'claude';
|
|
345
|
+
if (name === 'runnerAgent') return ['copilot', 'opencode'].includes(draft.provider);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
332
348
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
349
|
+
function buildOrder(draft) {
|
|
350
|
+
const order = [];
|
|
351
|
+
for (const section of SECTIONS) {
|
|
352
|
+
for (const name of section.fields) {
|
|
353
|
+
if (!isFieldVisible(name, draft)) continue;
|
|
354
|
+
order.push({ section: section.title, name });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return order;
|
|
358
|
+
}
|
|
336
359
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
360
|
+
function formatValue(name, value) {
|
|
361
|
+
if (value === null || value === undefined) return null;
|
|
362
|
+
if (Array.isArray(value)) return value.length ? value.join(', ') : null;
|
|
363
|
+
if (value === '') return null;
|
|
364
|
+
return String(value);
|
|
365
|
+
}
|
|
340
366
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
function defaultFor(name, draft) {
|
|
368
|
+
if (name === 'title') return draft.id ? defaultTitleFromId(draft.id) : '';
|
|
369
|
+
if (name === 'provider') return 'claude';
|
|
370
|
+
if (name === 'model') return defaultModelForProvider(draft.provider);
|
|
371
|
+
if (name === 'runnerAgent') return draft.id || '';
|
|
372
|
+
if (name === 'file') return draft.id ? `${draft.id}.md` : '';
|
|
373
|
+
if (name === 'permissionMode') return '';
|
|
374
|
+
return '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function suggestionsFor(name, draft, context) {
|
|
378
|
+
if (name === 'inputs') return uniqueSorted([...context.existingOutputs, ...context.existingInputs]);
|
|
379
|
+
if (name === 'outputs') return context.existingOutputs;
|
|
380
|
+
if (name === 'tags') return context.existingTags;
|
|
381
|
+
if (name === 'provider') return PROVIDERS.map((p) => p.value);
|
|
382
|
+
if (name === 'model') return modelChoicesForProvider(draft.provider).map((m) => m.value).filter(Boolean);
|
|
383
|
+
if (name === 'permissionMode') return ['(default)', 'bypassPermissions'];
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Builds a `shell.prompt({ completer })` callback for a scalar field. Items match
|
|
388
|
+
// the buffer substring, case-insensitively. Returns null when no autocomplete applies.
|
|
389
|
+
function buildScalarCompleter(name, draft, context) {
|
|
390
|
+
if (!['provider', 'model', 'permissionMode'].includes(name)) return null;
|
|
391
|
+
const items = suggestionsFor(name, draft, context);
|
|
392
|
+
if (!items.length) return null;
|
|
393
|
+
return ({ buffer }) => {
|
|
394
|
+
const t = String(buffer || '').toLowerCase();
|
|
395
|
+
return items
|
|
396
|
+
.filter((v) => v.toLowerCase().includes(t))
|
|
397
|
+
.map((v) => ({ label: v, value: v }));
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Completer for list fields: filters existing repo values, excludes already-picked
|
|
402
|
+
// items, and offers a `+ create "<term>"` row when the user types a fresh slug.
|
|
403
|
+
function buildListCompleter(name, draft, context, picked) {
|
|
404
|
+
const all = suggestionsFor(name, draft, context);
|
|
405
|
+
return ({ buffer }) => {
|
|
406
|
+
const t = String(buffer || '').trim();
|
|
407
|
+
const tl = t.toLowerCase();
|
|
408
|
+
const avail = all.filter((v) => !picked.includes(v));
|
|
409
|
+
const matching = tl
|
|
410
|
+
? avail.filter((v) => v.toLowerCase().includes(tl))
|
|
411
|
+
: avail;
|
|
412
|
+
const items = matching.map((v) => ({ label: v, value: v }));
|
|
413
|
+
if (t && SLUG_ITEM_RE.test(t) && !all.includes(t) && !picked.includes(t)) {
|
|
414
|
+
items.push({ label: `+ create "${t}"`, value: t });
|
|
415
|
+
}
|
|
416
|
+
return items;
|
|
417
|
+
};
|
|
418
|
+
}
|
|
344
419
|
|
|
345
|
-
|
|
346
|
-
|
|
420
|
+
function validateFieldRaw(name, value, draft, context) {
|
|
421
|
+
if (name === 'id') {
|
|
422
|
+
const verdict = validateAgentId(context.existingIds, value);
|
|
423
|
+
return verdict === true ? null : verdict;
|
|
347
424
|
}
|
|
348
|
-
|
|
425
|
+
if (name === 'description' && !value.trim()) return 'required';
|
|
426
|
+
if (name === 'outputs' && parseCsvList(value).length === 0) return 'at least one output is required';
|
|
427
|
+
if (name === 'provider' && !PROVIDERS.some((p) => p.value === value)) {
|
|
428
|
+
return `choose one of: ${PROVIDERS.map((p) => p.value).join(', ')}`;
|
|
429
|
+
}
|
|
430
|
+
if (name === 'model') {
|
|
431
|
+
const allowed = modelChoicesForProvider(draft.provider).map((m) => m.value);
|
|
432
|
+
if (value && !allowed.includes(value)) return `unknown model for ${draft.provider}`;
|
|
433
|
+
}
|
|
434
|
+
if (name === 'permissionMode') {
|
|
435
|
+
if (value && value !== '(default)' && value !== 'bypassPermissions') {
|
|
436
|
+
return 'choose: (default) or bypassPermissions';
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (name === 'estimatedTokens' && value !== '' && !/^\d+$/.test(value)) return 'expected an integer';
|
|
440
|
+
if (name === 'file' && !value.endsWith('.md')) return 'must end with .md';
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
349
443
|
|
|
350
|
-
|
|
351
|
-
|
|
444
|
+
function parseFieldValue(name, value) {
|
|
445
|
+
if (['inputs', 'outputs', 'tags'].includes(name)) return parseCsvList(value);
|
|
446
|
+
if (name === 'permissionMode' && (value === '' || value === '(default)')) return '';
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function renderForm(shell, { draft, activeField, currentStep, totalSteps, context, error = null }) {
|
|
451
|
+
const lines = [];
|
|
452
|
+
|
|
453
|
+
lines.push('');
|
|
454
|
+
lines.push(
|
|
455
|
+
`{${S.text}-fg}{bold}New agent{/}` +
|
|
456
|
+
` {${S.subtle}-fg}·{/} {${S.muted}-fg}step ${currentStep}/${totalSteps}{/}` +
|
|
457
|
+
` {${S.subtle}-fg}·{/} {${S.keyword}-fg}${FIELD_LABELS[activeField]}{/}`
|
|
458
|
+
);
|
|
459
|
+
lines.push(` {${S.subtle}-fg}:back to revisit · :cancel to abort{/}`);
|
|
460
|
+
lines.push('');
|
|
461
|
+
|
|
462
|
+
for (const section of SECTIONS) {
|
|
463
|
+
const visible = section.fields.filter((name) => isFieldVisible(name, draft));
|
|
464
|
+
if (!visible.length) continue;
|
|
465
|
+
|
|
466
|
+
lines.push(` {${S.muted}-fg}{bold}${section.title}{/} {${S.subtle}-fg}${section.hint}{/}`);
|
|
467
|
+
|
|
468
|
+
for (const name of visible) {
|
|
469
|
+
const label = FIELD_LABELS[name].padEnd(16);
|
|
470
|
+
const value = formatValue(name, draft[name]);
|
|
471
|
+
const isActive = name === activeField;
|
|
472
|
+
|
|
473
|
+
if (isActive) {
|
|
474
|
+
lines.push(` {${S.accent}-fg}▸{/} {${S.text}-fg}{bold}${label}{/} {${S.muted}-fg}…{/}`);
|
|
475
|
+
} else if (value !== null) {
|
|
476
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.text}-fg}${value}{/}`);
|
|
477
|
+
} else {
|
|
478
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.subtle}-fg}—{/}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
lines.push('');
|
|
352
482
|
}
|
|
353
|
-
const outputs = parseCsvList(await askShellValue(shell, 'outputs (comma separated)', {
|
|
354
|
-
validate: (v) => (parseCsvList(v).length > 0 ? true : 'at least one output is required'),
|
|
355
|
-
}));
|
|
356
483
|
|
|
357
|
-
|
|
358
|
-
|
|
484
|
+
const suggestions = suggestionsFor(activeField, draft, context);
|
|
485
|
+
if (suggestions.length) {
|
|
486
|
+
const label = ['inputs', 'outputs', 'tags'].includes(activeField) ? 'existing' : 'options';
|
|
487
|
+
lines.push(` {${S.muted}-fg}${label}:{/} {${S.subtle}-fg}${suggestions.join(', ')}{/}`);
|
|
359
488
|
}
|
|
360
|
-
const tags = parseCsvList(await askShellValue(shell, 'tags (comma separated, optional)'));
|
|
361
|
-
|
|
362
|
-
const provider = await askShellChoice(shell, 'provider', PROVIDERS, 'claude');
|
|
363
|
-
const model = await askShellChoice(
|
|
364
|
-
shell,
|
|
365
|
-
'model',
|
|
366
|
-
modelChoicesForProvider(provider),
|
|
367
|
-
defaultModelForProvider(provider)
|
|
368
|
-
);
|
|
369
|
-
const permissionMode = provider === 'claude'
|
|
370
|
-
? await askShellChoice(shell, 'permission_mode', permissionChoicesForProvider(provider), '')
|
|
371
|
-
: '';
|
|
372
|
-
const runnerAgent = ['copilot', 'opencode'].includes(provider)
|
|
373
|
-
? await askShellValue(shell, 'runner_agent', { defaultValue: id })
|
|
374
|
-
: '';
|
|
375
489
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
490
|
+
// The default is rendered as ghost text inside the prompt bar itself
|
|
491
|
+
// (shell.prompt({ default })), so we don't duplicate it inline here.
|
|
379
492
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
493
|
+
if (error) {
|
|
494
|
+
lines.push('');
|
|
495
|
+
lines.push(` {${S.error}-fg}✕ ${error}{/}`);
|
|
496
|
+
}
|
|
384
497
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
498
|
+
shell.setContent(lines.join('\n'));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Review render — same shape as renderForm but no active field marker, and a
|
|
502
|
+
// concise header tailored to the confirmation step. The full target path is
|
|
503
|
+
// shown in the prompt bar message, not duplicated here.
|
|
504
|
+
function renderReview(shell, { draft, error = null }) {
|
|
505
|
+
const lines = [];
|
|
506
|
+
lines.push('');
|
|
507
|
+
lines.push(`{${S.text}-fg}{bold}New agent{/} {${S.subtle}-fg}·{/} {${S.muted}-fg}review{/}`);
|
|
508
|
+
lines.push('');
|
|
509
|
+
|
|
510
|
+
for (const section of SECTIONS) {
|
|
511
|
+
const visible = section.fields.filter((name) => isFieldVisible(name, draft));
|
|
512
|
+
if (!visible.length) continue;
|
|
513
|
+
|
|
514
|
+
lines.push(` {${S.muted}-fg}{bold}${section.title}{/} {${S.subtle}-fg}${section.hint}{/}`);
|
|
515
|
+
|
|
516
|
+
for (const name of visible) {
|
|
517
|
+
const label = FIELD_LABELS[name].padEnd(16);
|
|
518
|
+
const value = formatValue(name, draft[name]);
|
|
519
|
+
if (value !== null) {
|
|
520
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.text}-fg}${value}{/}`);
|
|
521
|
+
} else {
|
|
522
|
+
lines.push(` {${S.muted}-fg}${label}{/} {${S.subtle}-fg}—{/}`);
|
|
410
523
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
if (!targetFile) return null;
|
|
524
|
+
}
|
|
525
|
+
lines.push('');
|
|
526
|
+
}
|
|
415
527
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
528
|
+
if (error) {
|
|
529
|
+
lines.push(` {${S.error}-fg}✕ ${error}{/}`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
shell.setContent(lines.join('\n'));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function cancelled(shell) {
|
|
536
|
+
shell.setMode(null);
|
|
537
|
+
shell.clear();
|
|
538
|
+
shell.log(`{${S.muted}-fg}/new cancelled{/}`);
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// One-token-at-a-time collection sub-loop for list fields. Renders the form
|
|
543
|
+
// after each pick so the user sees the growing list. Returns 'done' / 'back' /
|
|
544
|
+
// 'cancelled'.
|
|
545
|
+
async function collectListInShell({ shell, draft, name, context, renderForField }) {
|
|
546
|
+
const picked = Array.isArray(draft[name]) ? [...draft[name]] : [];
|
|
547
|
+
const isRequired = name === 'outputs';
|
|
548
|
+
let error = null;
|
|
549
|
+
|
|
550
|
+
while (true) {
|
|
551
|
+
draft[name] = picked.length ? picked : null;
|
|
552
|
+
renderForField(error);
|
|
553
|
+
error = null;
|
|
554
|
+
|
|
555
|
+
const message = picked.length
|
|
556
|
+
? `${FIELD_LABELS[name]} + ${picked.length} picked · empty Enter to finish · :pop to undo last`
|
|
557
|
+
: `${FIELD_LABELS[name]} ${isRequired ? 'pick at least one' : 'empty Enter to skip'}`;
|
|
558
|
+
const completer = buildListCompleter(name, draft, context, picked);
|
|
559
|
+
const answer = await shell.prompt(message, { silent: true, completer });
|
|
560
|
+
if (answer === ESC) return 'cancelled';
|
|
561
|
+
|
|
562
|
+
const t = String(answer || '').trim();
|
|
563
|
+
if (t === ':cancel' || t === ':q') return 'cancelled';
|
|
564
|
+
if (t === ':back') {
|
|
565
|
+
draft[name] = null;
|
|
566
|
+
return 'back';
|
|
567
|
+
}
|
|
568
|
+
if (t === ':pop') {
|
|
569
|
+
if (picked.length > 0) picked.pop();
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (t === '') {
|
|
573
|
+
if (isRequired && picked.length === 0) {
|
|
574
|
+
error = 'at least one output is required';
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
draft[name] = picked;
|
|
578
|
+
return 'done';
|
|
579
|
+
}
|
|
580
|
+
if (!SLUG_ITEM_RE.test(t)) {
|
|
581
|
+
error = `invalid slug "${t}" (a-z, 0-9, hyphens, underscores)`;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (picked.includes(t)) {
|
|
585
|
+
error = `"${t}" already picked`;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
picked.push(t);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// Run the form loop starting at `startIndex`, mutating `draft` in place.
|
|
593
|
+
// Returns 'done' when all fields are filled, or 'cancelled' if the user aborted.
|
|
594
|
+
async function runForm({ shell, draft, context, startIndex = 0 }) {
|
|
595
|
+
let order = buildOrder(draft);
|
|
596
|
+
let i = Math.min(startIndex, order.length - 1);
|
|
597
|
+
let lastError = null;
|
|
598
|
+
|
|
599
|
+
while (i < order.length) {
|
|
600
|
+
const name = order[i].name;
|
|
601
|
+
const isList = ['inputs', 'outputs', 'tags'].includes(name);
|
|
602
|
+
|
|
603
|
+
const renderForField = (errOverride = null) => renderForm(shell, {
|
|
604
|
+
draft,
|
|
605
|
+
activeField: name,
|
|
606
|
+
currentStep: i + 1,
|
|
607
|
+
totalSteps: order.length,
|
|
608
|
+
context,
|
|
609
|
+
error: errOverride ?? lastError,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
if (isList) {
|
|
613
|
+
lastError = null;
|
|
614
|
+
const result = await collectListInShell({ shell, draft, name, context, renderForField });
|
|
615
|
+
if (result === 'cancelled') return 'cancelled';
|
|
616
|
+
if (result === 'back') {
|
|
617
|
+
if (i > 0) { i -= 1; draft[order[i].name] = null; order = buildOrder(draft); }
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
i += 1;
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
renderForField();
|
|
625
|
+
lastError = null;
|
|
626
|
+
|
|
627
|
+
const def = defaultFor(name, draft);
|
|
628
|
+
const completer = buildScalarCompleter(name, draft, context);
|
|
629
|
+
const answer = await shell.prompt(FIELD_LABELS[name], {
|
|
630
|
+
silent: true,
|
|
631
|
+
completer,
|
|
632
|
+
default: def,
|
|
633
|
+
});
|
|
634
|
+
if (answer === ESC) return 'cancelled';
|
|
635
|
+
|
|
636
|
+
const trimmed = String(answer || '').trim();
|
|
637
|
+
if (trimmed === ':cancel' || trimmed === ':q') return 'cancelled';
|
|
638
|
+
|
|
639
|
+
if (trimmed === ':back') {
|
|
640
|
+
if (i > 0) {
|
|
641
|
+
i -= 1;
|
|
642
|
+
draft[order[i].name] = null;
|
|
643
|
+
order = buildOrder(draft);
|
|
644
|
+
}
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Shell already substituted the default when buffer was empty; treat the answer as-is.
|
|
649
|
+
const raw = trimmed;
|
|
650
|
+
const err = validateFieldRaw(name, raw, draft, context);
|
|
651
|
+
if (err) { lastError = err; continue; }
|
|
652
|
+
|
|
653
|
+
draft[name] = parseFieldValue(name, raw);
|
|
654
|
+
|
|
655
|
+
if (name === 'provider') {
|
|
656
|
+
// Provider change adds/removes conditional fields; reset model if it's
|
|
657
|
+
// no longer valid for the new provider.
|
|
658
|
+
const allowedModels = modelChoicesForProvider(draft.provider).map((m) => m.value);
|
|
659
|
+
if (draft.model && !allowedModels.includes(draft.model)) draft.model = null;
|
|
660
|
+
order = buildOrder(draft);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
i += 1;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return 'done';
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export async function newAgentShellCommand({ root, shell }) {
|
|
670
|
+
const absRoot = path.resolve(root || process.cwd());
|
|
671
|
+
const context = await loadAgentCreationContext(absRoot);
|
|
672
|
+
|
|
673
|
+
const draft = {
|
|
674
|
+
id: null, title: null, description: null,
|
|
675
|
+
inputs: null, outputs: null, tags: null,
|
|
676
|
+
provider: null, model: null,
|
|
677
|
+
permissionMode: null, runnerAgent: null,
|
|
678
|
+
estimatedTokens: null, file: null,
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
shell.setMode('running');
|
|
682
|
+
|
|
683
|
+
const formResult = await runForm({ shell, draft, context });
|
|
684
|
+
if (formResult === 'cancelled') return cancelled(shell);
|
|
685
|
+
|
|
686
|
+
// ── Confirm + write ────────────────────────────────────────────────────
|
|
687
|
+
let targetDir = DEFAULT_AGENTS_DIR;
|
|
688
|
+
let confirmError = null;
|
|
689
|
+
|
|
690
|
+
while (true) {
|
|
691
|
+
renderReview(shell, { draft, error: confirmError });
|
|
692
|
+
confirmError = null;
|
|
693
|
+
|
|
694
|
+
const fullPath = path.join(targetDir, draft.file);
|
|
695
|
+
// The prompt message itself carries the target path and the inline action
|
|
696
|
+
// hints, so the form panel above stays clean and the user reads everything
|
|
697
|
+
// in one place. Tagged content is rendered verbatim by updatePrompt.
|
|
698
|
+
const promptMessage =
|
|
699
|
+
`{${S.warning}-fg}{bold}write to{/} {${S.keyword}-fg}${fullPath}{/}` +
|
|
700
|
+
` {${S.subtle}-fg}· :dir · :back · :cancel{/}`;
|
|
701
|
+
const answer = await shell.prompt(promptMessage, { silent: true });
|
|
702
|
+
if (answer === ESC) return cancelled(shell);
|
|
703
|
+
|
|
704
|
+
const trimmed = String(answer || '').trim();
|
|
705
|
+
|
|
706
|
+
if (trimmed === ':cancel' || trimmed === ':q') return cancelled(shell);
|
|
707
|
+
|
|
708
|
+
if (trimmed === ':back') {
|
|
709
|
+
// Re-edit the last field — clear it and run the form from that index.
|
|
710
|
+
const order = buildOrder(draft);
|
|
711
|
+
const lastIdx = order.length - 1;
|
|
712
|
+
draft[order[lastIdx].name] = null;
|
|
713
|
+
const r = await runForm({ shell, draft, context, startIndex: lastIdx });
|
|
714
|
+
if (r === 'cancelled') return cancelled(shell);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (trimmed === ':dir') {
|
|
719
|
+
const next = await shell.prompt('directory', { silent: true, default: targetDir });
|
|
720
|
+
if (next === ESC) continue;
|
|
721
|
+
const cleaned = String(next || '').trim();
|
|
722
|
+
// Reserved control tokens inside the dir sub-prompt: `:back` cancels just
|
|
723
|
+
// the directory change, `:cancel`/`:q` aborts the whole `/new`. Without
|
|
724
|
+
// this guard the user would end up creating a literal `:back/` folder.
|
|
725
|
+
if (cleaned === ':back') continue;
|
|
726
|
+
if (cleaned === ':cancel' || cleaned === ':q') return cancelled(shell);
|
|
727
|
+
if (cleaned) targetDir = cleaned;
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (trimmed === '' || ['y', 'yes'].includes(trimmed.toLowerCase())) {
|
|
732
|
+
const targetAbsDir = path.resolve(absRoot, targetDir);
|
|
733
|
+
const targetFile = path.join(targetAbsDir, draft.file);
|
|
734
|
+
|
|
735
|
+
let exists = false;
|
|
736
|
+
try { await fs.access(targetFile); exists = true; } catch {}
|
|
737
|
+
|
|
738
|
+
if (exists) {
|
|
739
|
+
const overwrite = await shell.prompt(`overwrite ${path.relative(absRoot, targetFile)}? [y/N]`, { silent: true });
|
|
740
|
+
if (overwrite === ESC) { confirmError = 'overwrite cancelled'; continue; }
|
|
741
|
+
const ow = String(overwrite || '').trim().toLowerCase();
|
|
742
|
+
if (!['y', 'yes'].includes(ow)) { confirmError = 'overwrite cancelled'; continue; }
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
await fs.mkdir(targetAbsDir, { recursive: true });
|
|
746
|
+
await fs.writeFile(targetFile, renderAgentFile(normalizeAgentDraft({
|
|
747
|
+
...draft,
|
|
748
|
+
filename: draft.file,
|
|
749
|
+
inputs: draft.inputs || [],
|
|
750
|
+
outputs: draft.outputs || [],
|
|
751
|
+
tags: draft.tags || [],
|
|
752
|
+
permissionMode: draft.permissionMode || '',
|
|
753
|
+
runnerAgent: draft.runnerAgent || '',
|
|
754
|
+
estimatedTokens: draft.estimatedTokens || '',
|
|
755
|
+
})));
|
|
756
|
+
|
|
757
|
+
shell.setMode(null);
|
|
758
|
+
shell.clear();
|
|
759
|
+
shell.log(`{${S.success}-fg}✓{/} {${S.text}-fg}${path.relative(absRoot, targetFile)}{/}`);
|
|
760
|
+
return targetFile;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
confirmError = 'unknown command (Enter, :dir, :back, :cancel)';
|
|
764
|
+
}
|
|
419
765
|
}
|
|
420
766
|
|
|
421
767
|
function renderAgentFile({ title, id, description, inputs, outputs, tags, provider, model, runnerAgent, permissionMode, estimatedTokens }) {
|