crewly 1.20.131 → 1.20.133

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 (186) hide show
  1. package/config/constants.ts +49 -0
  2. package/config/index.ts +2 -0
  3. package/config/skills/agent/core/find-skill/SKILL.md +58 -0
  4. package/config/skills/agent/core/find-skill/execute.sh +67 -0
  5. package/config/skills/agent/core/find-skill/execute.test.sh +69 -0
  6. package/config/skills/agent/core/generate-pdf/SKILL.md +24 -29
  7. package/config/skills/agent/core/generate-pdf/execute.sh +22 -78
  8. package/config/skills/agent/core/generate-pdf/execute.test.sh +45 -0
  9. package/config/skills/agent/core/install-skill/SKILL.md +67 -0
  10. package/config/skills/agent/core/install-skill/execute.sh +80 -0
  11. package/config/skills/agent/core/install-skill/execute.test.sh +74 -0
  12. package/config/skills/agent/pdf-tools/SKILL.md +103 -0
  13. package/config/skills/agent/pdf-tools/execute.sh +301 -0
  14. package/config/skills/agent/pdf-tools/execute.test.sh +218 -0
  15. package/config/skills/agent/pdf-tools/instructions.md +14 -0
  16. package/config/skills/agent/pdf-tools/pdf_tools.py +268 -0
  17. package/config/skills/agent/pdf-tools/skill.json +103 -0
  18. package/config/skills/agent/pdf-tools/style.css +143 -0
  19. package/config/skills/agent/transcribe-audio/SKILL.md +42 -5
  20. package/config/skills/agent/transcribe-audio/execute.sh +44 -11
  21. package/config/skills/agent/transcribe-audio/execute.test.sh +90 -0
  22. package/config/skills/agent/transcribe-audio/install-whisper-cpp.sh +126 -0
  23. package/config/skills/agent/transcribe-audio/install-whisper-cpp.test.sh +69 -0
  24. package/config/skills/agent/transcribe-audio/skill.json +67 -6
  25. package/config/skills/marketplace-registry.ts +8 -1
  26. package/config/skills/registry.json +337 -3
  27. package/dist/backend/backend/src/constants.d.ts +71 -0
  28. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  29. package/dist/backend/backend/src/constants.js +96 -0
  30. package/dist/backend/backend/src/constants.js.map +1 -1
  31. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts +56 -0
  32. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.d.ts.map +1 -0
  33. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js +114 -0
  34. package/dist/backend/backend/src/controllers/cloud/cloud-device-pairing.controller.js.map +1 -0
  35. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts +25 -0
  36. package/dist/backend/backend/src/controllers/cloud/cloud.controller.d.ts.map +1 -1
  37. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js +73 -55
  38. package/dist/backend/backend/src/controllers/cloud/cloud.controller.js.map +1 -1
  39. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts +3 -0
  40. package/dist/backend/backend/src/controllers/cloud/cloud.routes.d.ts.map +1 -1
  41. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js +9 -0
  42. package/dist/backend/backend/src/controllers/cloud/cloud.routes.js.map +1 -1
  43. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts +49 -0
  44. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.d.ts.map +1 -0
  45. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js +185 -0
  46. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.controller.js.map +1 -0
  47. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts +21 -0
  48. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.d.ts.map +1 -0
  49. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js +29 -0
  50. package/dist/backend/backend/src/controllers/skill-setup/skill-setup.routes.js.map +1 -0
  51. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  52. package/dist/backend/backend/src/routes/api.routes.js +5 -0
  53. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  54. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
  55. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  56. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
  57. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  58. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
  59. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
  60. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
  61. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
  62. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts +115 -0
  63. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.d.ts.map +1 -0
  64. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js +160 -0
  65. package/dist/backend/backend/src/services/cloud/cloud-device-pairing.service.js.map +1 -0
  66. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  67. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +8 -0
  68. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  69. package/dist/backend/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -1
  70. package/dist/backend/backend/src/services/marketplace/marketplace.service.js +11 -3
  71. package/dist/backend/backend/src/services/marketplace/marketplace.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
  73. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
  74. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
  75. package/dist/backend/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
  76. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
  77. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
  78. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
  79. package/dist/backend/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
  80. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
  81. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
  82. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
  83. package/dist/backend/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
  84. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
  85. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
  86. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
  87. package/dist/backend/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
  88. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  89. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
  90. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  91. package/dist/backend/backend/src/types/marketplace.types.d.ts +16 -1
  92. package/dist/backend/backend/src/types/marketplace.types.d.ts.map +1 -1
  93. package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
  94. package/dist/backend/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
  95. package/dist/backend/backend/src/utils/inbound-file-hint.utils.js +78 -0
  96. package/dist/backend/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
  97. package/dist/backend/build-info.json +2 -2
  98. package/dist/backend/config/constants.d.ts +48 -0
  99. package/dist/backend/config/constants.d.ts.map +1 -1
  100. package/dist/backend/config/constants.js +48 -0
  101. package/dist/backend/config/constants.js.map +1 -1
  102. package/dist/backend/config/index.d.ts +2 -2
  103. package/dist/backend/config/index.d.ts.map +1 -1
  104. package/dist/backend/config/index.js +2 -2
  105. package/dist/backend/config/index.js.map +1 -1
  106. package/dist/cli/backend/src/constants.d.ts +71 -0
  107. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  108. package/dist/cli/backend/src/constants.js +96 -0
  109. package/dist/cli/backend/src/constants.js.map +1 -1
  110. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts +14 -0
  111. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  112. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +33 -1
  113. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  114. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts +132 -0
  115. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.d.ts.map +1 -0
  116. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js +228 -0
  117. package/dist/cli/backend/src/services/cloud/cloud-device-pairing.client.js.map +1 -0
  118. package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts +101 -0
  119. package/dist/cli/backend/src/services/marketplace/marketplace.service.d.ts.map +1 -0
  120. package/dist/cli/backend/src/services/marketplace/marketplace.service.js +392 -0
  121. package/dist/cli/backend/src/services/marketplace/marketplace.service.js.map +1 -0
  122. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts +281 -0
  123. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.d.ts.map +1 -0
  124. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js +559 -0
  125. package/dist/cli/backend/src/services/skill-setup/skill-discovery.service.js.map +1 -0
  126. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts +138 -0
  127. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.d.ts.map +1 -0
  128. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js +281 -0
  129. package/dist/cli/backend/src/services/skill-setup/skill-setup-manifest.js.map +1 -0
  130. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts +338 -0
  131. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.d.ts.map +1 -0
  132. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js +761 -0
  133. package/dist/cli/backend/src/services/skill-setup/skill-setup-runner.service.js.map +1 -0
  134. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  135. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +6 -0
  136. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  137. package/dist/cli/backend/src/types/marketplace.types.d.ts +219 -0
  138. package/dist/cli/backend/src/types/marketplace.types.d.ts.map +1 -0
  139. package/dist/cli/backend/src/types/marketplace.types.js +6 -0
  140. package/dist/cli/backend/src/types/marketplace.types.js.map +1 -0
  141. package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts +55 -0
  142. package/dist/cli/backend/src/utils/inbound-file-hint.utils.d.ts.map +1 -0
  143. package/dist/cli/backend/src/utils/inbound-file-hint.utils.js +78 -0
  144. package/dist/cli/backend/src/utils/inbound-file-hint.utils.js.map +1 -0
  145. package/dist/cli/backend/src/utils/package-root.d.ts +15 -0
  146. package/dist/cli/backend/src/utils/package-root.d.ts.map +1 -0
  147. package/dist/cli/backend/src/utils/package-root.js +38 -0
  148. package/dist/cli/backend/src/utils/package-root.js.map +1 -0
  149. package/dist/cli/cli/src/commands/cloud.d.ts +45 -25
  150. package/dist/cli/cli/src/commands/cloud.d.ts.map +1 -1
  151. package/dist/cli/cli/src/commands/cloud.js +153 -47
  152. package/dist/cli/cli/src/commands/cloud.js.map +1 -1
  153. package/dist/cli/cli/src/commands/install.d.ts +13 -1
  154. package/dist/cli/cli/src/commands/install.d.ts.map +1 -1
  155. package/dist/cli/cli/src/commands/install.js +40 -3
  156. package/dist/cli/cli/src/commands/install.js.map +1 -1
  157. package/dist/cli/cli/src/commands/onboard-checklist.js +3 -3
  158. package/dist/cli/cli/src/commands/onboard-checklist.js.map +1 -1
  159. package/dist/cli/cli/src/commands/skills.d.ts +58 -0
  160. package/dist/cli/cli/src/commands/skills.d.ts.map +1 -0
  161. package/dist/cli/cli/src/commands/skills.js +132 -0
  162. package/dist/cli/cli/src/commands/skills.js.map +1 -0
  163. package/dist/cli/cli/src/index.js +18 -7
  164. package/dist/cli/cli/src/index.js.map +1 -1
  165. package/dist/cli/cli/src/utils/archive-creator.d.ts +14 -0
  166. package/dist/cli/cli/src/utils/archive-creator.d.ts.map +1 -1
  167. package/dist/cli/cli/src/utils/archive-creator.js +24 -3
  168. package/dist/cli/cli/src/utils/archive-creator.js.map +1 -1
  169. package/dist/cli/cli/src/utils/cloud-submit.d.ts.map +1 -1
  170. package/dist/cli/cli/src/utils/cloud-submit.js +14 -5
  171. package/dist/cli/cli/src/utils/cloud-submit.js.map +1 -1
  172. package/dist/cli/cli/src/utils/package-validator.d.ts +2 -0
  173. package/dist/cli/cli/src/utils/package-validator.d.ts.map +1 -1
  174. package/dist/cli/cli/src/utils/package-validator.js +16 -0
  175. package/dist/cli/cli/src/utils/package-validator.js.map +1 -1
  176. package/dist/cli/config/constants.d.ts +48 -0
  177. package/dist/cli/config/constants.d.ts.map +1 -1
  178. package/dist/cli/config/constants.js +48 -0
  179. package/dist/cli/config/constants.js.map +1 -1
  180. package/dist/cli/config/index.d.ts +2 -2
  181. package/dist/cli/config/index.d.ts.map +1 -1
  182. package/dist/cli/config/index.js +2 -2
  183. package/dist/cli/config/index.js.map +1 -1
  184. package/frontend/dist/assets/{index-5b98bf77.js → index-e6c403b6.js} +265 -265
  185. package/frontend/dist/index.html +1 -1
  186. package/package.json +1 -1
