@laitszkin/apollo-toolkit 2.14.23 → 3.0.1

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 (58) hide show
  1. package/AGENTS.md +3 -0
  2. package/CHANGELOG.md +17 -0
  3. package/README.md +9 -0
  4. package/analyse-app-logs/README.md +5 -5
  5. package/analyse-app-logs/SKILL.md +7 -5
  6. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  7. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  8. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  9. package/codex/codex-memory-manager/README.md +2 -2
  10. package/codex/codex-memory-manager/SKILL.md +5 -5
  11. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +3 -2
  12. package/codex/learn-skill-from-conversations/README.md +1 -1
  13. package/codex/learn-skill-from-conversations/SKILL.md +2 -2
  14. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +3 -2
  15. package/docs-to-voice/README.md +3 -3
  16. package/docs-to-voice/SKILL.md +4 -4
  17. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  18. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +51 -0
  19. package/feature-propose/SKILL.md +1 -0
  20. package/generate-spec/README.md +3 -6
  21. package/generate-spec/SKILL.md +2 -3
  22. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  23. package/generate-spec/tests/test_create_specs.py +166 -0
  24. package/jupiter-development/SKILL.md +5 -0
  25. package/katex/SKILL.md +3 -3
  26. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  27. package/katex/tests/test_render_katex.py +174 -0
  28. package/learning-error-book/SKILL.md +2 -2
  29. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +134 -0
  30. package/lib/cli.js +66 -0
  31. package/lib/tool-runner.js +214 -0
  32. package/maintain-project-constraints/SKILL.md +3 -3
  33. package/maintain-skill-catalog/SKILL.md +2 -2
  34. package/novel-to-short-video/SKILL.md +2 -2
  35. package/open-github-issue/README.md +31 -22
  36. package/open-github-issue/SKILL.md +54 -40
  37. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  38. package/open-github-issue/scripts/open_github_issue.py +130 -3
  39. package/open-github-issue/tests/test_open_github_issue.py +95 -0
  40. package/openai-text-to-image-storyboard/README.md +1 -1
  41. package/openai-text-to-image-storyboard/SKILL.md +1 -1
  42. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +177 -0
  43. package/package.json +1 -1
  44. package/read-github-issue/SKILL.md +9 -9
  45. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  46. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  47. package/resolve-review-comments/SKILL.md +8 -8
  48. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  49. package/review-codebases/README.md +2 -0
  50. package/review-codebases/SKILL.md +1 -0
  51. package/scheduled-runtime-health-check/SKILL.md +3 -0
  52. package/systematic-debug/SKILL.md +3 -0
  53. package/text-to-short-video/README.md +1 -1
  54. package/text-to-short-video/SKILL.md +1 -1
  55. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  56. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +194 -0
  57. package/weekly-financial-event-report/SKILL.md +2 -2
  58. package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +64 -0
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import random
9
+ import string
10
+ import tempfile
11
+ import types
12
+ import unittest
13
+ from pathlib import Path
14
+ from unittest.mock import patch
15
+
16
+ PIL_AVAILABLE = importlib.util.find_spec("PIL") is not None
17
+
18
+ if PIL_AVAILABLE:
19
+ from PIL import Image
20
+ SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "generate_storyboard_images.py"
21
+ SPEC = importlib.util.spec_from_file_location("generate_storyboard_images", SCRIPT_PATH)
22
+ MODULE = importlib.util.module_from_spec(SPEC)
23
+ SPEC.loader.exec_module(MODULE)
24
+ else:
25
+ Image = None
26
+ MODULE = None
27
+
28
+
29
+ def png_bytes(width: int, height: int, color: tuple[int, int, int] = (64, 128, 255)) -> bytes:
30
+ buffer = io.BytesIO()
31
+ Image.new("RGB", (width, height), color).save(buffer, format="PNG")
32
+ return buffer.getvalue()
33
+
34
+
35
+ @unittest.skipUnless(PIL_AVAILABLE, "Pillow is required for storyboard image tests")
36
+ class GenerateStoryboardImagesTests(unittest.TestCase):
37
+ def test_sanitize_component_property_removes_invalid_path_characters(self) -> None:
38
+ generator = random.Random(20260418)
39
+ alphabet = string.ascii_letters + string.digits + string.punctuation + " \t中文"
40
+
41
+ for _ in range(200):
42
+ raw = "".join(generator.choice(alphabet) for _ in range(generator.randint(0, 30)))
43
+ sanitized = MODULE.sanitize_component(raw, "fallback")
44
+ with self.subTest(raw=raw, sanitized=sanitized):
45
+ self.assertTrue(sanitized)
46
+ self.assertNotRegex(sanitized, MODULE.INVALID_PATH_CHARS)
47
+ self.assertNotIn(" ", sanitized)
48
+
49
+ def test_parse_image_dimensions_supports_png_and_jpeg(self) -> None:
50
+ png = png_bytes(320, 180)
51
+ self.assertEqual(MODULE.parse_image_dimensions(png), (320, 180))
52
+
53
+ buffer = io.BytesIO()
54
+ Image.new("RGB", (160, 90), (0, 0, 0)).save(buffer, format="JPEG")
55
+ self.assertEqual(MODULE.parse_image_dimensions(buffer.getvalue()), (160, 90))
56
+
57
+ def test_parse_prompts_file_supports_structured_scene_mode(self) -> None:
58
+ payload = {
59
+ "characters": [
60
+ {
61
+ "id": "hero",
62
+ "name": "Kai",
63
+ "appearance": "short black hair",
64
+ "outfit": "blue jacket",
65
+ "description": "determined teenage inventor",
66
+ }
67
+ ],
68
+ "scenes": [
69
+ {
70
+ "title": "Workshop",
71
+ "description": "Kai repairs a flickering lantern inside a cramped workshop.",
72
+ "character_ids": ["hero"],
73
+ "character_descriptions": {"hero": "focused and covered with grease"},
74
+ "camera": "medium shot",
75
+ }
76
+ ],
77
+ }
78
+
79
+ with tempfile.TemporaryDirectory() as temp_dir:
80
+ prompts_file = Path(temp_dir) / "prompts.json"
81
+ prompts_file.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
82
+ items = MODULE.parse_prompts_file(prompts_file)
83
+
84
+ self.assertEqual(len(items), 1)
85
+ self.assertEqual(items[0]["title"], "Workshop")
86
+ prompt_payload = json.loads(items[0]["prompt"])
87
+ self.assertEqual(prompt_payload["characters"][0]["description"], "focused and covered with grease")
88
+ self.assertEqual(prompt_payload["camera"], "medium shot")
89
+
90
+ def test_build_scene_json_prompt_rejects_unknown_override_character(self) -> None:
91
+ scene = {
92
+ "title": "Workshop",
93
+ "description": "Lantern repair",
94
+ "character_ids": ["hero"],
95
+ "character_descriptions": {"ghost": "not listed"},
96
+ }
97
+
98
+ with self.assertRaises(SystemExit) as context:
99
+ MODULE.build_scene_json_prompt(
100
+ scene=scene,
101
+ scene_index=1,
102
+ prompts_file=Path("prompts.json"),
103
+ character_profiles={
104
+ "hero": {
105
+ "id": "hero",
106
+ "name": "Kai",
107
+ "appearance": "short black hair",
108
+ "outfit": "blue jacket",
109
+ "description": "determined teenage inventor",
110
+ }
111
+ },
112
+ )
113
+
114
+ self.assertIn("ids not listed in character_ids", str(context.exception))
115
+
116
+ def test_generate_image_supports_b64_payload(self) -> None:
117
+ raw = png_bytes(100, 60)
118
+
119
+ with patch.object(
120
+ MODULE,
121
+ "post_json",
122
+ return_value={"data": [{"b64_json": MODULE.base64.b64encode(raw).decode("ascii"), "revised_prompt": "ok"}]},
123
+ ):
124
+ image_bytes, revised_prompt = MODULE.generate_image(
125
+ base_url="https://example.com",
126
+ api_key="token",
127
+ image_model="gpt-image-1",
128
+ prompt="scene",
129
+ aspect_ratio=None,
130
+ image_size=None,
131
+ quality=None,
132
+ style=None,
133
+ )
134
+
135
+ self.assertEqual(image_bytes, raw)
136
+ self.assertEqual(revised_prompt, "ok")
137
+
138
+ def test_main_generates_cropped_images_and_summary(self) -> None:
139
+ with tempfile.TemporaryDirectory() as temp_dir:
140
+ project_dir = Path(temp_dir)
141
+ args = types.SimpleNamespace(
142
+ content_name="Lantern / Story",
143
+ project_dir=str(project_dir),
144
+ env_file=str(project_dir / ".env"),
145
+ api_url="https://example.com",
146
+ api_key="token",
147
+ prompts_file=None,
148
+ prompt=["Lantern workshop at dusk"],
149
+ image_model="gpt-image-1",
150
+ aspect_ratio="1:1",
151
+ image_size=None,
152
+ quality=None,
153
+ style=None,
154
+ )
155
+
156
+ with patch.object(MODULE, "parse_args", return_value=args), patch.object(
157
+ MODULE, "load_dotenv_file", return_value=False
158
+ ), patch.object(
159
+ MODULE,
160
+ "generate_image",
161
+ return_value=(png_bytes(200, 100), "Lantern workshop at dusk, improved"),
162
+ ):
163
+ exit_code = MODULE.main()
164
+
165
+ self.assertEqual(exit_code, 0)
166
+ output_dir = project_dir / "pictures" / "Lantern_Story"
167
+ summary = json.loads((output_dir / "storyboard.json").read_text(encoding="utf-8"))
168
+ self.assertEqual(summary["aspect_ratio"], "1:1")
169
+ self.assertEqual(summary["images"][0]["width"], 100)
170
+ self.assertEqual(summary["images"][0]["height"], 100)
171
+ self.assertEqual(summary["images"][0]["source_width"], 200)
172
+ self.assertEqual(summary["images"][0]["source_height"], 100)
173
+ self.assertEqual(summary["images"][0]["revised_prompt"], "Lantern workshop at dusk, improved")
174
+
175
+
176
+ if __name__ == "__main__":
177
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laitszkin/apollo-toolkit",
3
- "version": "2.14.23",
3
+ "version": "3.0.1",
4
4
  "description": "Apollo Toolkit npm installer for managed skill copying across Codex, OpenClaw, and Trae.",
