diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..49f6a64 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "name": "remove-ai-watermarks", + "owner": { + "name": "wiltodelta", + "url": "https://github.com/wiltodelta" + }, + "metadata": { + "description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "remove-ai-watermarks", + "description": "Identify and remove AI provenance marks from images and video the user generated or edited. Use when the user asks to strip Gemini sparkles, SynthID, Microsoft InvisMark, Meta Content Seal, C2PA, EXIF/XMP AI metadata, Sora/Veo/Seedance/Doubao/Dola/Hailuo/Kling labels, or other AI watermarks, or asks whether a file was AI-generated. Do not use for stock-agency, marketplace, classifieds, or other third-party paid-asset watermarks.", + "source": "./skills", + "strict": false + } + ] +} \ No newline at end of file diff --git a/.claude/rules/development.md b/.claude/rules/development.md index b78110a..30c49d8 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -1,6 +1,6 @@ --- -globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"] -description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, and the detection-path measurement rule. +globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "skills/**", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"] +description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, the agent-skill parity seam, and the detection-path measurement rule. --- # Development invariants @@ -9,11 +9,13 @@ description: Command contracts, project gate, typing boundaries, model-adjacent Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard. -Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. +Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it. Keep [`../../skills/remove-ai-watermarks/`](../../skills/remove-ai-watermarks/) in the same change when command routing, extras, exit codes, mark keys, or the intended-use boundary move. + +`tests/test_agent_skill.py` enforces that by comparing the skill's prose against the live Click tree. Guard the SET, not the instances: per-flag comparisons only check what the skill already documents, so `--vendor`, `--pipeline auto` and then the whole `classify` and `verify-openai-synthid` commands each shipped with a green suite. `test_every_cli_command_is_named_somewhere_in_the_skill` closes that; raise `MIN_CLI_VERSION` in the skill's probe whenever a reference names a command, flag or value the previous release lacked, or the probe tells an agent its CLI is current right before it types something that build rejects. Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it. -`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. It is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds. +`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. On the image path it is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds. The video SynthID commands (`video invisible`, `video all`, `video batch`) do expose `--device`, whose VAE runs on cuda, mps, or cpu, so a user can usefully pick one. `test_device_exists_exactly_where_the_skill_says_it_does` pins that split; do not read this paragraph as licence to delete the video flag. The same rule applies to install hints: name the extra that actually makes the command work (`qwen-zimage`, not `diffusion`), and keep the printed command shell-quoted -- bare `pkg[extra]` is a glob in zsh. diff --git a/CLAUDE.md b/CLAUDE.md index 1460bc8..b62f72e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,8 @@ Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/ Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist. +The published Agent Skill is [`skills/remove-ai-watermarks/`](skills/remove-ai-watermarks/). Update it in the same change as any CLI edit that alters commands, extras, exit codes, mark keys, or the intended-use boundary. Install and publish steps: [`docs/agent-skill.md`](docs/agent-skill.md). + ## Module architecture [`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem. @@ -53,4 +55,4 @@ Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when match | File | Covers | |---|---| -| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, the docs-coverage seam, and the detection-path measurement rule | +| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, the docs-coverage and agent-skill parity seams, and the detection-path measurement rule | diff --git a/README.md b/README.md index 69f199e..cf27d1f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ removal. [![License](https://img.shields.io/pypi/l/remove-ai-watermarks?color=blue)](LICENSE) [![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml) [![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta) +[![skills.sh](https://skills.sh/b/wiltodelta/remove-ai-watermarks)](https://skills.sh/wiltodelta/remove-ai-watermarks) > This project is for lawful use on content you own. It does not target stock > agency previews or other watermarks that protect third party paid content. @@ -439,6 +440,24 @@ See the [Python API guide](docs/python-api.md) for visible removal, the full `remove_all` and `remove_batch` pipeline, provenance inspection, metadata stripping, and diffusion usage. +## Agent skill + +Coding agents can install the published skill and drive this CLI: + +```bash +npx skills add wiltodelta/remove-ai-watermarks +``` + +Claude Code can add this repository as a plugin marketplace: + +```text +/plugin marketplace add wiltodelta/remove-ai-watermarks +/plugin install remove-ai-watermarks@remove-ai-watermarks +``` + +The skill refuses stock-agency and other third-party paid-asset marks. See +[the agent skill guide](docs/agent-skill.md). + ## ComfyUI The separate @@ -514,6 +533,7 @@ Start with the [documentation index](docs/index.md). - [Scope, safety, and legal notes](docs/legal-and-safety.md) - [Module internals](docs/module-internals.md) - [Release and distribution](docs/release-and-distribution.md) +- [Agent skill](docs/agent-skill.md) Research notes and historical experiments are listed separately in the [documentation index](docs/index.md). They explain past decisions but do not diff --git a/docs/agent-skill.md b/docs/agent-skill.md new file mode 100644 index 0000000..62c862a --- /dev/null +++ b/docs/agent-skill.md @@ -0,0 +1,170 @@ +# Agent skill + +The published Agent Skill lives in +[`skills/remove-ai-watermarks/`](../skills/remove-ai-watermarks/). It teaches +coding agents to drive the `remove-ai-watermarks` CLI on content the user +generated or edited. It is not a fifth release surface: it ships with this +repository and updates when the skill files change. + +## Install + +Any agent that speaks the [Agent Skills](https://agentskills.io/specification) +format: + +```bash +npx skills add wiltodelta/remove-ai-watermarks +``` + +Claude Code, from this repository as a marketplace: + +```text +/plugin marketplace add wiltodelta/remove-ai-watermarks +/plugin install remove-ai-watermarks@remove-ai-watermarks +``` + +SkillsMP indexes public GitHub `SKILL.md` files and does not need a separate +upload. ClawHub and the Claude community marketplace are explicit publishes; +see [Release and distribution](release-and-distribution.md#agent-skill). + +## Where the skill can actually be listed + +Most skill catalogs are install registries, not discovery engines. A listing +without search traffic does not bring people to the CLI or to +[raiw.cc](https://raiw.cc). Prefer channels that a stranger can find, and keep +the intended-use boundary in every public description. Official review may +still reject a watermark-removal listing; that is a risk, not a reason to +widen the scope. + +Do these in order after the skill is on the default branch. + +1. **GitHub default branch.** Unlocks `npx skills add wiltodelta/remove-ai-watermarks`. + Cursor, OpenCode, Pi, Codex, and Copilot have no separate skill store; they + consume this repo or a copied `SKILL.md`. +2. **SkillsMP.** Aggregates public GitHub `SKILL.md` files. No submit form. A + search for `remove-ai-watermarks` was empty before the first public push. + Neighboring `watermark` hits are PDF-add and detection skills, not removal. +3. **skills.sh.** No submit form. A row appears on the leaderboard only after + people run `npx skills add`. The README install line is the seed, not a + registration. +4. **Awesome-list PRs.** Hand-curated GitHub lists are how people browse skills + today. One accurate PR per list, with the scope sentence intact. Candidates: + `ComposioHQ/awesome-claude-skills`, `VoltAgent/awesome-agent-skills`, + `VoltAgent/awesome-openclaw-skills`. +5. **Claude community marketplace.** Form at + [platform.claude.com/plugins/submit](https://platform.claude.com/plugins/submit) + (individuals) or the claude.ai directory form (Team/Enterprise). Validate + with `claude plugin validate` first. The official Anthropic marketplace is + curated and has no application. +6. **ClawHub.** `clawhub login` then `clawhub skill publish` from + `skills/remove-ai-watermarks/`. The public catalog already has an add-watermark + skill; this would be the removal counterpart. Frame it as the user's own + content. ClawHub disallows deception, impersonation, and fake-engagement + install loops. +7. **OpenAI ChatGPT / Codex plugin directory.** Skills-only plugins are a + supported submission type at + [platform.openai.com/plugins](https://platform.openai.com/plugins). This is + the largest consumer surface that actually reviews listings. It needs a + verified Platform identity, public website / support / privacy / terms URLs, + starter prompts, and five positive plus three negative test cases. Claude + marketplace approval does not transfer. Convert with + [Submit your Claude Code plugin to OpenAI](https://developers.openai.com/plugins/guides/submit-claude-plugin). + +Do not treat Smithery or other MCP directories as skill catalogs. Do not open +a second near-identical skill to farm installs. The conversion path that +matters is: agent cannot run CUDA locally, so it sends the user to raiw.cc. + +## When to edit the skill + +Update `SKILL.md` and `references/` in the same change as any CLI edit that +alters: + +- command names or routing; +- extras a command needs; +- no-signal or exit-code behavior; +- registered visible mark keys; +- the CUDA-only invisible-image rule; +- the intended-use boundary. + +Keep `SKILL.md` under 500 lines. Put flag lists in `references/`. Do not copy +calibration numbers or retired pipeline names into the skill. + +Write for every harness and a weaker model, not only Claude Opus: + +- third-person description, keywords first, so a truncated listing still matches; +- a numbered checklist in `SKILL.md`; +- `scripts/probe.py` for CUDA, ffmpeg, CLI, and installer detection. It measures + CAPABILITY, not presence: it writes a blank PNG with `zlib` and `struct` and + runs the installed `visible` on it, because exit `2` is a state only a working + pixel stack reaches. Reporting a found binary as ready is what sent an agent + into a missing-cv2 crash on the Homebrew build, which carries no extras. It + also compares the installed release against `MIN_CLI_VERSION`, so a stale CLI + is named instead of being reported as a broken flag; +- uv, pipx, and pip as installer fallbacks; +- forward slashes only; +- no `allowed-tools` (that field is experimental and host-specific). + +Raise `MIN_CLI_VERSION` in `scripts/probe.py` in the same change as any reference +that names a command, flag or value the previous release lacked. Left behind, it tells an agent its +CLI is current right before it types an option that build rejects. + +Bump `metadata.version` in `SKILL.md` plus `version` in +`skills/.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` when +the skill instructions change. That version is independent of the PyPI package +version. + +## Behavior evals + +The deterministic suite proves the skill's prose matches the CLI. It cannot prove +the only thing a skill exists for: that a model reaches for it on a real request +and then obeys it. `scripts/skill_eval.py` measures that. + +Each case builds a throwaway project with the skill installed and a recording +stand-in for the CLI first on `PATH`, runs a headless agent on one user prompt, +and grades the recorded command trace. Grading is mechanical wherever it can be: +"did it run `all` on a machine the probe said writes nothing" is a fact in the +trace, and "did it name an output file that is not on disk" is how a fabricated +result is caught without asking a model to judge itself. + +```bash +python3 scripts/skill_eval.py --model haiku --repeat 3 +python3 scripts/skill_eval.py --case visible_ru --model sonnet --out /tmp/eval.json +``` + +Cases live in `data/evaluations/skill/cases.json`; each carries the prompt, the +scenario the stand-in replays, and the expectations. Run outputs stay outside the +repository, per `data/README.md`. This never runs in `maintain.sh`: it spends model +tokens and needs the `claude` CLI. + +Agents are nondeterministic, so the report is a pass RATE per check over `--repeat` +runs, never one verdict. Weak models are the point: run haiku before opus. + +Four things the first runs found, all on haiku and none visible to the +deterministic suite: + +- a skill that never triggered on "скажи, видно ли, что она сгенерирована ИИ", + because the description sold removal and not the question; +- a finished-looking removal report, with a size table for a file nobody wrote, + on a machine whose pixel stack was missing; +- `--mark gemini-sparkle`, a key that does not exist, guessed instead of read; +- a removal command run to answer a question that only needed `identify`. + +`tests/test_skill_evals.py` keeps the harness honest: the stand-in's exit codes are +compared against the real CLI where the state is reproducible locally, the grader is +driven over hand-built traces, and every case is checked for internal consistency. A +scenario where `identify` names a visible mark that `visible` then cannot find sends +any agent into a retry loop and measures the case, not the skill. + +## Validate + +```bash +claude plugin validate . +claude plugin validate --strict . +claude plugin validate ./skills +``` + +The suite also checks frontmatter and manifest agreement in +`tests/test_agent_skill.py`, plus two things a catalog cannot see: that the +skill's prose still matches the live click application (mark keys, flag choices, +batch modes, exit codes, extras) and that a machine without the pixel stack gets +an install hint from the CLI, a caveat from `identify`, and `pixel_stack: +missing` from the probe. diff --git a/docs/development.md b/docs/development.md index 378160f..00159ac 100644 --- a/docs/development.md +++ b/docs/development.md @@ -14,6 +14,10 @@ Read this reference for environment setup, dependency recovery, CI behavior, and - On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend. - Run `uv` from the repository root or it may create a bare environment without the project dependencies. +`maintain.sh` starts with `uv sync --all-extras`, so a lean environment is replaced whenever the gate runs. Syncing back down can leave `opencv-python-headless` half-removed: `import cv2` then succeeds against an empty namespace while `cv2.INTER_LINEAR` is gone, which surfaces as a suite that stalls or reports hundreds of unrelated failures. Repair it with `uv sync --frozen --extra dev --reinstall-package opencv-python-headless` rather than re-reading the diff. + +Pyright is only meaningful with the extras its imports need. Scoped to `src/`, a lean environment reports `TrustMark`, `transformers` and `huggingface_hub` as unresolved, which reads as a type error and is not one; add `--extra trustmark --extra classify` before believing it. + The optional TrustMark decoder downloads weights into its installed package directory. After pruning that extra, a leftover weights directory can make availability checks see an empty namespace package. If Pyright reports an unknown `TrustMark` import and `find_spec("trustmark")` returns a loader-less spec, remove that regenerable remnant from the active virtual environment and resync. ### Known security-gate blocks diff --git a/docs/index.md b/docs/index.md index d6d89ca..b9280c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ to run the tool. Use the maintainer references only when changing the code. | [Code provenance](code-provenance.md) | Required notices for licensed derivative work. | | [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. | | [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, photo-classify Hub model, ComfyUI Registry, and the release workflow. | +| [Agent skill](agent-skill.md) | Install and maintain the published Agent Skill and Claude plugin. | | [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. | | [SynthID technical reference](synthid.md) | Mechanism, provenance, robustness, regeneration. | diff --git a/docs/installation.md b/docs/installation.md index dbfb587..a8df707 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -31,6 +31,14 @@ You can also install the Homebrew package on macOS or Linux: brew install wiltodelta/tap/remove-ai-watermarks ``` +A formula cannot carry extras, so this installs the default package: metadata +commands work, and `visible`, `erase`, `all`, `batch` and every `video` command +stop with an install hint naming the extra that fixes them. `identify` still +runs, but reports metadata only and says so in its caveats. `metadata --remove` +also works on a file whose markers survive the first strip: the raster +normalization that recovery needs is skipped rather than crashing, and the +survivors are reported. Reinstall with uv or pipx to add the extra. + ## Visible watermark removal Visible mark detection, OpenCV inpainting, and manual region erasing need the diff --git a/docs/module-internals.md b/docs/module-internals.md index 99baec4..fb4a5ac 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -52,6 +52,19 @@ Important contracts: `video metadata`) end by repeating the `identify` limit: the pixel channel is untouched and a watermark such as SynthID has no local decoder once its metadata proxy is gone, so neither outcome is a clean verdict. +- `visible`, `erase`, `all` and `batch` report a missing pixel stack as an + install hint naming `remove-ai-watermarks[visible]`, not as a traceback. The + default package ships without those dependencies and the Homebrew formula + installs exactly that build, so a first run following the project's own + instructions used to die on `ModuleNotFoundError: No module named 'cv2'`. + `_pixels_required` wraps the four image commands (the video ones reach the same + build through `video._require_video_runtime`, which names the `video` extra -- + the one that actually makes them work); `remove_batch` re-raises the same + ImportError instead of counting it once per file, because one absent package + is not N broken images. All three sites ask `optional_deps.pixels_available()` + rather than matching the exception's module name: an ImportError raised inside + `cv2/__init__.py` carries `cv2.cv2` and a numpy ABI mismatch carries `None`, so + a name match let exactly the tracebacks this guard exists for through. - Hard processing and write failures exit with code `1`. - `all` can still write the completed visible and metadata stages when the diffusion dependencies are unavailable, but exits with code `1` so the @@ -561,6 +574,15 @@ metadata extraction from verdict logic: reported no SynthID for images the file path flagged. - `identify` preserves the path-based API and adds the optional registered visible-mark and open invisible-watermark detectors after extraction. +- When the pixel stack is absent, the visible arm still no-ops (that is the + historical `get_or_none` contract), but the report now carries a caveat saying + the detectors did NOT run. Without it, an install lacking the `visible` extra + dropped `Visible Gemini sparkle` from a marked image and read exactly like a + clean scan -- a silent false negative on the one install path Homebrew + produces. The caveat names WHICH silence happened: an ImportError on a build + `pixels_available()` calls incomplete is the absent extra, and anything else is + a file that could not be decoded. The two need different fixes, so naming the + wrong one sends the user to reinstall a working install. ### SynthID periodic carrier detector (research only) diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 922c7d0..f982a6e 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -122,6 +122,33 @@ dispatch is interrupted. The `COMFYUI_RELEASE_TOKEN` repository secret is a fine-grained token limited to the ComfyUI node repository, with Actions read and write access. +## Agent skill + +The Agent Skill and Claude plugin live in this repository: + +- [`skills/remove-ai-watermarks/`](../skills/remove-ai-watermarks/) is the + portable Agent Skills package; +- [`skills/.claude-plugin/plugin.json`](../skills/.claude-plugin/plugin.json) + is the Claude plugin manifest, with source `./skills` so an install copies + only the skill tree; +- [`.claude-plugin/marketplace.json`](../.claude-plugin/marketplace.json) + makes the repository a Claude Code marketplace. + +They are not a fifth required release surface. A CLI change that affects +command routing, extras, exit codes, mark keys, or the intended-use boundary +must update the skill in the same commit. User-facing install steps live in +[`docs/agent-skill.md`](agent-skill.md). + +After the skill files reach the default branch: + +- `npx skills add wiltodelta/remove-ai-watermarks` installs from GitHub; +- SkillsMP can index the public `SKILL.md`; +- Claude Code users can run `/plugin marketplace add wiltodelta/remove-ai-watermarks`. + +ClawHub and the Claude community marketplace need a separate authenticated +publish. Do not treat those listings as complete until the live catalog shows +the skill. + ## Release verification Forensic transports are versioned independently from the package. Before publishing @@ -174,3 +201,6 @@ from the PyPI JSON API (which updates first) but installs the test dependency with pip against the simple index, whose CDN edges lag by minutes. Rerun the failed `distribute.yml` comfyui job once the simple index lists the release; nothing about the release itself is wrong. + +If the release changed CLI routing, extras, exit codes, mark keys, or the +intended-use boundary, confirm `skills/remove-ai-watermarks/` still matches. diff --git a/skills/.claude-plugin/plugin.json b/skills/.claude-plugin/plugin.json new file mode 100644 index 0000000..fb6a78b --- /dev/null +++ b/skills/.claude-plugin/plugin.json @@ -0,0 +1,23 @@ +{ + "name": "remove-ai-watermarks", + "displayName": "Remove AI Watermarks", + "version": "1.0.0", + "description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.", + "author": { + "name": "wiltodelta", + "url": "https://github.com/wiltodelta" + }, + "homepage": "https://raiw.cc", + "repository": "https://github.com/wiltodelta/remove-ai-watermarks", + "license": "Apache-2.0", + "skills": "./", + "keywords": [ + "ai-watermark", + "watermark-removal", + "c2pa", + "synthid", + "provenance", + "gemini", + "sora" + ] +} diff --git a/skills/remove-ai-watermarks/SKILL.md b/skills/remove-ai-watermarks/SKILL.md new file mode 100644 index 0000000..9c990aa --- /dev/null +++ b/skills/remove-ai-watermarks/SKILL.md @@ -0,0 +1,205 @@ +--- +name: remove-ai-watermarks +description: "Removes and identifies AI watermarks and provenance marks in images and video the user generated or edited. Covers Gemini sparkles, SynthID, Microsoft InvisMark, Meta Content Seal, C2PA, EXIF/XMP/IPTC metadata, and Sora/Veo/Seedance/Doubao/Dola/Hailuo/Kling labels. Use when the user wants to strip or clean AI watermarks and generation labels, or asks whether a file was AI-generated or carries AI provenance, even if they do not name remove-ai-watermarks. Do not use for stock-agency, marketplace, classifieds, or other third-party paid-asset watermarks." +license: Apache-2.0 +compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip. +metadata: + author: wiltodelta + version: "1.0.0" + homepage: https://raiw.cc + repository: https://github.com/wiltodelta/remove-ai-watermarks +--- + +# Remove AI watermarks + +Drive the `remove-ai-watermarks` CLI. Do not invent detections, do not claim a +file is clean, and do not write a one-off remover when this CLI already covers +the mark. + +This skill must work in any coding harness and with weaker models. Follow the +checklist. Trust `scripts/probe.py` over guesses about GPU, Python, or +installers. + +## Scope + +Use this skill only on content the user generated or edited and is allowed to +change. + +Refuse automatic removal of stock-agency, marketplace, classifieds, +tiled-preview, or other marks that protect a third party's paid or copyrighted +asset. `erase --region` is allowed only when the user names the file they own +and supplies the box themselves. + +Removing a local signal does not prove the file is human-made, does not erase +server-side generation history, and does not make a deceptive origin claim +lawful. If the user wants to present generated work as human-made, stop. + +## Workflow + +Copy this checklist and check items off: + +``` +- [ ] 1. Scope is allowed +- [ ] 2. Probe the machine +- [ ] 3. Install if the CLI is missing or its pixel stack is +- [ ] 4. Identify first unless the user already named a removal +- [ ] 5. Run one command with -o +- [ ] 6. Report from the exit code and output path +``` + +### 1. Scope + +If the mark protects someone else's paid asset, stop. + +### 2. Probe + +The directory that contains this `SKILL.md` is the skill root. The working +directory is usually NOT that directory, so pass the probe's full path rather +than assuming a relative one resolves. Use forward slashes. Try `python3` +first, then `python`: + +```bash +python3 /scripts/probe.py +python /scripts/probe.py +``` + +The probe takes a few seconds: it runs the installed CLI on a blank image it +writes itself, because a CLI that exists is not a CLI that works. + +Read the JSON. Do not infer CUDA, ffmpeg, or an installer from the OS name. + +- `pixel_stack` = `missing`: the installed build has no pixel dependencies. + Only `identify` and `metadata` answer. `identify` skips the visible detectors + and says so in its caveats, so read that caveat rather than its quiet verdict. + Go to step 3. +- `pixel_stack` = `unknown`: the check gave an unrecognized result. Do not + claim a command will work; run it and read the real error. +- `advice.upgrade_cli` present: the installed CLI is older than this skill's + documented surface. Upgrade before reporting a flag as broken. +- `advice.invisible_images` = `unavailable_no_cuda`: do not run `invisible` +- `advice.image_all` = `do_not_run_writes_nothing`: do not run `all` or + `batch --mode all`. Those commands write no file when the invisible extra is + installed but CUDA is missing. +- `advice.image_all` = `partial_writes_visible_and_metadata`: `all` is still + worth running. Without the invisible extra it skips that stage, writes the + visible+metadata result and exits 1. Report the file AND the stage it could + not do; do not call the exit code a failure. +- `advice.no_cuda_fallback` is https://raiw.cc. Offer that for invisible image + work. Do not invent a CPU or MPS profile. + +If the user does not want a local install, skip to raiw.cc. Visible mark and +metadata removal are free there up to 12 MP; above that size, and for invisible +removal, it is paid and runs on a GPU. Do not quote a price: check the site. + +### 3. Install + +Install when `cli.found` is false OR `pixel_stack` is `missing`. A present +binary is not enough: the default package, which is what Homebrew installs, +carries no pixel dependencies, so `visible`, `erase`, `all`, `batch` and every +`video` command stop with an install hint, and `identify` sees metadata only. +Reinstalling with the extra fixes it. + +Read [references/install.md](references/install.md) for the extra list and the +uv / pipx / pip commands. Default extra is `visible`. Always quote extras: +`"remove-ai-watermarks[visible]"`. + +### 4. Choose the command + +Default: inspect first. + +| User goal | Command | Stop if probe says | +| --- | --- | --- | +| What is on this file? | `identify` or `video identify` | | +| Known visible AI label on an image | `visible` | | +| User-supplied box on their own image | `erase` | | +| AI metadata only | `metadata` or `video metadata` | | +| Known visible AI label on video | `video visible` or `video all` | `video_visible=needs_ffmpeg` | +| Invisible image / image SynthID | `invisible` | `invisible_images=unavailable_no_cuda` | +| Video SynthID | `video invisible` | `video_invisible=needs_ffmpeg` | +| Everything on an image | `all` | `image_all=do_not_run_writes_nothing` | +| A directory | `batch` or `video batch` | same as the mode | +| No signal found, user still wants a guess | `classify` | it is a guess, not provenance | +| An independent OpenAI SynthID reading | `verify-openai-synthid` | it uploads the image; ask first | + +Flags and mark names: [references/commands.md](references/commands.md). Read +that file only when you need a flag, a mark key, or a video recipe. + +### 5. Run + +Always pass `-o` unless the user explicitly asked to overwrite. Image +`metadata` without `-o` overwrites the source. Video commands never overwrite +the source. + +### 6. Report + +Report ONLY what a command in this session actually did. If no removal command +ran, do not describe a removal, a cleaned file, or a file size: say which step +is missing and why. Before naming an output path, confirm the file exists. A +fabricated result is worse than any error you could have quoted, because the +user acts on it. + +Read the process exit code and whether `-o` exists. + +| Exit | Meaning | What to tell the user | +| --- | --- | --- | +| 0 | Requested work completed | The output path. Do not add "this is clean". | +| 2 | No targeted signal | Unknown, not clean. No new file from `visible`. | +| 1 | Error or partial `all` | Quote the error. Say which stage ran, if any. | + +Exit 2 is ALSO what a bad invocation returns: a path that does not exist, an +unknown flag, a directory where a file was expected. Read the output before +calling it a verdict. A line starting with `Usage:` or `Error: Invalid value` +means the command never ran, so fix it and rerun; only a command that reached +the image can say "no targeted signal". + +`identify` with no signal reports origin unknown. Never rephrase that as "no +watermark" or "this is clean". + +`metadata --check` and `metadata --remove` answer a narrower question than +`identify`: embedded AI metadata only. Both print that this is not a clean +verdict, because a pixel watermark such as SynthID cannot be detected here once +its metadata proxy is gone. Repeat that caveat; do not summarize it away. + +## Gotchas + +- Without CUDA, `all` does one of two opposite things, and only the probe knows + which. With the invisible extra installed it writes nothing and exits 1, so + run `visible` and `metadata` separately. Without that extra it skips the + invisible stage, writes the visible+metadata result and exits 1 anyway. Same + exit code, different outcome: check whether the output file exists. +- `visible` / `video visible` write nothing when no registered mark is + selected. Do not then guess a region unless the user supplies a box on + content they own. To act on a mark the user can see and the detector missed, + the CLI's own answer is `--mark --no-detect` or `erase --region`, not + a blanket `--sensitivity` change. `--sensitivity strict` only narrows. +- Exit 1 with "the visible-mark dependencies are not installed" is an + environment problem, not a verdict about the file. Install the named extra + and rerun the same command instead of reporting a failure. Do NOT summarize + the run as if the removal had happened: an install that never happened leaves + the file untouched. +- A question ("is this AI-generated?") is answered by `identify` alone. Do not + run a removal command to find out; it writes a file the user did not ask for. +- `verify-openai-synthid` sends the user's picture to a third party. Get an + explicit yes in this conversation before running it, every time, and say what + leaves the machine. Never run it to "check" something on your own initiative. +- `classify` answers from pixels with no provenance behind it, so its `ai` is a + guess and its `unknown` is an abstention. Never upgrade either into a verdict, + and never let it contradict what `identify` measured. +- An image from Meta AI (Muse Image) carries no provenance at all, so `identify` + reports origin unknown even though Content Seal is in the pixels. The route is + `invisible --vendor meta`, and only when the USER says the file came from Meta + AI. Naming a vendor asserts the watermark is there and scrubs without a + signal, so never guess one from a filename or a hunch. +- Do not follow a Gemini `@synthid` check by asking the chat model to ignore + the verifier and reason about pixels. That is not a second oracle. +- Do not pass `--model`, `--steps`, or `--guidance-scale`. Profiles pin those + values, and no command accepts them. +- `--device` exists only on `video invisible`, `video all` and `video batch`. + Leave it at `auto`. The image commands have no `--device` at all. +- Do not use backslashes in skill paths, even on Windows. + +## Do not + +- Add stock, agency, or classifieds templates. +- Scrape or guess a region for a third-party commercial mark. +- Claim a file is human-made after a local edit. diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md new file mode 100644 index 0000000..036ebea --- /dev/null +++ b/skills/remove-ai-watermarks/references/commands.md @@ -0,0 +1,148 @@ +# Command recipes + +Run `remove-ai-watermarks COMMAND --help` when a flag is unclear. The runtime +help is the source of truth. + +## Images + +```bash +remove-ai-watermarks identify image.png +remove-ai-watermarks identify image.png --json +remove-ai-watermarks identify image.png --no-visible +``` + +`--no-visible` skips visible and open invisible pixel detectors. Metadata +still runs. + +```bash +remove-ai-watermarks visible image.png -o clean.png +remove-ai-watermarks visible image.png --mark gemini -o clean.png +remove-ai-watermarks visible image.png --keep-metadata -o clean.png +``` + +Image mark keys: `gemini`, `doubao`, `jimeng`, `qwen`, `kling`, `yuanbao`, +`samsung`, `runninghub`, `baidu`, `liblib`, `microsoft`, `jimeng_pill`. Default +`--mark auto` removes every selected match. `microsoft` is the top-right AI +badge, separate from the invisible InvisMark watermark on the same vendor's +images. `jimeng_pill` is a weak detector and needs corroboration. + +```bash +remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png +``` + +Region is `x,y,width,height`. Repeat `--region` for more boxes. Backends: +`cv2`, `migan`, `lama`. `erase` has no `auto` backend; `visible`, `all`, +`batch` and the video commands do. Use this only with a user-supplied box on +content they own. + +```bash +remove-ai-watermarks classify image.png +remove-ai-watermarks classify image.png --json +``` + +Pixel classifier, needs the `classify` extra. It is NOT provenance: it guesses +from pixels alone and `identify` never runs it. Labels are `ai` (both models +agree), `human`, and `unknown` -- `unknown` is an abstention, not a negative. +Offer it only when `identify` found no signal and the user still wants a guess, +and report it as a guess. + +```bash +remove-ai-watermarks verify-openai-synthid image.png --acknowledge-upload +``` + +This UPLOADS a copy of the user's image to OpenAI, so never run it without the +user's explicit go-ahead in this conversation; `--acknowledge-upload` is that +consent and the command refuses without it. It strips AI metadata from a +temporary pixel-identical copy, reads only the SynthID result, and never +modifies the source. Needs the `verify` extra. + +```bash +remove-ai-watermarks metadata image.png --check +remove-ai-watermarks metadata image.png --remove -o clean.png +``` + +Without `-o`, image metadata removal overwrites the source. + +```bash +remove-ai-watermarks invisible image.png -o clean.png +remove-ai-watermarks invisible image.png -o clean.png --force +``` + +CUDA only. Default pipeline is `qwen-zimage`. The local signals it acts on are +SynthID provenance, C2PA soft bindings, Microsoft InvisMark, and the open +DWT-DCT / TrustMark detectors. Use `--force` when the user knows the file +should be regenerated even though no local signal fired. + +```bash +remove-ai-watermarks invisible image.png --vendor meta -o clean.png +``` + +Vendor cohorts: `auto`, `openai`, `google`, `microsoft`, `meta`. `auto` derives +the strength from C2PA provenance and falls back to a resolution curve. Naming a +cohort asserts the watermark IS present, so it also runs the scrub without a +local signal, exactly like `--force`. Set it only when the USER says where the +file came from. `--vendor` is on `invisible`, `all` and `batch`. + +Meta Muse Image is the case this exists for: Content Seal ships with no C2PA and +its IPTC tag is a generic code, so nothing routes it and `identify` reports +origin unknown. Without `--vendor meta` there is no way to clean it. +There is no `--model`, `--steps`, or `--guidance-scale` flag, and no `--device` +on the image path: auto-detection already finds the only device these profiles +run on. + +Pipeline profiles: `qwen-zimage` (the default), `sdxl-zimage`, `chroma-zimage`, +`auto`. All are CUDA-only and all install from the same `qwen-zimage` extra. +`auto` picks the engine from the file's own provenance -- chroma-zimage for +OpenAI and Microsoft, qwen-zimage for Google, Meta and unknown -- so prefer it +over naming an engine when the user has no reason to care. `sdxl-zimage` is the +heavier alternative and `chroma-zimage` is the Apache-2.0 global stage. + +```bash +remove-ai-watermarks all image.png -o clean.png +remove-ai-watermarks batch ./images --mode visible --output-dir ./clean +``` + +`batch` modes: `visible`, `invisible`, `metadata`, `all`. + +## Video + +```bash +remove-ai-watermarks video identify input.mp4 +remove-ai-watermarks video identify input.mp4 --json +remove-ai-watermarks video identify input.mp4 --no-visible +remove-ai-watermarks video all input.mp4 -o clean.mp4 +remove-ai-watermarks video metadata input.mp4 --check +remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4 +remove-ai-watermarks video visible input.mp4 -o clean.mp4 +remove-ai-watermarks video visible input.mp4 --mark veo -o clean.mp4 +remove-ai-watermarks video invisible input.mp4 -o clean.mp4 +remove-ai-watermarks video batch ./videos --mode all +``` + +Video mark keys: `sora`, `veo`, `seedance`, `doubao`, `dola`, `hailuo`, +`kling`. Default `--mark auto` picks the first temporally stable match in that +order. + +`video invisible` and `video all --invisible` accept MP4, MOV, and M4V only. +They are lossy. The shipped noise profile is pinned; do not lower it. + +Unlike the image path, `video invisible`, `video all` and `video batch` do +accept `--device [auto|cuda|mps|cpu]`. Leave it at `auto`. Set it only when the +user names a device, and never to claim a device the probe did not report. + +Without `-o`, video commands write `_clean` and keep the original. +Output extension must match the input container. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| 0 | Requested work completed | +| 2 | No targeted signal (`visible` / no-signal `invisible`) | +| 1 | Hard failure, or `all` skipped a required invisible stage | + +A written file plus exit `1` from `all` is a partial result. + +Click's own usage errors also exit `2` (missing file, unknown flag, directory +where a file was expected). They print `Usage:` or `Error: Invalid value` and +never touch the image, so they are not a no-signal verdict. diff --git a/skills/remove-ai-watermarks/references/install.md b/skills/remove-ai-watermarks/references/install.md new file mode 100644 index 0000000..7b0e598 --- /dev/null +++ b/skills/remove-ai-watermarks/references/install.md @@ -0,0 +1,78 @@ +# Install extras + +# Contents +- Installers +- Extra list +- Notes + +## Installers + +Use the first tool `scripts/probe.py` reported as present. Quote every extra +list. Forward slashes only. + +uv: + +```bash +uv tool install --force "remove-ai-watermarks[visible]" +``` + +pipx: + +```bash +pipx install --force "remove-ai-watermarks[visible]" +``` + +pip (into the active environment): + +```bash +python3 -m pip install --upgrade "remove-ai-watermarks[visible]" +``` + +On Windows, `python` may exist when `python3` does not. + +Homebrew is macOS/Linux only and CANNOT carry extras: the formula installs the +default package, which has no pixel dependencies, so `visible`, `erase`, `all`, +`batch` and the `video` commands stop with an install hint, and `identify` +reports metadata only. That is exactly what `pixel_stack: missing` means in the +probe. Use it for metadata-only work, +or when the user asks for brew by name and accepts that limit: + +```bash +brew install wiltodelta/tap/remove-ai-watermarks +``` + +Reinstalling over a brew build with uv or pipx is the fix; the extra is what +was missing, not the tool. + +After installing, rerun `scripts/probe.py` and confirm `pixel_stack` is `ok` +before promising a command will work. + +Read this file when the probe says the CLI is missing, the extra is wrong, +ffmpeg is missing, or the user asked about Homebrew, pipx, or pip. + +## Extra list + +| Need | Extra | +| --- | --- | +| Metadata inspect and strip | no extra | +| Image identify, `visible`, `erase` | `visible` | +| HEIC / HEIF / AVIF pixels | add `heif` | +| Video identify / visible / all / batch | `video`, plus ffmpeg | +| Video SynthID (`video invisible`) | `video,diffusion`, plus ffmpeg | +| Invisible images | `qwen-zimage`, plus NVIDIA CUDA | +| Learned image/video fill | add `migan` or `lama` | +| Open DWT-DCT detect | add `detect` | +| Pixel classifier (`classify`) | add `classify` | +| OpenAI verifier (`verify-openai-synthid`) | add `verify` | +| Adobe TrustMark on Python 3.11-3.12 | add `trustmark` | +| Every production extra on this Python | `all` | + +`all` the extra and `all` the command are different. The extra installs +backends. The command runs visible, then invisible when available, then +metadata. + +ffmpeg is required to write cleaned video and to strip non-ISOBMFF containers +(MKV, WebM, AVI, FLV). Image work does not need it. + +Invisible image removal refuses CPU and MPS at construction. There is no +fallback profile. diff --git a/skills/remove-ai-watermarks/scripts/probe.py b/skills/remove-ai-watermarks/scripts/probe.py new file mode 100644 index 0000000..7b6f902 --- /dev/null +++ b/skills/remove-ai-watermarks/scripts/probe.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +"""Report whether this machine can run remove-ai-watermarks. + +Stdlib only. JSON on stdout, indented with --pretty. +Works on Windows, macOS, and Linux. No interactive prompts. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib +from pathlib import Path +from typing import Any + +_GPU_MARKERS = ("GPU ", "gpu:") +# Below this, the CLI predates flags and behavior this skill documents. Raise it in +# the same change as any reference that names a flag the previous release lacked: +# 0.37.0 is here because `classify` and `verify-openai-synthid` arrived with it +# (`--pipeline auto` needed 0.36.0; chroma-zimage and the doubao video mark 0.35.0; +# the `microsoft` mark 0.34.0; `--vendor` 0.32.0), and a stale floor told an agent +# its CLI was current right before it typed a value that build rejects. +# tests/test_agent_skill.py pins it against the package version, so it can never +# advertise a release that does not exist. +MIN_CLI_VERSION = (0, 37, 0) +# Markers a build without the pixel stack prints. The first is the current CLI's own +# install hint; the rest are what older builds emit before the guard existed, and the +# Homebrew formula ships exactly such a build. +_NO_PIXELS_MARKERS = ( + "visible-mark dependencies are not installed", + "No module named 'cv2'", + "No module named 'numpy'", +) +_NO_INVISIBLE_MARKER = "invisible-removal dependencies are not installed" +# The capability checks run the CLI on a file with this name. A caller reading a trace +# of CLI invocations uses it to tell the probe's own calls from the ones an agent chose. +PROBE_IMAGE_NAME = "probe.png" + + +def _which(name: str) -> str | None: + return shutil.which(name) + + +def _run(argv: list[str], timeout: float = 8.0) -> tuple[int, str, str]: + try: + proc = subprocess.run( # noqa: S603 + argv, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return 1, "", "" + return proc.returncode, proc.stdout or "", proc.stderr or "" + + +def _probe_python() -> dict[str, Any]: + info = sys.version_info + return { + "executable": sys.executable, + "version": f"{info.major}.{info.minor}.{info.micro}", + "note": "Interpreter running this probe, not the CLI runtime.", + } + + +def _parse_version(text: str) -> tuple[int, ...] | None: + """The dotted release out of ``remove-ai-watermarks, version X.Y.Z``.""" + match = re.search(r"(\d+)\.(\d+)\.(\d+)", text) + return tuple(int(part) for part in match.groups()) if match else None + + +def _cli() -> dict[str, Any]: + path = _which("remove-ai-watermarks") + if path is None: + return {"found": False, "path": None, "version": None, "outdated": None} + code, stdout, stderr = _run([path, "--version"]) + text = (stdout or stderr).strip() + parsed = _parse_version(text) + return { + "found": code == 0, + "path": path, + "version": text or None, + # None means the version could not be read, which is not the same as current. + "outdated": None if parsed is None else parsed < MIN_CLI_VERSION, + } + + +def _blank_png(path: Path) -> None: + """Write a 64x64 grey PNG with zlib and struct alone, no Pillow, no cv2.""" + width = height = 64 + raw = b"".join(b"\x00" + bytes([128]) * (width * 3) for _ in range(height)) + + def chunk(tag: bytes, payload: bytes) -> bytes: + body = tag + payload + return struct.pack(">I", len(payload)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF) + + path.write_bytes( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(raw, 6)) + + chunk(b"IEND", b"") + ) + + +def _run_on_blank(cli_path: str, subcommand: str) -> tuple[int, str]: + """Run one subcommand on a blank image this probe writes, and return (code, output). + + Both capability checks need the same invocation, so they share one shape: writing + the PNG twice and spelling the flags twice is how the two would drift apart. + """ + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / PROBE_IMAGE_NAME + _blank_png(source) + code, stdout, stderr = _run( + [cli_path, subcommand, str(source), "-o", str(Path(tmp) / "probe_clean.png")], + timeout=60.0, + ) + return code, f"{stdout}\n{stderr}" + + +def _pixel_stack(cli_path: str) -> str: + """Whether the INSTALLED build can actually run a visible-mark command. + + A found binary says nothing about this. The default package ships without the + pixel extra, so ``cli.found`` reported "ok" for a Homebrew install whose very + first ``visible`` died on a missing cv2. So ask the real seam: run ``visible`` + on a blank image the probe writes itself. Exit 2 is the documented "no mark + here", which only a working pixel stack can reach. + """ + code, blob = _run_on_blank(cli_path, "visible") + if code == 2: + return "ok" + if any(marker in blob for marker in _NO_PIXELS_MARKERS): + return "missing" + return "ok" if code == 0 else "unknown" + + +def _invisible_stack(cli_path: str) -> str: + """Whether the diffusion extra is installed, WITHOUT downloading a model. + + ``all`` behaves in two opposite ways on a machine without CUDA, and the + difference is this stack, not the GPU: without the extra it skips the invisible + stage and still writes a visible+metadata result, and with the extra it fails at + device construction and writes nothing. Advising "do not run" in both cases threw + away the one command that does useful work on a Mac. + + ``invisible`` checks the extra first and detection second, so a blank image with + no signal exits 2 before anything loads -- 0.23s here, no download. + """ + code, blob = _run_on_blank(cli_path, "invisible") + if _NO_INVISIBLE_MARKER in blob: + return "missing" + return "installed" if code == 2 else "unknown" + + +def _has_cuda() -> bool: + exe = _which("nvidia-smi") + if exe is None: + return False + code, stdout, stderr = _run([exe, "-L"]) + blob = f"{stdout}\n{stderr}" + return code == 0 and any(marker in blob for marker in _GPU_MARKERS) + + +def _installers() -> dict[str, str | None]: + return { + "uv": _which("uv"), + "pipx": _which("pipx"), + "pip": _which("pip"), + "brew": _which("brew"), + } + + +def _preferred_installer(installers: dict[str, str | None]) -> str | None: + for name in ("uv", "pipx", "pip"): + if installers[name]: + return name + return None + + +def _image_all(*, no_pixels: bool, cuda: bool, invisible: str) -> str: + """What ``all`` actually does here, measured rather than inferred from the GPU.""" + if no_pixels: + return "needs_visible_extra" + if cuda: + return "ok" + if invisible == "missing": + # Writes the visible+metadata result and exits 1 with the invisible stage + # named as skipped. A partial file, not a failure. + return "partial_writes_visible_and_metadata" + return "do_not_run_writes_nothing" + + +def _advice( + *, + cli_found: bool, + pixels: str, + cuda: bool, + ffmpeg: bool, + installer: str | None, + outdated: bool | None, + invisible: str, +) -> dict[str, str]: + host = "https://raiw.cc" + if not cli_found: + if installer is None: + return { + "next": "install_python_tooling", + "detail": ( + "No remove-ai-watermarks CLI and no uv/pipx/pip. " + f"Send the user to {host} or install Python 3.11-3.14 first." + ), + } + return { + "next": "install_visible", + "detail": f"Install with {installer}. Default extra is visible.", + } + + # A found CLI without the pixel stack can still read metadata, and NOTHING else. + # Reporting these as "ok" is what sent an agent into a missing-cv2 crash on a + # machine the probe had just called ready. + no_pixels = pixels == "missing" + advice = { + "identify": "metadata_only_no_pixels" if no_pixels else "ok", + "visible": "needs_visible_extra" if no_pixels else "ok", + "metadata": "ok", + "video_visible": "needs_visible_extra" if no_pixels else ("ok" if ffmpeg else "needs_ffmpeg"), + "invisible_images": "unavailable_no_cuda" if not cuda else ("needs_visible_extra" if no_pixels else "ok"), + "image_all": _image_all(no_pixels=no_pixels, cuda=cuda, invisible=invisible), + "video_invisible": "ok_cpu_or_gpu" if ffmpeg else "needs_ffmpeg", + "no_cuda_fallback": host, + "pixel_stack": pixels, + "invisible_stack": invisible, + } + if no_pixels: + advice["next"] = "install_visible" + advice["detail"] = ( + "The installed CLI has no pixel dependencies, so only metadata commands work. " + f"Reinstall with the visible extra using {installer or 'uv, pipx or pip'}. " + "A Homebrew install cannot carry extras." + ) + elif pixels == "unknown": + advice["detail"] = ( + "The pixel check gave an unrecognized result; treat visible commands as unverified " + "and read the first real error instead of assuming a cause." + ) + if outdated: + advice["upgrade_cli"] = ( + f"Installed CLI is older than {'.'.join(str(part) for part in MIN_CLI_VERSION)}; " + "flags this skill documents may be missing. Upgrade before reporting a flag as broken." + ) + return advice + + +def build_report() -> dict[str, Any]: + installers = _installers() + installer = _preferred_installer(installers) + cli = _cli() + cuda = _has_cuda() + ffmpeg = _which("ffmpeg") is not None + pixels = _pixel_stack(str(cli["path"])) if cli["found"] else "unknown" + invisible = _invisible_stack(str(cli["path"])) if cli["found"] else "unknown" + return { + "os": os.name, + "platform": sys.platform, + "probe_python": _probe_python(), + "cli": cli, + "cuda": cuda, + "ffmpeg": ffmpeg, + "pixel_stack": pixels, + "invisible_stack": invisible, + "installers": {name: path is not None for name, path in installers.items()}, + "preferred_installer": installer, + "advice": _advice( + cli_found=bool(cli["found"]), + pixels=pixels, + cuda=cuda, + ffmpeg=ffmpeg, + installer=installer, + outdated=cli["outdated"], + invisible=invisible, + ), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Probe this machine for remove-ai-watermarks.", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="Indent the JSON on stdout.", + ) + args = parser.parse_args(argv) + report = build_report() + dumped = json.dumps(report, indent=2 if args.pretty else None, sort_keys=True) + sys.stdout.write(dumped + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())