SLOPSHOPPER

qsm-book-to-skill

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…

newprocess
A shopper browsing a rack in a slop shop
README

qsm-book-to-skill

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.

Why it exists

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.

What you get

KindNameWhat it does
Skillbook-to-skillDrives the stages. The agent reads and writes each chapter, the CLI does the mechanical work.
CLIqsm-book-to-skill7 verbs: validate, extract, analyze, draft, assemble, scan, doctor. See USAGE.md.

Install

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.

Use

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.

Caveats

  • Markdown, AsciiDoc, reStructuredText, HTML and plain text are built in. .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.
  • A PDF whose headings extract as body text comes back as one chapter.
  • assemble merges every chapter summary into one SKILL.md and overwrites it: edit the draft.
  • The skill states rules, not where the book said them. scan fails on a page or chapter reference.
Source 3 files
hooks/register.tsx 10 lines
1// 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}
10
hooks/noun.generated.ts 131 lines
1// 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}
131
types/index.d.ts 42 lines
1// 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