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
88 changes: 88 additions & 0 deletions app/(setup)/setup/wizard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,25 @@ import type { Key } from '@/lib/types'
const STEP_LABELS = ['Identity', 'Model', 'Platforms', 'Keys', 'Deploy']
const TOTAL_STEPS = 5

type UseCaseTemplateInfo = {
id: string
name: string
description: string
recommends?: {
provider?: string
primaryModel?: string
fallbackModel?: string
platforms?: string[]
browser?: boolean
}
}

type WizardState = {
// Step 1
name: string
persona: string
tier: string
template: string
// Step 2
provider: string
primaryModel: string
Expand Down Expand Up @@ -46,6 +60,7 @@ const INITIAL_STATE: WizardState = {
name: '',
persona: '',
tier: 'individual',
template: '',
provider: 'anthropic',
primaryModel: '',
fallbackModel: '',
Expand Down Expand Up @@ -122,6 +137,7 @@ export default function WizardPage() {
const [dockerAvailable, setDockerAvailable] = useState<boolean | null>(null)
const [dockerChecking, setDockerChecking] = useState(true)
const [availableKeys, setAvailableKeys] = useState<Key[]>([])
const [templates, setTemplates] = useState<UseCaseTemplateInfo[]>([])
const [signalDialogOpen, setSignalDialogOpen] = useState(false)
const [signalCaptured, setSignalCaptured] = useState<SignalCapturedConfig | null>(null)
const [signalConnectFailed, setSignalConnectFailed] = useState(false)
Expand All @@ -145,8 +161,31 @@ export default function WizardPage() {
.then((res) => (res.ok ? res.json() : []))
.then((keys: Key[]) => setAvailableKeys(Array.isArray(keys) ? keys : []))
.catch(() => setAvailableKeys([]))
fetch('/api/usecase-templates')
.then((res) => (res.ok ? res.json() : []))
.then((t: UseCaseTemplateInfo[]) => setTemplates(Array.isArray(t) ? t : []))
.catch(() => setTemplates([]))
}, [])

// Select a use-case package: records the id and prefills the recommended
// model/provider/browser so the following steps start from the template's
// defaults (the user can still override). Empty id = "Base (blank)".
function selectTemplate(id: string) {
const t = templates.find((x) => x.id === id)
if (!t) {
update({ template: '' })
return
}
const r = t.recommends ?? {}
update({
template: id,
...(r.provider ? { provider: r.provider } : {}),
...(r.primaryModel ? { primaryModel: r.primaryModel } : {}),
...(r.fallbackModel ? { fallbackModel: r.fallbackModel } : {}),
...(r.browser !== undefined ? { browserEnabled: r.browser } : {}),
})
}

// Keys in the registry matching the selected provider — offered for reuse.
const matchingKeys = availableKeys.filter((k) => k.provider === state.provider)

Expand Down Expand Up @@ -178,6 +217,7 @@ export default function WizardPage() {
provider: state.provider,
primaryModel: state.primaryModel,
fallbackModel: state.fallbackModel || undefined,
template: state.template || undefined,
bundledOllama: state.provider === 'ollama' ? state.bundledOllama : false,
persona: state.persona,
tier: state.tier,
Expand Down Expand Up @@ -336,6 +376,54 @@ export default function WizardPage() {
))}
</div>
</div>

{templates.length > 0 && (
<div>
<FieldLabel>Use-case package (optional)</FieldLabel>
<p className="text-xs text-muted-foreground mb-2">
Start from an opinionated public package — it pre-fills the recommended model and installs its skills/tools (each scanned before install). You can still change everything in the next steps.
</p>
<div className="space-y-2">
<label
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
!state.template ? 'border-[var(--accent)] bg-[var(--accent)]/10' : 'border-[var(--border)] hover:bg-muted/30'
}`}
>
<input
type="radio"
name="template"
checked={!state.template}
onChange={() => selectTemplate('')}
className="accent-[var(--accent)] mt-0.5"
/>
<div>
<div className="font-medium text-sm">Base (blank agent)</div>
<div className="text-xs text-muted-foreground">A general-purpose agent with the default baseline plugins.</div>
</div>
</label>
{templates.map((t) => (
<label
key={t.id}
className={`flex items-start gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
state.template === t.id ? 'border-[var(--accent)] bg-[var(--accent)]/10' : 'border-[var(--border)] hover:bg-muted/30'
}`}
>
<input
type="radio"
name="template"
checked={state.template === t.id}
onChange={() => selectTemplate(t.id)}
className="accent-[var(--accent)] mt-0.5"
/>
<div>
<div className="font-medium text-sm">{t.name}</div>
<div className="text-xs text-muted-foreground">{t.description}</div>
</div>
</label>
))}
</div>
</div>
)}
</Section>
)}

Expand Down
27 changes: 23 additions & 4 deletions app/api/setup/deploy/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { services } from '@/lib/services'
import { installBaselineTemplates } from '@/lib/services/templates'
import { defaultEnabledPlugins } from '@/lib/services/artifacts-manifest'
import { getUseCaseTemplate, installUseCaseTemplate, templateEnabledPlugins } from '@/lib/services/usecase-templates'
import { generateDefaultConfig, type McpServerConfig } from '@/lib/templates/config-yaml'
import { generateEnvContent, generateAgentCompose } from '@/lib/services/agent-deploy-templates'
import fs from 'fs'
Expand Down Expand Up @@ -47,8 +48,9 @@ function nextAvailablePort(): number {
return port
}

function generateConfigYaml(provider: string, primaryModel: string, fallbackModel?: string, browserEnabled?: boolean, mcpServers?: Record<string, McpServerConfig>): string {
return generateDefaultConfig({ provider, primaryModel, fallbackModel, browserEnabled, mcpServers, enabledPlugins: defaultEnabledPlugins() })
function generateConfigYaml(provider: string, primaryModel: string, fallbackModel?: string, browserEnabled?: boolean, mcpServers?: Record<string, McpServerConfig>, extraEnabledPlugins: string[] = []): string {
const enabledPlugins = Array.from(new Set([...defaultEnabledPlugins(), ...extraEnabledPlugins]))
return generateDefaultConfig({ provider, primaryModel, fallbackModel, browserEnabled, mcpServers, enabledPlugins })
}

export async function POST(request: Request) {
Expand All @@ -60,6 +62,14 @@ export async function POST(request: Request) {
githubToken, braveKey, existingKeyId, saveKeyToRegistry } = body
const bundledOllama = body.bundledOllama === true

// Optional use-case template (e.g. Matilde) — installs gated git artifacts +
// seeds its SOUL. Resolved from the server-side registry; unknown id → 400.
const templateId: string | undefined = body.template || undefined
const useCaseTemplate = templateId ? getUseCaseTemplate(templateId) : undefined
if (templateId && !useCaseTemplate) {
return NextResponse.json({ ok: false, error: `Unknown use-case template "${templateId}".` }, { status: 400 })
}

if (!name || !provider || !primaryModel) {
return NextResponse.json({ ok: false, error: 'name, provider, and primaryModel are required' }, { status: 400 })
}
Expand Down Expand Up @@ -201,8 +211,9 @@ export async function POST(request: Request) {
}
}

// Write config.yaml
const configContent = generateConfigYaml(provider, primaryModel, fallbackModel, body.browserEnabled === true, Object.keys(mcpServers).length > 0 ? mcpServers : undefined)
// Write config.yaml (enable any plugins the chosen use-case template ships)
const extraEnabledPlugins = useCaseTemplate ? templateEnabledPlugins(useCaseTemplate) : []
const configContent = generateConfigYaml(provider, primaryModel, fallbackModel, body.browserEnabled === true, Object.keys(mcpServers).length > 0 ? mcpServers : undefined, extraEnabledPlugins)
fs.writeFileSync(path.join(agentDataDir, 'config.yaml'), configContent, 'utf-8')

// Write SOUL.md
Expand Down Expand Up @@ -267,6 +278,14 @@ If this is your very first startup ever, introduce yourself briefly in your home
// Install baseline plugins and hooks from templates
await installBaselineTemplates(agentDataDir)

// Install the chosen use-case template (gated git artifacts + SOUL overlay).
// Runs after the default SOUL.md write so a template SOUL takes precedence.
// Throws (failing the create before the container starts) if the trust gate
// refuses any fetched content — a poisoned template must never deploy.
if (useCaseTemplate) {
await installUseCaseTemplate(agentDataDir, useCaseTemplate)
}

// Generate standalone compose
const agentComposeDir = path.join(composeBaseDir, slug)
fs.mkdirSync(agentComposeDir, { recursive: true })
Expand Down
15 changes: 15 additions & 0 deletions app/api/usecase-templates/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from 'next/server'
import { loadUseCaseTemplates } from '@/lib/services/usecase-templates'

// Lists public use-case templates the create-new wizard can offer. Returns only
// presentational fields + recommendations; artifact sources are non-secret repo
// refs but aren't needed by the client, so we keep the payload lean.
export async function GET() {
const templates = loadUseCaseTemplates().map((t) => ({
id: t.id,
name: t.name,
description: t.description,
recommends: t.recommends ?? {},
}))
return NextResponse.json(templates)
}
22 changes: 22 additions & 0 deletions infra/usecase-templates.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"_comment": "Public use-case templates offered by the create-new wizard. Each artifact 'source' is a PINNED git ref fetched + trust-gate-scanned before install (see lib/services/usecase-templates.ts). Private repos require ARTIFACT_GIT_TOKEN on the HSM host.",
"templates": [
{
"id": "matilde",
"name": "Matilde — science assistant",
"description": "Academic research agent: verifiable citations (Crossref/OpenAlex/DataCite + retraction checks) and OpenNeuro/BIDS dataset discovery. Seeds the Matilde persona + research methodology.",
"recommends": {
"provider": "anthropic",
"primaryModel": "claude-opus-4-6",
"fallbackModel": "claude-haiku-4-5",
"platforms": [],
"browser": false
},
"artifacts": [
{ "type": "skills", "name": "matilde-methodology", "source": "git:NimbleCoAI/Matilde#v0.1.0:hermes-skill" },
{ "type": "plugins", "name": "matilde", "source": "git:NimbleCoAI/Matilde#v0.1.0:matilde_plugin", "enabled": true }
],
"soul": { "source": "git:NimbleCoAI/Matilde#v0.1.0:docker", "file": "SOUL.Matilde.md" }
}
]
}
45 changes: 45 additions & 0 deletions lib/services/__tests__/deploy-route.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ vi.mock('os', async (importOriginal) => {

vi.mock('@/lib/services/templates', () => ({ installBaselineTemplates: vi.fn(async () => []) }))

const tmpl = vi.hoisted(() => ({ get: vi.fn(), install: vi.fn(async () => []) }))
vi.mock('@/lib/services/usecase-templates', () => ({
getUseCaseTemplate: tmpl.get,
installUseCaseTemplate: tmpl.install,
templateEnabledPlugins: () => ['matilde'],
}))

vi.mock('@/lib/services', () => ({
services: {
docker: { isAvailable: () => true, pullImage: () => ({ ok: true }), healthCheck: () => false },
Expand Down Expand Up @@ -58,6 +65,9 @@ describe('POST /api/setup/deploy — Phase 1 wiring', () => {
h.add.mockClear()
h.createOverlay.mockClear()
h.createOverlay.mockResolvedValue({ id: 'h_matilde' })
tmpl.get.mockReset()
tmpl.install.mockReset()
tmpl.install.mockResolvedValue([])
})

afterEach(() => {
Expand Down Expand Up @@ -116,4 +126,39 @@ describe('POST /api/setup/deploy — Phase 1 wiring', () => {
const env = fs.readFileSync(path.join(h.tmpHome, '.hermes-host', '.env'), 'utf-8')
expect(env).toContain('OLLAMA_BASE_URL=http://host.docker.internal:11434/v1')
})

// Phase 2: use-case template (e.g. Matilde)
it('P2: installs the chosen use-case template after baseline', async () => {
const template = { id: 'matilde', name: 'Matilde', description: '', artifacts: [], recommends: {} }
tmpl.get.mockReturnValue(template)
const res = await deploy({
name: 'sci', provider: 'anthropic', primaryModel: 'claude-opus-4-6', template: 'matilde', llmKey: 'sk-ant-api-x',
})
const json = await res.json()
expect(json.ok).toBe(true)
expect(tmpl.get).toHaveBeenCalledWith('matilde')
expect(tmpl.install).toHaveBeenCalledWith(path.join(h.tmpHome, '.hermes-sci'), template)
// template's enabled plugin is written into config.yaml plugins.enabled
const cfg = fs.readFileSync(path.join(h.tmpHome, '.hermes-sci', 'config.yaml'), 'utf-8')
expect(cfg).toContain('matilde')
})

it('P2: a template install failure removes the half-written agent dir (no leftover .env)', async () => {
tmpl.get.mockReturnValue({ id: 'matilde', name: 'Matilde', description: '', artifacts: [], recommends: {} })
tmpl.install.mockRejectedValue(new Error('Refused: injection scan'))
const res = await deploy({
name: 'sci', provider: 'anthropic', primaryModel: 'claude-opus-4-6', template: 'matilde', llmKey: 'sk-ant-api-x',
})
expect(res.status).toBe(500)
expect(fs.existsSync(path.join(h.tmpHome, '.hermes-sci'))).toBe(false)
})

it('P2: rejects an unknown template id with 400 and installs nothing', async () => {
tmpl.get.mockReturnValue(undefined)
const res = await deploy({
name: 'x', provider: 'anthropic', primaryModel: 'claude-opus-4-6', template: 'bogus', llmKey: 'sk-ant-api-x',
})
expect(res.status).toBe(400)
expect(tmpl.install).not.toHaveBeenCalled()
})
})
Loading