@laitszkin/apollo-toolkit 3.13.2 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/AGENTS.md +7 -7
  2. package/CHANGELOG.md +27 -0
  3. package/CLAUDE.md +8 -8
  4. package/analyse-app-logs/SKILL.md +3 -3
  5. package/bin/apollo-toolkit.ts +7 -0
  6. package/codex/codex-memory-manager/SKILL.md +2 -2
  7. package/codex/learn-skill-from-conversations/SKILL.md +3 -3
  8. package/dist/bin/apollo-toolkit.d.ts +2 -0
  9. package/dist/bin/apollo-toolkit.js +7 -0
  10. package/dist/lib/cli.d.ts +41 -0
  11. package/dist/lib/cli.js +655 -0
  12. package/dist/lib/installer.d.ts +59 -0
  13. package/dist/lib/installer.js +404 -0
  14. package/dist/lib/tool-runner.d.ts +19 -0
  15. package/dist/lib/tool-runner.js +536 -0
  16. package/dist/lib/tools/architecture.d.ts +2 -0
  17. package/dist/lib/tools/architecture.js +34 -0
  18. package/dist/lib/tools/create-specs.d.ts +2 -0
  19. package/dist/lib/tools/create-specs.js +175 -0
  20. package/dist/lib/tools/docs-to-voice.d.ts +2 -0
  21. package/dist/lib/tools/docs-to-voice.js +705 -0
  22. package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
  23. package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
  24. package/dist/lib/tools/extract-conversations.d.ts +2 -0
  25. package/dist/lib/tools/extract-conversations.js +105 -0
  26. package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
  27. package/dist/lib/tools/extract-pdf-text.js +92 -0
  28. package/dist/lib/tools/filter-logs.d.ts +2 -0
  29. package/dist/lib/tools/filter-logs.js +94 -0
  30. package/dist/lib/tools/find-github-issues.d.ts +2 -0
  31. package/dist/lib/tools/find-github-issues.js +176 -0
  32. package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
  33. package/dist/lib/tools/generate-storyboard-images.js +419 -0
  34. package/dist/lib/tools/log-cli-utils.d.ts +35 -0
  35. package/dist/lib/tools/log-cli-utils.js +233 -0
  36. package/dist/lib/tools/open-github-issue.d.ts +2 -0
  37. package/dist/lib/tools/open-github-issue.js +750 -0
  38. package/dist/lib/tools/read-github-issue.d.ts +2 -0
  39. package/dist/lib/tools/read-github-issue.js +134 -0
  40. package/dist/lib/tools/render-error-book.d.ts +2 -0
  41. package/dist/lib/tools/render-error-book.js +265 -0
  42. package/dist/lib/tools/render-katex.d.ts +2 -0
  43. package/dist/lib/tools/render-katex.js +294 -0
  44. package/dist/lib/tools/review-threads.d.ts +2 -0
  45. package/dist/lib/tools/review-threads.js +491 -0
  46. package/dist/lib/tools/search-logs.d.ts +2 -0
  47. package/dist/lib/tools/search-logs.js +164 -0
  48. package/dist/lib/tools/sync-memory-index.d.ts +2 -0
  49. package/dist/lib/tools/sync-memory-index.js +113 -0
  50. package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
  51. package/dist/lib/tools/validate-openai-agent-config.js +184 -0
  52. package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
  53. package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
  54. package/dist/lib/types.d.ts +82 -0
  55. package/dist/lib/types.js +2 -0
  56. package/dist/lib/updater.d.ts +34 -0
  57. package/dist/lib/updater.js +112 -0
  58. package/dist/lib/utils/format.d.ts +2 -0
  59. package/dist/lib/utils/format.js +6 -0
  60. package/dist/lib/utils/terminal.d.ts +12 -0
  61. package/dist/lib/utils/terminal.js +26 -0
  62. package/docs-to-voice/SKILL.md +0 -1
  63. package/generate-spec/SKILL.md +1 -1
  64. package/katex/SKILL.md +1 -2
  65. package/lib/cli.ts +780 -0
  66. package/lib/installer.ts +466 -0
  67. package/lib/tool-runner.ts +561 -0
  68. package/lib/tools/architecture.ts +34 -0
  69. package/lib/tools/create-specs.ts +204 -0
  70. package/lib/tools/docs-to-voice.ts +799 -0
  71. package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
  72. package/lib/tools/extract-conversations.ts +114 -0
  73. package/lib/tools/extract-pdf-text.ts +99 -0
  74. package/lib/tools/filter-logs.ts +118 -0
  75. package/lib/tools/find-github-issues.ts +211 -0
  76. package/lib/tools/generate-storyboard-images.ts +455 -0
  77. package/lib/tools/log-cli-utils.ts +262 -0
  78. package/lib/tools/open-github-issue.ts +930 -0
  79. package/lib/tools/read-github-issue.ts +179 -0
  80. package/lib/tools/render-error-book.ts +300 -0
  81. package/lib/tools/render-katex.ts +325 -0
  82. package/lib/tools/review-threads.ts +590 -0
  83. package/lib/tools/search-logs.ts +200 -0
  84. package/lib/tools/sync-memory-index.ts +114 -0
  85. package/lib/tools/validate-openai-agent-config.ts +209 -0
  86. package/lib/tools/validate-skill-frontmatter.ts +124 -0
  87. package/lib/types.ts +90 -0
  88. package/lib/updater.ts +165 -0
  89. package/lib/utils/format.ts +7 -0
  90. package/lib/utils/terminal.ts +22 -0
  91. package/open-github-issue/SKILL.md +2 -2
  92. package/optimise-skill/SKILL.md +1 -1
  93. package/package.json +13 -4
  94. package/resources/project-architecture/assets/architecture.css +764 -0
  95. package/resources/project-architecture/assets/viewer.client.js +144 -0
  96. package/resources/project-architecture/index.html +42 -0
  97. package/review-spec-related-changes/SKILL.md +1 -1
  98. package/solve-issues-found-during-review/SKILL.md +2 -1
  99. package/tsconfig.json +28 -0
  100. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  101. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  102. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  103. package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
  104. package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
  105. package/analyse-app-logs/scripts/search_logs.py +0 -137
  106. package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
  107. package/analyse-app-logs/tests/test_search_logs.py +0 -100
  108. package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
  109. package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
  110. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
  111. package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
  112. package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
  113. package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
  114. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
  115. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  116. package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
  117. package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
  118. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
  119. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
  120. package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
  121. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
  122. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
  123. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  124. package/generate-spec/scripts/create-specs +0 -215
  125. package/generate-spec/tests/test_create_specs.py +0 -200
  126. package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
  127. package/init-project-html/scripts/architecture.js +0 -296
  128. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  129. package/katex/scripts/render_katex.py +0 -247
  130. package/katex/scripts/render_katex.sh +0 -11
  131. package/katex/tests/test_render_katex.py +0 -174
  132. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
  133. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
  134. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  135. package/open-github-issue/scripts/open_github_issue.py +0 -705
  136. package/open-github-issue/tests/test_open_github_issue.py +0 -381
  137. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
  138. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
  139. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  140. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  141. package/read-github-issue/scripts/find_issues.py +0 -148
  142. package/read-github-issue/scripts/read_issue.py +0 -108
  143. package/read-github-issue/tests/test_find_issues.py +0 -127
  144. package/read-github-issue/tests/test_read_issue.py +0 -109
  145. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  146. package/resolve-review-comments/scripts/review_threads.py +0 -425
  147. package/resolve-review-comments/tests/test_review_threads.py +0 -74
  148. package/scripts/validate_openai_agent_config.py +0 -209
  149. package/scripts/validate_skill_frontmatter.py +0 -131
  150. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  151. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
  152. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
  153. package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
  154. package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +0 -64
