diff --git a/.changeset/open-loop-preset-discovery.md b/.changeset/open-loop-preset-discovery.md new file mode 100644 index 0000000..e15e814 --- /dev/null +++ b/.changeset/open-loop-preset-discovery.md @@ -0,0 +1,12 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Preset discovery API: enumerate domain presets so the CLI/UI picker can list and +pick one by name (#254). + +`builtinDomainPresets()` loads every domain preset shipped under the package's +`presets/` directory (today just Software Development; new built-ins are picked up +automatically). `loadDomainPresetsFrom(dir)` loads every immediate subdirectory +that holds a `preset.md`, skipping the rest, sorted by name. Pair either with the +existing `selectPreset(list, name)` to pick the user's chosen domain. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 56fe5b7..c25b4d3 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -102,6 +102,7 @@ * * - {@link defineDomainPreset} / {@link loadDomainPreset} — author, or load from a directory * - {@link composeDomainPresets} — merge presets into one (later wins on prompt/skill id) + * - {@link builtinDomainPresets} / {@link loadDomainPresetsFrom} — enumerate a set (the picker primitive) * - {@link selectPreset} — pick the user's chosen domain by name * - {@link softwareDevelopmentPreset} — the shipped, stack-agnostic built-in * - {@link loadDomainPreset} `{ modes }` — activate Autopilot/Technical variants @@ -337,6 +338,8 @@ export { composeDomainPresets, selectPreset, loadDomainPreset, + loadDomainPresetsFrom, + builtinDomainPresets, loadLoopsFrom, loadSkillsFrom, builtinPresetsDir, diff --git a/packages/ai-autopilot/src/preset/discover.test.ts b/packages/ai-autopilot/src/preset/discover.test.ts new file mode 100644 index 0000000..ba87e73 --- /dev/null +++ b/packages/ai-autopilot/src/preset/discover.test.ts @@ -0,0 +1,75 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { builtinDomainPresets, loadDomainPresetsFrom } from './load.js' +import { selectPreset } from './compose.js' + +async function writePreset(root: string, name: string, title: string) { + const dir = join(root, name) + await mkdir(dir) + await writeFile( + join(dir, 'preset.md'), + `---\nname: ${name}\ndescription: The ${title} domain.\nmetadata:\n title: ${title}\n---\nA domain preset.\n`, + ) + return dir +} + +describe('loadDomainPresetsFrom', () => { + let dir: string + before(async () => { + dir = await mkdtemp(join(tmpdir(), 'domain-presets-')) + await writePreset(dir, 'web-dev', 'Web Development') + await writePreset(dir, 'data-science', 'Data Science') + // a subdirectory without a preset.md is skipped + await mkdir(join(dir, 'not-a-preset')) + await writeFile(join(dir, 'not-a-preset', 'readme.md'), '# nope\n') + }) + after(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('loads every subdirectory that has a preset.md, sorted by name', async () => { + const presets = await loadDomainPresetsFrom(dir) + assert.deepEqual( + presets.map(p => p.name), + ['data-science', 'web-dev'], + ) + }) + + it('skips subdirectories without a preset.md', async () => { + const presets = await loadDomainPresetsFrom(dir) + assert.equal( + presets.find(p => p.name === 'not-a-preset'), + undefined, + ) + }) + + it('is pickable by name with selectPreset', async () => { + const presets = await loadDomainPresetsFrom(dir) + const picked = selectPreset(presets, 'web-dev') + assert.equal(picked?.title, 'Web Development') + assert.equal(selectPreset(presets, 'no-such-domain'), undefined) + }) + + it('returns [] for a directory that does not exist', async () => { + assert.deepEqual(await loadDomainPresetsFrom(join(tmpdir(), 'no-such-presets-dir-xyz')), []) + }) +}) + +describe('builtinDomainPresets', () => { + it('enumerates the shipped presets, including software-development', async () => { + const presets = await builtinDomainPresets() + assert.ok(presets.length >= 1) + const sd = selectPreset(presets, 'software-development') + assert.ok(sd, 'software-development is a shipped built-in') + assert.ok(sd!.loops.length >= 1) + assert.ok(sd!.prompts.length >= 1) + // every loop's prompt ids resolve to a shipped prompt body + const ids = new Set(sd!.prompts.map(p => p.id)) + for (const loop of sd!.loops) { + for (const id of loop.run) assert.ok(ids.has(id), `loop prompt ${id} has a body`) + } + }) +}) diff --git a/packages/ai-autopilot/src/preset/index.ts b/packages/ai-autopilot/src/preset/index.ts index f87b119..1c14292 100644 --- a/packages/ai-autopilot/src/preset/index.ts +++ b/packages/ai-autopilot/src/preset/index.ts @@ -11,6 +11,8 @@ export { defineDomainPreset, DomainPresetError } from './define.js' export { composeDomainPresets, selectPreset } from './compose.js' export { loadDomainPreset, + loadDomainPresetsFrom, + builtinDomainPresets, loadLoopsFrom, loadSkillsFrom, builtinPresetsDir, diff --git a/packages/ai-autopilot/src/preset/load.ts b/packages/ai-autopilot/src/preset/load.ts index fd0a4a4..11a70b0 100644 --- a/packages/ai-autopilot/src/preset/load.ts +++ b/packages/ai-autopilot/src/preset/load.ts @@ -1,4 +1,4 @@ -import { readFile, readdir } from 'node:fs/promises' +import { access, readFile, readdir } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import { parseSkillManifest } from '@gemstack/ai-skills' @@ -75,6 +75,41 @@ export function softwareDevelopmentPreset(opts: LoadPresetOptions = {}): Promise return loadDomainPreset(join(builtinPresetsDir(), 'software-development'), opts) } +/** + * Load every domain preset shipped with the package (under `presets/`) — the set + * the CLI/UI picker enumerates. Today that is just "Software Development" (#243), + * but new built-ins are discovered automatically as their directories land. Use + * {@link selectPreset} to pick one by name. + */ +export function builtinDomainPresets(opts: LoadPresetOptions = {}): Promise { + return loadDomainPresetsFrom(builtinPresetsDir(), opts) +} + +/** + * Load every domain preset under a directory: each immediate subdirectory that + * holds a `preset.md` is loaded with {@link loadDomainPreset}. Subdirectories + * without a manifest are skipped, and a missing `dir` yields `[]`. The result is + * sorted by directory name for a stable order. + */ +export async function loadDomainPresetsFrom(dir: string, opts: LoadPresetOptions = {}): Promise { + let names: string[] + try { + names = (await readdir(dir, { withFileTypes: true })) + .filter(e => e.isDirectory()) + .map(e => e.name) + .sort() + } catch { + return [] + } + const loaded = await Promise.all( + names.map(async name => { + const sub = join(dir, name) + return (await hasManifest(sub)) ? loadDomainPreset(sub, opts) : undefined + }), + ) + return loaded.filter((p): p is DomainPreset => p !== undefined) +} + /** Load the loop files in a directory, applying mode overrides (a missing directory yields `[]`). */ export async function loadLoopsFrom(dir: string, opts: LoadPresetOptions = {}): Promise { const winners = selectWinners(await manifestEntries(dir), opts.modes ?? []) @@ -138,6 +173,16 @@ async function manifestEntries(dir: string): Promise { ) } +/** True when a directory holds a `preset.md` manifest (i.e. is a domain preset). */ +async function hasManifest(dir: string): Promise { + try { + await access(join(dir, 'preset.md')) + return true + } catch { + return false + } +} + function str(meta: Record | undefined, key: string): string | undefined { const v = meta?.[key] return typeof v === 'string' && v.trim() ? v.trim() : undefined