diff --git a/packages/stagecraft/package.json b/packages/stagecraft/package.json index 138945e8..1a0175d6 100644 --- a/packages/stagecraft/package.json +++ b/packages/stagecraft/package.json @@ -8,15 +8,20 @@ ".": "./dist/index.js" }, "types": "./dist/index.d.ts", + "bin": { + "stagecraft": "./dist/stagecraft.js" + }, "files": [ "dist/", "skills/" ], "scripts": { - "build": "tsc" + "build": "tsc -b tsconfig.build.json", + "test": "vitest run" }, "dependencies": { - "@playwright-repl/core": "workspace:*" + "@playwright-repl/core": "workspace:*", + "playwright": "^1.59.1" }, "license": "ISC" } diff --git a/packages/stagecraft/src/index.ts b/packages/stagecraft/src/index.ts index 56ad9cc5..a0f9103a 100644 --- a/packages/stagecraft/src/index.ts +++ b/packages/stagecraft/src/index.ts @@ -5,4 +5,5 @@ * discover and invoke skills through existing run_command / run_script MCP tools. */ -export { } +export { discoverSkills, findSkill } from './skills.js'; +export type { SkillInfo } from './skills.js'; diff --git a/packages/stagecraft/src/skills.ts b/packages/stagecraft/src/skills.ts new file mode 100644 index 00000000..711a2cd3 --- /dev/null +++ b/packages/stagecraft/src/skills.ts @@ -0,0 +1,139 @@ +/** + * Skill discovery and parsing. + * + * Scans a skills directory for subdirectories containing SKILL.md, + * parses YAML frontmatter into typed SkillInfo objects. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export interface SkillInfo { + name: string; + description: string; + category: string; + preconditions?: string; + parameters?: { name: string; description: string }[]; + output?: string; + dir: string; // absolute path to skill directory + pwFile?: string; // path to .pw file (if exists) + jsFile?: string; // path to .js file (if exists) +} + +// ─── Frontmatter parsing ──────────────────────────────────────────────────── + +/** + * Parse YAML frontmatter from a SKILL.md file. + * Handles the simple subset used by skill definitions: + * scalar fields, and `parameters:` as a list of `- key: description` entries. + */ +function parseFrontmatter(content: string): Record | null { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + + const yaml = match[1]; + const result: Record = {}; + let currentKey: string | null = null; + let listItems: { name: string; description: string }[] = []; + + for (const rawLine of yaml.split('\n')) { + const line = rawLine.replace(/\r$/, ''); + // List item: " - key: value" + const listMatch = line.match(/^\s+-\s+(\w+):\s*(.*)$/); + if (listMatch && currentKey) { + listItems.push({ name: listMatch[1], description: listMatch[2].trim() }); + continue; + } + + // Top-level key: value + const kvMatch = line.match(/^(\w+):\s*(.*)$/); + if (kvMatch) { + // Flush previous list + if (currentKey && listItems.length > 0) { + result[currentKey] = listItems; + listItems = []; + } + const [, key, value] = kvMatch; + currentKey = key; + if (value) { + result[key] = value; + currentKey = null; + } + continue; + } + } + + // Flush trailing list + if (currentKey && listItems.length > 0) { + result[currentKey] = listItems; + } + + return result; +} + +// ─── Discovery ────────────────────────────────────────────────────────────── + +/** + * Discover all skills in the given directory. + * Each subdirectory with a SKILL.md is treated as a skill. + */ +export function discoverSkills(skillsDir: string): SkillInfo[] { + if (!fs.existsSync(skillsDir)) return []; + + const skills: SkillInfo[] = []; + + for (const entry of fs.readdirSync(skillsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + + const dir = path.join(skillsDir, entry.name); + const skillMd = path.join(dir, 'SKILL.md'); + if (!fs.existsSync(skillMd)) continue; + + const info = parseSkillMd(skillMd, dir); + if (info) skills.push(info); + } + + // Sort by category then name + skills.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); + return skills; +} + +/** + * Parse a single SKILL.md file into a SkillInfo object. + */ +function parseSkillMd(filepath: string, dir: string): SkillInfo | null { + const content = fs.readFileSync(filepath, 'utf-8'); + const data = parseFrontmatter(content); + if (!data) return null; + + const name = data.name as string; + const description = data.description as string; + const category = data.category as string; + if (!name || !description || !category) return null; + + // Find .pw and .js files in the skill directory + const files = fs.readdirSync(dir); + const pwFile = files.find(f => f.endsWith('.pw')); + const jsFile = files.find(f => f.endsWith('.js')); + + return { + name, + description, + category, + preconditions: data.preconditions as string | undefined, + parameters: data.parameters as { name: string; description: string }[] | undefined, + output: data.output as string | undefined, + dir, + pwFile: pwFile ? path.join(dir, pwFile) : undefined, + jsFile: jsFile ? path.join(dir, jsFile) : undefined, + }; +} + +/** + * Find a skill by name from the discovered list. + */ +export function findSkill(skills: SkillInfo[], name: string): SkillInfo | undefined { + return skills.find(s => s.name === name); +} diff --git a/packages/stagecraft/src/stagecraft.ts b/packages/stagecraft/src/stagecraft.ts new file mode 100644 index 00000000..c1169801 --- /dev/null +++ b/packages/stagecraft/src/stagecraft.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env node + +/** + * stagecraft CLI entry point. + * + * Usage: + * stagecraft list List available skills + * stagecraft run Run a skill's .pw file + * --variable key=value Set template variables + */ + +import path from 'node:path'; +import { minimist, SessionPlayer, resolveCommand } from '@playwright-repl/core'; +import { discoverSkills, findSkill } from './skills.js'; + +const args = minimist(process.argv.slice(2), { + string: ['variable'], + alias: { v: 'variable' }, +}); + +const command = args._[0]; +const skillsDir = path.resolve(import.meta.dirname, '..', 'skills'); + +if (!command || command === 'help') { + printHelp(); + process.exit(0); +} + +if (command === 'list') { + listSkills(); +} else if (command === 'run') { + await runSkill(); +} else { + console.error(`Unknown command: ${command}`); + console.error(`Run 'stagecraft help' for usage.`); + process.exit(1); +} + +// ─── Commands ─────────────────────────────────────────────────────────────── + +function printHelp() { + console.log(` +stagecraft — skill library for playwright-repl + +Usage: + stagecraft list List available skills + stagecraft run Run a skill's .pw file + --variable key=value (-v) Set template variables (repeatable) + +Examples: + stagecraft list + stagecraft run download-rogers-bill -v billing_period="January 24, 2026" +`.trim()); +} + +function listSkills(dir?: string) { + const skills = discoverSkills(dir || skillsDir); + if (skills.length === 0) { + console.log('No skills found.'); + return; + } + + console.log('Skills:'); + + // Group by category + const grouped = new Map(); + for (const skill of skills) { + const list = grouped.get(skill.category) || []; + list.push(skill); + grouped.set(skill.category, list); + } + + for (const [category, items] of grouped) { + console.log(` ${category}`); + for (const skill of items) { + const name = skill.name.padEnd(24); + console.log(` ${name}${skill.description}`); + } + } +} + +async function runSkill() { + const skillName = args._[1]; + if (!skillName) { + console.error('Usage: stagecraft run '); + process.exit(1); + } + + const skills = discoverSkills(skillsDir); + const skill = findSkill(skills, skillName); + if (!skill) { + console.error(`Skill not found: ${skillName}`); + console.error(`Run 'stagecraft list' to see available skills.`); + process.exit(1); + } + + if (!skill.pwFile) { + console.error(`Skill '${skillName}' has no .pw file to run.`); + process.exit(1); + } + + // Parse --variable args into a Record + const variables = parseVariables(args.variable as string | string[] | undefined); + + // Load commands via SessionPlayer + const commands = SessionPlayer.load(skill.pwFile, variables); + + console.log(`Running skill: ${skill.name}`); + if (skill.preconditions) { + console.log(` Note: ${skill.preconditions}`); + } + console.log(` Commands: ${commands.length}`); + console.log(''); + + // Launch browser via Playwright (dynamic import to avoid compile-time type dep) + const dynamicImport = Function('m', 'return import(m)') as (m: string) => Promise; + const { chromium } = await dynamicImport('playwright'); + const browser = await chromium.launch({ headless: false }); + const context = await browser.newContext(); + const page = await context.newPage(); + + const AsyncFn = Object.getPrototypeOf(async function () {}).constructor; + + try { + for (let i = 0; i < commands.length; i++) { + const cmd = commands[i]; + console.log(` [${i + 1}/${commands.length}] ${cmd}`); + + const resolved = resolveCommand(cmd); + if (!resolved) { + console.error(` ERROR: Unknown command: ${cmd}`); + process.exit(1); + } + + try { + const fn = new AsyncFn('page', 'context', resolved.jsExpr); + await fn(page, context); + } catch (err: unknown) { + console.error(` ERROR: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } + console.log('\nDone.'); + } finally { + await browser.close(); + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +function parseVariables(raw: string | string[] | undefined): Record | undefined { + if (!raw) return undefined; + const items = Array.isArray(raw) ? raw : [raw]; + const vars: Record = {}; + for (const item of items) { + const eq = item.indexOf('='); + if (eq === -1) { + console.error(`Invalid variable format: ${item} (expected key=value)`); + process.exit(1); + } + vars[item.slice(0, eq)] = item.slice(eq + 1); + } + return Object.keys(vars).length > 0 ? vars : undefined; +} diff --git a/packages/stagecraft/test/skills.test.ts b/packages/stagecraft/test/skills.test.ts new file mode 100644 index 00000000..83a93079 --- /dev/null +++ b/packages/stagecraft/test/skills.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { discoverSkills, findSkill } from '../src/skills.js'; + +// ─── Test fixtures ────────────────────────────────────────────────────────── + +function createTmpSkillsDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stagecraft-test-')); + return dir; +} + +function writeSkill(skillsDir: string, name: string, frontmatter: string, files?: { pw?: string; js?: string }) { + const dir = path.join(skillsDir, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'SKILL.md'), frontmatter, 'utf-8'); + if (files?.pw) fs.writeFileSync(path.join(dir, 'script.pw'), files.pw, 'utf-8'); + if (files?.js) fs.writeFileSync(path.join(dir, 'script.js'), files.js, 'utf-8'); + return dir; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +describe('discoverSkills', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpSkillsDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns empty array for non-existent directory', () => { + const result = discoverSkills('/non/existent/path'); + expect(result).toEqual([]); + }); + + it('returns empty array for directory with no skills', () => { + const result = discoverSkills(tmpDir); + expect(result).toEqual([]); + }); + + it('discovers a skill with valid SKILL.md', () => { + writeSkill(tmpDir, 'my-skill', `--- +name: my-skill +description: A test skill +category: testing +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('my-skill'); + expect(skills[0].description).toBe('A test skill'); + expect(skills[0].category).toBe('testing'); + }); + + it('parses optional fields (preconditions, output)', () => { + writeSkill(tmpDir, 'full-skill', `--- +name: full-skill +description: Full featured skill +category: automation +preconditions: Must be logged in +output: PDF files +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills[0].preconditions).toBe('Must be logged in'); + expect(skills[0].output).toBe('PDF files'); + }); + + it('parses parameters list', () => { + writeSkill(tmpDir, 'param-skill', `--- +name: param-skill +description: Skill with parameters +category: testing +parameters: + - billing_period: The billing period to check + - format: Output format (pdf or csv) +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills[0].parameters).toEqual([ + { name: 'billing_period', description: 'The billing period to check' }, + { name: 'format', description: 'Output format (pdf or csv)' }, + ]); + }); + + it('detects .pw and .js files', () => { + writeSkill(tmpDir, 'file-skill', `--- +name: file-skill +description: Skill with files +category: testing +--- +`, { pw: 'goto https://example.com', js: 'console.log("hi")' }); + + const skills = discoverSkills(tmpDir); + expect(skills[0].pwFile).toContain('script.pw'); + expect(skills[0].jsFile).toContain('script.js'); + }); + + it('sets pwFile/jsFile to undefined when not present', () => { + writeSkill(tmpDir, 'bare-skill', `--- +name: bare-skill +description: No script files +category: testing +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills[0].pwFile).toBeUndefined(); + expect(skills[0].jsFile).toBeUndefined(); + }); + + it('skips directories without SKILL.md', () => { + // Create a directory without SKILL.md + fs.mkdirSync(path.join(tmpDir, 'no-skill-md')); + fs.writeFileSync(path.join(tmpDir, 'no-skill-md', 'README.md'), 'Not a skill'); + + // Create a valid skill + writeSkill(tmpDir, 'valid', `--- +name: valid +description: Valid skill +category: testing +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('valid'); + }); + + it('skips SKILL.md with missing required fields', () => { + writeSkill(tmpDir, 'incomplete', `--- +name: incomplete +description: Missing category +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills).toHaveLength(0); + }); + + it('skips SKILL.md without frontmatter', () => { + writeSkill(tmpDir, 'no-frontmatter', `# Just a heading\nNo frontmatter here.`); + + const skills = discoverSkills(tmpDir); + expect(skills).toHaveLength(0); + }); + + it('sorts skills by category then name', () => { + writeSkill(tmpDir, 'b-skill', `--- +name: b-skill +description: B +category: z-category +--- +`); + writeSkill(tmpDir, 'a-skill', `--- +name: a-skill +description: A +category: a-category +--- +`); + writeSkill(tmpDir, 'c-skill', `--- +name: c-skill +description: C +category: a-category +--- +`); + + const skills = discoverSkills(tmpDir); + expect(skills.map(s => s.name)).toEqual(['a-skill', 'c-skill', 'b-skill']); + }); + + it('discovers the real rogers skill', () => { + const realSkillsDir = path.resolve(import.meta.dirname, '..', 'skills'); + const skills = discoverSkills(realSkillsDir); + expect(skills.length).toBeGreaterThanOrEqual(1); + + const rogers = findSkill(skills, 'download-rogers-bill'); + expect(rogers).toBeDefined(); + expect(rogers!.category).toBe('tax/bills/telecom'); + expect(rogers!.pwFile).toContain('download-bill.pw'); + expect(rogers!.parameters).toEqual([ + { name: 'billing_periods', description: 'list of dates to check (e.g. "January 24, 2026", "February 24, 2026")' }, + ]); + }); +}); + +describe('findSkill', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpSkillsDir(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('finds a skill by name', () => { + writeSkill(tmpDir, 'target', `--- +name: target-skill +description: Target +category: testing +--- +`); + + const skills = discoverSkills(tmpDir); + const found = findSkill(skills, 'target-skill'); + expect(found).toBeDefined(); + expect(found!.name).toBe('target-skill'); + }); + + it('returns undefined for unknown skill', () => { + const skills = discoverSkills(tmpDir); + expect(findSkill(skills, 'nonexistent')).toBeUndefined(); + }); +}); diff --git a/packages/stagecraft/tsconfig.json b/packages/stagecraft/tsconfig.json new file mode 100644 index 00000000..35707f60 --- /dev/null +++ b/packages/stagecraft/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true + }, + "include": ["src", "test"] +} diff --git a/packages/stagecraft/vitest.config.ts b/packages/stagecraft/vitest.config.ts new file mode 100644 index 00000000..43e56f45 --- /dev/null +++ b/packages/stagecraft/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f230ba8..e644f29f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@playwright-repl/core': specifier: workspace:* version: link:../core + playwright: + specifier: ^1.59.1 + version: 1.59.1 packages/vscode: dependencies: