@watermarkws/cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Much Media Inc.
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 CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # Watermark CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Watermark, resize and convert images, videos and PDFs from the command line with the [Watermark API](https://api.watermark.ws/docs). Built for people and for AI agents: `--json` prints machine-readable output and exit codes mean something.
4
+
5
+ ```bash
6
+ export WATERMARK_API_KEY=wm_test_… # https://watermark.ws/developers
7
+ npx @watermarkws/cli image process https://example.com/photo.jpg \
8
+ --op resize:width=1600,height=1600 \
9
+ --op watermark:image_url=https://example.com/logo.png,x=40,y=40 \
10
+ --format webp --out photo.webp
11
+ ```
12
+
13
+ Node 18+, no dependencies.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ brew install watermarkws/tap/watermark # Homebrew, macOS and Linux
19
+ npm install -g @watermarkws/cli # npm
20
+ ```
21
+
22
+ Either way the command is `watermark`. Homebrew brings its own Node; `npx @watermarkws/cli` runs it without installing.
23
+
24
+ ## Commands
25
+
26
+ ```
27
+ watermark image process <source> --op <operation> [--op ...] [--format webp] [--out file]
28
+ watermark image read <source> [--level] # --level also finds the angle that levels a tilted horizon
29
+ watermark image create --width 1200 --height 300 --op 'text:text=Hi,font=inter-bold,size=96' --op trim --out logo.png
30
+ watermark video process <source> --op <operation> [--max-duration 30] [--out file]
31
+ watermark video read <source> | watermark video screenshot <source> --seconds 5
32
+ watermark document process <source> --op <operation> [--max-pages 10] [--out file]
33
+ watermark document read <source> | watermark document snapshot <source> --page 1
34
+ watermark request get <id> | watermark request abort <id>
35
+ watermark requests [--before <id>] | watermark account | watermark usage --group-by endpoint [--customer <id>]
36
+ watermark upload photo.heic | watermark upload get|delete <upl_id>
37
+ watermark file get|delete <file_id>
38
+ watermark storage list | connect --bucket b --region us-east-1 --role-arn <arn> --prefix in/ [--read true|false] [--write true|false] | get|verify|disconnect <stg_id>
39
+ watermark storage update <stg_id> --prefix photos/ --prefix archive/ [--read true|false] [--write true|false] # the whole new list; checked before it is kept
40
+ watermark events --since 2026-10-13T15:00:00Z
41
+ watermark fonts
42
+ watermark operations image
43
+ ```
44
+
45
+ Processing commands wait for the request to finish (up to `--timeout`, default 300 seconds) unless `--no-wait` is given; `--out` downloads the result.
46
+
47
+ Operations run in order: `--op type:param=value,param=value`, or all at once with `--ops-json '[{"type":"resize","width":800,"height":600}]'`. `watermark operations` lists them all.
48
+
49
+ More than one file from a request, each with its own operations and format: `--outputs-json '[{"name":"large","format":"webp"},{"name":"thumb","operations":[{"type":"resize","width":200,"height":200}]}]'`.
50
+
51
+ Other options: `--webhook <url>`, `--event <type>` (repeatable, such as `request.progress`), `--customer <id>`, `--metadata key=value`, `--idempotency-key <key>`, `--json`, `--api-key`, `--base-url` (or `WATERMARK_API_URL`).
52
+
53
+ A source is a public http(s) URL, a local file (uploaded first), an upload
54
+ (`upl_…`), a file an earlier request kept (`file_…`), or an object in a
55
+ connected bucket (`stg_7a:path/to/key`).
56
+
57
+ ## Errors
58
+
59
+ The CLI passes every error code through as the API sends it: `Error 422 validation_failed: …`, with every invalid field below it, or with `--json` the whole error object (`type`, `code`, `message`, `param`, `request_id`, `errors`, and the `permissions_policy` a storage change whose check failed needs). The codes are listed at https://api.watermark.ws/docs/errors.md.
60
+
61
+ ## Exit codes
62
+
63
+ | Code | Meaning |
64
+ | --- | --- |
65
+ | 0 | Succeeded (or queued, with `--no-wait`). |
66
+ | 1 | The API refused the call, or the request failed or was aborted. The error code is printed. |
67
+ | 2 | The command was used wrongly. |
68
+
69
+ Rate limits (429) and server errors are retried twice with the same `Idempotency-Key`, so a retry never runs a request twice.
70
+
71
+ ## For AI agents
72
+
73
+ `SKILL.md` is an [Agent Skill](https://agentskills.io) for this CLI: when to use it, the commands, the output shape, exit codes and the rules an agent should follow (ask before a big batch, never paste file bytes). Save it as `.claude/skills/watermark/SKILL.md` in a project for Claude Code, or paste it into any agent's instructions. The API's own guide for agents, with its MCP server and plain HTTP, is at https://api.watermark.ws/docs/agents.md.
74
+
75
+ ## Development
76
+
77
+ ```bash
78
+ npm test
79
+ ```
80
+
81
+ `test/spec-coverage.test.js` holds the CLI to the API's OpenAPI spec: every endpoint has a command, every parameter is sent, and the spec is the one in `spec/openapi.json`, whose sha256 is in `spec.sha`. It reads the spec from `WATERMARK_API_SPEC` (a path or URL), else `../watermark-api`, else `spec/openapi.json`. When the API changes, update the CLI, then run `npm run spec:refresh` to record the new spec as checked.
package/SKILL.md ADDED
@@ -0,0 +1,106 @@
1
+ ---
2
+ name: watermark
3
+ description: Watermark, resize, crop, convert and stamp text on images, videos and PDF or Word documents with the Watermark API from the shell, through the `watermark` CLI. Use it when a user wants a logo or text placed on files, a batch of photos resized or converted (JPEG, PNG, WebP, HEIC, camera RAW and more), a video watermarked or trimmed to a preview, a frame or a page saved as an image, or every page of a PDF stamped while it stays a PDF. Sources can be public URLs, local files (uploaded automatically) or files an earlier request made. Needs a WATERMARK_API_KEY from https://watermark.ws/developers.
4
+ ---
5
+
6
+ # Watermark CLI
7
+
8
+ The `watermark` command sends a file and a list of operations to the Watermark API, waits for the result and downloads it. Use it instead of a local image library for batches, video, PDF/DOCX, camera formats (HEIC, CR3, NEF, ARW, DNG), text in real fonts, and when the result should be a link the user can download.
9
+
10
+ ## Install and set the key
11
+
12
+ ```bash
13
+ npm install -g @watermarkws/cli # or: brew install watermarkws/tap/watermark
14
+ npx @watermarkws/cli --version # or run it once without installing (Node 18+)
15
+ export WATERMARK_API_KEY=wm_test_… # keys: https://watermark.ws/developers
16
+ ```
17
+
18
+ `wm_test_…` keys are free and return real results within small limits (10 requests a minute, 100 a day, sources up to 10 MB, no video). `wm_live_…` keys spend the account's prepaid credit. If the user has no key, send them to https://watermark.ws/developers; new accounts start with $1 of free credit.
19
+
20
+ ## Core commands
21
+
22
+ A `<source>` is a public http(s) URL, a local file path (uploaded first, automatically), an upload id `upl_…`, a file an earlier request made `file_…`, or `stg_7a:path/key` in a connected bucket. Operations run in the order given: `--op type:param=value,param=value`; numbers and `true`/`false` are typed, everything else is text.
23
+
24
+ ```bash
25
+ watermark image process photo.jpg --op resize:width=1600,height=1600 --op watermark:image_url=https://example.com/logo.png,x=40,y=40 --format webp --out photo.webp
26
+ watermark image read photo.jpg --json # width, height, format; add --level for the angle that levels the horizon
27
+ watermark image create --width 1200 --height 300 --op 'text:text=Lena Hart,font=inter-bold,size=96,color=#202020,position=center' --op trim --format png --out logo.png
28
+ watermark video process clip.mp4 --op watermark:image_url=https://example.com/logo.png,x=24,y=24 --max-duration 30 --out preview.mp4
29
+ watermark video screenshot clip.mp4 --seconds 5 --format png --out frame.png
30
+ watermark document process contract.pdf --op watermark:image_url=https://example.com/stamp.png,x=20,y=230 --out contract-stamped.pdf
31
+ watermark document snapshot contract.pdf --page 1 --format png --out page-1.png
32
+ watermark operations image # every operation and parameter, live from the API (also video, document)
33
+ watermark fonts # font names a text operation accepts
34
+ watermark request get 1234 # follow a request started with --no-wait
35
+ watermark account # balance and limits
36
+ ```
37
+
38
+ Useful options on processing commands: `--out <file>` (wait, then download), `--no-wait` (print the queued request and return), `--timeout <seconds>` (default 300), `--format <fmt>`, `--ops-json '[…]'` instead of `--op`, `--max-duration <s>` (video), `--max-pages <n>` (documents), `--customer <id>`, `--metadata key=value`, `--json`.
39
+
40
+ Operations that exist: images `resize`, `crop`, `rotate`, `flip`, `grayscale`, `border`, `watermark`, `thumbnail`, `compress`, `sepia`, `exposure`, `filter`, `remove_background`, `orient`, `text`, `trim`, `color`, `frame`, `protect`, `shave`, `line`, `adjust`, `color_profile`; videos `resize`, `crop`, `rotate`, `flip`, `grayscale`, `border`, `watermark`; documents `watermark` only (an image on every page; make a text stamp with `image create` first). `watermark` takes `image_url` or `image` (a `file_…` id), `x`, `y` from the top left, `tile_spacing_x`/`tile_spacing_y` to repeat it, `diagonal_lines_color`/`diagonal_lines_opacity`. Run `watermark operations image` before using a parameter you are not sure of.
41
+
42
+ ## A folder of files
43
+
44
+ Prepare the watermark once and reuse its `file_…` id; one request per file after that.
45
+
46
+ ```bash
47
+ logo=$(watermark image process logo.png --op orient --format png --json | jq -r '.result.files[0].id')
48
+ mkdir -p out
49
+ for f in photos/*; do
50
+ name=$(basename "${f%.*}")
51
+ watermark image process "$f" --op resize:width=1600,height=1600 --op "watermark:image=$logo,x=40,y=40" --format webp --out "out/$name.webp"
52
+ done
53
+ ```
54
+
55
+ Stamp text across every page of a PDF: make the stamp, then watermark the document with it.
56
+
57
+ ```bash
58
+ stamp=$(watermark image create --width 800 --height 800 --op 'text:text=CONFIDENTIAL,font=inter-bold,size=80,color=#c0392b,opacity=35,position=center,rotation=-30' --op trim --format png --json | jq -r '.result.files[0].id')
59
+ watermark document process contract.pdf --op "watermark:image=$stamp,x=20,y=230" --out contract-confidential.pdf
60
+ ```
61
+
62
+ ## Output and exit codes
63
+
64
+ With `--json`, stdout is only the finished request as the API returns it (progress goes to stderr):
65
+
66
+ ```json
67
+ {"id": 1234, "object": "request", "endpoint": "images/process", "status": "succeeded",
68
+ "result": {"data": {"width": 1600, "height": 1067, "format": "WEBP"},
69
+ "files": [{"id": "file_…", "url": "https://…", "url_expires_at": "2026-10-18T15:00:04Z", "format": "webp", "size": 183021}]},
70
+ "price": {"amount": "0.002000", "currency": "usd"}, "saved_to": "photo.webp"}
71
+ ```
72
+
73
+ A failed request has `"status": "failed"` and `error.code` / `error.message`. An API error with `--json` prints `{"error": {"status": 422, "type": "…", "code": "…", "message": "…", "errors": {…}}}` on stdout. Without `--json`, the result is human-readable lines (`Request 1234 (images/process): succeeded`, `File: https://…`, `Saved to …`, `Price: $0.002000`).
74
+
75
+ | Exit | Meaning |
76
+ | --- | --- |
77
+ | 0 | The request succeeded (or was queued, with `--no-wait`). |
78
+ | 1 | The API refused the call, or the request failed or was aborted. The error code is printed. |
79
+ | 2 | The command was used wrongly (bad source, missing `--op`, unknown command). |
80
+
81
+ ## Rules
82
+
83
+ - Never paste file contents or base64 into a command; give the path and the CLI uploads it. Never ask the user to host a local file first.
84
+ - Credit is prepaid and every finished request is charged ($0.002 an image, $0.004 a document page, $0.02 a started minute of video). Before more than about 20 files, a video over about 10 minutes, or a document with hundreds of pages, tell the user the cost and ask.
85
+ - Failed and aborted requests are free; fix the cause (the message names the field) and run again. Rate limits and server errors are retried by the CLI itself.
86
+ - Always pass `--out` when the user wants the file, and `--json` when a script reads the result. Download links in `result.files[].url` last 7 days; `watermark file get <file_id>` gives a fresh one while the file is kept.
87
+ - Video needs a live key on an account that has bought credit; a test key gets `402 paid_account_required`.
88
+ - Check a file before guessing parameters: `watermark image read`, `watermark video read` and `watermark document read` give dimensions, duration and page count.
89
+
90
+ ## Common errors
91
+
92
+ | Error | Fix |
93
+ | --- | --- |
94
+ | `Set WATERMARK_API_KEY or pass --api-key`, `401 missing_api_key`, `401 invalid_api_key` | No key, or a wrong or revoked key. Export `WATERMARK_API_KEY` from https://watermark.ws/developers. |
95
+ | `402 insufficient_credits` | The account has no credit left. Buy credit at https://watermark.ws/developers. |
96
+ | `402 paid_account_required` | Video, or writing to your own storage, needs an account that has bought credit; a test key cannot. |
97
+ | `422 validation_failed` | A parameter is wrong; the invalid fields are listed under the message. Check `watermark operations <media>`. |
98
+ | `429 rate_limited` / `429 concurrency_limited` | Too many requests a minute, or too many running at once. The CLI retries twice; otherwise wait and run fewer in parallel. |
99
+ | `is not a URL, an id, stg_…:key, or a file here` | The source path does not exist or is not a file. |
100
+ | `Request … was still processing after 300 seconds` | A long video. Rerun with `--timeout 1800`, or `--no-wait` and later `watermark request get <id>`. |
101
+
102
+ ## More
103
+
104
+ - Agent guide, with MCP server, HTTP and worked examples: https://api.watermark.ws/docs/agents.md
105
+ - Every operation and parameter: https://api.watermark.ws/docs/operations.md
106
+ - Every command and option: `watermark --help`, or https://api.watermark.ws/docs/cli.md
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.js';
3
+
4
+ main(process.argv.slice(2), { env: process.env, stdout: process.stdout, stderr: process.stderr })
5
+ .then((code) => { process.exitCode = code; });
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@watermarkws/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Watermark, resize and convert images, videos and PDFs from the command line with the Watermark API.",
5
+ "type": "module",
6
+ "bin": {
7
+ "watermark": "bin/watermark.js"
8
+ },
9
+ "files": ["bin", "src", "README.md", "SKILL.md", "LICENSE"],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test test/",
15
+ "spec:refresh": "node scripts/refresh-spec.js"
16
+ },
17
+ "keywords": ["watermark", "image", "video", "pdf", "cli", "api"],
18
+ "homepage": "https://api.watermark.ws/docs",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/watermarkws/watermark-cli.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/watermarkws/watermark-cli/issues"
25
+ },
26
+ "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ }
30
+ }
package/src/api.js ADDED
@@ -0,0 +1,123 @@
1
+ // A small client for the Watermark API: the key, JSON, typed errors, and
2
+ // retries for 429 (after Retry-After), 5xx and dropped connections, with an
3
+ // Idempotency-Key on every POST so a retried POST never runs twice.
4
+
5
+ export const VERSION = '0.1.1';
6
+
7
+ export class ApiError extends Error {
8
+ constructor(status, error = {}) {
9
+ super(error.message || `The API answered ${status}.`);
10
+ this.status = status;
11
+ this.type = error.type || 'server_error';
12
+ this.code = error.code || 'unknown';
13
+ this.param = error.param;
14
+ this.requestId = error.request_id;
15
+ this.errors = error.errors;
16
+ // The whole error object as the API sent it, with the fields some errors
17
+ // add, such as a storage change's permissions_policy.
18
+ this.error = error;
19
+ }
20
+ }
21
+
22
+ export class Api {
23
+ constructor({ apiKey, baseUrl = 'https://api.watermark.ws', fetch = globalThis.fetch, sleep, maxRetries = 2 }) {
24
+ if (!apiKey) {
25
+ throw new Error('Set WATERMARK_API_KEY or pass --api-key. Get a key at https://watermark.ws/developers');
26
+ }
27
+ this.apiKey = apiKey;
28
+ this.baseUrl = baseUrl.replace(/\/+$/, '');
29
+ this.fetch = fetch;
30
+ this.sleep = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
31
+ this.maxRetries = maxRetries;
32
+ }
33
+
34
+ async request(method, path, { body, query, idempotencyKey } = {}) {
35
+ const url = new URL(this.baseUrl + path);
36
+ for (const [key, value] of Object.entries(query || {})) {
37
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
38
+ }
39
+
40
+ const headers = {
41
+ Authorization: `Bearer ${this.apiKey}`,
42
+ Accept: 'application/json',
43
+ 'User-Agent': `watermark-cli/${VERSION} node/${process.versions.node}`,
44
+ };
45
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
46
+ if (method === 'POST') headers['Idempotency-Key'] = idempotencyKey || crypto.randomUUID();
47
+
48
+ for (let attempt = 0; ; attempt++) {
49
+ let response;
50
+ try {
51
+ response = await this.fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
52
+ } catch (error) {
53
+ if (attempt >= this.maxRetries) throw error;
54
+ await this.sleep(500 * 2 ** attempt);
55
+ continue;
56
+ }
57
+
58
+ if ((response.status === 429 || response.status >= 500) && attempt < this.maxRetries) {
59
+ const retryAfter = Number(response.headers.get('retry-after'));
60
+ await this.sleep(Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 60) * 1000 : 500 * 2 ** attempt);
61
+ continue;
62
+ }
63
+
64
+ const text = await response.text();
65
+ const json = text ? JSON.parse(text) : {};
66
+ if (response.status >= 400) throw new ApiError(response.status, json.error);
67
+ return json;
68
+ }
69
+ }
70
+
71
+ // Wait for a request to be succeeded, failed or aborted. Each call asks the
72
+ // API to hold the response until the request finishes (up to 25 seconds),
73
+ // so the result comes back as soon as it is ready.
74
+ async wait(id, { timeoutMs = 300_000, intervalMs = 1000, onProgress } = {}) {
75
+ const started = Date.now();
76
+ for (;;) {
77
+ const hold = Math.max(0, Math.min(25, Math.floor((timeoutMs - (Date.now() - started)) / 1000)));
78
+ const before = Date.now();
79
+ const request = await this.request('GET', `/v1/requests/${id}`, { query: { wait: hold || undefined } });
80
+ if (['succeeded', 'failed', 'aborted'].includes(request.status)) return request;
81
+ if (onProgress) onProgress(request);
82
+ if (Date.now() - started >= timeoutMs) {
83
+ throw new Error(`Request ${id} was still ${request.status} after ${Math.round(timeoutMs / 1000)} seconds.`);
84
+ }
85
+ if (hold > 0 && Date.now() - before >= (hold - 1) * 1000) continue;
86
+ await this.sleep(intervalMs);
87
+ intervalMs = Math.min(5000, intervalMs * 1.5);
88
+ }
89
+ }
90
+
91
+ // Make an upload for a local file and PUT the file to its URL. Its id is
92
+ // then a request's source.
93
+ async upload(path, { readFile, contentType } = {}) {
94
+ const bytes = await readFile(path);
95
+ const filename = path.split(/[\\/]/).pop();
96
+ const type = contentType || contentTypeOf(filename);
97
+ const upload = await this.request('POST', '/v1/uploads', { body: { filename, size: bytes.length, content_type: type } });
98
+
99
+ const response = await this.fetch(upload.upload.url, {
100
+ method: 'PUT',
101
+ headers: upload.upload.headers || { 'Content-Type': type },
102
+ body: bytes,
103
+ });
104
+ if (!response.ok) throw new Error(`Sending ${filename} failed: HTTP ${response.status}.`);
105
+
106
+ return upload;
107
+ }
108
+ }
109
+
110
+ const CONTENT_TYPES = {
111
+ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif',
112
+ heic: 'image/heic', heif: 'image/heif', tif: 'image/tiff', tiff: 'image/tiff',
113
+ bmp: 'image/bmp', svg: 'image/svg+xml', psd: 'image/vnd.adobe.photoshop',
114
+ cr2: 'image/x-canon-cr2', cr3: 'image/x-canon-cr3', nef: 'image/x-nikon-nef', arw: 'image/x-sony-arw',
115
+ dng: 'image/x-adobe-dng', raf: 'image/x-fuji-raf', orf: 'image/x-olympus-orf', rw2: 'image/x-panasonic-rw2',
116
+ mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/x-m4v', webm: 'video/webm', mkv: 'video/x-matroska',
117
+ avi: 'video/x-msvideo', flv: 'video/x-flv', wmv: 'video/x-ms-wmv', mpg: 'video/mpeg',
118
+ pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
119
+ };
120
+
121
+ export function contentTypeOf(filename) {
122
+ return CONTENT_TYPES[(filename.split('.').pop() || '').toLowerCase()] || 'application/octet-stream';
123
+ }
package/src/cli.js ADDED
@@ -0,0 +1,407 @@
1
+ import { readFile, stat, writeFile } from 'node:fs/promises';
2
+ import { Api, ApiError, VERSION } from './api.js';
3
+
4
+ const HELP = `watermark: the Watermark API from the command line (https://api.watermark.ws/docs)
5
+
6
+ Usage:
7
+ watermark image process <source> --op <operation> [--op ...] [--format webp] [--out file]
8
+ watermark image read <source> [--level]
9
+ watermark image create --width 1200 --height 300 [--background "#ffffff"] --op <operation> [--out file]
10
+ watermark video process <source> --op <operation> [--max-duration 30] [--out file]
11
+ watermark video read <source>
12
+ watermark video screenshot <source> --seconds 5 [--format png] [--out file]
13
+ watermark document process <source> --op <operation> [--max-pages 10] [--out file]
14
+ watermark document read <source>
15
+ watermark document snapshot <source> [--page 1] [--format png] [--out file]
16
+ watermark request get <id> | watermark request abort <id>
17
+ watermark requests [--limit 20] [--before <id>]
18
+ watermark upload <file> | watermark upload get|delete <upl_id>
19
+ watermark file get|delete <file_id>
20
+ watermark storage list | get|verify|disconnect <stg_id>
21
+ watermark storage connect --bucket <name> --region <region> --role-arn <arn> --prefix in/ [--prefix ...] [--read true|false] [--write true|false]
22
+ watermark storage update <stg_id> [--prefix in/ ...] [--read true|false] [--write true|false]
23
+ watermark events --since 2026-10-13T15:00:00Z [--limit 100]
24
+ watermark fonts
25
+ watermark account
26
+ watermark usage [--from 2026-10-01] [--to 2026-10-31] [--group-by day|endpoint|customer|status] [--customer <id>]
27
+ watermark operations [image|video|document]
28
+
29
+ A source is a public http(s) URL, a local file (uploaded first), an upload
30
+ (upl_…), a file an earlier request kept (file_…), or an object in a
31
+ connected bucket (stg_7a:path/to/key).
32
+
33
+ Operations (repeat --op; they run in order):
34
+ --op resize:width=800,height=600
35
+ --op grayscale
36
+ --op watermark:image_url=https://example.com/logo.png,x=40,y=40
37
+ --ops-json '[{"type":"resize","width":800,"height":600}]'
38
+ See: watermark operations image
39
+
40
+ More than one file from a request, each with its own operations and format:
41
+ --outputs-json '[{"name":"large","format":"webp"},{"name":"thumb","operations":[{"type":"resize","width":200,"height":200}]}]'
42
+
43
+ Options:
44
+ --out <file> Wait, then download the result to this file.
45
+ --no-wait Return as soon as the request is queued.
46
+ --timeout <seconds> How long to wait (default 300).
47
+ --webhook <url> Post the finished request here.
48
+ --event <type> The webhook events to send (repeatable), such as request.progress.
49
+ --customer <id> Your own reference for who the request is for.
50
+ --metadata key=value Repeatable.
51
+ --idempotency-key <k> Make a retry of this command safe.
52
+ --json Print JSON only (for scripts and AI agents).
53
+ --api-key <key> Or set WATERMARK_API_KEY.
54
+ --base-url <url> Or set WATERMARK_API_URL (default https://api.watermark.ws).
55
+ --version Print the version.
56
+ `;
57
+
58
+ const MEDIA = { image: 'images', images: 'images', video: 'videos', videos: 'videos', document: 'documents', documents: 'documents', pdf: 'documents' };
59
+ const ACTIONS = {
60
+ images: ['process', 'read', 'create'],
61
+ videos: ['process', 'read', 'screenshot'],
62
+ documents: ['process', 'read', 'snapshot'],
63
+ };
64
+ const REPEATABLE = new Set(['op', 'metadata', 'prefix', 'event']);
65
+ const FLAGS = new Set(['json', 'no-wait', 'help', 'version', 'level']);
66
+
67
+ // Parse argv into positionals and options; repeatable options collect arrays.
68
+ export function parseArgs(argv) {
69
+ const positionals = [];
70
+ const options = { op: [], metadata: [], prefix: [], event: [] };
71
+
72
+ for (let i = 0; i < argv.length; i++) {
73
+ const arg = argv[i];
74
+ if (!arg.startsWith('--')) {
75
+ positionals.push(arg);
76
+ continue;
77
+ }
78
+ let [name, value] = arg.slice(2).split(/=(.*)/s, 2);
79
+ if (FLAGS.has(name)) {
80
+ options[name] = true;
81
+ continue;
82
+ }
83
+ if (value === undefined) {
84
+ value = argv[++i];
85
+ if (value === undefined) throw new UsageError(`--${name} needs a value.`);
86
+ }
87
+ if (REPEATABLE.has(name)) options[name].push(value);
88
+ else options[name] = value;
89
+ }
90
+
91
+ return { positionals, options };
92
+ }
93
+
94
+ // "resize:width=800,height=600" -> {type: "resize", width: 800, height: 600}
95
+ export function parseOperation(text) {
96
+ const [type, rest] = text.split(/:(.*)/s, 2);
97
+ if (!/^[a-z_]+$/.test(type)) throw new UsageError(`"${text}" is not an operation: use type:param=value,...`);
98
+
99
+ const operation = { type };
100
+ for (const pair of (rest || '').split(',').filter(Boolean)) {
101
+ const [key, value] = pair.split(/=(.*)/s, 2);
102
+ if (value === undefined) throw new UsageError(`"${pair}" in --op ${type} needs a value: param=value.`);
103
+ operation[key.trim()] = typedValue(value.trim());
104
+ }
105
+ return operation;
106
+ }
107
+
108
+ function typedValue(value) {
109
+ if (value === 'true') return true;
110
+ if (value === 'false') return false;
111
+ if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
112
+ return value;
113
+ }
114
+
115
+ class UsageError extends Error {}
116
+
117
+ export async function main(argv, { env = {}, stdout, stderr, fetch, sleep } = {}) {
118
+ const print = (text) => stdout.write(text + '\n');
119
+ const fail = (text) => stderr.write(text + '\n');
120
+
121
+ let parsed;
122
+ try {
123
+ parsed = parseArgs(argv);
124
+ } catch (error) {
125
+ fail(error.message);
126
+ return 2;
127
+ }
128
+ const { positionals, options } = parsed;
129
+
130
+ if (options.version) {
131
+ print(`watermark ${VERSION}`);
132
+ return 0;
133
+ }
134
+
135
+ if (options.help || positionals.length === 0) {
136
+ print(HELP);
137
+ return positionals.length === 0 && !options.help ? 2 : 0;
138
+ }
139
+
140
+ const json = Boolean(options.json);
141
+ const output = (value, human) => print(json ? JSON.stringify(value, null, 2) : human(value));
142
+
143
+ try {
144
+ const api = new Api({
145
+ apiKey: options['api-key'] || env.WATERMARK_API_KEY,
146
+ baseUrl: options['base-url'] || env.WATERMARK_API_URL || 'https://api.watermark.ws',
147
+ fetch,
148
+ sleep,
149
+ });
150
+
151
+ const [command, ...rest] = positionals;
152
+
153
+ if (MEDIA[command]) {
154
+ return await runMedia(api, MEDIA[command], rest, options, { output, print, json, fetch: fetch || globalThis.fetch });
155
+ }
156
+
157
+ switch (command) {
158
+ case 'request': {
159
+ const [action, id] = rest;
160
+ if (!['get', 'abort'].includes(action) || !/^\d+$/.test(id || '')) throw new UsageError('Usage: watermark request get|abort <id>');
161
+ const request = await api.request(action === 'get' ? 'GET' : 'POST', `/v1/requests/${id}${action === 'abort' ? '/abort' : ''}`);
162
+ output(request, describeRequest);
163
+ return request.status === 'failed' ? 1 : 0;
164
+ }
165
+ case 'requests': {
166
+ const list = await api.request('GET', '/v1/requests', { query: { limit: options.limit, before: options.before } });
167
+ output(list, (value) => value.data.map((request) => `${request.id}\t${request.endpoint}\t${request.status}\t${request.created_at}`).join('\n') || 'No requests yet.');
168
+ return 0;
169
+ }
170
+ case 'account': {
171
+ const account = await api.request('GET', '/v1/account');
172
+ output(account, (value) => [
173
+ `${value.name} (${value.tier}${value.livemode === false ? ', test key' : ''})`,
174
+ `Balance: $${value.balance.amount}`,
175
+ value.limits ? `Limits: ${value.limits.requests_per_minute} requests a minute, sources up to ${Math.round(value.limits.max_source_bytes / 1048576)} MB` : '',
176
+ ].filter(Boolean).join('\n'));
177
+ return 0;
178
+ }
179
+ case 'usage': {
180
+ const usage = await api.request('GET', '/v1/usage', { query: { from: options.from, to: options.to, group_by: options['group-by'], customer: options.customer } });
181
+ output(usage, (value) => [`${value.group_by}\trequests\tfailed\tspent`, ...value.data.map((row) => `${row[value.group_by]}\t${row.requests}\t${row.failed}\t$${row.spent}`)].join('\n'));
182
+ return 0;
183
+ }
184
+ case 'operations': {
185
+ const catalog = await api.request('GET', '/v1/operations');
186
+ const media = rest[0] ? { image: 'image', images: 'image', video: 'video', videos: 'video', document: 'document', documents: 'document' }[rest[0]] : null;
187
+ const selected = media ? { [media]: catalog.data[media] } : catalog.data;
188
+ output(selected, (value) => Object.entries(value).map(([name, entry]) => [
189
+ `${name} (formats: ${entry.formats.join(', ')})`,
190
+ ...Object.entries(entry.operations).map(([type, operation]) => ` ${type}: ${operation.description}\n${Object.entries(operation.params).map(([param, info]) => ` ${param}${info.required ? ' (required)' : ''}: ${info.description}`).join('\n')}`),
191
+ ].join('\n')).join('\n\n'));
192
+ return 0;
193
+ }
194
+ case 'upload': {
195
+ const [action, id] = rest;
196
+ if (['get', 'delete'].includes(action)) {
197
+ if (!/^upl_[A-Za-z0-9]{22}$/.test(id || '')) throw new UsageError('Usage: watermark upload get|delete <upl_id>');
198
+ output(await api.request(action === 'get' ? 'GET' : 'DELETE', `/v1/uploads/${id}`), describeUpload);
199
+ return 0;
200
+ }
201
+ if (!action) throw new UsageError('Usage: watermark upload <file>');
202
+ output(await api.upload(action, { readFile }), describeUpload);
203
+ return 0;
204
+ }
205
+ case 'file': {
206
+ const [action, id] = rest;
207
+ if (!['get', 'delete'].includes(action) || !/^file_[A-Za-z0-9]{22}$/.test(id || '')) throw new UsageError('Usage: watermark file get|delete <file_id>');
208
+ output(await api.request(action === 'get' ? 'GET' : 'DELETE', `/v1/files/${id}`), describeFile);
209
+ return 0;
210
+ }
211
+ case 'storage': {
212
+ const [action, id] = rest;
213
+ if (action === 'list' || !action) {
214
+ output(await api.request('GET', '/v1/storage'), (value) => value.data.map(describeStorage).join('\n\n') || 'No storage connections.');
215
+ return 0;
216
+ }
217
+ if (action === 'connect') {
218
+ if (!options.bucket || !options.region || !options['role-arn'] || !options.prefix.length) throw new UsageError('Usage: watermark storage connect --bucket <name> --region <region> --role-arn <arn> --prefix in/ [--prefix ...] [--read true|false] [--write true|false]');
219
+ const body = { provider: 's3', bucket: options.bucket, region: options.region, role_arn: options['role-arn'], prefixes: options.prefix, ...storageSwitches(options) };
220
+ output(await api.request('POST', '/v1/storage', { body }), describeStorage);
221
+ return 0;
222
+ }
223
+ if (action === 'update') {
224
+ const usage = 'Usage: watermark storage update <stg_id> [--prefix in/ ...] [--read true|false] [--write true|false]';
225
+ if (!/^stg_[0-9a-z]{1,12}$/.test(id || '')) throw new UsageError(usage);
226
+ const body = storageSwitches(options);
227
+ if (options.prefix.length) body.prefixes = options.prefix;
228
+ if (!Object.keys(body).length) throw new UsageError(usage);
229
+ output(await api.request('PATCH', `/v1/storage/${id}`, { body }), describeStorage);
230
+ return 0;
231
+ }
232
+ if (!['get', 'verify', 'disconnect'].includes(action) || !/^stg_[0-9a-z]{1,12}$/.test(id || '')) throw new UsageError('Usage: watermark storage list | get|verify|disconnect <stg_id> | connect ... | update ...');
233
+ const method = { get: 'GET', verify: 'POST', disconnect: 'DELETE' }[action];
234
+ const storage = await api.request(method, `/v1/storage/${id}${action === 'verify' ? '/verify' : ''}`);
235
+ output(storage, describeStorage);
236
+ return action === 'verify' && storage.status !== 'verified' ? 1 : 0;
237
+ }
238
+ case 'events': {
239
+ if (!options.since) throw new UsageError('Usage: watermark events --since 2026-10-13T15:00:00Z [--limit 100]');
240
+ const events = await api.request('GET', '/v1/events', { query: { since: options.since, limit: options.limit } });
241
+ output(events, (value) => [
242
+ ...value.data.map((event) => `${new Date(event.created * 1000).toISOString()}\t${event.type}\trequest ${event.data?.id}`),
243
+ value.has_more ? `More: watermark events --since ${new Date(value.data.at(-1).created * 1000).toISOString()}` : '',
244
+ ].filter(Boolean).join('\n') || 'No events since then.');
245
+ return 0;
246
+ }
247
+ case 'fonts': {
248
+ const fonts = await api.request('GET', '/v1/fonts');
249
+ output(fonts, (value) => value.data.map((font) => `${font.name}\t${[font.family, font.style].filter(Boolean).join(' ')}`).join('\n'));
250
+ return 0;
251
+ }
252
+ default:
253
+ throw new UsageError(`Unknown command "${command}". Run watermark --help.`);
254
+ }
255
+ } catch (error) {
256
+ if (error instanceof UsageError) {
257
+ fail(error.message);
258
+ return 2;
259
+ }
260
+ if (error instanceof ApiError) {
261
+ if (json) print(JSON.stringify({ error: { ...error.error, status: error.status, type: error.type, code: error.code, message: error.message } }, null, 2));
262
+ else fail(describeError(error));
263
+ return 1;
264
+ }
265
+ fail(error.message);
266
+ return 1;
267
+ }
268
+ }
269
+
270
+ async function runMedia(api, media, [action, source], options, { output, print, json, fetch }) {
271
+ if (!ACTIONS[media].includes(action)) throw new UsageError(`Usage: watermark ${media.replace(/s$/, '')} ${ACTIONS[media].join('|')} <source>`);
272
+
273
+ const body = {};
274
+
275
+ if (action === 'create') {
276
+ if (!options.width || !options.height) throw new UsageError('watermark image create needs --width and --height.');
277
+ body.width = Number(options.width);
278
+ body.height = Number(options.height);
279
+ if (options.background) body.background = options.background;
280
+ } else {
281
+ body.source = await resolveSource(api, source);
282
+ }
283
+
284
+ if (options.level) {
285
+ if (media !== 'images' || action !== 'read') throw new UsageError('--level is for watermark image read.');
286
+ body.level = true;
287
+ }
288
+
289
+ if (action === 'process' || action === 'create') {
290
+ const operations = options['ops-json'] ? JSON.parse(options['ops-json']) : options.op.map(parseOperation);
291
+ if (operations.length === 0) throw new UsageError('Give at least one --op (see: watermark operations).');
292
+ body.operations = operations;
293
+ }
294
+ if (options['outputs-json']) body.outputs = JSON.parse(options['outputs-json']);
295
+ if (options.format) body.output = { format: options.format };
296
+ else if (action === 'create' && options.out) body.output = { format: options.out.split('.').pop().toLowerCase() };
297
+ if (options.seconds !== undefined) body.seconds = Number(options.seconds);
298
+ if (action === 'screenshot' && body.seconds === undefined) throw new UsageError('A screenshot needs --seconds.');
299
+ if (options.page !== undefined) body.page = Number(options.page);
300
+ if (options['max-duration'] !== undefined) body.max_duration = Number(options['max-duration']);
301
+ if (options['max-pages'] !== undefined) body.max_pages = Number(options['max-pages']);
302
+ if (options.webhook) body.webhook_url = options.webhook;
303
+ if (options.event.length) body.events = options.event;
304
+ if (options.customer) body.customer = options.customer;
305
+ if (options.metadata.length) {
306
+ body.metadata = Object.fromEntries(options.metadata.map((pair) => {
307
+ const [key, value] = pair.split(/=(.*)/s, 2);
308
+ if (value === undefined) throw new UsageError(`--metadata needs key=value, got "${pair}".`);
309
+ return [key, value];
310
+ }));
311
+ }
312
+
313
+ let request = await api.request('POST', `/v1/${media}/${action}`, { body, idempotencyKey: options['idempotency-key'] });
314
+
315
+ if (!options['no-wait']) {
316
+ request = await api.wait(request.id, {
317
+ timeoutMs: Number(options.timeout || 300) * 1000,
318
+ onProgress: json ? undefined : (current) => process.stderr.write(`Request ${current.id}: ${current.status}${current.progress !== undefined ? ` ${current.progress}%` : ''}\r`),
319
+ });
320
+ }
321
+
322
+ if (options.out && request.status === 'succeeded') {
323
+ const url = request.result?.files?.[0]?.url;
324
+ if (!url) throw new Error(`Request ${request.id} made no file to download.`);
325
+ const response = await fetch(url);
326
+ if (!response.ok) throw new Error(`Downloading the result failed: HTTP ${response.status}.`);
327
+ await writeFile(options.out, Buffer.from(await response.arrayBuffer()));
328
+ request = { ...request, saved_to: options.out };
329
+ }
330
+
331
+ output(request, describeRequest);
332
+
333
+ return request.status === 'failed' || request.status === 'aborted' ? 1 : 0;
334
+ }
335
+
336
+ // A source as the API takes it: a URL, an id, a storage object, or a local
337
+ // file, which is uploaded first.
338
+ async function resolveSource(api, source) {
339
+ if (!source) throw new UsageError('Give a source: a URL, a local file, upl_…, file_… or stg_…:key.');
340
+ if (/^https?:\/\//.test(source) || /^(upl|file)_[A-Za-z0-9]{22}$/.test(source)) return source;
341
+
342
+ const object = source.match(/^(stg_[0-9a-z]{1,12}):(.+)$/s);
343
+ if (object) return { storage: object[1], key: object[2] };
344
+
345
+ const local = await stat(source).catch(() => null);
346
+ if (!local?.isFile()) throw new UsageError(`"${source}" is not a URL, an id, stg_…:key, or a file here.`);
347
+
348
+ const upload = await api.upload(source, { readFile });
349
+ return upload.id;
350
+ }
351
+
352
+ // --read and --write as the API takes them, for connecting or changing storage.
353
+ function storageSwitches(options) {
354
+ const switches = {};
355
+ for (const field of ['read', 'write']) {
356
+ if (options[field] === undefined) continue;
357
+ if (!['true', 'false'].includes(options[field])) throw new UsageError(`--${field} takes true or false.`);
358
+ switches[field] = options[field] === 'true';
359
+ }
360
+ return switches;
361
+ }
362
+
363
+ // An API error for people: the code to branch on, every invalid field, and
364
+ // the permissions policy a storage change whose check failed needs.
365
+ function describeError(error) {
366
+ const lines = [`Error ${error.status} ${error.code}: ${error.message}${error.param ? ` (${error.param})` : ''}`];
367
+ for (const [field, messages] of Object.entries(error.errors || {})) {
368
+ lines.push(` ${field}: ${[].concat(messages).join(' ')}`);
369
+ }
370
+ if (error.error.permissions_policy) {
371
+ lines.push(`The role needs this permissions policy:\n${JSON.stringify(error.error.permissions_policy, null, 2)}`);
372
+ }
373
+ return lines.join('\n');
374
+ }
375
+
376
+ function describeUpload(upload) {
377
+ return `Upload ${upload.id} (${upload.filename}): ${upload.status}${upload.expires_at ? `, until ${upload.expires_at}` : ''}`;
378
+ }
379
+
380
+ function describeFile(file) {
381
+ const lines = [`File ${file.id} (${file.name}, ${file.format}, ${file.size} bytes)`];
382
+ if (file.url) lines.push(`Download: ${file.url} (until ${file.url_expires_at})`);
383
+ if (file.destination) lines.push(`Written to: ${file.destination.storage ? `${file.destination.storage}:${file.destination.key}` : 'your URL'}`);
384
+ if (file.deleted_at && !file.url) lines.push(`Deleted ${file.deleted_at}`);
385
+ return lines.join('\n');
386
+ }
387
+
388
+ function describeStorage(storage) {
389
+ const lines = [`${storage.id}: s3://${storage.bucket} (${storage.region}), ${storage.status}`];
390
+ if (storage.prefixes?.length) lines.push(`Prefixes: ${storage.prefixes.join(', ')}`);
391
+ if (storage.status !== 'verified' && storage.trust_policy) lines.push(`Create the role with the trust_policy and permissions_policy shown with --json (external id ${storage.external_id}), then run: watermark storage verify ${storage.id}`);
392
+ if (storage.last_error) lines.push(`Problem: ${storage.last_error}`);
393
+ return lines.join('\n');
394
+ }
395
+
396
+ function describeRequest(request) {
397
+ const lines = [`Request ${request.id} (${request.endpoint}): ${request.status}`];
398
+ if (request.status === 'failed' && request.error) lines.push(`Error ${request.error.code}: ${request.error.message}`);
399
+ if (request.result?.data) lines.push(`Result: ${JSON.stringify(request.result.data)}`);
400
+ for (const file of request.result?.files || []) {
401
+ if (file.destination) lines.push(`File: written to ${file.destination.storage ? `${file.destination.storage}:${file.destination.key}` : 'your URL'}`);
402
+ else lines.push(file.url ? `File: ${file.url} (link valid until ${file.url_expires_at})` : 'File: made, but no download link is available right now; fetch the request again.');
403
+ }
404
+ if (request.saved_to) lines.push(`Saved to ${request.saved_to}`);
405
+ if (request.price) lines.push(`Price: $${request.price.amount}`);
406
+ return lines.join('\n');
407
+ }