@laitszkin/apollo-toolkit 3.13.2 → 3.14.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.
- package/AGENTS.md +7 -7
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +8 -8
- package/analyse-app-logs/SKILL.md +3 -3
- package/bin/apollo-toolkit.ts +7 -0
- package/codex/codex-memory-manager/SKILL.md +2 -2
- package/codex/learn-skill-from-conversations/SKILL.md +3 -3
- package/dist/bin/apollo-toolkit.d.ts +2 -0
- package/dist/bin/apollo-toolkit.js +7 -0
- package/dist/lib/cli.d.ts +41 -0
- package/dist/lib/cli.js +655 -0
- package/dist/lib/installer.d.ts +59 -0
- package/dist/lib/installer.js +404 -0
- package/dist/lib/tool-runner.d.ts +19 -0
- package/dist/lib/tool-runner.js +536 -0
- package/dist/lib/tools/architecture.d.ts +2 -0
- package/dist/lib/tools/architecture.js +23 -0
- package/dist/lib/tools/create-specs.d.ts +2 -0
- package/dist/lib/tools/create-specs.js +175 -0
- package/dist/lib/tools/docs-to-voice.d.ts +2 -0
- package/dist/lib/tools/docs-to-voice.js +705 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
- package/dist/lib/tools/extract-conversations.d.ts +2 -0
- package/dist/lib/tools/extract-conversations.js +105 -0
- package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
- package/dist/lib/tools/extract-pdf-text.js +92 -0
- package/dist/lib/tools/filter-logs.d.ts +2 -0
- package/dist/lib/tools/filter-logs.js +94 -0
- package/dist/lib/tools/find-github-issues.d.ts +2 -0
- package/dist/lib/tools/find-github-issues.js +176 -0
- package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
- package/dist/lib/tools/generate-storyboard-images.js +419 -0
- package/dist/lib/tools/log-cli-utils.d.ts +35 -0
- package/dist/lib/tools/log-cli-utils.js +233 -0
- package/dist/lib/tools/open-github-issue.d.ts +2 -0
- package/dist/lib/tools/open-github-issue.js +750 -0
- package/dist/lib/tools/read-github-issue.d.ts +2 -0
- package/dist/lib/tools/read-github-issue.js +134 -0
- package/dist/lib/tools/render-error-book.d.ts +2 -0
- package/dist/lib/tools/render-error-book.js +265 -0
- package/dist/lib/tools/render-katex.d.ts +2 -0
- package/dist/lib/tools/render-katex.js +294 -0
- package/dist/lib/tools/review-threads.d.ts +2 -0
- package/dist/lib/tools/review-threads.js +491 -0
- package/dist/lib/tools/search-logs.d.ts +2 -0
- package/dist/lib/tools/search-logs.js +164 -0
- package/dist/lib/tools/sync-memory-index.d.ts +2 -0
- package/dist/lib/tools/sync-memory-index.js +113 -0
- package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
- package/dist/lib/tools/validate-openai-agent-config.js +190 -0
- package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
- package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
- package/dist/lib/types.d.ts +82 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/updater.d.ts +34 -0
- package/dist/lib/updater.js +112 -0
- package/dist/lib/utils/format.d.ts +2 -0
- package/dist/lib/utils/format.js +6 -0
- package/dist/lib/utils/terminal.d.ts +12 -0
- package/dist/lib/utils/terminal.js +26 -0
- package/docs-to-voice/SKILL.md +0 -1
- package/generate-spec/SKILL.md +1 -1
- package/katex/SKILL.md +1 -2
- package/lib/cli.ts +780 -0
- package/lib/installer.ts +466 -0
- package/lib/tool-runner.ts +561 -0
- package/lib/tools/architecture.ts +20 -0
- package/lib/tools/create-specs.ts +204 -0
- package/lib/tools/docs-to-voice.ts +799 -0
- package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
- package/lib/tools/extract-conversations.ts +114 -0
- package/lib/tools/extract-pdf-text.ts +99 -0
- package/lib/tools/filter-logs.ts +118 -0
- package/lib/tools/find-github-issues.ts +211 -0
- package/lib/tools/generate-storyboard-images.ts +455 -0
- package/lib/tools/log-cli-utils.ts +262 -0
- package/lib/tools/open-github-issue.ts +930 -0
- package/lib/tools/read-github-issue.ts +179 -0
- package/lib/tools/render-error-book.ts +300 -0
- package/lib/tools/render-katex.ts +325 -0
- package/lib/tools/review-threads.ts +590 -0
- package/lib/tools/search-logs.ts +200 -0
- package/lib/tools/sync-memory-index.ts +114 -0
- package/lib/tools/validate-openai-agent-config.ts +213 -0
- package/lib/tools/validate-skill-frontmatter.ts +124 -0
- package/lib/types.ts +90 -0
- package/lib/updater.ts +165 -0
- package/lib/utils/format.ts +7 -0
- package/lib/utils/terminal.ts +22 -0
- package/open-github-issue/SKILL.md +2 -2
- package/optimise-skill/SKILL.md +1 -1
- package/package.json +13 -4
- package/resources/project-architecture/assets/architecture.css +764 -0
- package/resources/project-architecture/assets/viewer.client.js +144 -0
- package/resources/project-architecture/index.html +42 -0
- package/review-spec-related-changes/SKILL.md +1 -1
- package/solve-issues-found-during-review/SKILL.md +2 -1
- package/tsconfig.json +28 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
- package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
- package/analyse-app-logs/scripts/search_logs.py +0 -137
- package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
- package/analyse-app-logs/tests/test_search_logs.py +0 -100
- package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
- package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
- package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
- package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
- package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
- package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
- package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
- package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
- package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
- package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
- package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
- package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
- package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/generate-spec/scripts/create-specs +0 -215
- package/generate-spec/tests/test_create_specs.py +0 -200
- package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
- package/init-project-html/scripts/architecture.js +0 -296
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/katex/scripts/render_katex.py +0 -247
- package/katex/scripts/render_katex.sh +0 -11
- package/katex/tests/test_render_katex.py +0 -174
- package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
- package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/open_github_issue.py +0 -705
- package/open-github-issue/tests/test_open_github_issue.py +0 -381
- package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
- package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/find_issues.py +0 -148
- package/read-github-issue/scripts/read_issue.py +0 -108
- package/read-github-issue/tests/test_find_issues.py +0 -127
- package/read-github-issue/tests/test_read_issue.py +0 -109
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/review_threads.py +0 -425
- package/resolve-review-comments/tests/test_review_threads.py +0 -74
- package/scripts/validate_openai_agent_config.py +0 -209
- package/scripts/validate_skill_frontmatter.py +0 -131
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
- package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
- package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
- package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
- 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>
|
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
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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()
|