clipqa-mcp 0.2.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 (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +152 -0
  3. package/build/artifacts.d.ts +4 -0
  4. package/build/artifacts.d.ts.map +1 -0
  5. package/build/artifacts.js +24 -0
  6. package/build/artifacts.js.map +1 -0
  7. package/build/backends.d.ts +16 -0
  8. package/build/backends.d.ts.map +1 -0
  9. package/build/backends.js +48 -0
  10. package/build/backends.js.map +1 -0
  11. package/build/config.d.ts +7 -0
  12. package/build/config.d.ts.map +1 -0
  13. package/build/config.js +15 -0
  14. package/build/config.js.map +1 -0
  15. package/build/index.d.ts +3 -0
  16. package/build/index.d.ts.map +1 -0
  17. package/build/index.js +25 -0
  18. package/build/index.js.map +1 -0
  19. package/build/media.d.ts +13 -0
  20. package/build/media.d.ts.map +1 -0
  21. package/build/media.js +139 -0
  22. package/build/media.js.map +1 -0
  23. package/build/process-runner.d.ts +9 -0
  24. package/build/process-runner.d.ts.map +1 -0
  25. package/build/process-runner.js +25 -0
  26. package/build/process-runner.js.map +1 -0
  27. package/build/recorder.d.ts +42 -0
  28. package/build/recorder.d.ts.map +1 -0
  29. package/build/recorder.js +272 -0
  30. package/build/recorder.js.map +1 -0
  31. package/build/replay.d.ts +10 -0
  32. package/build/replay.d.ts.map +1 -0
  33. package/build/replay.js +39 -0
  34. package/build/replay.js.map +1 -0
  35. package/build/sampling.d.ts +13 -0
  36. package/build/sampling.d.ts.map +1 -0
  37. package/build/sampling.js +52 -0
  38. package/build/sampling.js.map +1 -0
  39. package/build/server.d.ts +5 -0
  40. package/build/server.d.ts.map +1 -0
  41. package/build/server.js +360 -0
  42. package/build/server.js.map +1 -0
  43. package/build/session-store.d.ts +16 -0
  44. package/build/session-store.d.ts.map +1 -0
  45. package/build/session-store.js +93 -0
  46. package/build/session-store.js.map +1 -0
  47. package/build/types.d.ts +46 -0
  48. package/build/types.d.ts.map +1 -0
  49. package/build/types.js +2 -0
  50. package/build/types.js.map +1 -0
  51. package/build/visual.d.ts +62 -0
  52. package/build/visual.d.ts.map +1 -0
  53. package/build/visual.js +145 -0
  54. package/build/visual.js.map +1 -0
  55. package/build/windows.d.ts +4 -0
  56. package/build/windows.d.ts.map +1 -0
  57. package/build/windows.js +60 -0
  58. package/build/windows.js.map +1 -0
  59. package/docs/assets/banner.png +0 -0
  60. package/package.json +67 -0
  61. package/scripts/install-wgc.ps1 +40 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WheresRace and ClipQA contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,152 @@
1
+ <div align="center">
2
+ <img src="docs/assets/banner.png" alt="ClipQA" width="900" />
3
+ <p><strong>Record. Inspect. Compare.</strong></p>
4
+ <p><a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-7c3aed" /></a> <img alt="Node 20+" src="https://img.shields.io/badge/node-20%2B-22c55e" /></p>
5
+ </div>
6
+
7
+ **Video evidence for AI-assisted testing of desktop apps, websites, creative tools, and games.** Record a selected source, see screenshots while tests run, inspect subtle changes, and compare before/after clips with a visual report.
8
+
9
+ ClipQA runs locally through MCP. It never moves your mouse, types, or activates application windows. Saved videos are available for you to watch, not just for an AI to analyze.
10
+
11
+ > **v0.2 public preview.** Windows Graphics Capture has been tested live on Windows. Linux X11 and macOS display backends have argument/parsing tests; native capture on those systems still needs validation. See [platform setup](docs/CAPTURE.md).
12
+
13
+ ## Quick start
14
+
15
+ ```sh
16
+ npx -y clipqa-mcp
17
+ ```
18
+
19
+ MCP client configuration:
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "clipqa": {
25
+ "command": "npx",
26
+ "args": ["-y", "clipqa-mcp"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ Node.js 20+ is required. FFmpeg and FFprobe are supplied through package dependencies. Override their locations with `CLIPQA_FFMPEG_PATH` and `CLIPQA_FFPROBE_PATH`. Change the session directory with `CLIPQA_DATA_DIR` (default `~/.clipqa/sessions`).
33
+
34
+ ### Stronger Windows capture
35
+
36
+ The bundled FFmpeg may not include Windows Graphics Capture. This optional setup downloads a newer build from the Windows build provider linked by FFmpeg, verifies its published SHA-256, and installs it into `~/.clipqa/bin`:
37
+
38
+ ```powershell
39
+ npx -y clipqa-mcp --setup-wgc
40
+ ```
41
+
42
+ Restart your MCP client and call `capture_status`. Automatic selection prefers `gfxcapture` when available, falling back to `gdigrab`. An explicitly requested unavailable backend returns an error. Capture never silently switches from a window to an entire display.
43
+
44
+ ## See what is happening
45
+
46
+ Ask your assistant:
47
+
48
+ > Record this app at 15 FPS, keeping the last 30 seconds. Show me a progress screenshot. Include a short video preview when something changes. Stop and show me the saved video when the test finishes.
49
+
50
+ - `recording_progress` returns a screenshot as an MCP image. With `includeVideo: true`, it also returns a short MP4 preview and its local path. Recording continues.
51
+ - Live previews use completed segments and lag by roughly two seconds. Early requests return `previewReady: false`.
52
+ - `get_clip` returns the completed video's absolute path and a video resource link.
53
+ - Inline video playback depends on your MCP client. A client with local-media support can render the path in chat; otherwise open it in a media player. Images use standard MCP image responses.
54
+
55
+ Saved videos, previews, screenshots, and reports stay on disk until explicitly deleted. ClipQA does **not** automatically erase evidence once an AI inspects it.
56
+
57
+ ## Tools
58
+
59
+ | Tool | Purpose |
60
+ | --- | --- |
61
+ | `capture_status` | Detect capture backends and explain requirements. |
62
+ | `list_sources` | List Windows/X11 windows or macOS displays. |
63
+ | `list_windows` | List Windows/X11 titles and native handles. |
64
+ | `start_recording` | Record a source, optionally retaining the last N seconds. |
65
+ | `recording_progress` | Return status, a screenshot, and optionally a live video preview. |
66
+ | `mark_event` | Attach actions, assertions, errors, or application logs to timestamps. |
67
+ | `stop_recording` | Stop capture and finalize the saved video. |
68
+ | `get_clip` | Access a completed session's video. |
69
+ | `list_sessions` | Browse recordings. |
70
+ | `import_clip` | Copy an existing local video into a session on any platform. |
71
+ | `inspect_clip` | Return a contact sheet, perceptual-change index and quality warnings. |
72
+ | `get_frames` | Retrieve specific frames with configurable image size. |
73
+ | `compare_clips` | Generate paired frames, difference maps, and HTML/JSON reports. |
74
+ | `delete_session` | Delete a stopped session and all its artifacts. |
75
+ | `cleanup_sessions` | Preview or delete old sessions; defaults to a dry run. |
76
+
77
+ ### Rolling recording
78
+
79
+ ```json
80
+ {
81
+ "hwnd": "123456",
82
+ "backend": "auto",
83
+ "fps": 15,
84
+ "maxWidth": 1280,
85
+ "maxSeconds": 300,
86
+ "bufferSeconds": 30
87
+ }
88
+ ```
89
+
90
+ Replace the handle with one from `list_sources`. Without `bufferSeconds`, the full recording is retained. Rolling mode removes old two-second disk segments while recording. A few extra seconds are temporarily retained for segment boundaries. Stopping saves only the last N seconds (or the available duration if shorter), removes working segments, drops older events, and shifts retained event timestamps to match the saved video.
91
+
92
+ This is a **disk buffer**, bounded by time rather than bytes. Preview exports and finalization need extra space. Saved previews remain until cleanup.
93
+
94
+ ### Perceptual indexing and comparison
95
+
96
+ `inspect_clip` analyzes resized RGB frames using local structural similarity and color differences. Local scoring can find a small button flash that scene-cut detection misses. `indexFps` defaults to 15 and can be raised to 60. Analysis streams frames with bounded candidate storage and caches results. Contact sheets are capped at 24 images; exact-frame requests at 16.
97
+
98
+ `compare_clips` accepts two session IDs, start offsets, duration, sample count, and threshold. `ignoreRegions` uses normalized coordinates (0–1) to exclude expected changes. Reports include paired frames, difference maps, scores and timestamps. The highest-scoring pair is returned as an image in chat. Comparison uses a common 320×180 analysis canvas; inspect larger frames for very small details.
99
+
100
+ ```json
101
+ {
102
+ "beforeSessionId": "<baseline-session-uuid>",
103
+ "afterSessionId": "<new-session-uuid>",
104
+ "beforeStart": 0,
105
+ "afterStart": 0.5,
106
+ "seconds": 10,
107
+ "samples": 30,
108
+ "threshold": 0.08,
109
+ "ignoreRegions": [{"x": 0.9, "y": 0, "width": 0.1, "height": 0.1}]
110
+ }
111
+ ```
112
+
113
+ Offsets align runs manually. Reports identify **visual differences**, which can be intentional; they do not automatically establish a regression.
114
+
115
+ ## Privacy and resources
116
+
117
+ - Windows/X11 capture targets one window. macOS captures an explicitly selected **display**, including other visible apps.
118
+ - Recorder processes are hidden and do not control input or focus.
119
+ - Recording excludes audio and webcams. Imported files are copied as supplied and may contain audio from their original recorder.
120
+ - ClipQA opens no network listener and does not upload recordings itself. Images returned over MCP go to your client, which may send them to its AI provider.
121
+ - One recording runs per MCP process. Configure FPS and `maxWidth` to manage overhead. Automatic encoder selection probes hardware first.
122
+ - Startup waits for actual frames. Wall-clock limits stop stalled capture. Normal shutdown finalizes recordings; concurrent event updates are serialized.
123
+ - Cleanup defaults to a preview and excludes active sessions.
124
+
125
+ ## Validation
126
+
127
+ ```sh
128
+ npm install
129
+ npm run check
130
+ npm test
131
+ npm run benchmark
132
+ npm run build
133
+ ```
134
+
135
+ Tests use real FFmpeg video for rolling retention, subtle changes, clean controls, ignore masks and reports. A real MCP client exercises import → annotation → inspection → comparison → progress → cleanup. The opt-in Windows test creates its own animated window without activation and verifies WGC, rolling output and live previews. See [test instructions](docs/TESTING.md).
136
+
137
+ The original synthetic timestamp benchmark covers 8/8 defect intervals versus 2/8 with four periodic screenshots, using 144 versus 36 selected frames. That is **temporal evidence coverage**, not AI diagnosis accuracy, with unequal image budgets. Real-video tests separately verify the new analysis code. See [the evaluation protocol](docs/EVALUATION.md) for model evaluations.
138
+
139
+ ## Remaining constraints
140
+
141
+ - Minimized apps may stop rendering; minimized Windows targets are rejected at startup. ClipQA does not restore or focus them. Protected content can remain black. Static/black-frame warnings flag suspicious evidence, not confirmed bugs.
142
+ - WGC needs a suitable OS, driver and FFmpeg build. X11 can include occluding windows and stops when a target is unmapped. Native Wayland and macOS window-only capture are not implemented.
143
+ - Sampling can miss changes between frames or below analysis resolution. Comparisons require aligned runs. Internal failures still need logs and reproducible test steps.
144
+ - Recording, analysis, disk writes and finalization consume resources. Zero slowdown is not guaranteed. Full recordings and saved previews grow until cleanup.
145
+ - Forced termination may leave segments without a finalized MP4. Use orderly shutdown; preserve interrupted sessions if you want to recover their evidence.
146
+ - Releases are published to npm and GitHub. Direct GitHub installs remain available for testing unreleased commits.
147
+
148
+ The Roblox Studio plugin is deferred. Core recording, progress, comparison and event tools work independently of any game engine.
149
+
150
+ ## License
151
+
152
+ MIT. FFmpeg binaries have separate licenses; consult the selected distributor or package's license and source information.
@@ -0,0 +1,4 @@
1
+ import { SessionStore } from "./session-store.js";
2
+ export declare function directoryBytes(directory: string): Promise<number>;
3
+ export declare function deleteSession(store: SessionStore, id: string): Promise<void>;
4
+ //# sourceMappingURL=artifacts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.d.ts","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,wBAAsB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQvE;AAED,wBAAsB,aAAa,CAAC,KAAK,EAAE,YAAY,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOlF"}
@@ -0,0 +1,24 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export async function directoryBytes(directory) {
4
+ let total = 0;
5
+ for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
6
+ const file = path.join(directory, entry.name);
7
+ if (entry.isDirectory())
8
+ total += await directoryBytes(file);
9
+ else if (entry.isFile())
10
+ total += (await fs.stat(file).catch(() => ({ size: 0 }))).size;
11
+ }
12
+ return total;
13
+ }
14
+ export async function deleteSession(store, id) {
15
+ const session = await store.read(id);
16
+ if (session.state === "recording")
17
+ throw new Error("Stop the recording before deleting its artifacts.");
18
+ const root = await fs.realpath(store.rootDir), target = await fs.realpath(store.sessionDir(id));
19
+ const relative = path.relative(root, target);
20
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative !== id)
21
+ throw new Error("Refusing to delete a directory outside the session store.");
22
+ await fs.rm(target, { recursive: true });
23
+ }
24
+ //# sourceMappingURL=artifacts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.js","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAG7B,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,SAAiB;IACpD,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QACzE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QAC9C,IAAI,KAAK,CAAC,WAAW,EAAE;YAAE,KAAK,IAAI,MAAM,cAAc,CAAC,IAAI,CAAC,CAAC;aACxD,IAAI,KAAK,CAAC,MAAM,EAAE;YAAE,KAAK,IAAI,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAC1F,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,KAAmB,EAAE,EAAU;IACjE,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACrC,IAAI,OAAO,CAAC,KAAK,KAAK,WAAW;QAAE,MAAM,IAAI,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACxG,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC,CAAC;IAChG,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC7C,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,IAAI,QAAQ,KAAK,EAAE;QAAE,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;IACzK,MAAM,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;AAC3C,CAAC"}
@@ -0,0 +1,16 @@
1
+ import type { CommandRunner } from "./process-runner.js";
2
+ import type { CaptureTarget } from "./types.js";
3
+ export type Backend = "gdigrab" | "gfxcapture" | "x11grab" | "avfoundation";
4
+ export interface Capabilities {
5
+ platform: string;
6
+ available: Backend[];
7
+ recommended?: Backend;
8
+ warnings: string[];
9
+ }
10
+ export declare function capabilities(ffmpeg: string, runner: CommandRunner, platform?: NodeJS.Platform): Promise<Capabilities>;
11
+ export declare function captureInput(backend: Backend, target: CaptureTarget, fps: number, mouse: boolean): string[];
12
+ export declare function listScreens(ffmpeg: string, runner: CommandRunner): Promise<Array<{
13
+ screenIndex: number;
14
+ name: string;
15
+ }>>;
16
+ //# sourceMappingURL=backends.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backends.d.ts","sourceRoot":"","sources":["../src/backends.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,MAAM,OAAO,GAAG,SAAS,GAAG,YAAY,GAAG,SAAS,GAAG,cAAc,CAAC;AAC5E,MAAM,WAAW,YAAY;IAAG,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,EAAE,CAAC;IAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE;AAEnH,wBAAsB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,QAAQ,kBAAmB,GAAG,OAAO,CAAC,YAAY,CAAC,CAc5H;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,EAAE,CAgB3G;AAED,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,KAAK,CAAC;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAK9H"}
@@ -0,0 +1,48 @@
1
+ export async function capabilities(ffmpeg, runner, platform = process.platform) {
2
+ const [filters, devices] = await Promise.all([
3
+ runner.run(ffmpeg, ["-hide_banner", "-filters"]),
4
+ runner.run(ffmpeg, ["-hide_banner", "-devices"]),
5
+ ]);
6
+ if (filters.code || devices.code)
7
+ throw new Error("Cannot query FFmpeg capabilities. Check CLIPQA_FFMPEG_PATH.");
8
+ const text = [filters.stdout, filters.stderr, devices.stdout, devices.stderr].join("\n");
9
+ const names = platform === "win32" ? ["gfxcapture", "gdigrab"] : platform === "darwin" ? ["avfoundation"] : platform === "linux" ? ["x11grab"] : [];
10
+ const available = names.filter(name => new RegExp(`\\b${name}\\b`).test(text));
11
+ const warnings = [];
12
+ if (platform === "win32" && !available.includes("gfxcapture"))
13
+ warnings.push("This FFmpeg lacks Windows Graphics Capture. Set CLIPQA_FFMPEG_PATH to a build with the gfxcapture filter to enable it.");
14
+ if (platform === "linux")
15
+ warnings.push("X11 requires DISPLAY and x11grab. Native Wayland capture needs a portal backend and is not supported; import a video recorded by a portal-aware recorder instead.");
16
+ if (platform === "darwin")
17
+ warnings.push("macOS records an explicitly selected display, including other visible apps. Screen Recording permission is required. Window-only capture is not provided by AVFoundation.");
18
+ return { platform, available, ...(available[0] ? { recommended: available[0] } : {}), warnings };
19
+ }
20
+ export function captureInput(backend, target, fps, mouse) {
21
+ switch (backend) {
22
+ case "gfxcapture":
23
+ if (!target.hwnd || !/^[1-9]\d*$/.test(target.hwnd))
24
+ throw new Error("Windows Graphics Capture requires a nonzero HWND.");
25
+ return ["-filter_complex", `gfxcapture=hwnd=${target.hwnd}:capture_cursor=${Number(mouse)}:max_framerate=${fps},hwdownload,format=bgra,fps=${fps},pad=ceil(iw/2)*2:ceil(ih/2)*2[out]`, "-map", "[out]"];
26
+ case "gdigrab":
27
+ if (!target.title && !target.hwnd)
28
+ throw new Error("Provide an exact window title or HWND.");
29
+ return ["-f", "gdigrab", "-framerate", String(fps), "-draw_mouse", String(Number(mouse)), "-thread_queue_size", "64", "-i", target.title ? `title=${target.title}` : `hwnd=${target.hwnd}`];
30
+ case "x11grab":
31
+ if (!target.windowId || !/^(?:0x[\da-fA-F]+|[1-9]\d*)$/.test(target.windowId) || BigInt(target.windowId) === 0n)
32
+ throw new Error("Provide a nonzero X11 windowId from list_windows or xwininfo.");
33
+ if (!target.display || !/^(?:[\w.-]+)?:\d+(?:\.\d+)?$/.test(target.display))
34
+ throw new Error("Provide an X11 display such as :0.0 (or set DISPLAY).");
35
+ return ["-f", "x11grab", "-framerate", String(fps), "-draw_mouse", String(Number(mouse)), "-window_id", target.windowId, "-i", target.display];
36
+ case "avfoundation":
37
+ if (!Number.isInteger(target.screenIndex) || target.screenIndex < 0)
38
+ throw new Error("Provide a screenIndex from list_sources; camera devices are not accepted.");
39
+ return ["-f", "avfoundation", "-framerate", String(fps), "-capture_cursor", String(Number(mouse)), "-capture_mouse_clicks", "0", "-i", `${target.screenIndex}:none`];
40
+ }
41
+ }
42
+ export async function listScreens(ffmpeg, runner) {
43
+ // AVFoundation exits nonzero after listing. Parse video section only; never expose cameras.
44
+ const result = await runner.run(ffmpeg, ["-hide_banner", "-f", "avfoundation", "-list_devices", "true", "-i", ""]);
45
+ const video = result.stderr.split("AVFoundation audio devices")[0] ?? "";
46
+ return [...video.matchAll(/\[(\d+)\]\s+(Capture screen[^\r\n]*)/g)].map(m => ({ screenIndex: Number(m[1]), name: m[2] }));
47
+ }
48
+ //# sourceMappingURL=backends.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backends.js","sourceRoot":"","sources":["../src/backends.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,7 @@
1
+ export interface ClipQaConfig {
2
+ dataDir: string;
3
+ ffmpegPath: string;
4
+ ffprobePath: string;
5
+ }
6
+ export declare function resolveConfig(env?: NodeJS.ProcessEnv): ClipQaConfig;
7
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,YAAY;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,aAAa,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,YAAY,CAQhF"}
@@ -0,0 +1,15 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import ffmpegStatic from "ffmpeg-static";
5
+ import ffprobeStatic from "ffprobe-static";
6
+ export function resolveConfig(env = process.env) {
7
+ const managed = path.join(os.homedir(), ".clipqa", "bin");
8
+ const executable = (name) => path.join(managed, `${name}${process.platform === "win32" ? ".exe" : ""}`);
9
+ return {
10
+ dataDir: env.CLIPQA_DATA_DIR ?? path.join(os.homedir(), ".clipqa", "sessions"),
11
+ ffmpegPath: env.CLIPQA_FFMPEG_PATH ?? (existsSync(executable("ffmpeg")) ? executable("ffmpeg") : ffmpegStatic ?? "ffmpeg"),
12
+ ffprobePath: env.CLIPQA_FFPROBE_PATH ?? (existsSync(executable("ffprobe")) ? executable("ffprobe") : ffprobeStatic.path ?? "ffprobe"),
13
+ };
14
+ }
15
+ //# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,YAAY,MAAM,eAAe,CAAC;AACzC,OAAO,aAAa,MAAM,gBAAgB,CAAC;AAQ3C,MAAM,UAAU,aAAa,CAAC,GAAG,GAAsB,OAAO,CAAC,GAAG;IAChE,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,KAAK,CAAC,CAAC;IAC1D,MAAM,UAAU,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,KAAK,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAChH,OAAO;QACL,OAAO,EAAE,GAAG,CAAC,eAAe,IAAI,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,UAAU,CAAC;QAC9E,UAAU,EAAE,GAAG,CAAC,kBAAkB,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,YAAY,IAAI,QAAQ,CAAC;QAC1H,WAAW,EAAE,GAAG,CAAC,mBAAmB,IAAI,CAAC,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,IAAI,IAAI,SAAS,CAAC;KACtI,CAAC;AACJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
package/build/index.js ADDED
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { resolveConfig } from "./config.js";
3
+ import { serve } from "./server.js";
4
+ import { spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ if (process.argv.includes("--setup-wgc")) {
7
+ if (process.platform !== "win32") {
8
+ console.error("Windows Graphics Capture setup is Windows-only.");
9
+ process.exitCode = 1;
10
+ }
11
+ else {
12
+ const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", fileURLToPath(new URL("../scripts/install-wgc.ps1", import.meta.url))], { windowsHide: true, stdio: "inherit" });
13
+ child.on("error", error => { console.error(error.message); process.exitCode = 1; });
14
+ child.on("close", code => { process.exitCode = code ?? 1; });
15
+ }
16
+ }
17
+ else if (process.argv.includes("--help")) {
18
+ console.log("ClipQA MCP — start without arguments for STDIO MCP.\n--setup-wgc Install WGC-capable FFmpeg on Windows (downloads a verified build).\n--help Show this help.");
19
+ }
20
+ else
21
+ serve(resolveConfig()).catch((error) => {
22
+ console.error("clipqa-mcp failed to start:", error);
23
+ process.exitCode = 1;
24
+ });
25
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAC3C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,IAAI,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC;IACzC,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;QAAC,OAAO,CAAC,KAAK,CAAC,iDAAiD,CAAC,CAAC;QAAC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IAAC,CAAC;SACxH,CAAC;QACJ,MAAM,KAAK,GAAG,KAAK,CAAC,gBAAgB,EAAE,CAAC,YAAY,EAAE,kBAAkB,EAAE,QAAQ,EAAE,OAAO,EAAE,aAAa,CAAC,IAAI,GAAG,CAAC,4BAA4B,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,WAAW,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,CAAC,CAAC;QAC7M,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACpF,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC/D,CAAC;AACH,CAAC;KAAM,IAAI,OAAO,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;IAC3C,OAAO,CAAC,GAAG,CAAC,qKAAqK,CAAC,CAAC;AACrL,CAAC;;IAAM,KAAK,CAAC,aAAa,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE;QACrD,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;QACpD,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;IACvB,CAAC,CAAC,CAAC"}
@@ -0,0 +1,13 @@
1
+ import type { CommandRunner } from "./process-runner.js";
2
+ export declare class MediaProcessor {
3
+ private readonly ffmpegPath;
4
+ private readonly ffprobePath;
5
+ private readonly runner;
6
+ constructor(ffmpegPath: string, ffprobePath: string, runner: CommandRunner);
7
+ frameRate(videoPath: string): Promise<number>;
8
+ duration(videoPath: string): Promise<number>;
9
+ extractFrame(videoPath: string, timestamp: number, outputPath: string, maxWidth?: number): Promise<void>;
10
+ detectVisualChanges(videoPath: string, threshold?: number, maxChanges?: number): Promise<number[]>;
11
+ createContactSheet(videoPath: string, outputPath: string, timestamps: number[]): Promise<number[]>;
12
+ }
13
+ //# sourceMappingURL=media.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.d.ts","sourceRoot":"","sources":["../src/media.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAGzD,qBAAa,cAAc;IAEvB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,MAAM;IAHzB,YACmB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,aAAa,EACpC;IAEE,SAAS,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAKlD;IAEK,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAejD;IAEK,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,QAAQ,SAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAwB3G;IAEK,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,SAAS,SAAO,EAChB,UAAU,SAAI,GACb,OAAO,CAAC,MAAM,EAAE,CAAC,CA2BnB;IAEK,kBAAkB,CACtB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAAE,GACnB,OAAO,CAAC,MAAM,EAAE,CAAC,CA2CnB;CACF"}
package/build/media.js ADDED
@@ -0,0 +1,139 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import sharp from "sharp";
4
+ import { conciseError } from "./process-runner.js";
5
+ export class MediaProcessor {
6
+ ffmpegPath;
7
+ ffprobePath;
8
+ runner;
9
+ constructor(ffmpegPath, ffprobePath, runner) {
10
+ this.ffmpegPath = ffmpegPath;
11
+ this.ffprobePath = ffprobePath;
12
+ this.runner = runner;
13
+ }
14
+ async frameRate(videoPath) {
15
+ const result = await this.runner.run(this.ffprobePath, ["-v", "error", "-protocol_whitelist", "file,pipe", "-select_streams", "v:0", "-show_entries", "stream=avg_frame_rate", "-of", "default=noprint_wrappers=1:nokey=1", videoPath]);
16
+ const [n, d] = result.stdout.trim().split("/").map(Number);
17
+ const rate = n / (d ?? 1);
18
+ return Number.isFinite(rate) && rate > 0 ? rate : 30;
19
+ }
20
+ async duration(videoPath) {
21
+ const result = await this.runner.run(this.ffprobePath, [
22
+ "-v",
23
+ "error",
24
+ "-protocol_whitelist", "file,pipe",
25
+ "-show_entries",
26
+ "format=duration",
27
+ "-of",
28
+ "default=noprint_wrappers=1:nokey=1",
29
+ videoPath,
30
+ ]);
31
+ if (result.code !== 0)
32
+ throw new Error(`Unable to inspect clip: ${conciseError(result.stderr)}`);
33
+ const duration = Number(result.stdout.trim());
34
+ if (!Number.isFinite(duration))
35
+ throw new Error("FFprobe returned an invalid duration.");
36
+ return duration;
37
+ }
38
+ async extractFrame(videoPath, timestamp, outputPath, maxWidth = 1280) {
39
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
40
+ await fs.unlink(outputPath).catch(error => { if (error.code !== "ENOENT")
41
+ throw error; });
42
+ const result = await this.runner.run(this.ffmpegPath, [
43
+ "-hide_banner",
44
+ "-loglevel",
45
+ "error",
46
+ "-y",
47
+ "-ss",
48
+ timestamp.toFixed(3),
49
+ "-protocol_whitelist", "file,pipe",
50
+ "-i",
51
+ videoPath,
52
+ "-frames:v",
53
+ "1",
54
+ "-vf",
55
+ `scale='min(${maxWidth},iw)':-2`,
56
+ ...(/\.jpe?g$/i.test(outputPath) ? ["-pix_fmt", "yuvj420p"] : []),
57
+ "-q:v",
58
+ "2",
59
+ outputPath,
60
+ ]);
61
+ if (result.code !== 0)
62
+ throw new Error(`Unable to extract frame: ${conciseError(result.stderr)}`);
63
+ if (!(await fs.stat(outputPath).catch(() => undefined))?.size)
64
+ throw new Error("No frame at this timestamp. Request a time strictly before the clip duration.");
65
+ }
66
+ async detectVisualChanges(videoPath, threshold = 0.25, maxChanges = 8) {
67
+ if (threshold <= 0 || threshold >= 1)
68
+ throw new Error("Scene threshold must be between 0 and 1.");
69
+ if (!Number.isInteger(maxChanges) || maxChanges < 1)
70
+ throw new Error("maxChanges must be at least 1.");
71
+ const result = await this.runner.run(this.ffmpegPath, [
72
+ "-hide_banner",
73
+ "-loglevel",
74
+ "info",
75
+ "-protocol_whitelist", "file,pipe",
76
+ "-i",
77
+ videoPath,
78
+ "-an",
79
+ "-vf",
80
+ `select='gt(scene,${threshold})',showinfo`,
81
+ "-f",
82
+ "null",
83
+ "-",
84
+ ]);
85
+ if (result.code !== 0) {
86
+ throw new Error(`Unable to detect visual changes: ${conciseError(result.stderr)}`);
87
+ }
88
+ const timestamps = [...result.stderr.matchAll(/pts_time:\s*([0-9.]+)/g)]
89
+ .map((match) => Number(match[1]))
90
+ .filter(Number.isFinite);
91
+ if (timestamps.length <= maxChanges)
92
+ return timestamps;
93
+ if (maxChanges === 1)
94
+ return [timestamps[0]];
95
+ const step = (timestamps.length - 1) / (maxChanges - 1);
96
+ return Array.from({ length: maxChanges }, (_, index) => timestamps[Math.round(index * step)]);
97
+ }
98
+ async createContactSheet(videoPath, outputPath, timestamps) {
99
+ if (timestamps.length === 0)
100
+ throw new Error("At least one timestamp is required.");
101
+ const frameDir = path.join(path.dirname(outputPath), "overview-frames");
102
+ const files = [];
103
+ for (const [index, timestamp] of timestamps.entries()) {
104
+ const file = path.join(frameDir, `${String(index).padStart(3, "0")}.jpg`);
105
+ await this.extractFrame(videoPath, timestamp, file);
106
+ files.push(file);
107
+ }
108
+ const columns = Math.min(4, timestamps.length);
109
+ const rows = Math.ceil(timestamps.length / columns);
110
+ const tileWidth = 336;
111
+ const tileHeight = 220;
112
+ const composites = [];
113
+ for (const [index, file] of files.entries()) {
114
+ const frame = await sharp(file)
115
+ .resize(320, 180, { fit: "contain", background: "#05070d" })
116
+ .jpeg({ quality: 82 })
117
+ .toBuffer();
118
+ const label = Buffer.from(`<svg width="320" height="24"><rect width="320" height="24" fill="#111827"/><text x="8" y="17" font-family="Arial" font-size="14" fill="#f9fafb">${timestamps[index].toFixed(3)}s</text></svg>`);
119
+ const left = (index % columns) * tileWidth + 8;
120
+ const top = Math.floor(index / columns) * tileHeight + 8;
121
+ composites.push({ input: frame, left, top });
122
+ composites.push({ input: label, left, top: top + 180 });
123
+ }
124
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
125
+ await sharp({
126
+ create: {
127
+ width: columns * tileWidth,
128
+ height: rows * tileHeight,
129
+ channels: 3,
130
+ background: "#0b1020",
131
+ },
132
+ })
133
+ .composite(composites)
134
+ .jpeg({ quality: 88 })
135
+ .toFile(outputPath);
136
+ return timestamps;
137
+ }
138
+ }
139
+ //# sourceMappingURL=media.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"media.js","sourceRoot":"","sources":["../src/media.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,KAA8B,MAAM,OAAO,CAAC;AAEnD,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,OAAO,cAAc;IAEN,UAAU;IACV,WAAW;IACX,MAAM;IAHzB,YACmB,UAAkB,EAClB,WAAmB,EACnB,MAAqB;0BAFrB,UAAU;2BACV,WAAW;sBACX,MAAM;IACtB,CAAC;IAEJ,KAAK,CAAC,SAAS,CAAC,SAAiB;QAC/B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,qBAAqB,EAAE,WAAW,EAAE,iBAAiB,EAAE,KAAK,EAAE,eAAe,EAAE,uBAAuB,EAAE,KAAK,EAAE,oCAAoC,EAAE,SAAS,CAAC,CAAC,CAAC;QACxO,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAC3D,MAAM,IAAI,GAAG,CAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3B,OAAO,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;IACvD,CAAC;IAED,KAAK,CAAC,QAAQ,CAAC,SAAiB;QAC9B,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,WAAW,EAAE;YACrD,IAAI;YACJ,OAAO;YACP,qBAAqB,EAAE,WAAW;YAClC,eAAe;YACf,iBAAiB;YACjB,KAAK;YACL,oCAAoC;YACpC,SAAS;SACV,CAAC,CAAC;QACH,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,2BAA2B,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACjG,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QAC9C,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,uCAAuC,CAAC,CAAC;QACzF,OAAO,QAAQ,CAAC;IAClB,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,SAAiB,EAAE,SAAiB,EAAE,UAAkB,EAAE,QAAQ,GAAG,IAAI;QAC1F,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9D,MAAM,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,GAAG,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QACrH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE;YACpD,cAAc;YACd,WAAW;YACX,OAAO;YACP,IAAI;YACJ,KAAK;YACL,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;YACpB,qBAAqB,EAAE,WAAW;YAClC,IAAI;YACJ,SAAS;YACT,WAAW;YACX,GAAG;YACH,KAAK;YACL,cAAc,QAAQ,UAAU;YAChC,GAAG,CAAC,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,UAAU,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACjE,MAAM;YACN,GAAG;YACH,UAAU;SACX,CAAC,CAAC;QACH,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QAClG,IAAI,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC,EAAE,IAAI;YAAE,MAAM,IAAI,KAAK,CAAC,+EAA+E,CAAC,CAAC;IAClK,CAAC;IAED,KAAK,CAAC,mBAAmB,CACvB,SAAiB,EACjB,SAAS,GAAG,IAAI,EAChB,UAAU,GAAG,CAAC;QAEd,IAAI,SAAS,IAAI,CAAC,IAAI,SAAS,IAAI,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,0CAA0C,CAAC,CAAC;QAClG,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,IAAI,UAAU,GAAG,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;QACvG,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,UAAU,EAAE;YACpD,cAAc;YACd,WAAW;YACX,MAAM;YACN,qBAAqB,EAAE,WAAW;YAClC,IAAI;YACJ,SAAS;YACT,KAAK;YACL,KAAK;YACL,oBAAoB,SAAS,aAAa;YAC1C,IAAI;YACJ,MAAM;YACN,GAAG;SACJ,CAAC,CAAC;QACH,IAAI,MAAM,CAAC,IAAI,KAAK,CAAC,EAAE,CAAC;YACtB,MAAM,IAAI,KAAK,CAAC,oCAAoC,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;QACrF,CAAC;QACD,MAAM,UAAU,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAC;aACrE,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;aAChC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC3B,IAAI,UAAU,CAAC,MAAM,IAAI,UAAU;YAAE,OAAO,UAAU,CAAC;QACvD,IAAI,UAAU,KAAK,CAAC;YAAE,OAAO,CAAC,UAAU,CAAC,CAAC,CAAE,CAAC,CAAC;QAC9C,MAAM,IAAI,GAAG,CAAC,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;QACxD,OAAO,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,IAAI,CAAC,CAAE,CAAC,CAAC;IACjG,CAAC;IAED,KAAK,CAAC,kBAAkB,CACtB,SAAiB,EACjB,UAAkB,EAClB,UAAoB;QAEpB,IAAI,UAAU,CAAC,MAAM,KAAK,CAAC;YAAE,MAAM,IAAI,KAAK,CAAC,qCAAqC,CAAC,CAAC;QACpF,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAC,CAAC;QACxE,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,IAAI,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC;YACtD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;YAC1E,MAAM,IAAI,CAAC,YAAY,CAAC,SAAS,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC;YACpD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACnB,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,UAAU,CAAC,MAAM,CAAC,CAAC;QAC/C,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,GAAG,OAAO,CAAC,CAAC;QACpD,MAAM,SAAS,GAAG,GAAG,CAAC;QACtB,MAAM,UAAU,GAAG,GAAG,CAAC;QACvB,MAAM,UAAU,GAAqB,EAAE,CAAC;QAExC,KAAK,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,IAAI,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAC5C,MAAM,KAAK,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC;iBAC5B,MAAM,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,GAAG,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC;iBAC3D,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;iBACrB,QAAQ,EAAE,CAAC;YACd,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CACvB,mJAAmJ,UAAU,CAAC,KAAK,CAAE,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CACjM,CAAC;YACF,MAAM,IAAI,GAAG,CAAC,KAAK,GAAG,OAAO,CAAC,GAAG,SAAS,GAAG,CAAC,CAAC;YAC/C,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,OAAO,CAAC,GAAG,UAAU,GAAG,CAAC,CAAC;YACzD,UAAU,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;YAC7C,UAAU,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC,CAAC;QAC1D,CAAC;QAED,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC9D,MAAM,KAAK,CAAC;YACV,MAAM,EAAE;gBACN,KAAK,EAAE,OAAO,GAAG,SAAS;gBAC1B,MAAM,EAAE,IAAI,GAAG,UAAU;gBACzB,QAAQ,EAAE,CAAC;gBACX,UAAU,EAAE,SAAS;aACtB;SACF,CAAC;aACC,SAAS,CAAC,UAAU,CAAC;aACrB,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,CAAC;aACrB,MAAM,CAAC,UAAU,CAAC,CAAC;QACtB,OAAO,UAAU,CAAC;IACpB,CAAC;CACF"}
@@ -0,0 +1,9 @@
1
+ import type { ProcessResult } from "./types.js";
2
+ export interface CommandRunner {
3
+ run(command: string, args: string[]): Promise<ProcessResult>;
4
+ }
5
+ export declare class NodeCommandRunner implements CommandRunner {
6
+ run(command: string, args: string[]): Promise<ProcessResult>;
7
+ }
8
+ export declare function conciseError(stderr: string, maxLength?: number): string;
9
+ //# sourceMappingURL=process-runner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-runner.d.ts","sourceRoot":"","sources":["../src/process-runner.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhD,MAAM,WAAW,aAAa;IAC5B,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;CAC9D;AAED,qBAAa,iBAAkB,YAAW,aAAa;IAC/C,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,aAAa,CAAC,CAgBjE;CACF;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,SAAM,GAAG,MAAM,CAGpE"}
@@ -0,0 +1,25 @@
1
+ import { spawn } from "node:child_process";
2
+ export class NodeCommandRunner {
3
+ async run(command, args) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, {
6
+ windowsHide: true,
7
+ stdio: ["ignore", "pipe", "pipe"],
8
+ });
9
+ let stdout = "";
10
+ let stderr = "";
11
+ const timeout = setTimeout(() => { child.kill(); reject(new Error(`Command timed out: ${command}`)); }, 120_000);
12
+ child.stdout.setEncoding("utf8");
13
+ child.stderr.setEncoding("utf8");
14
+ child.stdout.on("data", (chunk) => (stdout = (stdout + chunk).slice(-2_000_000)));
15
+ child.stderr.on("data", (chunk) => (stderr = (stderr + chunk).slice(-2_000_000)));
16
+ child.on("error", error => { clearTimeout(timeout); reject(error); });
17
+ child.on("close", (code) => { clearTimeout(timeout); resolve({ code: code ?? -1, stdout, stderr }); });
18
+ });
19
+ }
20
+ }
21
+ export function conciseError(stderr, maxLength = 800) {
22
+ const cleaned = stderr.trim().replace(/\r/g, "");
23
+ return cleaned.length <= maxLength ? cleaned : `${cleaned.slice(0, maxLength)}…`;
24
+ }
25
+ //# sourceMappingURL=process-runner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-runner.js","sourceRoot":"","sources":["../src/process-runner.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAO3C,MAAM,OAAO,iBAAiB;IAC5B,KAAK,CAAC,GAAG,CAAC,OAAe,EAAE,IAAc;QACvC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACrC,MAAM,KAAK,GAAG,KAAK,CAAC,OAAO,EAAE,IAAI,EAAE;gBACjC,WAAW,EAAE,IAAI;gBACjB,KAAK,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,CAAC;aAClC,CAAC,CAAC;YACH,IAAI,MAAM,GAAG,EAAE,CAAC;YAChB,IAAI,MAAM,GAAG,EAAE,CAAC;YAChB,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,sBAAsB,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;YACjH,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YACjC,KAAK,CAAC,MAAM,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;YACjC,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;YAC1F,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE,CAAC,CAAC,MAAM,GAAG,CAAC,MAAM,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;YAC1F,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,EAAE,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACtE,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,GAAG,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,IAAI,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACzG,CAAC,CAAC,CAAC;IACL,CAAC;CACF;AAED,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,SAAS,GAAG,GAAG;IAC1D,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IACjD,OAAO,OAAO,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC;AACnF,CAAC"}