singleton-pipeline 0.4.0-beta.1 → 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.
@@ -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
- export async function newAgentShellCommand({ root, shell }) {
325
- const absRoot = path.resolve(root || process.cwd());
326
- const context = await loadAgentCreationContext(absRoot);
327
- const inputSuggestions = inputSuggestionsFromContext(context);
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
- shell.log('{bold}New agent{/}');
330
- shell.log(`{#797C81-fg}Canonical dir: ${DEFAULT_AGENTS_DIR}{/}`);
331
- shell.log('');
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
- const id = await askShellValue(shell, 'id', {
334
- validate: (v) => validateAgentId(context.existingIds, v),
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
- const title = await askShellValue(shell, 'title', {
338
- defaultValue: defaultTitleFromId(id),
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
- const description = await askShellValue(shell, 'description', {
342
- validate: (v) => (v.trim() ? true : 'required'),
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
- if (inputSuggestions.length) {
346
- shell.log(`{#797C81-fg}Input suggestions: ${inputSuggestions.join(', ')}{/}`);
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
- const inputs = parseCsvList(await askShellValue(shell, 'inputs (comma separated)'));
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
- if (context.existingOutputs.length) {
351
- shell.log(`{#797C81-fg}Output suggestions: ${context.existingOutputs.join(', ')}{/}`);
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
- if (context.existingTags.length) {
358
- shell.log(`{#797C81-fg}Tag suggestions: ${context.existingTags.join(', ')}{/}`);
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
- const estimatedTokens = await askShellValue(shell, 'estimated_tokens (optional)', {
377
- validate: (v) => (v === '' || /^\d+$/.test(v) ? true : 'expected an integer'),
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
- const filename = await askShellValue(shell, 'file', {
381
- defaultValue: `${id}.md`,
382
- validate: (v) => (v.endsWith('.md') ? true : 'must end with .md'),
383
- });
493
+ if (error) {
494
+ lines.push('');
495
+ lines.push(` {${S.error}-fg}✕ ${error}{/}`);
496
+ }
384
497
 
385
- const targetFile = await writeAgentDraft({
386
- root: absRoot,
387
- draft: {
388
- title,
389
- id,
390
- description,
391
- inputs,
392
- outputs,
393
- tags,
394
- provider,
395
- model,
396
- runnerAgent,
397
- permissionMode,
398
- estimatedTokens,
399
- filename,
400
- },
401
- askOverwrite: async (relativeFile) => {
402
- const overwrite = await askShellValue(shell, `${relativeFile} already exists. Overwrite? [y/N]`, {
403
- defaultValue: 'n',
404
- normalize: (v) => v.toLowerCase(),
405
- validate: (v) => (['y', 'yes', 'n', 'no'].includes(v.toLowerCase()) ? true : 'answer y or n'),
406
- });
407
- if (!['y', 'yes'].includes(overwrite)) {
408
- shell.log(`{#797C81-fg}Creation cancelled.{/}`);
409
- return false;
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
- return true;
412
- },
413
- });
414
- if (!targetFile) return null;
524
+ }
525
+ lines.push('');
526
+ }
415
527
 
416
- shell.log('');
417
- shell.log(`{green-fg}✓{/} ${path.relative(absRoot, targetFile)}`);
418
- return targetFile;
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 }) {