From 89e1e5a19d975aa642c5a531c9bc735a87d31b66 Mon Sep 17 00:00:00 2001 From: Rob Levin Date: Wed, 29 Apr 2026 09:43:40 -0500 Subject: [PATCH] Fix #478: ag context auto-detects installed playbooks and injects intent recipes - playbook.ts: write sdui.json to destRoot on install so ag context can find it - context.ts: scan src/playbooks/*/sdui.json, render Agentic Intent section, announce detected playbooks, warn on stale schema version, show count in summary box - context.test.ts: 5 new tests (no dir, empty dir, one valid, one stale, two valid) - playbook.test.ts: 1 new test verifying sdui.json is written to destRoot --- v2/cli/src/commands/context.ts | 131 +++++++++++++++++++++++++++++++- v2/cli/src/commands/playbook.ts | 9 +++ v2/cli/test/context.test.ts | 96 ++++++++++++++++++++++- v2/cli/test/playbook.test.ts | 26 +++++++ 4 files changed, 258 insertions(+), 4 deletions(-) diff --git a/v2/cli/src/commands/context.ts b/v2/cli/src/commands/context.ts index 3e7b26d82..d6c0aca8c 100644 --- a/v2/cli/src/commands/context.ts +++ b/v2/cli/src/commands/context.ts @@ -4,7 +4,7 @@ import * as p from '@clack/prompts'; import path from 'node:path'; import { existsSync } from 'node:fs'; -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; import type { ContextOptions } from '../types/index.js'; import { loadConfig, saveConfig } from '../utils/config.js'; import { logger } from '../utils/logger.js'; @@ -27,6 +27,28 @@ const AI_TOOLS = { type AiTool = keyof typeof AI_TOOLS; +const EXPECTED_SDUI_VERSION = 1; + +interface PlaybookRecipeComponent { + component: string; + role: string; +} + +interface PlaybookSduiFile { + version: number; + slug: string; + displayName: string; + intent?: { triggers: string[]; summary: string }; + recipe?: { layout: string; components: PlaybookRecipeComponent[]; notes?: string }; +} + +interface ScannedPlaybook { + slug: string; + displayName: string; + data: PlaybookSduiFile; + stale: boolean; +} + /** Detect which AI tools appear to be configured in the project */ function detectTools(cwd: string): AiTool[] { return (Object.keys(AI_TOOLS) as AiTool[]).filter(tool => @@ -93,10 +115,87 @@ function findPropsFile(cwd: string, componentsPath: string, componentName: strin return null; } +/** Scan for installed playbooks by looking for sdui.json files */ +async function scanPlaybooks(cwd: string, playbooksDir: string): Promise { + const fullPath = path.resolve(cwd, playbooksDir); + if (!existsSync(fullPath)) return []; + + let entries; + try { + entries = await readdir(fullPath, { withFileTypes: true }); + } catch { + return []; + } + + const results: ScannedPlaybook[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const sduiPath = path.join(fullPath, entry.name, 'sdui.json'); + if (!existsSync(sduiPath)) continue; + try { + const raw = await readFile(sduiPath, 'utf-8'); + const data = JSON.parse(raw) as PlaybookSduiFile; + results.push({ + slug: data.slug ?? entry.name, + displayName: data.displayName ?? entry.name, + data, + stale: data.version < EXPECTED_SDUI_VERSION, + }); + } catch { + // Skip unparseable files silently + } + } + return results; +} + +/** Build the playbook intent section lines */ +function buildPlaybooksSection(playbooks: ScannedPlaybook[]): string[] { + const lines: string[] = [ + '## AgnosticUI Agentic Intent — Playbook Recipes', + '', + "The following playbooks are installed in this project. When a user's request matches", + 'a trigger phrase, use the corresponding recipe as your structural scaffold.', + '', + ]; + + for (const pb of playbooks) { + const { displayName, data } = pb; + const { intent, recipe } = data; + + lines.push(`### ${displayName}`, ''); + + if (intent?.triggers?.length) { + lines.push(`> Triggers: ${intent.triggers.join(', ')}`, ''); + } + + if (recipe?.layout) { + lines.push(`Layout: ${recipe.layout}`, ''); + } + + if (recipe?.components?.length) { + lines.push('**Component recipe:**'); + for (const comp of recipe.components) { + const tag = toAgTag(comp.component.replace(/^Ag/, '')); + lines.push(`- \`${tag}\` — ${comp.role}`); + } + lines.push(''); + } + + if (recipe?.notes) { + lines.push(`**Notes:** ${recipe.notes}`, ''); + } + + lines.push('---', ''); + } + + return lines; +} + /** Generate the AgnosticUI markdown body (shared across all tool formats) */ async function generateBody( config: Awaited> & object, - cwd: string + cwd: string, + playbooks: ScannedPlaybook[] = [] ): Promise { const { framework, version, paths, components } = config; const componentNames = Object.keys(components).sort(); @@ -136,6 +235,10 @@ async function generateBody( } } + if (playbooks.length > 0) { + lines.push(...buildPlaybooksSection(playbooks)); + } + lines.push(SECTION_END); return lines.join('\n'); } @@ -173,6 +276,23 @@ export async function context(options: ContextOptions = {}): Promise { const cwd = process.cwd(); + // Scan for installed playbooks (src/playbooks/*/sdui.json) + const scannedPlaybooks = await scanPlaybooks(cwd, 'src/playbooks'); + + if (scannedPlaybooks.length > 0) { + const slugList = scannedPlaybooks.map(pb => pb.slug).join(', '); + const noun = scannedPlaybooks.length === 1 ? 'playbook' : 'playbooks'; + console.log(pc.dim(` Detected ${scannedPlaybooks.length} installed ${noun} (${slugList}) — including intent recipes.`)); + } + + for (const pb of scannedPlaybooks) { + if (pb.stale) { + logger.warn( + `sdui.json for '${pb.slug}' uses schema version ${pb.data.version}, expected ${EXPECTED_SDUI_VERSION} — recipe may be stale. Re-install: npx agnosticui-cli playbook ${pb.slug} --force` + ); + } + } + // Resolve the output path in priority order: // 1. --output flag (explicit user override) // 2. --format flag (use that tool's default file) @@ -234,7 +354,7 @@ export async function context(options: ContextOptions = {}): Promise { spinner.start(`Generating context for ${componentCount} component(s)...`); try { - const body = await generateBody(config, cwd); + const body = await generateBody(config, cwd, scannedPlaybooks); // Ensure parent directory exists (e.g. .cursor/rules/) await mkdir(path.dirname(absOutputPath), { recursive: true }); @@ -263,10 +383,15 @@ export async function context(options: ContextOptions = {}): Promise { } logger.newline(); + const playbookSummary = scannedPlaybooks.length > 0 + ? [`${pc.dim('Playbooks: ')}${pc.white(`${scannedPlaybooks.length} (${scannedPlaybooks.map(pb => pb.slug).join(', ')})`)}`, ''] + : []; + logger.box('Context generated!', [ pc.dim('File: ') + pc.cyan(relOutputPath), pc.dim('Components: ') + pc.white(String(componentCount)), pc.dim('Framework: ') + pc.white(config.framework), + ...playbookSummary, '', pc.dim('AI coding tools (Claude Code, Cursor, Windsurf) will now'), pc.dim('automatically know about your installed components.'), diff --git a/v2/cli/src/commands/playbook.ts b/v2/cli/src/commands/playbook.ts index fe060e577..8b6ac2f66 100644 --- a/v2/cli/src/commands/playbook.ts +++ b/v2/cli/src/commands/playbook.ts @@ -210,6 +210,15 @@ export async function playbook(slug: string | undefined, options: PlaybookOption spinner.message(`Installing ${pc.cyan(playbookEntry.title)} (${framework}) — ${written}/${totalFiles} written...`); } + // Fetch and write sdui.json so `ag context` can auto-detect this playbook + const sduiUrl = `${githubRawBase}/${basePath}/sdui.json`; + const sduiText = await fetchText(sduiUrl); + if (sduiText !== null) { + await writeFile(path.join(destRoot, 'sdui.json'), sduiText, 'utf-8'); + } else { + logger.warn('sdui.json not found — ag context will not include intent recipes for this playbook'); + } + spinner.stop(pc.green('✓') + ` Installed ${written} file(s)${failed > 0 ? pc.yellow(` (${failed} skipped)`) : ''}`); logger.newline(); diff --git a/v2/cli/test/context.test.ts b/v2/cli/test/context.test.ts index fd873fa50..8d76b4376 100644 --- a/v2/cli/test/context.test.ts +++ b/v2/cli/test/context.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; import path from 'node:path'; import { context } from '../src/commands/context.js'; import { createTempDir, removeTempDir, createInitializedProject } from './helpers.js'; @@ -95,4 +95,98 @@ describe('ag context', () => { await context({ output: '.github/copilot-instructions.md' }); expect(existsSync(path.join(tmpDir, '.github', 'copilot-instructions.md'))).toBe(true); }); + + it('omits playbook section when src/playbooks/ does not exist', async () => { + await createInitializedProject(tmpDir, 'react'); + await addButtonToConfig(tmpDir); + await context({ output: 'CLAUDE.md' }); + const content = await readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + expect(content).not.toContain('Agentic Intent'); + }); + + it('omits playbook section when src/playbooks/ is empty', async () => { + await createInitializedProject(tmpDir, 'react'); + await addButtonToConfig(tmpDir); + await mkdir(path.join(tmpDir, 'src', 'playbooks'), { recursive: true }); + await context({ output: 'CLAUDE.md' }); + const content = await readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + expect(content).not.toContain('Agentic Intent'); + }); + + it('includes playbook section for one valid sdui.json', async () => { + await createInitializedProject(tmpDir, 'react'); + await addButtonToConfig(tmpDir); + const pbDir = path.join(tmpDir, 'src', 'playbooks', 'dashboard'); + await mkdir(pbDir, { recursive: true }); + await writeFile(path.join(pbDir, 'sdui.json'), JSON.stringify({ + version: 1, + slug: 'dashboard', + displayName: 'Discovery Dashboard', + intent: { triggers: ['dashboard', 'analytics'], summary: 'Dashboard recipe' }, + recipe: { + layout: 'Sidebar left, main content right.', + components: [ + { component: 'AgCard', role: 'Metric KPI tile' }, + { component: 'AgTabs', role: 'Switch content sections' }, + ], + notes: 'Group metric cards in a flex container.', + }, + })); + await context({ output: 'CLAUDE.md' }); + const content = await readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + expect(content).toContain('Agentic Intent'); + expect(content).toContain('Discovery Dashboard'); + expect(content).toContain('dashboard, analytics'); + expect(content).toContain('ag-card'); + expect(content).toContain('ag-tabs'); + expect(content).toContain('Group metric cards'); + }); + + it('warns and still renders section for stale sdui.json schema version', async () => { + await createInitializedProject(tmpDir, 'react'); + await addButtonToConfig(tmpDir); + const pbDir = path.join(tmpDir, 'src', 'playbooks', 'login'); + await mkdir(pbDir, { recursive: true }); + await writeFile(path.join(pbDir, 'sdui.json'), JSON.stringify({ + version: 0, + slug: 'login', + displayName: 'Login Form', + intent: { triggers: ['login'], summary: 'Login recipe' }, + recipe: { layout: 'centered card', components: [], notes: '' }, + })); + const logLines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }); + await context({ output: 'CLAUDE.md' }); + const output = logLines.join('\n'); + expect(output).toContain('schema version 0'); + expect(output).toContain('expected 1'); + const content = await readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + expect(content).toContain('Login Form'); + }); + + it('includes all sections and announces count for two valid sdui.json files', async () => { + await createInitializedProject(tmpDir, 'react'); + await addButtonToConfig(tmpDir); + for (const [slug, displayName] of [['dashboard', 'Discovery Dashboard'], ['login', 'Login Form']]) { + const pbDir = path.join(tmpDir, 'src', 'playbooks', slug); + await mkdir(pbDir, { recursive: true }); + await writeFile(path.join(pbDir, 'sdui.json'), JSON.stringify({ + version: 1, slug, displayName, + intent: { triggers: [slug], summary: `${displayName} recipe` }, + recipe: { layout: 'test layout', components: [], notes: '' }, + })); + } + const logLines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }); + await context({ output: 'CLAUDE.md' }); + const announced = logLines.join('\n'); + expect(announced).toContain('Detected 2 installed playbooks'); + const content = await readFile(path.join(tmpDir, 'CLAUDE.md'), 'utf-8'); + expect(content).toContain('Discovery Dashboard'); + expect(content).toContain('Login Form'); + }); }); diff --git a/v2/cli/test/playbook.test.ts b/v2/cli/test/playbook.test.ts index a5e79745d..c57a72f47 100644 --- a/v2/cli/test/playbook.test.ts +++ b/v2/cli/test/playbook.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { playbook } from '../src/commands/playbook.js'; import { createTempDir, removeTempDir, initPackageJson, MOCK_MANIFEST } from './helpers.js'; @@ -87,4 +88,29 @@ describe('ag playbook', () => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 503 }))); await expect(playbook(undefined, { list: true })).rejects.toThrow(/process\.exit/); }); + + it('writes sdui.json to destRoot for ag context integration', async () => { + const mockSdui = JSON.stringify({ + version: 1, + slug: 'login', + displayName: 'Login Form', + intent: { triggers: ['login', 'sign in'], summary: 'Login recipe' }, + recipe: { layout: 'centered card', components: [], notes: '' }, + }); + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (String(url).includes('playbooks-manifest.json')) { + return { ok: true, json: async () => MOCK_MANIFEST }; + } + if (String(url).endsWith('sdui.json')) { + return { ok: true, text: async () => mockSdui }; + } + return { ok: true, text: async () => '// content', arrayBuffer: async () => new ArrayBuffer(0) }; + })); + await playbook('login', { framework: 'react', force: true }); + const sduiPath = path.join(tmpDir, 'src', 'playbooks', 'login', 'sdui.json'); + expect(existsSync(sduiPath)).toBe(true); + const parsed = JSON.parse(await readFile(sduiPath, 'utf-8')); + expect(parsed.slug).toBe('login'); + expect(parsed.version).toBe(1); + }); });