@@ -0,0 +1,218 @@
1
+ #!/bin/bash
2
+ # Tests for pdf-tools — run with: bash execute.test.sh </dev/null
3
+ # Chrome and WeasyPrint are fakes on an isolated PATH; Markdown uses a stub
4
+ # `markdown` module. Reading uses real pypdf when some Python has it
5
+ # (PDF_TOOLS_TEST_PYTHON, then the pdf-tools venv), else pdftotext, else it
6
+ # checks the needsSetup answer. Nothing is installed. Exit 0 on pass.
7
+ set -u
8
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ EXEC="$HERE/execute.sh"
10
+ PASS=0; FAIL=0; SKIP=0
11
+ check() {
12
+ local name="$1"; local got="$2"; local want="$3"
13
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
14
+ }
15
+ contains() {
16
+ local name="$1"; local hay="$2"; local needle="$3"
17
+ case "$hay" in *"$needle"*) PASS=$((PASS+1)) ;; *) FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " missing: $needle"; echo " in: ${hay:0:400}" ;; esac
18
+ }
19
+
20
+ JQ="$(command -v jq)"; BASH_BIN="${TEST_BASH:-$(command -v bash)}"; SYS_PY="$(command -v python3)"
21
+ [ -n "$JQ" ] && [ -n "$SYS_PY" ] || { echo "jq and python3 are required to run these tests"; exit 1; }
22
+ T="$(mktemp -d)"
23
+ trap 'rm -rf "$T"' EXIT
24
+ mkdir -p "$T/bin" "$T/home" "$T/stub/markdown" "$T/docs/img"
25
+ for tool in bash jq python3 mktemp cat rm tail head dirname mkdir sed tr wc sleep kill id awk grep basename cp uname date; do
26
+ src="$(command -v "$tool" 2>/dev/null)" && ln -sf "$src" "$T/bin/$tool"
27
+ done
28
+
29
+ # A real one-page PDF with the text "Hello Crewly PDF".
30
+ cat > "$T/mkpdf.py" <<'PY'
31
+ import sys
32
+ text = sys.argv[2].encode()
33
+ content = b"BT /F1 18 Tf 20 70 Td (" + text + b") Tj ET"
34
+ objs = [
35
+ b"<< /Type /Catalog /Pages 2 0 R >>",
36
+ b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
37
+ b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
38
+ b"<< /Length %d >>\nstream\n" % len(content) + content + b"\nendstream",
39
+ b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
40
+ ]
41
+ out = b"%PDF-1.4\n"
42
+ offsets = []
43
+ for i, o in enumerate(objs, 1):
44
+ offsets.append(len(out))
45
+ out += b"%d 0 obj\n" % i + o + b"\nendobj\n"
46
+ xref = len(out)
47
+ out += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
48
+ for off in offsets:
49
+ out += b"%010d 00000 n \n" % off
50
+ out += b"trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n" % (len(objs) + 1, xref)
51
+ open(sys.argv[1], "wb").write(out)
52
+ PY
53
+ "$SYS_PY" "$T/mkpdf.py" "$T/fixture.pdf" "Hello Crewly PDF"
54
+
55
+ # Fake Chrome: records its argv, "prints" the fixture to --print-to-pdf.
56
+ cat > "$T/fake-chrome" <<EOF
57
+ #!/bin/bash
58
+ printf '%s\n' "\$@" > "$T/chrome.args"
59
+ for a in "\$@"; do case "\$a" in --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
60
+ EOF
61
+ chmod 755 "$T/fake-chrome"
62
+ # Chrome that crashes without output.
63
+ printf '#!/bin/bash\nexit 1\n' > "$T/broken-chrome"; chmod 755 "$T/broken-chrome"
64
+
65
+ # Stub python-markdown: wraps each line in <p>, enough to prove the pipeline.
66
+ cat > "$T/stub/markdown/__init__.py" <<'PY'
67
+ import html
68
+ def markdown(text, **_kw):
69
+ return "\n".join("<p>%s</p>" % html.escape(line) for line in text.splitlines() if line.strip())
70
+ PY
71
+
72
+ run() {
73
+ env -i HOME="$T/home" PATH="$T/bin" CREWLY_HOME="$T/home/.crewly" PYTHONPATH="$T/stub" \
74
+ PDF_TOOLS_CHROME_PATHS="" PDF_TOOLS_CHROME_TIMEOUT_SEC=10 "$@" "$BASH_BIN" "$EXEC" "$JSON" 2>/dev/null </dev/null
75
+ }
76
+
77
+ printf '# 季度报告\n\nRevenue grew **12%%**.\n\n![chart](img/c.png)\n' > "$T/docs/report.md"
78
+ printf '<html><head><style>h1{color:red}</style></head><body><h1>Brief</h1></body></html>\n' > "$T/docs/brief.html"
79
+
80
+ # 1. Markdown → PDF with (fake) Chrome.
81
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/report.md\",\"title\":\"Q3 报告\"}"
82
+ OUT=$(run CHROME_BIN="$T/fake-chrome")
83
+ check "md: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
84
+ check "md: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "chrome"
85
+ check "md: default output path" "$(printf '%s' "$OUT" | "$JQ" -r .pdf)" "$T/docs/report.pdf"
86
+ check "md: output is a PDF" "$(head -c 4 "$T/docs/report.pdf")" "%PDF"
87
+ ARGS="$(cat "$T/chrome.args" 2>/dev/null)"
88
+ contains "chrome: headless new" "$ARGS" "--headless=new"
89
+ contains "chrome: no header/footer" "$ARGS" "--no-pdf-header-footer"
90
+ contains "chrome: print target" "$ARGS" "--print-to-pdf=$T/docs/report.pdf"
91
+ case "$ARGS" in *--user-data-dir*) FAIL=$((FAIL+1)); echo "FAIL: chrome: no --user-data-dir (it makes Chrome hang on macOS)";; *) PASS=$((PASS+1));; esac
92
+ HTML_URL="$(tail -n 1 "$T/chrome.args")"
93
+ check "chrome: renders a file:// URL" "${HTML_URL:0:7}" "file://"
94
+ check "md: nothing written next to the markdown" "$(ls -A "$T/docs" | tr '\n' ' ')" "brief.html img report.md report.pdf "
95
+
96
+ # The generated HTML (captured by a Chrome that copies it) carries the stylesheet, title and <base>.
97
+ cat > "$T/capture-chrome" <<EOF
98
+ #!/bin/bash
99
+ for a in "\$@"; do case "\$a" in file://*) f="\${a#file://}"; cp "\$(printf '%b' "\${f//%/\\\\x}")" "$T/captured.html" ;; --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
100
+ EOF
101
+ chmod 755 "$T/capture-chrome"
102
+ OUT=$(run CHROME_BIN="$T/capture-chrome")
103
+ CAP="$(cat "$T/captured.html" 2>/dev/null)"
104
+ contains "md html: CJK font stack" "$CAP" '"PingFang SC"'
105
+ contains "md html: A4 page rule" "$CAP" "size: A4"
106
+ contains "md html: title" "$CAP" "<title>Q3 报告</title>"
107
+ contains "md html: base href to the markdown dir" "$CAP" "<base href=\"file://$T/docs/\">"
108
+ contains "md html: body converted" "$CAP" "<p># 季度报告</p>"
109
+
110
+ # Chrome that writes the PDF and then hangs (seen with Chrome 153 on macOS) is
111
+ # stopped once the PDF is complete instead of blocking until the timeout.
112
+ cat > "$T/hanging-chrome" <<EOF
113
+ #!/bin/bash
114
+ for a in "\$@"; do case "\$a" in --print-to-pdf=*) cp "$T/fixture.pdf" "\${a#--print-to-pdf=}" ;; esac; done
115
+ exec sleep 60
116
+ EOF
117
+ chmod 755 "$T/hanging-chrome"
118
+ START=$(date +%s)
119
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/report.md\",\"output\":\"$T/out/hang.pdf\"}"
120
+ OUT=$(run CHROME_BIN="$T/hanging-chrome")
121
+ ELAPSED=$(( $(date +%s) - START ))
122
+ check "hanging chrome: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
123
+ [ "$ELAPSED" -lt 9 ] && OK=yes || OK="no (${ELAPSED}s)"
124
+ check "hanging chrome: stopped after the grace period, not the timeout" "$OK" "yes"
125
+
126
+ # 2. Styled HTML is printed as-is (the original file, not a copy).
127
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/brief.pdf\"}"
128
+ OUT=$(run CHROME_BIN="$T/fake-chrome")
129
+ check "html: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
130
+ check "html: printed the original file" "$(tail -n 1 "$T/chrome.args")" "file://$T/docs/brief.html"
131
+ check "html: output dir created" "$(head -c 4 "$T/out/brief.pdf")" "%PDF"
132
+
133
+ # 3. addDefaultStyle prepends the stylesheet to a copy.
134
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/b2.pdf\",\"addDefaultStyle\":true}"
135
+ OUT=$(run CHROME_BIN="$T/capture-chrome")
136
+ CAP="$(cat "$T/captured.html")"
137
+ contains "addDefaultStyle: stylesheet added" "$CAP" '"PingFang SC"'
138
+ contains "addDefaultStyle: document kept" "$CAP" "h1{color:red}"
139
+
140
+ # 4. engine=chrome with no Chrome → needsSetup.
141
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"engine\":\"chrome\"}"
142
+ OUT=$(run)
143
+ check "no chrome: needsSetup" "$(printf '%s' "$OUT" | "$JQ" -r .needsSetup)" "true"
144
+ check "no chrome: skill" "$(printf '%s' "$OUT" | "$JQ" -r .skill)" "pdf-tools"
145
+ check "no chrome: missing" "$(printf '%s' "$OUT" | "$JQ" -c .missing)" '["chrome"]'
146
+
147
+ # 5. No engine at all → needsSetup naming both engines.
148
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\"}"
149
+ OUT=$(run)
150
+ check "no engine: missing" "$(printf '%s' "$OUT" | "$JQ" -c .missing)" '["chrome","weasyprint-libs"]'
151
+
152
+ # 6. Chrome crashes → WeasyPrint fallback (fake weasyprint CLI).
153
+ printf '#!/bin/bash\ncp "%s" "$2"\n' "$T/fixture.pdf" > "$T/bin/weasyprint"; chmod 755 "$T/bin/weasyprint"
154
+ JSON="{\"action\":\"render\",\"input\":\"$T/docs/brief.html\",\"output\":\"$T/out/w.pdf\"}"
155
+ OUT=$(run CHROME_BIN="$T/broken-chrome")
156
+ check "fallback: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "weasyprint"
157
+ check "fallback: pdf" "$(head -c 4 "$T/out/w.pdf")" "%PDF"
158
+ rm -f "$T/bin/weasyprint"
159
+
160
+ # 7. Bad input.
161
+ JSON='{"action":"render"}'
162
+ check "no input: error" "$(run | "$JQ" -r .error)" "input (a .md/.html file) or inputs (a list of .md/.html/.pdf parts) is required"
163
+ JSON='{"action":"explode"}'
164
+ check "unknown action" "$(run | "$JQ" -r .error)" "unknown action: explode (use render, merge, read, info or check)"
165
+ JSON="{\"action\":\"read\",\"input\":\"$T/docs/report.md\"}"
166
+ check "read non-pdf" "$(run | "$JQ" -r .error)" "not a PDF (or empty): $T/docs/report.md"
167
+
168
+ # 8. Reading + merging with real pypdf when available.
169
+ PYPDF_PY=""
170
+ for cand in "${PDF_TOOLS_TEST_PYTHON:-}" "${CREWLY_HOME:-$HOME/.crewly}/venv/pdf-tools/bin/python3" "$SYS_PY"; do
171
+ [ -n "$cand" ] && [ -x "$cand" ] && "$cand" -c "import pypdf" >/dev/null 2>&1 && { PYPDF_PY="$cand"; break; }
172
+ done
173
+ if [ -n "$PYPDF_PY" ]; then
174
+ JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\"}"
175
+ OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY")
176
+ check "read: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "pypdf"
177
+ check "read: pages" "$(printf '%s' "$OUT" | "$JQ" -r .pages)" "1"
178
+ contains "read: text" "$(printf '%s' "$OUT" | "$JQ" -r .text)" "Hello Crewly PDF"
179
+ JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\",\"maxChars\":5,\"textFile\":\"$T/all.txt\"}"
180
+ OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY")
181
+ check "read: truncated" "$(printf '%s' "$OUT" | "$JQ" -r .truncated)" "true"
182
+ contains "read: full text on disk" "$(cat "$T/all.txt")" "Hello Crewly PDF"
183
+
184
+ "$SYS_PY" "$T/mkpdf.py" "$T/second.pdf" "Second part"
185
+ JSON="{\"action\":\"render\",\"inputs\":[\"$T/docs/brief.html\",\"$T/second.pdf\"],\"output\":\"$T/out/merged.pdf\"}"
186
+ OUT=$(run PDF_TOOLS_PYTHON="$PYPDF_PY" CHROME_BIN="$T/fake-chrome")
187
+ check "multi-part: success" "$(printf '%s' "$OUT" | "$JQ" -r .success)" "true"
188
+ check "multi-part: parts" "$(printf '%s' "$OUT" | "$JQ" -r .parts)" "2"
189
+ JSON="{\"action\":\"info\",\"input\":\"$T/out/merged.pdf\"}"
190
+ check "multi-part: 2 pages" "$(run PDF_TOOLS_PYTHON="$PYPDF_PY" | "$JQ" -r .pages)" "2"
191
+ JSON="{\"action\":\"read\",\"input\":\"$T/out/merged.pdf\",\"pages\":\"2\"}"
192
+ contains "multi-part: order kept" "$(run PDF_TOOLS_PYTHON="$PYPDF_PY" | "$JQ" -r .text)" "Second part"
193
+ else
194
+ SKIP=$((SKIP+1)); echo "SKIP: pypdf not available (set PDF_TOOLS_TEST_PYTHON) — checking the needsSetup answer instead"
195
+ JSON="{\"action\":\"merge\",\"inputs\":[\"$T/fixture.pdf\",\"$T/fixture.pdf\"],\"output\":\"$T/out/m.pdf\"}"
196
+ OUT=$(run)
197
+ check "merge without pypdf: needsSetup" "$(printf '%s' "$OUT" | "$JQ" -r .needsSetup)" "true"
198
+ fi
199
+
200
+ # 9. Reading without pypdf falls back to pdftotext when the machine has it.
201
+ if command -v pdftotext >/dev/null 2>&1; then
202
+ ln -sf "$(command -v pdftotext)" "$T/bin/pdftotext"
203
+ JSON="{\"action\":\"read\",\"input\":\"$T/fixture.pdf\"}"
204
+ OUT=$(run PDF_TOOLS_PYTHON="$SYS_PY")
205
+ if "$SYS_PY" -c "import pypdf" >/dev/null 2>&1; then EXPECT=pypdf; else EXPECT=pdftotext; fi
206
+ check "read fallback: engine" "$(printf '%s' "$OUT" | "$JQ" -r .engine)" "$EXPECT"
207
+ contains "read fallback: text" "$(printf '%s' "$OUT" | "$JQ" -r .text)" "Hello Crewly PDF"
208
+ rm -f "$T/bin/pdftotext"
209
+ fi
210
+
211
+ # 10. check action + the shipped setup block.
212
+ JSON='{"action":"check"}'
213
+ OUT=$(run CHROME_BIN="$T/fake-chrome")
214
+ check "check: canRender" "$(printf '%s' "$OUT" | "$JQ" -r .canRender)" "true"
215
+ check "setup: venv name reuses pdf-tools" "$("$JQ" -r '.setup.steps[] | select(.type=="python") | .venv' "$HERE/skill.json")" "pdf-tools"
216
+
217
+ echo "=== Results: $PASS passed, $FAIL failed, $SKIP skipped ==="
218
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,14 @@
1
+ # pdf-tools — agent instructions
2
+
3
+ - **Making a document someone will read:** write Markdown (or styled HTML when
4
+ layout matters), then `render`. Check the result: `info` for the page count,
5
+ and open or `read` it before sending. Send the file itself (`core/attach-file`),
6
+ not a path.
7
+ - **Someone sent a PDF:** `read` it (with `pages` for long files, `textFile` to
8
+ keep all of it) before answering. If the result has a `note` about no text
9
+ layer, say the PDF is scanned instead of guessing its content.
10
+ - **Several parts** (cover + body + appendix): pass them as `inputs` in order;
11
+ existing PDFs can be parts too.
12
+ - **`needsSetup: true`** in any result: tell the user in one line that you are
13
+ installing the PDF tools (about 3 minutes), run
14
+ `install-skill --id pdf-tools`, and continue when `[SKILL INSTALLED]` arrives.
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """pdf-tools helper — the Python half of the Crewly `pdf-tools` skill.
3
+
4
+ Runs inside the skill's venv ($CREWLY_HOME/venv/pdf-tools). Every command
5
+ prints one JSON object on stdout and exits non-zero on failure.
6
+
7
+ Commands:
8
+ md2html <in.md> <out.html> [--title T] [--css FILE ...] [--lang zh] [--base URL]
9
+ weasyprint <in.html> <out.pdf>
10
+ merge <out.pdf> <in.pdf> [<in.pdf> ...]
11
+ read <in.pdf> [--pages 1-3,5] [--max-chars N] [--text-file OUT.txt]
12
+ info <in.pdf>
13
+
14
+ Written for Crewly (MIT). Uses pypdf, python-markdown and weasyprint.
15
+ """
16
+
17
+ import argparse
18
+ import html
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+
25
+ # Characters of extracted text returned inline before truncating (the rest
26
+ # goes to --text-file when given).
27
+ DEFAULT_MAX_CHARS = 20000
28
+ # Markdown extensions: tables, fenced code, heading ids, sane lists, footnotes.
29
+ MARKDOWN_EXTENSIONS = ["extra", "toc", "sane_lists", "smarty"]
30
+
31
+
32
+ def emit(obj, code=0):
33
+ """Print one JSON object and exit."""
34
+ print(json.dumps(obj, ensure_ascii=False))
35
+ sys.exit(code)
36
+
37
+
38
+ def fail(message, **extra):
39
+ """Print a failure object and exit 1."""
40
+ emit(dict(success=False, error=message, **extra), 1)
41
+
42
+
43
+ def cmd_md2html(args):
44
+ """Convert Markdown to a standalone HTML document with the given stylesheets."""
45
+ try:
46
+ import markdown # noqa: WPS433 — optional dependency, imported on use
47
+ except ImportError:
48
+ fail("python-markdown is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
49
+ with open(args.input, encoding="utf-8") as fh:
50
+ body = markdown.markdown(fh.read(), extensions=MARKDOWN_EXTENSIONS, output_format="html5")
51
+ styles = []
52
+ for css in args.css or []:
53
+ with open(css, encoding="utf-8") as fh:
54
+ styles.append(fh.read())
55
+ title = html.escape(args.title or os.path.splitext(os.path.basename(args.input))[0])
56
+ base = f'<base href="{html.escape(args.base)}">' if args.base else ""
57
+ doc = (
58
+ f'<!DOCTYPE html>\n<html lang="{html.escape(args.lang)}"><head><meta charset="utf-8">{base}'
59
+ f"<title>{title}</title><style>\n" + "\n".join(styles) + "\n</style></head>\n"
60
+ f'<body><main class="doc">\n{body}\n</main></body></html>\n'
61
+ )
62
+ with open(args.output, "w", encoding="utf-8") as fh:
63
+ fh.write(doc)
64
+ emit({"success": True, "html": args.output})
65
+
66
+
67
+ def cmd_weasyprint(args):
68
+ """Render HTML to PDF with WeasyPrint (the fallback engine)."""
69
+ try:
70
+ import weasyprint # noqa: WPS433
71
+ except Exception as exc: # ImportError, or OSError when pango is missing
72
+ fail(f"weasyprint is not usable: {exc}", needsSetup=True, skill="pdf-tools", missing=["weasyprint-libs"])
73
+ base = os.path.dirname(os.path.abspath(args.input))
74
+ weasyprint.HTML(filename=args.input, base_url=base).write_pdf(args.output)
75
+ emit({"success": True, "pdf": args.output})
76
+
77
+
78
+ def load_pypdf():
79
+ """Import pypdf or fail with a needsSetup object."""
80
+ try:
81
+ import pypdf # noqa: WPS433
82
+ return pypdf
83
+ except ImportError:
84
+ return None
85
+
86
+
87
+ def cmd_merge(args):
88
+ """Concatenate PDFs in order."""
89
+ pypdf = load_pypdf()
90
+ if pypdf is None:
91
+ fail("pypdf is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
92
+ writer = pypdf.PdfWriter()
93
+ pages = 0
94
+ for part in args.inputs:
95
+ reader = pypdf.PdfReader(part)
96
+ for page in reader.pages:
97
+ writer.add_page(page)
98
+ pages += 1
99
+ with open(args.output, "wb") as fh:
100
+ writer.write(fh)
101
+ emit({"success": True, "pdf": args.output, "pages": pages, "parts": len(args.inputs)})
102
+
103
+
104
+ def parse_pages(spec, total):
105
+ """Turn '1-3,5' into zero-based page indexes within range."""
106
+ if not spec:
107
+ return list(range(total))
108
+ wanted = []
109
+ for chunk in spec.split(","):
110
+ chunk = chunk.strip()
111
+ if not chunk:
112
+ continue
113
+ if "-" in chunk:
114
+ lo, hi = chunk.split("-", 1)
115
+ start = int(lo) if lo else 1
116
+ end = int(hi) if hi else total
117
+ wanted.extend(range(start, end + 1))
118
+ else:
119
+ wanted.append(int(chunk))
120
+ return [p - 1 for p in wanted if 1 <= p <= total]
121
+
122
+
123
+ def pdftotext_all(path):
124
+ """Extract text per page with poppler's pdftotext (fallback).
125
+
126
+ pdftotext ends every page with a form feed, so splitting on it gives one
127
+ entry per page. Returns None when pdftotext is not installed or fails.
128
+ """
129
+ exe = shutil.which("pdftotext")
130
+ if not exe:
131
+ return None
132
+ out = subprocess.run(
133
+ [exe, "-layout", "-enc", "UTF-8", path, "-"],
134
+ capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL,
135
+ )
136
+ if out.returncode != 0:
137
+ return None
138
+ pages = out.stdout.split("\f")
139
+ if pages and not pages[-1].strip():
140
+ pages = pages[:-1]
141
+ return pages
142
+
143
+
144
+ def cmd_read(args):
145
+ """Extract text from a PDF (pypdf; pdftotext when pypdf finds none)."""
146
+ pypdf = load_pypdf()
147
+ engine = None
148
+ texts = None
149
+ total = None
150
+ indexes = []
151
+ if pypdf is not None:
152
+ reader = pypdf.PdfReader(args.input)
153
+ if reader.is_encrypted:
154
+ try:
155
+ reader.decrypt("")
156
+ except Exception: # noqa: BLE001 — any failure means we cannot read it
157
+ fail("the PDF is encrypted and needs a password")
158
+ total = len(reader.pages)
159
+ indexes = parse_pages(args.pages, total)
160
+ texts = [(reader.pages[i].extract_text() or "") for i in indexes]
161
+ engine = "pypdf"
162
+ if texts is None or not "".join(texts).strip():
163
+ whole = pdftotext_all(args.input)
164
+ if whole is not None:
165
+ total = len(whole)
166
+ fb_indexes = parse_pages(args.pages, total)
167
+ fallback = [whole[i] for i in fb_indexes]
168
+ if texts is None or "".join(fallback).strip():
169
+ texts, engine, indexes = fallback, "pdftotext", fb_indexes
170
+ if texts is None:
171
+ fail("cannot read PDFs yet: neither pypdf nor pdftotext is installed", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
172
+ full = "\n\n".join(f"--- page {i + 1} ---\n{t.strip()}" for i, t in zip(indexes, texts))
173
+ result = {
174
+ "success": True,
175
+ "engine": engine,
176
+ "pages": total,
177
+ "pagesRead": [i + 1 for i in indexes],
178
+ "chars": len(full),
179
+ }
180
+ if not "".join(texts).strip():
181
+ result["note"] = "No extractable text: the PDF is probably scanned images. Use OCR or read the page images."
182
+ if args.text_file:
183
+ with open(args.text_file, "w", encoding="utf-8") as fh:
184
+ fh.write(full)
185
+ result["textFile"] = args.text_file
186
+ if len(full) > args.max_chars:
187
+ result["text"] = full[: args.max_chars]
188
+ result["truncated"] = True
189
+ else:
190
+ result["text"] = full
191
+ result["truncated"] = False
192
+ emit(result)
193
+
194
+
195
+ def pdf_page_count(path):
196
+ """Page count via pdfinfo (poppler) when pypdf is unavailable."""
197
+ exe = shutil.which("pdfinfo")
198
+ if not exe:
199
+ return None
200
+ out = subprocess.run([exe, path], capture_output=True, text=True, check=False, stdin=subprocess.DEVNULL)
201
+ for line in out.stdout.splitlines():
202
+ if line.startswith("Pages:"):
203
+ return int(line.split(":", 1)[1].strip())
204
+ return None
205
+
206
+
207
+ def cmd_info(args):
208
+ """Page count, encryption and document metadata."""
209
+ pypdf = load_pypdf()
210
+ if pypdf is None:
211
+ pages = pdf_page_count(args.input)
212
+ if pages is None:
213
+ fail("pypdf is not installed in the pdf-tools venv", needsSetup=True, skill="pdf-tools", missing=["python-packages"])
214
+ emit({"success": True, "pages": pages, "engine": "pdfinfo"})
215
+ reader = pypdf.PdfReader(args.input)
216
+ meta = {}
217
+ if reader.metadata:
218
+ for key, value in reader.metadata.items():
219
+ meta[str(key).lstrip("/")] = str(value)
220
+ emit({"success": True, "pages": len(reader.pages), "encrypted": reader.is_encrypted, "metadata": meta, "engine": "pypdf"})
221
+
222
+
223
+ def main():
224
+ """Parse arguments and dispatch."""
225
+ parser = argparse.ArgumentParser(prog="pdf_tools.py")
226
+ sub = parser.add_subparsers(dest="command", required=True)
227
+
228
+ p = sub.add_parser("md2html")
229
+ p.add_argument("input")
230
+ p.add_argument("output")
231
+ p.add_argument("--title")
232
+ p.add_argument("--css", action="append")
233
+ p.add_argument("--lang", default="zh")
234
+ p.add_argument("--base", help="<base href> for relative links (file:// URL of the source directory)")
235
+ p.set_defaults(func=cmd_md2html)
236
+
237
+ p = sub.add_parser("weasyprint")
238
+ p.add_argument("input")
239
+ p.add_argument("output")
240
+ p.set_defaults(func=cmd_weasyprint)
241
+
242
+ p = sub.add_parser("merge")
243
+ p.add_argument("output")
244
+ p.add_argument("inputs", nargs="+")
245
+ p.set_defaults(func=cmd_merge)
246
+
247
+ p = sub.add_parser("read")
248
+ p.add_argument("input")
249
+ p.add_argument("--pages")
250
+ p.add_argument("--max-chars", type=int, default=DEFAULT_MAX_CHARS)
251
+ p.add_argument("--text-file")
252
+ p.set_defaults(func=cmd_read)
253
+
254
+ p = sub.add_parser("info")
255
+ p.add_argument("input")
256
+ p.set_defaults(func=cmd_info)
257
+
258
+ args = parser.parse_args()
259
+ try:
260
+ args.func(args)
261
+ except SystemExit:
262
+ raise
263
+ except Exception as exc: # noqa: BLE001 — report every failure as JSON
264
+ fail(f"{type(exc).__name__}: {exc}")
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
@@ -0,0 +1,103 @@
1
+ {
2
+ "id": "pdf-tools",
3
+ "name": "pdf-tools",
4
+ "displayName": "PDF Tools (make + read PDFs)",
5
+ "description": "Make polished PDFs from Markdown or styled HTML (headless Chrome preferred, WeasyPrint fallback, clean CJK-ready default stylesheet), merge several parts into one PDF, and read PDFs a user sends (text extraction with pypdf / pdftotext). Declares its own setup: Python venv with pypdf, markdown and weasyprint, plus optional Chrome and CJK fonts.",
6
+ "category": "content-creation",
7
+ "assignableRoles": ["*"],
8
+ "version": "1.0.0",
9
+ "author": "Crewly Team",
10
+ "license": "MIT",
11
+ "tags": [
12
+ "pdf",
13
+ "document",
14
+ "report",
15
+ "markdown",
16
+ "html",
17
+ "chrome",
18
+ "weasyprint",
19
+ "merge",
20
+ "read-pdf",
21
+ "extract-text",
22
+ "pypdf",
23
+ "cjk",
24
+ "中文",
25
+ "文档"
26
+ ],
27
+ "triggers": [
28
+ "make a pdf",
29
+ "generate pdf",
30
+ "markdown to pdf",
31
+ "html to pdf",
32
+ "merge pdfs",
33
+ "read this pdf",
34
+ "extract text from pdf"
35
+ ],
36
+ "execution": {
37
+ "type": "script",
38
+ "script": { "file": "execute.sh", "interpreter": "bash", "timeoutMs": 300000 }
39
+ },
40
+ "setup": {
41
+ "estimatedMinutes": 3,
42
+ "steps": [
43
+ {
44
+ "id": "jq",
45
+ "type": "command",
46
+ "description": "jq — parses the skill's JSON input and output",
47
+ "check": { "commands": ["jq"] },
48
+ "install": { "darwin": { "brew": ["jq"] }, "debian": { "apt": ["jq"] } }
49
+ },
50
+ {
51
+ "id": "python3",
52
+ "type": "command",
53
+ "description": "Python 3 with venv support",
54
+ "check": { "shell": "python3 -c 'import venv, ensurepip' >/dev/null 2>&1" },
55
+ "install": { "darwin": { "brew": ["python"] }, "debian": { "apt": ["python3", "python3-venv"] } },
56
+ "manualHint": "Install Python 3 (with the venv module) and put python3 on PATH."
57
+ },
58
+ {
59
+ "id": "python-packages",
60
+ "type": "python",
61
+ "description": "pypdf (merge/read), markdown and weasyprint in ~/.crewly/venv/pdf-tools",
62
+ "venv": "pdf-tools",
63
+ "packages": ["pypdf>=4", "markdown>=3.5", "weasyprint>=60"],
64
+ "imports": ["pypdf", "markdown"]
65
+ },
66
+ {
67
+ "id": "weasyprint-libs",
68
+ "type": "command",
69
+ "optional": true,
70
+ "description": "Pango for WeasyPrint (the fallback engine when there is no Chrome)",
71
+ "check": { "shell": "\"$CREWLY_HOME/venv/pdf-tools/bin/python3\" -c 'import weasyprint' >/dev/null 2>&1" },
72
+ "install": {
73
+ "darwin": { "brew": ["pango"] },
74
+ "debian": { "apt": ["libpango-1.0-0", "libpangoft2-1.0-0", "libharfbuzz-subset0"] }
75
+ }
76
+ },
77
+ {
78
+ "id": "chrome",
79
+ "type": "command",
80
+ "optional": true,
81
+ "description": "Chrome/Chromium — the preferred engine (headless print-to-pdf)",
82
+ "check": {
83
+ "commands": ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"],
84
+ "paths": [
85
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
86
+ "~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
87
+ "/Applications/Chromium.app/Contents/MacOS/Chromium"
88
+ ]
89
+ },
90
+ "install": { "debian": { "apt": ["chromium"] } },
91
+ "manualHint": "Install Google Chrome; until then PDFs are rendered with WeasyPrint."
92
+ },
93
+ {
94
+ "id": "cjk-fonts",
95
+ "type": "command",
96
+ "optional": true,
97
+ "description": "Chinese/Japanese/Korean fonts (Noto CJK) so CJK text is not blank boxes",
98
+ "check": { "shell": "[ \"$(uname -s)\" = Darwin ] || fc-list :lang=zh 2>/dev/null | grep -q ." },
99
+ "install": { "debian": { "apt": ["fonts-noto-cjk"] } }
100
+ }
101
+ ]
102
+ }
103
+ }