@cteno/sdk-linux-x64 0.0.0-stage → 2.10.13

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 (116) hide show
  1. package/LICENSE.daemon +31 -0
  2. package/README.md +4 -2
  3. package/agents/flow-analyzer/AGENT.md +71 -0
  4. package/bin/cteno-host-cli +4 -0
  5. package/bin/cteno-wiki +0 -0
  6. package/bin/rg +0 -0
  7. package/bin/secret-exec +0 -0
  8. package/manifest.json +12 -0
  9. package/package.json +33 -4
  10. package/plugins/agent-secrets/.cteno-plugin/plugin.json +133 -0
  11. package/plugins/agent-secrets/bin/linux-x64/fnox +4 -0
  12. package/plugins/agent-secrets/bin/linux-x64/secret-exec +0 -0
  13. package/plugins/agent-secrets/skills/secret/SKILL.md +201 -0
  14. package/plugins/cteno.browser/.cteno-plugin/plugin.json +65 -0
  15. package/plugins/cteno.browser/skills/browser/SKILL.md +28 -0
  16. package/plugins/cteno.inbox/.cteno-plugin/plugin.json +128 -0
  17. package/plugins/cteno.inbox/bin/all/cteno-connector-credential +31 -0
  18. package/plugins/cteno.inbox/bin/all/cteno-telegram-connector +33 -0
  19. package/plugins/cteno.inbox/bin/linux-x64/cteno-inbox +0 -0
  20. package/plugins/cteno.inbox/skills/cteno-inbox/SKILL.md +374 -0
  21. package/plugins/cteno.inbox/tools/inbox_focus/TOOL.md +44 -0
  22. package/plugins/cteno.memory/.cteno-plugin/plugin.json +176 -0
  23. package/plugins/cteno.memory/agents/llm-wiki/AGENT.md +96 -0
  24. package/plugins/cteno.memory/bin/linux-x64/cteno-wiki +0 -0
  25. package/plugins/cteno.memory/skills/codebase-wiki/SKILL.md +52 -0
  26. package/plugins/cteno.memory/skills/cteno.memory.native/SKILL.md +110 -0
  27. package/plugins/cteno.memory/tools/memory/TOOL.md +492 -0
  28. package/plugins/cteno.memory/ui/index.html +822 -0
  29. package/plugins/cteno.models/.cteno-plugin/plugin.json +191 -0
  30. package/plugins/cteno.websearch/.cteno-plugin/plugin.json +45 -0
  31. package/plugins/jev/.cteno-plugin/plugin.json +305 -0
  32. package/plugins/jev/skills/jev-browser/SKILL.md +66 -0
  33. package/plugins/jev/skills/jev-browser/jev_browser.py +335 -0
  34. package/skills/agent-create/SKILL.md +86 -0
  35. package/skills/cteno-host-cli/SKILL.md +248 -0
  36. package/skills/docx/LICENSE +23 -0
  37. package/skills/docx/NOTICE.md +8 -0
  38. package/skills/docx/SKILL.md +69 -0
  39. package/skills/docx/references/cjk_typography.md +357 -0
  40. package/skills/docx/references/design_principles.md +819 -0
  41. package/skills/docx/references/revisions-and-comments.md +88 -0
  42. package/skills/docx/references/track_changes_guide.md +200 -0
  43. package/skills/docx/references/typography_guide.md +294 -0
  44. package/skills/docx/scripts/cjk_fonts.py +244 -0
  45. package/skills/docx/scripts/docx_comments.py +289 -0
  46. package/skills/docx/scripts/docx_common.py +92 -0
  47. package/skills/docx/scripts/docx_create.py +259 -0
  48. package/skills/docx/scripts/docx_edit.py +250 -0
  49. package/skills/docx/scripts/docx_read.py +149 -0
  50. package/skills/docx/scripts/docx_revisions.py +146 -0
  51. package/skills/docx/scripts/docx_template.py +70 -0
  52. package/skills/docx/scripts/docx_validate.py +156 -0
  53. package/skills/docx/scripts/render.py +250 -0
  54. package/skills/docx/tests/test_docx_skill.py +525 -0
  55. package/skills/home-devices/SKILL.md +121 -0
  56. package/skills/mcp/SKILL.md +72 -0
  57. package/skills/mcp/scripts/mcp_setup.py +439 -0
  58. package/skills/pdf/LICENSE +23 -0
  59. package/skills/pdf/NOTICE.md +8 -0
  60. package/skills/pdf/SKILL.md +63 -0
  61. package/skills/pdf/references/design-tokens.md +381 -0
  62. package/skills/pdf/references/forms.md +99 -0
  63. package/skills/pdf/references/nano-pdf-editing.md +40 -0
  64. package/skills/pdf/references/ocr-extraction.md +165 -0
  65. package/skills/pdf/scripts/_raster.py +76 -0
  66. package/skills/pdf/scripts/extract_marker.py +88 -0
  67. package/skills/pdf/scripts/extract_pymupdf.py +101 -0
  68. package/skills/pdf/scripts/pdf_create.py +191 -0
  69. package/skills/pdf/scripts/pdf_fill_form.py +97 -0
  70. package/skills/pdf/scripts/pdf_form_layout.py +168 -0
  71. package/skills/pdf/scripts/pdf_make_form.py +145 -0
  72. package/skills/pdf/scripts/pdf_merge.py +50 -0
  73. package/skills/pdf/scripts/pdf_meta.py +115 -0
  74. package/skills/pdf/scripts/pdf_page_image.py +99 -0
  75. package/skills/pdf/scripts/pdf_read.py +153 -0
  76. package/skills/pdf/scripts/pdf_secure.py +71 -0
  77. package/skills/pdf/scripts/pdf_split.py +84 -0
  78. package/skills/pdf/scripts/pdf_stamp.py +143 -0
  79. package/skills/pdf/scripts/pdf_watermark.py +51 -0
  80. package/skills/pdf/scripts/render.py +250 -0
  81. package/skills/pdf/tests/test_pdf_skill.py +415 -0
  82. package/skills/pptx/LICENSE +23 -0
  83. package/skills/pptx/NOTICE.md +8 -0
  84. package/skills/pptx/SKILL.md +62 -0
  85. package/skills/pptx/references/design-system.md +392 -0
  86. package/skills/pptx/references/pptxgenjs.md +420 -0
  87. package/skills/pptx/references/slide-types.md +413 -0
  88. package/skills/pptx/scripts/cjk_fonts.py +244 -0
  89. package/skills/pptx/scripts/pptx_create.py +240 -0
  90. package/skills/pptx/scripts/pptx_edit.py +436 -0
  91. package/skills/pptx/scripts/pptx_from_template.py +88 -0
  92. package/skills/pptx/scripts/pptx_read.py +131 -0
  93. package/skills/pptx/scripts/pptx_render.py +96 -0
  94. package/skills/pptx/scripts/render.py +250 -0
  95. package/skills/pptx/tests/test_powerpoint_skill.py +477 -0
  96. package/skills/skill-create/SKILL.md +114 -0
  97. package/skills/video-generation/SKILL.md +27 -0
  98. package/skills/xlsx/LICENSE +23 -0
  99. package/skills/xlsx/NOTICE.md +8 -0
  100. package/skills/xlsx/SKILL.md +63 -0
  101. package/skills/xlsx/references/format.md +71 -0
  102. package/skills/xlsx/references/restructuring.md +71 -0
  103. package/skills/xlsx/scripts/csv_to_xlsx.py +110 -0
  104. package/skills/xlsx/scripts/formula_check.py +428 -0
  105. package/skills/xlsx/scripts/render.py +250 -0
  106. package/skills/xlsx/scripts/xlsx_create.py +265 -0
  107. package/skills/xlsx/scripts/xlsx_edit.py +269 -0
  108. package/skills/xlsx/scripts/xlsx_read.py +166 -0
  109. package/skills/xlsx/scripts/xlsx_recalc.py +119 -0
  110. package/skills/xlsx/scripts/xlsx_restructure.py +343 -0
  111. package/skills/xlsx/scripts/xlsx_to_csv.py +75 -0
  112. package/skills/xlsx/tests/test_xlsx_skill.py +544 -0
  113. package/tools/create_goal/TOOL.md +36 -0
  114. package/tools/image_generation/TOOL.md +81 -0
  115. package/tools/skill/TOOL.md +117 -0
  116. package/tools/update_profile_notes/TOOL.md +39 -0
