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
131 changes: 128 additions & 3 deletions v2/cli/src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 =>
Expand Down Expand Up @@ -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<ScannedPlaybook[]> {
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<ReturnType<typeof loadConfig>> & object,
cwd: string
cwd: string,
playbooks: ScannedPlaybook[] = []
): Promise<string> {
const { framework, version, paths, components } = config;
const componentNames = Object.keys(components).sort();
Expand Down Expand Up @@ -136,6 +235,10 @@ async function generateBody(
}
}

if (playbooks.length > 0) {
lines.push(...buildPlaybooksSection(playbooks));
}

lines.push(SECTION_END);
return lines.join('\n');
}
Expand Down Expand Up @@ -173,6 +276,23 @@ export async function context(options: ContextOptions = {}): Promise<void> {

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)
Expand Down Expand Up @@ -234,7 +354,7 @@ export async function context(options: ContextOptions = {}): Promise<void> {
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 });
Expand Down Expand Up @@ -263,10 +383,15 @@ export async function context(options: ContextOptions = {}): Promise<void> {
}

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.'),
Expand Down
9 changes: 9 additions & 0 deletions v2/cli/src/commands/playbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
96 changes: 95 additions & 1 deletion v2/cli/test/context.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
});
});
26 changes: 26 additions & 0 deletions v2/cli/test/playbook.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading