@ai-ecoverse/slicc-capture 0.0.0 → 1.0.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.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,9 +1,205 @@
1
- # @ai-ecoverse/slicc-capture
1
+ # slicc-capture
2
2
 
3
- 🐣 This package name has been claimed with [fledgling](https://github.com/dmno-dev/fledgling) for [https://github.com/ai-ecoverse/slicc-capture](https://github.com/ai-ecoverse/slicc-capture), and a real release will land here soon.
3
+ Screen capture for SLICC: `screencapture`, with the options of macOS's `screencapture(1)` wherever they make sense in a browser, and slicc 6's `-c` (to the clipboard) and `-v` (inline in the agent's tool result). Seven's page shows a prompt, the user picks a screen, window or tab in Chrome's `getDisplayMedia` picker, and the command writes a PNG (or JPEG or WebP).
4
4
 
5
- This is a placeholder — there is nothing to install yet.
5
+ `sccapture` is the same command under a name that works everywhere: in SLICC it is `screencapture`, and on macOS, Linux and Windows it hands off to the platform's own screenshot tool (see [On a host](#on-a-host)). A host's own `screencapture` is never shadowed.
6
6
 
7
- ---
7
+ ```sh
8
+ screencapture shot.png
9
+ screencapture -c
10
+ screencapture -v
11
+ screencapture -T 3 -R 0,0,800,600 -t jpg crop.jpg
12
+ ```
8
13
 
9
- Want to claim a name of your own? Run `npx fledgling add <name> --new --yes` — it reserves the name on npm and can set up token-less (OIDC trusted) publishing for when you are ready.
14
+ ## Install
15
+
16
+ In SLICC, the package declares `screencapture` and `sccapture` in `slicc.commands`, so installing it puts both in `/usr/bin`:
17
+
18
+ ```sh
19
+ pnpm add -g @ai-ecoverse/slicc-capture
20
+ ```
21
+
22
+ On macOS and Linux:
23
+
24
+ ```sh
25
+ curl -fsSL https://get.sliccy.ai/slicc-capture | sh
26
+ ```
27
+
28
+ On Windows, in PowerShell:
29
+
30
+ ```powershell
31
+ irm https://get.sliccy.ai/slicc-capture.ps1 | iex
32
+ ```
33
+
34
+ Or, on any machine with Node 24 or later:
35
+
36
+ ```sh
37
+ npm install -g @ai-ecoverse/slicc-capture
38
+ ```
39
+
40
+ or without installing, `npx -p @ai-ecoverse/slicc-capture sccapture shot.png`.
41
+
42
+ ### The installers
43
+
44
+ Each release attaches `install.sh` and `install.ps1` next to the package tarball, `slicc-capture-<version>.tgz`, and a `SHA256SUMS` that lists all three. The PR that adds the installers also adds them, the tarball and `SHA256SUMS` to `@semantic-release/github`'s `assets` in `.releaserc.json`, with a test in `test/integration/repo.test.mjs`, so no release ships without them. get.sliccy.ai serves the latest release's installers. Both work like mcpcat's `install.sh`:
45
+
46
+ - **Options:** `--version <version>` installs that release instead of the latest; `--dir <dir>` puts the launcher there instead of the default; `--help` prints the usage and exits 0. An unknown option prints `slicc-capture: unknown option: <arg>` to stderr and exits 1. The environment variables `VERSION` and `INSTALL_DIR` do the same as the options. Through a pipe, `sh -s -- --dir ~/bin` passes options to `install.sh`, and `& ([scriptblock]::Create((irm https://get.sliccy.ai/slicc-capture.ps1))) --dir C:\tools` to `install.ps1`.
47
+ - **Node 24 or later:** the `node` on `PATH` if it is new enough, else the one the installer downloaded before; else it downloads the current Node LTS from nodejs.org, checks it against that release's `SHASUMS256.txt`, and keeps it in the data directory. On musl Linux, where nodejs.org has no build, it says to install Node with the package manager and exits 1.
48
+ - **Where it goes:** the tarball is checked against `SHA256SUMS`, unpacked into `<data>/<version>`, and `<data>/current` points at it. `<data>` is `${XDG_DATA_HOME:-~/.local/share}/slicc-capture` on macOS and Linux, `%LOCALAPPDATA%\slicc-capture` on Windows. The launcher, `sccapture` (`sccapture.cmd` on Windows), goes into `~/.local/bin` (`%LOCALAPPDATA%\slicc-capture\bin` on Windows) and runs that Node on `<data>/<version>/host/sccapture.mjs`, so the launcher keeps working when the `node` on `PATH` changes.
49
+ - **Output:** `sccapture <version> installed to <launcher>`, and, when the directory isn't on `PATH`, the line that adds it. Any failure (no `curl` or `wget`, a checksum mismatch, an unsupported OS or architecture) is one line on stderr and exit 1; on an unsupported OS it names `npm install -g @ai-ecoverse/slicc-capture` instead.
50
+
51
+ The get.sliccy.ai entries for `slicc-capture` and `slicc-capture.ps1` are added once the first release is out.
52
+
53
+ ## Commands
54
+
55
+ | command | where | what |
56
+ | --- | --- | --- |
57
+ | `screencapture` | SLICC | captures through seven's page, as below |
58
+ | `sccapture` | SLICC | the same as `screencapture` |
59
+ | `sccapture` | macOS, Linux, Windows | hands off to the platform's tool, as in [On a host](#on-a-host) |
60
+
61
+ ```
62
+ usage: screencapture [-acdimorvwxCW] [-t format] [-T seconds] [-R x,y,w,h] [-D display] [-J style] [file]
63
+ ```
64
+
65
+ Short options may be grouped (`-xc`), an option's value may be attached or separate (`-T3`, `-T 3`), and `--` ends the options. `--clipboard` is `-c` and `--view` is `-v`, as in slicc 6. `-h` or `--help` anywhere prints the usage to stdout and exits 0. `--version` and `--upgrade` are described [below](#--upgrade); either must be the only argument, else `<cmd>: <opt>: takes no other arguments` and the usage to stderr, exit 2. An option without its value prints `<cmd>: <opt>: option requires a value` and the usage to stderr, exit 2.
66
+
67
+ ### In SLICC
68
+
69
+ `screencapture file` asks seven's page to capture. `getDisplayMedia` needs a user gesture, so the page first shows a prompt, `screencapture wants to capture your screen`, with **Choose…** and **Cancel**; **Choose…** opens Chrome's picker. After the user picks, the command waits for `-T` seconds, if given, takes one frame, crops it to `-R`, if given, encodes it and stops the capture.
70
+
71
+ - **The file:** written as PNG, JPEG or WebP: `-t` decides, else the file's extension (`.png`, `.jpg` or `.jpeg`, `.webp`), else PNG. JPEG and WebP use quality 0.92. On success it prints `captured <n> KB to <file>`.
72
+ - **`-c`:** copies the capture to the clipboard as `image/png`, through [slicc-clipboard](https://github.com/ai-ecoverse/slicc-clipboard), and prints `captured <n> KB to clipboard`. A file named with `-c` is ignored, as with macOS's `-c`, and so is `-t`. slicc-clipboard's device is text only today; `-c` needs its image path, which is being added there.
73
+ - **`-v`:** shows the capture inline in the agent's tool result. With a file, it writes the file and prints `<path> (<n> KB)` followed by the image; without one, it prints only the image; with `-c`, it copies and shows. slicc 6 printed the image as `<img:data:<type>;base64,<data>>`; seven's marker for an inline image is being agreed with slicc-agent, and `-v` uses whatever that is. macOS's `-v` (record a video) is out of scope everywhere.
74
+ - **No file:** without a file, `-c` or `-v`: `screencapture: output file required (or use -c for clipboard)` and the usage to stderr, exit 2.
75
+
76
+ Errors, on stderr, from slicc 6 where they still apply:
77
+
78
+ | error | exit |
79
+ | --- | --- |
80
+ | `screencapture: user cancelled or permission denied` (**Cancel**, the picker closed, or the capture refused) | 1 |
81
+ | `screencapture: timed out waiting for the picker` (no answer within 5 minutes) | 1 |
82
+ | `screencapture: browser APIs are unavailable in this environment` (an embedder that doesn't answer capture requests) | 1 |
83
+ | `screencapture: screen capture is not supported in this browser` (no `getDisplayMedia`) | 1 |
84
+ | `screencapture: -R: rectangle outside the capture` | 1 |
85
+ | `screencapture: failed to write file: <message>` | 1 |
86
+ | `screencapture: failed to copy to clipboard: <message>` | 1 |
87
+ | `screencapture: unknown option: <arg>`, then the usage | 2 |
88
+ | `screencapture: <opt>: not supported in SLICC` | 2 |
89
+ | `screencapture: -t <format>: not supported in SLICC` | 2 |
90
+ | `screencapture: -T: invalid delay: <value>`, `-R: invalid rectangle: <value>`, `-D: invalid display: <value>`, `-J: invalid style: <value>` | 2 |
91
+ | `screencapture: more than one file: not supported in SLICC` | 2 |
92
+
93
+ As `sccapture`, the messages start with `sccapture:`.
94
+
95
+ **The bridge from the kernel to the page is not settled yet.** The commands run in a kernel worker, which has no `getDisplayMedia`, so a request has to reach seven's page and the frame has to come back. slicc-clipboard does this with a kernel character device that the kernel answers from its embedder's hook; slicc-open, slicc-capture and slicc-audio are agreeing one pattern for all three in [ai-ecoverse/slicc-kernel#1527](https://github.com/ai-ecoverse/slicc-kernel/issues/1527) (pending), with the kernel and seven's embedder (slicc-bios). Until then this section describes only what the user sees. The page side (the prompt, `getDisplayMedia`, the frame, the crop and the encoding) ships in this package for the embedder; its entry point, the transport and the `engines.slicc-kernel` it needs are added here once the pattern is agreed.
96
+
97
+ ### Options
98
+
99
+ Every option of macOS's `screencapture`, and what it does in SLICC and with `sccapture` on each host. On macOS, `sccapture` passes the options through to `screencapture` unchanged, except the ones the macOS column names. "No-op" means the option is accepted and changes nothing. "Not supported" means `<cmd>: <opt>: not supported in SLICC` (or `on Linux`, `on Windows`, `on macOS`) on stderr, exit 2, before anything is captured; "not with `<tool>`" means `sccapture: <opt>: not supported with <tool>`, exit 2, when no tool on `PATH` supports every option given (it names the first one tried).
100
+
101
+ | option | macOS `screencapture` | SLICC | macOS `sccapture` | Linux `sccapture` | Windows `sccapture` |
102
+ | --- | --- | --- | --- | --- | --- |
103
+ | `-a` | don't capture attached windows | no-op | passed | no-op | no-op |
104
+ | `-b` | capture the Touch Bar | not supported | passed | not supported | not supported |
105
+ | `-B <bundleid>` | open in that app | not supported | passed | not supported | not supported |
106
+ | `-c` | to the clipboard | through slicc-clipboard, as `image/png` | passed | see [Linux](#linux) | `Clipboard.SetImage` |
107
+ | `-C` | include the cursor | asks for `cursor: 'always'` (else `'never'`), which the browser may not honour | passed | see [Linux](#linux) | draws the cursor into the image |
108
+ | `-d` | show errors graphically | no-op | passed | no-op | no-op |
109
+ | `-D <display>` | that display (1 is main) | prefers `displaySurface: 'monitor'`; the user picks the screen | passed | not supported | screen *n*: 1 is the primary, then the others in `AllScreens` order; no such screen is `sccapture: -D <n>: no such display`, exit 1 |
110
+ | `-g` | record default audio input | not supported | not supported | not supported | not supported |
111
+ | `-G <id>` | record that audio source | not supported | not supported | not supported | not supported |
112
+ | `-i` | interactive, by selection or window | no-op: the picker is the interaction | passed | see [Linux](#linux) | not supported |
113
+ | `-J <style>` | start in `selection`, `window` or `video` | `window` as `-W`; `selection` and `video` not supported | passed | `selection` as `-s`, `window` as `-w`, `video` not supported | not supported |
114
+ | `-k` | show clicks in a recording | not supported | not supported | not supported | not supported |
115
+ | `-l <windowid>` | that window | not supported | passed | see [Linux](#linux) | not supported |
116
+ | `-m` | the main monitor only | prefers `displaySurface: 'monitor'`; the user picks the screen | passed | not supported | the primary screen |
117
+ | `-M` | open in a new Mail message | not supported | passed | not supported | not supported |
118
+ | `-o` | no window shadow | no-op: the browser captures none | passed | no-op | no-op |
119
+ | `-p` | default settings, file ignored | not supported | passed | not supported | not supported |
120
+ | `-P` | open in Preview | not supported | passed | `xdg-open <file>` afterwards; without it, `sccapture: no viewer found (tried xdg-open)`, exit 1, after the file is written | `Invoke-Item <file>` afterwards |
121
+ | `-r` | no dpi metadata | no-op: none is written | passed | no-op | no-op |
122
+ | `-R <x,y,w,h>` | that rectangle | crops the frame, in its pixels | passed | see [Linux](#linux) | that rectangle of the virtual screen |
123
+ | `-s` | mouse selection only | not supported: use `-R` | passed | see [Linux](#linux) | not supported |
124
+ | `-S` | the screen, in window mode | not supported | passed | not supported | not supported |
125
+ | `-t <format>` | the image format, `png` by default | `png`, `jpg` or `jpeg`, `webp`; others not supported | passed | see [Linux](#linux) | `png`, `jpg` or `jpeg`, `bmp`, `gif`, `tiff`; others not supported |
126
+ | `-T <seconds>` | wait before capturing | waits after the picker | passed | `sccapture` waits, then runs the tool | `sccapture` waits, then captures |
127
+ | `-u` | show the UI afterwards, file ignored | not supported | passed | not supported | not supported |
128
+ | `-U` | the interactive toolbar | not supported | passed | not supported | not supported |
129
+ | `-v` | record a video | inline in the tool result (slicc 6's `-v`) | not supported | not supported | not supported |
130
+ | `-V <seconds>` | record a video for that long | not supported | not supported | not supported | not supported |
131
+ | `-w` | window selection only | prefers `displaySurface: 'window'` | passed | see [Linux](#linux) | not supported |
132
+ | `-W` | start in window selection | prefers `displaySurface: 'window'` | passed | as `-w` | not supported |
133
+ | `-x` | no sounds | no-op | passed | no-op | no-op |
134
+ | `file ...` | one file per screen | one file; more not supported | passed | one file; more not supported | one file; more not supported |
135
+
136
+ Video recording (`-v`, `-V`, `-g`, `-G`, `-k`) is out of scope on every platform, macOS included; a later release may add it, sharing an encoder with whatever replaces `ffmpeg` in SLICC. `--clipboard` and `--view` are `-c` and `-v` on every platform; on macOS, `sccapture` passes `--clipboard` on as `-c`.
137
+
138
+ ## On a host
139
+
140
+ Only `sccapture` is installed on a host (the package's `bin`, a small Node script, `host/sccapture.mjs`); the host's own `screencapture`, `grim` and the rest are never shadowed. It runs the platform's tool with a fixed argv and no shell: of the tools below, in this order, the first one on `PATH` that supports every option given (so on GNOME under X11, `-l` picks `import` over `gnome-screenshot`):
141
+
142
+ | platform | tool | `-c` |
143
+ | --- | --- | --- |
144
+ | macOS | `screencapture`, with the options passed through as above | `screencapture -c` |
145
+ | Linux, under Wayland (`WAYLAND_DISPLAY` set) | `grim` (with `slurp` to select), else `gnome-screenshot` | `wl-copy --type image/png`, else `xclip -selection clipboard -t image/png` |
146
+ | Linux, X11 | `gnome-screenshot`, else ImageMagick `import`, else `scrot` | `xclip -selection clipboard -t image/png` |
147
+ | Windows | PowerShell, `powershell.exe` else `pwsh.exe`, with System.Drawing and System.Windows.Forms | `Clipboard.SetImage` |
148
+
149
+ - **The file and its format:** without `-t`, the file's extension picks the format (on macOS, `sccapture` adds `-t` for `.jpg`, `.jpeg`, `.pdf`, `.tiff`, `.gif` and `.bmp`), else PNG. `-c` copies PNG; a file, `-t` and `-P` are then ignored, except on macOS, where `screencapture` decides. Success prints nothing, as `screencapture` does.
150
+ - **A file is required**, as in SLICC (`sccapture: output file required (or use -c for clipboard)`, exit 2), except on macOS with `-p`, `-u`, `-P`, `-M` or `-B`, which `screencapture` handles without one.
151
+ - **Errors:** with no tool on `PATH`, `sccapture: no screenshot tool found (tried grim, gnome-screenshot)` (the names tried on that platform) to stderr, exit 1; with `-c` and no clipboard tool, `sccapture: no clipboard tool found (tried wl-copy, xclip)`, exit 1. A tool that fails, a cancelled selection included, has its stderr passed through, exit 1. An unknown option prints `sccapture: unknown option: <arg>` and the usage to stderr, exit 2; an option the table marks as not supported on that platform prints `sccapture: <opt>: not supported on <platform>`, exit 2. The values of `-T`, `-R`, `-D` and `-J` are checked as in SLICC, and `-l` takes a window id in decimal or `0x` hex, else `sccapture: -l: invalid window: <value>`, exit 2. `-h` and `--help` print the usage to stdout and exit 0.
152
+ - **`--upgrade`:** the command counts as installed by the installer when it runs from `<data>/<version>/host/sccapture.mjs` with a `<data>/current` next to it, and as installed with npm otherwise. `--dir` is the directory on `PATH` whose `sccapture` (`sccapture.cmd` on Windows) names `<data>`, else the installer's default. It downloads the installer with `curl`, else `wget` (`curl.exe` on Windows), and without one prints `sccapture: no downloader found (tried curl, wget)`, exit 1; a failed download has its stderr passed through, exit 1. The installer runs with `sh` (`powershell.exe`, else `pwsh.exe`, with `-ExecutionPolicy Bypass -File` on Windows), without `VERSION` and `INSTALL_DIR` in its environment, so it installs the latest release where the command is; the new version is read from `<data>/current/package.json`.
153
+ - **Other Unix systems** are treated as Linux.
154
+
155
+ ### Linux
156
+
157
+ How each option reaches the tool. "Not with `<tool>`" is the error only when no tool on `PATH` supports every option given, and it names the first one tried. `<file>` is the file, or a temporary PNG that is then copied with `-c` and deleted.
158
+
159
+ | option | `grim` | `gnome-screenshot` | `import` | `scrot` |
160
+ | --- | --- | --- | --- | --- |
161
+ | none | `grim <file>` | `gnome-screenshot -f <file>` | `import -window root <file>` | `scrot -o <file>` |
162
+ | `-i`, `-s` | `grim -g <slurp's output> <file>`; without `slurp`, `sccapture: no region selector found (tried slurp)`, exit 1 | `-a` | `import <file>`: click a window or drag a region | `-s` |
163
+ | `-w`, `-W` | not with `grim` | not with `gnome-screenshot` | `import <file>` | `-s` |
164
+ | `-R x,y,w,h` | `-g "x,y wxh"` | not with `gnome-screenshot` | `-crop wxh+x+y +repage` | `-a x,y,w,h` |
165
+ | `-C` | `-c` | `-p` | not with `import` | `-p` |
166
+ | `-l <windowid>` | not with `grim` | not with `gnome-screenshot` | `-window <windowid>` | not with `scrot` |
167
+ | `-t png`, `jpg` | `-t png`, `-t jpeg`; without `-t`, `-t jpeg` for a `.jpg` or `.jpeg` file, since grim writes PNG whatever the extension | `png` only, else not with `gnome-screenshot` | `png:<file>`, `jpg:<file>`; without `-t`, `png:<file>` for a file without an extension, which `import` can't guess | by the file's extension; a `-t` that differs from it is not with `scrot` |
168
+
169
+ Under Wayland, `grim` needs a compositor with wlr-screencopy (sway, Hyprland and other wlroots ones); elsewhere it fails with its own message, so install `gnome-screenshot` there. `import` and `scrot` aren't tried under Wayland, where they would see only XWayland windows.
170
+
171
+ ### Windows
172
+
173
+ `sccapture` runs `powershell.exe` (else `pwsh.exe`) with `-NoProfile -NonInteractive -STA -EncodedCommand` and a fixed script; the file name and the option values reach it as data, in `SCCAPTURE_*` environment variables, never as script. (PowerShell reads a script on stdin line by line, as if typed, so a multi-line block in it can stop at the first blank line; `-EncodedCommand` is what slicc-clipboard uses.) The process is made per-monitor DPI-aware first (system DPI-aware before Windows 10 1703), so a scaled display is captured at its real resolution, even when the monitors' scales differ. It copies the virtual screen (every monitor), or the screen `-m`/`-D` names, or the `-R` rectangle of that (its `x,y` from the top left of the virtual screen, or of the screen `-m`/`-D` names), with `Graphics.CopyFromScreen`, draws the cursor for `-C`, and saves with the `-t` format's `ImageFormat`, or `Clipboard.SetImage` for `-c`.
174
+
175
+ ### In SLICC, the SLICC commands win
176
+
177
+ The package has both `bin` and `slicc.commands`, and both name `sccapture`. As with slicc-clipboard, the kernel ignores a package's `bin` entry when the same package declares that name in `slicc.commands`, and bios removes the `$PNPM_HOME/bin` link pnpm makes for it, so the Node script, which SLICC has no Node to run, never shadows the SLICC command.
178
+
179
+ ## `--upgrade`
180
+
181
+ Every host command takes `--upgrade`. It runs the latest release's installer (`install.sh`, or `install.ps1` on Windows) with `--dir` set to the directory the command is installed in, so the new version replaces the old one in place, then prints `<cmd>: <old> → <new>` (for example `sccapture: 1.2.0 → 1.3.0`), or `<cmd>: already up to date (<version>)`, to stdout and exits 0. If the installer fails, its stderr is passed through and the command exits 1. A command installed with npm prints `npm install -g @ai-ecoverse/slicc-capture@latest` to run instead, and exits 0. In SLICC, `--upgrade` prints `pnpm add -g @ai-ecoverse/slicc-capture@latest` to run, and exits 0; it installs nothing itself. `--upgrade` takes no other arguments.
182
+
183
+ For slicc-capture, the commands are `sccapture` on a host and `screencapture` and `sccapture` in SLICC.
184
+
185
+ `--version` prints the version (`0.1.0`) to stdout and exits 0, on a host and in SLICC; the installers call the launcher with it. It takes no other arguments either.
186
+
187
+ ## Development
188
+
189
+ ```sh
190
+ npm install
191
+ npm run lint
192
+ npm test
193
+ npm run test:unit
194
+ ```
195
+
196
+ - `npm install` also installs the git hooks into this worktree's own `node_modules/` through slicc-shared-web's `slicc-install-hooks` and the committed `.githooks/`.
197
+ - `npm run lint`: Biome, the no-comment check and the other [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web) checks.
198
+ - `npm test`: the integration tests in `test/integration/`. `test/integration/repo.test.mjs` checks the workflow, the release config, the pinned devDependencies and what `npm pack` ships. The host tests run `host/` against fake `screencapture`, `grim`, `slurp`, `gnome-screenshot`, `import`, `scrot`, `wl-copy`, `xclip`, `xdg-open`, `powershell.exe`, `pwsh.exe`, `curl`, `wget` and a fake installer on a temporary `PATH`, with the platform forced, so each branch is checked on any machine; only CI touches a real screen.
199
+ - `npm run test:unit`: the unit tests in the gitignored `test/unit/`, with coverage in `coverage/unit/lcov.info`. The pre-commit hook wants 100% coverage of the changed lines, and runs `diff-cover` through `uvx`, so install [uv](https://docs.astral.sh/uv/) once.
200
+
201
+ CI is `.github/workflows/ci.yml`, the only workflow, which runs [slicc-shared-web's pipeline](https://github.com/ai-ecoverse/slicc-shared-web#pipeline) at the devDependency's version: `npm run lint`, then `npm test`, then on a pull request the Claude and Codex reviews. The merge queue requires `ci / integration` and `ci / review`. On a push to `main`, semantic-release releases and publishes to npm through trusted publishing, with provenance from `ci.yml`, and attaches the installers.
202
+
203
+ ## License
204
+
205
+ Apache-2.0
@@ -0,0 +1,61 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { setTimeout } from 'node:timers/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { darwin } from './darwin.mjs';
6
+ import { linux } from './linux.mjs';
7
+ import { check, names, parse, settings, usage } from './options.mjs';
8
+ import { say } from './tools.mjs';
9
+ import { upgrade } from './upgrade.mjs';
10
+ import { windows } from './windows.mjs';
11
+
12
+ const optional = 'puPMB';
13
+
14
+ export async function run({
15
+ argv = process.argv.slice(2),
16
+ env = process.env,
17
+ platform = process.platform,
18
+ fds = [0, 1, 2],
19
+ entry = fileURLToPath(new URL('sccapture.mjs', import.meta.url)),
20
+ wait = (seconds) => setTimeout(seconds * 1000),
21
+ } = {}) {
22
+ const os = names[platform] ? platform : 'linux';
23
+ const parsed = parse(argv);
24
+ if (parsed.help) {
25
+ say(fds[1], usage);
26
+ return 0;
27
+ }
28
+ if (parsed.error) {
29
+ say(fds[2], `sccapture: ${parsed.error}\n${usage}`);
30
+ return 2;
31
+ }
32
+ if (parsed.special.length) {
33
+ if (argv.length > 1) {
34
+ say(fds[2], `sccapture: ${parsed.special[0]}: takes no other arguments\n${usage}`);
35
+ return 2;
36
+ }
37
+ if (parsed.special[0] === '--upgrade') return upgrade({ entry, env, platform: os, fds });
38
+ const pkg = JSON.parse(readFileSync(join(dirname(dirname(entry)), 'package.json'), 'utf8'));
39
+ say(fds[1], `${pkg.version}\n`);
40
+ return 0;
41
+ }
42
+ const problem = check(parsed.options, os);
43
+ if (problem) {
44
+ say(fds[2], `sccapture: ${problem}\n`);
45
+ return 2;
46
+ }
47
+ const o = settings(parsed.options);
48
+ const free = os === 'darwin' && parsed.options.some((option) => optional.includes(option.letter));
49
+ if (!parsed.files.length && !o.clipboard && !free) {
50
+ say(fds[2], `sccapture: output file required (or use -c for clipboard)\n${usage}`);
51
+ return 2;
52
+ }
53
+ if (os === 'darwin') return darwin({ parsed, o, env, platform: os, fds });
54
+ if (parsed.files.length > 1 && !o.clipboard) {
55
+ say(fds[2], `sccapture: more than one file: not supported on ${names[os]}\n`);
56
+ return 2;
57
+ }
58
+ const file = parsed.files.length ? resolve(parsed.files[0]) : undefined;
59
+ const context = { o, file, env, platform: os, fds, wait };
60
+ return os === 'win32' ? windows(context) : linux(context);
61
+ }
@@ -0,0 +1,26 @@
1
+ import { extname } from 'node:path';
2
+ import { lookup, say, step } from './tools.mjs';
3
+
4
+ const extensions = {
5
+ '.jpg': 'jpg',
6
+ '.jpeg': 'jpg',
7
+ '.pdf': 'pdf',
8
+ '.tiff': 'tiff',
9
+ '.gif': 'gif',
10
+ '.bmp': 'bmp',
11
+ };
12
+
13
+ export async function darwin({ parsed, o, env, platform, fds }) {
14
+ const path = lookup('screencapture', platform, env);
15
+ if (!path) {
16
+ say(fds[2], 'sccapture: no screenshot tool found (tried screencapture)\n');
17
+ return 1;
18
+ }
19
+ const args = [...parsed.tokens];
20
+ const type =
21
+ !o.last.t && parsed.files.length && extensions[extname(parsed.files[0]).toLowerCase()];
22
+ if (type) args.push('-t', type);
23
+ if (parsed.files.some((file) => file.startsWith('-'))) args.push('--');
24
+ args.push(...parsed.files);
25
+ return (await step('screencapture', path, args, { env }, fds)) ? 0 : 1;
26
+ }
package/host/linux.mjs ADDED
@@ -0,0 +1,131 @@
1
+ import { closeSync, mkdtempSync, openSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { extname, join } from 'node:path';
4
+ import { lookup, say, step } from './tools.mjs';
5
+
6
+ const extensions = { '.png': 'png', '.jpg': 'jpg', '.jpeg': 'jpg' };
7
+
8
+ const builders = {
9
+ grim(o, file) {
10
+ if (o.window) return { unsupported: o.windowLabel };
11
+ if (o.id) return { unsupported: '-l' };
12
+ const args = o.cursor ? ['-c'] : [];
13
+ const type = o.type ?? extensions[extname(file).toLowerCase()];
14
+ if (o.type === 'png') args.push('-t', 'png');
15
+ else if (type === 'jpg') args.push('-t', 'jpeg');
16
+ if (o.rect) {
17
+ const [x, y, w, h] = o.rect;
18
+ args.push('-g', `${x},${y} ${w}x${h}`);
19
+ }
20
+ return { args: [...args, file], slurp: o.select && !o.rect };
21
+ },
22
+ 'gnome-screenshot'(o, file) {
23
+ if (o.window) return { unsupported: o.windowLabel };
24
+ if (o.rect) return { unsupported: '-R' };
25
+ if (o.id) return { unsupported: '-l' };
26
+ if (o.type && o.type !== 'png') return { unsupported: `-t ${o.last.t.value}` };
27
+ const args = [];
28
+ if (o.select) args.push('-a');
29
+ if (o.cursor) args.push('-p');
30
+ return { args: [...args, '-f', file] };
31
+ },
32
+ import(o, file) {
33
+ if (o.cursor) return { unsupported: '-C' };
34
+ const args = [];
35
+ if (o.id) args.push('-window', o.id);
36
+ else if (!o.select && !o.window) args.push('-window', 'root');
37
+ if (o.rect) {
38
+ const [x, y, w, h] = o.rect;
39
+ args.push('-crop', `${w}x${h}+${x}+${y}`, '+repage');
40
+ }
41
+ const type = o.type ?? (extname(file) ? undefined : 'png');
42
+ return { args: [...args, type ? `${type}:${file}` : file] };
43
+ },
44
+ scrot(o, file) {
45
+ if (o.id) return { unsupported: '-l' };
46
+ if (o.type && extensions[extname(file).toLowerCase()] !== o.type) {
47
+ return { unsupported: `-t ${o.last.t.value}` };
48
+ }
49
+ const args = [];
50
+ if (o.select || o.window) args.push('-s');
51
+ if (o.rect) args.push('-a', o.rect.join(','));
52
+ if (o.cursor) args.push('-p');
53
+ return { args: [...args, '-o', file] };
54
+ },
55
+ };
56
+
57
+ function pick(names, o, file, platform, env) {
58
+ let first;
59
+ for (const name of names) {
60
+ const path = lookup(name, platform, env);
61
+ if (!path) continue;
62
+ const built = builders[name](o, file);
63
+ if (!built.unsupported) return { name, path, ...built };
64
+ first ??= `${built.unsupported}: not supported with ${name}`;
65
+ }
66
+ return { error: first };
67
+ }
68
+
69
+ function clipboard(wayland, platform, env) {
70
+ const xclip = { name: 'xclip', args: ['-selection', 'clipboard', '-t', 'image/png'] };
71
+ const clips = wayland ? [{ name: 'wl-copy', args: ['--type', 'image/png'] }, xclip] : [xclip];
72
+ for (const clip of clips) {
73
+ const path = lookup(clip.name, platform, env);
74
+ if (path) return { ...clip, path };
75
+ }
76
+ return { error: `no clipboard tool found (tried ${clips.map((c) => c.name).join(', ')})` };
77
+ }
78
+
79
+ export async function linux({ o, file, env, platform, fds, wait }) {
80
+ const wayland = Boolean(env.WAYLAND_DISPLAY);
81
+ const names = wayland ? ['grim', 'gnome-screenshot'] : ['gnome-screenshot', 'import', 'scrot'];
82
+ const scratch = o.clipboard ? mkdtempSync(join(tmpdir(), 'sccapture-')) : undefined;
83
+ const target = scratch ? join(scratch, 'capture.png') : file;
84
+ try {
85
+ const tool = pick(names, o, target, platform, env);
86
+ if (!tool.name) {
87
+ if (tool.error) {
88
+ say(fds[2], `sccapture: ${tool.error}\n`);
89
+ return 2;
90
+ }
91
+ say(fds[2], `sccapture: no screenshot tool found (tried ${names.join(', ')})\n`);
92
+ return 1;
93
+ }
94
+ const clip = o.clipboard ? clipboard(wayland, platform, env) : undefined;
95
+ if (clip?.error) {
96
+ say(fds[2], `sccapture: ${clip.error}\n`);
97
+ return 1;
98
+ }
99
+ const slurp = tool.slurp ? lookup('slurp', platform, env) : undefined;
100
+ if (tool.slurp && !slurp) {
101
+ say(fds[2], 'sccapture: no region selector found (tried slurp)\n');
102
+ return 1;
103
+ }
104
+ await wait(o.delay);
105
+ let args = tool.args;
106
+ if (slurp) {
107
+ const region = await step('slurp', slurp, [], { env, capture: true }, fds);
108
+ if (!region) return 1;
109
+ args = ['-g', region.stdout.trim(), ...args];
110
+ }
111
+ if (!(await step(tool.name, tool.path, args, { env }, fds))) return 1;
112
+ if (clip) {
113
+ const fd = openSync(target, 'r');
114
+ try {
115
+ if (!(await step(clip.name, clip.path, clip.args, { env, stdin: fd }, fds))) return 1;
116
+ } finally {
117
+ closeSync(fd);
118
+ }
119
+ return 0;
120
+ }
121
+ if (!o.open) return 0;
122
+ const viewer = lookup('xdg-open', platform, env);
123
+ if (!viewer) {
124
+ say(fds[2], 'sccapture: no viewer found (tried xdg-open)\n');
125
+ return 1;
126
+ }
127
+ return (await step('xdg-open', viewer, [file], { env }, fds)) ? 0 : 1;
128
+ } finally {
129
+ if (scratch) rmSync(scratch, { recursive: true, force: true });
130
+ }
131
+ }
@@ -0,0 +1,111 @@
1
+ const flags = 'abcCdgiklmMoprPsSuUvwWx';
2
+ const valued = 'BDGJlRtTV';
3
+
4
+ export const usage =
5
+ 'usage: sccapture [-acdimorvwxCW] [-t format] [-T seconds] [-R x,y,w,h] [-D display] [-J style] [file]\n';
6
+
7
+ const long = { '--clipboard': 'c', '--view': 'v' };
8
+
9
+ function group(argv, i, result, fail) {
10
+ const arg = argv[i];
11
+ let at = i;
12
+ for (let j = 1; j < arg.length; j++) {
13
+ const letter = arg[j];
14
+ if (letter === 'h') result.help = true;
15
+ else if (valued.includes(letter)) {
16
+ const value = j + 1 < arg.length ? arg.slice(j + 1) : argv[++at];
17
+ if (value === undefined) fail(`-${letter}: option requires a value`);
18
+ else result.options.push({ letter, label: `-${letter}`, value });
19
+ break;
20
+ } else if (flags.includes(letter)) result.options.push({ letter, label: `-${letter}` });
21
+ else fail(`unknown option: -${letter}`);
22
+ }
23
+ result.tokens.push(...argv.slice(i, at + 1));
24
+ return at;
25
+ }
26
+
27
+ export function parse(argv) {
28
+ const result = { help: false, error: undefined, special: [], options: [], files: [], tokens: [] };
29
+ const fail = (message) => {
30
+ result.error ??= message;
31
+ };
32
+ let ended = false;
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const arg = argv[i];
35
+ if (ended) result.files.push(arg);
36
+ else if (arg === '--') ended = true;
37
+ else if (arg === '--help') result.help = true;
38
+ else if (arg === '--version' || arg === '--upgrade') result.special.push(arg);
39
+ else if (long[arg]) {
40
+ result.options.push({ letter: long[arg], label: arg });
41
+ result.tokens.push(`-${long[arg]}`);
42
+ } else if (arg.startsWith('--')) fail(`unknown option: ${arg}`);
43
+ else if (arg.startsWith('-') && arg.length > 1) i = group(argv, i, result, fail);
44
+ else result.files.push(arg);
45
+ }
46
+ return result;
47
+ }
48
+
49
+ const unsupported = {
50
+ darwin: 'gGkvV',
51
+ linux: 'bBDgGkmMpSuUvV',
52
+ win32: 'bBgGiJklMpsSuUvVwW',
53
+ };
54
+
55
+ const formats = {
56
+ linux: ['png', 'jpg', 'jpeg'],
57
+ win32: ['png', 'jpg', 'jpeg', 'bmp', 'gif', 'tiff'],
58
+ };
59
+
60
+ const checks = {
61
+ T: [/^\d+(\.\d+)?$/, 'invalid delay'],
62
+ R: [/^\d+,\d+,[1-9]\d*,[1-9]\d*$/, 'invalid rectangle'],
63
+ D: [/^[1-9]\d*$/, 'invalid display'],
64
+ J: [/^(selection|window|video)$/, 'invalid style'],
65
+ l: [/^(\d+|0x[0-9a-fA-F]+)$/, 'invalid window'],
66
+ };
67
+
68
+ export const names = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
69
+
70
+ export function check(options, platform) {
71
+ const where = `on ${names[platform]}`;
72
+ const own = options.find((option) => unsupported[platform].includes(option.letter));
73
+ if (own) return `${own.label}: not supported ${where}`;
74
+ for (const option of options) {
75
+ const rule = checks[option.letter];
76
+ if (rule && !rule[0].test(option.value))
77
+ return `-${option.letter}: ${rule[1]}: ${option.value}`;
78
+ }
79
+ if (platform === 'darwin') return undefined;
80
+ const style = options.find((option) => option.letter === 'J' && option.value === 'video');
81
+ if (style) return `-J video: not supported ${where}`;
82
+ if (options.some((option) => option.letter === 'c')) return undefined;
83
+ const type = options.find(
84
+ (option) => option.letter === 't' && !formats[platform].includes(option.value.toLowerCase())
85
+ );
86
+ if (type) return `-t ${type.value}: not supported ${where}`;
87
+ return undefined;
88
+ }
89
+
90
+ export function settings(options) {
91
+ const last = {};
92
+ for (const option of options) last[option.letter] = option;
93
+ const style = last.J?.value;
94
+ const label = (...letters) => letters.map((letter) => last[letter]?.label).find(Boolean);
95
+ const rect = last.R?.value.split(',').map(Number);
96
+ return {
97
+ last,
98
+ clipboard: Boolean(last.c),
99
+ cursor: Boolean(last.C),
100
+ open: Boolean(last.P),
101
+ main: Boolean(last.m),
102
+ display: last.D?.value,
103
+ delay: last.T ? Number(last.T.value) : 0,
104
+ rect,
105
+ id: last.l?.value,
106
+ type: last.c ? undefined : last.t?.value.toLowerCase().replace('jpeg', 'jpg'),
107
+ select: Boolean(last.s || last.i || style === 'selection'),
108
+ window: Boolean(last.w || last.W || style === 'window'),
109
+ windowLabel: label('w', 'W') ?? (style === 'window' ? '-J window' : undefined),
110
+ };
111
+ }
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './capture.mjs';
3
+
4
+ process.exitCode = await run();
package/host/tools.mjs ADDED
@@ -0,0 +1,87 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { accessSync, constants, statSync, writeSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export function say(fd, text) {
6
+ writeSync(fd, text);
7
+ }
8
+
9
+ function runnable(path, platform) {
10
+ try {
11
+ if (!statSync(path).isFile()) return false;
12
+ accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK);
13
+ return true;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ export function dirs(platform, env) {
20
+ const path = (platform === 'win32' ? (env.PATH ?? env.Path) : env.PATH) ?? '';
21
+ return path.split(platform === 'win32' ? ';' : ':').filter(Boolean);
22
+ }
23
+
24
+ export function lookup(file, platform, env) {
25
+ for (const dir of dirs(platform, env)) {
26
+ if (runnable(join(dir, file), platform)) return join(dir, file);
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ export function spawned(path, args, { env, stdin = 'ignore', capture = false }) {
32
+ return new Promise((resolve) => {
33
+ let child;
34
+ try {
35
+ child = spawn(path, args, {
36
+ env,
37
+ stdio: [stdin, capture ? 'pipe' : 'ignore', 'pipe'],
38
+ shell: false,
39
+ windowsHide: true,
40
+ });
41
+ } catch (error) {
42
+ resolve({ error });
43
+ return;
44
+ }
45
+ const out = [];
46
+ const errors = [];
47
+ let code;
48
+ let outClosed = !capture;
49
+ let errClosed = false;
50
+ const settle = () => {
51
+ if (code === undefined || !outClosed) return;
52
+ if (code === 0) {
53
+ child.stderr.destroy();
54
+ resolve({ code, stdout: Buffer.concat(out).toString('utf8') });
55
+ } else if (errClosed) resolve({ code, stderr: Buffer.concat(errors) });
56
+ };
57
+ if (capture) {
58
+ child.stdout.on('data', (chunk) => out.push(chunk));
59
+ child.stdout.on('close', () => {
60
+ outClosed = true;
61
+ settle();
62
+ });
63
+ }
64
+ child.stderr.on('data', (chunk) => errors.push(chunk));
65
+ child.stderr.on('close', () => {
66
+ errClosed = true;
67
+ settle();
68
+ });
69
+ child.on('error', (error) => resolve({ error }));
70
+ child.on('exit', (status) => {
71
+ code = status ?? 1;
72
+ settle();
73
+ });
74
+ });
75
+ }
76
+
77
+ export async function step(name, path, args, options, fds) {
78
+ const result = await spawned(path, args, options);
79
+ if (result.error) {
80
+ say(fds[2], `sccapture: ${name}: ${result.error.message}\n`);
81
+ return undefined;
82
+ }
83
+ if (result.code === 0) return result;
84
+ if (result.stderr.length) say(fds[2], result.stderr);
85
+ else say(fds[2], `sccapture: ${name} failed\n`);
86
+ return undefined;
87
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
2
+ import { homedir, tmpdir } from 'node:os';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { dirs, lookup, say, step } from './tools.mjs';
5
+
6
+ const base = 'https://github.com/ai-ecoverse/slicc-capture/releases/latest/download';
7
+
8
+ function version(dir) {
9
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version;
10
+ }
11
+
12
+ function home(platform, env) {
13
+ if (platform === 'win32') return join(env.LOCALAPPDATA ?? homedir(), 'slicc-capture');
14
+ return join(env.XDG_DATA_HOME || join(env.HOME ?? homedir(), '.local', 'share'), 'slicc-capture');
15
+ }
16
+
17
+ function real(path) {
18
+ try {
19
+ return realpathSync(path);
20
+ } catch {
21
+ return path;
22
+ }
23
+ }
24
+
25
+ function launcher(platform, env, data) {
26
+ const fallback =
27
+ platform === 'win32'
28
+ ? join(home(platform, env), 'bin')
29
+ : join(env.HOME ?? homedir(), '.local', 'bin');
30
+ const name = platform === 'win32' ? 'sccapture.cmd' : 'sccapture';
31
+ const marks = [data];
32
+ if (real(home(platform, env)) === data) marks.push(home(platform, env));
33
+ for (const dir of [...dirs(platform, env), fallback]) {
34
+ let text;
35
+ try {
36
+ text = readFileSync(join(dir, name), 'utf8');
37
+ } catch {
38
+ continue;
39
+ }
40
+ if (marks.some((mark) => text.includes(mark))) return dir;
41
+ }
42
+ return fallback;
43
+ }
44
+
45
+ export function installed(entry) {
46
+ const data = dirname(dirname(dirname(real(entry))));
47
+ if (basename(data) !== 'slicc-capture' || !existsSync(join(data, 'current'))) return undefined;
48
+ return data;
49
+ }
50
+
51
+ export async function upgrade({ entry, env, platform, fds }) {
52
+ const root = dirname(dirname(entry));
53
+ const old = version(root);
54
+ const data = installed(entry);
55
+ if (!data) {
56
+ say(fds[1], 'npm install -g @ai-ecoverse/slicc-capture@latest\n');
57
+ return 0;
58
+ }
59
+ const windows = platform === 'win32';
60
+ const getters = windows ? ['curl.exe'] : ['curl', 'wget'];
61
+ const getter = getters
62
+ .map((name) => ({ name, path: lookup(name, platform, env) }))
63
+ .find((g) => g.path);
64
+ if (!getter) {
65
+ say(fds[2], `sccapture: no downloader found (tried ${getters.join(', ')})\n`);
66
+ return 1;
67
+ }
68
+ const shells = windows ? ['powershell.exe', 'pwsh.exe'] : ['sh'];
69
+ const shell =
70
+ shells.map((name) => lookup(name, platform, env)).find(Boolean) ??
71
+ (windows ? undefined : '/bin/sh');
72
+ if (!shell) {
73
+ say(fds[2], `sccapture: no PowerShell found (tried ${shells.join(', ')})\n`);
74
+ return 1;
75
+ }
76
+ const child = {};
77
+ for (const [key, value] of Object.entries(env)) {
78
+ if (!['VERSION', 'INSTALL_DIR'].includes(key.toUpperCase())) child[key] = value;
79
+ }
80
+ const scratch = mkdtempSync(join(tmpdir(), 'sccapture-upgrade-'));
81
+ try {
82
+ const name = windows ? 'install.ps1' : 'install.sh';
83
+ const script = join(scratch, name);
84
+ const url = `${base}/${name}`;
85
+ const fetch = getter.name === 'wget' ? ['-q', '-O', script, url] : ['-fsSL', '-o', script, url];
86
+ if (!(await step(getter.name, getter.path, fetch, { env: child }, fds))) return 1;
87
+ const dir = launcher(platform, env, data);
88
+ const args = windows
89
+ ? [
90
+ '-NoProfile',
91
+ '-NonInteractive',
92
+ '-ExecutionPolicy',
93
+ 'Bypass',
94
+ '-File',
95
+ script,
96
+ '--dir',
97
+ dir,
98
+ ]
99
+ : [script, '--dir', dir];
100
+ if (!(await step(name, shell, args, { env: child }, fds))) return 1;
101
+ const now = version(join(data, 'current'));
102
+ say(
103
+ fds[1],
104
+ now === old ? `sccapture: already up to date (${old})\n` : `sccapture: ${old} → ${now}\n`
105
+ );
106
+ return 0;
107
+ } finally {
108
+ rmSync(scratch, { recursive: true, force: true });
109
+ }
110
+ }
@@ -0,0 +1,125 @@
1
+ import { extname } from 'node:path';
2
+ import { lookup, say, step } from './tools.mjs';
3
+
4
+ const extensions = {
5
+ '.png': 'png',
6
+ '.jpg': 'jpg',
7
+ '.jpeg': 'jpg',
8
+ '.bmp': 'bmp',
9
+ '.gif': 'gif',
10
+ '.tif': 'tiff',
11
+ '.tiff': 'tiff',
12
+ };
13
+
14
+ const native = [
15
+ 'using System;',
16
+ 'using System.Runtime.InteropServices;',
17
+ 'public static class SccaptureNative {',
18
+ '[StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; }',
19
+ '[StructLayout(LayoutKind.Sequential)] public struct CURSORINFO { public int cbSize; public int flags; public IntPtr hCursor; public POINT pt; }',
20
+ '[StructLayout(LayoutKind.Sequential)] public struct ICONINFO { public bool fIcon; public int xHotspot; public int yHotspot; public IntPtr hbmMask; public IntPtr hbmColor; }',
21
+ '[DllImport("user32.dll")] static extern bool SetProcessDPIAware();',
22
+ '[DllImport("user32.dll")] static extern bool SetProcessDpiAwarenessContext(IntPtr context);',
23
+ 'public static void Aware() {',
24
+ 'try { if (SetProcessDpiAwarenessContext(new IntPtr(-4))) return; } catch (EntryPointNotFoundException) { }',
25
+ 'SetProcessDPIAware();',
26
+ '}',
27
+ '[DllImport("user32.dll")] static extern bool GetCursorInfo(ref CURSORINFO info);',
28
+ '[DllImport("user32.dll")] static extern bool GetIconInfo(IntPtr icon, out ICONINFO info);',
29
+ '[DllImport("user32.dll")] static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr icon, int w, int h, int step, IntPtr brush, int flags);',
30
+ '[DllImport("gdi32.dll")] static extern bool DeleteObject(IntPtr handle);',
31
+ 'public static void DrawCursor(IntPtr hdc, int left, int top) {',
32
+ 'CURSORINFO info = new CURSORINFO();',
33
+ 'info.cbSize = Marshal.SizeOf(typeof(CURSORINFO));',
34
+ 'if (!GetCursorInfo(ref info) || info.flags != 1) return;',
35
+ 'ICONINFO icon;',
36
+ 'if (!GetIconInfo(info.hCursor, out icon)) return;',
37
+ 'DrawIconEx(hdc, info.pt.X - icon.xHotspot - left, info.pt.Y - icon.yHotspot - top, info.hCursor, 0, 0, 0, IntPtr.Zero, 3);',
38
+ 'if (icon.hbmMask != IntPtr.Zero) DeleteObject(icon.hbmMask);',
39
+ 'if (icon.hbmColor != IntPtr.Zero) DeleteObject(icon.hbmColor);',
40
+ '}',
41
+ '}',
42
+ ].join(' ');
43
+
44
+ export const script = [
45
+ "$ErrorActionPreference = 'Stop'",
46
+ "$ProgressPreference = 'SilentlyContinue'",
47
+ 'try {',
48
+ ' Add-Type -AssemblyName System.Drawing',
49
+ ' Add-Type -AssemblyName System.Windows.Forms',
50
+ ` Add-Type -TypeDefinition '${native}'`,
51
+ ' [SccaptureNative]::Aware()',
52
+ ' $screen = $env:SCCAPTURE_SCREEN',
53
+ " if ($screen -eq 'all') {",
54
+ ' $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen',
55
+ " } elseif ($screen -eq 'primary') {",
56
+ ' $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds',
57
+ ' } else {',
58
+ ' $screens = @([System.Windows.Forms.Screen]::PrimaryScreen) + @([System.Windows.Forms.Screen]::AllScreens | Where-Object { -not $_.Primary })',
59
+ ' $index = [int]$screen',
60
+ ' if ($index -gt $screens.Count) {',
61
+ " [Console]::Error.WriteLine(('sccapture: -D {0}: no such display' -f $index))",
62
+ ' exit 1',
63
+ ' }',
64
+ ' $bounds = $screens[$index - 1].Bounds',
65
+ ' }',
66
+ ' if ($env:SCCAPTURE_RECT) {',
67
+ " $rect = [int[]]($env:SCCAPTURE_RECT -split ',')",
68
+ ' $bounds = New-Object System.Drawing.Rectangle ($bounds.X + $rect[0]), ($bounds.Y + $rect[1]), $rect[2], $rect[3]',
69
+ ' }',
70
+ ' $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height',
71
+ ' $graphics = [System.Drawing.Graphics]::FromImage($bitmap)',
72
+ ' $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)',
73
+ " if ($env:SCCAPTURE_CURSOR -eq '1') {",
74
+ ' $hdc = $graphics.GetHdc()',
75
+ ' [SccaptureNative]::DrawCursor($hdc, $bounds.X, $bounds.Y)',
76
+ ' $graphics.ReleaseHdc($hdc)',
77
+ ' }',
78
+ ' $graphics.Dispose()',
79
+ " if ($env:SCCAPTURE_CLIPBOARD -eq '1') {",
80
+ ' [System.Windows.Forms.Clipboard]::SetImage($bitmap)',
81
+ ' } else {',
82
+ " $formats = @{ png = 'Png'; jpg = 'Jpeg'; bmp = 'Bmp'; gif = 'Gif'; tiff = 'Tiff' }",
83
+ ' $bitmap.Save($env:SCCAPTURE_FILE, [System.Drawing.Imaging.ImageFormat]::($formats[$env:SCCAPTURE_FORMAT]))',
84
+ " if ($env:SCCAPTURE_OPEN -eq '1') { Invoke-Item -LiteralPath $env:SCCAPTURE_FILE }",
85
+ ' }',
86
+ ' $bitmap.Dispose()',
87
+ '} catch {',
88
+ ' [Console]::Error.WriteLine($_.Exception.Message)',
89
+ ' exit 1',
90
+ '}',
91
+ ].join('\n');
92
+
93
+ export const encoded = Buffer.from(script, 'utf16le').toString('base64');
94
+
95
+ export async function windows({ o, file, env, platform, fds, wait }) {
96
+ const names = ['powershell.exe', 'pwsh.exe'];
97
+ const name = names.find((candidate) => lookup(candidate, platform, env));
98
+ if (!name) {
99
+ say(fds[2], `sccapture: no screenshot tool found (tried ${names.join(', ')})\n`);
100
+ return 1;
101
+ }
102
+ let screen = 'all';
103
+ if (o.display) screen = o.display;
104
+ else if (o.main) screen = 'primary';
105
+ const data = {
106
+ SCCAPTURE_FILE: o.clipboard ? undefined : file,
107
+ SCCAPTURE_FORMAT: o.clipboard
108
+ ? 'png'
109
+ : (o.type ?? extensions[extname(file).toLowerCase()] ?? 'png'),
110
+ SCCAPTURE_CLIPBOARD: o.clipboard ? '1' : undefined,
111
+ SCCAPTURE_CURSOR: o.cursor ? '1' : undefined,
112
+ SCCAPTURE_SCREEN: screen,
113
+ SCCAPTURE_RECT: o.rect?.join(','),
114
+ SCCAPTURE_OPEN: o.open && !o.clipboard ? '1' : undefined,
115
+ };
116
+ const child = {};
117
+ for (const [key, value] of Object.entries(env)) {
118
+ if (!key.toUpperCase().startsWith('SCCAPTURE_')) child[key] = value;
119
+ }
120
+ for (const [key, value] of Object.entries(data)) if (value !== undefined) child[key] = value;
121
+ await wait(o.delay);
122
+ const args = ['-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', encoded];
123
+ const done = await step(name, lookup(name, platform, env), args, { env: child }, fds);
124
+ return done ? 0 : 1;
125
+ }
package/package.json CHANGED
@@ -1,10 +1,53 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/slicc-capture",
3
- "version": "0.0.0",
3
+ "version": "1.0.0",
4
4
  "description": "screencapture for SLICC, and sccapture for SLICC and any host",
5
+ "type": "module",
5
6
  "license": "Apache-2.0",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/ai-ecoverse/slicc-capture.git"
10
+ },
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "bin": {
15
+ "sccapture": "host/sccapture.mjs"
16
+ },
17
+ "fledgling": {
18
+ "provider": "github",
19
+ "workflow": "ci.yml",
20
+ "repo": "ai-ecoverse/slicc-capture",
21
+ "publish": true
22
+ },
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "slicc": {
28
+ "unitSources": [
29
+ "host"
30
+ ]
31
+ },
32
+ "files": [
33
+ "host"
34
+ ],
35
+ "scripts": {
36
+ "prepare": "slicc-install-hooks",
37
+ "lint": "slicc-lint",
38
+ "format": "biome check --write .",
39
+ "test": "node --test --test-timeout=120000 test/integration/*.test.mjs",
40
+ "test:unit": "mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
41
+ "build": "npm pack --dry-run",
42
+ "trust": "npx fledgling sync -y --skip-publish",
43
+ "trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
44
+ "release": "semantic-release"
45
+ },
46
+ "devDependencies": {
47
+ "@ai-ecoverse/slicc-shared-web": "2.11.3",
48
+ "@biomejs/biome": "2.5.15",
49
+ "lefthook": "2.2.2",
50
+ "monocart-coverage-reports": "2.13.2",
51
+ "semantic-release": "25.0.9"
9
52
  }
10
53
  }