Skip to content
Draft
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
52 changes: 52 additions & 0 deletions .claude/skills/migrate-skills-yaml/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
name: migrate-skills-yaml
description: Converts the old flat skills.yaml format to the new grouped format with variations. Use when a PR or branch still has a top-level "skills:" array instead of grouped skill objects.
metadata:
temporary: "true"
---

<!-- TEMPORARY: remove after all PRs are migrated to grouped skills.yaml format -->

## Old format (flat array)

```yaml
shared_docs:
- https://posthog.com/docs/getting-started/identify-users.md

skills:
- id: nextjs-app-router
type: example
example_path: basics/next-app-router
display_name: Next.js App Router
description: PostHog integration for Next.js App Router applications
tags: [nextjs, react, ssr, app-router, javascript]
docs_urls:
- https://posthog.com/docs/libraries/next-js.md
```

## New format (grouped with variations)

```yaml
integration-skills:
type: example
template: integration-skill-description.md
shared_docs:
- https://posthog.com/docs/getting-started/identify-users.md
variations:
- id: nextjs-app-router
example_path: basics/next-app-router
display_name: Next.js App Router
description: PostHog integration for Next.js App Router applications
tags: [nextjs, react, ssr, app-router, javascript]
docs_urls:
- https://posthog.com/docs/libraries/next-js.md
```

## Migration steps

