
Book To Skill
- 926 installs
- 16.5k repo stars
- Updated August 5, 2026
- virgiliojr94/book-to-skill
Book-to-Skill is a Claude Code skill that turns books and documents (PDF, EPUB, DOCX, and more) into structured agent skills, extracting frameworks, principles, techniques, and anti-patterns.
About
Book-to-Skill converts books and documents - PDF, EPUB, DOCX, HTML, Markdown, and more - into structured agent skills instead of summaries. It extracts named frameworks, actionable principles, step-by-step techniques, and anti-patterns, preserving the author’s exact terminology, then writes a complete skill (SKILL.md plus chapters, glossary, patterns, and a cheatsheet) that Claude Code, GitHub Copilot CLI, or Amp can load. Technical sources run through Docling for structure-aware extraction so tables, code blocks, and formulas survive. Four modes cover full conversion, analyze-only review, generation from a prior analysis, and folding new sources into an existing skill.
- Extracts structure - frameworks, principles, techniques, anti-patterns - not summaries
- Handles PDF, EPUB, DOCX, HTML, Markdown, RTF, and MOBI/AZW
- Docling-powered structure-aware extraction for technical books
- Cross-agent: Claude Code, GitHub Copilot CLI, and Amp
- Four modes: full convert, analyze-only, generate, update/fold-in
Book To Skill by the numbers
- 926 all-time installs (skills.sh)
- +171 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #76 of 782 Skill Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/virgiliojr94/book-to-skill --skill book-to-skillAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 926 |
|---|---|
| repo stars | ★ 16.5k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 5, 2026 |
| Repository | virgiliojr94/book-to-skill ↗ |
You buy a technical book, read 30 pages, and it sits on the shelf - so how do you actually put its frameworks to work while you code?
Turn books, papers, and internal docs into structured agent skills you can apply while coding.
Who is it for?
Developers and teams who want to turn the books, docs, and internal guides they already own into reusable agent skills.
Skip if: Skip it if you just want a quick summary or book report - it extracts structured frameworks, not prose recaps.
When should I use this skill?
When the user wants to study a document, apply an author’s frameworks while building, or turn a PDF, EPUB, or DOCX into a reusable knowledge base.
What you get
Each chapter, principle, and technique becomes a structured agent skill your coding agent can reference and apply on demand, instead of a PDF you never reopen.
- A complete SKILL.md
- Per-chapter reference files
- A glossary, patterns, and cheatsheet
By the numbers
- 10-step conversion workflow (Steps 0-9)
- 4 operating modes
- 14+ supported document formats
Files
<!-- Cross-agent notes (informational; ignored by host agents):
- Compatible skill roots: GitHub Copilot CLI (~/.copilot/skills, ~/.agents/skills,
.github/skills, .claude/skills, .agents/skills), Amp (.agents/skills, ~/.config/agents/skills, ~/.config/amp/skills), Claude Code (~/.claude/skills).
allowed-toolsis intentionally omitted to stay agent-neutral: Copilot CLI uses
shell/MCP-server names, Claude uses Bash/Read/Write/Glob/Grep, Amp adds shell_command. The skill needs shell (to run extract.py) and file read/write — each host will prompt for those on first use.
- Argument hint: <path-to-document-folder-or-glob>... [skill-name-slug]
-->
Book-to-Skill Converter
Transform written knowledge into actionable agent skills by extracting structure — not producing summaries.
Philosophy
Books contain crystallized expertise: frameworks, principles, and techniques that took years to develop. This skill extracts that knowledge into a format GitHub Copilot CLI, Amp, Claude Code, or another compatible agent can leverage repeatedly.
Extract structure, not summaries. A skill isn't a book report. It's a toolkit of:
- Named frameworks (mental models with clear application)
- Actionable principles (rules that guide decisions)
- Techniques (step-by-step methods)
- Anti-patterns (what to avoid and why)
- Voice calibration (how the author thinks and communicates)
Preserve the author's precision. Frameworks often have specific names for reasons. "The 5 Whys" isn't interchangeable with "ask why multiple times." Capture the exact formulation.
Layer depth appropriately. Simple books → simple skills. Complex books with 10+ frameworks → skills with reference files and on-demand chapters.
---
Modes of Operation
Four paths available. Route based on what the user asks:
1. Full Conversion (Default)
Trigger: User provides one or more document/directory/glob paths without special instructions Action: Run all steps below (Steps 0–9) Output: Complete skill with SKILL.md, chapters/, glossary, patterns, cheatsheet
2. Analyze Only
Trigger: User says "analyze", "just extract", or "I want to review before generating" Action: Run Steps 0–3, then produce a structured extraction report (frameworks, principles, techniques found). Stop — do NOT generate skill files. Output: Analysis report for user review
3. Generate from Prior Analysis
Trigger: User has existing analysis notes or previously ran analyze-only Action: Skip Steps 0–3, use the provided analysis as input, run Steps 4–9 Output: Skill files from the provided analysis
4. Update / Fold-in (Existing Skill)
Trigger: User provides one or more new source paths and indicates they want to update an existing skill (either by pointing to the existing skill folder, providing a skill slug that already exists in SKILLS_HOME, or explicitly requesting an update). Action: Run Step 0 (out-of-scope check), Step 1 (validate inputs), Step 1.5 (identify book type), and Step 2 (extract new files). Then skip to Step 5 (identify/detect existing skill path) and run the Update / Fold-in Workflow to merge the new content into the existing skill files. Output: Updated existing skill with new/revised chapter summaries and merged indexes/glossaries.
---
Skill Locations
This converter can run from multiple skill systems. When looking for this converter's helper script or writing the generated book skill, prefer these locations in order:
1. GitHub Copilot CLI personal skills: ~/.copilot/skills/ 2. Cross-agent personal skills (Copilot + Amp): ~/.agents/skills/ 3. Claude Code personal skills: ~/.claude/skills/ 4. Project-local Copilot skills: .github/skills/ 5. Project-local Claude skills: .claude/skills/ 6. Project-local Amp / Copilot skills: .agents/skills/ 7. Amp global skills: ~/.config/agents/skills/ 8. Amp legacy global skills: ~/.config/amp/skills/
For generated book skills, pick a destination that the user's host agent can actually discover (see Step 5). When more than one valid root exists, ask the user once and remember the answer for the session — do not silently default.
---
Step 0 — Out-of-scope check
If no arguments are provided, stop and respond:
"book-to-skill requires a supported document path, folder, or glob pattern. Usage: book-to-skill <path-to-document-folder-or-glob>... [skill-name-slug]"Throughout the workflow:
- Identify the input paths and the optional skill slug.
- If the last argument is not a file, folder, or glob that exists or matches any files, and it looks like a skill slug (e.g. lowercase hyphens, alphanumeric), treat it as
SKILL_NAME. - Treat all other arguments as the list of
INPUT_PATHS. - If any input path is an existing skill directory (contains
SKILL.mdand achapters/sub-folder), or ifSKILL_NAMEmatches an existing skill slug inSKILLS_HOME, flag this run as an Update/Fold-in operation (Mode 4).
---
Step 1 — Validate input
Verify that there is at least one supported file, directory, or glob pattern among the INPUT_PATHS. For directories and globs, expand them to find matching supported files (.pdf, .epub, .docx, .txt, .md, .markdown, .rst, .adoc, .html, .htm, .rtf, .mobi, .azw, .azw3).
If no supported files are found, stop with a clear error message.
---
Step 1.5 — Identify content type
Before extracting, ask the user:
"What kind of content do these sources have? This helps me choose the best extraction method.
>
1. Technical — has code blocks, tables, formulas, diagrams (e.g. programming books, academic papers, architecture guides)
2. Text-heavy — mostly prose, few or no tables/code (e.g. management, productivity, narrative non-fiction)
3. Not sure — I'll use the fast method and warn you if quality seems limited"
Store the answer as BOOK_TYPE:
- Option 1 →
BOOK_TYPE=technical - Option 2 →
BOOK_TYPE=text - Option 3 →
BOOK_TYPE=text
If `BOOK_TYPE=technical`, inform the user before proceeding:
"📐 Technical mode selected — using Docling for structure-aware extraction (tables, code blocks, formulas preserved as markdown). This takes ~1.5s per page, so expect a few minutes for longer sources. Starting now…"
If `BOOK_TYPE=text`, inform:
"📄 Text mode selected — using the fastest suitable extractor for each file type. Plain text/Markdown/HTML are usually ready in seconds; PDFs use pdftotext when available."
---
Step 2 — Extract text from the source documents
Run the extraction script, passing the input paths:
SCRIPT_PATH=""
for candidate in \
"$HOME/.copilot/skills/book-to-skill/scripts/extract.py" \
"$HOME/.agents/skills/book-to-skill/scripts/extract.py" \
"$HOME/.claude/skills/book-to-skill/scripts/extract.py" \
".github/skills/book-to-skill/scripts/extract.py" \
".claude/skills/book-to-skill/scripts/extract.py" \
".agents/skills/book-to-skill/scripts/extract.py" \
"$HOME/.config/agents/skills/book-to-skill/scripts/extract.py" \
"$HOME/.config/amp/skills/book-to-skill/scripts/extract.py"
do
if [ -f "$candidate" ]; then
SCRIPT_PATH="$candidate"
break
fi
done
if [ -z "$SCRIPT_PATH" ]; then
echo "Could not find scripts/extract.py for book-to-skill" >&2
exit 1
fi
PYTHON_BIN="${PYTHON_BIN:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
PYTHON_BIN="python"
fi
"$PYTHON_BIN" "$SCRIPT_PATH" $INPUT_PATHS --mode <BOOK_TYPE> --install-missing askBefore extraction, the script checks optional Python packages needed for the detected format. If a better extractor is missing, it prompts the user with the available fallback. Non-interactive sessions default to fallback unless install mode is explicitly yes.
Tip — preflight the environment: run "$PYTHON_BIN" "$SCRIPT_PATH" --check to print a per-format report of which extractors are installed and the exact command to install whatever is missing, without processing any file. Useful when a user reports a setup or quality problem.
This creates:
<tempdir>/book_skill_work/full_text.txt— combined extracted text of all sources with clear visually demarcated boundaries.<tempdir>/book_skill_work/metadata.json— overall combined size, words, pages, token counts, and a detailed list of individual processedsources.
Read <tempdir>/book_skill_work/metadata.json to inspect the results.
---
Step 2.5 — Pre-flight cost estimate
Read <tempdir>/book_skill_work/metadata.json and present the user with an estimate before doing any generation:
📖 Sources detected: <total_sources> source(s)
<list each source filename and format from the sources metadata list>
📄 Combined Pages/Sections: ~<N> | Words: ~<N> | Total tokens: ~<N>K
💰 Estimated token cost (Full Conversion / Update):
Input (reading + prompts): ~<N>K tokens
Output (skill files generated/updated): ~<N>K tokens
Total: ~<N>K tokens
Reference prices (as of 2025):
Claude Sonnet 4.5 → ~$<X> USD
Claude Haiku 4.5 → ~$<X> USD
⏱ Estimated time: ~<N> minutes
📁 Files to be generated/updated:
SKILL.md + chapter files + glossary + patterns + cheatsheet
➡ Proceed with Full Conversion / Update? (or type "analyze only" to preview first)How to estimate:
- Input tokens ≈
estimated_tokensfrom metadata × 1.3 (prompts overhead per chapter pass) - Output tokens ≈ chapters × per-chapter budget + 4,000 (SKILL.md) + 4,500 (glossary + patterns + cheatsheet)
- Per-chapter budget midpoint by
BOOK_TYPE(DEPTH is decided later in Step 4 and can raise it):text≈ 1,000,technical≈ 1,800. If the user has already indicated reference-only vs deep study, use the matching row of the Step 7 matrix. - Price: Sonnet input=$3/MTok output=$15/MTok — Haiku input=$0.80/MTok output=$4/MTok
Wait for the user to confirm before proceeding. If they say "analyze only", switch to Mode 2.
---
Step 2.6 — REPL-style access for large books (> 50k tokens)
Inspired by the Recursive Language Model (RLM) paradigm: treat full_text.txt as a queryable corpus, not a single read. Loading the whole file into context burns budget you will need later for generation.
For books over ~50k tokens, prefer programmatic probes over Read(full_text.txt) without bounds:
# Size check before any Read
wc -w "$FULL_TEXT_PATH"
# Find chapter offsets without loading the whole file
grep -n -E "^\s*(Chapter|CHAPTER)\s+[0-9]+" "$FULL_TEXT_PATH" | head -40
# Pull only the chapter you need (lines start..end inclusive)
sed -n '<start>,<end>p' "$FULL_TEXT_PATH"
# Verify a framework is actually mentioned before claiming it in SKILL.md
grep -c -i "westrum\|dora" "$FULL_TEXT_PATH"
# Targeted Read with offset/limit avoids dumping the full file
# Read(file_path=full_text.txt, offset=<line>, limit=<lines>)Use this approach for Step 3 (structure analysis), Step 7 (per-chapter summaries), and Step 8 (glossary / patterns extraction). On books under 50k tokens, a single Read is fine.
Why this matters: a 200-page book is ~75k tokens. Re-reading it once per chapter (28 passes) costs ~2M input tokens; using grep + sed to pull only relevant slices keeps generation cost proportional to the output, not the source.
---
Step 3 — Analyze book structure
Read the first 8,000 characters of the extracted full_text.txt to identify:
- Book title and author(s)
- Chapter structure (look for "Chapter N", "PART I", numbered headings, table of contents)
- Core themes and subject domain
- Approximate number of chapters
Then read the Table of Contents section if present to map all chapters.
If mode is "Analyze Only": produce the extraction report now and stop. Structure:
## Extraction Report — <Title>
### Author's Core Frameworks
- **<Framework Name>**: <what it is and when to apply>
### Key Principles
- <Principle>: <actionable rule>
### Techniques & Methods
- <Technique>: <step-by-step or how-to>
### Anti-patterns
- <What to avoid>: <why>
### Suggested Skill Name
`{author-lastname}-{core-concept}` — e.g. `cialdini-influence`
### Chapters Detected
| # | Title | Main Frameworks |---
Step 4 — Ask purpose (Full Conversion only)
Before generating, ask the user:
"What should this skill help you do? (Pick one or more)
1. Apply the author's frameworks while working
2. Think with the author's mental models
3. Reference specific chapters and concepts
4. All of the above"
Use the answer to weight what gets highlighted in the SKILL.md Core section.
Derive `DEPTH` from the answer (no extra prompt):
- Answer is only option 3 (reference) →
DEPTH=reference— lean, fast-lookup chapters. - Answer includes option 1, 2, or 4 →
DEPTH=study— deeper chapters with more worked detail, examples, and reasoning.
DEPTH and BOOK_TYPE together set the per-chapter token budget in Step 7. Do not ask a separate "study vs reference" question — it is inferred here. (In Modes 2/3, where Step 4 is skipped, default DEPTH=study.)
---
Step 5 — Determine skill name
If SKILL_NAME was provided, use it as the skill slug. Otherwise, propose two options and let the user choose:
- By author-concept:
{author-lastname}-{core-concept}(e.g.cialdini-influence,meadows-systems) - By title: lowercase hyphens from book title (e.g.
designing-data-intensive-apps)
Default to author-concept format if the book has a strong methodological identity.
Choose the destination skill root (SKILLS_HOME). Probe the user's filesystem for existing skill homes and pick by the host the user is running in:
| Host agent | Personal skill root (probe in order) | Project-local root |
|---|---|---|
| GitHub Copilot CLI | ~/.copilot/skills → ~/.agents/skills | .github/skills → .claude/skills → .agents/skills |
| Amp | ~/.agents/skills → ~/.config/agents/skills → ~/.config/amp/skills | .agents/skills |
| Claude Code | ~/.claude/skills | .claude/skills |
Selection rules: 1. If exactly one of the host's candidate roots exists on disk, use it without asking. 2. If none exist (fresh machine), ask the user which root to create — present the host-appropriate options and remember the choice for the session. Do not silently pick. 3. If the user explicitly asked for project-local output, prefer the project-local row. 4. If you cannot identify the host, ask: "Which agent are you running this in — GitHub Copilot CLI, Amp, or Claude Code?"
Set SKILLS_HOME to the selected root and check if $SKILLS_HOME/<skill_name>/ already exists. If it does, prompt the user to choose: 1. Update / Fold-in (Mode 4) — integrate new files/content into the existing skill components. 2. Overwrite — delete and regenerate the skill from scratch. 3. Rename — append -2 or use a different custom slug.
If the user selects Update / Fold-in, proceed immediately to the Update / Fold-in Workflow section after Step 2.5 (skipping Steps 3, 4, 6, 7, 8, 9).
---
Step 6 — Create skill directory structure
mkdir -p "$SKILLS_HOME/<skill_name>/chapters"---
Step 7 — Generate chapter summaries
TOKEN BUDGET RULE — CRITICAL (adaptive):
The per-chapter budget scales with BOOK_TYPE and DEPTH. Technical chapters need room for code and tables; study depth needs room for worked reasoning. Pick the budget from this matrix:
DEPTH=reference | DEPTH=study | |
|---|---|---|
BOOK_TYPE=text | 800–1,200 tokens | 1,000–1,800 tokens |
BOOK_TYPE=technical | 1,200–1,800 tokens | 2,000–3,000 tokens |
- These are per-file targets, not hard caps — a dense chapter may run over, a thin one under. Density still beats length (Quality Rule #3): never pad to hit a number.
- Files are loaded on-demand, so a larger chapter only costs tokens when that chapter is actually read.
- When in doubt between two cells (e.g. mixed-content book), use the lower budget and let depth come from precision, not volume.
`DEPTH=study` is earned with content, not a bigger number. The standard section template (Core Idea → Connects To) naturally lands a dense prose chapter around 700–900 tokens. To reach the study budget honestly — not by padding — a study-depth chapter must add concrete material:
- Reproduce one worked example or artifact from the chapter (e.g. the example press release, a sample dialogue, a filled-in template, a decision the author walks through) under a
## Worked Examplesection. This is the single biggest lever and the main thing a learner returns for. - Expand the "How" of each framework into explicit steps or criteria, not a one-liner.
- Add a short "Why it works / failure mode" note to the top 1–2 frameworks.
If a chapter genuinely has no worked example and resists expansion, let it land below the study floor rather than padding — and note that the chapter is thin in its Core Idea. A reference-depth chapter, by contrast, deliberately omits worked examples and keeps only the decision-ready essentials.
For EACH chapter/major section identified in Step 3:
Read the corresponding section of the extracted full_text.txt (use character offsets or grep for chapter headings).
Create $SKILLS_HOME/<skill_name>/chapters/ch<NN>-<slug>.md using the structure below.
Adapt emphasis based on `BOOK_TYPE`:
technical→ prioritize "Code Examples", "Reference Tables", and "Commands & APIs" sections; preserve exact syntaxtext→ prioritize "Frameworks Introduced", "Mental Models", and "Key Takeaways"; skip empty technical sections
# Chapter N: <Full Title>
## Core Idea
<1–2 sentences: the single most important thing this chapter teaches>
## Frameworks Introduced
- **<Framework Name>**: <exact formulation — preserve the author's naming>
- When to use: <specific situation>
- How: <steps or criteria>
## Key Concepts
- **<Term>**: <precise definition in 1 sentence>
(5–10 most important terms from this chapter)
## Mental Models
<2–4 frameworks or thinking tools. Write as "Use X when Y" or "Think of X as Y">
## Anti-patterns
- **<What to avoid>**: <why it fails>
## Code Examples *(technical books only — omit if BOOK_TYPE=text)*
<!-- Copy the most instructive snippet from the chapter. Preserve indentation exactly. --><key code example from this chapter>
- **What it demonstrates**: <one line>
## Reference Tables *(technical books only — omit if BOOK_TYPE=text)*
<!-- Reproduce any comparison matrix, parameter table, or decision table from the chapter in markdown. -->
## Worked Example *(DEPTH=study only — omit for DEPTH=reference)*
<!-- Reproduce or reconstruct one concrete example the author works through: a
sample document, a dialogue, a filled-in template, a before/after, or a
decision walked end-to-end. This is what makes a study chapter worth its
budget. Keep it faithful to the source; never copy long raw passages —
reconstruct the example compactly. -->
## Key Takeaways
1. <Actionable insight>
2. <Actionable insight>
3. <Actionable insight>
(3–7 takeaways a practitioner must remember)
## Connects To
- **Ch N**: <why this chapter relates>
- **<Concept>**: <external concept or standard it connects with>---
Step 8 — Generate supporting files
glossary.md
Create $SKILLS_HOME/<skill_name>/glossary.md:
- Every significant term from the book, alphabetically sorted
- Format:
**Term** — definition (Ch N) - Max 1,500 tokens
patterns.md
Create $SKILLS_HOME/<skill_name>/patterns.md:
- All concrete techniques, design patterns, algorithms from the book
- Format:
## Pattern Name\n**When to use**: ...\n**How**: ...\n**Trade-offs**: ... - Max 2,000 tokens
cheatsheet.md
Create $SKILLS_HOME/<skill_name>/cheatsheet.md:
This is the most differentiated layer of the skill — treat it as a reasoning aid, not a keyword list. Anyone can grep the glossary for a term. The cheatsheet captures the author's judgment: the decisions they'd make and why. It's the file that turns "I know the words" into "I'd act the way the author would".
Prioritize, in order: 1. Decision rules — "When X, do Y, because Z." The if/then logic the author applies, stated so the reader can apply it without re-reading the book. 2. Decision trees / flowcharts (as nested bullets or a small table) — for choices with more than two branches. 3. Trade-off matrices — competing options scored on the dimensions the author cares about, so the reader can pick under their own constraints. 4. Thresholds & defaults — the specific numbers, ratios, or rules of thumb the author commits to (e.g. "keep functions under ~20 lines", "alert when error budget < 10%"). 5. Tells & smells — fast heuristics for recognizing a situation ("if you see X, you're probably in trouble Y").
Avoid: bare term→definition rows (that's the glossary), and prose paragraphs (that's the chapters). Every line should help the reader decide something.
- Format mostly as compact tables and decision rules; the content you'd want on a single printed page kept beside you while working.
- Max 1,200 tokens.
---
Step 9 — Generate the master SKILL.md
CRITICAL TOKEN BUDGET: Keep SKILL.md body under 4,000 tokens. Compaction truncates from the END — put the most important content FIRST.
Create $SKILLS_HOME/<skill_name>/SKILL.md:
---
name: <skill_name>
description: "Knowledge base from \"<Full Title>\" by <Author(s)>. Use when applying <author>'s frameworks for <key topics, 3–6 terms>, studying the book, or referencing its concepts."
---
<!-- argument-hint: [topic, framework name, or chapter number] -->
# <Full Title>
**Author**: <Author(s)> | **Pages**: ~<N> | **Chapters**: <N> | **Generated**: <YYYY-MM-DD>
## How to Use This Skill
- **Without arguments** — load core frameworks for reference
- **With a topic** — ask about `replication`, `pricing`, or another indexed topic; I find and read the relevant chapter
- **With chapter** — ask for `ch05`; I load that specific chapter
- **Browse** — ask "what chapters do you have?" to see the full index
When you ask about a topic not covered in Core Frameworks below, I will read
the relevant chapter file before answering.
---
## Core Frameworks & Mental Models
<!-- ~2,000 tokens: the author's most important named frameworks and principles.
Preserve exact names. Write as "Use X when Y", "Prefer X over Y because Z".
This is a toolkit, not a summary. -->
<generate 2,000 tokens of the most critical frameworks and insights here>
---
## Chapter Index
| # | Title | Key Frameworks |
|---|-------|----------------|
| [ch01](chapters/ch01-<slug>.md) | <Title> | <framework1>, <framework2> |
| [ch02](chapters/ch02-<slug>.md) | <Title> | <framework1>, <framework2> |
...
## Topic Index
<!-- Alphabetical. Major terms/frameworks → chapter(s) that cover them. -->
- **<Term>** → ch<N>[, ch<N>]
- **<Term>** → ch<N>
## Supporting Files
- [glossary.md](glossary.md) — all key terms with definitions
- [patterns.md](patterns.md) — all techniques and design patterns
- [cheatsheet.md](cheatsheet.md) — quick reference tables and decision guides
---
## Scope & Limits
This skill covers the book content only. For hands-on implementation in your codebase,
combine with project-specific tools. For topics beyond this book, check related skills
or ask the agent directly.---
Step 10 — Cleanup and report
PYTHON_BIN="${PYTHON_BIN:-python3}"
if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
PYTHON_BIN="python"
fi
"$PYTHON_BIN" - <<'PY'
import os
import shutil
import tempfile
from pathlib import Path
shutil.rmtree(
os.environ.get("BOOK_SKILL_WORKDIR", Path(tempfile.gettempdir()) / "book_skill_work"),
ignore_errors=True,
)
PYThen report to the user:
✅ Skill created: $SKILLS_HOME/<skill_name>/
📚 Book: <Full Title> — <Author>
📄 Pages: ~<N> | Chapters: <N>
Files generated:
SKILL.md — core frameworks + index (~X tokens)
chapters/ — <N> chapter summaries (~X tokens each, ~X total)
glossary.md — key terms (~X tokens)
patterns.md — techniques & patterns (~X tokens)
cheatsheet.md — quick reference (~X tokens)
─────────────────────────────────────────────────────
Total skill size: ~X tokens (loaded on-demand, not all at once)
💡 Tip: check your agent's session cost/usage command to see actual token usage.
Usage:
Ask for <skill_name> → load core frameworks
Ask <skill_name> about <topic> → find and explain a topic
Ask <skill_name> for ch<N> → dive into a specific chapter
Reload (if your agent doesn't auto-detect new skills):
GitHub Copilot CLI: /skills reload
Claude Code: restart the session
Amp: restart the session
Share this skill (Copilot ecosystem, optional):
gh skill publish $SKILLS_HOME/<skill_name>---
Update / Fold-in Workflow
When performing an Update/Fold-in operation on an existing skill at $SKILLS_HOME/<skill_name>/:
1. Read Existing Skill Structure
Read and parse the existing skill's files:
- Read
$SKILLS_HOME/<skill_name>/SKILL.mdto parse the existing Chapter Index, Topic Index, metadata (author, total chapters), and Core Frameworks. - List all files in
$SKILLS_HOME/<skill_name>/chapters/to find the highest chapter number (e.g.ch12). - Read
$SKILLS_HOME/<skill_name>/glossary.md,$SKILLS_HOME/<skill_name>/patterns.md, and$SKILLS_HOME/<skill_name>/cheatsheet.mdto see what terms and frameworks are already indexed.
2. Match Content & Identify Revisions vs. Additions
Analyze the new extracted text in <tempdir>/book_skill_work/full_text.txt to identify if the new content represents:
- Updates/Revisions to existing chapters: If a section of the new content directly updates or expands an existing chapter's topic, read the existing chapter file, merge the new details into it, and rewrite the file.
- New additions: If the content introduces new chapters, papers, or separate sections, create new chapter summary files under
chapters/. Start numbering these files after the highest existing chapter number (e.g. if the existing chapters stop atch12, createch13-*.md,ch14-*.md, etc.).
3. Generate or Update Chapter Summary Files
For each new or revised chapter:
- Read the corresponding section of the extracted new text.
- Follow the formatting guidelines in Step 7 to build the summary.
- Write/update the file in
$SKILLS_HOME/<skill_name>/chapters/.
4. Merge Supporting Files
- Merge glossary.md:
- Read the existing
$SKILLS_HOME/<skill_name>/glossary.md. - Extract all new terms and definitions from the new content (Step 8 glossary guidelines).
- Combine and alphabetize the list of existing and new terms.
- If a term already exists, append the new chapter/source references to it (e.g.
**Term** — definition (Ch 4, Ch 13)). - Rewrite
$SKILLS_HOME/<skill_name>/glossary.mdwith the fully merged, alphabetized list. - Merge patterns.md:
- Read existing
$SKILLS_HOME/<skill_name>/patterns.md. - Extract any new techniques, algorithms, or patterns from the new content.
- Append the new patterns, ensuring consistent formatting, and keeping the total length concise (under 2,500 tokens).
- Merge cheatsheet.md:
- Read existing
$SKILLS_HOME/<skill_name>/cheatsheet.md. - Extract new comparison rules, decision tables, or parameter guides.
- Integrate them cleanly into the cheatsheet structure.
5. Re-generate the Master SKILL.md
Update the master skill file $SKILLS_HOME/<skill_name>/SKILL.md:
- Metadata: Increment the chapter count, update the estimated page count, and add the new source names if appropriate. Update the
Generateddate to the current date. - Core Frameworks: Fold in the most high-impact mental models or principles from the new content (ensuring the overall file remains under 4,000 tokens).
- Chapter Index: Append the new chapters to the index table, linking to the newly created files.
- Topic Index: Merge the new topics alphabetically. If an existing topic is also covered in the new chapters, append the new chapter links to its line (e.g.
- **Topic** → ch05, ch13).
6. Cleanup and Proceed to Step 10
Once the files are successfully written and merged, skip to Step 10 to perform cleanup and print a custom update report summarizing the newly added chapters, merged glossary terms, and updated indices.
---
Quality Rules
1. Extract structure, not summaries — capture named frameworks, exact formulations, anti-patterns; not chapter recaps 2. Preserve the author's precision — "The 5 Whys" ≠ "ask why multiple times"; keep exact naming 3. Density over completeness — a 1,000-token summary beats a 10,000-token excerpt 4. Practitioner voice — write "Use X when Y", not "The book explains X" 5. Front-load SKILL.md — compaction keeps the first 5,000 tokens; most important content comes first 6. Chapter files are on-demand — they don't count against skill budget until loaded 7. Never copy raw book text — always synthesize, summarize, extract signal 8. Topic index is critical — it's how the agent navigates to the right chapter file
version: 2
updates:
# Keep GitHub Actions fresh (and enables pinning actions to SHAs later).
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: ci
# One PR per week with all action bumps grouped, instead of one PR per action.
groups:
github-actions:
patterns: ["*"]
# Python dependencies (the pyproject extras: pdf/epub/docx/rtf/technical).
# Surfaces security alerts and version bumps for the optional extractors.
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: deps
groups:
python:
patterns: ["*"]
github: [virgiliojr94]
What happened
What you expected
Source document
- Format: <!-- PDF / EPUB / DOCX / … -->
- Pages / size:
- Language:
- Does
python3 scripts/extract.py --checkshow the relevant extractor installed? <!-- yes/no -->
Repro
<!-- The exact command and (if shareable) a small sample file or input -->
Environment
- OS:
- Python version:
- book-to-skill version / commit:
Problem
<!-- What are you trying to do that's hard or impossible today? -->
Proposed change
How would we know it's better?
<!-- A measurable acceptance criterion: a token delta, a test, a before/after. Changes that add weight without a demonstrated gain are unlikely to land. -->
Alternatives considered
<!-- PR title: use Conventional Commits, e.g. fix(extractor): scan full text, feat(cli): .... Fill EVERY section marked (Required). Sections marked (Optional) can be deleted if not applicable. A PR that leaves Required sections empty or unchecked will be asked to complete them before review. -->
Summary (Required)
<!-- In 1-3 sentences and plain language: what does this PR do? Someone skimming should understand the change without reading the diff. -->
Type of change (Required)
<!-- Mark all that apply with [x]. -->
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ Feature (non-breaking change that adds capability)
- [ ] ⚡ Performance (faster / cheaper, no behavior change)
- [ ] ♻️ Refactor (no behavior change)
- [ ] 📝 Docs only
- [ ] 🔒 Security
- [ ] 🧹 Chore / CI / tooling
- [ ] 💥 Breaking change (changes existing behavior or a public interface)
What it does (Required)
<!-- Be specific. Use the lines that apply, delete the rest. -->
- Fixes: <!-- the exact wrong behavior, and the root cause -->
- Improves: <!-- what gets better, and by how much (numbers, not adjectives) -->
- Adds: <!-- the new capability and who it is for -->
Motivation & context (Required)
<!-- Why is this needed? What problem or use case drives it? --> Closes #<!-- issue number, or "n/a" with a one-line reason if there is no issue -->
How it works (Required for anything non-trivial)
<!-- The approach: key decision(s), why this way, any alternative you rejected. For a one-line fix, "see diff" is fine. -->
Evidence (Required)
<!-- Proof, not claims. This project's rule is "measure, don't assert". -->
- Tests: <!-- which tests you added/changed and what they assert -->
- Before / after: <!-- terminal output, repro of the bug, or measured numbers.
For performance: a tools/discovery_tax.py number or a timed before/after. For a bug fix: the failing input and the now-correct output. -->
<!-- paste the before/after terminal output here -->Screenshots / output (Required if behavior or output changes — Optional for pure internal refactor)
<!-- Drag in an image: the bug vs the fix, the generated skill, the dashboard, etc. A picture of broken-vs-fixed is the fastest way to get a PR reviewed. Delete this section only for invisible internals. -->
Checklist (Required — all must be checked)
- [ ] One focused change (one feature/fix per PR; not a stack of unrelated work)
- [ ] Branch is rebased on the latest
masterand has no merge conflicts - [ ] Tests added/updated for behavior changes
- [ ]
pytest -qis green on a clean checkout - [ ]
ruff check .is clean - [ ]
python3 tools/validate_skill.py SKILL.mdpasses (ifSKILL.mdchanged) - [ ]
CHANGELOG.mdupdated under## [Unreleased] - [ ] No raw book text shipped; no net
SKILL.mdbloat without justification
Breaking changes & back-compat (Optional)
<!-- What breaks, who is affected, and the migration path. Delete if none. -->
Notes for reviewers (Optional)
<!-- Anything that helps review: areas you are unsure about, follow-ups you left out on purpose, things explicitly out of scope. -->
name: CI
on:
push:
branches: [master]
pull_request:
permissions:
contents: read
jobs:
test:
name: test (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: ${{ matrix.python-version }}
- name: Install pytest
run: pip install pytest
- name: Run test suite
run: pytest tests/ -q
lint:
name: lint (ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: "3.12"
- name: Install ruff
run: pip install ruff
- name: Ruff check
# High-value gate: syntax errors (E9, e.g. IndentationError) + pyflakes
# (F: undefined names, unused imports). Style rules are intentionally
# not gated yet to avoid blocking on cosmetic churn.
run: ruff check --select E9,F --target-version py310 book_to_skill/ scripts/ tests/ tools/
smoke:
name: smoke (dependency-free extraction)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: "3.12"
- name: Extract a sample with no optional deps installed
run: |
set -euo pipefail
mkdir -p sample
printf '# Backpressure\n\nChapter 1\nBounded queues prevent overload.\n' > sample/note.md
export BOOK_SKILL_WORKDIR="$RUNNER_TEMP/work"
python3 scripts/extract.py sample/note.md --mode text --install-missing no
test -f "$BOOK_SKILL_WORKDIR/full_text.txt"
test -f "$BOOK_SKILL_WORKDIR/metadata.json"
grep -q "Backpressure" "$BOOK_SKILL_WORKDIR/full_text.txt"
security:
name: security (bandit + zizmor)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: "3.12"
- name: Install scanners
run: pip install bandit zizmor
- name: Bandit — gate on HIGH severity
# Hard gate: only HIGH-severity / MEDIUM-confidence findings fail CI, to
# avoid blocking on the known-acceptable subprocess (pip install) calls.
# Ratchet down to medium once the open B314 docx-XML finding is hardened.
run: bandit -q -r book_to_skill scripts tools --severity-level high --confidence-level medium
- name: Bandit — report MEDIUM+ (informational)
run: bandit -q -r book_to_skill scripts tools -ll || true
- name: Zizmor — workflow audit (informational)
# Surfaces GitHub Actions risks (injection, pull_request_target misuse).
# Currently only flags unpinned-uses (tags vs SHA) — non-blocking until
# the actions are pinned to SHAs (Dependabot follow-up).
run: zizmor .github/workflows/ || true
validate-skill:
name: validate SKILL.md (Claude Code rules)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405
with:
python-version: "3.12"
- name: Audit SKILL.md against Claude Code skill rules
# Fails on Claude-breaking issues (missing name/description, oversized
# description, a tool restriction that omits Bash while the skill shells
# out). Cross-agent metadata Claude ignores is reported as WARN only.
run: python3 tools/validate_skill.py SKILL.md
dependency-review:
name: dependency review (PR)
# Only runs on PRs: it diffs the base vs head dependency graph and flags any
# newly introduced dependency with a known CVE (or a denied license), so a
# contributor cannot slip a vulnerable/malicious package past review. The
# result is posted as a summary comment on the PR itself.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # post the vulnerability summary comment on the PR
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
with:
# Block the merge when an introduced dependency has a moderate+ CVE.
fail-on-severity: moderate
# Always leave the findings as a comment on the PR, even when clean.
comment-summary-in-pr: always
pr-template:
name: PR description check (PR)
# Enforces the PR template: required sections present, a type-of-change
# selected, and every Required-checklist item ticked. Skips Dependabot.
if: github.event_name == 'pull_request' && github.event.pull_request.user.login != 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Validate PR description
# The body is passed via env (never interpolated into the shell) so a
# crafted PR description cannot inject commands.
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
python3 - <<'PY'
import os, sys
body = os.environ.get("PR_BODY") or ""
def section(header):
i = body.find(header)
if i < 0:
return ""
rest = body[i + len(header):]
j = rest.find("\n## ")
return rest if j < 0 else rest[:j]
required = [
"## Summary", "## Type of change", "## What it does",
"## Motivation & context", "## Evidence", "## Checklist",
]
errs = [f"missing required section: {h}" for h in required if h not in body]
if "- [x]" not in section("## Type of change").lower():
errs.append("Type of change: tick at least one box with [x]")
if "- [ ]" in section("## Checklist"):
errs.append("Checklist: every Required item must be ticked [x]")
if len(body.strip()) < 200:
errs.append("description too short, please fill the template")
if errs:
print("PR description is incomplete:\n")
for e in errs:
print(" - " + e)
print("\nFill the PR template (all (Required) sections) and push again.")
sys.exit(1)
print("PR description looks complete.")
PY
name: CodeQL
on:
push:
branches: [master]
pull_request:
branches: [master]
schedule:
# Weekly scan so newly-disclosed query packs catch latent issues.
- cron: "27 4 * * 1"
permissions:
contents: read
security-events: write
jobs:
analyze:
name: analyze (python)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: python
queries: security-and-quality
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:python"
name: Deploy docs
on:
push:
branches: [master]
paths:
- docs/**
- README.md
- SKILL.md
- BACKERS.md
- mkdocs.yml
- .github/workflows/deploy-docs.yml
pull_request:
paths:
- docs/**
- README.md
- SKILL.md
- BACKERS.md
- mkdocs.yml
- .github/workflows/deploy-docs.yml
# Minimal: gh-deploy pushes the built site to the gh-pages branch.
permissions:
contents: write
jobs:
docs:
runs-on: ubuntu-latest
steps:
# Actions pinned to commit SHAs (tags in comments) so a supply-chain
# swap on a moving tag cannot alter the build. Zizmor-clean.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.12"
- name: Install MkDocs Material
run: pip install mkdocs-material
- name: Assemble docs sources
# Single-source: the Guide and Skill Reference pages are the repo-root
# README.md / SKILL.md, copied in (never committed under docs/). The
# landing page docs/index.md is committed and curated separately.
run: |
cp README.md docs/guide.md
cp SKILL.md docs/skill-reference.md
cp BACKERS.md docs/BACKERS.md
- name: Build
run: mkdocs build
- name: Deploy to gh-pages (master push only)
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
run: mkdocs gh-deploy --force
*.pyc
__pycache__/
# Agent session traces (Stone CLI) — never commit
.stone/
**/.stone/
# PyPI packaging build directories
dist/
build/
*.egg-info/
# uv lockfile (not pinned for a library/tool; deps live in pyproject extras)
uv.lock
# Local design specifications
docs/superpowers/specs/
# MkDocs: build output + pages assembled from README/SKILL at deploy time
# (docs/index.md is a committed landing page, NOT assembled)
site/
docs/guide.md
docs/skill-reference.md
docs/BACKERS.md
Backers & Sponsors
Thank you to everyone who sponsors my open-source work. Your support funds PR reviews, multilingual edge-case fixes, releases, and documentation, and keeps these tools free, open, and privacy-first.
[Become a sponsor → github.com/sponsors/virgiliojr94](https://github.com/sponsors/virgiliojr94)
---
🥇 Gold Sponsors
_Be the first._
🥈 Silver Sponsors
_Be the first._
🥉 Bronze Sponsors
_Be the first._
💖 Backers
_Be the first._
---
This file is updated as sponsorships come in. If you sponsored and want a different name, link, or logo (or prefer to stay anonymous), open an issue or reply on your sponsorship and I will adjust it.
from book_to_skill.utils import resolve_input_files, extract_single_file, main
from book_to_skill.exceptions import ExtractionError
__all__ = ["resolve_input_files", "extract_single_file", "main", "ExtractionError"]
from book_to_skill.cli import main
if __name__ == "__main__":
main()
import sys
from book_to_skill.utils import main as utils_main
def main():
# Force UTF-8 stdout/stderr to avoid UnicodeEncodeError on Windows console
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
# Ignore if the stream does not support reconfigure (e.g. mock streams during testing)
pass
utils_main()
# Expose main for packaging console scripts entry points
if __name__ == "__main__":
main()
import os
import tempfile
from pathlib import Path
OUTPUT_DIR = Path(
os.environ.get(
"BOOK_SKILL_WORKDIR",
str(Path(tempfile.gettempdir()) / "book_skill_work"),
)
)
OUTPUT_TEXT = OUTPUT_DIR / "full_text.txt"
OUTPUT_META = OUTPUT_DIR / "metadata.json"
WORDS_PER_TOKEN = 0.75 # approximate
TEXT_EXTENSIONS = {".txt", ".text", ".md", ".markdown", ".rst", ".adoc", ".asciidoc"}
HTML_EXTENSIONS = {".html", ".htm", ".xhtml"}
CALIBRE_EBOOK_EXTENSIONS = {".mobi", ".azw", ".azw3"}
SUPPORTED_EXTENSIONS = {
".pdf", ".epub", ".docx", ".rtf",
*TEXT_EXTENSIONS,
*HTML_EXTENSIONS,
*CALIBRE_EBOOK_EXTENSIONS,
}
PYTHON_DEPENDENCIES = {
"docling": "docling",
"pypdf": "pypdf",
"pdfminer": "pdfminer.six",
"ebooklib": "ebooklib",
"bs4": "beautifulsoup4",
"docx": "python-docx",
"striprtf": "striprtf",
}
def supported_formats_message() -> str:
return ", ".join(sorted(SUPPORTED_EXTENSIONS))
from __future__ import annotations
import importlib.util
import os
import shutil
import subprocess
import sys
from book_to_skill.config import PYTHON_DEPENDENCIES, HTML_EXTENSIONS
# Ordered groups for the --check preflight report. Each entry describes one
# format and what it needs. `modules` are optional Python packages (any one is
# enough unless noted); `system` are external commands resolved via PATH.
DEPENDENCY_GROUPS = [
{
"label": "PDF (text-heavy)",
"modules": ["pypdf", "pdfminer"],
"any_of_modules": True,
"any_tool_suffices": True,
"system": [("pdftotext", "poppler-utils", "sudo apt install poppler-utils")],
"note": "any one of pdftotext / pypdf / pdfminer is enough",
},
{
"label": "PDF (technical: tables, code, formulas)",
"modules": ["docling"],
"any_of_modules": True,
"system": [],
"note": "needed only for --mode technical; otherwise falls back to the text chain",
},
{
"label": "EPUB",
"modules": ["ebooklib", "bs4"],
"any_of_modules": False,
"system": [],
"note": "falls back to a stdlib zipfile parser if missing",
},
{
"label": "DOCX",
"modules": ["docx"],
"any_of_modules": True,
"system": [],
"note": "falls back to a stdlib ZIP/XML parser if missing",
},
{
"label": "HTML",
"modules": ["bs4"],
"any_of_modules": True,
"system": [],
"note": "falls back to the stdlib html.parser if missing",
},
{
"label": "RTF",
"modules": ["striprtf"],
"any_of_modules": True,
"system": [],
"note": "falls back to a basic regex cleanup if missing",
},
{
"label": "MOBI / AZW / AZW3",
"modules": [],
"any_of_modules": True,
"required": True,
"system": [
("ebook-convert", "Calibre", "install Calibre: https://calibre-ebook.com/download"),
],
"note": "no fallback — Calibre is required for these formats",
},
]
def python_module_available(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None
def missing_python_packages(module_names: list[str]) -> list[str]:
missing = []
for module_name in module_names:
if not python_module_available(module_name):
missing.append(PYTHON_DEPENDENCIES[module_name])
return missing
def install_python_packages(packages: list[str]) -> bool:
if not packages:
return True
print(f"Installing missing Python package(s): {', '.join(packages)}")
try:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", *packages],
text=True,
timeout=600,
)
except Exception as exc:
print(f"Package installation failed: {exc}", file=sys.stderr)
return False
importlib.invalidate_caches()
return result.returncode == 0
def normalize_install_mode(argv: list[str]) -> str:
mode = os.environ.get("BOOK_SKILL_INSTALL_MISSING", "ask").lower()
if "--no-install-missing" in argv:
return "no"
if "--install-missing" in argv:
idx = argv.index("--install-missing")
if idx + 1 < len(argv) and not argv[idx + 1].startswith("--"):
mode = argv[idx + 1].lower()
else:
mode = "yes"
if mode in {"1", "true", "y", "yes", "install"}:
return "yes"
if mode in {"0", "false", "n", "no", "fallback", "skip"}:
return "no"
return "ask"
def offer_dependency_install(
*,
feature: str,
module_names: list[str],
fallback: str | None,
install_mode: str,
) -> None:
packages = missing_python_packages(module_names)
if not packages:
return
message = f"{feature} uses {', '.join(packages)} if installed"
if fallback:
message += f", otherwise {fallback}"
message += "."
print(message)
should_install = False
if install_mode == "yes":
should_install = True
elif install_mode == "ask" and sys.stdin.isatty():
answer = input("Missing package(s) detected. Do you want to install? y=install, n=fallback: ").strip().lower()
should_install = answer in {"y", "yes", "install"}
else:
if fallback:
print("Non-interactive mode or install disabled; using fallback.")
else:
print("Non-interactive mode or install disabled; installation skipped.")
if not should_install:
if fallback:
print(f"Using fallback: {fallback}.")
return
if install_python_packages(packages):
still_missing = missing_python_packages(module_names)
if not still_missing:
print("Package installation complete.")
return
print(f"Package installation incomplete; still missing: {', '.join(still_missing)}", file=sys.stderr)
else:
print("Package installation failed.", file=sys.stderr)
if fallback:
print(f"Using fallback: {fallback}.")
def prepare_dependencies(ext: str, extraction_mode: str, install_mode: str) -> None:
if ext == ".pdf" and extraction_mode == "technical":
offer_dependency_install(
feature="Technical PDF extraction",
module_names=["docling"],
fallback="the PDF text fallback chain",
install_mode=install_mode,
)
if ext == ".pdf" and not shutil.which("pdftotext"):
offer_dependency_install(
feature="PDF text extraction",
module_names=["pypdf", "pdfminer"],
fallback="any installed Python PDF parser; extraction fails if none are available",
install_mode=install_mode,
)
if ext == ".epub":
offer_dependency_install(
feature="EPUB extraction",
module_names=["ebooklib", "bs4"],
fallback="a stdlib ZIP/HTML parser",
install_mode=install_mode,
)
if ext in HTML_EXTENSIONS:
offer_dependency_install(
feature="HTML extraction",
module_names=["bs4"],
fallback="a stdlib HTML parser",
install_mode=install_mode,
)
if ext == ".docx":
offer_dependency_install(
feature="DOCX extraction",
module_names=["docx"],
fallback="a stdlib ZIP/XML parser",
install_mode=install_mode,
)
if ext == ".rtf":
offer_dependency_install(
feature="RTF extraction",
module_names=["striprtf"],
fallback="a basic regex cleanup fallback",
install_mode=install_mode,
)
def run_dependency_check() -> int:
"""Scan every optional dependency across all formats and print a status
report plus the exact command to install whatever is missing.
Returns a process exit code: 0 always (a missing optional dep is not an
error — most formats degrade to a fallback). Intended for `extract.py --check`.
"""
print("book-to-skill — dependency check\n")
missing_pip_packages: list[str] = []
missing_system: list[tuple[str, str]] = [] # (name, install hint)
for group in DEPENDENCY_GROUPS:
print(f" {group['label']}")
present_modules = [m for m in group["modules"] if python_module_available(m)]
absent_modules = [m for m in group["modules"] if not python_module_available(m)]
system_present = [c for c, _, _ in group["system"] if shutil.which(c)]
system_absent = [c for c, _, _ in group["system"] if not shutil.which(c)]
for module_name in group["modules"]:
pip_name = PYTHON_DEPENDENCIES.get(module_name, module_name)
ok = module_name in present_modules
print(f" {'✓' if ok else '✗'} python: {pip_name}")
if not ok:
missing_pip_packages.append(pip_name)
for cmd, pretty, hint in group["system"]:
ok = cmd in system_present
print(f" {'✓' if ok else '✗'} system: {cmd} ({pretty})")
if not ok:
missing_system.append((pretty, hint))
# Satisfaction semantics:
# - any_tool_suffices: any single extractor (module OR system) is enough
# - any_of_modules: at least one module present
# - otherwise: every listed module present
# - system tools that aren't alternatives are always required
if group.get("any_tool_suffices"):
satisfied = bool(present_modules) or bool(system_present)
else:
if group["modules"]:
satisfied = bool(present_modules) if group["any_of_modules"] else not absent_modules
else:
satisfied = True
if system_absent:
satisfied = False
if satisfied:
status = "ready"
elif group.get("required"):
status = "MISSING — required, no fallback"
else:
status = "fallback available (install for best quality)"
print(f" → {status} — {group['note']}\n")
# Deduplicate while preserving order
missing_pip_packages = list(dict.fromkeys(missing_pip_packages))
missing_system = list(dict.fromkeys(missing_system))
if not missing_pip_packages and not missing_system:
print("All optional dependencies are installed. You're ready for every format.")
return 0
print("To enable the best extractor for every format, install the missing pieces:\n")
if missing_pip_packages:
print(f" {sys.executable} -m pip install {' '.join(missing_pip_packages)}")
for pretty, hint in missing_system:
print(f" # {pretty}: {hint}")
print(
"\nNote: missing Python packages are optional — most formats fall back to a "
"stdlib parser. Calibre is the only hard requirement, and only for MOBI/AZW files."
)
return 0
class ExtractionError(Exception):
"""Raised when a single file cannot be extracted (non-fatal in batch mode)."""
# Parsers package
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from book_to_skill.config import OUTPUT_DIR
def extract_with_ebook_convert(input_path: str) -> str | None:
if not shutil.which("ebook-convert"):
return None
output_path = OUTPUT_DIR / "ebook-convert-output.txt"
try:
input_path = os.path.abspath(input_path)
result = subprocess.run(
["ebook-convert", input_path, str(output_path)],
capture_output=True, text=True, timeout=300
)
if result.returncode == 0 and output_path.exists():
text = output_path.read_text(encoding="utf-8", errors="replace")
if text.strip():
return text
except Exception as e:
print(f" [warn] extract_with_ebook_convert failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
from __future__ import annotations
import zipfile
import sys
from book_to_skill.exceptions import ExtractionError
def extract_docx_with_python_docx(docx_path: str) -> str | None:
try:
import docx
document = docx.Document(docx_path)
parts = [paragraph.text for paragraph in document.paragraphs if paragraph.text]
for table in document.tables:
for row in table.rows:
cells = [cell.text.strip() for cell in row.cells]
if any(cells):
parts.append("\t".join(cells))
return "\n".join(parts)
except ImportError:
return None
except Exception as e:
print(f" [warn] extract_docx_with_python_docx failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def extract_docx_with_zipfile(docx_path: str) -> str | None:
try:
import xml.etree.ElementTree as ET
with zipfile.ZipFile(docx_path) as zf:
xml_bytes = zf.read("word/document.xml")
root = ET.fromstring(xml_bytes)
ns = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
parts: list[str] = []
def emit_block(elem) -> None:
# Walk block content in document order. Paragraphs join their runs;
# tables emit one tab-joined line per row (same row format as the
# python-docx path, but order-preserving — python-docx appends all
# tables last). Unknown wrappers (e.g. <w:sdt> content controls) are
# recursed into so their paragraphs/tables are not lost; <w:p> and
# <w:tbl> are NOT recursed into, so table-cell paragraphs are not
# double-counted. Cell text concatenates the cell's runs; nested
# tables fold into the parent cell and are also emitted standalone
# (rare; best-effort).
for child in elem:
tag = child.tag
if tag == f"{ns}p":
texts = [t.text for t in child.iter(f"{ns}t") if t.text]
if texts:
parts.append("".join(texts))
elif tag == f"{ns}tbl":
for row in child.iter(f"{ns}tr"):
cells = []
for cell in row.iter(f"{ns}tc"):
cell_texts = [t.text for t in cell.iter(f"{ns}t") if t.text]
cells.append("".join(cell_texts).strip())
if any(cells):
parts.append("\t".join(cells))
else:
emit_block(child)
body = root.find(f"{ns}body")
emit_block(body if body is not None else root)
return "\n".join(parts) if parts else None
except Exception as e:
print(f" [warn] extract_docx_with_zipfile failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def validate_docx_xml_safety(docx_path: str) -> None:
"""Scan all XML files in the DOCX zip archive to prevent XML Entity Expansion (Billion Laughs) and XXE injections."""
try:
with zipfile.ZipFile(docx_path) as zf:
for name in zf.namelist():
if name.endswith(".xml") or name.endswith(".rels"):
xml_bytes = zf.read(name)
for encoding in ("utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32"):
try:
content = xml_bytes.decode(encoding, errors="ignore").upper()
except LookupError:
continue
if "<!DOCTYPE" in content or "<!ENTITY" in content:
raise ExtractionError(
f"Security validation failed: XML file '{name}' in DOCX archive contains forbidden DTD or entity declarations."
)
except zipfile.BadZipFile as e:
raise ExtractionError(f"Invalid DOCX file: {e}")
except ExtractionError:
raise
except Exception as e:
raise ExtractionError(f"Error during security validation of DOCX archive: {e}")
def extract_docx(docx_path: str) -> tuple[str, str]:
validate_docx_xml_safety(docx_path)
print("Trying python-docx...", end=" ", flush=True)
text = extract_docx_with_python_docx(docx_path)
if text and text.strip():
print("OK")
return text, "python-docx"
print("not available")
print("Trying stdlib DOCX parser...", end=" ", flush=True)
text = extract_docx_with_zipfile(docx_path)
if text and text.strip():
print("OK")
return text, "zipfile-docx"
print("FAILED")
raise ExtractionError(
"Could not extract text from DOCX.\n"
"Install python-docx for best results:\n"
" pip3 install python-docx"
)
from __future__ import annotations
import posixpath
import re
import zipfile
import sys
from book_to_skill.parsers.html import _HTMLTextExtractor
def extract_with_ebooklib(epub_path: str) -> str | None:
try:
import ebooklib
from ebooklib import epub
from bs4 import BeautifulSoup
book = epub.read_epub(epub_path)
parts = []
for item in book.get_items_of_type(ebooklib.ITEM_DOCUMENT):
soup = BeautifulSoup(item.get_content(), "html.parser")
parts.append(soup.get_text(separator="\n"))
return "\n\n".join(parts)
except ImportError:
return None
except Exception as e:
print(f" [warn] extract_with_ebooklib failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def _find_opf_path(zf: zipfile.ZipFile) -> str | None:
"""Locate the OPF package document inside an EPUB archive.
First tries ``META-INF/container.xml`` (the spec-defined entry point),
then falls back to scanning the archive for any ``.opf`` file.
"""
# Spec-defined: read container.xml for the rootfile path
try:
container = zf.read("META-INF/container.xml").decode("utf-8", errors="replace")
match = re.search(r'full-path=["\']([^"\']+\.opf)["\']', container)
if match:
return match.group(1)
except (KeyError, Exception):
pass
# Fallback: glob for any .opf file
opf_files = [n for n in zf.namelist() if n.endswith(".opf")]
return opf_files[0] if opf_files else None
def extract_with_zipfile(epub_path: str) -> str | None:
"""stdlib-only EPUB extractor: unzip → parse HTML files."""
try:
with zipfile.ZipFile(epub_path) as zf:
names = zf.namelist()
# Locate OPF and determine its directory for resolving relative hrefs
opf_path = _find_opf_path(zf)
opf_dir = posixpath.dirname(opf_path) if opf_path else ""
# Read OPF spine to get reading order, fall back to sorted xhtml files
spine_order: list[str] = []
if opf_path:
opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
raw_hrefs = re.findall(r'href=["\']([^"\']+\.(?:xhtml|html))["\']', opf_text)
# Resolve hrefs relative to the OPF directory
for href in raw_hrefs:
resolved = posixpath.normpath(posixpath.join(opf_dir, href)) if opf_dir else href
spine_order.append(resolved)
html_files = spine_order or sorted(
n for n in names if n.endswith((".html", ".xhtml"))
)
if not html_files:
return None
parts = []
for name in html_files:
try:
raw = zf.read(name).decode("utf-8", errors="replace")
parser = _HTMLTextExtractor()
parser.feed(raw)
parts.append(parser.get_text())
except Exception:
continue
return "\n\n".join(parts) if parts else None
except Exception as e:
print(f" [warn] extract_with_zipfile failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def count_epub_chapters(epub_path: str) -> int:
"""Count spine items (approximate chapter count) without dependencies."""
try:
with zipfile.ZipFile(epub_path) as zf:
opf_path = _find_opf_path(zf)
if not opf_path:
return 0
opf_text = zf.read(opf_path).decode("utf-8", errors="replace")
return len(re.findall(r'<itemref\b', opf_text))
except Exception:
return 0
from __future__ import annotations
import html
import html.parser
from book_to_skill.parsers.text import read_text_file
class _HTMLTextExtractor(html.parser.HTMLParser):
"""Minimal HTML → plain text converter using stdlib only."""
SKIP_TAGS = {"script", "style", "head"}
def __init__(self):
super().__init__()
self._parts: list[str] = []
self._skip_depth = 0
self._current_skip: str | None = None
def handle_starttag(self, tag, attrs):
if tag in self.SKIP_TAGS:
self._skip_depth += 1
if tag in ("p", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "div"):
self._parts.append("\n")
def handle_endtag(self, tag):
if tag in self.SKIP_TAGS and self._skip_depth:
self._skip_depth -= 1
def handle_data(self, data):
if not self._skip_depth:
self._parts.append(data)
def get_text(self) -> str:
# HTMLParser(convert_charrefs=True) already decoded entities in
# handle_data; do NOT unescape again or double-encoded entities
# (e.g. "&amp;") collapse incorrectly.
return "".join(self._parts)
def extract_html_content(raw_html: str) -> str:
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(raw_html, "html.parser")
for element in soup(["script", "style", "head"]):
element.decompose()
return soup.get_text(separator="\n")
except ImportError:
parser = _HTMLTextExtractor()
parser.feed(raw_html)
return parser.get_text()
def extract_html_file(path: str) -> str | None:
raw = read_text_file(path)
if raw is None:
return None
return extract_html_content(raw)
from __future__ import annotations
import os
import shutil
import subprocess
import sys
def extract_with_pdftotext(pdf_path: str) -> str | None:
if not shutil.which("pdftotext"):
return None
try:
pdf_path = os.path.abspath(pdf_path)
result = subprocess.run(
["pdftotext", "-layout", pdf_path, "-"],
capture_output=True, text=True, timeout=120
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout
except Exception as e:
print(f" [warn] extract_with_pdftotext failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def extract_with_pypdf(pdf_path: str) -> str | None:
try:
import pypdf
text_parts = []
with open(pdf_path, "rb") as f:
reader = pypdf.PdfReader(f)
for page in reader.pages:
try:
text_parts.append(page.extract_text() or "")
except Exception:
text_parts.append("")
return "\n".join(text_parts)
except ImportError:
return None
except Exception as e:
print(f" [warn] extract_with_pypdf failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def extract_with_pdfminer(pdf_path: str) -> str | None:
try:
from pdfminer.high_level import extract_text
return extract_text(pdf_path)
except ImportError:
return None
except Exception as e:
print(f" [warn] extract_with_pdfminer failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def extract_with_docling(pdf_path: str) -> str | None:
"""Layout-aware extraction using Docling. Best for technical books with tables and code."""
try:
from docling.document_converter import DocumentConverter
from docling.datamodel.pipeline_options import PdfPipelineOptions
from docling.datamodel.base_models import InputFormat
from docling.document_converter import PdfFormatOption
pipeline_options = PdfPipelineOptions()
pipeline_options.do_ocr = False
pipeline_options.do_table_structure = True
converter = DocumentConverter(
format_options={
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
}
)
result = converter.convert(pdf_path)
return result.document.export_to_markdown()
except ImportError:
return None
except Exception as e:
print(f" [warn] extract_with_docling failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
def count_pages(pdf_path: str) -> int:
# Try pdfinfo first
if shutil.which("pdfinfo"):
try:
pdf_path = os.path.abspath(pdf_path)
result = subprocess.run(
["pdfinfo", pdf_path], capture_output=True, text=True, timeout=15
)
for line in result.stdout.splitlines():
if line.startswith("Pages:"):
return int(line.split(":")[1].strip())
except Exception:
pass
# Fallback: count pages with pypdf
try:
import pypdf
with open(pdf_path, "rb") as f:
return len(pypdf.PdfReader(f).pages)
except Exception:
return 0
import html
import re
import sys
from book_to_skill.parsers.text import read_text_file
from book_to_skill.exceptions import ExtractionError
# RTF unicode escape: \uN (signed decimal) followed by its fallback char(s).
# Decode the code point and drop the standard single fallback — a \'XX hex byte
# or a literal "?". Assumes the default \uc1 (one fallback char); \ucN directives
# and multi-char/group fallbacks are not parsed (best-effort fallback only).
_RTF_UNICODE = re.compile(r"\\u(-?\d+)[ ]?(?:\\'[0-9a-fA-F]{2}|\?)?")
def _rtf_unicode_repl(match: re.Match) -> str:
cp = int(match.group(1)) % 0x10000 # RTF uses signed 16-bit; wrap negatives
if cp == 0 or 0xD800 <= cp <= 0xDFFF: # NUL and lone surrogates: unwanted in text
return ""
return chr(cp)
def strip_rtf_fallback(raw: str) -> str:
raw = _RTF_UNICODE.sub(_rtf_unicode_repl, raw) # decode \uN escapes first
raw = re.sub(r"\\'[0-9a-fA-F]{2}", " ", raw)
raw = re.sub(r"\\par[d]?", "\n", raw)
raw = re.sub(r"\\tab", "\t", raw)
raw = re.sub(r"\\[a-zA-Z]+-?\d* ?", "", raw)
raw = raw.replace("{", "").replace("}", "")
return html.unescape(raw)
def extract_rtf(rtf_path: str) -> tuple[str, str]:
raw = read_text_file(rtf_path)
if raw is None:
raise ExtractionError(f"Could not read RTF file: {rtf_path}")
try:
from striprtf.striprtf import rtf_to_text
text = rtf_to_text(raw)
if text.strip():
return text, "striprtf"
except ImportError:
pass
except Exception as e:
print(f" [warn] extract_rtf/striprtf failed: {type(e).__name__}: {e}", file=sys.stderr)
return strip_rtf_fallback(raw), "rtf-regex"
from __future__ import annotations
import sys
from pathlib import Path
def read_text_file(path: str) -> str | None:
for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
try:
return Path(path).read_text(encoding=encoding)
except UnicodeDecodeError:
continue
except Exception as e:
print(f" [warn] read_text_file failed: {type(e).__name__}: {e}", file=sys.stderr)
return None
return None
from __future__ import annotations
import glob
import json
import os
import re
import sys
import shutil
import zipfile
from pathlib import Path
from book_to_skill.exceptions import ExtractionError
from book_to_skill.config import (
OUTPUT_DIR,
OUTPUT_TEXT,
OUTPUT_META,
WORDS_PER_TOKEN,
SUPPORTED_EXTENSIONS,
TEXT_EXTENSIONS,
HTML_EXTENSIONS,
CALIBRE_EBOOK_EXTENSIONS,
supported_formats_message,
)
from book_to_skill.dependencies import (
normalize_install_mode,
prepare_dependencies,
run_dependency_check,
)
from book_to_skill.parsers.text import read_text_file
from book_to_skill.parsers.html import extract_html_file
from book_to_skill.parsers.docx import extract_docx
from book_to_skill.parsers.rtf import extract_rtf
from book_to_skill.parsers.calibre import extract_with_ebook_convert
from book_to_skill.parsers.pdf import (
extract_with_docling,
extract_with_pdftotext,
extract_with_pypdf,
extract_with_pdfminer,
count_pages,
)
from book_to_skill.parsers.epub import (
extract_with_ebooklib,
extract_with_zipfile,
count_epub_chapters,
)
def estimate_tokens(text: str) -> int:
return int(len(text.split()) / WORDS_PER_TOKEN)
# Explicit chapter heading: "Chapter 5", "Capítulo 5: ...", "Chapter 1. Intro".
# Also French/German/Italian/Dutch chapter words (chapitre/kapitel/capitolo/
# hoofdstuk), matching the ToC languages added alongside. "ch.?" stays last so
# the longer words match in full. Captures the number (bounded to 1..99 — drops
# years like "2025.") and whatever follows it on the line, so we can reject prose.
_EXPLICIT_CHAPTER = re.compile(
r"^\s*(?:chapter|chapitre|kapitel|cap[ií]tulo|capitolo|hoofdstuk|ch\.?)\s*(\d{1,2})\b(?P<rest>.*)$",
re.IGNORECASE,
)
# A heading's number is followed by end-of-line, punctuation (“. : - —“), or a
# Capitalized title word. A lowercase continuation (“Chapter 6 explores...”,
# “Chapter 8 are relevant...”) is prose / a cross-reference, not a heading.
# The uppercase class is À-Þ so titles starting with Ü/Û (common in German, e.g. “Überblick”) are recognized.
_HEADING_TAIL = re.compile(r"^\s*$|^\s*[.:\-—–]|^\s+[A-ZÀ-Þ0-9\"“(]")
# Roman-numeral chapter heading: "I: Loomings", "II. The Carpet-Bag".
# Requires a separator (":" or ".") and a Capitalized title after it, so a bare
# "I" or "V." (a page divider / list marker) is not mistaken for a chapter.
_ROMAN_HEAD = re.compile(r"^\s*([IVXLCDM]+)\s*[:.]\s+[A-ZÀ-Þ\"“(]")
_ROMAN_VALUES = {"I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000}
# Chinese chapter headings. Two common styles:
# 1. explicit "第N章" / "第 3 回" / "第十二节" / "第一讲" — 第 + numeral + a
# chapter classifier (章回卷节篇讲);
# 2. a Markdown heading led by a CJK ordinal and a separator, e.g.
# "## 一 · 缘起" or "## 第一讲" — common in CJK ebooks and lecture notes.
# Scoped to CJK numerals, so Latin/Roman detection above is completely unaffected
# (e.g. "## 5 Setup" is still not treated as a heading here). detect_structure()
# dedupes by number, so a "##" heading and a repeated "###" sub-ordinal collapse
# to a single chapter.
_CN_NUM_VALUES = {
"〇": 0, "零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5,
"六": 6, "七": 7, "八": 8, "九": 9,
}
_CN_NUM_UNITS = {"十": 10, "百": 100, "千": 1000}
_CN_NUM_CLASS = "〇零一二两三四五六七八九十百千"
# Full-width Arabic digits (U+FF10–U+FF19) are common in Japanese typesetting,
# e.g. "第1章". int() already parses them (str.isdigit() is True), so only the
# regex character classes need to accept them.
_FW_DIGITS = "0-9"
_CN_CHAPTER = re.compile(rf"^\s*第\s*([0-9{_FW_DIGITS}{_CN_NUM_CLASS}]+)\s*[章回卷节篇讲]")
_MD_CN_HEADING = re.compile(rf"^#{{1,6}}\s+第?\s*([{_FW_DIGITS}{_CN_NUM_CLASS}]+)\s*[·、.::章回卷节篇讲]")
# Table-of-contents header lines across common languages. Anchored to a whole
# line (^\s*X\s*$) so an inline "the contents of this chapter" never matches.
_TOC_HEADERS = (
"table of contents", "contents", "índice", "sumário", # EN / ES / PT
"目录", "目錄", "目次", # Chinese / Japanese
"table des matières", # French
"inhaltsverzeichnis", # German
"indice", "sommario", # Italian (no accent — distinct from índice above)
"inhoudsopgave", # Dutch
)
_TOC_PATTERN = re.compile(
r"^\s*(?:" + "|".join(re.escape(h) for h in _TOC_HEADERS) + r")\s*$",
re.IGNORECASE | re.MULTILINE,
)
# ATX-style heading: "# Title", "## Section", AsciiDoc "= Title", "== Section".
# The required space after the marker distinguishes an AsciiDoc "== X" from a
# reStructuredText underline "=====" (no space) — the latter is intentionally
# ignored (RST underline headings are out of scope).
_ATX_HEADING = re.compile(r"^(#{1,6}|={1,6})\s+(.+?)\s*#*$")
# Setext/RST underline: a full line of "=" (level 1) or "-" (level 2), length
# >= 2. Marks the line directly above it as a heading title.
_SETEXT_UNDERLINE = re.compile(r"^(={2,}|-{2,})$")
def _structural_chapter_count(text: str) -> int:
"""Count chapter-like structural headings in Markdown/AsciiDoc/RST sources.
Recognizes ATX headings ("# Title", "== Section") and setext/RST underline
headings (a title line directly above a row of "=" or "-"). Groups distinct
(case-normalized) titles by depth and returns the count at the shallowest
depth with >= 2 distinct titles — this selects the real chapter level in the
common "# Book Title / ## Chapter" layout where the top level appears once.
Guards against false positives: headings inside fenced code blocks are
skipped; an ATX title starting with a bare digit ("## 5 Setup") or made only
of punctuation ("=====" table borders) is rejected; a setext underline counts
only when it sits directly under a non-blank title line at least as long as
the underline (so thematic breaks, table borders, and front-matter "---" do
not match).
"""
levels: dict[int, set[str]] = {}
in_fence = False
prev = "" # previous non-fence line (stripped); a setext title candidate
for line in text.splitlines():
s = line.strip()
if s.startswith("```") or s.startswith("~~~"):
in_fence = not in_fence
prev = ""
continue
if in_fence:
prev = ""
continue
# Setext/RST underline: "=" (level 1) or "-" (level 2) directly under a
# title line at least as long as the underline.
if (
_SETEXT_UNDERLINE.match(s)
and prev
and not _SETEXT_UNDERLINE.match(prev)
and len(s) >= len(prev)
):
depth = 1 if s[0] == "=" else 2
levels.setdefault(depth, set()).add(prev.lower())
prev = ""
continue
# ATX heading ("# Title", "== Section").
m = _ATX_HEADING.match(s)
if m:
title = m.group(2).strip().lower()
# Reject empty, bare-digit-led ("## 5 Setup"), and all-punctuation
# ("=====" table-border) titles — none are real chapter headings.
if title and not title[0].isdigit() and re.search(r"\w", title):
levels.setdefault(len(m.group(1)), set()).add(title)
# An ATX heading line is not a setext title for the next line.
prev = ""
continue
prev = s
if not levels:
return 0
for depth in sorted(levels):
if len(levels[depth]) >= 2:
return len(levels[depth])
# No level has >= 2 distinct headings: a thin doc (e.g. one heading per
# level). Count them all — this path runs only as a fallback when numeric
# chapter detection already found zero, so it cannot inflate real books.
return sum(len(titles) for titles in levels.values())
def _cn_numeral_to_int(s: str) -> int | None:
"""Parse a Chinese (or ASCII-digit) chapter numeral into an int (1..999)."""
if s.isdigit():
n = int(s)
return n if 1 <= n <= 999 else None
section = current = 0
for ch in s:
if ch in _CN_NUM_VALUES:
current = _CN_NUM_VALUES[ch]
elif ch in _CN_NUM_UNITS:
section += (current or 1) * _CN_NUM_UNITS[ch]
current = 0
else:
return None
total = section + current
return total if 1 <= total <= 999 else None
def _int_to_roman(n: int) -> str:
table = [(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"),
(90, "XC"), (50, "L"), (40, "XL"), (10, "X"), (9, "IX"),
(5, "V"), (4, "IV"), (1, "I")]
out = []
for val, sym in table:
while n >= val:
out.append(sym)
n -= val
return "".join(out)
def _roman_to_int(s: str) -> int | None:
"""Convert a Roman numeral to int, returning None if it isn't canonical."""
s = s.upper()
total = prev = 0
for ch in reversed(s):
v = _ROMAN_VALUES.get(ch)
if v is None:
return None
total += -v if v < prev else v
prev = max(prev, v)
if total == 0 or total > 200:
return None
# Reject non-canonical forms ("IIII", "VV") by round-tripping.
return total if _int_to_roman(total) == s else None
def _chapter_number(line: str) -> int | None:
"""Return the chapter number if the line is a genuine chapter heading.
Handles Arabic ("Chapter 5", "Capítulo 5: ..."), Roman-numeral
("I: Loomings", "II. The Carpet-Bag") and Chinese ("第三章 …", "## 一 · …",
"## 第一讲") heading styles.
"""
s = line.strip()
if len(s) > 80:
return None
m = _EXPLICIT_CHAPTER.match(s)
if m and _HEADING_TAIL.match(m.group("rest")):
return int(m.group(1))
rm = _ROMAN_HEAD.match(s)
if rm:
return _roman_to_int(rm.group(1))
cm = _CN_CHAPTER.match(s) or _MD_CN_HEADING.match(s)
if cm:
return _cn_numeral_to_int(cm.group(1))
return None
def detect_structure(text: str) -> dict:
"""Detect chapter count and table of contents presence.
Scans the whole text (not just the head) and counts DISTINCT chapter numbers
from explicit "Chapter N"/"Capítulo N" headings, rejecting prose
cross-references and numbered list items. Counting distinct numbers means a
ToC entry and its body heading are not double-counted.
"""
lines = text.splitlines()
headings = []
numbers = set()
for line in lines:
num = _chapter_number(line)
if num is not None:
numbers.add(num)
headings.append(line.strip())
numeric_count = len(numbers)
# Fall back to structural (Markdown/AsciiDoc) headings only when no numeric
# "Chapter N" headings were found, so books with real chapters are unaffected.
chapters_detected = (
numeric_count if numeric_count > 0 else _structural_chapter_count(text)
)
# Look for ToC indicators in the first ~30k chars (multilingual; see _TOC_PATTERN)
has_toc = bool(_TOC_PATTERN.search(text[:30000]))
return {
"chapters_detected": chapters_detected,
"chapter_headings_sample": headings[:10],
"has_toc": has_toc,
}
def parse_arguments(argv: list[str]) -> tuple[list[str], str, str]:
"""Parse argv into (input_paths, extraction_mode, install_mode)."""
input_paths = []
extraction_mode = "text"
args = argv[1:]
i = 0
while i < len(args):
arg = args[i]
if arg == "--mode":
if i + 1 < len(args):
extraction_mode = args[i+1].lower()
i += 2
else:
i += 1
elif arg == "--install-missing":
if i + 1 < len(args) and not args[i+1].startswith("--"):
i += 2
else:
i += 1
elif arg == "--no-install-missing":
i += 1
elif arg.startswith("-"):
i += 1
else:
input_paths.append(arg)
i += 1
install_mode = normalize_install_mode(argv)
if extraction_mode not in ("technical", "text"):
extraction_mode = "text"
return input_paths, extraction_mode, install_mode
def resolve_input_files(paths: list[str]) -> list[Path]:
"""Resolve paths including files, directories, and glob patterns to Path objects.
User-given order is preserved for explicit file arguments. Expanded
results (directories, globs) are sorted deterministically so repeated
runs produce the same output.
"""
resolved = []
for path_str in paths:
# Check if it has glob wildcards
if any(char in path_str for char in ("*", "?", "[")):
glob_matches = glob.glob(path_str, recursive=True)
# Sort expanded glob results deterministically
expanded = []
for match in glob_matches:
p = Path(match)
if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS:
expanded.append(p.resolve())
expanded.sort(key=lambda x: str(x).lower())
resolved.extend(expanded)
else:
p = Path(path_str)
if p.is_dir():
# Sort expanded directory results deterministically
dir_files = []
for root, _, files in os.walk(p):
for file in files:
file_path = Path(root) / file
if file_path.suffix.lower() in SUPPORTED_EXTENSIONS:
dir_files.append(file_path.resolve())
dir_files.sort(key=lambda x: str(x).lower())
resolved.extend(dir_files)
else:
# Keep even if it doesn't exist so the error check can report it
resolved.append(p.resolve())
# Deduplicate while preserving insertion order (user order for explicit files)
seen = set()
unique_paths = []
for path in resolved:
resolved_path = path.resolve() if path.exists() else path
if resolved_path not in seen:
seen.add(resolved_path)
unique_paths.append(resolved_path)
return unique_paths
def extract_single_file(input_path: Path, extraction_mode: str, install_mode: str) -> dict:
"""Extract text and metadata from a single file path."""
input_str = str(input_path)
if not input_path.exists():
raise ExtractionError(f"File not found: {input_str}")
ext = input_path.suffix.lower()
document_format = ext.lstrip(".")
# Sniff magic bytes if suffix is not supported
if ext not in SUPPORTED_EXTENSIONS:
with open(input_str, "rb") as f:
header = f.read(8)
if header[:4] == b"%PDF":
ext = ".pdf"
document_format = "pdf"
elif header[:2] == b"PK":
try:
with zipfile.ZipFile(input_str) as zf:
names = set(zf.namelist())
if "mimetype" in names and zf.read("mimetype").startswith(b"application/epub"):
ext = ".epub"
document_format = "epub"
elif "word/document.xml" in names:
ext = ".docx"
document_format = "docx"
else:
raise ExtractionError(
f"Unsupported ZIP-based format '{input_path.name}'. Supported: {supported_formats_message()}"
)
except (zipfile.BadZipFile, KeyError, OSError):
raise ExtractionError(
f"Unsupported ZIP-based format '{input_path.name}'. Supported: {supported_formats_message()}"
)
else:
raise ExtractionError(
f"Unsupported format '{ext or '<none>'}'. Supported: {supported_formats_message()}"
)
prepare_dependencies(ext, extraction_mode, install_mode)
if ext in CALIBRE_EBOOK_EXTENSIONS and not shutil.which("ebook-convert"):
raise ExtractionError(
"MOBI/AZW/AZW3 extraction requires Calibre's ebook-convert command. "
"Install Calibre and ensure ebook-convert is on PATH, then rerun this command."
)
text = ""
method = ""
pages = 0
pages_label = "sections"
if ext == ".epub":
print(f"Extracting EPUB: {input_str}")
text = extract_with_ebooklib(input_str)
if text and text.strip():
method = "ebooklib"
else:
print("ebooklib not available")
print("Trying stdlib zipfile parser...", end=" ", flush=True)
text = extract_with_zipfile(input_str)
if text and text.strip():
print("OK")
method = "zipfile"
else:
print("FAILED")
raise ExtractionError(
"Could not extract text from EPUB.\n"
"Install ebooklib + beautifulsoup4 for best results:\n"
" pip3 install ebooklib beautifulsoup4"
)
pages = count_epub_chapters(input_str)
pages_label = "spine_items"
elif ext == ".pdf":
print(f"Extracting PDF: {input_str}")
if extraction_mode == "technical":
print("Mode: technical — using Docling (layout-aware)...", end=" ", flush=True)
text = extract_with_docling(input_str)
if text:
method = "docling"
print("OK")
else:
print("not available, falling back to pdftotext")
extraction_mode = "text"
if extraction_mode == "text" or not text:
print("Mode: text — using pdftotext...")
print("Trying pdftotext...", end=" ", flush=True)
text = extract_with_pdftotext(input_str)
if text:
method = "pdftotext"
print("OK")
else:
print("not available")
print("Trying pypdf...", end=" ", flush=True)
text = extract_with_pypdf(input_str)
if text:
method = "pypdf"
print("OK")
else:
print("not available")
print("Trying pdfminer.six...", end=" ", flush=True)
text = extract_with_pdfminer(input_str)
if text:
method = "pdfminer"
print("OK")
else:
print("FAILED")
raise ExtractionError(
"Could not extract text from PDF.\n"
"Install one of: poppler-utils (pdftotext), pypdf, or pdfminer.six\n"
" sudo apt install poppler-utils\n"
" pip3 install pypdf\n"
" pip3 install pdfminer.six"
)
pages = count_pages(input_str)
pages_label = "pages"
elif ext in TEXT_EXTENSIONS:
print(f"Extracting text document: {input_str}")
text = read_text_file(input_str)
if text is None or not text.strip():
raise ExtractionError(f"Could not read text document: {input_path.name}")
method = "plain-text"
pages = 0
pages_label = "sections"
elif ext in HTML_EXTENSIONS:
print(f"Extracting HTML: {input_str}")
text = extract_html_file(input_str)
if text is None or not text.strip():
raise ExtractionError(f"Could not extract text from HTML: {input_path.name}")
method = "html-parser"
pages = 0
pages_label = "sections"
elif ext == ".docx":
print(f"Extracting DOCX: {input_str}")
text, method = extract_docx(input_str)
pages = 0
pages_label = "sections"
elif ext == ".rtf":
print(f"Extracting RTF: {input_str}")
text, method = extract_rtf(input_str)
pages = 0
pages_label = "sections"
elif ext in CALIBRE_EBOOK_EXTENSIONS:
print(f"Extracting ebook with Calibre: {input_str}")
text = extract_with_ebook_convert(input_str)
if text is None or not text.strip():
raise ExtractionError(
f"Could not extract text from {ext}. Install Calibre and ensure ebook-convert is on PATH."
)
method = "ebook-convert"
pages = 0
pages_label = "sections"
tokens = estimate_tokens(text)
structure = detect_structure(text)
file_size_mb = os.path.getsize(input_str) / (1024 * 1024)
return {
"source_file": str(input_path.resolve()),
"filename": input_path.name,
"format": document_format,
"extraction_method": method,
"file_size_mb": round(file_size_mb, 2),
pages_label: pages,
"pages_label": pages_label,
"pages": pages,
"chars": len(text),
"words": len(text.split()),
"estimated_tokens": tokens,
"text": text,
**structure,
}
def print_banner() -> None:
"""Print the attribution banner. Done here (not only in SKILL.md) so it
shows on every run regardless of how the agent invokes extraction."""
banner = Path(__file__).resolve().parent.parent / "scripts" / "banner.txt"
try:
sys.stderr.write(banner.read_text(encoding="utf-8") + "\n")
except Exception:
pass # best-effort: never block extraction on the banner
def main():
print_banner()
if "--check" in sys.argv[1:]:
sys.exit(run_dependency_check())
if len(sys.argv) < 2:
print("Usage: extract.py <path-to-document-folder-or-glob>... [--mode technical|text] [--install-missing ask|yes|no]", file=sys.stderr)
print(" extract.py --check # report which extractors are installed", file=sys.stderr)
print(f"Supported formats: {supported_formats_message()}", file=sys.stderr)
sys.exit(1)
raw_input_paths, extraction_mode, install_mode = parse_arguments(sys.argv)
if not raw_input_paths:
print("ERROR: No input document, folder, or glob pattern specified.", file=sys.stderr)
sys.exit(1)
input_files = resolve_input_files(raw_input_paths)
if not input_files:
print(f"ERROR: No supported files found matching: {', '.join(raw_input_paths)}", file=sys.stderr)
sys.exit(1)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
extracted_sources = []
combined_texts = []
errors = []
for file_path in input_files:
try:
res = extract_single_file(file_path, extraction_mode, install_mode)
except ExtractionError as exc:
print(f"WARNING: Skipping {file_path.name}: {exc}", file=sys.stderr)
errors.append((file_path, str(exc)))
continue
extracted_sources.append(res)
# Format the text with a clear boundary
separator = f"\n\n{'=' * 80}\nSOURCE: {res['filename']} (Path: {res['source_file']})\n{'=' * 80}\n\n"
combined_texts.append(separator + res["text"])
if not extracted_sources:
print(f"\nERROR: All {len(errors)} source(s) failed extraction:", file=sys.stderr)
for path, err in errors:
print(f" - {path.name}: {err}", file=sys.stderr)
sys.exit(1)
# Combine texts
consolidated_text = "".join(combined_texts).strip()
# Write combined text
OUTPUT_TEXT.write_text(consolidated_text, encoding="utf-8")
# Consolidate metadata
total_file_size_mb = sum(src["file_size_mb"] for src in extracted_sources)
total_pages = sum(src["pages"] for src in extracted_sources)
total_chars = len(consolidated_text)
total_words = len(consolidated_text.split())
total_tokens = estimate_tokens(consolidated_text)
# Detect structure on consolidated text
consolidated_structure = detect_structure(consolidated_text)
metadata = {
"source_file": "Consolidated from multiple sources" if len(extracted_sources) > 1 else extracted_sources[0]["source_file"],
"filename": "multi-source" if len(extracted_sources) > 1 else extracted_sources[0]["filename"],
"format": "mixed" if len(extracted_sources) > 1 else extracted_sources[0]["format"],
"extraction_method": "multi-method" if len(extracted_sources) > 1 else extracted_sources[0]["extraction_method"],
"extraction_mode": extraction_mode,
"file_size_mb": round(total_file_size_mb, 2),
"pages": total_pages,
"chars": total_chars,
"words": total_words,
"estimated_tokens": total_tokens,
"estimated_tokens_human": f"~{total_tokens // 1000}K",
"output_text": str(OUTPUT_TEXT),
"total_sources": len(extracted_sources),
"sources": [
{
"source_file": src["source_file"],
"filename": src["filename"],
"format": src["format"],
"extraction_method": src["extraction_method"],
"file_size_mb": src["file_size_mb"],
"pages": src["pages"],
"pages_label": src["pages_label"],
"chars": src["chars"],
"words": src["words"],
"estimated_tokens": src["estimated_tokens"],
"chapters_detected": src["chapters_detected"],
"has_toc": src["has_toc"]
}
for src in extracted_sources
],
**consolidated_structure,
}
OUTPUT_META.write_text(json.dumps(metadata, indent=2, ensure_ascii=False))
page_line = f" Total Pages: {total_pages}"
print("\nExtraction complete:")
print(f" Sources : {len(extracted_sources)} processed")
print(f" Size : {total_file_size_mb:.2f} MB")
print(page_line)
print(f" Words : {total_words:,}")
print(f" Tokens : ~{total_tokens // 1000}K")
print(f" Chapters: {consolidated_structure['chapters_detected']} detected overall")
print(f" ToC : {'yes' if consolidated_structure['has_toc'] else 'not detected'}")
if not consolidated_structure["has_toc"]:
print(
" WARN : No table of contents detected — chapter mapping in Step 3 "
"will rely on heading scan only, which may miss or duplicate sections."
)
print(f"\n Text -> {OUTPUT_TEXT}")
print(f" Meta -> {OUTPUT_META}")
if errors:
print(f"\n WARNING: {len(errors)} source(s) skipped due to errors:")
for path, err in errors:
print(f" - {path.name}: {err}")
Changelog
All notable changes to book-to-skill are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Security
- DOCX XXE / Billion Laughs hardening — the DOCX extractor now scans the
archive and rejects any XML part that declares a DTD or entities before parsing, blocking XML external-entity and entity-expansion attacks (#53, #54).
- Subprocess argument-injection hardening — file paths are absolutised
before being passed to pdftotext / pdfinfo / ebook-convert, so a filename starting with - cannot be interpreted as a command-line option (#53, #54).
- Dependency CVE review on pull requests — a
dependency-reviewCI job
flags any newly introduced dependency carrying a moderate-or-higher CVE (or a denied license) and posts the findings as a PR comment. Dependabot now also covers the pip ecosystem.
Changed
- The `pdf` extra now installs `pypdf` instead of the deprecated `PyPDF2`
(pip install book-to-skill[pdf]). pypdf is the maintained successor; PyPDF2 is end-of-life and no longer receives security fixes (#54).
Fixed
- The dependency-free RTF fallback (used when
striprtfis not installed) now
decodes \uN unicode escapes — smart quotes, dashes, accented letters — instead of dropping them and leaving only the ASCII fallback character.
- The stdlib HTML parser (the fallback for HTML files and EPUB extraction when
BeautifulSoup is not installed) no longer decodes HTML entities twice, so double-encoded entities such as &amp; survive intact.
- The dependency-free DOCX fallback (used when
python-docxis not installed)
now reconstructs tables as tab-joined rows in document order, instead of flattening each cell onto its own line.
[1.2.0] — 2026-06-17
Added
- Installable Python package. The extractor is now a proper
book_to_skill
package with a pyproject.toml (hatchling build backend), a book-to-skill console script, and python -m book_to_skill. Optional extractors are exposed as extras (epub, pdf, docx, rtf, technical, all); the base install stays dependency-free with stdlib fallbacks. requires-python = ">=3.9". scripts/extract.py is kept as a thin shim so the existing skill flow is unchanged (#34, #35, #48).
- Markdown / AsciiDoc heading detection. Structure detection recognizes ATX
headings (#, ==) as chapters when no numeric "Chapter N" headings are present, fixing a zero-chapter result for .md / .adoc sources. Headings inside fenced code blocks are ignored (#44).
- setext / reStructuredText underline headings — a title line over a row of
= or - is now detected, so .rst and setext-style Markdown no longer report zero chapters. Guarded against thematic breaks, table borders, and YAML front matter (#51).
- More chapter languages. Chapter-word detection now covers French, German,
Italian, and Dutch (Chapitre, Kapitel, Capitolo, Hoofdstuk), and heading titles starting with Ü/Û/Ý/Þ (e.g. "Überblick") are accepted (#49).
- Multilingual table-of-contents detection — Chinese, Japanese, French,
German, Italian, and Dutch (#44).
Fixed
- Full-width Arabic digits in CJK chapter headings —
第1章(U+FF10–FF19),
common in Japanese typesetting, is now detected like 第1章 (#46).
- Parser errors are no longer swallowed silently. Unexpected exceptions in
any extractor are logged to stderr (extractor name + exception type) while the fallback chain still returns None and continues, so corrupt files and encoding errors are diagnosable (#47, #50).
- All-punctuation ATX "titles" (e.g. a
===== =====table border) are no
longer miscounted as chapters (#51).
- Package imports on interpreters that evaluate annotations eagerly. Added
from __future__ import annotations to every module using PEP 604 unions (str | None), so the package imports and runs cleanly on Python 3.9 (#34).
Security
- CI security scanning — CodeQL (Python, security-and-quality + weekly
schedule), Bandit (gates on HIGH severity; reports MEDIUM+ informationally), and Zizmor (GitHub Actions workflow audit, informational), plus a Dependabot config for the github-actions ecosystem. Known finding to harden next: Bandit B314 (xml.etree.ElementTree.fromstring in the DOCX parser).
Changed
- CI test matrix now includes Python 3.9 so the import path above is guarded and
cannot silently re-break.
[1.1.0] — 2026-06-12
Added
- GitHub Copilot CLI as a first-class target — the same
SKILL.mdnow
discovers, installs, and runs across GitHub Copilot CLI, Amp, and Claude Code via the open Agent Skills standard. Skill Locations cover 8 discovery paths and the script probe walks all of them (#30).
- `validate_skill.py --lens claude|copilot|amp` — audits a generated SKILL.md
against each host's rules; claude stays the default for CI back-compat (#30).
- Attribution banner —
scripts/banner.txtis printed at the start of each
run (best-effort, never fails the run).
Changed
SKILL.mdfrontmatter trimmed toward the open-standard minimum and the
description now names all three hosts so each agent's auto-loader picks it up (#30).
- README headline + "Agent Skills" badge; install/usage sections cover all three
hosts. docs/ARCHITECTURE.md shows per-host destination paths (#30).
Notes
allowed-toolswas dropped from the frontmatter for host-neutrality; the skill
is conformant on all three hosts (validated with all three lenses). If Claude users hit permission-prompt friction, the Bash grant from #18 will be restored with Claude-native tokens (Copilot ignores the key either way).
[1.0.0] — 2026-06-08
First formally tagged release. The converter is stable, multi-format, and validated on real books.
Added
- Multi-format extraction — PDF, EPUB, DOCX, HTML, Markdown, reStructuredText,
AsciiDoc, RTF, and MOBI/AZW/AZW3 (via Calibre), through a modular extractor package with per-format parsers and graceful stdlib fallbacks.
- `extract.py --check` — preflight that reports which extractors are installed
for every format and the exact command to install whatever is missing (#21).
- Adaptive per-chapter depth — token budget scales with
BOOK_TYPE × DEPTH;
study-depth chapters require a worked example, and the cheatsheet is generated as a decision/reasoning layer (decision rules, trees, trade-offs, thresholds, tells) rather than a keyword list (#20).
- `tools/discovery_tax.py` — measures the "Discovery Loop Tax": tokens a
context-dump vs a discovery loop vs book-to-skill put into context to answer one question, on a real book (#23).
- Update / fold-in workflow — merge new sources into an existing skill, keeping
chapter index, topic index, glossary, patterns, and cheatsheet in sync.
- GitHub Actions CI — lint (ruff), test matrix (py3.10–3.13), dependency-free
smoke test, and SKILL.md Claude-conformance validation (#15, #18).
Changed
- README positioning — copyright & fair-use section, "Beyond books" use cases,
context-dump / RAG / 1M-window FAQ, and a measured Discovery Loop Tax + real per-conversion cost table across four books (#19, #27).
- Default output target is
~/.claude/skills/for Claude Code, with Amp skill
directories also supported (#13, #14).
Fixed
- Chapter detection — scans the full text (was capped at 50k chars) and counts
distinct explicit Chapter N / Capítulo N headings, rejecting numbered list items, inline cross-references, and years; adds Portuguese support (#26).
- Roman-numeral headings —
I: Loomings,II. The Carpet-Bagare now detected
with canonical-numeral validation (#28).
- EPUB extraction — resolve OPF-relative hrefs in the stdlib zipfile fallback (#11, #12).
- Batch resilience — one bad source is skipped with a warning instead of aborting
the whole run; explicit input order is preserved (#7).
Known limitations
- Chapter auto-detection needs explicit
Chapter N/Capítulo Nor Roman-numeral
headings. Books that head chapter bodies with bare titles (e.g. Moby-Dick, where numerals appear only in the table of contents) or use section titles (e.g. Pro Git) do not auto-segment.
- Technical PDFs extracted in text mode may lose heading structure; use technical
mode (Docling) to preserve tables, code, and headings.
[1.2.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.2.0 [1.1.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.1.0 [1.0.0]: https://github.com/virgiliojr94/book-to-skill/releases/tag/v1.0.0
Contributing to book-to-skill
Thanks for helping improve book-to-skill. This project turns books and documents into structured agent skills; contributions that make extraction more robust, generation higher-signal, or the docs clearer are all welcome.
Ground rules
- Measure, don't assert. A change that claims a gain should show it — a test,
a benchmark number from tools/discovery_tax.py, or a before/after. PRs that add weight (e.g. to SKILL.md, which is loaded on every run) without a demonstrated benefit will be asked for evidence first.
- Keep `SKILL.md` lean. It is the always-loaded converter spec. Prefer editing
existing steps over adding new ones; justify net additions.
- Never ship raw book text. Generated skills synthesize; they never reproduce
long passages. Respect source licenses (see the README's Copyright section).
Development
git clone https://github.com/virgiliojr94/book-to-skill.git
cd book-to-skill
python3 -m venv .venv && . .venv/bin/activate
pip install pytest ruff
python3 scripts/extract.py --check # see which optional extractors you haveRun the checks the CI runs before opening a PR:
ruff check .
pytest -q
python3 tools/validate_skill.py SKILL.mdPull requests
- One focused change per PR; small and reviewable.
- Conventional Commits for titles and commits:
feat:,fix:,docs:,
chore:, test:, ci: … (e.g. fix(extractor): scan full text).
- Add or update tests for any behavior change.
- Update
CHANGELOG.mdunder an## [Unreleased]section. - CI must be green (lint, test matrix py3.10–3.13, smoke, SKILL.md validation).
Releases
Maintainers cut releases with semantic versioning: tag vX.Y.Z, move the Unreleased changelog section under the new version with the date, and publish a GitHub Release using those notes.
Reporting bugs / requesting features
Open an issue using the templates in .github/ISSUE_TEMPLATE/. For extraction bugs, please include the format, page count, and whether --check shows the relevant extractor installed.
Architecture
book-to-skill has two halves: a deterministic extractor (Python) and a spec-driven generator (the agent following SKILL.md). The extractor turns any document into clean text + metadata; the agent turns that into a structured skill.
┌─────────────────────────── EXTRACTOR (Python, deterministic) ──┐
documents │ scripts/extract.py → extractor/ │
(pdf/epub/ │ ├─ utils.py CLI parse · multi-source resolve · runner │
docx/...) │ ├─ config.py supported extensions · paths · deps map │
│ │ ├─ dependencies.py optional-dep probing · --check report │
▼ │ └─ parsers/ pdf · epub · docx · html · rtf · calibre · │
───────────│ text (best tool first, stdlib fallback) │
│ output → <tempdir>/book_skill_work/ │
│ full_text.txt (all sources merged, source-marked) │
│ metadata.json (pages, words, tokens, chapters, ToC) │
└────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────── GENERATOR (agent, follows SKILL.md) ┐
│ Step 1.5 ask content type → BOOK_TYPE (technical | text) │
│ Step 2/2.5 extract · cost estimate · confirm │
│ Step 2.6 REPL-style probing for large books (grep/sed, no │
│ full re-reads) │
│ Step 3 analyze structure (title, author, chapters, ToC) │
│ Step 4 purpose → DEPTH (reference | study) │
│ Step 7 per-chapter summaries (budget = BOOK_TYPE × DEPTH) │
│ Step 8 glossary · patterns · cheatsheet (decision layer) │
│ Step 9/9.5 SKILL.md core + indexes │
└────────────────────────────────────────────────────────────────┘
│
▼
<SKILLS_HOME>/<slug>/ ← chosen per host:
~/.copilot/skills/ GitHub Copilot CLI
~/.agents/skills/ Copilot CLI or Amp (cross-agent)
~/.claude/skills/ Claude Code
.github|.claude|.agents/skills/ project-local
SKILL.md core frameworks + chapter & topic index (~4K)
chapters/*.md on-demand, loaded only when asked
glossary.md terms
patterns.md techniques
cheatsheet.md decision rules / trees / trade-offs / tellsDesign principles
1. Extract structure, not summaries — named frameworks, decision rules, anti-patterns; never raw passages. 2. Compile-time over runtime — pay navigation/structuring once; at query time load only the relevant chapter. See PERFORMANCE.md. 3. On-demand chapters — SKILL.md stays small; chapter files cost tokens only when read. 4. Front-loaded `SKILL.md` — most important content first (compaction truncates from the end). 5. Graceful degradation — every format has a stdlib fallback; one bad source is skipped, not fatal.
Key components
| Path | Responsibility |
|---|---|
scripts/extract.py | thin entrypoint wrapper |
scripts/extractor/utils.py | CLI parsing, multi-source resolution, chapter/ToC detection, runner |
scripts/extractor/parsers/ | one module per format |
scripts/extractor/dependencies.py | optional-dependency probing + --check |
tools/discovery_tax.py | measures token cost vs context-dump / discovery loop |
tools/validate_skill.py | checks a generated SKILL.md against host rules (`--lens claude |
SKILL.md | the generator spec (Steps 0–10 + fold-in workflow) |
Extending
- New format → add
parsers/<fmt>.py, register its extension inconfig.py,
wire dependency probing in dependencies.py, branch in utils.extract_single_file.
- New generation behavior → edit the relevant Step in
SKILL.md; keep it lean
and back the change with evidence (see CONTRIBUTING.md).
booktoskill.is-a.dev
book-to-skill
<p style="font-size: 1.25rem; max-width: 42rem;"> Turn any book or document into a structured, on-demand agent skill — named frameworks, decision rules, and anti-patterns. <strong>Structure, not a summary.</strong> </p>
Get started{ .md-button .md-button--primary } Skill reference{ .md-button } GitHub{ .md-button }
---
Why book-to-skill
<div class="grid cards" markdown>
- :material-file-document-multiple:{ .lg .middle } __Multi-format__
---
PDF, EPUB, DOCX, HTML, Markdown, RTF, MOBI/AZW (via Calibre). Extraction runs locally with graceful stdlib fallbacks — no upload, no lock-in.
- :material-brain:{ .lg .middle } __Structure, not summaries__
---
Named frameworks, mental models, decision rules, and anti-patterns — the author's toolkit, captured with their exact terms, not a book report.
- :material-flash:{ .lg .middle } __On-demand chapters__
---
Per-chapter files load only when the topic is relevant, so a 200-page book costs tokens proportional to the question, not the page count.
- :material-robot-happy:{ .lg .middle } __Multi-agent__
---
One SKILL.md runs on Claude Code, GitHub Copilot CLI, and Amp through the open Agent Skills standard.
</div>
Install
pip install book-to-skill # the extractor package
# or run the skill in your agent:
/book-to-skill /path/to/book.pdf [skill-name]Learn more
<div class="grid cards" markdown>
- :material-sitemap:{ .lg .middle } __Architecture__
---
How the deterministic extractor and the spec-driven generator fit together.
- :material-speedometer:{ .lg .middle } __Performance__
---
The measured Discovery Loop Tax and real per-conversion token cost.
- :material-book-open-page-variant:{ .lg .middle } __Skill Reference__
---
The full SKILL.md spec: every step, depth budget, and quality rule.
- :material-heart:{ .lg .middle } __Sponsor__
---
book-to-skill is free and MIT. Sponsoring funds reviews, releases, and fixes.
</div>
Performance & Cost
All numbers below are measured, not estimated, using tiktoken (cl100k_base) for token counts and tools/discovery_tax.py for the discovery model. Reproduce any of them with the commands shown.
Extraction (real conversions)
Measured with pdftotext (PDF) and ebooklib (EPUB):
| Book | Format | Pages | Tokens | Chapters auto-detected |
|---|---|---|---|---|
| Think Python 2 | 244 | 119K | 19 | |
| Working Backwards | 371 | 175K | 10 | |
| Pro Git | 501 | 229K | — † | |
| Moby-Dick | EPUB | — | 301K | 133 |
† Pro Git heads chapters with section titles (no Chapter N), so it does not auto-segment. Moby-Dick's bodies use bare titles, but its Roman-numeral table of contents is detected (133) — see Known limitations in the README.
Extraction method matters for technical books. On a 103-page technical PDF:
| Method | Time | Tables | Code blocks |
|---|---|---|---|
| pdftotext | 0.1s | 0 | 0 |
| Docling (technical mode) | 164s | 48 | 36 |
pdftotext is instant but flattens structure; Docling is ~1.5s/page but preserves tables and code as markdown. Pick text mode for prose, technical mode for code/tables.
The Discovery Loop Tax
Tokens entering context to answer one targeted question. book-to-skill loads a resident core (~4K) plus one compiled chapter (~1K) ≈ 5,000 tokens.
| Book (chapter size) | Context-dump | Discovery loop | book-to-skill | vs dump / loop |
|---|---|---|---|---|
| Think Python 2 (small) | 119,264 | 12,152 | ~5,000 | 24× / 2.4× |
| Working Backwards (medium) | 175,253 | 33,444 | ~5,000 | 35× / 6.7× |
| AI Engineering (large) | 256,287 | 77,866 | ~5,000 | 51× / 15.6× |
python3 tools/discovery_tax.py --full-text /tmp/book_skill_work/full_text.txt --target-chapter 5- The context-dump advantage (24–51×) is the strongest claim: that cost recurs on
every conversation turn.
- The discovery-loop advantage (2.4–15.6×) is a one-time cost and a model using
the book's real ToC/chapter sizes; it scales with chapter size.
Generation cost
One-pass full conversion, estimated from measured tokens (Claude Sonnet 4.5, \$3 / \$15 per MTok input/output):
| Book | Input | Output | ~Cost |
|---|---|---|---|
| Think Python 2 | 155K | 28K | \$0.88 |
| Working Backwards | 228K | 19K | \$0.96 |
| Pro Git | 298K | 23K | \$1.23 |
| Moby-Dick | 391K | 17K | \$1.42 |
Roughly \$1 per book for a full skill — paid once. Re-reading the same PDF into context every session costs far more over time (see the Discovery Loop Tax above).
Generated-skill output quality
A before/after of the adaptive-depth change (v1.0.0, #20) on one chapter:
| Artifact | Old spec | New spec |
|---|---|---|
| Chapter file (tokens) | 473 | 1,219 |
| Worked example present | no | yes |
| Cheatsheet decision rules | 0 | 32 |
| Cheatsheet keyword/definition lines | 9 | 0 |
The new spec turns the cheatsheet from a glossary into a decision layer and gives study-depth chapters a reproduced worked example.
MIT License
Copyright (c) 2025 virgiliojr94
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
site_name: book-to-skill
site_description: Convert books and documents into structured, on-demand agent skills.
site_url: https://booktoskill.is-a.dev/
repo_url: https://github.com/virgiliojr94/book-to-skill
repo_name: virgiliojr94/book-to-skill
edit_uri: ""
# index.md is a committed landing page. Guide (README.md) and Skill Reference
# (SKILL.md) are assembled from the repo root at build time, so prose docs stay
# single-source. See .github/workflows/deploy-docs.yml.
theme:
name: material
logo: assets/logo.png
favicon: assets/logo.png
icon:
repo: fontawesome/brands/github
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: deep purple
accent: deep purple
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: deep purple
accent: deep purple
toggle:
icon: material/weather-sunny
name: Switch to light mode
features:
- navigation.tabs
- navigation.sections
- navigation.instant
- navigation.top
- navigation.footer
- toc.follow
- content.code.copy
- content.code.annotate
- search.suggest
- search.highlight
nav:
- Home: index.md
- Guide: guide.md
- Architecture: ARCHITECTURE.md
- Performance: PERFORMANCE.md
- Skill Reference: skill-reference.md
markdown_extensions:
- admonition
- attr_list
- md_in_html
- tables
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- toc:
permalink: true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "book-to-skill"
version = "1.2.0"
description = "Convert books and documents into structured, on-demand agent skills."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
[project.scripts]
book-to-skill = "book_to_skill.cli:main"
[project.optional-dependencies]
epub = ["ebooklib", "beautifulsoup4"]
pdf = ["pypdf", "pdfminer.six"]
docx = ["python-docx"]
rtf = ["striprtf"]
technical = ["docling"]
all = [
"ebooklib",
"beautifulsoup4",
"pypdf",
"pdfminer.six",
"python-docx",
"striprtf",
"docling"
]
# Tooling config so `ruff check .` and `pytest` locally match CI without flags.
[tool.ruff]
target-version = "py39"
[tool.ruff.lint]
# High-value gate only: syntax errors (E9) + pyflakes (F). Style stays ungated.
select = ["E9", "F"]
[tool.pytest.ini_options]
testpaths = ["tests"]
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣤⣴⣶⣶⣶⣶⣆⢰⣦⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⢀⣠⣶⣿⣿⣿⣿⣿⡿⢿⣿⣿⠀⣿⣿⣿⣿⣶⣄⡀⠀⠀⠀⠀⠀
⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⠃⠀⣿⠿⠛⠀⠻⢿⣿⣿⣿⣿⣿⣦⠀⠀⠀⠀
⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠃⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀
⠀⢠⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀⠀⠀⠀⠀⠀⢛⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀
⠀⣾⣿⣿⣿⣿⣿⠿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢹⣿⣿⣿⣿⣿⣿⣿⣿⣷⠀
⢸⣿⣿⣿⣿⣛⡉⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⡆⢠⡈⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
⢸⣿⣿⣿⣿⣟⣉⣁⠀⠀⠀⠀⠀⠀⠀⠀⣻⡇⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠇⠀⠀⣠⣴⣿⣿⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇
⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⠀⠀⠙⣿⣿⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇
⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⠈⠁⠀⢨⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀
⠀⠈⢿⣿⣿⣿⣿⣿⠿⢿⣿⡇⠀⠀⠀⠀⠀⣤⠀⢸⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀
⠀⠀⠈⢿⣿⣿⣿⠁⣴⣾⡿⠁⠀⠀⠀⠀⠀⠘⡇⢸⣿⣿⣿⣿⣿⣿⡟⠁⠀⠀
⠀⠀⠀⠀⠙⢿⣿⡀⠿⣿⡧⠀⠀⠀⠀⠀⠀⢠⡄⢸⣿⣿⣿⣿⡿⠋⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠙⠻⢶⣤⣤⣾⠀⠀⠀⠀⢠⣼⡇⢸⣿⡿⠟⠋⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠃⠀⠀⠀⠠⠿⠟⠃⠈⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀
██╗ ██╗██╗██████╗ ██████╗ ██╗██╗ ██╗ ██████╗ ██╗██████╗ █████╗ ██╗ ██╗
██║ ██║██║██╔══██╗██╔════╝ ██║██║ ██║██╔═══██╗ ██║██╔══██╗██╔══██╗██║ ██║
██║ ██║██║██████╔╝██║ ███╗██║██║ ██║██║ ██║ ██║██████╔╝╚██████║███████║
╚██╗ ██╔╝██║██╔══██╗██║ ██║██║██║ ██║██║ ██║██ ██║██╔══██╗ ╚═══██║╚════██║
╚████╔╝ ██║██║ ██║╚██████╔╝██║███████╗██║╚██████╔╝╚█████╔╝██║ ██║ █████╔╝ ██║
╚═══╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝╚══════╝╚═╝ ╚═════╝ ╚════╝ ╚═╝ ╚═╝ ╚════╝ ╚═╝
book-to-skill · github.com/virgiliojr94/book-to-skill
#!/usr/bin/env python3
"""
Extract text from a document file for book-to-skill processing.
Backward-compatible entrypoint wrapper.
"""
import os
import sys
# Force UTF-8 stdout/stderr so the attribution banner (braille art) and the
# dependency-check glyphs (✓ / ✗) don't raise UnicodeEncodeError on Windows
# consoles that default to a legacy code page (e.g. GBK / cp936).
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except (AttributeError, ValueError):
pass
# Ensure the project root directory (where the 'book_to_skill' package lives) is in sys.path
# so the modular package can be imported reliably regardless of the working directory.
sys.path.insert(0, str(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from book_to_skill.cli import main
if __name__ == "__main__":
main()
Related skills
How it compares
Use instead of pasting book excerpts into chat - it captures the author’s frameworks as a durable, reusable skill, not one-off context.
FAQ
What file formats can Book-to-Skill convert?
PDF, EPUB, DOCX, HTML, Markdown, plain text, RTF, and MOBI/AZW via Calibre. Point it at a file, a folder, or a glob and it expands to every supported source.
Does it just summarize the book?
No. It extracts structure - named frameworks, actionable principles, step-by-step techniques, and anti-patterns - into a toolkit your agent applies, not a book report.
Which agents can use the skills it generates?
Claude Code, GitHub Copilot CLI, and Amp. The output stays agent-neutral and is written to whichever skills directory your host agent discovers.
Is Book To Skill safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.