5
5
  "license": "MIT",
6
6
  "author": "LaiTszKin",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: read-github-issue
3
- description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use when users ask to list issues, filter issue candidates, inspect a specific issue with comments, or gather issue context before planning follow-up work. Prefer the bundled scripts when they are present and working, but fall back to direct `gh issue list` / `gh issue view` commands when the scripts are missing or fail for repository-specific reasons.
3
+ description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use when users ask to list issues, filter issue candidates, inspect a specific issue with comments, or gather issue context before planning follow-up work. Prefer the bundled CLI tools when they are present and working, but fall back to direct `gh issue list` / `gh issue view` commands when the tools are missing or fail for repository-specific reasons.
4
4
  ---
5
5
 
6
6
  # Read GitHub Issue
@@ -10,12 +10,12 @@ description: Read and search remote GitHub issues via GitHub CLI (`gh`). Use whe
10
10
  - Required: none.
11
11
  - Conditional: none.
12
12
  - Optional: none.
13
- - Fallback: If the bundled scripts are missing or fail but `gh` is available, continue with raw `gh issue list` / `gh issue view`; only stop when `gh` itself is unavailable or unauthenticated.
13
+ - Fallback: If the bundled CLI tools are missing or fail but `gh` is available, continue with raw `gh issue list` / `gh issue view`; only stop when `gh` itself is unavailable or unauthenticated.
14
14
 
