bmad-method 6.6.1-next.8 → 6.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +3 -3
  2. package/{tools/installer/modules/registry-fallback.yaml → bmad-modules.yaml} +29 -15
  3. package/package.json +4 -4
  4. package/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +18 -11
  5. package/src/bmm-skills/1-analysis/bmad-product-brief/customize.toml +13 -8
  6. package/src/bmm-skills/2-plan-workflows/bmad-prd/SKILL.md +54 -57
  7. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/headless-schemas.md +2 -2
  8. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-template.md +40 -30
  9. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/prd-validation-checklist.md +126 -21
  10. package/src/bmm-skills/2-plan-workflows/bmad-prd/assets/validation-report-template.html +193 -58
  11. package/src/bmm-skills/2-plan-workflows/bmad-prd/customize.toml +47 -13
  12. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/headless.md +27 -12
  13. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validate.md +97 -0
  14. package/src/bmm-skills/module.yaml +2 -2
  15. package/src/core-skills/module.yaml +1 -1
  16. package/tools/installer/core/installer.js +1 -22
  17. package/tools/installer/core/manifest.js +0 -22
  18. package/tools/installer/modules/channel-plan.js +1 -1
  19. package/tools/installer/modules/external-manager.js +9 -27
  20. package/tools/installer/modules/official-modules.js +9 -48
  21. package/tools/installer/prompts.js +149 -0
  22. package/tools/installer/ui.js +13 -197
  23. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/facilitation-guide.md +0 -79
  24. package/src/bmm-skills/2-plan-workflows/bmad-prd/references/validation-render.md +0 -58
  25. package/src/bmm-skills/2-plan-workflows/bmad-prd/scripts/render-validation-html.py +0 -290
  26. package/tools/installer/modules/community-manager.js +0 -704
  27. package/tools/installer/modules/registry-client.js +0 -187
