Turns a book or a long document into a skill: a compiled binary runs the conversion as one verb per stage, from validating the sources through extraction…

Turns a book or a long document into one self-contained skill, for a person who wants the agent to work by the book's methods.
A book holds rules, ideas and abstractions the agent could work by, but in a format it cannot carry into a session. This plugin turns a book or a long document into one self-contained skill, written as instructions the agent can act on, after which the skill has nothing to do with the book. It converts a source only when it changes how the agent works, and the skill names the book only as its source, never its chapters or pages. It refuses to be a document reader, a search index, or a library of book reports. When every method we care about already lives in a skill, it has done its work and can go.
| Kind | Name | What it does |
|---|---|---|
| Skill | book-to-skill | Drives the stages. The agent reads and writes each chapter, the CLI does the mechanical work. |
| CLI | qsm-book-to-skill | 7 verbs: validate, extract, analyze, draft, assemble, scan, doctor. See USAGE.md. |
claude plugin install qsm-book-to-skill@qsm-marketplace
bun plugins/qsm-book-to-skill/scripts/install.ts
Install compiles qsm-book-to-skill into $QSM_BIN_DIR (default ~/.local/bin). See INSTALL.md.
Ask the agent: "turn this PDF into a skill" or "extract the frameworks from this EPUB".
qsm-book-to-skill validate ~/books/book.pdf --work /tmp/run
qsm-book-to-skill extract --work /tmp/run
qsm-book-to-skill analyze --work /tmp/run
qsm-book-to-skill draft --work /tmp/run
# fill every scaffold in /tmp/run/draft
qsm-book-to-skill assemble --work /tmp/run --out ~/skills --description "Use when ..."
qsm-book-to-skill scan --work /tmp/run
Each stage reads the previous one's output from the run directory, so any stage re-runs alone.
.pdf needs pdftotext (poppler). .epub, .docx and .rtf need pandoc. .mobi, .azw and .azw3 need ebook-convert (calibre). qsm-book-to-skill doctor shows which are present.assemble merges every chapter summary into one SKILL.md and overwrites it: edit the draft.scan fails on a page or chapter reference.hooks/register.tsx 10 lines1// qsm-book-to-skill's function-hooks module.
2import type { On } from "claude-code";
3import { registerNoun } from "./noun.generated.ts";
4
5export function register(on: On): void {
6 // Adds $.bookToSkill: one method per qsm-book-to-skill verb, generated by lib/generate-noun.ts.
7 // Never hand-edit noun.generated.ts or types/index.d.ts.
8 registerNoun(on);
9}
10hooks/noun.generated.ts 131 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3import type { Caught, EngineInterface, On } from "claude-code";
4import type { BookToSkill } from "../types/index.d.ts";
5
6/** How one option becomes argv: a positional value (`flag` null), a bare
7 * boolean flag, or a flag and its value; a list repeats it per element. */
8export interface VerbArg {
9 readonly name: string;
10 readonly flag: string | null;
11 readonly boolean?: true;
12}
13
14export interface VerbRow {
15 readonly words: readonly string[];
16 readonly args: readonly VerbArg[];
17 readonly passthrough?: true;
18}
19
20export const verbs: Readonly<Record<string, VerbRow>> = {
21 validate: { words: ["qsm-book-to-skill","validate"], args: [{ name: "input", flag: null }, { name: "work", flag: "--work" }] },
22 extract: { words: ["qsm-book-to-skill","extract"], args: [{ name: "sources", flag: "--sources" }, { name: "work", flag: "--work" }] },
23 analyze: { words: ["qsm-book-to-skill","analyze"], args: [{ name: "text", flag: "--text" }, { name: "work", flag: "--work" }] },
24 draft: { words: ["qsm-book-to-skill","draft"], args: [{ name: "text", flag: "--text" }, { name: "structure", flag: "--structure" }, { name: "out", flag: "--out" }, { name: "work", flag: "--work" }] },
25 assemble: { words: ["qsm-book-to-skill","assemble"], args: [{ name: "draft", flag: "--draft" }, { name: "out", flag: "--out" }, { name: "name", flag: "--name" }, { name: "description", flag: "--description" }, { name: "work", flag: "--work" }] },
26 scan: { words: ["qsm-book-to-skill","scan"], args: [{ name: "skill", flag: "--skill" }, { name: "work", flag: "--work" }] },
27 doctor: { words: ["qsm-book-to-skill","doctor"], args: [] },
28};
29
30/** One verb's argv: its words, each option in declaration order, the
31 * passthrough rest after `--`, and `--json` last. */
32export function argvOf(row: VerbRow, options: Readonly<Record<string, unknown>> = {}): string[] {
33 const argv = [...row.words];
34 for (const arg of row.args) {
35 const value = options[arg.name];
36 const values = Array.isArray(value) ? value : [value];
37 for (const one of values) {
38 if (one === undefined || one === false) {
39 continue;
40 }
41 argv.push(...(arg.flag === null ? [] : [arg.flag]), ...(arg.boolean ? [] : [String(one)]));
42 }
43 }
44 const rest = options.rest;
45 if (row.passthrough && Array.isArray(rest)) {
46 argv.push("--", ...rest.map(String));
47 }
48 argv.push("--json");
49 return argv;
50}
51
52/** The binary prints one outcome envelope on stdout under --json:
53 * { code, exit, reason, message, warnings, data }. Data on ok; otherwise the
54 * code and message become the error. A stale or misbehaving binary that
55 * prints something else (not JSON, or JSON with no numeric `exit`) throws
56 * loud here instead of a raw JSON.parse SyntaxError or a silent `undefined`. */
57export function unwrap(
58 argv: readonly string[],
59 result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string },
60 label = argv.slice(0, 2).join(" "),
61): unknown {
62 const text = result.stdout.trim();
63 const stderr = result.stderr.trim().slice(0, 200);
64 const cause = `(exit ${result.exitCode}): ${stderr.length > 0 ? stderr : "no stderr"}`;
65 if (text.length === 0) {
66 throw new Error(`${argv.join(" ")}: printed no envelope ${cause}`);
67 }
68 let parsed: unknown;
69 try {
70 parsed = JSON.parse(text);
71 } catch {
72 parsed = undefined;
73 }
74 if (typeof parsed !== "object" || parsed === null || typeof (parsed as { exit?: unknown }).exit !== "number") {
75 throw new Error(`${argv.join(" ")}: stdout is not an outcome envelope ${cause}: ${text.slice(0, 200)}`);
76 }
77 const envelope = parsed as { code: string; exit: number; message: string; data: unknown };
78 if (envelope.exit !== 0) {
79 throw new Error(`${label}: ${envelope.code}: ${envelope.message}`);
80 }
81 return envelope.data;
82}
83
84/** What every `.catch` does first: a toast and a log for the person. The toast says `fixedReason`
85 * when the cause may carry something the toast must not show, else the cause; the cause goes to
86 * the log and comes back for the caller's own policy. */
87export function failure(
88 $: EngineInterface,
89 event: string,
90 error: Caught["error"],
91 fixedReason?: string,
92): string {
93 const said = error.message ?? error.kind;
94 $.ui.toast(`qsm-book-to-skill ERROR: ${event}: ${(fixedReason ?? said).slice(0, 120)}`);
95 $.ui.log(`${event} failed (${error.kind}): ${said}`);
96 return said;
97}
98
99/** Adds $.bookToSkill to the engine. Call once from the plugin's own
100 * `register(on, options)` — never call the engine.create hook's own
101 * `next(e)` result from anywhere but here. */
102export function registerNoun(on: On): void {
103 on("engine.create", async (_$, e, next) => {
104 const beneath = await next(e);
105 const run = async (row: VerbRow, options?: Readonly<Record<string, unknown>>): Promise<unknown> => {
106 const argv = argvOf(row, options);
107 let result: Awaited<ReturnType<typeof beneath.process.run>>;
108 try {
109 result = await beneath.process.run(argv);
110 } catch (error) {
111 // A spawn in a deleted directory fails as ENOENT naming the binary; name the directory. A
112 // check that cannot run leaves the spawn's own error as the cause.
113 const gone = await beneath.session
114 .cwd()
115 .then(async (cwd) => ((await beneath.fs.exists(cwd)) ? null : cwd))
116 .catch(() => null);
117 if (gone !== null) {
118 throw new Error(`${row.words.join(" ")}: the session's working directory ${gone} no longer exists; cd to one that does`);
119 }
120 throw error;
121 }
122 return unwrap(argv, result, row.words.join(" "));
123 };
124 const methods = Object.entries(verbs).map(([method, row]) => [
125 method,
126 (options?: Readonly<Record<string, unknown>>) => run(row, options),
127 ]);
128 return { ...beneath, bookToSkill: Object.fromEntries(methods) as BookToSkill };
129 });
130}
131types/index.d.ts 42 lines1// Generated by lib/generate-noun.ts from qsm.json. Do not hand-edit; run
2// `bun lib/generate-noun.ts plugins/<name>` again after changing qsm.json.
3
4export type BookToSkill = {
5 validate(options: {
6 input: (string)[];
7 work?: string;
8 }): Promise<unknown>;
9 extract(options: {
10 sources?: string;
11 work?: string;
12 }): Promise<unknown>;
13 analyze(options: {
14 text?: string;
15 work?: string;
16 }): Promise<unknown>;
17 draft(options: {
18 text?: string;
19 structure?: string;
20 out?: string;
21 work?: string;
22 }): Promise<unknown>;
23 assemble(options: {
24 draft?: string;
25 out?: string;
26 name?: string;
27 description: string;
28 work?: string;
29 }): Promise<unknown>;
30 scan(options: {
31 skill?: string;
32 work?: string;
33 }): Promise<unknown>;
34 doctor(): Promise<unknown>;
35};
36
37declare module 'claude-code' {
38 interface EngineInterface {
39 bookToSkill: BookToSkill;
40 }
41}
42