15
15
  ## Standards
16
16
 
17
17
  - Evidence: Verify repository context first, then read remote issue data directly from `gh issue list` / `gh issue view` instead of paraphrasing from memory.
18
- - Execution: Confirm the target repo, prefer the bundled scripts for deterministic output, then fall back to raw `gh` commands whenever the scripts are unavailable or broken in the target repository.
18
+ - Execution: Confirm the target repo, prefer the bundled CLI tools for deterministic output, then fall back to raw `gh` commands whenever the tools are unavailable or broken in the target repository.
19
19
  - Quality: Keep the skill focused on issue discovery and retrieval only; do not embed any hardcoded fixing, branching, PR, or push workflow.
20
20
  - Output: Return candidate issues, selected issue details, comments summary, and any missing information needed before follow-up work.
21
21
 
@@ -35,12 +35,12 @@ Use this skill to gather trustworthy GitHub issue context with `gh`: discover op
35
35
  - Run `gh repo view --json nameWithOwner,isPrivate,defaultBranchRef` to confirm target repo.
36
36
  - If the user specifies another repository, always use `--repo <owner>/<repo>` in issue commands.
37
37
 
38
- ### 2) Find candidate issues with the bundled script
38
+ ### 2) Find candidate issues with the bundled CLI
39
39
 
