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
9 changes: 7 additions & 2 deletions packages/stagecraft/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
3 changes: 2 additions & 1 deletion packages/stagecraft/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
139 changes: 139 additions & 0 deletions packages/stagecraft/src/skills.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;

const yaml = match[1];
const result: Record<string, unknown> = {};
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);
}
164 changes: 164 additions & 0 deletions packages/stagecraft/src/stagecraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env node

/**
* stagecraft CLI entry point.
*
* Usage:
* stagecraft list List available skills
* stagecraft run <skill-name> 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 <skill-name> 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<string, typeof skills>();
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 <skill-name>');
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<any>;
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<string, string> | undefined {
if (!raw) return undefined;
const items = Array.isArray(raw) ? raw : [raw];
const vars: Record<string, string> = {};
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;
}
Loading
Loading