@@ -0,0 +1,97 @@
1
+ # Validate
2
+
3
+ The Validate intent playbook. Standalone — this intent critiques an existing PRD without changing it and ends after the user has seen the report; it does not run Finalize. The synthesis pipeline below is also reused for mid-session report requests during Create/Update.
4
+
5
+ ## Orient
6
+
7
+ Source-extract against `.decision-log.md`, any original inputs, and the PRD/addendum themselves. Delegate to subagents per PRD Discipline → "Extract, don't ingest" (in SKILL.md); the parent assembles from extracts.
8
+
9
+ ## Run the Reviewer Gate
10
+
11
+ Run the Reviewer Gate (see SKILL.md) against `prd.md` (and `addendum.md` if present). The rubric walker is the default entry in the gate menu; under Validate intent it additionally runs the synthesis pipeline below. The Finalize discipline pass during Create/Update does NOT render a report — findings stay in-conversation.
12
+
13
+ ## Rubric-walker pipeline
14
+
15
+ The rubric walker is the primary review entry. Spawn it as a subagent with this prompt:
16
+
17
+ > You are validating a PRD against the quality rubric at `{workflow.validation_checklist_template}`. Read the full rubric first, then read `prd.md` (and `addendum.md` if present). Form a judgment per dimension — *strong / adequate / thin / broken* — and write findings only where they add information. Cite specific PRD locations and quote phrases. Severity ranks impact on the PRD's usefulness, not how easy the fix is. Write your review to `{doc_workspace}/review-rubric.md` in the format the rubric specifies. Return ONLY a compact summary (overall verdict, dimension verdicts, finding counts by severity, file path).
18
+
19
+ The Reviewer Gate may also dispatch additional reviewers from `{workflow.finalize_reviewers}` (adversarial-general by default) and any ad-hoc reviewers the parent judges warranted. Each writes its review to `{doc_workspace}/review-{slug}.md` and returns a compact summary. Run in parallel.
20
+
21
+ ## Synthesis pipeline
22
+
23
+ Once every selected reviewer has returned, the parent synthesizes one consolidated report. **Do not skip this step under Validate intent** — it produces the persistent artifact the user opens.
24
+
25
+ ### Inputs
26
+
27
+ - `{doc_workspace}/review-rubric.md` — primary, structured by the seven dimensions
28
+ - Zero or more `{doc_workspace}/review-{slug}.md` files — extra reviewers (adversarial, etc.)
29
+ - `{workflow.validation_report_template}` — the HTML skeleton
30
+
31
+ ### What the synthesis pass does
32
+
33
+ 1. Read every reviewer file in `{doc_workspace}/review-*.md`.
34
+ 2. Fill the HTML skeleton:
35
+ - **Header.** PRD name, path. Grade derived from the rubric verdicts and severity counts: *Excellent* = all dimensions strong/adequate, no high/critical findings · *Good* = ≤1 thin dimension, no critical findings · *Fair* = multiple thin dimensions or any high finding · *Poor* = any broken dimension or any critical finding. Set the matching `grade-excellent | grade-good | grade-fair | grade-poor` class.
36
+ - **Synthesis block.** Lift the rubric's *Overall verdict* paragraph as the lead; if adversarial or ad-hoc reviewers materially shift the picture, add a second paragraph that names what they surfaced.
37
+ - **Dimension summary cards.** One per dimension that was assessed. Colored verdict text. Skip dimensions the rubric marked n/a for this PRD (e.g. downstream usability for a standalone PRD).
38
+ - **Dimension sections.** One `<section class="dimension">` per assessed dimension, in rubric order. `<details open>` for *thin* and *broken*; closed for *strong* and *adequate*. Each contains the dimension judgment (the prose from review-rubric.md) and the findings list.
39
+ - **Reviewer sections.** One `<section class="reviewer-section">` per extra reviewer that ran. The source file path goes in the `<span class="reviewer-source">`. Closed by default. Adversarial findings keep their adversarial voice — do not soften.
40
+ - **Mechanical notes.** Bullet list from the rubric's "Mechanical notes" section. Skip the block if empty.
41
+ - **Footer.** Rubric path, ISO timestamp.
42
+ 3. Write the filled HTML to `{doc_workspace}/validation-report.html`.
43
+ 4. Write the markdown twin to `{doc_workspace}/validation-report.md` (same content, grouped by severity rather than by dimension — see format below; this is the canonical form for downstream re-reading).
44
+ 5. Open the HTML in the default browser:
45
+ ```bash
46
+ python3 -c "import webbrowser, pathlib; webbrowser.open(pathlib.Path('{doc_workspace}/validation-report.html').resolve().as_uri())"
47
+ ```
48
+ Skip the open step in headless mode (see `references/headless.md`).
49
+
50
+ ### Markdown twin format
51
+
52
+ ```markdown
53
+ # Validation Report — {prd_name}
54
+
55
+ - **PRD:** `{prd_path}`
56
+ - **Rubric:** `{rubric_path}`
57
+ - **Run at:** {ISO timestamp}
58
+ - **Grade:** {Excellent | Good | Fair | Poor}
59
+
60
+ ## Overall verdict
61
+ {synthesis paragraphs}
62
+
63
+ ## Dimension verdicts
64
+ - Decision-readiness — {verdict}
65
+ - Substance over theater — {verdict}
66
+ - (etc. for each assessed dimension)
67
+
68
+ ## Findings by severity
69
+
70
+ ### Critical (n)
71
+ **[Dimension or Reviewer]** — Title (§ location)
72
+ {Note}
73
+ Fix: {suggested fix}
74
+
75
+ ### High (n)
76
+ ...
77
+
78
+ ### Medium (n)
79
+ ...
80
+
81
+ ### Low (n)
82
+ ...
83
+
84
+ ## Mechanical notes
85
+ - {bullet}
86
+
87
+ ## Reviewer files
88
+ - `review-rubric.md`
89
+ - `review-adversarial-general.md` (if present)
90
+ - (etc.)
91
+ ```
92
+
93
+ Re-running validation overwrites the consolidated report in place. The individual `review-*.md` files are preserved so the user can drill in.
94
+
95
+ ## Close
96
+
97
+ Surface artifact paths; the rendered HTML/markdown is the persistent artifact. Always offer to roll findings into an Update.
@@ -1,6 +1,6 @@
1
1
  code: bmm
2
- name: "BMad Method Agile-AI Driven-Development"
3
- description: "AI-driven agile development framework"
2
+ name: "BMad Method"
3
+ description: "Full-lifecycle AI agile development: analysis, planning, architecture, implementation"
4
4
  default_selected: true # This module will be selected by default for new installations
5
5
 
6
6
  # Variables from Core Config inserted:
@@ -1,6 +1,6 @@
1
1
  code: core
2
2
  name: "BMad Core Module"
