Published on

Building a Documentation-to-Skill Generator for Claude Code

Authors

Building a Documentation-to-Skill Generator for Claude Code

Ever wished Claude Code could instantly understand any framework's documentation? Whether it's Next.js, Prisma, ESLint, or any other library, having relevant documentation at Claude's fingertips dramatically improves code quality and reduces hallucinations.

This guide shows you how to build a metaskill - a skill that generates other skills - converting documentation files into structured, searchable Claude Code skills.


What Are Claude Code Skills?

Skills are specialized instructions that extend Claude Code's capabilities. They're stored as Markdown files with YAML frontmatter and automatically activate when relevant to your task.

Skill Structure

Every skill lives in a SKILL.md file with this format:

---
name: skill-name
description: What the skill does and when to use it.
---

# Skill Title

Instructions and documentation here...

Where Skills Live

LocationScope
.claude/skills/[name]/SKILL.mdProject-specific
~/.claude/skills/[name]/SKILL.mdPersonal (all projects)

When Claude detects a task matching a skill's description, it automatically loads and applies that skill's knowledge.


The Problem: Documentation Overload

Modern projects use dozens of dependencies. Keeping Claude up-to-date with the latest APIs, patterns, and best practices for each is challenging. Documentation sites change, APIs evolve, and Claude's training data has a cutoff.

The solution? Convert documentation into skills that Claude can reference on-demand.


The Solution: docs-to-skill Metaskill

We'll build a skill that:

  1. Accepts any input - Local files or remote URLs (like context7.com)
  2. Intelligently splits content - Uses header-based parsing, not arbitrary separators
  3. Auto-detects structure - Analyzes the document to find the optimal split level
  4. Generates complete skills - Creates a master SKILL.md with links to section files

Example Output

Running the skill on Next.js documentation:

Analyzing headers...
  H1: 1 section (avg 33 chars)
  H2: 3 sections (avg 1815 chars)
  H3: 22 sections (avg 1855 chars)

Auto-detected split level: H3 (22 sections)

This creates:

.claude/skills/nextjs/
├── SKILL.md                           # Master skill with section links
├── app-router-basic-page-structure.md
├── form-component-progressive-enhancement.md
├── cache-management-with-cachelife.md
└── ... (22 section files)

How It Works

1. Smart Input Handling

The script accepts both local files and URLs:

function isUrl(input: string): boolean {
  return input.startsWith("http://") || input.startsWith("https://");
}

// Fetch from URL or read from file
if (isUrl(input)) {
  content = await fetch(url).then(r => r.text());
} else {
  content = readFileSync(filePath, "utf-8");
}

2. Header-Based Splitting

Instead of relying on arbitrary --- separators, we parse Markdown headers while ignoring those inside code blocks:

function splitByHeaders(content: string): Section[] {
  const lines = content.split("\n");
  let inCodeBlock = false;

  for (const line of lines) {
    // Track code block state
    if (line.match(/^```/)) {
      inCodeBlock = !inCodeBlock;
      continue;
    }

    // Only match headers outside code blocks
    const headerMatch = !inCodeBlock && line.match(/^(#{1,6})\s+(.+)$/);

    if (headerMatch) {
      // Start new section
      const level = headerMatch[1].length;
      const title = headerMatch[2].trim();
      // ... save section
    }
  }
}

3. Auto-Detection of Split Level

The script analyzes header distribution to find the optimal split level:

function detectSplitLevel(sections: Section[]): number {
  // Count headers and content at each level
  for (const section of sections) {
    levelCounts.set(section.level, count + 1);
    levelContentLengths.get(section.level).push(section.content.length);
  }

  // Score: count * log(avgLength)
  // Prefers levels with more sections AND substantial content
  for (const [level, count] of levelCounts) {
    if (level === 1) continue; // Skip H1 (usually just title)

    const avgLength = totalLength / count;
    const score = count * Math.log(Math.max(avgLength, 1));

    if (score > maxScore) {
      bestLevel = level;
    }
  }

  return bestLevel;
}

This ensures the script automatically adapts to different documentation structures - whether they use H2 or H3 as their primary content level.


Usage Examples

From a URL (context7)

bun run .claude/skills/docs-to-skill/parse-docs.ts \
  https://context7.com/vercel/next.js/llms.txt \
  nextjs

From a Local File

bun run .claude/skills/docs-to-skill/parse-docs.ts \
  ai/docs/eslint.md \
  eslint

Result

After running, Claude Code will automatically use the generated skill when you work on related tasks. Ask about Next.js routing, and Claude references the app-router-basic-page-structure.md section. Ask about caching, and it pulls from cache-management-with-cachelife.md.


Real-World Benefits

  1. Always Current - Fetch the latest docs from context7 anytime
  2. Reduced Hallucinations - Claude references actual documentation, not training data
  3. Faster Development - No need to context-switch to documentation sites
  4. Project-Specific - Store skills in .claude/skills/ for team sharing

Build It Yourself

Copy the prompt below into Claude Code to create this skill in your project:


Copy-Paste Prompt for Claude Code

Create a docs-to-skill metaskill that converts documentation files into Claude Code skills.

Create two files:

1. `.claude/skills/docs-to-skill/SKILL.md`:

---
name: docs-to-skill
description: Generates Claude Code skills from context7 documentation files or URLs. Use when you need to convert documentation into a skill with proper references. Supports both local files and remote URLs. Auto-detects optimal split level based on header structure.
---

# Documentation to Skill Generator (v2)

This skill converts context7 documentation files into structured Claude Code skills.

## Features

- **URL support**: Fetch docs directly from context7 URLs
- **Header-based splitting**: Splits by markdown headers (#, ##, ###)
- **Auto-detection**: Analyzes header distribution to find optimal split level
- **Code block aware**: Ignores headers inside code blocks

## Usage

When the user asks to convert a documentation file to a skill:

1. Get the input (file path OR URL)
2. Get the desired skill name
3. Run the parsing script:

```bash
bun run .claude/skills/docs-to-skill/parse-docs.ts <input> <skill-name>
```

**Examples:**

```bash
# From local file
bun run .claude/skills/docs-to-skill/parse-docs.ts ai/docs/eslint.md eslint

# From URL
bun run .claude/skills/docs-to-skill/parse-docs.ts https://context7.com/vercel/next.js/llms.txt nextjs
```

2. `.claude/skills/docs-to-skill/parse-docs.ts`:

#!/usr/bin/env bun
/**
 * Documentation to Skill Parser (v2)
 *
 * Converts context7 documentation files into Claude Code skills.
 * Supports both local files and remote URLs.
 * Uses header-based splitting with auto-detection of optimal split level.
 *
 * Usage:
 *   bun run parse-docs.ts <input> <skill-name>
 *
 * Examples:
 *   bun run parse-docs.ts ai/docs/eslint.md eslint
 *   bun run parse-docs.ts https://context7.com/vercel/next.js/llms.txt nextjs
 */

import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";

interface Section {
  level: number;
  title: string;
  description: string;
  content: string;
  filename: string;
}

interface ParseResult {
  sections: Section[];
  splitLevel: number;
  stats: {
    total: number;
    byLevel: Map<number, number>;
  };
}

function isUrl(input: string): boolean {
  return input.startsWith("http://") || input.startsWith("https://");
}

async function fetchContent(url: string): Promise<string> {
  console.log(`Fetching: ${url}`);
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
  }
  return response.text();
}

function readContent(filePath: string): string {
  return readFileSync(filePath, "utf-8");
}

function toKebabCase(title: string): string {
  return title
    .toLowerCase()
    .replace(/[^a-z0-9\s-]/g, "")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 64);
}

function extractDescription(content: string): string {
  const lines = content.split("\n");
  let description = "";
  let foundHeader = false;

  for (const line of lines) {
    if (line.match(/^#{1,6}\s+/)) {
      foundHeader = true;
      continue;
    }
    if (!description && !line.trim()) continue;
    if (line.match(/^```/)) break;
    if (line.match(/^#{1,6}\s+/) && foundHeader) break;
    if (foundHeader) {
      if (line.trim()) {
        description += (description ? " " : "") + line.trim();
      } else if (description) break;
    }
  }

  return description || "Documentation section.";
}

function splitByHeaders(content: string): Section[] {
  const lines = content.split("\n");
  const sections: Section[] = [];
  let currentSection: Partial<Section> | null = null;
  let contentBuffer: string[] = [];
  let inCodeBlock = false;

  for (const line of lines) {
    if (line.match(/^```/)) {
      inCodeBlock = !inCodeBlock;
      contentBuffer.push(line);
      continue;
    }

    const headerMatch = !inCodeBlock && line.match(/^(#{1,6})\s+(.+)$/);

    if (headerMatch) {
      if (currentSection?.title) {
        currentSection.content = contentBuffer.join("\n").trim();
        currentSection.description = extractDescription(currentSection.content);
        sections.push(currentSection as Section);
      }

      const level = headerMatch[1].length;
      const title = headerMatch[2].trim();
      currentSection = {
        level,
        title,
        filename: toKebabCase(title),
        description: "",
        content: "",
      };
      contentBuffer = [line];
    } else {
      contentBuffer.push(line);
    }
  }

  if (currentSection?.title) {
    currentSection.content = contentBuffer.join("\n").trim();
    currentSection.description = extractDescription(currentSection.content);
    sections.push(currentSection as Section);
  }

  return sections;
}

function detectSplitLevel(sections: Section[]): number {
  const levelCounts: Map<number, number> = new Map();
  const levelContentLengths: Map<number, number[]> = new Map();

  for (const section of sections) {
    levelCounts.set(section.level, (levelCounts.get(section.level) || 0) + 1);
    const lengths = levelContentLengths.get(section.level) || [];
    lengths.push(section.content.length);
    levelContentLengths.set(section.level, lengths);
  }

  console.log("\nAnalyzing headers...");
  const sortedLevels = [...levelCounts.keys()].sort((a, b) => a - b);
  for (const level of sortedLevels) {
    const count = levelCounts.get(level) || 0;
    const lengths = levelContentLengths.get(level) || [];
    const avgLength = Math.round(lengths.reduce((a, b) => a + b, 0) / count);
    console.log(`  H${level}: ${count} section${count !== 1 ? "s" : ""} (avg ${avgLength} chars)`);
  }

  let bestLevel = 2;
  let maxScore = 0;

  for (const [level, count] of levelCounts) {
    if (level === 1) continue;
    const lengths = levelContentLengths.get(level) || [];
    const avgLength = lengths.reduce((a, b) => a + b, 0) / count;
    const score = count * Math.log(Math.max(avgLength, 1));

    if (score > maxScore) {
      maxScore = score;
      bestLevel = level;
    }
  }

  const selectedCount = levelCounts.get(bestLevel) || 0;
  console.log(`\nAuto-detected split level: H${bestLevel} (${selectedCount} sections)`);

  return bestLevel;
}

function parseDocumentation(content: string): ParseResult {
  const allSections = splitByHeaders(content);

  if (allSections.length === 0) {
    return { sections: [], splitLevel: 2, stats: { total: 0, byLevel: new Map() } };
  }

  const splitLevel = detectSplitLevel(allSections);
  const sections = allSections.filter((s) => s.level === splitLevel);

  const filenameCount: Record<string, number> = {};
  for (const section of sections) {
    if (filenameCount[section.filename]) {
      filenameCount[section.filename]++;
      section.filename = `${section.filename}-${filenameCount[section.filename]}`;
    } else {
      filenameCount[section.filename] = 1;
    }
  }

  const byLevel = new Map<number, number>();
  for (const s of allSections) {
    byLevel.set(s.level, (byLevel.get(s.level) || 0) + 1);
  }

  return { sections, splitLevel, stats: { total: allSections.length, byLevel } };
}

function generateMasterSkill(sections: Section[], skillName: string, splitLevel: number): string {
  const topTitles = sections.slice(0, 5).map((s) => s.title).join(", ");
  const description = `${skillName} documentation with ${sections.length} sections covering: ${topTitles}${sections.length > 5 ? ", and more" : ""}. Use when working with ${skillName} related tasks.`;

  const sectionLinks = sections
    .map((s) => `- [${s.title}](${s.filename}.md) - ${s.description.slice(0, 100)}${s.description.length > 100 ? "..." : ""}`)
    .join("\n");

  return `---
name: ${skillName}
description: ${description}
---

# ${skillName.charAt(0).toUpperCase() + skillName.slice(1)} Documentation

> Auto-generated from documentation. Split at H${splitLevel} level (${sections.length} sections).

## Available Sections

${sectionLinks}

## Usage

Reference specific sections as needed for detailed documentation. Each section file contains the full documentation content including code examples.
`;
}

async function main() {
  const args = process.argv.slice(2);

  if (args.length < 2) {
    console.error("Usage: bun run parse-docs.ts <input> <skill-name>");
    console.error("\nExamples:");
    console.error("  bun run parse-docs.ts ai/docs/eslint.md eslint");
    console.error("  bun run parse-docs.ts https://context7.com/vercel/next.js/llms.txt nextjs");
    process.exit(1);
  }

  const [input, skillName] = args;

  let content: string;
  try {
    content = isUrl(input) ? await fetchContent(input) : readContent(input);
  } catch (err) {
    console.error(`Error reading input: ${input}`);
    console.error(err);
    process.exit(1);
  }

  console.log(`Input: ${input}`);
  console.log(`Skill name: ${skillName}`);
  console.log(`Content length: ${content.length} chars`);

  const result = parseDocumentation(content);

  if (result.sections.length === 0) {
    console.error("\nNo valid sections found in the document.");
    process.exit(1);
  }

  const outputDir = join(process.cwd(), ".claude", "skills", skillName);
  if (!existsSync(outputDir)) {
    mkdirSync(outputDir, { recursive: true });
  }

  const masterContent = generateMasterSkill(result.sections, skillName, result.splitLevel);
  writeFileSync(join(outputDir, "SKILL.md"), masterContent);
  console.log(`\nCreated: ${join(outputDir, "SKILL.md")}`);

  for (const section of result.sections) {
    const sectionPath = join(outputDir, `${section.filename}.md`);
    writeFileSync(sectionPath, section.content);
  }

  console.log(`Created ${result.sections.length} section files`);
  console.log(`\nSkill created at: ${outputDir}`);
}

main().catch(console.error);

After creating these files, test it with:

bun run .claude/skills/docs-to-skill/parse-docs.ts https://context7.com/vercel/next.js/llms.txt nextjs

Now you have a powerful tool for converting any documentation into Claude Code skills. Import your favorite frameworks, keep them updated from context7, and watch your development velocity soar!

Happy coding!