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,80 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # install-skill — install a skill and its dependencies as a background job.
4
+ #
5
+ # Returns at once with a job id (or "already-ready"). When the job ends you get
6
+ # a message: [SKILL INSTALLED] … or [SKILL INSTALL FAILED] … with the reason.
7
+ # Tell the user in one line that you are installing it, then continue with
8
+ # other work or wait; resume the paused task when the message arrives.
9
+ # Backed by POST /api/skill-setup/install.
10
+ #
11
+ # Only official skills (bundled with Crewly, or published by the Crewly Team in
12
+ # the official registry) install without asking. A third-party skill needs the
13
+ # owner's yes in chat first; then pass --approved-by-owner (it is checked
14
+ # against the owner's chat history — claiming it without their yes fails).
15
+ #
16
+ # Usage:
17
+ # bash execute.sh --id transcribe-audio --resume "transcribe clip.m4a for Steve"
18
+ # bash execute.sh --id some-skill --approved-by-owner --owner-said "好的,装 some-skill"
19
+ # bash execute.sh '{"id":"pdf-tools","resume":"…"}'
20
+ # =============================================================================
21
+ set -euo pipefail
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ source "${SCRIPT_DIR}/../../_common/lib.sh"
24
+
25
+ print_usage() {
26
+ cat <<'EOF_USAGE'
27
+ Usage:
28
+ bash execute.sh --id <skill-id> [--resume "what you were doing"] [--approved-by-owner --owner-said "…"] [--force]
29
+ bash execute.sh '{"id":"…","resume":"…","approvedByOwner":false}'
30
+
31
+ Options:
32
+ --id Skill id from find-skill (required)
33
+ --resume What you were doing; echoed back in the completion message
34
+ --approved-by-owner The owner said yes in chat (third-party skills only; verified)
35
+ --owner-said Quote the owner's yes (recorded next to the verified message)
36
+ --force Re-run setup even if everything looks installed
37
+ --help | -h Show this help
38
+ EOF_USAGE
39
+ }
40
+
41
+ INPUT_JSON=""; ID=""; RESUME=""; APPROVED=false; FORCE=false; OWNER_SAID=""
42
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then INPUT_JSON="$1"; shift || true; fi
43
+ while [[ $# -gt 0 ]]; do
44
+ case "$1" in
45
+ --id) [ $# -ge 2 ] || error_exit "--id requires a value"; ID="$2"; shift 2 ;;
46
+ --resume) [ $# -ge 2 ] || error_exit "--resume requires a value"; RESUME="$2"; shift 2 ;;
47
+ --approved-by-owner) APPROVED=true; shift ;;
48
+ --owner-said) [ $# -ge 2 ] || error_exit "--owner-said requires a value"; OWNER_SAID="$2"; shift 2 ;;
49
+ --force) FORCE=true; shift ;;
50
+ --help|-h) print_usage; exit 0 ;;
51
+ *) error_exit "Unknown option: $1" ;;
52
+ esac
53
+ done
54
+ if [ -n "$INPUT_JSON" ]; then
55
+ INPUT=$(read_json_input "$INPUT_JSON")
56
+ [ -z "$ID" ] && ID=$(printf '%s' "$INPUT" | jq -r '.id // empty')
57
+ [ -z "$RESUME" ] && RESUME=$(printf '%s' "$INPUT" | jq -r '.resume // empty')
58
+ [ "$(printf '%s' "$INPUT" | jq -r '.approvedByOwner // false')" = "true" ] && APPROVED=true
59
+ [ "$(printf '%s' "$INPUT" | jq -r '.force // false')" = "true" ] && FORCE=true
60
+ fi
61
+ require_param "id (--id)" "$ID"
62
+ # The quote travels as X-Agent-Authorization (lib.sh encodes it): recorded, never trusted.
63
+ [ -n "$OWNER_SAID" ] && export CREWLY_AGENT_AUTHORIZATION="$OWNER_SAID"
64
+
65
+ BODY=$(jq -cn --arg id "$ID" --arg resume "$RESUME" --argjson approved "$APPROVED" --argjson force "$FORCE" \
66
+ '{id: $id} + (if $resume != "" then {resume: $resume} else {} end)
67
+ + (if $approved then {approvedByOwner: true} else {} end) + (if $force then {force: true} else {} end)')
68
+
69
+ ERR_FILE=$(mktemp); trap 'rm -f "$ERR_FILE"' EXIT
70
+ RESPONSE=$(api_call POST "/skill-setup/install" "$BODY" 2>"$ERR_FILE") || {
71
+ ERR=$(tail -n 1 "$ERR_FILE")
72
+ printf '%s' "$ERR" | jq -c '{success: false,
73
+ reason: (.details.code // "install_failed"),
74
+ error: (.details.error // .error // "install failed")}
75
+ + (if (.details.code // "") | startswith("owner_approval") then {official: false, next: "Ask the owner in chat before installing this third-party skill."} else {} end)' 2>/dev/null \
76
+ || jq -cn --arg e "$ERR" '{success: false, reason: "install_failed", error: $e}'
77
+ exit 1
78
+ }
79
+
80
+ printf '%s' "$RESPONSE" | jq -c '.data | {success: true} + .'
@@ -0,0 +1,74 @@
1
+ #!/bin/bash
2
+ # Tests for install-skill — run with: bash execute.test.sh </dev/null
3
+ # A python HTTP stub plays the backend; asserts the request body, the
4
+ # X-Agent-Authorization citation and how refusals are reported.
5
+ set -u
6
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ EXEC="$HERE/execute.sh"
8
+ PASS=0; FAIL=0
9
+ check() {
10
+ local name="$1"; local got="$2"; local want="$3"
11
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
12
+ }
13
+
14
+ PORT=18852
15
+ STUB_LOG="$(mktemp)"; export STUB_LOG; export STUB_PORT="$PORT"
16
+ python3 -u <<'PY' >/dev/null 2>&1 &
17
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
+ import base64, json, os
19
+ LOG = os.environ['STUB_LOG']; PORT = int(os.environ['STUB_PORT'])
20
+ class H(BaseHTTPRequestHandler):
21
+ def reply(self, code, body):
22
+ self.send_response(code); self.send_header('Content-Type', 'application/json'); self.end_headers()
23
+ self.wfile.write(json.dumps(body).encode())
24
+ def do_POST(self):
25
+ body = json.loads(self.rfile.read(int(self.headers.get('Content-Length', 0))) or b'{}')
26
+ auth = self.headers.get('X-Agent-Authorization')
27
+ if auth and auth.startswith('b64:'):
28
+ auth = base64.b64decode(auth[4:]).decode()
29
+ open(LOG, 'w').write(json.dumps({'path': self.path, 'body': body, 'agent': self.headers.get('X-Agent-Session'), 'auth': auth}))
30
+ if body['id'] == 'shady-ocr' and not body.get('approvedByOwner'):
31
+ return self.reply(403, {'success': False, 'code': 'owner_approval_required', 'error': '"shady-ocr" is a third-party skill. Ask the owner in chat…'})
32
+ if body['id'] == 'ready-one':
33
+ return self.reply(200, {'success': True, 'data': {'state': 'already-ready', 'skillId': 'ready-one', 'executePath': '/x/execute.sh', 'next': 'use it now'}})
34
+ self.reply(202, {'success': True, 'data': {'jobId': 'job1', 'skillId': body['id'], 'state': 'running', 'official': True,
35
+ 'estimatedMinutes': 6, 'willNotify': ['test-agent'], 'next': 'Installing…'}})
36
+ def log_message(self, *a, **k): pass
37
+ HTTPServer(('127.0.0.1', PORT), H).serve_forever()
38
+ PY
39
+ STUB_PID=$!
40
+ disown "$STUB_PID" 2>/dev/null || true
41
+ trap 'kill "$STUB_PID" >/dev/null 2>&1; rm -f "$STUB_LOG"' EXIT
42
+ for i in $(seq 1 30); do curl -s -o /dev/null -m 0.2 "http://127.0.0.1:${PORT}/" 2>/dev/null && break; sleep 0.1; done
43
+
44
+ run() { CREWLY_SESSION_NAME=test-agent CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>/dev/null; }
45
+ run_err() { CREWLY_SESSION_NAME=test-agent CREWLY_API_URL="http://127.0.0.1:${PORT}" bash "$EXEC" "$@" 2>&1 >/dev/null | jq -rs 'last.error' 2>/dev/null; }
46
+
47
+ OUT=$(run --id transcribe-audio --resume "transcribe clip.m4a for Steve")
48
+ check "path" "$(jq -r .path "$STUB_LOG")" "/api/skill-setup/install"
49
+ check "body" "$(jq -c .body "$STUB_LOG")" '{"id":"transcribe-audio","resume":"transcribe clip.m4a for Steve"}'
50
+ check "agent header (who gets the completion message)" "$(jq -r .agent "$STUB_LOG")" "test-agent"
51
+ check "no citation by default" "$(jq -r .auth "$STUB_LOG")" "null"
52
+ check "job returned at once" "$(printf '%s' "$OUT" | jq -c '{success, jobId, state, estimatedMinutes}')" '{"success":true,"jobId":"job1","state":"running","estimatedMinutes":6}'
53
+
54
+ OUT=$(run --id shady-ocr); RC=$?
55
+ check "third-party: exit 1" "$RC" "1"
56
+ check "third-party: reason" "$(printf '%s' "$OUT" | jq -r .reason)" "owner_approval_required"
57
+ check "third-party: next says ask the owner" "$(printf '%s' "$OUT" | jq -r .next)" "Ask the owner in chat before installing this third-party skill."
58
+
59
+ OUT=$(run --id shady-ocr --approved-by-owner --owner-said "好的,装 shady-ocr")
60
+ check "approved: flag sent" "$(jq -c .body "$STUB_LOG")" '{"id":"shady-ocr","approvedByOwner":true}'
61
+ check "approved: owner quote travels as X-Agent-Authorization" "$(jq -r .auth "$STUB_LOG")" "好的,装 shady-ocr"
62
+ check "approved: job" "$(printf '%s' "$OUT" | jq -r .jobId)" "job1"
63
+
64
+ OUT=$(run '{"id":"pdf-tools","force":true}')
65
+ check "json input" "$(jq -c .body "$STUB_LOG")" '{"id":"pdf-tools","force":true}'
66
+
67
+ OUT=$(run --id ready-one)
68
+ check "already ready" "$(printf '%s' "$OUT" | jq -c '{state, executePath}')" '{"state":"already-ready","executePath":"/x/execute.sh"}'
69
+
70
+ check "missing id" "$(run_err)" "Missing required parameter: id (--id)"
71
+ check "unknown option" "$(run_err --yes)" "Unknown option: --yes"
72
+
73
+ echo "=== Results: $PASS passed, $FAIL failed ==="
74
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,103 @@
1
+ ---
2
+ name: pdf-tools
3
+ description: Make polished PDFs from Markdown or styled HTML (headless Chrome preferred, WeasyPrint fallback, CJK-ready default stylesheet), merge several parts into one PDF, and read the text of a PDF someone sent you. When a dependency is missing it returns `needsSetup:true` — run `install-skill --id pdf-tools`.
4
+ category: content-creation
5
+ assignableRoles:
6
+ - "*"
7
+ version: "1.0.0"
8
+ author: Crewly Team
9
+ tags:
10
+ - pdf
11
+ - document
12
+ - report
13
+ - markdown
14
+ - html
15
+ - chrome
16
+ - weasyprint
17
+ - merge
18
+ - read-pdf
19
+ - extract-text
20
+ - pypdf
21
+ - cjk
22
+ - 中文
23
+ - 文档
24
+ triggers:
25
+ - make a pdf
26
+ - generate pdf
27
+ - markdown to pdf
28
+ - html to pdf
29
+ - merge pdfs
30
+ - read this pdf
31
+ - extract text from pdf
32
+ execution:
33
+ type: script
34
+ script:
35
+ file: execute.sh
36
+ interpreter: bash
37
+ timeoutMs: 300000
38
+ ---
39
+
40
+ # PDF Tools
41
+
42
+ Make PDFs people like to read, and read the PDFs people send you.
43
+
44
+ ## Make a PDF (`render`)
45
+
46
+ ```bash
47
+ # Markdown → PDF with the default A4 stylesheet (CJK fonts, tables, code, quotes)
48
+ bash {{AGENT_SKILLS_PATH}}/pdf-tools/execute.sh '{"action":"render","input":"/tmp/report.md","output":"/tmp/report.pdf","title":"Q3 报告"}'
49
+
50
+ # Your own styled HTML → PDF (kept exactly as you styled it)
51
+ bash {{AGENT_SKILLS_PATH}}/pdf-tools/execute.sh '{"action":"render","input":"/tmp/brief.html","output":"/tmp/brief.pdf"}'
52
+
53
+ # Several parts → one PDF, in order (.md / .html / .pdf)
54
+ bash {{AGENT_SKILLS_PATH}}/pdf-tools/execute.sh '{"action":"render","inputs":["/tmp/cover.html","/tmp/body.md","/tmp/appendix.pdf"],"output":"/tmp/full.pdf"}'
55
+ ```
56
+
57
+ | Field | Default | Meaning |
58
+ |---|---|---|
59
+ | `input` / `inputs` | — | One file, or a list of parts merged in order |
60
+ | `output` | input path with `.pdf` | Where the PDF goes (required for several parts) |
61
+ | `title` | file name | Document title (Markdown only) |
62
+ | `css` | — | Extra stylesheet appended after the default one |
63
+ | `addDefaultStyle` | `false` | Also apply the default stylesheet to HTML input |
64
+ | `engine` | `auto` | `auto` (Chrome, else WeasyPrint), `chrome`, `weasyprint` |
65
+ | `lang` | `zh` | `lang` attribute of the generated HTML (font selection) |
66
+
67
+ **Engines.** Headless Chrome/Chromium renders exactly what a browser shows
68
+ (modern CSS, web fonts, CJK with system fonts) and is used whenever it is
69
+ installed. WeasyPrint is the fallback. Markdown goes through python-markdown
70
+ (or pandoc when the venv is missing).
71
+
72
+ **Writing HTML for print.** Use `@page { size: A4; margin: 18mm }`, and
73
+ `<div class="page-break"></div>` (default stylesheet) or `break-after: page` to
74
+ start a new page. For a cover page, render it as its own part and list it first.
75
+
76
+ Output: `{"success":true,"pdf":"/tmp/report.pdf","size":48213,"parts":1,"engine":"chrome"}`
77
+
78
+ ## Read a PDF (`read`)
79
+
80
+ ```bash
81
+ bash {{AGENT_SKILLS_PATH}}/pdf-tools/execute.sh '{"action":"read","input":"/path/to/file.pdf"}'
82
+ bash {{AGENT_SKILLS_PATH}}/pdf-tools/execute.sh '{"action":"read","input":"/path/to/file.pdf","pages":"1-3,7","textFile":"/tmp/file.txt"}'
83
+ ```
84
+
85
+ Returns `{success, engine, pages, pagesRead, chars, text, truncated, textFile?}`.
86
+ Text is split with `--- page N ---` markers. Long documents are cut at
87
+ `maxChars` (default 20000) — pass `textFile` to get everything on disk. A `note`
88
+ says when the PDF has no text layer (scanned images).
89
+
90
+ ## Other actions
91
+
92
+ - `merge` — `{"action":"merge","inputs":["a.pdf","b.pdf"],"output":"ab.pdf"}`
93
+ - `info` — `{"action":"info","input":"x.pdf"}` → pages, metadata, encrypted
94
+ - `check` — which engines exist here (`canRender`, `canRead`, `canMerge`)
95
+
96
+ ## Setup
97
+
98
+ Declared in `skill.json` → `setup`; `install-skill --id pdf-tools` (agents) or
99
+ `crewly skills setup pdf-tools` (terminal) installs what is missing:
100
+ Python venv `~/.crewly/venv/pdf-tools` with pypdf, markdown and weasyprint;
101
+ optionally Chrome (Linux: `chromium`), Pango for WeasyPrint and Noto CJK fonts.
102
+ A missing dependency fails with `"needsSetup": true, "skill": "pdf-tools"` —
103
+ tell the user you are setting it up and run `install-skill`, don't give up.
@@ -0,0 +1,301 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # pdf-tools — make and read PDFs.
4
+ #
5
+ # render : Markdown / HTML (one or several parts) → one PDF.
6
+ # Engine: headless Chrome/Chromium (preferred) → WeasyPrint (fallback).
7
+ # Markdown gets a clean A4 stylesheet with CJK fonts (style.css).
8
+ # merge : concatenate PDFs (pypdf).
9
+ # read : extract text from a PDF (pypdf, falls back to pdftotext).
10
+ # info : page count + metadata.
11
+ # check : which engines are available here.
12
+ #
13
+ # Usage:
14
+ # bash execute.sh '{"action":"render","input":"/tmp/report.md","output":"/tmp/report.pdf","title":"Q3 报告"}'
15
+ # bash execute.sh '{"action":"render","inputs":["cover.html","body.md","appendix.pdf"],"output":"/tmp/all.pdf"}'
16
+ # bash execute.sh '{"action":"read","input":"/tmp/contract.pdf","pages":"1-3"}'
17
+ # bash execute.sh '{"action":"merge","inputs":["a.pdf","b.pdf"],"output":"/tmp/ab.pdf"}'
18
+ # bash execute.sh --file /tmp/input.json (JSON in a file)
19
+ #
20
+ # Dependencies are declared in skill.json → setup (install-skill --id pdf-tools).
21
+ # A missing dependency fails with "needsSetup": true, "skill": "pdf-tools".
22
+ #
23
+ # Test hooks: PDF_TOOLS_PYTHON (interpreter), CHROME_BIN (browser),
24
+ # PDF_TOOLS_CHROME_PATHS (browser paths searched before PATH, colon separated).
25
+ # =============================================================================
26
+ set -euo pipefail
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ SKILL_ID="pdf-tools"
29
+ HELPER="${SCRIPT_DIR}/pdf_tools.py"
30
+ DEFAULT_CSS="${SCRIPT_DIR}/style.css"
31
+ CREWLY_HOME_DIR="${CREWLY_HOME:-${HOME}/.crewly}"
32
+ VENV_PYTHON="${CREWLY_HOME_DIR}/venv/${SKILL_ID}/bin/python3"
33
+ CHROME_TIMEOUT_SEC="${PDF_TOOLS_CHROME_TIMEOUT_SEC:-120}"
34
+ CHROME_EXIT_GRACE_SEC=3
35
+ CHROME_APP_PATHS="${PDF_TOOLS_CHROME_PATHS-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome:${HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome:/Applications/Chromium.app/Contents/MacOS/Chromium:/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge}"
36
+ CHROME_COMMANDS="google-chrome google-chrome-stable chromium chromium-browser microsoft-edge"
37
+
38
+ json_str() { printf '%s' "$1" | jq -Rsa .; }
39
+ err_json() { printf '{"success":false,"error":%s}\n' "$(json_str "$1")"; exit 1; }
40
+ needs_setup() {
41
+ # needs_setup <message> <missing...>
42
+ local msg="$1"; shift
43
+ jq -cn --arg e "$msg" --arg s "$SKILL_ID" --args '{success:false,error:$e,needsSetup:true,skill:$s,missing:$ARGS.positional,
44
+ hint:("Run install-skill --id " + $s + " (installs in the background and messages you when done), then retry.")}' "$@"
45
+ exit 1
46
+ }
47
+
48
+ command -v jq >/dev/null 2>&1 || {
49
+ printf '{"success":false,"error":"jq is required but not installed","needsSetup":true,"skill":"%s","missing":["jq"]}\n' "$SKILL_ID"; exit 1; }
50
+
51
+ # ── Input: JSON argument, --file <path>, or stdin ────────────────────────────────
52
+ INPUT=""
53
+ if [ "${1:-}" = "--file" ] && [ -n "${2:-}" ]; then
54
+ [ -f "$2" ] || err_json "File not found: $2"
55
+ INPUT="$(cat "$2")"
56
+ elif [ -n "${1:-}" ]; then
57
+ INPUT="$1"
58
+ elif [ ! -t 0 ]; then
59
+ INPUT="$(cat)"
60
+ fi
61
+ [ -z "$INPUT" ] && err_json "Usage: execute.sh '{\"action\":\"render\",\"input\":\"/tmp/report.md\"}' (actions: render, merge, read, info, check)"
62
+ printf '%s' "$INPUT" | jq -e 'type == "object"' >/dev/null 2>&1 || err_json "input must be a JSON object"
63
+
64
+ field() { printf '%s' "$INPUT" | jq -r "$1 // empty"; }
65
+ ACTION="$(field .action)"; [ -z "$ACTION" ] && ACTION="render"
66
+
67
+ WORK="$(mktemp -d "${TMPDIR:-/tmp}/pdf-tools-XXXXXX")"
68
+ trap 'rm -rf "$WORK"' EXIT
69
+
70
+ # ── Tool discovery ────────────────────────────────────────────────────────────────
71
+ # Python with the skill's packages: override → skill venv → system python3
72
+ # (stdlib only; enough for `read` via pdftotext).
73
+ python_bin() {
74
+ if [ -n "${PDF_TOOLS_PYTHON:-}" ]; then echo "$PDF_TOOLS_PYTHON"; return; fi
75
+ if [ -x "$VENV_PYTHON" ]; then echo "$VENV_PYTHON"; return; fi
76
+ command -v python3 2>/dev/null || true
77
+ }
78
+ py_has() { local py; py="$(python_bin)"; [ -n "$py" ] && "$py" -c "import $1" >/dev/null 2>&1; }
79
+
80
+ find_chrome() {
81
+ if [ -n "${CHROME_BIN:-}" ]; then [ -x "$CHROME_BIN" ] && echo "$CHROME_BIN"; return; fi
82
+ local IFS=':' p c
83
+ for p in $CHROME_APP_PATHS; do [ -n "$p" ] && [ -x "$p" ] && { echo "$p"; return; }; done
84
+ IFS=' '
85
+ for c in $CHROME_COMMANDS; do command -v "$c" >/dev/null 2>&1 && { command -v "$c"; return; }; done
86
+ }
87
+
88
+ weasy_available() { py_has weasyprint || command -v weasyprint >/dev/null 2>&1; }
89
+
90
+ file_url() { printf 'file://%s' "$(printf '%s' "$1" | jq -sRr @uri | sed 's/%2F/\//g')"; }
91
+
92
+ abs_path() { case "$1" in /*) printf '%s' "$1" ;; *) printf '%s/%s' "$(pwd)" "$1" ;; esac; }
93
+
94
+ is_pdf() { [ -s "$1" ] && [ "$(head -c 4 "$1")" = "%PDF" ]; }
95
+
96
+ # ── Engines ───────────────────────────────────────────────────────────────────────
97
+ # A PDF Chrome has finished writing: starts with %PDF, ends with %%EOF.
98
+ pdf_complete() { is_pdf "$1" && tail -c 64 "$1" | grep -q '%%EOF'; }
99
+
100
+ # Headless print. No --user-data-dir: with a fresh profile dir Chrome 15x on
101
+ # macOS writes the PDF and then never exits. As a guard against any other
102
+ # hang, once the PDF is complete Chrome gets CHROME_EXIT_GRACE_SEC to exit
103
+ # before it is stopped; CHROME_TIMEOUT_SEC bounds the whole run.
104
+ render_chrome() {
105
+ local html="$1" out="$2" chrome="$3"
106
+ local args=(--headless=new --disable-gpu --no-pdf-header-footer --no-first-run --no-default-browser-check
107
+ --disable-extensions --run-all-compositor-stages-before-draw "--print-to-pdf=${out}")
108
+ # Chrome refuses to run as root without this (servers / containers).
109
+ [ "$(id -u)" = "0" ] && args+=(--no-sandbox)
110
+ rm -f "$out"
111
+ "$chrome" "${args[@]}" "$(file_url "$html")" </dev/null >"${WORK}/cmd.out" 2>&1 &
112
+ local pid=$! waited=0 done_at=-1
113
+ while kill -0 "$pid" 2>/dev/null; do
114
+ if [ "$done_at" -lt 0 ] && pdf_complete "$out"; then done_at=$waited; fi
115
+ if [ "$waited" -ge "$((CHROME_TIMEOUT_SEC * 2))" ] || { [ "$done_at" -ge 0 ] && [ "$((waited - done_at))" -ge "$((CHROME_EXIT_GRACE_SEC * 2))" ]; }; then
116
+ kill -9 "$pid" 2>/dev/null || true
117
+ break
118
+ fi
119
+ sleep 0.5
120
+ waited=$((waited + 1))
121
+ done
122
+ wait "$pid" 2>/dev/null || true
123
+ pdf_complete "$out"
124
+ }
125
+
126
+ render_weasy() {
127
+ local html="$1" out="$2" py
128
+ py="$(python_bin)"
129
+ if [ -n "$py" ] && "$py" -c "import weasyprint" >/dev/null 2>&1; then
130
+ "$py" "$HELPER" weasyprint "$html" "$out" >"${WORK}/weasy.out" 2>&1 || true
131
+ elif command -v weasyprint >/dev/null 2>&1; then
132
+ weasyprint "$html" "$out" >"${WORK}/weasy.out" 2>&1 || true
133
+ fi
134
+ is_pdf "$out"
135
+ }
136
+
137
+ # html_to_pdf <html> <out> → sets USED_ENGINE
138
+ USED_ENGINE=""
139
+ html_to_pdf() {
140
+ local html="$1" out="$2" engine="$3" chrome
141
+ chrome="$(find_chrome || true)"
142
+ if [ "$engine" != "weasyprint" ] && [ -n "$chrome" ]; then
143
+ if render_chrome "$html" "$out" "$chrome"; then USED_ENGINE="chrome"; return 0; fi
144
+ [ "$engine" = "chrome" ] && err_json "Chrome failed to print ${html}: $(tail -c 300 "${WORK}/cmd.out" 2>/dev/null)"
145
+ echo '{"status":"fallback","message":"Chrome failed; trying WeasyPrint"}' >&2
146
+ elif [ "$engine" = "chrome" ]; then
147
+ needs_setup "engine=chrome requested but no Chrome/Chromium was found (set CHROME_BIN or install Chrome)" chrome
148
+ fi
149
+ if weasy_available; then
150
+ if render_weasy "$html" "$out"; then USED_ENGINE="weasyprint"; return 0; fi
151
+ err_json "WeasyPrint failed to render ${html}: $(tail -c 400 "${WORK}/weasy.out" 2>/dev/null)"
152
+ fi
153
+ needs_setup "No PDF engine is available: no Chrome/Chromium and no working WeasyPrint" chrome weasyprint-libs
154
+ }
155
+
156
+ # md_to_html <md> <html> <title> — relative images resolve from the Markdown's
157
+ # own directory through a <base href>, so nothing is written next to it.
158
+ md_to_html() {
159
+ local md="$1" html="$2" title="$3" py base
160
+ base="$(file_url "$(dirname "$md")")/"
161
+ local args=("$HELPER" md2html "$md" "$html" --lang "$LANG_ATTR" --base "$base")
162
+ [ -n "$title" ] && args+=(--title "$title")
163
+ [ -f "$DEFAULT_CSS" ] && args+=(--css "$DEFAULT_CSS")
164
+ [ -n "$EXTRA_CSS" ] && args+=(--css "$EXTRA_CSS")
165
+ py="$(python_bin)"
166
+ if [ -n "$py" ] && "$py" -c "import markdown" >/dev/null 2>&1; then
167
+ "$py" "${args[@]}" >"${WORK}/md.out" 2>&1 || err_json "Markdown conversion failed: $(tail -c 300 "${WORK}/md.out")"
168
+ elif command -v pandoc >/dev/null 2>&1; then
169
+ local body; body="$(pandoc -f gfm -t html "$md")" || err_json "pandoc failed on ${md}"
170
+ {
171
+ printf '<!DOCTYPE html>\n<html lang="%s"><head><meta charset="utf-8"><base href="%s"><title>%s</title><style>\n' "$LANG_ATTR" "$base" "$title"
172
+ [ -f "$DEFAULT_CSS" ] && cat "$DEFAULT_CSS"
173
+ [ -n "$EXTRA_CSS" ] && cat "$EXTRA_CSS"
174
+ printf '\n</style></head>\n<body><main class="doc">\n%s\n</main></body></html>\n' "$body"
175
+ } > "$html"
176
+ else
177
+ needs_setup "Markdown needs python-markdown (pdf-tools venv) or pandoc" python-packages
178
+ fi
179
+ }
180
+
181
+ # Copy an HTML file into the work dir with the default stylesheet (and the
182
+ # optional extra css) added first, so the document's own rules still win.
183
+ # A <base href> keeps its relative links pointing at the original directory.
184
+ style_html() {
185
+ local src="$1" dest="$2"
186
+ {
187
+ printf '<base href="%s/">\n<style>\n' "$(file_url "$(dirname "$src")")"
188
+ cat "$DEFAULT_CSS"
189
+ [ -n "$EXTRA_CSS" ] && cat "$EXTRA_CSS"
190
+ printf '\n</style>\n'
191
+ cat "$src"
192
+ } > "$dest"
193
+ }
194
+
195
+ run_helper() {
196
+ local py; py="$(python_bin)"
197
+ [ -n "$py" ] || needs_setup "python3 is not installed" python3
198
+ "$py" "$HELPER" "$@"
199
+ }
200
+
201
+ # ── Actions ───────────────────────────────────────────────────────────────────────
202
+ EXTRA_CSS="$(field .css)"
203
+ [ -n "$EXTRA_CSS" ] && [ ! -f "$EXTRA_CSS" ] && err_json "css file not found: ${EXTRA_CSS}"
204
+ LANG_ATTR="$(field .lang)"; [ -z "$LANG_ATTR" ] && LANG_ATTR="zh"
205
+
206
+ case "$ACTION" in
207
+ render)
208
+ ENGINE="$(field .engine)"; [ -z "$ENGINE" ] && ENGINE="auto"
209
+ case "$ENGINE" in auto|chrome|weasyprint) ;; *) err_json "engine must be auto, chrome or weasyprint (got: ${ENGINE})" ;; esac
210
+ TITLE="$(field .title)"
211
+ ADD_STYLE="$(printf '%s' "$INPUT" | jq -r '.addDefaultStyle // false')"
212
+ PARTS=()
213
+ while IFS= read -r p; do [ -n "$p" ] && PARTS+=("$p"); done < <(printf '%s' "$INPUT" | jq -r 'if (.inputs|type) == "array" then .inputs[] else (.input // empty) end')
214
+ [ ${#PARTS[@]} -eq 0 ] && err_json "input (a .md/.html file) or inputs (a list of .md/.html/.pdf parts) is required"
215
+ OUTPUT="$(field .output)"
216
+ if [ -z "$OUTPUT" ]; then
217
+ [ ${#PARTS[@]} -gt 1 ] && err_json "output is required when rendering several parts"
218
+ OUTPUT="${PARTS[0]%.*}.pdf"
219
+ fi
220
+ OUTPUT="$(abs_path "$OUTPUT")"
221
+ mkdir -p "$(dirname "$OUTPUT")"
222
+ PDF_PARTS=(); ENGINES=()
223
+ i=0
224
+ for part in "${PARTS[@]}"; do
225
+ i=$((i + 1))
226
+ [ -f "$part" ] || err_json "input not found: ${part}"
227
+ part="$(abs_path "$part")"
228
+ case "$(printf '%s' "${part##*.}" | tr '[:upper:]' '[:lower:]')" in
229
+ md|markdown)
230
+ html="${WORK}/part-${i}.html"
231
+ part_title="$TITLE"; [ -z "$part_title" ] && part_title="$(basename "${part%.*}")"
232
+ md_to_html "$part" "$html" "$part_title"
233
+ ;;
234
+ html|htm)
235
+ html="$part"
236
+ if [ "$ADD_STYLE" = "true" ]; then
237
+ html="${WORK}/part-${i}.html"
238
+ style_html "$part" "$html"
239
+ fi
240
+ ;;
241
+ pdf) PDF_PARTS+=("$part"); ENGINES+=("pdf"); continue ;;
242
+ *) err_json "unsupported input type: ${part} (use .md, .html or .pdf)" ;;
243
+ esac
244
+ out="${WORK}/part-${i}.pdf"; [ ${#PARTS[@]} -eq 1 ] && out="$OUTPUT"
245
+ html_to_pdf "$html" "$out" "$ENGINE"
246
+ PDF_PARTS+=("$out"); ENGINES+=("$USED_ENGINE")
247
+ done
248
+ if [ ${#PARTS[@]} -gt 1 ] || [ "${PDF_PARTS[0]}" != "$OUTPUT" ]; then
249
+ MERGED="$(run_helper merge "$OUTPUT" "${PDF_PARTS[@]}" 2>&1)" || {
250
+ printf '%s' "$MERGED" | jq -e .needsSetup >/dev/null 2>&1 && { printf '%s\n' "$MERGED"; exit 1; }
251
+ err_json "merging the parts failed: $(printf '%s' "$MERGED" | tail -c 300)"
252
+ }
253
+ fi
254
+ is_pdf "$OUTPUT" || err_json "no PDF was produced at ${OUTPUT}"
255
+ SIZE=$(wc -c < "$OUTPUT" | tr -d ' ')
256
+ jq -cn --arg pdf "$OUTPUT" --argjson size "$SIZE" --argjson parts "${#PARTS[@]}" \
257
+ --arg engines "$(IFS=,; echo "${ENGINES[*]}")" \
258
+ '{success:true, pdf:$pdf, size:$size, parts:$parts, engine:($engines | split(",") | unique | map(select(. != "pdf")) | join(","))}'
259
+ ;;
260
+
261
+ merge)
262
+ OUTPUT="$(field .output)"; [ -z "$OUTPUT" ] && err_json "output is required"
263
+ INPUTS=()
264
+ while IFS= read -r p; do [ -n "$p" ] && INPUTS+=("$p"); done < <(printf '%s' "$INPUT" | jq -r '(.inputs // [])[]')
265
+ [ ${#INPUTS[@]} -lt 2 ] && err_json "inputs must list at least two PDFs"
266
+ for p in "${INPUTS[@]}"; do is_pdf "$p" || err_json "not a PDF: ${p}"; done
267
+ mkdir -p "$(dirname "$OUTPUT")"
268
+ run_helper merge "$OUTPUT" "${INPUTS[@]}"
269
+ ;;
270
+
271
+ read)
272
+ IN="$(field .input)"; [ -z "$IN" ] && err_json "input (a PDF path) is required"
273
+ is_pdf "$IN" || err_json "not a PDF (or empty): ${IN}"
274
+ ARGS=(read "$IN")
275
+ PAGES="$(field .pages)"; [ -n "$PAGES" ] && ARGS+=(--pages "$PAGES")
276
+ MAXC="$(field .maxChars)"; [ -n "$MAXC" ] && ARGS+=(--max-chars "$MAXC")
277
+ TF="$(field .textFile)"; [ -n "$TF" ] && ARGS+=(--text-file "$TF")
278
+ run_helper "${ARGS[@]}"
279
+ ;;
280
+
281
+ info)
282
+ IN="$(field .input)"; [ -z "$IN" ] && err_json "input (a PDF path) is required"
283
+ is_pdf "$IN" || err_json "not a PDF (or empty): ${IN}"
284
+ run_helper info "$IN"
285
+ ;;
286
+
287
+ check)
288
+ CHROME="$(find_chrome || true)"
289
+ PY="$(python_bin)"
290
+ jq -cn --arg chrome "$CHROME" --arg python "$PY" \
291
+ --argjson weasy "$(weasy_available && echo true || echo false)" \
292
+ --argjson pypdf "$(py_has pypdf && echo true || echo false)" \
293
+ --argjson markdown "$(py_has markdown && echo true || echo false)" \
294
+ --argjson pdftotext "$(command -v pdftotext >/dev/null 2>&1 && echo true || echo false)" \
295
+ '{success:true, chrome:(if $chrome == "" then null else $chrome end), python:(if $python == "" then null else $python end),
296
+ weasyprint:$weasy, pypdf:$pypdf, markdown:$markdown, pdftotext:$pdftotext,
297
+ canRender:(($chrome != "") or $weasy), canRead:($pypdf or $pdftotext), canMerge:$pypdf}'
298
+ ;;
299
+
300
+ *) err_json "unknown action: ${ACTION} (use render, merge, read, info or check)" ;;
301
+ esac