@qvac/skills 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE.md +198 -0
  2. package/README.md +49 -0
  3. package/index.d.ts +4 -0
  4. package/index.js +9 -0
  5. package/package.json +87 -1
  6. package/skills/apple-notes/SKILL.md +92 -0
  7. package/skills/apple-notes/append-note.applescript +9 -0
  8. package/skills/apple-notes/cli.schema.json +32 -0
  9. package/skills/apple-notes/create-note.applescript +15 -0
  10. package/skills/apple-notes/delete-note.applescript +10 -0
  11. package/skills/apple-notes/edit-note.applescript +10 -0
  12. package/skills/apple-notes/read-note.applescript +28 -0
  13. package/skills/apple-notes/references/read.md +65 -0
  14. package/skills/apple-notes/references/write.md +105 -0
  15. package/skills/apple-notes/search-notes.applescript +21 -0
  16. package/skills/apple-reminders/SKILL.md +129 -0
  17. package/skills/apple-reminders/cli.schema.json +201 -0
  18. package/skills/apple-reminders/references/edit.md +69 -0
  19. package/skills/apple-reminders/references/view.md +58 -0
  20. package/skills/asana/SKILL.md +59 -0
  21. package/skills/diagrams/SKILL.md +107 -0
  22. package/skills/diagrams/references/class.md +29 -0
  23. package/skills/diagrams/references/er.md +27 -0
  24. package/skills/diagrams/references/flowchart.md +33 -0
  25. package/skills/diagrams/references/gantt.md +38 -0
  26. package/skills/diagrams/references/mindmap.md +35 -0
  27. package/skills/diagrams/references/pie.md +27 -0
  28. package/skills/diagrams/references/sequence.md +32 -0
  29. package/skills/diagrams/references/state.md +30 -0
  30. package/skills/diagrams/references/timeline.md +28 -0
  31. package/skills/excel/SKILL.md +120 -0
  32. package/skills/excel/references/create.md +374 -0
  33. package/skills/excel/references/edit.md +353 -0
  34. package/skills/excel/references/read.md +99 -0
  35. package/skills/github/SKILL.md +42 -0
  36. package/skills/gmail/SKILL.md +142 -0
  37. package/skills/gmail/operations.json +71 -0
  38. package/skills/google-calendar/SKILL.md +139 -0
  39. package/skills/google-calendar/operations.json +62 -0
  40. package/skills/google-docs/SKILL.md +74 -0
  41. package/skills/google-docs/operations.json +61 -0
  42. package/skills/google-docs/references/create.md +97 -0
  43. package/skills/google-docs/references/edit.md +146 -0
  44. package/skills/google-docs/references/read.md +49 -0
  45. package/skills/google-drive/SKILL.md +118 -0
  46. package/skills/google-drive/operations.json +40 -0
  47. package/skills/google-sheets/SKILL.md +71 -0
  48. package/skills/google-sheets/operations.json +85 -0
  49. package/skills/google-sheets/references/create.md +54 -0
  50. package/skills/google-sheets/references/edit.md +124 -0
  51. package/skills/google-sheets/references/read.md +74 -0
  52. package/skills/image-generation/SKILL.md +48 -0
  53. package/skills/music-generation/SKILL.md +76 -0
  54. package/skills/notion/SKILL.md +61 -0
  55. package/skills/notion/operations.json +53 -0
  56. package/skills/notion/references/comments.md +65 -0
  57. package/skills/notion/references/databases.md +68 -0
  58. package/skills/notion/references/pages.md +119 -0
  59. package/skills/notion/references/tasks.md +28 -0
  60. package/skills/obsidian/SKILL.md +122 -0
  61. package/skills/obsidian/cli.schema.json +392 -0
  62. package/skills/obsidian/references/read.md +79 -0
  63. package/skills/obsidian/references/write.md +67 -0
  64. package/skills/pdf/SKILL.md +110 -0
  65. package/skills/pdf/references/create.md +169 -0
  66. package/skills/pdf/references/transform.md +270 -0
  67. package/skills/pdf/scripts/decrypt.py +26 -0
  68. package/skills/pdf/scripts/encrypt.py +25 -0
  69. package/skills/pdf/scripts/extract_text.py +25 -0
  70. package/skills/pdf/scripts/merge.py +21 -0
  71. package/skills/pdf/scripts/rotate.py +27 -0
  72. package/skills/presentations/SKILL.md +118 -0
  73. package/skills/presentations/references/create.md +399 -0
  74. package/skills/presentations/references/edit.md +314 -0
  75. package/skills/presentations/references/read.md +127 -0
  76. package/skills/spotify/SKILL.md +86 -0
  77. package/skills/weather/SKILL.md +33 -0
  78. package/skills/word/SKILL.md +141 -0
  79. package/skills/word/references/create.md +368 -0
  80. package/skills/word/references/edit.md +704 -0
  81. package/skills/word/references/read.md +141 -0
  82. package/skills/word/references/replace.md +86 -0
  83. package/skills/word/scripts/list_paragraphs.py +19 -0
  84. package/skills/word/scripts/replace_paragraphs.py +58 -0
