Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/open-loop-preset-discovery.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -337,6 +338,8 @@ export {
composeDomainPresets,
selectPreset,
loadDomainPreset,
loadDomainPresetsFrom,
builtinDomainPresets,
loadLoopsFrom,
loadSkillsFrom,
builtinPresetsDir,
Expand Down
75 changes: 75 additions & 0 deletions packages/ai-autopilot/src/preset/discover.test.ts
Original file line number Diff line number Diff line change
@@ -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`)
}
})
})
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/preset/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export { defineDomainPreset, DomainPresetError } from './define.js'
export { composeDomainPresets, selectPreset } from './compose.js'
export {
loadDomainPreset,
loadDomainPresetsFrom,
builtinDomainPresets,
loadLoopsFrom,
loadSkillsFrom,
builtinPresetsDir,
Expand Down
47 changes: 46 additions & 1 deletion packages/ai-autopilot/src/preset/load.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<DomainPreset[]> {
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<DomainPreset[]> {
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<Loop[]> {
const winners = selectWinners(await manifestEntries(dir), opts.modes ?? [])
Expand Down Expand Up @@ -138,6 +173,16 @@ async function manifestEntries(dir: string): Promise<Entry[]> {
)
}

/** True when a directory holds a `preset.md` manifest (i.e. is a domain preset). */
async function hasManifest(dir: string): Promise<boolean> {
try {
await access(join(dir, 'preset.md'))
return true
} catch {
return false
}
}

function str(meta: Record<string, unknown> | undefined, key: string): string | undefined {
const v = meta?.[key]
return typeof v === 'string' && v.trim() ? v.trim() : undefined
Expand Down
Loading