1. Remove the top-level `shared_docs` key
2. Remove the top-level `skills:` key
3. Create `integration-skills:` with `type: example`, `template: integration-skill-description.md`, and `shared_docs` (moved from top level)
4. Move all former `skills` entries under `integration-skills.variations`
5. Remove `type: example` from each variation (it's inherited from the group)
6. Keep all other fields (`id`, `example_path`, `display_name`, `description`, `tags`, `docs_urls`) on each variation
15 changes: 11 additions & 4 deletions scripts/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,18 @@ async function createBundledArchive(outputPath, manifest, skillZips) {
* Generate a shell command to install a skill from its download URL.
* This command can be run by any agent with Bash access.
*/
function generateInstallCommand(skillId, downloadUrl) {
function generateInstallCommand(skillId, downloadUrl, group) {
if (!/^[a-zA-Z0-9_-]+$/.test(skillId)) {
throw new Error(`Invalid skill ID: ${skillId}`);
}

// Escape single quotes in URL for safe shell interpolation
const escapedUrl = downloadUrl.replace(/'/g, "'\\''");

const targetDir = `.claude/skills/posthog-${skillId}`;
// Generate install directory name: posthog-{category}-{skillId}
// e.g., posthog-integration-nextjs-app-router, posthog-feature-flag-react
const category = group ? group.replace(/-skills$/, '') : 'skill';
const targetDir = `.claude/skills/posthog-${category}-${skillId}`;
const tempFile = `/tmp/posthog-skill-${skillId}.zip`;

return `mkdir -p ${targetDir} && curl -sL '${escapedUrl}' -o ${tempFile} && unzip -o ${tempFile} -d ${targetDir} && rm ${tempFile}`;
Expand All @@ -93,6 +96,7 @@ function generateInstallCommand(skillId, downloadUrl) {
function generateManifest(skills, uriSchema, version, guideContents = {}) {
const scheme = uriSchema.scheme;
const skillPattern = uriSchema.patterns.skill;
const docPattern = uriSchema.patterns.doc;
// Base URL for skill ZIP downloads
// Production: GitHub releases (default)
// Development: Local server (set via SKILLS_BASE_URL env var)
Expand All @@ -104,12 +108,15 @@ function generateManifest(skills, uriSchema, version, guideContents = {}) {
buildTimestamp: new Date().toISOString(),
resources: skills.map(skill => {
const isGuide = skill.type === 'doc' && guideContents[skill.id];
const uri = isGuide
? `${scheme}${docPattern.replace('{id}', skill.id)}`
: `${scheme}${skillPattern.replace('{group}', skill.group).replace('{id}', skill.id)}`;
const base = {
id: skill.id,
name: skill.name,
description: skill.description,
tags: skill.tags,
uri: `${scheme}${skillPattern.replace('{id}', skill.id)}`,
uri,
};

if (isGuide) {
Expand All @@ -132,7 +139,7 @@ function generateManifest(skills, uriSchema, version, guideContents = {}) {
resource: {
mimeType: 'text/plain',
description: `${skill.description}. Run this command in Bash to install the skill.`,
text: generateInstallCommand(skill.id, downloadUrl),
text: generateInstallCommand(skill.id, downloadUrl, skill.group),
},
};
}),
Expand Down
177 changes: 122 additions & 55 deletions scripts/lib/skill-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,21 @@ function loadYaml(configPath) {
}

/**
* Load skills configuration
* Load skills configuration from individual files in skills/ directory.
* Each YAML file represents a skill group. Returns a merged config object
* keyed by filename (without extension).
*/
function loadSkillsConfig(configDir) {
return loadYaml(path.join(configDir, 'skills.yaml'));
const skillsDir = path.join(configDir, 'skills');
const files = fs.readdirSync(skillsDir).filter(f => f.endsWith('.yaml'));
const config = {};

for (const file of files) {
const key = file.replace('.yaml', '');
config[key] = loadYaml(path.join(skillsDir, file));
}

return config;
}

/**
Expand All @@ -37,10 +48,70 @@ function loadCommandments(configDir) {
}

/**
* Load skill description template
* Load a skill description template by filename
*/
function loadSkillTemplate(configDir) {
return fs.readFileSync(path.join(configDir, 'skill-description.md'), 'utf8');
function loadSkillTemplate(configDir, templateFile) {
return fs.readFileSync(path.join(configDir, 'skills', templateFile), 'utf8');
}

/**
* Expand grouped skill config into a flat array of skill objects.
* Each top-level key (except shared_docs) is a skill group with
* base properties and a variants array.
*/
function expandSkillGroups(config, configDir) {
const skills = [];

for (const [key, group] of Object.entries(config)) {
if (key === 'shared_docs') continue;
if (!group.variants) continue;

const baseTemplate = group.template ? loadSkillTemplate(configDir, group.template) : null;
const baseTags = group.tags || [];
const baseType = group.type || 'example';
const baseDescription = group.description || null;
const baseSharedDocs = group.shared_docs || [];

// Derive category from group key (e.g., "feature-flag-skills" → "feature-flag")
const category = key.replace(/-skills$/, '');

for (const variation of group.variants) {
const mergedTags = [...baseTags, ...(variation.tags || [])];
let description = variation.description;
if (!description && baseDescription) {
description = baseDescription.replace(/{display_name}/g, variation.display_name);
}

// Support per-variation template override
const template = variation.template
? loadSkillTemplate(configDir, variation.template)
: baseTemplate;

// Support per-variation shared_docs (merged with base)
const sharedDocs = [...baseSharedDocs, ...(variation.shared_docs || [])];

// Namespace the ID: "nextjs" + "feature-flag" → "nextjs-feature-flag"
// Skip suffix for "other" category
const namespacedId = category === 'other'
? variation.id
: `${variation.id}-${category}`;

skills.push({
...variation,
id: namespacedId,
_shortId: variation.id,
_category: category,
type: variation.type || baseType,
tags: mergedTags,
description,
_template: template,
_sharedDocs: sharedDocs,
_group: key,
});
}
}

return skills;
}

/**
Expand Down Expand Up @@ -361,31 +432,12 @@ async function generateSkill({
});
}

// Fetch and write skill-specific docs
if (skill.docs_urls && skill.docs_urls.length > 0) {
for (const url of skill.docs_urls) {
console.log(` Fetching doc: ${url}`);

const result = await fetchDoc(url);
if (result) {
const filename = urlToFilename(url);
fs.writeFileSync(
path.join(referencesDir, filename),
result.content,
'utf8'
);

references.push({
filename,
description: result.title,
});
}
}
}
// Helper to process a doc entry (string URL or {url, title} object)
async function processDoc(docEntry, logPrefix = '') {
const url = typeof docEntry === 'string' ? docEntry : docEntry.url;
const titleOverride = typeof docEntry === 'object' ? docEntry.title : null;

// Fetch and write shared docs
for (const url of sharedDocs) {
console.log(` Fetching shared doc: ${url}`);
console.log(` ${logPrefix}Fetching doc: ${url}`);

const result = await fetchDoc(url);
if (result) {
Expand All @@ -398,31 +450,46 @@ async function generateSkill({

references.push({
filename,
description: result.title,
description: titleOverride || result.title,
});
}
}

// Include relevant workflows (flattened with category prefix, linked to next step)
for (const workflow of workflows) {
let content = fs.readFileSync(workflow.fullPath, 'utf8');

// Append continuation message if there's a next step
if (workflow.nextFilename) {
content += `\n\n---\n\n**Upon completion, continue with:** [${workflow.nextFilename}](${workflow.nextFilename})`;
// Fetch and write skill-specific docs
if (skill.docs_urls && skill.docs_urls.length > 0) {
for (const docEntry of skill.docs_urls) {
await processDoc(docEntry);
}
}

const filename = `${workflow.category}-${workflow.filename}`;
fs.writeFileSync(
path.join(referencesDir, filename),
content,
'utf8'
);
// Fetch and write shared docs
for (const docEntry of sharedDocs) {
await processDoc(docEntry, 'shared ');
}

references.push({
filename,
description: toSentenceCase(workflow.title),
});
// Include relevant workflows (flattened with category prefix, linked to next step)
// Skip workflows for docs-only skills
if (skill.type !== 'docs-only') {
for (const workflow of workflows) {
let content = fs.readFileSync(workflow.fullPath, 'utf8');

// Append continuation message if there's a next step
if (workflow.nextFilename) {
content += `\n\n---\n\n**Upon completion, continue with:** [${workflow.nextFilename}](${workflow.nextFilename})`;
}

const filename = `${workflow.category}-${workflow.filename}`;
fs.writeFileSync(
path.join(referencesDir, filename),
content,
'utf8'
);

references.push({
filename,
description: toSentenceCase(workflow.title),
});
}
}

// Build references list for SKILL.md
Expand Down Expand Up @@ -477,9 +544,11 @@ async function generateAllSkills({
// Load all configs
const skillsConfig = loadSkillsConfig(configDir);
const commandmentsConfig = loadCommandments(configDir);
const skillTemplate = loadSkillTemplate(configDir);
const skipPatterns = loadSkipPatterns(path.join(configDir, 'skip-patterns.yaml'));

// Expand grouped skills into flat array
const skills = expandSkillGroups(skillsConfig, configDir);

// Discover workflows
console.log('Discovering workflows...');
const workflows = discoverWorkflows(promptsDir);
Expand All @@ -488,10 +557,6 @@ async function generateAllSkills({
// Create output directory
fs.mkdirSync(outputDir, { recursive: true });

// Generate each skill
const skills = skillsConfig.skills || [];
const sharedDocs = skillsConfig.shared_docs || [];

console.log(`\nGenerating ${skills.length} skills...`);

for (const skill of skills) {
Expand All @@ -505,8 +570,8 @@ async function generateAllSkills({
outputDir,
skipPatterns,
commandmentsConfig,
skillTemplate,
sharedDocs,
skillTemplate: skill._template,
sharedDocs: skill._sharedDocs || [],
workflows,
});

Expand All @@ -519,7 +584,8 @@ async function generateAllSkills({
return skills.map(s => ({
id: s.id,
type: s.type || 'example',
name: `PostHog integration for ${s.display_name}`,
group: s._group,
name: s.description,
description: s.description,
tags: s.tags || [],
}));
Expand All @@ -529,6 +595,7 @@ module.exports = {
loadSkillsConfig,
loadCommandments,
loadSkillTemplate,
expandSkillGroups,
collectCommandments,
discoverWorkflows,
generateSkill,
Expand Down
Loading