@@ -0,0 +1,144 @@
1
+ /* viewer.client.js — pan/zoom for every atlas SVG on the page.
2
+ *
3
+ * Each `[data-pan-zoom-viewport]` element gets its own state and
4
+ * handlers, so a single page can host the macro atlas SVG AND
5
+ * sub-module-internal dataflow SVGs simultaneously. Toolbar buttons
6
+ * (`[data-pan-zoom="zoom-in|zoom-out|fit"]`) are scoped to the
7
+ * containing `[data-pan-zoom-container]` (falls back to the viewport's
8
+ * direct parent), so multiple toolbars on one page do not collide.
9
+ * Keyboard shortcuts (`←` / `→` / `↑` / `↓` / `+` / `−` / `0`) drive
10
+ * the page's first viewport — the "primary" diagram of that page
11
+ * (macro SVG on `index.html`; sub-dataflow SVG on a sub-module page).
12
+ */
13
+
14
+ (function () {
15
+ 'use strict';
16
+
17
+ const viewports = Array.from(document.querySelectorAll('[data-pan-zoom-viewport]'));
18
+ const controllers = viewports.map(setupViewport).filter(Boolean);
19
+ if (controllers.length === 0) return;
20
+
21
+ const primary = controllers[0];
22
+ document.addEventListener('keydown', function (evt) {
23
+ if (evt.target && (evt.target.tagName === 'INPUT' || evt.target.tagName === 'TEXTAREA')) return;
24
+ if (evt.key === 'ArrowLeft') { primary.pan(-1, 0); }
25
+ else if (evt.key === 'ArrowRight') { primary.pan(1, 0); }
26
+ else if (evt.key === 'ArrowUp') { primary.pan(0, -1); }
27
+ else if (evt.key === 'ArrowDown') { primary.pan(0, 1); }
28
+ else if (evt.key === '+' || evt.key === '=') { primary.zoom(1 / 1.2); }
29
+ else if (evt.key === '-' || evt.key === '_') { primary.zoom(1.2); }
30
+ else if (evt.key === '0') { primary.fit(); }
31
+ });
32
+
33
+ function setupViewport(viewport) {
34
+ const svg = viewport.querySelector('[data-atlas-svg]');
35
+ if (!svg) return null;
36
+ const initial = svg.getAttribute('viewBox');
37
+ if (!initial) return null;
38
+ const [ix, iy, iw, ih] = initial.split(/\s+/).map(Number);
39
+ const state = { x: ix, y: iy, w: iw, h: ih };
40
+
41
+ function apply() {
42
+ svg.setAttribute('viewBox', `${state.x} ${state.y} ${state.w} ${state.h}`);
43
+ }
44
+ function fit() {
45
+ state.x = ix; state.y = iy; state.w = iw; state.h = ih;
46
+ apply();
47
+ }
48
+ function zoom(factor, cx, cy) {
49
+ const newW = Math.max(40, Math.min(state.w * factor, iw * 8));
50
+ const newH = newW * (state.h / state.w);
51
+ if (cx == null) { cx = state.x + state.w / 2; cy = state.y + state.h / 2; }
52
+ state.x = cx - (cx - state.x) * (newW / state.w);
53
+ state.y = cy - (cy - state.y) * (newH / state.h);
54
+ state.w = newW;
55
+ state.h = newH;
56
+ apply();
57
+ }
58
+ function pan(dirX, dirY) {
59
+ const stepX = state.w * 0.08;
60
+ const stepY = state.h * 0.08;
61
+ state.x += dirX * stepX;
62
+ state.y += dirY * stepY;
63
+ apply();
64
+ }
65
+ function clientToSvg(evt) {
66
+ const rect = svg.getBoundingClientRect();
67
+ const xRatio = (evt.clientX - rect.left) / rect.width;
68
+ const yRatio = (evt.clientY - rect.top) / rect.height;
69
+ return { x: state.x + xRatio * state.w, y: state.y + yRatio * state.h };
70
+ }
71
+
72
+ // The diagram viewport owns the wheel gesture entirely: ANY wheel
73
+ // event that lands inside the viewport is consumed so the host page
74
+ // never scrolls underneath the user. The wheel zooms the SVG around
75
+ // the cursor; trackpad pinch-zoom (which arrives as ctrlKey wheel
76
+ // on macOS) is treated the same way for a single predictable model.
77
+ viewport.addEventListener('wheel', function (evt) {
78
+ evt.preventDefault();
79
+ evt.stopPropagation();
80
+ const absX = Math.abs(evt.deltaX);
81
+ const absY = Math.abs(evt.deltaY);
82
+ if (absX < 0.5 && absY < 0.5) return;
83
+ const factor = evt.deltaY > 0 ? 1.08 : 1 / 1.08;
84
+ const pt = clientToSvg(evt);
85
+ zoom(factor, pt.x, pt.y);
86
+ }, { passive: false });
87
+
88
+ // Drag-pan: defer the pointer capture (and the "is-grabbing" class)
89
+ // until the pointer actually moves past a small threshold. Without
90
+ // this, a single click on an SVG <a> (sub-module link) would be
91
+ // captured by the viewport and never reach the link.
92
+ const DRAG_THRESHOLD_PX = 4;
93
+ let pending = null;
94
+ let dragging = null;
95
+ viewport.addEventListener('pointerdown', function (evt) {
96
+ if (evt.button !== 0) return;
97
+ pending = { x: evt.clientX, y: evt.clientY, pointerId: evt.pointerId };
98
+ });
99
+ viewport.addEventListener('pointermove', function (evt) {
100
+ if (!dragging && pending && pending.pointerId === evt.pointerId) {
101
+ const dx = evt.clientX - pending.x;
102
+ const dy = evt.clientY - pending.y;
103
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
104
+ dragging = { x: pending.x, y: pending.y, pointerId: pending.pointerId };
105
+ pending = null;
106
+ viewport.classList.add('is-grabbing');
107
+ try { viewport.setPointerCapture(dragging.pointerId); } catch (e) { /* ignore */ }
108
+ }
109
+ if (!dragging || dragging.pointerId !== evt.pointerId) return;
110
+ evt.preventDefault();
111
+ const rect = svg.getBoundingClientRect();
112
+ const dx = ((evt.clientX - dragging.x) / rect.width) * state.w;
113
+ const dy = ((evt.clientY - dragging.y) / rect.height) * state.h;
114
+ state.x -= dx;
115
+ state.y -= dy;
116
+ dragging.x = evt.clientX;
117
+ dragging.y = evt.clientY;
118
+ apply();
119
+ });
120
+ function endDrag(evt) {
121
+ pending = null;
122
+ if (!dragging) return;
123
+ const draggedId = dragging.pointerId;
124
+ dragging = null;
125
+ viewport.classList.remove('is-grabbing');
126
+ try { viewport.releasePointerCapture(draggedId); } catch (e) { /* ignore */ }
127
+ // Suppress the synthetic click that would follow a drag-release
128
+ // on top of a sub-module <a>; only the no-movement case should
129
+ // navigate.
130
+ const swallow = (e) => { e.preventDefault(); e.stopPropagation(); };
131
+ viewport.addEventListener('click', swallow, { capture: true, once: true });
132
+ }
133
+ viewport.addEventListener('pointerup', endDrag);
134
+ viewport.addEventListener('pointercancel', endDrag);
135
+ viewport.addEventListener('pointerleave', endDrag);
136
+
137
+ const container = viewport.closest('[data-pan-zoom-container]') || viewport.parentElement || document;
138
+ container.querySelectorAll('[data-pan-zoom="zoom-in"]').forEach((btn) => btn.addEventListener('click', () => zoom(1 / 1.2)));
139
+ container.querySelectorAll('[data-pan-zoom="zoom-out"]').forEach((btn) => btn.addEventListener('click', () => zoom(1.2)));
140
+ container.querySelectorAll('[data-pan-zoom="fit"]').forEach((btn) => btn.addEventListener('click', fit));
141
+
142
+ return { zoom, fit, pan };
143
+ }
144
+ })();
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-atlas-page="macro">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>Project architecture</title>
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <meta name="color-scheme" content="dark">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..500&family=Geist:wght@300..700&family=JetBrains+Mono:wght@400..600&display=swap">
11
+ <link rel="stylesheet" href="assets/architecture.css">
12
+ </head>
13
+ <body>
14
+ <header class="atlas-header">
15
+ <h1>Project architecture</h1>
16
+
17
+ </header>
18
+ <main class="atlas-main">
19
+ <section class="atlas-canvas" aria-label="Macro architecture diagram">
20
+ <div class="atlas-canvas__toolbar" role="toolbar" aria-label="Diagram controls">
21
+ <button type="button" data-pan-zoom="zoom-in" aria-label="Zoom in">+</button>
22
+ <button type="button" data-pan-zoom="zoom-out" aria-label="Zoom out">−</button>
23
+ <button type="button" data-pan-zoom="fit" aria-label="Reset view">Fit</button>
24
+ </div>
25
+ <div class="atlas-canvas__viewport" data-pan-zoom-viewport>
26
+ <svg class="atlas-svg" viewBox="0 0 320 160" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Atlas is empty"><text x="160" y="80" text-anchor="middle" fill="currentColor">Atlas has no features yet</text></svg>
27
+ </div>
28
+ <ol class="atlas-legend" aria-label="Edge legend">
29
+ <li><span class="legend-swatch legend-swatch--call"></span>call</li>
30
+ <li><span class="legend-swatch legend-swatch--return"></span>return</li>
31
+ <li><span class="legend-swatch legend-swatch--data-row"></span>data-row</li>
32
+ <li><span class="legend-swatch legend-swatch--failure"></span>failure</li>
33
+ </ol>
34
+ </section>
35
+ <section class="atlas-index" aria-label="Submodule index">
36
+ <h2>Submodule index</h2>
37
+
38
+ </section>
39
+ </main>
40
+ <script src="assets/viewer.client.js" defer></script>
41
+ </body>
42
+ </html>
@@ -34,6 +34,6 @@ description: 當你需要審查規格文檔相關變更時,調用此技能
34
34
  ### 3. 生成 code review report