40
40
  - Preferred command:
41
41
 
42
42
  ```bash
43
- python3 scripts/find_issues.py --limit 50 --state open
43
+ apltk find-github-issues --limit 50 --state open
44
44
  ```
45
45
 
46
46
  - Optional filters:
@@ -61,7 +61,7 @@ gh issue list --limit 50 --state open
61
61
  - Preferred command:
62
62
 
63
63
  ```bash
64
- python3 scripts/read_issue.py 123 --comments
64
+ apltk read-github-issue 123 --comments
65
65
  ```
66
66
 
67
67
  - Optional flags:
@@ -82,16 +82,16 @@ gh issue view 123 --comments
82
82
  - Identify missing acceptance criteria, repro details, affected components, or environment context.
83
83
  - If issue text and comments are insufficient, state exactly what is missing instead of inventing a fix plan.
84
84
 
85
- ## Included Script
85
+ ## Included CLI
86
86
 
87
- ### `scripts/find_issues.py`
87
+ ### `apltk find-github-issues`
88
88
 
89
89
  - Purpose: consistent remote issue listing via `gh issue list`.
90
90
  - Outputs a readable table by default, or JSON with `--output json`.
91
91
  - Uses only GitHub CLI so it reflects remote GitHub state.
92
92
  - Treat it as a convenience wrapper, not a hard dependency.
93
93
 
94
- ### `scripts/read_issue.py`
94
+ ### `apltk read-github-issue`
95
95
 
96
96
  - Purpose: deterministic issue detail retrieval via `gh issue view`.
97
97
  - Outputs either a human-readable summary or full JSON for downstream automation.
@@ -46,8 +46,8 @@ Use this skill to run an end-to-end GitHub PR review loop: collect review thread
46
46
  - If user does not provide PR number, infer from current branch context.
47
47
 
48
48
  ```bash
49
- python3 scripts/review_threads.py list --repo <owner>/<repo> --pr <number>
50
- python3 scripts/review_threads.py list
49
+ apltk review-threads list --repo <owner>/<repo> --pr <number>
50
+ apltk review-threads list
51
51
  ```
52
52
 
53
53
  ## 2) Read unresolved review threads
@@ -55,8 +55,8 @@ python3 scripts/review_threads.py list
55
55
  Use table view for quick scan, then JSON when you need full details.
56
56
 
57
57
  ```bash
58
- python3 scripts/review_threads.py list --pr <number> --state unresolved --output table
59
- python3 scripts/review_threads.py list --pr <number> --state unresolved --output json > /tmp/pr_threads.json
58
+ apltk review-threads list --pr <number> --state unresolved --output table
59
+ apltk review-threads list --pr <number> --state unresolved --output json > /tmp/pr_threads.json
60
60
  ```
61
61
 
62
62
  The JSON output contains `thread_id`, `path`, `line`, and comment bodies for decision and resolution.
@@ -98,13 +98,13 @@ Track adopted thread IDs in a JSON file:
98
98
  Resolve only threads you actually addressed in code.
99
99
 
100
100
  ```bash
101
- python3 scripts/review_threads.py resolve --pr <number> --thread-id-file adopted_threads.json
101
+ apltk review-threads resolve --pr <number> --thread-id-file adopted_threads.json
102
102
  ```
103
103
 
104
104
  Optional preview without mutating GitHub state:
105
105
 
106
106
  ```bash
107
- python3 scripts/review_threads.py resolve --pr <number> --thread-id-file adopted_threads.json --dry-run
107
+ apltk review-threads resolve --pr <number> --thread-id-file adopted_threads.json --dry-run
108
108
  ```
109
109
 
110
110
  ## 8) Handle non-adopted comments
@@ -113,9 +113,9 @@ python3 scripts/review_threads.py resolve --pr <number> --thread-id-file adopted
113
113
  - Reply with a concise technical reason and, if needed, a proposed follow-up.
114
114
  - Never resolve rejected or unhandled feedback threads.
115
115
 
116
- ## Scripts
116
+ ## CLI
117
117
 
118
- ### `scripts/review_threads.py`
118
+ ### `apltk review-threads`
119
119
 
