diff --git a/apps/daemon/src/cli.ts b/apps/daemon/src/cli.ts index ab785e847..217346657 100644 --- a/apps/daemon/src/cli.ts +++ b/apps/daemon/src/cli.ts @@ -11,7 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js'; import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js'; import { splitResearchSubcommand } from './research/cli-args.js'; import { resolveDaemonUrl } from './daemon-url.js'; -import { buildSkillCatalogTree } from '@open-design/contracts'; +import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts'; import { requestJsonIpc } from '@open-design/sidecar'; import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto'; @@ -5433,14 +5433,20 @@ async function runSkillsTree(args) { const resp = await fetch(`${base}/api/skills`); if (!resp.ok) return structuredHttpFailure(resp); const data = await resp.json().catch(() => null); - if (!data || !Array.isArray(data.skills)) { + let skills; + try { + skills = data && Array.isArray(data.skills) ? parseSkillSummaries(data.skills) : null; + } catch { + skills = null; + } + if (!skills) { return exitWithStructuredError({ code: 'daemon-protocol-error', message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }', data: { endpoint: '/api/skills' }, }); } - const tree = buildSkillCatalogTree(data.skills); + const tree = buildSkillCatalogTree(skills); if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n'); console.log(`Skills tree (${tree.total})`); for (const mode of tree.modes) { diff --git a/apps/daemon/tests/skills-cli.test.ts b/apps/daemon/tests/skills-cli.test.ts index 3813c609a..0e72ba4ae 100644 --- a/apps/daemon/tests/skills-cli.test.ts +++ b/apps/daemon/tests/skills-cli.test.ts @@ -39,11 +39,18 @@ describe('od skills CLI', () => { { id: 'dashboard', name: 'Dashboard', + description: 'A dashboard skill.', + triggers: [], mode: 'prototype', scenario: 'operation', platform: 'desktop', previewType: 'html', designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: 'Build a dashboard.', + aggregatesExamples: false, }, ], })); @@ -139,4 +146,65 @@ describe('od skills CLI', () => { }, ); }); + + it('rejects malformed skill elements in skills tree responses', async () => { + await withSkillsServer( + (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + skills: [ + { + id: 'dashboard', + name: 'Dashboard', + description: 'A dashboard skill.', + triggers: [], + mode: 'prototype', + scenario: 'operation', + platform: 'desktop', + previewType: 'html', + designSystemRequired: true, + defaultFor: [], + upstream: null, + hasBody: true, + examplePrompt: 'Build a dashboard.', + aggregatesExamples: false, + }, + { id: 'broken', mode: 'prototype' }, + ], + })); + }, + async (baseUrl) => { + try { + await execFileAsync( + process.execPath, + [ + '--import', + 'tsx', + cliEntry, + 'skills', + 'tree', + '--json', + '--daemon-url', + baseUrl, + ], + { + cwd: daemonRoot, + env: process.env, + }, + ); + throw new Error('skills tree command unexpectedly succeeded'); + } catch (error: unknown) { + const failed = error as { code?: number; stderr?: string; stdout?: string }; + expect(failed.code).toBe(74); + expect(failed.stdout ?? '').toBe(''); + const envelope = JSON.parse(failed.stderr ?? '{}') as { + error?: { code?: string; message?: string; data?: { endpoint?: string } }; + }; + expect(envelope.error?.code).toBe('daemon-protocol-error'); + expect(envelope.error?.message).toContain('Malformed /api/skills response'); + expect(envelope.error?.data?.endpoint).toBe('/api/skills'); + } + }, + ); + }); }); diff --git a/apps/web/src/providers/registry.ts b/apps/web/src/providers/registry.ts index bea088e41..d87d8dc49 100644 --- a/apps/web/src/providers/registry.ts +++ b/apps/web/src/providers/registry.ts @@ -13,6 +13,7 @@ import type { ImportLocalDesignSystemResponse, ReplaceProjectWorkingDirResponse, } from '@open-design/contracts'; +import { parseSkillSummaries } from '@open-design/contracts'; import type { AgentInfo, AppVersionInfo, @@ -107,12 +108,12 @@ export async function fetchSkills(options?: { throwOnError?: boolean }): Promise if (options?.throwOnError) throw new Error(`skills ${resp.status}`); return []; } - const json = (await resp.json()) as { skills?: SkillSummary[] }; + const json = await resp.json() as { skills?: unknown }; if (!Array.isArray(json.skills)) { if (options?.throwOnError) throw new Error('skills response malformed'); return []; } - return json.skills; + return parseSkillSummaries(json.skills); } catch (err) { if (options?.throwOnError) throw err; return []; diff --git a/apps/web/tests/components/IntegrationsView.skills.test.tsx b/apps/web/tests/components/IntegrationsView.skills.test.tsx index 7792c42a5..cac45720f 100644 --- a/apps/web/tests/components/IntegrationsView.skills.test.tsx +++ b/apps/web/tests/components/IntegrationsView.skills.test.tsx @@ -252,6 +252,21 @@ describe('IntegrationsView skills tree', () => { expect(screen.queryByText('No skills match these filters.')).toBeNull(); }); + it('shows a load failure when skills payload contains malformed entries', async () => { + renderSkillsIntegration([], { + skillsBody: { + skills: [ + skill({ id: 'dashboard', name: 'Dashboard' }), + { id: 'broken', mode: 'prototype' }, + ], + }, + }); + + expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy(); + expect(screen.queryByText('No skills match these filters.')).toBeNull(); + expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull(); + }); + it('localizes tree guide and legend labels', async () => { renderSkillsIntegration([ skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }), diff --git a/packages/contracts/src/api/registry.ts b/packages/contracts/src/api/registry.ts index 2f5684c7d..fb5cd0c03 100644 --- a/packages/contracts/src/api/registry.ts +++ b/packages/contracts/src/api/registry.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export interface AgentModelOption { id: string; label: string; @@ -140,6 +142,52 @@ export interface SkillsResponse { skills: SkillSummary[]; } +export const SkillSummarySchema: z.ZodType = z.object({ + id: z.string(), + name: z.string(), + displayName: z.record(z.string()).optional(), + description: z.string(), + descriptionI18n: z.record(z.string()).optional(), + triggers: z.array(z.string()), + mode: z.enum([ + 'prototype', + 'deck', + 'template', + 'design-system', + 'image', + 'video', + 'audio', + ]), + surface: z.enum(['web', 'image', 'video', 'audio']).optional(), + platform: z.enum(['desktop', 'mobile']).nullable().optional(), + scenario: z.string().nullable().optional(), + category: z.string().nullable().optional(), + source: z.enum(['built-in', 'user']).optional(), + previewType: z.string(), + designSystemRequired: z.boolean(), + defaultFor: z.array(z.string()), + upstream: z.string().nullable(), + featured: z.number().nullable().optional(), + fidelity: z.enum(['wireframe', 'high-fidelity']).nullable().optional(), + speakerNotes: z.boolean().nullable().optional(), + animations: z.boolean().nullable().optional(), + craftRequires: z.array(z.string()).optional(), + hasBody: z.boolean(), + examplePrompt: z.string(), + examplePromptI18n: z.record(z.string()).optional(), + aggregatesExamples: z.boolean(), +}).passthrough() as z.ZodType; + +export const SkillSummaryArraySchema = z.array(SkillSummarySchema); + +export function parseSkillSummaries(value: unknown): SkillSummary[] { + return SkillSummaryArraySchema.parse(value); +} + +export function isSkillSummaries(value: unknown): value is SkillSummary[] { + return SkillSummaryArraySchema.safeParse(value).success; +} + export interface SkillCatalogTreeSkill { id: string; name: string; diff --git a/packages/contracts/tests/skill-catalog-tree.test.ts b/packages/contracts/tests/skill-catalog-tree.test.ts index 8ae267f82..71619e518 100644 --- a/packages/contracts/tests/skill-catalog-tree.test.ts +++ b/packages/contracts/tests/skill-catalog-tree.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildSkillCatalogTree, + parseSkillSummaries, type SkillSummary, } from '../src/api/registry.js'; @@ -111,3 +112,21 @@ describe('buildSkillCatalogTree', () => { expect(leaf?.skill).toBe(source); }); }); + +describe('parseSkillSummaries', () => { + it('accepts valid skill summary arrays', () => { + const summaries = parseSkillSummaries([ + skill({ id: 'dashboard', name: 'Dashboard' }), + ]); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.id).toBe('dashboard'); + }); + + it('rejects malformed skill summary array elements', () => { + expect(() => parseSkillSummaries([ + skill({ id: 'dashboard', name: 'Dashboard' }), + { id: 'broken', mode: 'prototype' }, + ])).toThrow(); + }); +});