35
35
 
36
36
  將上一步發現的所有問題代碼總結,並按照問題嚴重程度排序,生成code review report。report需要包含以下元素:
37
- - 問題描述、嚴重度及影響
37
+ - 問題描述、嚴重度(分為P0-P3)及影響
38
38
  - 涉及的代碼檔案、行數
39
39
  - 建議修正方案
@@ -20,4 +20,5 @@ description: 當你需要修復 code review report 之中發現的問題時,
20
20
 
21
21
  ### 2. 修復發現的問題
22
22
 
23
- 按照 code review report 之中的嚴重度排序,從最高嚴重度的問題開始建議方案修復。
23
+ 按照 code review report 之中的嚴重度排序,使用 `systematic-debug` 這個技能,從最高嚴重度的問題開始建議方案修復。
24
+ 如果外部環境允許使用 subagents,建議將修復任務拆分並分配給多個 subagents 並行完成。
package/tsconfig.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "outDir": "dist",
6
+ "rootDir": ".",
7
+ "declaration": true,
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "moduleResolution": "node",
14
+ "ignoreDeprecations": "6.0",
15
+ "types": ["node"]
16
+ },
17
+ "include": [
18
+ "bin/**/*.ts",
19
+ "lib/**/*.ts",
20
+ "test/**/*.ts"
21
+ ],
22
+ "exclude": [
23
+ "node_modules",
24
+ "dist",
25
+ ".claude",
26
+ "resources"
27
+ ]
28
+ }
@@ -1,64 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import sys
7
-
8
- from log_cli_utils import ensure_timezone, extract_timestamp, in_window, iter_input_lines, parse_cli_timestamp
9
-
10
-
11
- def parse_args() -> argparse.Namespace:
12
- parser = argparse.ArgumentParser(
13
- description="Filter log lines by timestamp window from files or stdin."
14
- )
15
- parser.add_argument("paths", nargs="*", help="Log file paths. Reads stdin when omitted.")
16
- parser.add_argument("--start", help="Inclusive start timestamp.")
17
- parser.add_argument("--end", help="Inclusive end timestamp.")
18
- parser.add_argument(
19
- "--assume-timezone",
20
- default="UTC",
21
- help="Timezone for naive timestamps and --start/--end values. Default: UTC.",
22
- )
23
- parser.add_argument(
24
- "--keep-undated",
25
- action="store_true",
26
- help="Keep lines without a parseable timestamp.",
27
- )
28
- parser.add_argument(
29
- "--count-only",
30
- action="store_true",
31
- help="Print only the count of matching lines.",
32
- )
33
- return parser.parse_args()
34
-
35
-
36
- def main() -> int:
37
- args = parse_args()
38
- assume_timezone = ensure_timezone(args.assume_timezone)
39
- start = parse_cli_timestamp(args.start, assume_timezone) if args.start else None
40
- end = parse_cli_timestamp(args.end, assume_timezone) if args.end else None
41
-
42
- if start and end and start > end:
43
- print("Error: --start must be earlier than or equal to --end.", file=sys.stderr)
44
- return 1
45
-
46
- matches = 0
47
- for line in iter_input_lines(args.paths):
48
- timestamp = extract_timestamp(line, assume_timezone)
49
- if timestamp is None and not args.keep_undated:
50
- continue
51
- if timestamp is not None and not in_window(timestamp, start, end):
52
- continue
53
-
54
- matches += 1
55
- if not args.count_only:
56
- print(line)
57
-
58
- if args.count_only:
59
- print(matches)
60
- return 0
61
-
62
-
63
- if __name__ == "__main__":
64
- sys.exit(main())
@@ -1,112 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import re
7
- from datetime import datetime, timedelta, timezone
8
- from pathlib import Path
9
- from typing import Iterator, Sequence
10
-
11
-
12
- TIMESTAMP_PATTERN = re.compile(
13
- r"(?P<timestamp>"
14
- r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:\d{2})?"
15
- r")"
16
- )
17
-
18
- TIMESTAMP_FORMATS = (
19
- "%Y-%m-%dT%H:%M:%S.%f%z",
20
- "%Y-%m-%dT%H:%M:%S%z",
21
- "%Y-%m-%d %H:%M:%S.%f%z",
22
- "%Y-%m-%d %H:%M:%S%z",
23
- "%Y-%m-%dT%H:%M:%S.%f",
24
- "%Y-%m-%dT%H:%M:%S",
25
- "%Y-%m-%d %H:%M:%S.%f",
26
- "%Y-%m-%d %H:%M:%S",
27
- )
28
-
29
-
30
- def normalize_timestamp(raw: str) -> str:
31
- value = raw.strip().replace(",", ".")
32
- if value.endswith("Z"):
33
- return value[:-1] + "+00:00"
34
- return value
35
-
36
-
37
- def parse_cli_timestamp(raw: str, assume_timezone: timezone) -> datetime:
38
- normalized = normalize_timestamp(raw)
39
- for fmt in TIMESTAMP_FORMATS:
40
- try:
41
- parsed = datetime.strptime(normalized, fmt)
42
- except ValueError:
43
- continue
44
- if parsed.tzinfo is None:
45
- return parsed.replace(tzinfo=assume_timezone)
46
- return parsed
47
- raise argparse.ArgumentTypeError(f"invalid timestamp: {raw}")
48
-
49
-
50
- def extract_timestamp(line: str, assume_timezone: timezone) -> datetime | None:
51
- match = TIMESTAMP_PATTERN.search(line)
52
- if not match:
53
- return None
54
- try:
55
- return parse_cli_timestamp(match.group("timestamp"), assume_timezone)
56
- except argparse.ArgumentTypeError:
57
- return None
58
-
59
-
60
- def build_timezone(raw: str) -> timezone:
61
- if raw.upper() == "UTC":
62
- return timezone.utc
63
-
64
- match = re.fullmatch(r"([+-])(\d{2}):(\d{2})", raw)
65
- if not match:
66
- raise argparse.ArgumentTypeError("timezone must be UTC or ±HH:MM")
67
-
68
- sign, hours, minutes = match.groups()
69
- total_minutes = int(hours) * 60 + int(minutes)
70
- if sign == "-":
71
- total_minutes *= -1
72
- return timezone.utc if total_minutes == 0 else timezone(timedelta(minutes=total_minutes))
73
-
74
-
75
- def iter_input_lines(paths: Sequence[str]) -> Iterator[str]:
76
- if not paths:
77
- import sys
78
-
79
- for line in sys.stdin:
80
- yield line.rstrip("\n")
81
- return
82
-
83
- for raw_path in paths:
84
- if raw_path == "-":
85
- import sys
86
-
87
- for line in sys.stdin:
88
- yield line.rstrip("\n")
89
- continue
90
-
91
- path = Path(raw_path)
92
- with path.open("r", encoding="utf-8") as handle:
93
- for line in handle:
94
- yield line.rstrip("\n")
95
-
96
-
97
- def in_window(
98
- timestamp: datetime | None,
99
- start: datetime | None,
100
- end: datetime | None,
101
- ) -> bool:
102
- if timestamp is None:
103
- return False
104
- if start and timestamp < start:
105
- return False
106
- if end and timestamp > end:
107
- return False
108
- return True
109
-
110
-
111
- def ensure_timezone(value: str) -> timezone:
112
- return build_timezone(value)
@@ -1,137 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import re
7
- import sys
8
- from collections import deque
9
- from typing import Callable
10
-
11
- from log_cli_utils import ensure_timezone, extract_timestamp, in_window, iter_input_lines, parse_cli_timestamp
12
-
13
-
14
- def parse_args() -> argparse.Namespace:
15
- parser = argparse.ArgumentParser(
16
- description="Search log lines by keyword or regex, with optional time filtering."
17
- )
18
- parser.add_argument("paths", nargs="*", help="Log file paths. Reads stdin when omitted.")
19
- parser.add_argument(
20
- "--keyword",
21
- action="append",
22
- default=[],
23
- help="Keyword to match. Repeat for multiple values.",
24
- )
25
- parser.add_argument(
26
- "--regex",
27
- action="append",
28
- default=[],
29
- help="Regular expression to match. Repeat for multiple values.",
30
- )
31
- parser.add_argument(
32
- "--mode",
33
- choices=["any", "all"],
34
- default="any",
35
- help="Require any or all patterns to match. Default: any.",
36
- )
37
- parser.add_argument(
38
- "--ignore-case",
39
- action="store_true",
40
- help="Case-insensitive matching for keywords and regex.",
41
- )
42
- parser.add_argument("--start", help="Inclusive start timestamp.")
43
- parser.add_argument("--end", help="Inclusive end timestamp.")
44
- parser.add_argument(
45
- "--assume-timezone",
46
- default="UTC",
47
- help="Timezone for naive timestamps and --start/--end values. Default: UTC.",
48
- )
49
- parser.add_argument(
50
- "--before-context",
51
- type=int,
52
- default=0,
53
- help="Print N lines of context before each match.",
54
- )
55
- parser.add_argument(
56
- "--after-context",
57
- type=int,
58
- default=0,
59
- help="Print N lines of context after each match.",
60
- )
61
- parser.add_argument(
62
- "--count-only",
63
- action="store_true",
64
- help="Print only the number of matching lines.",
65
- )
66
- return parser.parse_args()
67
-
68
-
69
- def build_matchers(args: argparse.Namespace) -> list[Callable[[str], bool]]:
70
- flags = re.IGNORECASE if args.ignore_case else 0
71
- matchers: list[Callable[[str], bool]] = []
72
-
73
- for keyword in args.keyword:
74
- needle = keyword.lower() if args.ignore_case else keyword
75
-
76
- def match_keyword(line: str, needle: str = needle) -> bool:
77
- haystack = line.lower() if args.ignore_case else line
78
- return needle in haystack
79
-
80
- matchers.append(match_keyword)
81
-
82
- for pattern in args.regex:
83
- compiled = re.compile(pattern, flags)
84
- matchers.append(lambda line, compiled=compiled: bool(compiled.search(line)))
85
-
86
- return matchers
87
-
88
-
89
- def line_matches(line: str, matchers: list[Callable[[str], bool]], mode: str) -> bool:
90
- if not matchers:
91
- return True
92
- results = [matcher(line) for matcher in matchers]
93
- return any(results) if mode == "any" else all(results)
94
-
95
-
96
- def main() -> int:
97
- args = parse_args()
98
- assume_timezone = ensure_timezone(args.assume_timezone)
99
- start = parse_cli_timestamp(args.start, assume_timezone) if args.start else None
100
- end = parse_cli_timestamp(args.end, assume_timezone) if args.end else None
101
- if start and end and start > end:
102
- print("Error: --start must be earlier than or equal to --end.", file=sys.stderr)
103
- return 1
104
-
105
- matchers = build_matchers(args)
106
- matches = 0
107
- before_buffer: deque[str] = deque(maxlen=args.before_context)
108
- after_remaining = 0
109
-
110
- for line in iter_input_lines(args.paths):
111
- timestamp = extract_timestamp(line, assume_timezone)
112
- if (start or end) and not in_window(timestamp, start, end):
113
- before_buffer.append(line)
114
- continue
115
-
116
- is_match = line_matches(line, matchers, args.mode)
117
-
118
- if is_match:
119
- matches += 1
120
- if not args.count_only:
121
- while before_buffer:
122
- print(before_buffer.popleft())
123
- print(line)
124
- after_remaining = args.after_context
125
- elif after_remaining > 0 and not args.count_only:
126
- print(line)
127
- after_remaining -= 1
128
-
129
- before_buffer.append(line)
130
-
131
- if args.count_only:
132
- print(matches)
133
- return 0
134
-
135
-
136
- if __name__ == "__main__":
137
- sys.exit(main())
@@ -1,95 +0,0 @@
1
- #!/usr/bin/env python3
2
-
3
- from __future__ import annotations
4
-
5
- import importlib.util
6
- import io
7
- import tempfile
8
- import unittest
9
- from argparse import Namespace
10
- from pathlib import Path
11
- from unittest.mock import patch
12
-
13
-
14
- SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "filter_logs_by_time.py"
15
- SCRIPT_DIR = SCRIPT_PATH.parent
16
- if str(SCRIPT_DIR) not in __import__("sys").path:
17
- __import__("sys").path.insert(0, str(SCRIPT_DIR))
18
- SPEC = importlib.util.spec_from_file_location("filter_logs_by_time", SCRIPT_PATH)
19
- MODULE = importlib.util.module_from_spec(SPEC)
20
- SPEC.loader.exec_module(MODULE)
21
-
22
-
23
- class FilterLogsByTimeTests(unittest.TestCase):
24
- def test_filters_lines_inside_window(self) -> None:
25
- with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
26
- handle.write(
27
- "2026-03-24T10:00:00Z INFO boot\n"
28
- "2026-03-24T10:05:00Z ERROR failed\n"
29
- "2026-03-24T10:10:00Z INFO recovered\n"
30
- )
31
- path = handle.name
32
-
33
- args = Namespace(
34
- paths=[path],
35
- start="2026-03-24T10:01:00Z",
36
- end="2026-03-24T10:06:00Z",
37
- assume_timezone="UTC",
38
- keep_undated=False,
39
- count_only=False,
40
- )
41
-
42
- with patch.object(MODULE, "parse_args", return_value=args), patch(
43
- "sys.stdout", new_callable=io.StringIO
44
- ) as stdout:
45
- code = MODULE.main()
46
-
47
- self.assertEqual(code, 0)
48
- self.assertEqual(stdout.getvalue().strip(), "2026-03-24T10:05:00Z ERROR failed")
49
-
50
- def test_keep_undated_lines_when_requested(self) -> None:
51
- with tempfile.NamedTemporaryFile("w+", encoding="utf-8", delete=False) as handle:
52
- handle.write(
53
- "2026-03-24T10:00:00Z INFO boot\n"
54
- "plain undated line\n"
55
- )
56
- path = handle.name
57
-
58
- args = Namespace(
59
- paths=[path],
60
- start=None,
61
- end=None,
62
- assume_timezone="UTC",
63
- keep_undated=True,
64
- count_only=False,
65
- )
66
-
67
- with patch.object(MODULE, "parse_args", return_value=args), patch(
68
- "sys.stdout", new_callable=io.StringIO
69
- ) as stdout:
70
- code = MODULE.main()
71
-
72
- self.assertEqual(code, 0)
73
- self.assertIn("plain undated line", stdout.getvalue())
74
-
75
- def test_rejects_inverted_window(self) -> None:
76
- args = Namespace(
77
- paths=[],
78
- start="2026-03-24T10:10:00Z",
79
- end="2026-03-24T10:00:00Z",
80
- assume_timezone="UTC",
81
- keep_undated=False,
82
- count_only=False,
83
- )
84
-
85
- with patch.object(MODULE, "parse_args", return_value=args), patch(
86
- "sys.stderr", new_callable=io.StringIO
87
- ) as stderr:
88
- code = MODULE.main()
89
-
90
- self.assertEqual(code, 1)
91
- self.assertIn("--start", stderr.getvalue())
92
-
93
-
94
- if __name__ == "__main__":
95
- unittest.main()