@@ -0,0 +1,525 @@
1
+ # MIT License. End-to-end tests for the docx skill.
2
+ """Pytest suite proving create / read / edit / template round-trips.
3
+
4
+ Runs the scripts as subprocesses (argparse CLIs) and also verifies the
5
+ outputs with python-docx directly. Stdlib + python-docx only; all
6
+ fixtures are generated on the fly; no network.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import struct
13
+ import subprocess
14
+ import sys
15
+ import zlib
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+ from docx import Document
20
+
21
+ SKILL = Path(__file__).resolve().parent.parent
22
+ SCRIPTS = SKILL / "scripts"
23
+
24
+ NON_ASCII = "Фамилия — ‘test’"
25
+
26
+
27
+ def make_png(path: Path) -> None:
28
+ """Write a tiny valid 2x2 red PNG using only stdlib."""
29
+ def chunk(tag: bytes, data: bytes) -> bytes:
30
+ return (struct.pack(">I", len(data)) + tag + data
31
+ + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
32
+
33
+ ihdr = struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 0)
34
+ raw = b"".join(b"\x00" + b"\xff\x00\x00" * 2 for _ in range(2))
35
+ png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)
36
+ + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))
37
+ path.write_bytes(png)
38
+
39
+
40
+ def run(script: str, *args: str) -> dict:
41
+ env = dict(os.environ)
42
+ env["LC_ALL"] = "C" # prove no locale-default text reads
43
+ env["PYTHONIOENCODING"] = "utf-8"
44
+ proc = subprocess.run(
45
+ [sys.executable, str(SCRIPTS / script), *map(str, args)],
46
+ capture_output=True, env=env, check=False)
47
+ assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace")
48
+ return json.loads(proc.stdout.decode("utf-8"))
49
+
50
+
51
+ @pytest.fixture(scope="module")
52
+ def workdir(tmp_path_factory) -> Path:
53
+ return tmp_path_factory.mktemp("docxskill")
54
+
55
+
56
+ @pytest.fixture(scope="module")
57
+ def created(workdir: Path) -> Path:
58
+ """Create a document exercising every create feature."""
59
+ png = workdir / "pic.png"
60
+ make_png(png)
61
+ spec = {
62
+ "page": {"width_mm": 210, "height_mm": 297,
63
+ "margins_mm": {"top": 25, "bottom": 25,
64
+ "left": 20, "right": 20}},
65
+ "header": "Report header",
66
+ "footer": "Page footer",
67
+ "styles": [{"name": "FancyNote", "base": "Normal", "font": "Arial",
68
+ "size_pt": 11, "italic": True, "color": "1F4E79"}],
69
+ "blocks": [
70
+ {"type": "heading", "text": "Main Title", "level": 1},
71
+ {"type": "heading", "text": "Section One", "level": 2},
72
+ {"type": "paragraph", "runs": [
73
+ {"text": "plain "},
74
+ {"text": "boldbit", "bold": True},
75
+ {"text": " italicbit", "italic": True},
76
+ {"text": " underbit", "underline": True}]},
77
+ {"type": "paragraph", "text": "Styled note.",
78
+ "style": "FancyNote"},
79
+ {"type": "bullet_list", "items": ["alpha", "beta"]},
80
+ {"type": "numbered_list", "items": ["first", "second"]},
81
+ {"type": "table", "header": ["Name", "Qty"],
82
+ "rows": [["Widget", "3"], ["Gadget", "5"]],
83
+ "style": "Table Grid", "header_bold": True},
84
+ {"type": "image", "path": str(png), "width_mm": 30},
85
+ {"type": "page_break"},
86
+ {"type": "paragraph", "text": "After the break."},
87
+ ],
88
+ }
89
+ spec_path = workdir / "spec.json"
90
+ spec_path.write_text(json.dumps(spec), encoding="utf-8")
91
+ out = workdir / "created.docx"
92
+ res = run("docx_create.py", spec_path, out)
93
+ assert res["ok"] and out.exists()
94
+ return out
95
+
96
+
97
+ class TestCreateAndRead:
98
+ def test_text_roundtrip(self, created: Path):
99
+ text = run("docx_read.py", created, "--text")
100
+ body = "\n".join(text["body"])
101
+ for expected in ("Main Title", "plain boldbit italicbit underbit",
102
+ "Styled note.", "alpha", "second",
103
+ "After the break."):
104
+ assert expected in body
105
+ assert text["tables"] == [[["Name", "Qty"], ["Widget", "3"],
106
+ ["Gadget", "5"]]]
107
+ assert "Report header" in text["headers"]
108
+ assert "Page footer" in text["footers"]
109
+
110
+ def test_structure(self, created: Path):
111
+ st = run("docx_read.py", created, "--structure")
112
+ outline = [(h["level"], h["text"]) for h in st["outline"]]
113
+ assert (1, "Main Title") in outline
114
+ assert (2, "Section One") in outline
115
+ assert st["table_count"] == 1
116
+ assert st["tables"][0] == {"rows": 3, "cols": 2}
117
+
118
+ def test_styles_used(self, created: Path):
119
+ styles = run("docx_read.py", created, "--styles")["styles"]
120
+ for s in ("Heading 1", "FancyNote", "List Bullet", "List Number",
121
+ "Table Grid"):
122
+ assert s in styles
123
+
124
+ def test_images_extracted(self, created: Path, workdir: Path):
125
+ outdir = workdir / "media"
126
+ res = run("docx_read.py", created, "--images", outdir)
127
+ assert len(res["images"]) == 1
128
+ img = Path(res["images"][0])
129
+ assert img.read_bytes().startswith(b"\x89PNG")
130
+
131
+ def test_run_formatting_persisted(self, created: Path):
132
+ doc = Document(str(created))
133
+ para = next(p for p in doc.paragraphs if "boldbit" in p.text)
134
+ flags = {r.text.strip(): (r.bold, r.italic, r.underline)
135
+ for r in para.runs if r.text.strip()}
136
+ assert flags["boldbit"][0] is True
137
+ assert flags["italicbit"][1] is True
138
+ assert flags["underbit"][2] is True
139
+
140
+ def test_page_setup(self, created: Path):
141
+ sec = Document(str(created)).sections[0]
142
+ assert round(sec.page_width.mm) == 210
143
+ assert round(sec.top_margin.mm) == 25
144
+
145
+ def test_revisions_detection(self, created: Path):
146
+ rev = run("docx_read.py", created, "--revisions")
147
+ assert rev["has_tracked_changes"] is False
148
+ assert rev["comments"] is False
149
+
150
+
151
+ class TestEdit:
152
+ def test_replace_preserves_formatting(self, created: Path, workdir: Path):
153
+ out = workdir / "edited.docx"
154
+ res = run("docx_edit.py", "replace", created, "--find", "boldbit",
155
+ "--replace", "REPLACED", "-o", out)
156
+ assert res["replacements"] == 1
157
+ doc = Document(str(out))
158
+ para = next(p for p in doc.paragraphs if "REPLACED" in p.text)
159
+ run_ = next(r for r in para.runs if "REPLACED" in r.text)
160
+ assert run_.bold is True # formatting survived
161
+
162
+ def test_set_cell(self, created: Path, workdir: Path):
163
+ out = workdir / "cell.docx"
164
+ run("docx_edit.py", "set-cell", created, "--table", "0", "--row",
165
+ "1", "--col", "1", "--text", "99", "-o", out)
166
+ assert Document(str(out)).tables[0].cell(1, 1).text == "99"
167
+
168
+ def test_insert_and_delete(self, created: Path, workdir: Path):
169
+ out = workdir / "ins.docx"
170
+ run("docx_edit.py", "insert", created, "--index", "0", "--text",
171
+ "Inserted first", "-o", out)
172
+ doc = Document(str(out))
173
+ assert doc.paragraphs[0].text == "Inserted first"
174
+ out2 = workdir / "del.docx"
175
+ run("docx_edit.py", "delete", out, "--index", "0", "-o", out2)
176
+ assert Document(str(out2)).paragraphs[0].text != "Inserted first"
177
+
178
+ def test_apply_style(self, created: Path, workdir: Path):
179
+ out = workdir / "styled.docx"
180
+ doc = Document(str(created))
181
+ idx = next(i for i, p in enumerate(doc.paragraphs)
182
+ if p.text == "After the break.")
183
+ run("docx_edit.py", "style", created, "--index", str(idx),
184
+ "--style", "Heading 2", "-o", out)
185
+ doc2 = Document(str(out))
186
+ assert doc2.paragraphs[idx].style.name == "Heading 2"
187
+
188
+
189
+ class TestTemplate:
190
+ def test_fill_everywhere_non_ascii(self, workdir: Path):
191
+ # Build a template: tokens in body, split runs, table, header, footer.
192
+ tpl = workdir / "tpl.docx"
193
+ doc = Document()
194
+ doc.sections[0].header.paragraphs[0].text = "H: {{name}}"
195
+ doc.sections[0].footer.paragraphs[0].text = "F: {{date}}"
196
+ p = doc.add_paragraph()
197
+ p.add_run("Dear {{na") # token split across runs
198
+ p.add_run("me}}, hello.")
199
+ t = doc.add_table(rows=1, cols=2)
200
+ t.cell(0, 0).text = "{{name}}"
201
+ t.cell(0, 1).text = "{{ date }}" # spaced variant
202
+ doc.add_paragraph("Unfilled: {{missing}}")
203
+ doc.save(str(tpl))
204
+
205
+ values = workdir / "values.json"
206
+ values.write_text(
207
+ json.dumps({"name": NON_ASCII, "date": "2026-08-08"},
208
+ ensure_ascii=False), encoding="utf-8")
209
+ out = workdir / "filled.docx"
210
+ res = run("docx_template.py", tpl, values, out)
211
+ assert res["ok"] is True
212
+ assert res["unfilled_tokens"] == ["missing"]
213
+
214
+ text = run("docx_read.py", out, "--text")
215
+ assert f"Dear {NON_ASCII}, hello." in text["body"]
216
+ assert text["tables"][0][0] == [NON_ASCII, "2026-08-08"]
217
+ assert f"H: {NON_ASCII}" in text["headers"]
218
+ assert "F: 2026-08-08" in text["footers"]
219
+
220
+ def test_strict_fails_on_unfilled(self, workdir: Path):
221
+ tpl = workdir / "tpl2.docx"
222
+ doc = Document()
223
+ doc.add_paragraph("{{gone}}")
224
+ doc.save(str(tpl))
225
+ values = workdir / "empty.json"
226
+ values.write_text("{}", encoding="utf-8")
227
+ env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
228
+ proc = subprocess.run(
229
+ [sys.executable, str(SCRIPTS / "docx_template.py"), str(tpl),
230
+ str(values), str(workdir / "out2.docx"), "--strict"],
231
+ capture_output=True, env=env, check=False)
232
+ assert proc.returncode == 1
233
+ payload = json.loads(proc.stdout.decode("utf-8"))
234
+ assert payload["unfilled_tokens"] == ["gone"]
235
+
236
+
237
+ # --------------------------------------------------------------- new parity
238
+
239
+ W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
240
+
241
+
242
+ def q(tag: str) -> str:
243
+ return f"{{{W}}}{tag}"
244
+
245
+
246
+ def run_raw(script: str, *args: str):
247
+ env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")
248
+ return subprocess.run(
249
+ [sys.executable, str(SCRIPTS / script), *map(str, args)],
250
+ capture_output=True, env=env, check=False)
251
+
252
+
253
+ def _add_ins(para, rev_id: int, text: str, author="Editor"):
254
+ from lxml import etree
255
+ ins = etree.SubElement(para._p, q("ins"))
256
+ ins.set(q("id"), str(rev_id))
257
+ ins.set(q("author"), author)
258
+ ins.set(q("date"), "2026-01-02T03:04:05Z")
259
+ r = etree.SubElement(ins, q("r"))
260
+ t = etree.SubElement(r, q("t"))
261
+ t.text = text
262
+ t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
263
+
264
+
265
+ def _add_del(para, rev_id: int, text: str, author="Editor"):
266
+ from lxml import etree
267
+ dele = etree.SubElement(para._p, q("del"))
268
+ dele.set(q("id"), str(rev_id))
269
+ dele.set(q("author"), author)
270
+ dele.set(q("date"), "2026-01-02T03:04:05Z")
271
+ r = etree.SubElement(dele, q("r"))
272
+ t = etree.SubElement(r, q("delText"))
273
+ t.text = text
274
+ t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")
275
+
276
+
277
+ @pytest.fixture()
278
+ def tracked(tmp_path: Path) -> Path:
279
+ """Doc with a tracked insertion + deletion in body AND in a table."""
280
+ doc = Document()
281
+ p = doc.add_paragraph("Base ")
282
+ _add_ins(p, 1, "ADDED")
283
+ _add_del(p, 2, "REMOVED")
284
+ table = doc.add_table(rows=1, cols=1)
285
+ cp = table.cell(0, 0).paragraphs[0]
286
+ cp.add_run("Cell ")
287
+ _add_ins(cp, 3, "CELLADD")
288
+ _add_del(cp, 4, "CELLGONE")
289
+ path = tmp_path / "tracked.docx"
290
+ doc.save(str(path))
291
+ return path
292
+
293
+
294
+ class TestRevisions:
295
+ def test_list(self, tracked: Path):
296
+ res = run("docx_revisions.py", "list", tracked)
297
+ revs = {r["id"]: r for r in res["revisions"]}
298
+ assert len(revs) == 4
299
+ assert revs["1"] == {"id": "1", "author": "Editor",
300
+ "date": "2026-01-02T03:04:05Z",
301
+ "type": "insertion", "text": "ADDED"}
302
+ assert revs["2"]["type"] == "deletion"
303
+ assert revs["2"]["text"] == "REMOVED"
304
+ assert revs["3"]["text"] == "CELLADD" # inside table
305
+ assert revs["4"]["type"] == "deletion"
306
+
307
+ def test_accept_all(self, tracked: Path, tmp_path: Path):
308
+ out = tmp_path / "acc.docx"
309
+ res = run("docx_revisions.py", "accept-all", tracked, "-o", out)
310
+ assert res["resolved"] == 4
311
+ doc = Document(str(out))
312
+ assert doc.paragraphs[0].text == "Base ADDED"
313
+ assert doc.tables[0].cell(0, 0).text == "Cell CELLADD"
314
+ assert run("docx_revisions.py", "list", out)["revisions"] == []
315
+
316
+ def test_reject_all(self, tracked: Path, tmp_path: Path):
317
+ out = tmp_path / "rej.docx"
318
+ run("docx_revisions.py", "reject-all", tracked, "-o", out)
319
+ doc = Document(str(out))
320
+ assert doc.paragraphs[0].text == "Base REMOVED"
321
+ assert doc.tables[0].cell(0, 0).text == "Cell CELLGONE"
322
+
323
+ def test_accept_single_by_id(self, tracked: Path, tmp_path: Path):
324
+ out = tmp_path / "one.docx"
325
+ res = run("docx_revisions.py", "accept", tracked, "--id", "1",
326
+ "-o", out)
327
+ assert res["resolved"] == 1
328
+ doc = Document(str(out))
329
+ assert doc.paragraphs[0].text == "Base ADDED" # del 2 unresolved
330
+ remaining = run("docx_revisions.py", "list", out)["revisions"]
331
+ assert sorted(r["id"] for r in remaining) == ["2", "3", "4"]
332
+
333
+ def test_reject_single_by_id(self, tracked: Path, tmp_path: Path):
334
+ out = tmp_path / "rone.docx"
335
+ run("docx_revisions.py", "reject", tracked, "--id", "2", "-o", out)
336
+ doc = Document(str(out))
337
+ assert doc.paragraphs[0].text == "Base REMOVED" # ins 1 unresolved
338
+
339
+ def test_unknown_id_fails(self, tracked: Path, tmp_path: Path):
340
+ proc = run_raw("docx_revisions.py", "accept", tracked, "--id",
341
+ "999", "-o", tmp_path / "x.docx")
342
+ assert proc.returncode == 1
343
+
344
+
345
+ class TestComments:
346
+ @pytest.fixture()
347
+ def base(self, tmp_path: Path) -> Path:
348
+ doc = Document()
349
+ doc.add_paragraph("The quarterly revenue rose sharply.")
350
+ doc.add_paragraph("Second paragraph.")
351
+ path = tmp_path / "base.docx"
352
+ doc.save(str(path))
353
+ return path
354
+
355
+ def test_add_list_delete(self, base: Path, tmp_path: Path):
356
+ out = tmp_path / "com.docx"
357
+ res = run("docx_comments.py", "add", base, "--target",
358
+ "quarterly revenue", "--text", "Needs a source",
359
+ "--author", "Reviewer", "--initials", "R", "-o", out)
360
+ assert res["ok"] is True
361
+ cid = res["comment_id"]
362
+
363
+ listed = run("docx_comments.py", "list", out)["comments"]
364
+ assert len(listed) == 1
365
+ c = listed[0]
366
+ assert c["id"] == cid
367
+ assert c["author"] == "Reviewer"
368
+ assert c["text"] == "Needs a source"
369
+ assert c["anchored_text"] == "quarterly revenue"
370
+ assert c["date"]
371
+
372
+ # document text unchanged by anchoring
373
+ text = run("docx_read.py", out, "--text")
374
+ assert "The quarterly revenue rose sharply." in text["body"]
375
+
376
+ out2 = tmp_path / "nocom.docx"
377
+ run("docx_comments.py", "delete", out, "--id", cid, "-o", out2)
378
+ assert run("docx_comments.py", "list", out2)["comments"] == []
379
+ text2 = run("docx_read.py", out2, "--text")
380
+ assert "The quarterly revenue rose sharply." in text2["body"]
381
+
382
+ def test_xml_fallback_path(self, base: Path, tmp_path: Path):
383
+ out = tmp_path / "xmlcom.docx"
384
+ res = run("docx_comments.py", "add", base, "--target",
385
+ "Second paragraph", "--text", "fallback note",
386
+ "--author", "Bot", "--xml", "-o", out)
387
+ assert res["native_api"] is False
388
+ listed = run("docx_comments.py", "list", out)["comments"]
389
+ assert listed[0]["text"] == "fallback note"
390
+ assert listed[0]["anchored_text"] == "Second paragraph"
391
+ # file still opens cleanly
392
+ assert Document(str(out)).paragraphs[1].text == "Second paragraph."
393
+
394
+ def test_missing_target_fails(self, base: Path, tmp_path: Path):
395
+ proc = run_raw("docx_comments.py", "add", base, "--target",
396
+ "not present", "--text", "x", "-o",
397
+ tmp_path / "y.docx")
398
+ assert proc.returncode == 1
399
+
400
+
401
+ class TestValidate:
402
+ def test_healthy_file_passes(self, created: Path):
403
+ res = run("docx_validate.py", created)
404
+ assert res["ok"] is True
405
+ assert all(i["severity"] != "error" for i in res["issues"])
406
+
407
+ def test_not_a_zip(self, tmp_path: Path):
408
+ bad = tmp_path / "bad.docx"
409
+ bad.write_bytes(b"this is not a zip file")
410
+ proc = run_raw("docx_validate.py", bad)
411
+ assert proc.returncode == 1
412
+ rep = json.loads(proc.stdout.decode("utf-8"))
413
+ assert rep["issues"][0]["code"] == "not-a-zip"
414
+
415
+ def test_dangling_rel_and_empty_image(self, created: Path,
416
+ tmp_path: Path):
417
+ import shutil
418
+ import zipfile
419
+ broken = tmp_path / "broken.docx"
420
+ shutil.copy(created, broken)
421
+ # rebuild the zip: drop the image part, zero out nothing else
422
+ src = zipfile.ZipFile(str(created))
423
+ with zipfile.ZipFile(str(broken), "w") as dst:
424
+ for item in src.infolist():
425
+ if item.filename.startswith("word/media/"):
426
+ dst.writestr(item.filename, b"") # empty image
427
+ else:
428
+ dst.writestr(item, src.read(item.filename))
429
+ proc = run_raw("docx_validate.py", broken)
430
+ assert proc.returncode == 1
431
+ rep = json.loads(proc.stdout.decode("utf-8"))
432
+ codes = {i["code"] for i in rep["issues"]}
433
+ assert "empty-image" in codes
434
+
435
+ def test_missing_style(self, tmp_path: Path):
436
+ import zipfile
437
+ doc = Document()
438
+ doc.add_paragraph("styled", style="Heading 1")
439
+ path = tmp_path / "styles.docx"
440
+ doc.save(str(path))
441
+ # rewrite document.xml to reference a style id that doesn't exist
442
+ src = zipfile.ZipFile(str(path))
443
+ broken = tmp_path / "badstyle.docx"
444
+ with zipfile.ZipFile(str(broken), "w") as dst:
445
+ for item in src.infolist():
446
+ data = src.read(item.filename)
447
+ if item.filename == "word/document.xml":
448
+ data = data.replace(b'w:val="Heading1"',
449
+ b'w:val="GhostStyle"')
450
+ dst.writestr(item, data)
451
+ proc = run_raw("docx_validate.py", broken)
452
+ assert proc.returncode == 1
453
+ rep = json.loads(proc.stdout.decode("utf-8"))
454
+ assert any(i["code"] == "missing-style" and "GhostStyle"
455
+ in i["detail"] for i in rep["issues"])
456
+
457
+
458
+ class TestNormalize:
459
+ def test_merges_split_runs(self, tmp_path: Path):
460
+ doc = Document()
461
+ p = doc.add_paragraph()
462
+ p.add_run("Hel") # identical (no) formatting, split
463
+ p.add_run("lo wo")
464
+ p.add_run("rld")
465
+ b = p.add_run("BOLD1")
466
+ b.bold = True
467
+ b2 = p.add_run("BOLD2")
468
+ b2.bold = True
469
+ i = p.add_run("ital")
470
+ i.italic = True
471
+ path = tmp_path / "split.docx"
472
+ doc.save(str(path))
473
+
474
+ out = tmp_path / "norm.docx"
475
+ res = run("docx_edit.py", "normalize", path, "-o", out)
476
+ assert res["runs_merged"] == 3 # 2 plain merges + 1 bold merge
477
+
478
+ doc2 = Document(str(out))
479
+ para = doc2.paragraphs[0]
480
+ assert para.text == "Hello worldBOLD1BOLD2ital"
481
+ assert [r.text for r in para.runs] == \
482
+ ["Hello world", "BOLD1BOLD2", "ital"]
483
+ assert para.runs[1].bold is True
484
+ assert para.runs[2].italic is True
485
+
486
+
487
+ class TestFields:
488
+ def test_toc_and_page_numbers_via_edit(self, created: Path,
489
+ tmp_path: Path):
490
+ out = tmp_path / "fields.docx"
491
+ run("docx_edit.py", "toc", created, "--index", "0", "-o", out)
492
+ run("docx_edit.py", "page-numbers", out)
493
+
494
+ import zipfile
495
+ doc_xml = zipfile.ZipFile(str(out)).read(
496
+ "word/document.xml").decode("utf-8")
497
+ assert "TOC \\o" in doc_xml
498
+ assert "fldChar" in doc_xml
499
+ footer_names = [n for n in zipfile.ZipFile(str(out)).namelist()
500
+ if n.startswith("word/footer")]
501
+ footers = "".join(zipfile.ZipFile(str(out)).read(n).decode("utf-8")
502
+ for n in footer_names)
503
+ assert "PAGE" in footers and "NUMPAGES" in footers
504
+ # still a valid document
505
+ assert run("docx_validate.py", out)["ok"] is True
506
+
507
+ def test_toc_and_footer_in_create_spec(self, tmp_path: Path):
508
+ spec = {
509
+ "footer_page_numbers": True,
510
+ "blocks": [
511
+ {"type": "toc"},
512
+ {"type": "heading", "text": "Chapter", "level": 1},
513
+ ],
514
+ }
515
+ spec_path = tmp_path / "fspec.json"
516
+ spec_path.write_text(json.dumps(spec), encoding="utf-8")
517
+ out = tmp_path / "fcreate.docx"
518
+ run("docx_create.py", spec_path, out)
519
+
520
+ import zipfile
521
+ z = zipfile.ZipFile(str(out))
522
+ assert "TOC \\o" in z.read("word/document.xml").decode("utf-8")
523
+ footers = "".join(z.read(n).decode("utf-8") for n in z.namelist()
524
+ if n.startswith("word/footer"))
525
+ assert "NUMPAGES" in footers
@@ -0,0 +1,121 @@
1
+ ---
2
+ id: home-devices
3
+ name: home-devices
4
+ description: "扫描局域网里的设备(mDNS/SSDP),按设备类型用通用协议或现成开源库接入:Home Assistant、米家、NAS、打印机、电视/音箱"
5
+ when_to_use: "用户要扫描/发现家里或局域网里的设备,或要接入/控制 Home Assistant、米家、NAS、打印机、电视、音箱、门锁、摄像头等设备时使用"
6
+ version: "1.0.0"
7
+ tags:
8
+ - home
9
+ - devices
10
+ - lan
11
+ - integration
12
+ user_invocable: true
13
+ disable_model_invocation: false
14
+ argument_hint: "[设备或平台名,如 米家 / Home Assistant / NAS]"
15
+ suggested_prompts:
16
+ - id: scan
17
+ title: "扫一下家里有哪些设备"
18
+ - id: mijia
19
+ title: "帮我连上米家"
20
+ - id: home-assistant
21
+ title: "帮我连上 Home Assistant"
22
+ metadata:
23
+ display-name: "家庭设备发现与接入"
24
+ ---
25
+
26
+ # 家庭设备发现与接入
27
+
28
+ 目标:在用户同意的前提下,把局域网里的设备接到这只 Jelly 上,之后用人话就能查询和控制。
29
+ **以通用能力为主**:先扫到设备,再用通用协议或成熟的开源库去连;不为单个设备写插件。
30
+ 只有协议私有且没有现成库,或者必须常驻监听(例如持续接收门铃事件)时,才建议用户装插件。
31
+
32
+ ## 铁律(每条都必须遵守)
33
+
34
+ 1. **只扫本地网络。** 只发现本机所在的局域网(组播/本网段)。绝不扫描公网地址,不做大范围端口扫描,不对别人的网络做探测。
35
+ 2. **凭据只进保险箱。** 令牌、密码、设备 token 一律用 `request_secret`(`persist: true`,`name` 形如 `home/<平台>/<用途>`)获取,
36
+ 之后只以 `secret://vault/<name>` 引用写进命令。先用 `request_secret` 的 `op: "list"` 查是否已存过,存过就直接用。
37
+ 明文绝不出现在对话、memory、文件里;用户如果把密码直接贴在对话里,提醒他去改密码。
38
+ 3. **接入前先确认,而且是第一步。** 每接入一台设备或一个账号,在查保险箱、要令牌、试令牌、发任何带凭据的请求**之前**,先调用 `decision`(`mode: "approval"`):
39
+ `category: "device"`,`target` 用人话写清是哪台设备(名字 + 局域网地址,如「客厅 NAS(192.168.1.20)」),
40
+ `summary` 一句话写清要做什么(如「接入客厅 NAS,只读查看剩余空间」)。不要写内部 id、MAC、token。
41
+ 结果 `proceed: false` 就停下,告诉用户没有接入。即使保险箱里已经有令牌、或者上次接过,这台设备在本次对话里也要先确认。
42
+ 用户最新一条消息若明确要求了这一步(如「把路由器管理员密码改一下」),仍调用一次 `confirm_action`,并把那句话**原文**填进 `user_request`;
43
+ 主机核对通过就直接 `proceed: true`,不再弹卡。它只覆盖用户说的那一步(改 2.4GHz 不含 5GHz,「查一下密码」不等于授权修改),
44
+ 只用一次;被拒/过期后不得重试或绕路,等用户新消息再说。铁律 4 的高风险,以及恢复出厂、刷固件、会断网的重启,永远照常弹卡。
45
+ 4. **高风险操作每次都确认。** 门锁、燃气(阀门/灶具)、摄像头(查看/录像/关闭)、报警(布防/撤防)、门禁 ——
46
+ 对这几类的**每一次**操作,都必须先调用 `confirm_action`,并且带上 `risk: "high"`(`category: "device"`)。
47
+ 漏带 `risk: "high"` 是严重错误:带上它之后用户的自动放行规则也不会跳过这张卡。不能批量确认,不能沿用上一次的确认。
48
+ 5. **接入成功必须记下来(不能省),只记地址和类型。** 只读验证一通过,**在回复用户之前**就调用
49
+ `memory({ action: "save", file_path: "knowledge/home-devices.md", type: "reference", content: ... })`:
50
+ 名称、类型、局域网地址/端口、接入方式、凭据的 vault 名字(只写名字,不写值)。没写 memory 就不算接入完成。
51
+ 绝不写令牌、密码、token。
52
+ 这一步**没有例外**:地址是 127.0.0.1、看起来像测试环境、用户没提、之前已经记过(重新 save 会合并去重)——都照样调用。
53
+ 调用 `memory` 并拿到返回之前,不要写给用户的接入结论。
54
+
55
+ ## 1. 发现
56
+
57
+ 按顺序做,扫到足够信息就停:
58
+
59
+ - **mDNS / Bonjour**
60
+ - macOS:`dns-sd -B <服务类型> local.` 浏览;`dns-sd -L <实例名> <服务类型> local.` 解析主机和端口;`dns-sd -G v4 <主机名>` 取 IP。
61
+ `dns-sd` 不会自己退出,用 `timeout 5 dns-sd ...` 或 shell 超时收尾。
62
+ - Linux:`avahi-browse -rtp <服务类型>`(`-t` 扫完即退出)。
63
+ - 常用服务类型:`_home-assistant._tcp`、`_hap._tcp`(HomeKit)、`_miio._udp`(米家局域网)、`_smb._tcp`、`_afpovertcp._tcp`、
64
+ `_webdav._tcp`、`_ipp._tcp` / `_ipps._tcp`(打印机)、`_airplay._tcp`、`_raop._tcp`、`_googlecast._tcp`、`_http._tcp`。
65
+ - **SSDP / UPnP**:向 `239.255.255.250:1900` 发 `M-SEARCH`(`ST: ssdp:all`,`MX: 2`),读回应里的 `LOCATION` 描述文档,识别 DLNA 渲染器、NAS、路由器。
66
+ 可用 `python3` 标准库几行实现,等待不超过 3 秒。
67
+ - **ARP / 端口探测(克制)**:只在上面两步没发现、且用户明确要找某台设备时才做。只看 `arp -a` 已知邻居;
68
+ 端口只探测本网段、少量已知端口(如 8123、445、631、5000/5001),不做全端口扫描。
69
+
70
+ 把结果整理成一张简短清单给用户:名称、类型、地址、建议接法。然后问用户要接哪一台(或按用户原话直接进入对应接法)。
71
+
72
+ ## 2. 按设备类型接入
73
+
74
+ ### Home Assistant
75
+ - **Cteno Station 自带 HA**:若 `/var/lib/cteno/home-assistant/agent-token` 存在,说明在 Station 上,HA 在 `http://127.0.0.1:8123`,
76
+ 令牌已由首次开机脚本生成,不用找用户要。请求头写 `Authorization: Bearer $(cat /var/lib/cteno/home-assistant/agent-token)`,不要打印令牌。
77
+ 已预装小米官方 `xiaomi_home`(账号登录后网关子设备也会列出)、涂鸦 `tuya`、`homekit_controller`、`matter`。
78
+ HA 自动发现的设备用 WebSocket `config_entries/flow/progress` 查看,再用 `/api/config/config_entries/flow` 走配对流程;接入前照铁律 3 确认。
79
+ - 发现:`_home-assistant._tcp`(TXT 里有 `base_url` / `internal_url`、`version`),默认端口 8123。
80
+ - 凭据:长期访问令牌(HA 个人资料页 →「长期访问令牌」创建)。`request_secret` 取,name 如 `home/home-assistant/token`。
81
+ - 顺序:① `confirm_action`(device)→ ② `request_secret` 取令牌 → ③ `GET /api/` 验证 → ④ 写 memory → ⑤ 回复。不能跳过或调换 ①。
82
+ - 接口:REST `GET <base>/api/`(验证令牌,返回 `{"message": "API running."}`)、`GET <base>/api/states`、
83
+ `POST <base>/api/services/<domain>/<service>`;实时事件用 WebSocket `<base>/api/websocket`。
84
+ 请求头 `Authorization: Bearer secret://vault/<name>`(runtime 会替换成真实值并从输出中擦除)。
85
+ - 门锁(`lock.*`)、报警(`alarm_control_panel.*`)、摄像头(`camera.*`)、燃气阀(`valve.*` 中的燃气)服务调用前,按铁律 4 每次确认。
86
+
87
+ ### 米家(小米)
88
+ - 首选:小米官方开源的 **ha_xiaomi_home** 路线:用户在小米账号 OAuth 页登录授权,拿到的令牌存 vault;
89
+ 之后经 MIoT 云 HTTP 接口查询和控制设备(人不在家也能用)。如果用户已有 Home Assistant,建议直接在 HA 里装这个集成,再按 HA 接法控制。
90
+ - 备选:**python-miio** 局域网直连(`_miio._udp` 发现,端口 54321)。需要先拿到每台设备的 token(从米家账号导出),
91
+ token 用 `request_secret` 存 vault,每台设备一个 name。
92
+ - OAuth 登录页由用户本人完成;不要索要小米账号密码。
93
+
94
+ ### NAS
95
+ - SMB:`_smb._tcp`(445)。macOS 可 `smbutil view //<user>@<host>` 列共享;挂载用 `mount_smbfs`,查空间用 `df -h <挂载点>`。
96
+ - WebDAV:`_webdav._tcp`,用 `PROPFIND`(`quota-available-bytes` / `quota-used-bytes`)查空间。
97
+ - 密码用 `request_secret`;只读操作优先,删除/覆盖文件另按 `confirm_action`(irreversible)确认。
98
+
99
+ ### 打印机
100
+ - IPP:`_ipp._tcp` / `_ipps._tcp`(631)。`ipptool -tv ipp://<host>/ipp/print get-printer-attributes.test` 查状态与耗材;
101
+ macOS 也可 `lpstat -p`。
102
+
103
+ ### 电视和音箱
104
+ - AirPlay(`_airplay._tcp` / `_raop._tcp`)、Google Cast(`_googlecast._tcp`)、DLNA(SSDP `urn:schemas-upnp-org:device:MediaRenderer:1`)。
105
+ 可用 `pyatv`、`pychromecast`、UPnP AVTransport SOAP 控制播放/音量。
106
+
107
+ ## 3. 接入完成后
108
+
109
+ 固定三步,按顺序,一步都不能少:
110
+
111
+ 1. 做一次只读验证(例如 HA `GET /api/` 返回 `API running.`,NAS 能列出共享)。
112
+ 2. **必须调用** `memory`(`action: "save"`,`file_path: "knowledge/home-devices.md"`,`type: "reference"`)——在第 3 步之前。示例条目:
113
+ `- 客厅 Home Assistant — 类型 home-assistant — http://192.168.1.10:8123 — REST+长期令牌(vault: home/home-assistant/token)`
114
+ 3. memory 返回后,才用一句人话告诉用户接入结果。
115
+
116
+ 以后用户说「把客厅灯关了」「NAS 还剩多少」,先 `memory(recall)` 查已接入设备,再按上面的接法执行。
117
+
118
+ ## 验证状态
119
+
120
+ - Home Assistant REST、SMB 发现:有本地假服务 E2E(`scripts/e2e/home-devices/e2e.mjs`)。
121
+ - 米家 OAuth / MIoT 云、python-miio 直连:**未用真实账号验证**,仅流程说明。