@@ -0,0 +1,169 @@
1
+ # Creating a PDF (fpdf2)
2
+
3
+ Create a new `.pdf` from scratch by running Python through the `exec` tool.
4
+ A new PDF needs **no** `inputs` — do not invent attachment ids. **Exactly one**
5
+ `exec` call per user request when that call succeeds.
6
+
7
+ This file is also the recipe for **changing the content of an existing PDF**
8
+ (add a paragraph, reword, restyle): pypdf cannot edit page content, so the job
9
+ is writing the whole document again with fpdf2 — every original section,
10
+ reproduced unchanged, plus the requested change. A rebuild that condenses,
11
+ summarizes, or drops original sections is a failed turn; the user must get
12
+ their document back with only the asked-for difference. Save the rebuild under
13
+ a **new** output name (`updated.pdf`, never the original file's name), and
14
+ stop after the one successful build.
15
+
16
+ ## The exec call
17
+
18
+ ```json
19
+ {
20
+ "language": "python",
21
+ "packages": ["fpdf2==2.8.8"],
22
+ "outputs": ["report.pdf"],
23
+ "command": "..."
24
+ }
25
+ ```
26
+
27
+ - `packages` — pin exactly `fpdf2==2.8.8` (imported as `fpdf`). This version
28
+ ships with the app and installs with no network; any other version has to be
29
+ downloaded, which fails on a device that is offline. Never install `fpdf`
30
+ (no `2`) — that is an abandoned, incompatible library.
31
+ - `outputs` — the file to deliver. `pdf.output("report.pdf")` must match the
32
+ declared output name exactly.
33
+ - `command` — the multi-line Python source, with real newline characters.
34
+ Never collapse it to one line joined by `;` — a `for`/`if`/`with` after a
35
+ semicolon is a `SyntaxError`.
36
+
37
+ ## The Recipe
38
+
39
+ Start from this. It is a complete, working document — title, body paragraphs,
40
+ a bulleted section, a table, automatic page breaks, saved under the declared
41
+ output name. Copy it and change the content; do not assemble a PDF from memory.
42
+
43
+ ```python
44
+ from fpdf import FPDF
45
+
46
+ pdf = FPDF(format="A4")
47
+ pdf.set_auto_page_break(auto=True, margin=15)
48
+ pdf.add_page()
49
+
50
+ pdf.set_font("helvetica", style="B", size=24)
51
+ pdf.multi_cell(0, 12, "Quarterly Report", new_x="LMARGIN", new_y="NEXT")
52
+
53
+ pdf.set_font("helvetica", size=12)
54
+ pdf.ln(4)
55
+ pdf.multi_cell(0, 6, "Revenue grew 20% quarter over quarter, driven by APAC. "
56
+ "EMEA held flat while new logos offset churn.",
57
+ new_x="LMARGIN", new_y="NEXT")
58
+ pdf.ln(2)
59
+
60
+ pdf.set_font("helvetica", style="B", size=14)
61
+ pdf.multi_cell(0, 10, "Highlights", new_x="LMARGIN", new_y="NEXT")
62
+ pdf.set_font("helvetica", size=12)
63
+ for point in [
64
+ "APAC bookings grew 34% and drove most of the quarter",
65
+ "EMEA held flat; new logos offset churn",
66
+ "Gross margin improved 2 points on infra savings",
67
+ ]:
68
+ pdf.multi_cell(0, 6, "- " + point, new_x="LMARGIN", new_y="NEXT")
69
+ pdf.ln(2)
70
+
71
+ with pdf.table() as table:
72
+ for row_data in [("Region", "Revenue"), ("APAC", "$1.2M"), ("EMEA", "$0.9M")]:
73
+ row = table.row()
74
+ for cell_text in row_data:
75
+ row.cell(cell_text)
76
+
77
+ pdf.output("report.pdf") # must match the declared output exactly
78
+ print(f"{pdf.pages_count} pages")
79
+ ```
80
+
81
+ ## The Rules That Keep It Working
82
+
83
+ - **Sizes are positional — there is no `width=` or `height=` keyword.** The
84
+ first two arguments of `cell` and `multi_cell` are `w` and `h`; passing
85
+ `width=` raises `TypeError`. The fix is renaming the arguments as in the
86
+ sample — never deleting the `new_x`/`new_y` keywords, which "fixes" the
87
+ error and prints every later line on top of the previous one.
88
+ - **Every line of text goes through
89
+ `pdf.multi_cell(0, h, text, new_x="LMARGIN", new_y="NEXT")` — headings,
90
+ paragraphs, and bullets alike. Do not use `pdf.cell` at all.** `cell` does
91
+ not wrap, so a long heading is silently clipped at the right margin, and
92
+ without `new_x`/`new_y` it leaves the cursor at the END of the line so the
93
+ next write starts at the right margin — raising
94
+ `FPDFException: Not enough horizontal space` or printing on top of earlier
95
+ text. `multi_cell` wraps everything. Keep `new_x="LMARGIN", new_y="NEXT"`
96
+ on every call, exactly as in the sample. A bullet is one
97
+ `multi_cell(0, 6, "- " + point, …)` per point — never several bullets
98
+ packed into one string. `pdf.text(x, y, s)` is not a third option: it
99
+ paints at a fixed point with no wrapping and exists only for the watermark
100
+ stamp in `references/transform.md`.
101
+ - **Write the whole document, not a cover page.** "A small PDF about X"
102
+ still means real content: a title, then several short sections, each a
103
+ heading plus a paragraph or bullets, as in the sample. A PDF containing
104
+ only a title and a subtitle is a failed turn — the explanation the user
105
+ asked for belongs inside the PDF, not in your chat reply.
106
+ - **`set_font` before every block, not once.** A heading's bold 14-24 pt
107
+ style stays active until changed — reset to `pdf.set_font("helvetica",
108
+ size=12)` after each heading or the whole body renders huge and bold.
109
+ Core fonts: `helvetica`, `times`, `courier`; body text 10-12 pt.
110
+ - **ASCII punctuation only.** The core fonts cover latin-1 and nothing else,
111
+ and one character outside it fails the whole cell with
112
+ `FPDFUnicodeEncodingException`. Curly quotes, em dashes, arrows, the •
113
+ bullet, CJK, and emoji are all outside. Write straight quotes `"` `'` and
114
+ hyphens `-` in every string — a bullet is `"- "`, never the `•` character.
115
+ Accented latin (`café`, `naïve`) is fine. When user content may carry smart
116
+ punctuation, normalize it first:
117
+
118
+ ```python
119
+ def latin1(text):
120
+ for bad, good in [("‘", "'"), ("’", "'"), ("“", '"'),
121
+ ("”", '"'), ("–", "-"), ("—", "-"),
122
+ ("…", "..."), ("→", "->"), ("•", "-")]:
123
+ text = text.replace(bad, good)
124
+ return text
125
+ ```
126
+
127
+ Text that genuinely needs CJK or emoji cannot be rendered — there are no
128
+ font files in this runtime and `add_font` has nothing to load, so never call
129
+ it. Say so and offer a latin transliteration instead of shipping `?`.
130
+ - **Units are millimetres**, page format defaults to A4. `FPDF(format="letter")`
131
+ for US letter. An A4 page is 210 x 297 mm with 10 mm margins; a width of
132
+ `0` extends to the right margin.
133
+ - **No markdown syntax in strings.** fpdf2 prints text literally — `##`,
134
+ `**bold**`, and `*italics*` come out as those exact characters. Headings
135
+ and emphasis are made with `set_font(..., style="B", size=…)`, as in the
136
+ sample.
137
+ - **`pdf.output("name.pdf")` writes the deliverable.** Calling `output()`
138
+ with no argument returns the bytes instead — useful only for in-memory
139
+ intermediates; the file the user receives must be written under its
140
+ declared `outputs` name.
141
+ - Never print the PDF's bytes or base64 — stdout is capped and the file
142
+ travels through `outputs`. Print only the page-count line.
143
+
144
+ ## Errors
145
+
146
+ - `ModuleNotFoundError: No module named 'fpdf'` — `packages` was missing or
147
+ wrong; the pin is `fpdf2==2.8.8` (imported as `fpdf`). Never try to install
148
+ inside the script.
149
+ - `TypeError: FPDF.cell() (or multi_cell) got an unexpected keyword argument
150
+ 'width'` (or `'height'`) — the size parameters are the positional `w` and
151
+ `h`. Rewrite the call as in the sample —
152
+ `multi_cell(0, 6, text, new_x="LMARGIN", new_y="NEXT")`. Renaming is the
153
+ whole fix; deleting the keywords instead produces overlapping text.
154
+ - `FPDFException: Not enough horizontal space to render a single character` —
155
+ an earlier `cell`/`multi_cell` left the cursor at the right margin. Add
156
+ `new_x="LMARGIN", new_y="NEXT"` to every `cell` and `multi_cell` call.
157
+ - `FPDFUnicodeEncodingException: Character "…" is outside the range …` — a
158
+ non-latin-1 character reached a core font. Normalize the string (see the
159
+ `latin1` helper) and rerun; for CJK or emoji, tell the user it cannot be
160
+ rendered.
161
+ - On an `AttributeError` or `TypeError` from fpdf2 the API name or arguments
162
+ are wrong — fix against this file's recipe. Do not retry the same call and
163
+ do not switch to a shell.
164
+
165
+ ## Finish
166
+
167
+ When `exitCode` is `0` and `attachments` lists the `.pdf`, stop tool use and
168
+ answer with one line: file name + the page count from stdout. Exactly one
169
+ successful `exec` per request.
@@ -0,0 +1,270 @@
1
+ # Transforming an Existing PDF (pypdf)
2
+
3
+ Transform a PDF that is already in this chat by running Python through the
4
+ `exec` tool: merge, split, rotate, watermark, encrypt, decrypt, fill form
5
+ fields, extract text.
6
+
7
+ **pypdf transforms pages; it cannot restyle or edit their content.** Merging,
8
+ splitting, rotating, stamping, and encrypting work on a staged PDF. Adding a
9
+ paragraph, rewording, or changing fonts, sizes, or colors does not — never
10
+ poke at page objects to try (`KeyError`/`IndexError` is that mistake). For a
11
+ content change, load `references/create.md` NOW and rebuild the whole document
12
+ with fpdf2: every original section, unchanged, plus the requested change (no
13
+ `inputs` needed when the original text is already in this chat; otherwise run
14
+ the extract-text script below first). A rebuilt PDF that condenses or drops
15
+ original sections is a failed turn.
16
+
17
+ ## Staging the PDF
18
+
19
+ Any PDF already in this chat can be staged, whoever put it there. Stage it as
20
+ an input by its `attachmentId` and open it with `PdfReader("existing.pdf")` or
21
+ `PdfWriter(clone_from="existing.pdf")`; then transform and save a **new**
22
+ output such as `merged.pdf`. Never overwrite the staged input. The working
23
+ directory starts empty on every call, so a file you do not stage does not
24
+ exist — opening last turn's PDF by name alone raises `FileNotFoundError`.
25
+
26
+ The id comes from wherever the PDF entered the chat:
27
+
28
+ - **Produced earlier in this chat** (a prior `exec` output, a `create_pdf`
29
+ result) — the `attachmentId` is in that tool result.
30
+ - **Uploaded by the user** — the `[Attached file …]` line on their message
31
+ names it, and every non-image upload gets one:
32
+
33
+ ```
34
+ [Attached file "contract.pdf" (application/pdf) — attachmentId: 4f9c2ab1]
35
+ ```
36
+
37
+ Copy the id verbatim — never placeholders like `att_pdf` or any id you made
38
+ up. A `.pdf` is never staged id-less: the id-less form resolves to an uploaded
39
+ *image*, so it cannot reach a PDF. Id-less entries (`{ "path": "photo.png" }`,
40
+ no `attachmentId` key) are only for images the user uploaded — the first
41
+ id-less entry is the first image of the user's latest message, and so on.
42
+
43
+ If no `attachmentId` for the PDF is available anywhere in the chat, ask the
44
+ user to attach it again. Do not invent ids and do not retry.
45
+
46
+ ## The exec call
47
+
48
+ ```json
49
+ {
50
+ "language": "python",
51
+ "packages": ["pypdf==6.15.0"],
52
+ "inputs": [
53
+ { "attachmentId": "<id from the tool result that built a.pdf>", "path": "a.pdf" },
54
+ { "attachmentId": "<id from the tool result that built b.pdf>", "path": "b.pdf" }
55
+ ],
56
+ "outputs": ["merged.pdf"],
57
+ "command": "..."
58
+ }
59
+ ```
60
+
61
+ - `packages` — pin exactly `pypdf==6.15.0`; add `fpdf2==2.8.8` when one run
62
+ also builds pages (watermark). Add `"cryptography"` (deliberately unpinned —
63
+ the runtime supplies its own build) only for AES encryption or decrypting
64
+ AES-encrypted files. These exact versions ship with the app and install with
65
+ no network; any other version has to be downloaded, which fails on a device
66
+ that is offline.
67
+ - `inputs` — the staged attachments. Paths must be unique bare filenames;
68
+ staged files land in the working directory under those names — reference
69
+ `PdfReader("a.pdf")` by that name only.
70
+ - `outputs` — the files to deliver. A file you write but do not declare here
71
+ is discarded, and an undeclared `.pdf` earns a warning — build intermediate
72
+ pages in memory (`io.BytesIO`, as in the watermark recipe) instead of
73
+ writing extra files.
74
+ - `command` — the multi-line Python source, with real newline characters.
75
+ Never collapse it to one line joined by `;`.
76
+
77
+ `attachment … not found in this chat` means you invented an id or the file is
78
+ not attached. Re-copy the exact id from the tool result or the
79
+ `[Attached file …]` line that names the PDF; if neither exists, ask the user to
80
+ attach it again instead of retrying.
81
+
82
+ ## Scripted Transforms — Run These, Do Not Write Them
83
+
84
+ Merge, rotate, extract text, encrypt, and decrypt are bundled scripts: run them
85
+ by naming the script in the `exec` call — no Python source at all. Stage the
86
+ attachment(s) as `inputs`, declare the produced file in `outputs`, and pass the
87
+ file names again as `scriptArgs` (scripts see the working directory, so the
88
+ same bare names). Always write a new output name — never the staged input's.
89
+
90
+ **Merge** several PDFs into one — args: output first, then the inputs in order:
91
+
92
+ ```json
93
+ {
94
+ "language": "python",
95
+ "packages": ["pypdf==6.15.0"],
96
+ "inputs": [
97
+ { "attachmentId": "<real id>", "path": "a.pdf" },
98
+ { "attachmentId": "<real id>", "path": "b.pdf" }
99
+ ],
100
+ "outputs": ["merged.pdf"],
101
+ "skill": "pdf",
102
+ "script": "scripts/merge.py",
103
+ "scriptArgs": ["merged.pdf", "a.pdf", "b.pdf"]
104
+ }
105
+ ```
106
+
107
+ **Rotate** every page — args: input, output, degrees (a multiple of 90):
108
+
109
+ ```json
110
+ {
111
+ "language": "python",
112
+ "packages": ["pypdf==6.15.0"],
113
+ "inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
114
+ "outputs": ["rotated.pdf"],
115
+ "skill": "pdf",
116
+ "script": "scripts/rotate.py",
117
+ "scriptArgs": ["existing.pdf", "rotated.pdf", "90"]
118
+ }
119
+ ```
120
+
121
+ **Extract text** into a delivered `.txt` — args: input, output:
122
+
123
+ ```json
124
+ {
125
+ "language": "python",
126
+ "packages": ["pypdf==6.15.0"],
127
+ "inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
128
+ "outputs": ["extracted.txt"],
129
+ "skill": "pdf",
130
+ "script": "scripts/extract_text.py",
131
+ "scriptArgs": ["existing.pdf", "extracted.txt"]
132
+ }
133
+ ```
134
+
135
+ The script refuses a scanned PDF (no text layer, no OCR in this runtime) with a
136
+ message saying so — tell the user the pages are images rather than rerunning.
137
+
138
+ **Encrypt** with AES-256 — args: input, output, password. Needs the extra
139
+ `"cryptography"` package entry (deliberately unpinned):
140
+
141
+ ```json
142
+ {
143
+ "language": "python",
144
+ "packages": ["pypdf==6.15.0", "cryptography"],
145
+ "inputs": [{ "attachmentId": "<real id>", "path": "existing.pdf" }],
146
+ "outputs": ["locked.pdf"],
147
+ "skill": "pdf",
148
+ "script": "scripts/encrypt.py",
149
+ "scriptArgs": ["existing.pdf", "locked.pdf", "s3cret"]
150
+ }
151
+ ```
152
+
153
+ **Decrypt** — args: input, output, password (the password must come from the
154
+ user). Needs `"cryptography"` too when the file is AES-encrypted:
155
+
156
+ ```json
157
+ {
158
+ "language": "python",
159
+ "packages": ["pypdf==6.15.0", "cryptography"],
160
+ "inputs": [{ "attachmentId": "<real id>", "path": "locked.pdf" }],
161
+ "outputs": ["unlocked.pdf"],
162
+ "skill": "pdf",
163
+ "script": "scripts/decrypt.py",
164
+ "scriptArgs": ["locked.pdf", "unlocked.pdf", "s3cret"]
165
+ }
166
+ ```
167
+
168
+ ### If a script fails
169
+
170
+ Read its stderr first — the scripts print exactly what is wrong (bad argument
171
+ order, wrong password, scanned pages). A failure is almost always fixed by
172
+ correcting `scriptArgs` or `inputs` and running the script again. Only when the
173
+ message does not explain it, load the script's source to inspect it — call the
174
+ `skill` tool with `name: "pdf"` and `file: "scripts/<name>.py"` — then either
175
+ rerun the script with fixed arguments or fall back to an inline `command`
176
+ adapted from that source. Never load script sources up front; run them.
177
+
178
+ ## Coded Transforms
179
+
180
+ Split, watermark, and form filling take shapes a fixed script cannot cover, so
181
+ they stay inline Python in `command` (no `script` key). Each recipe below is
182
+ complete; stage the attachment(s) as `inputs` first. `reader.pages` is
183
+ zero-indexed. Always write a new output name — never the staged input's.
184
+
185
+ **Split** — keep a page range (here: pages 1-3, i.e. indexes 0-2):
186
+
187
+ ```python
188
+ from pypdf import PdfReader, PdfWriter
189
+
190
+ reader = PdfReader("existing.pdf")
191
+ part = PdfWriter()
192
+ for page in reader.pages[0:3]:
193
+ part.add_page(page)
194
+ part.write("pages-1-3.pdf")
195
+ print(f"{len(part.pages)} pages")
196
+ ```
197
+
198
+ **Watermark** — build the stamp with fpdf2 in memory, merge it onto every
199
+ page. Needs both packages (`pypdf==6.15.0` and `fpdf2==2.8.8`). `over=False`
200
+ puts the mark under the content, `over=True` on top:
201
+
202
+ ```python
203
+ import io
204
+ from fpdf import FPDF
205
+ from pypdf import PdfReader, PdfWriter
206
+
207
+ mark = FPDF(format="A4")
208
+ mark.add_page()
209
+ mark.set_font("helvetica", style="B", size=60)
210
+ mark.set_text_color(200, 200, 200)
211
+ with mark.rotation(45, x=105, y=148):
212
+ mark.text(30, 160, "DRAFT") # text(x, y, s) is stamp-only: fixed point, no wrapping
213
+ stamp = PdfReader(io.BytesIO(mark.output())).pages[0]
214
+
215
+ writer = PdfWriter(clone_from="existing.pdf")
216
+ for page in writer.pages:
217
+ page.merge_page(stamp, over=False)
218
+ writer.write("watermarked.pdf")
219
+ print(f"{len(writer.pages)} pages")
220
+ ```
221
+
222
+ **Fill form fields** — inspect the field names first (`get_fields`), then set
223
+ them by exact name:
224
+
225
+ ```python
226
+ from pypdf import PdfReader, PdfWriter
227
+
228
+ reader = PdfReader("form.pdf")
229
+ fields = reader.get_fields()
230
+ print("fields:", sorted(fields) if fields else "none")
231
+
232
+ writer = PdfWriter(clone_from="form.pdf")
233
+ writer.update_page_form_field_values(
234
+ writer.pages[0], {"name": "Ada Lovelace", "email": "ada@example.com"}
235
+ )
236
+ writer.write("filled.pdf")
237
+ print(f"{len(writer.pages)} pages")
238
+ ```
239
+
240
+ If `get_fields()` returns `None` the PDF has no form layer — flattened forms
241
+ cannot be filled; say so instead of guessing at overlay coordinates.
242
+
243
+ ## Errors
244
+
245
+ - `ModuleNotFoundError: No module named 'pypdf'` — `packages` was missing or
246
+ wrong; the pin is `pypdf==6.15.0`. Never try to install inside the script.
247
+ - `DependencyError: cryptography>=3.1 is required for AES algorithm` — add
248
+ `"cryptography"` to `packages` and rerun. The encrypt/decrypt scripts clear
249
+ pypdf's cached crypto backend themselves.
250
+ - `usage: <script>.py …` on stderr — the `scriptArgs` were wrong (order,
251
+ count, or a non-numeric degrees). Fix the arguments and run the script
252
+ again; do not rewrite the script as inline code for an argument mistake.
253
+ - `EOF marker not found` or `PdfReadError` on open — the staged file is not a
254
+ complete PDF; check the right attachment was staged, or ask the user to
255
+ re-attach it.
256
+ - `File has not been decrypted` — the input is password-protected: ask the
257
+ user for the password and run the decrypt recipe first.
258
+ - `FileNotFoundError` — the file was never staged, or the name does not match
259
+ an `inputs` path. The working directory starts empty on every call: stage
260
+ the PDF via `inputs` with the `attachmentId` from the tool result that
261
+ produced it, or rebuild from scratch. Do not invent ids.
262
+ - On an `AttributeError` or `TypeError` from pypdf the API name or arguments
263
+ are wrong — fix against this file's recipes. Do not retry the same call and
264
+ do not switch to a shell.
265
+
266
+ ## Finish
267
+
268
+ When `exitCode` is `0` and `attachments` lists the output file, stop tool use
269
+ and answer with one line: file name + the page count from stdout. Exactly one
270
+ successful `exec` per request.
@@ -0,0 +1,26 @@
1
+ """Decrypt a password-protected PDF (requires the "cryptography" package entry for AES files).
2
+
3
+ Usage: decrypt.py <input.pdf> <output.pdf> <password>
4
+ """
5
+
6
+ import sys
7
+
8
+ # pypdf picks its crypto backend at first import and the interpreter is reused
9
+ # across calls — clear it so the backend is chosen with "cryptography" present.
10
+ for name in [m for m in sys.modules if m.startswith("pypdf")]:
11
+ del sys.modules[name]
12
+ from pypdf import PdfReader, PdfWriter
13
+
14
+ if len(sys.argv) != 4:
15
+ sys.exit("usage: decrypt.py <input.pdf> <output.pdf> <password> — the password never prints")
16
+
17
+ source, target, password = sys.argv[1], sys.argv[2], sys.argv[3]
18
+ if source == target:
19
+ sys.exit(f"output {target} must be a new name, not the staged input")
20
+
21
+ reader = PdfReader(source)
22
+ if reader.is_encrypted and not reader.decrypt(password):
23
+ sys.exit("wrong password — ask the user for the correct one; do not retry blind")
24
+ writer = PdfWriter(clone_from=reader)
25
+ writer.write(target)
26
+ print(f"{len(writer.pages)} pages")
@@ -0,0 +1,25 @@
1
+ """Encrypt a PDF with AES-256 (requires the "cryptography" package entry).
2
+
3
+ Usage: encrypt.py <input.pdf> <output.pdf> <password>
4
+ """
5
+
6
+ import sys
7
+
8
+ # pypdf picks its crypto backend at first import and the interpreter is reused
9
+ # across calls, so an earlier pypdf run without "cryptography" would otherwise
10
+ # pin the backend without AES.
11
+ for name in [m for m in sys.modules if m.startswith("pypdf")]:
12
+ del sys.modules[name]
13
+ from pypdf import PdfWriter
14
+
15
+ if len(sys.argv) != 4:
16
+ sys.exit("usage: encrypt.py <input.pdf> <output.pdf> <password> — the password never prints")
17
+
18
+ source, target, password = sys.argv[1], sys.argv[2], sys.argv[3]
19
+ if source == target:
20
+ sys.exit(f"output {target} must be a new name, not the staged input")
21
+
22
+ writer = PdfWriter(clone_from=source)
23
+ writer.encrypt(user_password=password, algorithm="AES-256")
24
+ writer.write(target)
25
+ print(f"{len(writer.pages)} pages, AES-256")
@@ -0,0 +1,25 @@
1
+ """Extract all text from a PDF into a .txt file.
2
+
3
+ Usage: extract_text.py <input.pdf> <output.txt>
4
+ """
5
+
6
+ import sys
7
+
8
+ from pypdf import PdfReader
9
+
10
+ if len(sys.argv) != 3:
11
+ sys.exit(f"usage: extract_text.py <input.pdf> <output.txt> — got {sys.argv[1:]}")
12
+
13
+ source, target = sys.argv[1], sys.argv[2]
14
+ reader = PdfReader(source)
15
+ text = "\n\n".join(page.extract_text() for page in reader.pages)
16
+
17
+ if not text.strip():
18
+ sys.exit(
19
+ f"{source} has no text layer ({len(reader.pages)} pages are images; "
20
+ "there is no OCR in this runtime) — tell the user instead of rerunning"
21
+ )
22
+
23
+ with open(target, "w") as f:
24
+ f.write(text)
25
+ print(f"{len(reader.pages)} pages, {len(text)} characters")
@@ -0,0 +1,21 @@
1
+ """Merge two or more PDFs into one, in argument order.
2
+
3
+ Usage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [more inputs...]
4
+ """
5
+
6
+ import sys
7
+
8
+ from pypdf import PdfWriter
9
+
10
+ if len(sys.argv) < 4:
11
+ sys.exit(f"usage: merge.py <output.pdf> <input1.pdf> <input2.pdf> [...] — got {sys.argv[1:]}")
12
+
13
+ target, sources = sys.argv[1], sys.argv[2:]
14
+ if target in sources:
15
+ sys.exit(f"output {target} must be a new name, not one of the staged inputs")
16
+
17
+ merged = PdfWriter()
18
+ for source in sources:
19
+ merged.append(source)
20
+ merged.write(target)
21
+ print(f"{len(merged.pages)} pages from {len(sources)} files")
@@ -0,0 +1,27 @@
1
+ """Rotate every page of a PDF clockwise by a multiple of 90 degrees.
2
+
3
+ Usage: rotate.py <input.pdf> <output.pdf> <degrees>
4
+ """
5
+
6
+ import sys
7
+
8
+ from pypdf import PdfWriter
9
+
10
+ if len(sys.argv) != 4:
11
+ sys.exit(f"usage: rotate.py <input.pdf> <output.pdf> <degrees> — got {sys.argv[1:]}")
12
+
13
+ source, target, degrees_raw = sys.argv[1], sys.argv[2], sys.argv[3]
14
+ if source == target:
15
+ sys.exit(f"output {target} must be a new name, not the staged input")
16
+ try:
17
+ degrees = int(degrees_raw)
18
+ except ValueError:
19
+ sys.exit(f"degrees must be an integer multiple of 90, got: {degrees_raw}")
20
+ if degrees % 90 != 0:
21
+ sys.exit(f"degrees must be a multiple of 90, got: {degrees}")
22
+
23
+ writer = PdfWriter(clone_from=source)
24
+ for page in writer.pages:
25
+ page.rotate(degrees)
26
+ writer.write(target)
27
+ print(f"{len(writer.pages)} pages rotated {degrees} degrees")