120
120
  - `list`: fetch PR review threads via GitHub GraphQL (`gh api graphql`), supports repo/PR inference.
121
121
  - `resolve`: resolve selected review threads by thread IDs.
@@ -50,6 +50,8 @@ For each confirmed finding, delegate publication to `$open-github-issue` with:
50
50
  - file references and causal reasoning in `suspected-cause`
51
51
  - reproduction conditions when known
52
52
 
53
+ When invoking the publisher CLI directly, use `apltk open-github-issue --payload-file <json>` or `@file` inputs for Markdown-rich fields so shell parsing cannot consume backticks or code snippets.
54
+
53
55
  If issue publication is unavailable, return draft issue content instead of switching to an ad-hoc publishing path.
54
56
 
55
57
  ## Output expectations
@@ -74,6 +74,7 @@ Only continue to the next level when the current level has no confirmed findings
74
74
  - `suspected-cause`: file references, causal chain, and confidence
75
75
  - `reproduction`: concrete trigger or conditions when known; otherwise leave empty
76
76
  - `repo`: target repository in `owner/repo` format when known
77
+ - If invoking the publisher CLI directly, pass finding details through `apltk open-github-issue --payload-file <json>` or `@file` inputs rather than inline shell arguments so code snippets and backticks survive unchanged.
77
78
 
78
79
  ## Evidence standard
79
80
 
@@ -52,6 +52,7 @@ This skill is an orchestration layer. It owns the background terminal session, o
52
52
  - Do not call a module healthy unless there is at least one positive signal for it.
53
53
  - Separate scheduler failures, boot failures, runtime failures, and shutdown failures.
54
54
  - For complex pipelines, identify the last successful stage before attributing the failure to application logic.
55
+ - When the user asks to compare a bounded run with a previous run, compare only runs with the same command or preset, duration, runtime mode, and complete structured artifacts. If the previous run lacks canonical reports, databases, or startup artifacts, mark the runs incomparable and explain the artifact completeness gap instead of inventing performance deltas.
55
56
  - If logs cannot support a health judgment, mark the module as `unknown` instead of guessing.
56
57
 
57
58
  ## Required workflow
@@ -93,6 +94,8 @@ This skill is an orchestration layer. It owns the background terminal session, o
93
94
  - Invoke `analyse-app-logs` on only the captured runtime window.
94
95
  - Pass the service or module names, environment, timezone, canonical run folder, relevant log files, and the exact start/end boundaries.
95
96
  - When the command produced reports, databases, or other structured artifacts, compare them against the same run's logs before making a health judgment.
97
+ - For follow-up questions about why most business events did not happen, build a stage-by-stage funnel from the canonical artifacts before reading isolated logs: candidate counts, admission/precheck decisions, queue or governor outcomes, skipped/blocked reasons, executed counts, retry/remediation outcomes, and persistence records.
98
+ - For follow-up questions about runtime speed, report latency from structured timestamps when available, separating startup/readiness, queue wait, precheck/final-prepare, submission, confirmation, and end-to-end timings rather than collapsing them into one vague duration.
96
99
  - Reuse its confirmed issues, hypotheses, and monitoring improvements instead of rewriting a separate incident workflow.
97
100
  8. Produce the final report
98
101
  - Always summarize the actual command executed, actual start/end timestamps, execution status, and log locations.
@@ -25,6 +25,7 @@ description: "Systematic debugging workflow for program issues: understand obser
25
25
  - Cover all plausible causes with reproducible tests instead of guessing a single cause.
26
26
  - Keep fixes minimal, focused, and validated by passing tests.
27
27
  - When logs or runtime artifacts exist, treat one run as canonical and compare every conclusion against that same run's generated artifacts, not against ad hoc console recollection.
28
+ - When comparing runtime runs, first verify the baseline run is complete enough for the requested comparison: same command or scenario, same runtime mode, same bounded duration, and matching structured artifacts. If the baseline is incomplete, report that the only proven change is artifact/run completeness and avoid drawing strategy or performance conclusions from missing data.
28
29
  - When a repository has both scenario or harness runs and a production-like runtime, do not treat the lower-fidelity mode as proof about the higher-fidelity mode unless you explicitly state that limitation and the user agrees.
29
30
  - When the failing flow crosses multiple layers, identify the last confirmed successful stage before assigning blame.