3
- description: "Core configuration and shared resources"
3
+ description: "Shared utilities across modules"
4
4
 
5
5
  header: "BMad Core Configuration"
6
6
  subheader: "Configure the core settings for your BMad installation.\nThese settings will be used across all installed bmad skills, workflows, and agents."
@@ -640,13 +640,7 @@ class Installer {
640
640
  const moduleInfo = sourcePath ? await officialModules.getModuleInfo(sourcePath, moduleName, '') : null;
641
641
  const displayName = moduleInfo?.name || moduleName;
642
642
 
643
- const externalResolution = officialModules.externalModuleManager.getResolution(moduleName);
644
- let communityResolution = null;
645
- if (!externalResolution) {
646
- const { CommunityModuleManager } = require('../modules/community-manager');
647
- communityResolution = new CommunityModuleManager().getResolution(moduleName);
648
- }
649
- const resolution = externalResolution || communityResolution;
643
+ const resolution = officialModules.externalModuleManager.getResolution(moduleName);
650
644
  const cachedResolution = CustomModuleManager._resolutionCache.get(moduleName);
651
645
  const versionInfo = await resolveModuleVersion(moduleName, {
652
646
  moduleSourcePath: sourcePath,
@@ -1178,21 +1172,6 @@ class Installer {
1178
1172
  }
1179
1173
  }
1180
1174
 
1181
- // Add installed community modules to available modules
1182
- const { CommunityModuleManager } = require('../modules/community-manager');
1183
- const communityMgr = new CommunityModuleManager();
1184
- const communityModules = await communityMgr.listAll();
1185
- for (const communityModule of communityModules) {
1186
- if (installedModules.includes(communityModule.code) && !availableModules.some((m) => m.id === communityModule.code)) {
1187
- availableModules.push({
1188
- id: communityModule.code,
1189
- name: communityModule.displayName,
1190
- isExternal: true,
1191
- fromCommunity: true,
1192
- });
1193
- }
1194
- }
1195
-
1196
1175
  // Add installed custom modules to available modules
1197
1176
  const { CustomModuleManager } = require('../modules/custom-module-manager');
1198
1177
  const customMgr = new CustomModuleManager();
@@ -310,28 +310,6 @@ class Manifest {
310
310
  };
311
311
  }
312
312
 
313
- // Check if this is a community module
314
- const { CommunityModuleManager } = require('../modules/community-manager');
315
- const communityMgr = new CommunityModuleManager();
316
- const communityInfo = await communityMgr.getModuleByCode(moduleName);
317
- if (communityInfo) {
318
- const communityResolution = communityMgr.getResolution(moduleName);
319
- const versionInfo = await resolveModuleVersion(moduleName, {
320
- moduleSourcePath,
321
- fallbackVersion: communityInfo.version,
322
- });
323
- return {
324
- version: communityResolution?.version || versionInfo.version || communityInfo.version,
325
- source: 'community',
326
- npmPackage: communityInfo.npmPackage || null,
327
- repoUrl: communityInfo.url || null,
328
- channel: communityResolution?.channel || null,
329
- sha: communityResolution?.sha || null,
330
- registryApprovedTag: communityResolution?.registryApprovedTag || null,
331
- registryApprovedSha: communityResolution?.registryApprovedSha || null,
332
- };
333
- }
334
-
335
313
  // Check if this is a custom module (from user-provided URL or local path)
336
314
  const { CustomModuleManager } = require('../modules/custom-module-manager');
337
315
  const customMgr = new CustomModuleManager();
@@ -7,7 +7,7 @@
7
7
  * We build the plan from:
8
8
  * 1. CLI flags (--channel / --all-* / --next=CODE / --pin CODE=TAG)
9
9
  * 2. Interactive answers (the "all stable?" gate + per-module picker)
10
- * 3. Registry defaults (default_channel from registry-fallback.yaml / official.yaml)
10
+ * 3. Registry defaults (default_channel from bmad-modules.yaml)
11
11
  * 4. Hardcoded fallback 'stable'
12
12
  *
13
13
  * Precedence: --pin > --next=CODE > --channel (global) > registry default > 'stable'.
@@ -4,9 +4,9 @@ const path = require('node:path');
4
4
  const { execSync } = require('node:child_process');
5
5
  const yaml = require('yaml');
6
6
  const prompts = require('../prompts');
7
- const { RegistryClient } = require('./registry-client');
8
7
  const { resolveChannel, tagExists, parseGitHubRepo } = require('./channel-resolver');
9
8
  const { decideChannelForModule } = require('./channel-plan');
9
+ const { getProjectRoot } = require('../project-root');
10
10
 
11
11
  const VALID_CHANNELS = new Set(['stable', 'next', 'pinned']);
12
12
 
@@ -46,15 +46,12 @@ async function writeChannelMarker(markerPath, data) {
46
46
  }
47
47
  }
