SLOPSHOPPER

converter

Converts PDF, Word, Excel and PowerPoint files to Markdown with markitdown before reading them

newguardcommandprocess
v0.2.0MITupdated 2026-10-08zrdqns/claude-code-converter
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · converter
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /converter ⎿ converter: Converter active. This session: 0 converted, 0 reused, 0 failed. /converter off pauses it. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

converter

A mod for Claude Code: it converts PDF, Word, Excel and PowerPoint files to Markdown with markitdown before Claude reads them.

Reading the Markdown instead of the original spends fewer tokens and is easier to understand.

How it works

  • When Claude is about to read a .pdf, .docx, .xlsx, .xls or .pptx, the mod converts it and hands over the Markdown in its place.
  • The conversion is saved next to the original, as report.pdf.md, and is reused as long as the original does not change.
  • If the conversion fails, or finds no text (a scanned PDF), Claude reads the original. It is not retried until the file changes.
  • Claude gets a note that it is reading a conversion, without the original's images or layout.

Commands

CommandWhat it does
/converterThe mod's status and how many documents it converted, reused or could not convert in the session.
/converter offPauses it: documents are read in their original format.
/converter onResumes it.

Requirements

  • Python with the py launcher, the one Python installs on Windows.
  • The markitdown package:
py -m pip install "markitdown[all]"

Installation

At the prompt of a terminal session:

/plugin install converter --marketplace zrdqns/claude-code-converter

Answer y to add the marketplace and choose the scope (the user scope loads it in every session, including the desktop app's).

To try it from a local copy, without installing it:

claude --plugin-dir ./claude-code-converter

Development

claude plugin validate .
claude plugin test .

The module is in hooks/register.ts, its state contract in types/index.d.ts and the tests in tests/.

License

MIT

Source 2 files
hooks/register.ts 126 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4const DOCUMENT = /\.(pdf|docx|xlsx|xls|pptx)$/i
5const CONVERT_MS = 120000
6// Under this many bytes the conversion found no text (a scanned PDF): the
7// original reads better.
8const EMPTY_BYTES = 64
9const MAX_FAILURES = 100
10const SCRIPT = [
11  'import sys',
12  'from markitdown import MarkItDown',
13  'text = MarkItDown().convert(sys.argv[1]).text_content',
14  "open(sys.argv[2], 'w', encoding='utf-8').write(text)",
15].join('\n')
16
17const failures = atom({ plugin: 'converter', key: 'failures' } as const, {})
18const tally = atom({ plugin: 'converter', key: 'tally' } as const, {
19  converted: 0,
20  reused: 0,
21  failed: 0,
22})
23const isPaused = atom({ plugin: 'converter', key: 'isPaused' } as const, false)
24const notice = atom({ plugin: 'converter', key: 'notice' } as const, null)
25
26const baseName = (path: string) =>
27  path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
28
29export const register: Register = on => {
30  on('session.start', async ($, e, next) => {
31    await $.command.register({
32      name: 'converter',
33      description:
34        'Document converter status; "off" pauses it and "on" resumes it',
35    })
36
37    return next(e)
38  })
39
40  on('command.run', { command: 'converter' }, async ($, e) => {
41    const word = e.args.trim().toLowerCase()
42    if (word === 'off' || word === 'on') {
43      await update($, isPaused, () => word === 'off')
44    }
45    const [paused, sum] = await Promise.all([read($, isPaused), read($, tally)])
46
47    return {
48      text:
49        `Converter ${paused ? 'paused' : 'active'}. This session: ` +
50        `${sum.converted} converted, ${sum.reused} reused, ${sum.failed} failed. ` +
51        (paused ? '/converter on resumes it.' : '/converter off pauses it.'),
52    }
53  })
54
55  on('tool.call', { tool: 'Read' }, async ($, e, next) => {
56    if (!DOCUMENT.test(e.file_path)) return next(e)
57    if (await read($, isPaused)) return next(e)
58    const source = await $.fs.stat(e.file_path).catch(() => undefined)
59    if (source === undefined || source.kind !== 'file') return next(e)
60
61    const target = `${e.file_path}.md`
62    const name = baseName(e.file_path)
63    let made = await $.fs.stat(target).catch(() => undefined)
64    const isFresh =
65      made !== undefined && made.kind === 'file' && made.mtimeMs >= source.mtimeMs
66
67    if (!isFresh) {
68      if ((await read($, failures))[e.file_path] === source.mtimeMs) {
69        return next(e)
70      }
71      const ran = await $.process
72        .run(['py', '-c', SCRIPT, e.file_path, target], {
73          timeoutMs: CONVERT_MS,
74        })
75        .catch(() => undefined)
76      made =
77        ran !== undefined && ran.exitCode === 0
78          ? await $.fs.stat(target).catch(() => undefined)
79          : undefined
80
81      if (made === undefined || made.kind !== 'file') {
82        await update($, failures, all => ({
83          ...Object.fromEntries(Object.entries(all).slice(1 - MAX_FAILURES)),
84          [e.file_path]: source.mtimeMs,
85        }))
86        await update($, tally, sum => ({ ...sum, failed: sum.failed + 1 }))
87        const at = await $.clock.now()
88        await update($, notice, () => ({
89          text: `Could not convert ${name}; the original was read`,
90          at,
91        }))
92
93        return next(e)
94      }
95    }
96    if (made === undefined || made.size < EMPTY_BYTES) return next(e)
97
98    await update($, tally, sum =>
99      isFresh
100        ? { ...sum, reused: sum.reused + 1 }
101        : { ...sum, converted: sum.converted + 1 },
102    )
103    if (!isFresh) {
104      const at = await $.clock.now()
105      await update($, notice, () => ({
106        text: `${name} converted to Markdown`,
107        at,
108      }))
109    }
110
111    // A page range means nothing in the Markdown; offset and limit still do.
112    const { pages: _pages, ...rest } = e
113    const ran = await next({ ...rest, file_path: target })
114    if (ran.deny !== undefined) return ran
115
116    return {
117      ...ran,
118      context: [
119        ...(ran.context ?? []),
120        `This result is ${target}: the Markdown conversion of ${e.file_path} made with markitdown. ` +
121          "The original's images and layout are missing; if they are needed, /converter off lets the original be read.",
122      ],
123    }
124  })
125}
126
types/index.d.ts 17 lines
1export type Tally = { converted: number; reused: number; failed: number }
2
3/** The last thing worth telling the person, for a pane to show. */
4export type Notice = { text: string; at: number }
5
6declare module 'claude-code' {
7  interface PluginState {
8    converter: {
9      /** Sources markitdown could not convert, by path, with their mtime then. */
10      failures: Record<string, number>
11      tally: Tally
12      isPaused: boolean
13      notice: Notice | null
14    }
15  }
16}
17