30
31
  - When tests fail, separate stale assertions and fixture drift from real implementation regressions before changing product code.
@@ -58,6 +59,8 @@ Also auto-invoke this skill when mismatch evidence appears during normal executi
58
59
  - If a hypothesized cause cannot be reproduced, document why and deprioritize it explicitly.
59
60
  - For long-running or generated-artifact workflows, record the exact command, timestamps, and artifact paths before inspecting outputs so later comparisons stay on the same evidence set.
60
61
  - Do not mix baseline data and rerun data casually; compare the same scenario or command across runs and call out when a conclusion comes from a rerun rather than the original failure.
62
+ - For post-run "why did most events not happen" questions, derive the answer from a funnel over structured artifacts first, then corroborate with logs: discovered or eligible candidates, admission blocks, stale or skipped decisions, queue/governor outcomes, execution attempts, confirmations, retries, and persisted event rows.
63
+ - For speed questions, compute per-stage timings from available event timestamps and state which stages are measured versus unavailable; avoid treating wall-clock bounded duration as pipeline latency.
61
64
  - When test fixtures or assertions no longer match the implemented contract, update the tests instead of weakening the product behavior to satisfy stale expectations.
62
65
  - When tests shell out to shared local infrastructure, add deterministic isolation such as mutexes, unique temp roots, or serialized sections before accepting flakes as inevitable.
63
66
 
@@ -56,7 +56,7 @@ cp ~/.codex/skills/text-to-short-video/.env.example \
56
56
  If API-generated video ratio/size does not match the target, run:
57
57
 
58
58
  ```bash
59
- python ~/.codex/skills/text-to-short-video/scripts/enforce_video_aspect_ratio.py \
59
+ apltk enforce-video-aspect-ratio \
60
60
  --input-video "<downloaded_video_path>" \
61
61
  --output-video "<final_output_video_path>" \
62
62
  --env-file ~/.codex/skills/text-to-short-video/.env \
@@ -173,7 +173,7 @@ If provider returns multiple outputs, keep the best one that matches requested s
173
173
  When output ratio or resolution differs from target, run:
174
174
 
175
175
  ```bash
176
- python ~/.codex/skills/text-to-short-video/scripts/enforce_video_aspect_ratio.py \
176
+ apltk enforce-video-aspect-ratio \
177
177
  --input-video "<downloaded_video_path>" \
178
178
  --output-video "<final_output_video_path>" \