48
48
 
49
- const MARKETPLACE_OWNER = 'bmad-code-org';
50
- const MARKETPLACE_REPO = 'bmad-plugins-marketplace';
51
- const MARKETPLACE_REF = 'main';
52
- const FALLBACK_CONFIG_PATH = path.join(__dirname, 'registry-fallback.yaml');
49
+ const REGISTRY_CONFIG_PATH = path.join(getProjectRoot(), 'bmad-modules.yaml');
53
50
 
54
51
  /**
55
- * Manages official modules from the remote BMad marketplace registry.
56
- * Fetches registry/official.yaml from GitHub; falls back to the bundled
57
- * external-official-modules.yaml when the network is unavailable.
52
+ * Manages official modules from the bundled registry file. The remote
53
+ * marketplace fetch has been retired; this repo is the single source of truth
54
+ * for which official modules exist and how they are displayed.
58
55
  *
59
56
  * @class ExternalModuleManager
60
57
  */
@@ -65,9 +62,7 @@ class ExternalModuleManager {
65
62
  // ExternalModuleManager) sees resolutions made during install.
66
63
  static _resolutions = new Map();
67
64
 
68
- constructor() {
69
- this._client = new RegistryClient();
70
- }
65
+ constructor() {}
71
66
 
72
67
  /**
73
68
  * Get the most recent channel resolution for a module (if any).
@@ -79,8 +74,7 @@ class ExternalModuleManager {
79
74
  }
80
75
 
81
76
  /**
82
- * Load the official modules registry from GitHub, falling back to the
83
- * bundled YAML file if the fetch fails.
77
+ * Load the official modules registry from the bundled YAML file.
84
78
  * @returns {Object} Parsed YAML content with modules array
85
79
  */
86
80
  async loadExternalModulesConfig() {
@@ -88,23 +82,10 @@ class ExternalModuleManager {
88
82
  return this.cachedModules;
89
83
  }
90
84
 
91
- // Try remote registry first
92
- try {
93
- const config = await this._client.fetchGitHubYaml(MARKETPLACE_OWNER, MARKETPLACE_REPO, 'registry/official.yaml', MARKETPLACE_REF);
94
- if (config?.modules?.length) {
95
- this.cachedModules = config;
96
- return config;
97
- }
98
- } catch {
99
- // Fall through to local fallback
100
- }
101
-
102
- // Fallback to bundled file
103
85
  try {
104
- const content = await fs.readFile(FALLBACK_CONFIG_PATH, 'utf8');
86
+ const content = await fs.readFile(REGISTRY_CONFIG_PATH, 'utf8');
105
87
  const config = yaml.parse(content);
106
88
  this.cachedModules = config;
107
- await prompts.log.warn('Could not reach BMad registry; using bundled module list.');
108
89
  return config;
109
90
  } catch (error) {
110
91
  await prompts.log.warn(`Failed to load modules config: ${error.message}`);
@@ -130,6 +111,7 @@ class ExternalModuleManager {
130
111
  defaultSelected: mod.default_selected === true || mod.defaultSelected === true,
131
112
  type: mod.type || 'bmad-org',
132
113
  npmPackage: mod.npm_package || mod.npmPackage || null,
114
+ pluginName: mod.plugin_name || mod.pluginName || null,
133
115
  defaultChannel: normalizeChannelName(mod.default_channel || mod.defaultChannel) || 'stable',
134
116
  builtIn: mod.built_in === true,
135
117
  isExternal: mod.built_in !== true,
@@ -231,14 +231,6 @@ class OfficialModules {
231
231
  return externalSource;
232
232
  }
233
233
 
234
- // Check community modules (pass channelOptions for --next/--pin overrides)
235
- const { CommunityModuleManager } = require('./community-manager');
236
- const communityMgr = new CommunityModuleManager();
237
- const communitySource = await communityMgr.findModuleSource(moduleCode, options);
238
- if (communitySource) {
239
- return communitySource;
240
- }
241
-
242
234
  // Check custom modules (from user-provided URLs, already cloned to cache)
243
235
  const { CustomModuleManager } = require('./custom-module-manager');
244
236
  const customMgr = new CustomModuleManager();
@@ -269,21 +261,6 @@ class OfficialModules {
269
261
  return this.installFromResolution(resolved, bmadDir, fileTrackingCallback, options);
270
262
  }
271
263
 
272
- // Community modules whose cloned repo ships marketplace.json get the same
273
- // skill-level install treatment as custom-source installs. If the in-process
274
- // cache wasn't populated (e.g. caller skipped the pre-clone phase), fall
275
- // back to resolving directly from `~/.bmad/cache/community-modules/<name>/`
276
- // so we don't silently regress to the legacy half-install path.
277
- const { CommunityModuleManager } = require('./community-manager');
278
- const communityMgr = new CommunityModuleManager();
279
- let communityResolved = communityMgr.getPluginResolution(moduleName);
280
- if (!communityResolved) {
281
- communityResolved = await communityMgr.resolveFromCache(moduleName);
282
- }
283
- if (communityResolved) {
284
- return this.installFromResolution(communityResolved, bmadDir, fileTrackingCallback, options);
285
- }
286
-
287
264
  const sourcePath = await this.findModuleSource(moduleName, {
288
265
  silent: options.silent,
289
266
  channelOptions: options.channelOptions,
@@ -310,14 +287,9 @@ class OfficialModules {
310
287
  const manifestObj = new Manifest();
311
288
  const versionInfo = await manifestObj.getModuleVersionInfo(moduleName, bmadDir, sourcePath);
312
289
 
313
- // Pick up channel resolution recorded by whichever manager did the clone.
314
- const externalResolution = this.externalModuleManager.getResolution(moduleName);
315
- let communityResolution = null;
316
- if (!externalResolution) {
317
- const { CommunityModuleManager } = require('./community-manager');
318
- communityResolution = new CommunityModuleManager().getResolution(moduleName);
319
- }
320
- const resolution = externalResolution || communityResolution;
290
+ // Pick up channel resolution recorded by the external manager (the only
291
+ // manager that does pre-clone resolution now that community is retired).
292
+ const resolution = this.externalModuleManager.getResolution(moduleName);
321
293
 
322
294
  await manifestObj.addModule(bmadDir, moduleName, {
323
295
  version: resolution?.version || versionInfo.version,
@@ -326,8 +298,6 @@ class OfficialModules {
326
298
  repoUrl: versionInfo.repoUrl,
327
299
  channel: resolution?.channel,
328
300
  sha: resolution?.sha,
329
- registryApprovedTag: communityResolution?.registryApprovedTag,
330
- registryApprovedSha: communityResolution?.registryApprovedSha,
331
301
  });
332
302
 
333
303
  return { success: true, module: moduleName, path: targetPath, versionInfo };
@@ -375,27 +345,19 @@ class OfficialModules {
375
345
  await this.createModuleDirectories(resolved.code, bmadDir, options);
376
346
  }
377
347
 
378
- // Update manifest. For community installs we honor the channel resolved by
379
- // CommunityModuleManager (stable/next/pinned) and propagate the registry's
380
- // approved tag/sha. For custom-source installs we derive channel from the
348
+ // Update manifest. For custom-source installs we derive channel from the
381
349
  // cloneRef (present → pinned, absent → next; local paths have no channel).
382
350
  const { Manifest } = require('../core/manifest');
383
351
  const manifestObj = new Manifest();
384
352
 
385
353
  const hasGitClone = !!resolved.repoUrl;
386
- const isCommunity = resolved.communitySource === true;
387
354
  const manifestEntry = {
388
- version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
389
- source: isCommunity ? 'community' : 'custom',
355
+ version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || null),
356
+ source: 'custom',
390
357
  npmPackage: null,
391
358
  repoUrl: resolved.repoUrl || null,
392
359
  };
393
- if (isCommunity) {
394
- if (resolved.communityChannel) manifestEntry.channel = resolved.communityChannel;
395
- if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
396
- if (resolved.registryApprovedTag) manifestEntry.registryApprovedTag = resolved.registryApprovedTag;
397
- if (resolved.registryApprovedSha) manifestEntry.registryApprovedSha = resolved.registryApprovedSha;
398
- } else if (hasGitClone) {
360
+ if (hasGitClone) {
399
361
  manifestEntry.channel = resolved.cloneRef ? 'pinned' : 'next';
400
362
  if (resolved.cloneSha) manifestEntry.sha = resolved.cloneSha;
401
363
  if (resolved.rawInput) manifestEntry.rawSource = resolved.rawInput;
@@ -408,11 +370,10 @@ class OfficialModules {
408
370
  module: resolved.code,
409
371
  path: targetPath,
410
372
  // Mirror the manifestEntry.version precedence above so downstream summary
411
- // lines show the same string we just wrote to disk (community installs
412
- // use the registry-approved tag via `communityVersion`; custom git-backed
373
+ // lines show the same string we just wrote to disk (custom git-backed
413
374
  // installs show the cloned ref or 'main').
414
375
  versionInfo: {
415
- version: resolved.communityVersion || resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
376
+ version: resolved.cloneRef || (hasGitClone ? 'main' : resolved.version || ''),
416
377
  },
417
378
  };
418
379
  }
@@ -10,6 +10,9 @@
10
10
  let _clack = null;
11
11
  let _clackCore = null;
12
12
  let _picocolors = null;
13
+ const fs = require('node:fs');
14
+ const os = require('node:os');
15
+ const path = require('node:path');
13
16
 
14
17
  /**
15
18
  * Lazy-load @clack/prompts (ESM module)
@@ -575,6 +578,151 @@ async function autocomplete(options) {
575
578
  return result;
576
579
  }
577
580
 
581
+ function hasPathSeparator(value) {
582
+ return value.endsWith('/') || value.endsWith('\\');
583
+ }
584
+
585
+ function expandHome(input) {
586
+ if (!input) return input;
587
+ if (input === '~') return os.homedir();
588
+ if (input.startsWith('~/') || input.startsWith('~\\')) {
589
+ return path.join(os.homedir(), input.slice(2));
590
+ }
591
+ return input;
592
+ }
593
+
594
+ function toDirectoryOption(value, label = value, synthetic = false) {
595
+ return { value, label, synthetic };
596
+ }
597
+
598
+ function isExistingDirectory(value) {
599
+ try {
600
+ return fs.existsSync(value) && fs.statSync(value).isDirectory();
601
+ } catch {
602
+ return false;
603
+ }
604
+ }
605
+
606
+ function listDirectoryOptions(input, options) {
607
+ const cwd = options.cwd || process.cwd();
608
+ const rawInput = input.trim();
609
+ const expandedInput = expandHome(rawInput);
610
+ const trailingSep = hasPathSeparator(rawInput) || hasPathSeparator(expandedInput);
611
+ const resolvedInput = expandedInput ? path.resolve(cwd, expandedInput) : cwd;
612
+ const browseDir = expandedInput && !trailingSep && !isExistingDirectory(resolvedInput) ? path.dirname(resolvedInput) : resolvedInput;
613
+ const prefix = expandedInput && browseDir !== resolvedInput ? path.basename(resolvedInput).toLowerCase() : '';
614
+ const results = [];
615
+
616
+ if (!trailingSep && isExistingDirectory(resolvedInput)) {
617
+ results.push(toDirectoryOption(resolvedInput, `. (use this directory)`));
618
+ }
619
+
620
+ if (isExistingDirectory(browseDir)) {
621
+ try {
622
+ for (const entry of fs.readdirSync(browseDir, { withFileTypes: true })) {
623
+ if (!entry.isDirectory()) continue;
624
+ if (prefix && !entry.name.toLowerCase().startsWith(prefix)) continue;
625
+ const fullPath = path.join(browseDir, entry.name);
626
+ if (!results.some((option) => option.value === fullPath)) {
627
+ results.push(toDirectoryOption(fullPath));
628
+ }
629
+ }
630
+ } catch {
631
+ // Skip unreadable directories; validation still reports path issues.
632
+ }
633
+ }
634
+
635
+ const validation = options.validate?.(rawInput);
636
+ const hasMatchingOption = results.some((option) => option.value === resolvedInput);
637
+ if (expandedInput && !validation && !hasMatchingOption) {
638
+ results.unshift(toDirectoryOption(resolvedInput, `Create/use: ${resolvedInput}`, true));
639
+ }
640
+
641
+ return results;
642
+ }
643
+
644
+ /**
645
+ * Directory prompt with autocomplete candidates and create-directory support.
646
+ * Uses @clack/core directly so typed paths that do not exist yet can still be
647
+ * submitted when validation allows creating them.
648
+ * @param {Object} options - Prompt options
649
+ * @param {string} options.message - Prompt message
650
+ * @param {string} [options.default] - Default directory
651
+ * @param {string} [options.placeholder] - Placeholder text
652
+ * @param {Function} [options.validate] - Sync validation function
653
+ * @returns {Promise<string>} Selected or typed directory path
654
+ */
655
+ async function directory(options) {
656
+ const core = await getClackCore();
657
+ const color = await getPicocolors();
658
+ const tabCompletion = {
659
+ prefix: '',
660
+ index: -1,
661
+ options: [],
662
+ lastValue: '',
663
+ };
664
+
665
+ let prompt;
666
+ prompt = new core.AutocompletePrompt({
667
+ initialValue: options.default,
668
+ options: () => listDirectoryOptions(prompt?.userInput || '', options),
669
+ filter: () => true,
670
+ validate: (value) => options.validate?.(value ?? prompt.userInput),
671
+ render() {
672
+ const title = `${color.gray('◆')} ${options.message}`;
673
+ const bar = color.gray('│');
674
+ const barEnd = color.gray('└');
675
+ const userInput = this.userInput;
676
+ const placeholder = options.placeholder || options.default;
677
+ const inputDisplay = userInput ? this.userInputWithCursor : `${color.inverse(color.hidden('_'))}${color.dim(placeholder || '')}`;
678
+ const errorLine = this.state === 'error' ? [`${color.yellow('│')} ${color.yellow(this.error)}`] : [];
679
+
680
+ switch (this.state) {
681
+ case 'submit': {
682
+ return `${color.gray('◇')} ${options.message}\n${bar} ${color.dim(this.value || '')}`;
683
+ }
684
+ case 'cancel': {
685
+ return `${color.gray('◇')} ${options.message}\n${bar} ${color.strikethrough(color.dim(userInput || ''))}`;
686
+ }
687
+ default: {
688
+ return [title, `${bar} ${inputDisplay}`, ...errorLine, barEnd].join('\n');
689
+ }
690
+ }
691
+ },
692
+ });
693
+
694
+ const hasSetUserInput = typeof prompt._setUserInput === 'function';
695
+ const hasClearUserInput = typeof prompt._clearUserInput === 'function';
696
+
697
+ prompt.on('key', (_, key) => {
698
+ if (key?.name !== 'tab') return;
699
+ if (!hasSetUserInput) return; // @clack/core API surface changed — skip Tab silently.
700
+ const currentInput = prompt.userInput;
701
+ const isContinuingCycle = tabCompletion.lastValue && currentInput === tabCompletion.lastValue;
702
+ const completionOptions = isContinuingCycle ? tabCompletion.options : prompt.filteredOptions.filter((option) => !option.synthetic);
703
+ if (completionOptions.length === 0) return;
704
+
705
+ if (isContinuingCycle) {
706
+ tabCompletion.index = (tabCompletion.index + 1) % completionOptions.length;
707
+ } else {
708
+ tabCompletion.prefix = currentInput;
709
+ tabCompletion.options = completionOptions;
710
+ tabCompletion.index = 0;
711
+ }
712
+
713
+ const focusedOption = completionOptions[tabCompletion.index];
714
+ if (!focusedOption) return;
715
+ const completedValue = focusedOption.value;
716
+ tabCompletion.lastValue = completedValue;
717
+ if (hasClearUserInput) prompt._clearUserInput();
718
+ prompt._setUserInput(completedValue, true);
719
+ });
720
+
721
+ const result = await prompt.prompt();
722
+ await handleCancel(result);
723
+ return result;
724
+ }
725
+
578
726
  /**
579
727
  * Get the color utility (picocolors instance from @clack/prompts)
580
728
  * @returns {Promise<Object>} The color utility (picocolors)
@@ -694,6 +842,7 @@ module.exports = {
694
842
  multiselect,
695
843
  autocompleteMultiselect,
696
844
  autocomplete,
845
+ directory,
697
846
  confirm,
698
847
  text,
699
848
  password,