179
179
  --env-file ~/.codex/skills/text-to-short-video/.env \
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import io
7
+ import json
8
+ import os
9
+ import random
10
+ import re
11
+ import tempfile
12
+ import types
13
+ import unittest
14
+ from pathlib import Path
15
+ from unittest.mock import patch
16
+
17
+
18
+ SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "enforce_video_aspect_ratio.py"
19
+ SPEC = importlib.util.spec_from_file_location("enforce_video_aspect_ratio", SCRIPT_PATH)
20
+ MODULE = importlib.util.module_from_spec(SPEC)
21
+ SPEC.loader.exec_module(MODULE)
22
+
23
+
24
+ def parse_crop(expression: str) -> tuple[int, int, int, int] | None:
25
+ match = re.search(r"crop=(\d+):(\d+):(\d+):(\d+)", expression)
26
+ if not match:
27
+ return None
28
+ return tuple(int(value) for value in match.groups())
29
+
30
+
31
+ class EnforceVideoAspectRatioTests(unittest.TestCase):
32
+ def test_parse_size_property_round_trips_positive_dimensions(self) -> None:
33
+ generator = random.Random(20260418)
34
+ for _ in range(150):
35
+ width = generator.randint(1, 9999)
36
+ height = generator.randint(1, 9999)
37
+ raw = f"{width}x{height}"
38
+ with self.subTest(raw=raw):
39
+ self.assertEqual(MODULE.parse_size(raw), (width, height))
40
+
41
+ def test_load_dotenv_file_parses_quotes_and_respects_override(self) -> None:
42
+ with tempfile.TemporaryDirectory() as temp_dir:
43
+ env_file = Path(temp_dir) / ".env"
44
+ env_file.write_text(
45
+ 'TEXT_TO_SHORT_VIDEO_WIDTH="720"\n'
46
+ "TEXT_TO_SHORT_VIDEO_HEIGHT=1280 # comment\n",
47
+ encoding="utf-8",
48
+ )
49
+ original = os.environ.get("TEXT_TO_SHORT_VIDEO_WIDTH")
50
+ os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = "999"
51
+ try:
52
+ loaded = MODULE.load_dotenv_file(env_file, override=False)
53
+ self.assertTrue(loaded)
54
+ self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "999")
55
+ self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_HEIGHT"], "1280")
56
+ MODULE.load_dotenv_file(env_file, override=True)
57
+ self.assertEqual(os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"], "720")
58
+ finally:
59
+ if original is None:
60
+ os.environ.pop("TEXT_TO_SHORT_VIDEO_WIDTH", None)
61
+ else:
62
+ os.environ["TEXT_TO_SHORT_VIDEO_WIDTH"] = original
63
+ os.environ.pop("TEXT_TO_SHORT_VIDEO_HEIGHT", None)
64
+
65
+ def test_build_video_filter_property_keeps_crop_within_bounds(self) -> None:
66
+ generator = random.Random(20260418)
67
+ for _ in range(200):
68
+ input_width = generator.randint(32, 4096)
69
+ input_height = generator.randint(32, 4096)
70
+ target_width = generator.randint(32, 2160)
71
+ target_height = generator.randint(32, 3840)
72
+
73
+ expression, crop_applied = MODULE.build_video_filter(
74
+ input_width=input_width,
75
+ input_height=input_height,
76
+ target_width=target_width,
77
+ target_height=target_height,
78
+ )
79
+
80
+ with self.subTest(
81
+ input_width=input_width,
82
+ input_height=input_height,
83
+ target_width=target_width,
84
+ target_height=target_height,
85
+ expression=expression,
86
+ ):
87
+ if expression is None:
88
+ self.assertFalse(crop_applied)
89
+ self.assertEqual((input_width, input_height), (target_width, target_height))
90
+ continue
91
+
92
+ self.assertIn(f"scale={target_width}:{target_height}", expression)
93
+ crop = parse_crop(expression)
94
+ if crop is None:
95
+ self.assertFalse(crop_applied)
96
+ self.assertEqual(input_width * target_height, input_height * target_width)
97
+ continue
98
+
99
+ crop_width, crop_height, offset_x, offset_y = crop
100
+ self.assertTrue(crop_applied)
101
+ self.assertEqual(crop_width % 2, 0)
102
+ self.assertEqual(crop_height % 2, 0)
103
+ self.assertGreaterEqual(crop_width, 2)
104
+ self.assertGreaterEqual(crop_height, 2)
105
+ self.assertLessEqual(crop_width, input_width)
106
+ self.assertLessEqual(crop_height, input_height)
107
+ self.assertGreaterEqual(offset_x, 0)
108
+ self.assertGreaterEqual(offset_y, 0)
109
+ self.assertLessEqual(offset_x + crop_width, input_width)
110
+ self.assertLessEqual(offset_y + crop_height, input_height)
111
+
112
+ def test_resolve_output_path_enforces_in_place_rules(self) -> None:
113
+ input_video = Path("/tmp/input.mp4")
114
+ args = types.SimpleNamespace(in_place=False, output_video=None)
115
+ self.assertEqual(MODULE.resolve_output_path(args, input_video), Path("/tmp/input_aspect_fixed.mp4"))
116
+
117
+ args = types.SimpleNamespace(in_place=True, output_video=None)
118
+ self.assertEqual(MODULE.resolve_output_path(args, input_video), input_video)
119
+
120
+ args = types.SimpleNamespace(in_place=True, output_video="/tmp/out.mp4")
121
+ with self.assertRaises(SystemExit):
122
+ MODULE.resolve_output_path(args, input_video)
123
+
124
+ def test_main_copies_file_when_video_already_matches_target(self) -> None:
125
+ with tempfile.TemporaryDirectory() as temp_dir:
126
+ input_video = Path(temp_dir) / "input.mp4"
127
+ input_video.write_bytes(b"video")
128
+ output_video = Path(temp_dir) / "copy.mp4"
129
+ args = types.SimpleNamespace(
130
+ input_video=str(input_video),
131
+ output_video=str(output_video),
132
+ in_place=False,
133
+ target_size="640x360",
134
+ target_width=None,
135
+ target_height=None,
136
+ env_file=str(Path(temp_dir) / ".env"),
137
+ force=False,
138
+ ffmpeg_bin="ffmpeg",
139
+ ffprobe_bin="ffprobe",
140
+ )
141
+
142
+ with patch.object(MODULE, "parse_args", return_value=args), patch.object(
143
+ MODULE, "load_dotenv_file", return_value=False
144
+ ), patch.object(MODULE, "required_command"), patch.object(
145
+ MODULE, "probe_video_size", return_value=(640, 360)
146
+ ), patch("sys.stdout", new_callable=io.StringIO) as stdout:
147
+ exit_code = MODULE.main()
148
+
149
+ self.assertEqual(exit_code, 0)
150
+ self.assertEqual(output_video.read_bytes(), b"video")
151
+ self.assertIn("already matches target size", stdout.getvalue())
152
+
153
+ def test_main_runs_ffmpeg_and_reports_output_dimensions(self) -> None:
154
+ with tempfile.TemporaryDirectory() as temp_dir:
155
+ input_video = Path(temp_dir) / "input.mp4"
156
+ output_video = Path(temp_dir) / "output.mp4"
157
+ input_video.write_bytes(b"video")
158
+ args = types.SimpleNamespace(
159
+ input_video=str(input_video),
160
+ output_video=str(output_video),
161
+ in_place=False,
162
+ target_size="1080x1920",
163
+ target_width=None,
164
+ target_height=None,
165
+ env_file=str(Path(temp_dir) / ".env"),
166
+ force=True,
167
+ ffmpeg_bin="ffmpeg",
168
+ ffprobe_bin="ffprobe",
169
+ )
170
+
171
+ def fake_run(command, check):
172
+ self.assertIn("-vf", command)
173
+ Path(command[-1]).write_bytes(b"processed")
174
+ return types.SimpleNamespace(returncode=0)
175
+
176
+ with patch.object(MODULE, "parse_args", return_value=args), patch.object(
177
+ MODULE, "load_dotenv_file", return_value=False
178
+ ), patch.object(MODULE, "required_command"), patch.object(
179
+ MODULE,
180
+ "probe_video_size",
181
+ side_effect=[(1920, 1080), (1080, 1920)],
182
+ ), patch.object(MODULE.subprocess, "run", side_effect=fake_run), patch(
183
+ "sys.stdout", new_callable=io.StringIO
184
+ ) as stdout:
185
+ exit_code = MODULE.main()
186
+
187
+ self.assertEqual(exit_code, 0)
188
+ self.assertTrue(output_video.is_file())
189
+ self.assertIn("Center crop applied: yes", stdout.getvalue())
190
+ self.assertIn("Output size: 1080x1920", stdout.getvalue())
191
+
192
+
193
+ if __name__ == "__main__":
194
+ unittest.main()
@@ -10,7 +10,7 @@ description: Read a user-specified PDF that marks the week's key financial event
10
10
  - Required: `pdf` to render the final report.
11
11
  - Conditional: `document-vision-reader` when the source PDF's highlighted markers are visible in layout but not recoverable from machine-readable text alone.
12
12
  - Optional: none.
13
- - Fallback: If source-PDF extraction through `pdf` is unavailable or fails on macOS, use the bundled `scripts/extract_pdf_text_pdfkit.swift` helper before giving up; only stop when neither `pdf` nor the local PDFKit fallback can recover the marked events, or when final PDF rendering itself cannot be completed.
13
+ - Fallback: If source-PDF extraction through `pdf` is unavailable or fails on macOS, use the bundled `apltk extract-pdf-text-pdfkit` helper before giving up; only stop when neither `pdf` nor the local PDFKit fallback can recover the marked events, or when final PDF rendering itself cannot be completed.
14
14
 
15
15
  ## Standards
16
16
 
@@ -72,7 +72,7 @@ Do not guess any input that materially changes the research window or report sco
72
72
  - If `pdf` extraction is unavailable or fails because the current machine lacks local PDF tooling, and the host is macOS, run:
73
73
 
74
74
  ```bash
75
- swift scripts/extract_pdf_text_pdfkit.swift /absolute/path/to/source.pdf
75
+ apltk extract-pdf-text-pdfkit /absolute/path/to/source.pdf
76
76
  ```
77
77
 
78
78
  - The bundled extractor prints page-delimited text directly from PDFKit so the agent can still build the source-event table without adding Python PDF packages ad hoc.