# ShakerScan client

> ShakerScan documentation at v2.12.2. [Source](https://github.com/andriyze/shakerscan/blob/v2.12.2/docs/client.md) · [Web version](https://shakerscan.com/docs/open-source/client)

**Status:** shipped in the repository (`client/`); the `shakerscan` package on PyPI and the
Homebrew tap are published by `.github/workflows/publish-client.yml` on `client-v*` tags.

The ShakerScan client is the `shakerscan` command without the engine: the MCP adapter and the
scripted Hunt CLI, installed on a laptop or a CI runner with no Docker, pointed at any ShakerScan
instance (a local engine, a VPS, or an authenticating deployment such as ShakerScan Enterprise,
with a service token). This page documents the client itself; how an Enterprise deployment
issues tokens, assigns roles and enables Hunt is documented at
[shakerscan.com/docs/enterprise](https://shakerscan.com/docs/enterprise), not in this repository.

It is the same code the engine runtime already exposes as `scanner.sh mcp` and `scanner.sh hunt`:
the package vendors `scripts/shakerscan_mcp.py` and `scripts/v2_cli.py` at build time
(`client/hatch_build.py`), so a client and a runtime of the same release run identical code, and
the Arsenal and Hunt tools come from the instance's live contracts (`GET /arsenal/commands`,
`GET /hunts/contract`); the fixed posture-check tool calls that instance's `/public/check` route.

## One command, two install channels

| Channel                                                                                                | What lands on the PATH                         | Runs the engine                                                                        | `mcp`, `hunt` |
| ------------------------------------------------------------------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------------------- | ------------- |
| `curl -fsSL https://install.shakerscan.com \| sh`                                                      | the launcher shim at `~/.local/bin/shakerscan` | yes                                                                                    | yes           |
| `pipx install shakerscan`, `uv tool install shakerscan`, `brew install andriyze/shakerscan/shakerscan` | the client, as `shakerscan`                    | hands `start`, `stop`, `status`, `update`, … to a local engine install when one exists | yes           |

The client looks for the engine at `SHAKERSCAN_HOME` (default `~/.shakerscan`, where the
installer puts it) and runs its `scanner.sh` with the same defaults as the launcher shim. Without
an engine, an engine subcommand prints the install one-liner and exits 2.

When both channels meet on one machine, the installer keeps a client it finds at
`~/.local/bin/shakerscan` instead of replacing it, because the client already forwards engine
commands to the install. If the launcher shim was there first and pipx refuses to overwrite it,
`pipx install --force shakerscan` (or `uv tool install --force shakerscan`) replaces it; nothing is
lost, `shakerscan start` still works through the client.

Homebrew links the command into its own `bin` (`/opt/homebrew/bin` on Apple silicon), which other
tools share: a global npm package installed with Homebrew's Node also lands there. If
`brew install` warns that it could not symlink `bin/shakerscan`, another program owns that name and
keeps answering `shakerscan`; `which -a shakerscan` shows which one runs, and
`brew link --overwrite shakerscan` makes it the ShakerScan client.

## Install

```bash
pipx install shakerscan          # or: uv tool install shakerscan
shakerscan version
```

Homebrew (macOS and Linux), from the `andriyze/shakerscan` tap (Homebrew installs formulae only
from taps, so the release workflow publishes the formula there):

```bash
brew install andriyze/shakerscan/shakerscan
```

The Homebrew formula installs the client without python-fido2, so it cannot answer a security-key
touch (`brew install` and `brew info shakerscan` say so in its caveats, and `shakerscan doctor`
says whether your gateway requires the touch): on an Enterprise gateway whose administrator
requires a touch for person decisions (and for every terminal approval), install the client with
`pipx` or `uv tool` instead. For an open-source engine, or a gateway that requires no touch, the
Homebrew client does the same as the others.

Run without installing:

```bash
uvx shakerscan mcp --url https://scanner.example.com --token-file ~/.config/shakerscan/token
```

From a repository checkout the client runs the runtime scripts in place:
`PYTHONPATH=client/src python -m shakerscan …`.

Python 3.10 or newer. One third-party dependency since 0.11.2: python-fido2 (Yubico,
BSD-2-Clause; it brings `cryptography`, `cffi` and `pycparser`, and `typing-extensions` on Python
3.10), for a USB security key's touch, on macOS and on 64-bit Linux (x86-64 and ARM64): the
platforms `cryptography` publishes wheels for, so the client installs there without a compiler,
as it did before. On an Intel Mac `cryptography` stays on 48.0.x (49 and later publish no Intel
macOS wheel); it still gets that line's security fixes only as far as 48 goes, until a client
release says otherwise. Elsewhere (Windows, where the terminal touch is not supported yet; 32-bit
or other Linux architectures; the BSDs) the client installs without python-fido2 and works as
before without the touch; `pipx inject shakerscan fido2` adds it where a `cryptography` wheel or a
Rust and OpenSSL toolchain exists. On a Python newer than the ones `cffi` publishes wheels for
(3.10 to 3.15 for cffi 2.1.1), installing builds `cffi` from source, which needs a C compiler
and the libffi headers: `pipx install --python python3.14 shakerscan` (or `uv tool install --python 3.14 shakerscan`) picks a Python it has wheels for.

The package declares exactly `fido2==2.2.1` (the touch path is security-sensitive, so a new
python-fido2 release reaches you only with a client release that was checked with it) and
`cryptography>=50.0.2,<52` (from the release the publish checks lock up to python-fido2's own
bound, so cryptography's fixes keep reaching an installed client), or `>=48.0.1,<49` on an Intel
Mac. Said plainly: `pipx install shakerscan` or `uv tool install shakerscan` resolves the exact pin
`fido2==2.2.1` and a `cryptography` release in that range (with `cffi` and `pycparser`) from PyPI
by version, without hashes; the hash-locked `client/requirements/runtime.lock` protects the
publish checks and the SBOM, not your install. What is tested before a client is published:
the built wheel installed from the hash-locked set in `client/requirements/runtime.lock`
(python-fido2 2.2.1, cryptography 50.0.2, or 48.0.1 on an Intel Mac, with their dependencies;
`pip install --require-hashes -r client/requirements/runtime.lock` installs exactly that), and the
same wheel installed with no lock, as `pipx` does, on Linux x86-64 and Apple Silicon macOS with
Python 3.10, 3.12, 3.13, 3.14 and 3.15 (a 3.15 pre-release until its final release reaches the
CI runners; that pre-release is checked but never holds up a release): it checks that
python-fido2 2.2.1 and a cryptography within the declared range were resolved,
imports python-fido2's USB and CTAP2 client and verifies an ES256 signature through it. An Intel
Mac's set is checked through the lock only (there is no Intel macOS runner), and a cryptography
patch release after the client was published is installed untested (still within the declared
range). The hash-locked set is what the publish checks install; your `pipx` or `uv tool` install
is pinned by version (python-fido2 exactly), not by hash. On an Intel Mac, cryptography 48.0.x
receives no fixes after its own line: a known limitation a later client release revisits. If you
added python-fido2 yourself with `pipx inject shakerscan fido2` (as 0.11.1 asked), upgrading to
0.11.2 installs exactly 2.2.1; do not upgrade with `pipx upgrade --include-injected`, which can
raise python-fido2 past the client's pin with only a pip warning (`pipx runpip shakerscan install
fido2==2.2.1` puts it back). The first `uvx shakerscan mcp` downloads
`cryptography` and `cffi` too (a few megabytes); on a slow link, run it once by hand (or `uv tool
install shakerscan`) before an agent starts it, so the agent's MCP start-up does not time out. The
Homebrew formula installs the client without python-fido2, so a Homebrew install has no
security-key support (it says so when asked for a touch); install with `pipx` or `uv tool` for
it.

## Public checks without an engine or account

A pipx/Homebrew client can use the bounded ShakerScan public service immediately:

```bash
shakerscan check example.com
shakerscan check 1.1.1.1
shakerscan check https://example.com/api          # the URL path is used for the CORS probe
shakerscan check example.com --dkim-selector google --json
shakerscan mcp
```

`check` observes DNS (addresses, name servers, DNSSEC, CAA, HTTPS records), email policy (MX,
SPF, DMARC, DKIM with a selector, MTA-STS, TLS-RPT), bounded HTTP/HTTPS probes (response,
headers, CORS, redirects, security.txt) and TLS (certificate chain, protocols, ciphers), plus IP
network ownership. The answer (`schema_version` `2`) is a list of factual `observations` with the
probe scope of each; the service makes no pass/fail judgment. The CLI groups them and lists a few
clearly risky values (for example SPF `+all`/`?all`, DMARC `p=none`, a certificate close to
expiry) under "Review". An IP-address target gets the HTTP/TLS probes against the address itself
(the certificate is verified and reported with whether it lists the address) and its reverse
DNS; DNS and email observations do not apply to it.

The public MCP mode exposes only `shakerscan_public_check(target, path?, dkim_selector?)`. It
uses the same credential-free `/v1/check` pipeline as `check`; no private-engine discovery,
Hunt, Arsenal, shell or target management tools are available. When connected to an OSS or
Enterprise instance, MCP also exposes that check alongside the instance's existing tools and
sends it to the instance's `POST /public/check` using the saved connection, but only when the
instance serves that route: `tools/list` first probes it with a request the engine refuses before
doing any work (no body, not JSON). Only the engine's own answer to that, 415 with error code
`unsupported_media_type`, lists the tool. Any other answer leaves the tool out, so neither the
agent nor `doctor` counts it. That covers a gateway that keeps the route closed (403, as the
Enterprise beta does), an engine without the route (404), an engine image without the check
engine (503), a redirect, a gateway's own 400 or 429, a 200 page, and no answer at all. There
is no public fallback or per-check approval prompt on a connected client.

The hosted service accepts only public DNS names and global IP addresses, refuses government and
military targets, and applies per-caller limits: 30 requests a minute, and 25 uncached checks an
hour and 100 a day. Cached answers are reused for 10 minutes.

With no local, remote, or saved ShakerScan instance configured, the client defaults to
`https://pub.shakerscan.com`. Once an instance is configured, `check` sends the same request to
that instance's `POST /public/check`; a failed private connection never falls back to public.
The instance runs the same posture engine (`posture/`, bundled into the API image), so it answers
with the same schema and the CLI prints it the same way. An instance applies none of the hosted
restrictions: internal names, private addresses and any other target are the operator's decision,
with no quota or cache. `SHAKERSCAN_POSTURE_RESOLVER=system` makes the engine use the instance's
own DNS resolver (for internal names) instead of DNS over HTTPS, and
`SHAKERSCAN_POSTURE_IPINFO_TOKEN` enables IP ownership facts.
The OSS API is tokenless, so anyone who can reach it on the trusted LAN can invoke checks of
addresses reachable from the API container; keep its network boundary intentional.

## Connect the client to a server

The client talks to exactly one instance at a time: the one saved by `shakerscan connect`, or
the one named with `--url` on a command. There are two kinds of server, and the difference is
transport and identity, not features: both give the same `api`, `scan`, `hunt`, `mcp`, `doctor`
and `agent` commands.

|                   | Open-source engine on a trusted LAN                                      | ShakerScan Enterprise                                                             |
| ----------------- | ------------------------------------------------------------------------ | --------------------------------------------------------------------------------- |
| Transport         | plain `http://`, unencrypted                                             | `https://`, encrypted                                                             |
| Login             | none: no token, no per-person identity                                   | a service token with a role, issued by the console                                |
| Server side       | `shakerscan start --lan`                                                 | the console creates a token and shows a one-time connect link                     |
| Client side       | `shakerscan connect http://192.168.1.50:8080`                            | `shakerscan connect https://scanner.example.com/_enterprise/connect/<code>`       |
| Where it is saved | `~/.config/shakerscan/config.json` (address only)                        | `config.json` (address) and `token` (owner-only, 0600)                            |
| Trust boundary    | the network: anyone who can reach ports 8080/3000 can operate the engine | the token: every action runs under that person's identity and role and is audited |

### Open-source engine (unencrypted, no token)

On the machine with Docker and the engine:

```bash
curl -fsSL https://install.shakerscan.com | sh    # once
shakerscan start --lan                            # or: start --lan --bind-host 192.168.1.50
```

It prints the addresses it bound to (`API: http://192.168.1.50:8080`, `UI: http://192.168.1.50:3000`)
and the client commands. Postgres and Redis stay on loopback. On the laptop (no Docker):

```bash
pipx install shakerscan                           # or: uv tool install shakerscan / brew install andriyze/shakerscan/shakerscan
shakerscan connect http://192.168.1.50:8080       # saves the address; runs doctor
shakerscan doctor                                 # engine: reachable (healthy), mcp: N tools
shakerscan api GET /findings
shakerscan agent opencode                         # or claude, codex, pi: the full agent workspace
```

A plain-http address is always saved without a token (a bearer token is never sent over http);
an https engine that has no login needs `shakerscan connect https://… --no-token`. Only do this on
a network whose users you trust: LAN mode adds no login and no encryption, and the ports must
stay closed to everything else. To go back to localhost-only on the server:
`shakerscan restart --bind-host 127.0.0.1 --public-host localhost`. Networking details, multi-NIC
selection and hand-written MCP registration are in [LAN access](https://github.com/andriyze/shakerscan/blob/v2.12.2/docs/lan-access.md).

Scan links use port 3000 automatically for a direct private-IP or localhost API on
port 8080. Named gateways and URL prefixes keep their configured origin. For a custom
layout (including a reverse proxy on a private IP), specify the UI explicitly:
`shakerscan scan --ui-url https://scanner.example.com https://authorized-app.example`.

### ShakerScan Enterprise (encrypted, with a token)

An administrator creates a service token in the Enterprise console; it shows a one-time connect
link. On the laptop:

```bash
pipx install shakerscan
shakerscan connect https://scanner.example.com/_enterprise/connect/<code> --claude
shakerscan doctor                                 # token: set (sent as a bearer token, https only)
shakerscan api GET /findings
shakerscan agent claude
```

The link works once and expires after ten minutes; the command carries no secret. `connect`
fetches the token through it, writes it to `~/.config/shakerscan/token` (owner-only) and the
instance address to `~/.config/shakerscan/config.json`, runs `doctor`, and with `--claude`
registers `shakerscan mcp` in Claude Code (user scope). `shakerscan connect
https://scanner.example.com` prompts for a token instead (never on the command line). Before
anything is saved the token is checked against the instance: a token it refuses (HTTP 401), or an
instance that cannot be reached, saves nothing. The token is sent only over `https://` and is
never printed: `connect http://… --token-stdin` is refused with the https address to use instead,
and a token with a plain-http URL refuses to start.
How a deployment issues tokens, assigns roles and enables Hunt is documented at
[shakerscan.com/docs/enterprise](https://shakerscan.com/docs/enterprise).

### Either kind

* `shakerscan doctor` shows which instance is in use and whether it is reachable.
* `shakerscan disconnect` forgets the instance and deletes the token file.
* `--url` on any command wins over the saved instance for that one call, without saving anything.
* `SHAKERSCAN_CONFIG_DIR` relocates the saved files.
* Target authorization, budgets, credential admission and the deterministic proof rules are the
  server's in both cases; a route the instance keeps closed answers with a refusal that names what
  is missing.

Explicit options and the environment still win over the saved profile:

* `--url` (or `SHAKERSCAN_API_URL`): the API origin. The default is `http://127.0.0.1:8080`, a
  local engine. An explicit `--url` authorizes a remote origin; a URL taken from the environment
  keeps the adapter's own rule and needs `SHAKERSCAN_MCP_ALLOW_REMOTE_API=true`.
* `--token-file` (or `SHAKERSCAN_API_TOKEN_FILE`, or `SHAKERSCAN_API_TOKEN`): a service token for
  an authenticating deployment such as self-hosted Enterprise. The token reaches the adapter
  through the environment, never on the command line, is sent only over `https://`, and is never
  printed; a token with a plain-http URL refuses to start.

An instance whose certificate comes from a private or corporate CA is reachable once this machine
trusts that CA. Point `SSL_CERT_FILE` at a PEM bundle that contains it (for example your system
bundle with the CA appended), and export it where you run `shakerscan` and where your agent starts:
the MCP server the agent spawns (`shakerscan mcp`) inherits it from that environment, not from the
saved profile. A certificate the client does not trust is reported with this hint by `connect`,
`doctor` and every command:

```bash
export SSL_CERT_FILE=/etc/ssl/certs/ca-with-corporate-root.pem
shakerscan connect https://scanner.example.com/_enterprise/connect/<code>
```

Check a connection before handing it to an agent:

```bash
shakerscan doctor --url https://scanner.example.com --token-file ./token
```

`doctor` reports the client version, the URL rules, whether a token is set, engine reachability,
and the MCP tool catalogue the instance offers (read-only Arsenal tools plus Hunt tools). It
exits 1 when the catalogue cannot be read and 2 when the connection is misconfigured. For a
local engine (a loopback URL) it then hands off to the launcher's own `doctor`, the Docker and
host checks a curl-installed user expects from this command. Likewise `shakerscan version`
prints the client version and, when an engine install is present, the engine release beside it,
so both commands mean the same thing whichever channel put `shakerscan` on the PATH.

## Work with an agent: `shakerscan agent`

The open-source launcher's `shakerscan agent claude` starts the agent inside the runtime
directory, where `AGENTS.md`, the skills and the `.claude` commands (`/scan`,
`/findings`, `/status`, `/deep-hunt`, …) tell it how to work. The client does the same against
the connected instance:

```bash
shakerscan agent claude        # or codex, opencode, pi; omitted => codex, claude, opencode, then pi
shakerscan agent opencode --url http://192.168.1.50:8080   # an open-source engine on a trusted LAN
```

`--url` is for an open-source engine reached by address (the server printed it after
`shakerscan start --lan`; see `docs/lan-access.md`): there is no token and no per-person identity,
the workspace note says so, and the MCP registrations carry the address. Without `--url` the
saved `shakerscan connect` instance is used, then `SHAKERSCAN_API_URL`.

It materializes that kit (vendored into the package at build time) into a workspace
(`${XDG_DATA_HOME:-~/.local/share}/shakerscan/agent`, or `--here` for the current directory, or
`--workspace DIR`; a default workspace left at `~/.config/shakerscan/agent` by client 0.8.1 is
moved there once, with its record, and the move is reported),
prepends a note naming the connected instance, the kit's release and client version, and the
rules of a remote session (no local engine, use `shakerscan api`/`scan`/`hunt` and the MCP
tools, refusals name what is missing),
registers the MCP server for that workspace (`.mcp.json` for Claude Code, `opencode.json` for
OpenCode, `codex mcp add` for Codex), exports the connection with the token left in its file,
and starts the agent there. Pi has no MCP client: it is started with `--no-approve`, `--skill`
for each kit skill, and `--prompt-template .claude/commands`. That keeps trust-gated project
settings/extensions out of the launch without a trust prompt while still loading AGENTS.md and
the explicitly named ShakerScan resources. Pi reaches the instance through `shakerscan api`,
`scan` and `hunt`. `--no-launch` prepares the workspace and prints a shell-safe command to
start it; with no supported agent on the PATH it says so instead of naming one. OpenCode
workspaces also load `skills/hunt/SKILL.md` as instructions (`opencode.json`), so the permission,
budget and view rules are in every session that drives a Hunt. Running `shakerscan agent` again
on an existing workspace merges these files instead of rewriting them: the client updates only
its own parts (`mcp.shakerscan` and the Hunt skill instruction in `opencode.json`,
`mcpServers.shakerscan` in `.mcp.json`, the kit's hooks in `.claude/settings.json`) and keeps
everything else, such as an OpenCode `permission` block with a bash deny-list, Claude Code
`permissions`, other MCP servers and `.claude/settings.local.json`; each `kept:` line says what
was kept, hook command lines included. Because an agent can write these files too, each launch
records a fingerprint of the settings that change what an agent may do (permissions, plugins,
providers, other MCP servers, instructions, hooks) in the client's state directory
(`${XDG_STATE_HOME:-~/.local/state}/shakerscan/workspaces/`, never in the workspace an agent can
write), and the next launch lists under `changed:` exactly what changed in between (secret values
hidden); nothing is blocked. When the record of a workspace prepared before is missing or not
valid, the launch says that changes since the last launch could not be checked. A record that
would sit inside the workspace (`--here` from your home directory) is not used, and a workspace
that contains the client's configuration directory (it holds the token) is warned about.

Where the client keeps its state (workspace records) and data (the default agent workspace),
first match wins: `SHAKERSCAN_STATE_DIR` / `SHAKERSCAN_DATA_DIR`; beside `SHAKERSCAN_CONFIG_DIR`
when it is set (`<dir>.state`, `<dir>.data`, so an isolated profile stays isolated even when the
session sets XDG variables); `$XDG_STATE_HOME/shakerscan` / `$XDG_DATA_HOME/shakerscan`;
`~/.local/state/shakerscan` / `~/.local/share/shakerscan` (macOS too). Every path must be absolute (a relative XDG value is
ignored, as the XDG specification says). An isolated profile that kept its records and default
workspace under the session's XDG directories before 2.11 loses neither: the record of the workspace
it launches in is copied from `$XDG_STATE_HOME/shakerscan/workspaces` (never moved, another profile
may share it), and its first launch with its own default workspace names the old shared one
(`$XDG_DATA_HOME/shakerscan/agent`, left in place) and how to keep using it. A directory that cannot be created or written, or a
`SHAKERSCAN_CONFIG_DIR` without a usable sibling (such as `/`), or `/` as
`SHAKERSCAN_STATE_DIR` or `SHAKERSCAN_DATA_DIR`, stops `shakerscan agent` with an
error naming the variable to set. `scripts/clean-shakerscan.sh` removes the records, and the
default agent workspace only with `--agent-workspace` (docs/clean-reinstall.md). Kit files and hook entries the kit no longer ships are removed (a kit file you changed
is kept and named); your own files stay. `opencode.json` with comments or trailing commas is read
and written back as plain JSON, its original kept beside it; a file that is not JSON or JSONC is
moved aside (`.shakerscan-unreadable-*.bak`) with the reason. The client never writes through a
link: if any path it writes, or anything under `.claude/`, is a symbolic or hard link, it refuses
and writes nothing, and every file it writes is a new file renamed into place through directories
opened without following links. The
kit's API calls go through `shakerscan api`, so the same commands work locally and remotely.

`shakerscan api METHOD PATH [JSON]` calls the instance directly (`shakerscan api GET
"/findings?limit=20"`, `shakerscan api POST /scans '{…}'`); `shakerscan scan …` is the runtime's
scan CLI against the instance; `shakerscan status` reports the instance when no engine is
installed on this machine.

## Hunt from an agent (MCP)

Claude Code, after `shakerscan connect` (or `connect --claude` does this for you):

```bash
claude mcp add --scope user shakerscan -- shakerscan mcp
```

Without a saved profile (a CI runner, say), pass the connection explicitly:

```bash
claude mcp add shakerscan \
  -e SHAKERSCAN_API_TOKEN_FILE=$HOME/.config/shakerscan/token \
  -- shakerscan mcp --url https://scanner.example.com
```

Any MCP client that spawns a stdio server:

```json
{
  "mcpServers": {
    "shakerscan": {
      "command": "shakerscan",
      "args": ["mcp", "--url", "https://scanner.example.com"],
      "env": {"SHAKERSCAN_API_TOKEN_FILE": "/home/me/.config/shakerscan/token"}
    }
  }
}
```

The tools, their trust levels, and the fail-closed rules are those of the runtime adapter, in
[mcp.md](https://shakerscan.com/docs/open-source/mcp). The agent's own model does the reasoning; the instance's model credentials are
not involved. The MCP `serverInfo.version` reports `client-X.Y.Z`.

## Approve what a Hunt asks for: `shakerscan approve`

When a Hunt action is refused for something a person can allow (a budget raise, a capability
flag, another service or host, another target's credential), the action waits as a permission
request and the agent tells you the command to run. Run it yourself, in your own terminal:

```bash
shakerscan approve 8f0c…                # shows the server's text, then asks for your proof
shakerscan approve --all-pending        # every pending request, one proof
shakerscan deny 8f0c…
shakerscan approve --watch              # stay open; decide each new request on a keypress
shakerscan hunt permissions list        # JSON: the pending requests, as the server words them
shakerscan hunt permissions wait 8f0c…  # JSON: granted, denied, expired, withdrawn or still_pending
```

* **Enterprise (a token).** Once: register a USB or NFC security key under My account in the
  browser (see **Set up once** below), install the client with pipx or uv, then run `shakerscan
  doctor` and `shakerscan approve --test` (one touch, checked by the gateway as every touch is;
  nothing is decided, and it works whether or not your administrator requires the touch). After
  that, `shakerscan approve <id>` shows the request and asks for one touch.

  What `approve` asks for without `--method`:

  | Gateway                                    | A plugged-in key holds one of your credentials | No key here can answer                                                     |
  | ------------------------------------------ | ---------------------------------------------- | -------------------------------------------------------------------------- |
  | Its administrator requires a touch         | One touch, no PIN                              | Refused until a registered key is plugged in (no code, no browser sign-in) |
  | It does not require one                    | One touch, no PIN                              | Your TOTP code; for single sign-on, the browser sign-in                    |
  | An earlier release (it asks the key's PIN) | The key's PIN and a touch                      | Your TOTP code; for single sign-on, the browser sign-in                    |

  `--method totp` (or `security_key`, `sso`) chooses. Details:

  * The approver is the person the connection's token acts for, named by the gateway
    (`--account` names someone else; a token that acts for nobody needs it). An older gateway
    that does not say it names the approver (its `GET /_enterprise/me` reports no
    `person_decision_proof`) is asked with your sign-in name, which the client asks for once (it
    never sends such a gateway an empty name, which it would refuse and record as a denied
    approval). Without `--account`, the touch prompt names whom it approves as.
  * Whether a plugged-in key holds one of your credentials is asked silently (no blink), and only
    for this gateway's own relying party, so no gateway learns what else your key holds. With
    several keys plugged in the first that holds one is used; you pick one only among keys that
    cannot be asked that way.
  * The key's PIN, where a gateway asks for it: the client says before the prompt that it is the
    key's own PIN, not your authenticator code, and how many tries the key has left (three wrong
    in a row need the key removed and inserted again; eight block its PIN, which only a reset,
    removing every credential on the key, undoes). A key that has a PIN and holds none of your
    credentials when asked silently is still asked where the gateway wants its PIN (a passkey a
    browser made to need the PIN for every use is found only with it); where the gateway asks a
    touch alone and only such a key could answer, the client says so and asks its PIN once, then
    the touch, or uses your code when there is one. A key registered "for terminal approvals
    (touch)" is a touch alone.
  * Single sign-on: a key registered under My account answers with a touch in client 0.11.2,
    whether or not the touch is required. With client 0.11.1 and the touch not required, run
    `--method sso` (0.11.1 without `--method` picks the key and asks its PIN); where the touch is
    required, 0.11.1 needs `--method security_key` with a key that has a PIN, so upgrade to 0.11.2.
  * Key errors are said in plain words (not registered to your account, no touch in time, no PIN
    set). The proof goes to the gateway's step-up routes, which decide in your name; the token
    alone never approves, and a gateway without those routes gets an exact error and nothing is
    decided. `--watch` opens a 30-minute approver session after one step-up where the gateway
    allows one; its secret is held only in that process's memory (see
    `docs/hunt-permission-requests.md` for what that does and does not protect against).
* **A gateway that requires a security-key touch for each decision** (its administrator's
  choice): no approver session opens; `--watch` asks for one touch per decision ("Touch your
  security key to allow (Ctrl-C to cancel)...", or "to deny"; the result says "by alice,
  security-key touch"; it says once whom it approves as), and `allow a[l]l N shown` decides every request it is showing (one touch
  per 50). A deny takes a touch too, as every decision there does. A touch that fails or
  that you cancel with Ctrl-C leaves the request pending and shows it again; the watch goes on,
  except when retrying cannot help (no key registered to your account, your account no longer
  able to decide): it then ends with the reason. On such a gateway, and on any gateway that offers
  it, `--watch` also prints a line for each decision only a person should make that a connection
  acting for you made (with a token, by key touch, or refused), saying what it decided in the
  gateway's words.
* **Open-source engine (no token).** A `y/N` at the prompt, sent to the engine's decision route.
  `--watch` says which Hunts it watches and ends with "stopped watching for permission
  requests".
* `--watch` (both): each request's prompt appears about 0.75 s after the request, and keys pressed
  before it are ignored (and said so), so a key meant for an earlier request never decides the
  next one; when a request ends elsewhere while its prompt waits, the switch to the next one is
  announced. Only a single lowercase key pressed on its own decides: arrow, function and Alt
  keys (however slowly their bytes arrive), terminal control strings (OSC, DCS, APC, PM, SOS,
  up to their BEL or ST, or a 1.5 s pause with no input, and said so), 8-bit CSI, uppercase
  letters and pastes are ignored. It keeps
  polling while it waits, and SIGTERM or a closed terminal ends it like Ctrl-C (terminal
  restored, Enterprise approver session revoked, also during the step-up), with exit status
  128 + the signal number.
  On an engine from 2.11 it reads one feed of every open Hunt's requests (only what changed since
  the previous read); an older engine, or a gateway that does not pass that route, is read Hunt by
  Hunt. It reads every 3 s after a request was shown and waits longer (up to 15 s) while nothing
  new arrives.
  The engine has no accounts: anyone who can reach its API could decide, and the command says so.
* Neither runs without an interactive terminal, so an agent cannot run it in its own shell:
  `approve`, `approve --all-pending`, `approve --watch`, `deny`, `agent --allow`, `hunt start --allow` and the knowledge and secrets decisions ask when their input is a terminal, outside an
  agent that `shakerscan agent` started (even in its own window). Without one they exit with
  status 3 (refused, nothing ran) before anything is read from the instance, shown, or asked of a
  security key; the refusal ends with the command to run in your own terminal, with the request
  the agent named by its id (`shakerscan approve 8f0c…`). Never type a code into an agent's chat.
* With their output piped or captured (`| tee log`, `> file`, `--json | jq`, `HUNT=$(shakerscan
  hunt start ... --allow ... | jq -r .hunt_id)`) they ask as client 0.11.1 did: the prompts on
  standard error when that is the terminal, and the TOTP code where your account has one (a key
  is asked first only when the output is the terminal too). Where the gateway's administrator
  requires a security-key touch, such a command exits 3 having asked nothing and says to run it
  again with its output on your terminal (`shakerscan hunt list` shows a Hunt's id), so your key
  never blinks for a program that captures a command's output.
* With no security key registered where one is needed, `approve` says so whole, before showing
  any request, and exits 1.

Bounds set when you start an agent pre-authorize requests inside them, so they are granted as they
arise: `shakerscan agent opencode --allow budget.raise:2x --allow capability:state-changing`
(also `credential.use:<targets>`, `target.authorize:<patterns>`, `capability:active-testing`).
On Enterprise this asks for your step-up once, before the agent starts. `shakerscan hunt start --allow …` does the same for one Hunt. Bounds an agent proposes itself become one pending request
you approve like any other.

## Decisions only a person makes: a security-key touch

Some requests record a decision only a person should make: raising a Hunt's budget, authorizing a
target for active testing, sharing a saved credential with another target, widening a target's
Hunt permissions, and a few device and secret decisions. An Enterprise gateway's administrator can
require a touch of the person's security key for them when they come from a terminal. The gateway
then refuses such a request sent with the connection's token alone (HTTP 403,
`person_decision_required`, `x-shakerscan-step-up: required`, with the decision in plain words),
and every command of this client completes it the same way.

**Set up once** (a YubiKey 5, or another USB or NFC FIDO2 security key; recommended):

1. In the browser, sign in to the Enterprise console and open **My account**, **Passkeys**. Add
   the key with **Use it: for terminal approvals (touch)**, and when the browser asks, choose the
   security key (not this computer's or a phone's passkey). That key never signs you in. Single
   sign-on accounts use My account, **Security key for terminal decisions**, after a fresh
   sign-in at the identity provider.
2. Install the client with `pipx install shakerscan` or `uv tool install shakerscan` (python-fido2
   comes with it; a Homebrew install has no security-key support). On Linux, a desktop session on systemd 244 or later (Debian 11, Ubuntu 20.04, Fedora 32
   and later) already lets you open a FIDO key; on an older desktop install `libu2f-udev` (Debian,
   Ubuntu) or `u2f-hidraw-policy` (Fedora, RHEL). With no desktop session (signed in over SSH to the
   machine the key is plugged into), add a rule for a group you are in, for example for a YubiKey
   (`plugdev` exists on Debian and Ubuntu): `KERNEL=="hidraw*", SUBSYSTEM=="hidraw",
   ATTRS{idVendor}=="1050", MODE="0660", GROUP="plugdev"` in
   `/etc/udev/rules.d/70-fido-key.rules`, then `sudo udevadm control --reload-rules && sudo
   udevadm trigger`, and replug the key.
3. Run `shakerscan doctor` with the key plugged in: it says whether your administrator requires
   a touch and whether a key is plugged in. Then `shakerscan approve --test`: one touch, checked
   by the gateway against the keys registered to you exactly as every touch is, and nothing is
   decided ("Your security key answered for alice: a touch from this terminal works"). It works
   whether or not the touch is required yet, so try it before your administrator turns it on.
   From then on, run your decisions in your own terminal and touch the key when the command asks.

```text
$ shakerscan hunt budget raise 3f2a91c0-… --limit max_state_changing_requests=40
This needs your decision (your administrator requires a security-key touch):
  Raise state-changing requests 20 -> 40 (safety ceiling) for Hunt 3f2a91c0… on honey.example.com
Touch your security key to approve (Ctrl-C to cancel)...
touch accepted for alice (security-key touch); sending the request again...
raised state-changing requests 20 -> 40 (safety ceiling) for Hunt 3f2a91c0-…
```

* **In your own terminal**: stdin and stdout are terminals (the prompt goes to stdout). A
  command whose output is piped or captured (`--json | jq`, `| tee log`, `> file`, an agent's tool
  call) or whose input is piped or redirected (`shakerscan api … < /dev/null`, or any script feeding
  it) never asks for this touch, even in your own terminal: it exits 3, nothing changed, and names
  the command to run without the pipe. The client shows the
  decision whole (every limit a raise sets, the whole host; a
  decision too long for a terminal is never touched for), asks
  the gateway for a challenge bound to exactly that request (method, path and query, SHA-256 of the
  body bytes, this connection, the person it acts for), checks the binding, asks for one touch of
  a security key registered to your account (user presence; no PIN), and
  sends the same request once more with the one-time grant (`X-ShakerScan-Person-Proof`, valid two
  minutes, for that request only). "touch accepted" means the gateway took your touch; what the
  instance then did with the request (applied, or refused because, say, the Hunt's budget changed
  meanwhile) is what the command prints next. `shakerscan api`, `shakerscan scan`, the Hunt, knowledge and
  secrets commands and the commands below all do this. Ctrl-C at the touch cancels it: nothing is
  changed, and the command exits with status 130; a touch that fails exits with status 1, with the
  reason on one line. With no key plugged in, the client says "Insert your security key" and waits
  for one (up to a minute, within the challenge's two minutes) instead of failing. Over SSH, in WSL
  (unless the key is passed through with `usbipd attach`) and in a container, where a key plugged
  into your computer cannot arrive, it says so at once; over mosh or another remote shell it cannot
  tell, and the end of the wait says so. `shakerscan scan --active-testing` does not ask its own
  "Type 'yes'" when the touch follows: the gateway requires it, the scan carries no credential
  profile or approval receipt of its own, and the target registered for the URL (registered or
  found first, as the gateway does; registering is no decision) is not authorized yet. A target
  already authorized is scanned with no touch, so the question is asked for it as before, and so
  it is when any of this cannot be read.
  `shakerscan doctor` says whether the gateway requires the touch and whether one could happen
  here (python-fido2, a key plugged in), without asking the key anything: where a touch is
  required and no key is plugged in it says to plug in the key registered to you; elsewhere "none
  plugged in" is no failure. It cannot tell which plugged-in key is registered to you (`approve`
  checks that silently, without a blink); `shakerscan approve --test` asks it with one touch.
* **Anywhere else** (an agent's tool call, a pipe, a script, or inside `shakerscan agent`): it
  never prompts and never asks a key anything. It prints one line, `person decision required: <the decision>. Nothing was changed. The person runs this in their own terminal and touches
  their security key: <command>` (the command last, with nothing after it, so a copy of the line's
  end takes no punctuation with it), and exits with status **3** (refused, nothing ran); the Hunt
  and target commands print that one line too, not the CLI's error JSON. The command is the
  dedicated one where there is one (`targets authorize`, `hunt budget raise` when every limit
  name is lower-case letters and underscores, `secrets reactivate|expiry`), else `shakerscan api
  METHOD PATH '<body>'`; a body longer than 1,024
  characters is saved, readable by you only, for a week, under `~/.config/shakerscan/decisions/` and named as
  `@<file>` (`shakerscan api` reads a body from a file with `@`, from that directory only: any
  other JSON body is given inline), so the line stays short and the body stays out of your shell
  history. No option skips the
  touch. The MCP adapter answers the same refusal as `outcome:
  person_decision_required` with the decision and the command, and tells the agent never to retry
  it.
* Two commands make the usual decisions directly:

  * `shakerscan targets authorize <url-or-id>` registers the target if needed and records its
    standing authorization for active testing (never intrusive; that is made in the browser). A
    gateway that requires the touch records the person who touched. Elsewhere on Enterprise the
    engine records the connection's token first, with the person it acts for, and that no touch
    proved it (`alice's token laptop, no security-key touch (shakerscan targets authorize)`); on an
    open-source engine, this computer's user. A placeholder (`TARGET-ID`) or a value ending in
    punctuation copied from a sentence is refused before anything is sent. The
    gateway names the environment the authorization is evaluated in ("in the production
    environment", or a lab environment, which admits private and local addresses). It takes a
    connected device's id as well (a device is authorized the same way) and `host://<address>`
    for a host target. An agent's shell, or a run with its output captured, is refused before
    anything is read or registered (exit 3), whatever the gateway's switch says. In your own
    terminal the target is registered first (an
    ordinary add, no decision): a touch that fails or is cancelled leaves it registered and not
    authorized, and the command says so.
    `shakerscan targets unauthorize <url-or-id> [--reason …]` withdraws it.
  * `shakerscan hunt budget raise <hunt> --limit NAME=N|NAME=+N [--resume]` raises limits to new
    totals (or by `+N`), reading the Hunt's budget and its revision first. Where the gateway does
    not require the touch, the reason the engine keeps says the connection's token and that no
    touch proved it, after any `--reason` you give; where it requires the touch, the gateway
    records "Raised by <you> (terminal security-key touch)" instead of your `--reason`.

  Both run only in your own terminal (stdin and stdout terminals, outside `shakerscan agent`), on
  any instance and whatever the switch says; elsewhere they print the decision and your command
  and exit 3. On an open-source engine, or a gateway that does not require the touch, they are
  sent as they are (on such a gateway an agent holding your connection's token can still send the
  same request itself: the accepted risk of that setting).
* **What a touch proves.** A person was at the key: presence, not identity (anyone at a plugged-in
  key can touch it). An agent started some other way than `shakerscan agent` (Codex or Claude
  Code registered with `shakerscan connect --codex` or `--claude` and started directly, say) whose
  commands run in a full pseudo-terminal (input and output, as under `script`, `expect` or `tmux`)
  looks like your terminal to the client: it gets the touch prompt, and your key blinks for its
  request. Start agents with `shakerscan agent`, and touch only for a prompt you started. A program running as your own computer user could change this client, or
  start a request of its own the moment you expect to touch, so the key blinks for its request; a
  key has no display. Touch only for a prompt you started, and read the decision it names. Running
  an agent as another computer user, or in a container, with its own connection, keeps your
  client, token file and key out of its reach. Your client then reads no file of the agent's
  either: a body over 1,024 characters (a long Hunt start, typically), which the agent's refusal
  saves under the agent's own decisions directory for `shakerscan api … @FILE`, is not readable
  to you. Ask the agent for the body, save it in your own decisions directory
  (`~/.config/shakerscan/decisions/`), run the same command with that file and read the decision
  the touch prompt names (the gateway says it from the bytes you send), or decide in the browser.
  `approve --watch` cannot name a refused start's command (the gateway keeps no request body): it
  says to ask the agent for it.
* **Keys.** A USB FIDO2 key (a YubiKey 5, for example) over python-fido2. Touch ID, Windows Hello
  and synced passkeys are out of reach of a terminal client, and Windows is not supported for the
  touch in this release. On Linux, python-fido2 lists only the keys this user can open, so a key
  this user may not open (no FIDO udev rule; see **Set up once** for the rule per distribution)
  looks unplugged. When a device this user cannot open is a FIDO key (its
  report descriptor, which anyone may read, names the FIDO usage page) the client says so at once
  instead of waiting for an insertion; when its kind cannot be read, the wait and its end say your
  key may be one of them. On a remote machine over SSH the key is not reachable: the client says so and
  names the same command to run on the machine the key is plugged into, with a connection that
  acts for you.
* **What you are shown is what the key signs.** The client asks the gateway for the challenge
  first, so a gateway that cannot name the decision in full (a target or Hunt it could not read)
  or an account with no key registered is said before anything is asked of a key, naming the
  decision ("<decision>: no touch was asked (security\_key\_not\_registered): register a USB or NFC
  security key once, ..."), and so is a request the gateway would refuse anyway (a receipt only
  an administrator may write, for example); then it shows the decision exactly as the challenge
  states it. Two decisions are made in the browser only where the touch is required: confirming
  a Hunt's SSH plan (its commands and their effects are on the Hunt page) and approving an
  authorization investigation (its proposal in full is there too). A terminal is not shown what
  they approve in full, so no touch is offered for them: the command (and the MCP adapter) says
  to make them on the Hunt page, asks no key and exits 3.
* **A Hunt start past its agent session's safety budget.** Where the touch is required, the Hunts
  one agent session starts share one safety budget (state-changing requests, verifier write
  requests, TCP and UDP ports, hosts, out-of-band interactions, device fragility), which the one touch of `shakerscan agent --allow` approves with the bounds (the command shows it before the touch: "Every Hunt this agent
  session starts shares one safety budget: state-changing requests 40, ..."). Cancelling a Hunt
  and starting another does not reset it. A start past what is left is your decision: the agent
  gets one line whose command is that same start (`shakerscan api POST /hunts …`), and the agent
  does not send it again; in your own terminal the touch starts it. A start that fits needs no
  touch. A raise past the budget becomes the Hunt's own budget request (`shakerscan approve`).
* **A refused start** (a scan, Hunt start, retest or network scan of a target nobody authorized
  yet) started nothing. The person's decision is the target's authorization, never the start
  itself: in an agent's shell the command it names is `shakerscan targets authorize <target-id>`,
  by the target id the gateway names (`TARGET-ID` when it could name none) (running the agent's own scan or retest would run the work
  twice). The refusal line, the MCP adapter and the Hunt skill tell an agent not to send the
  request again until the person says the target is authorized, and then to send it once (with
  the same Idempotency-Key, if it had one). In your own terminal, your own `shakerscan scan … --active-testing`
  shows the decision, takes the touch and sends the scan once more.

## Secrets found on a target: `shakerscan secrets`

Values ShakerScan finds on a target (a password in an exposed configuration file, an API key in a
response, a value a Hunt withheld) are kept for that target, encrypted. A Hunt uses one by
reference and never sees it; these commands are yours.

```bash
shakerscan secrets list 4e1f…                    # reference, kind, field, length, sightings, uses; never a value
shakerscan secrets list 4e1f… --usable           # only those a Hunt can use now
shakerscan secrets revoke 4e1f… secret://9b2c…   # stop Hunts using it (later sightings keep it revoked)
shakerscan secrets reactivate 4e1f… 9b2c…
shakerscan secrets expiry 4e1f… 9b2c… --days 7
shakerscan secrets grant 4e1f… 9b2c… --to 7d3a…  # let another target's Hunts use it
shakerscan secrets ungrant 4e1f… 9b2c… --to 7d3a…
shakerscan secrets promote 4e1f… 9b2c… --name "Found API key" --auth-kind api_key_header --header-name X-Api-Key
shakerscan secrets reveal 4e1f… 9b2c…            # show the value, to you, in this terminal
```

`grant`, `promote` and `reveal` are your decisions and run only in your own interactive terminal:
on an open-source engine after a `y` keypress (a pipe or a paste never decides), on Enterprise with
your step-up for that one decision (the gateway applies it; a token alone never can). A revealed
value is shown in that terminal only, with control characters escaped.

**Trying a secret on another target's login.** A Hunt of target B may want to try a password found
on target A on B's login. It never does so on its own: the attempt is refused with
`secret_other_target` and a permission request is raised (`shakerscan approve <id>` shows the
secret by field name, kind and length, the target it was found on and the exact login). Allowing it
lets that Hunt try that secret on that login only, 3 times unless you choose 1-10, and never more
than 3 times per 15 minutes on one host across Hunts; each attempt is recorded with the login's
answer on the Hunt record. No start bound (`--allow`) covers it. Some logins never receive a
discovered secret at all (the shared `honey.shakerscan.com/hub` demonstration login, and any
`host/path` listed in `SHAKERSCAN_SECRET_TRY_DENY`), whether named by host or reached at one of
their addresses; a request carrying a discovered secret follows no redirect. Only secrets of
related targets (same root domain or host asset, the ones `secrets.inspect` lists) can be asked
about; any other needs your `shakerscan secrets grant`.

**Letting the agent see values.** By default a Hunt's agent never sees a discovered value.
`shakerscan hunt start ... --agent-may-see-secrets` (for a local model you trust with them) lets
that Hunt's `secrets.inspect` answer carry the values of secrets found on that target (never one
another target granted to it). The engine accepts it only with the person who chose it
(`allow_asserted_by`) and records that name; the agent's start tool never sends it, and nothing
changes it after the start. Be aware that on a local open-source install published only on
loopback, anyone with access to the local API, a local agent included, can make that same
assertion: your host is the boundary there. On any other install it needs a step-up, which the
Enterprise gateway does not offer for it yet (it strips the field). Each value shown is recorded as a reveal (`reveals` in `shakerscan secrets list`,
`disclosed_to_agent` on the Hunt record); stored actions, receipts, records and logs never hold the
value, and a retried call returns the answer without it. A connected (Enterprise) instance refuses
the option until its gateway records your step-up for it.

## Record a browser session: `shakerscan record`

```bash
pipx install --force 'shakerscan[record]' && python -m playwright install chromium
shakerscan record https://app.example.com        # or a target id
```

Opens Chromium on the target, keeps the requests to the target's exact origin as you browse and
sign in, and uploads them to a browser capture on the connected instance; close the browser or
press Ctrl-C to finish. Scans and Hunts use the capture like an imported HAR, and an agent can
follow it while it records (`shakerscan_captures`). Captured cookies and passwords are never
promoted to credentials. See [Browser capture](https://github.com/andriyze/shakerscan/blob/v2.12.2/docs/browser-capture.md).

## Hunt from a script

```bash
shakerscan hunt --url https://scanner.example.com --token-file ./token list
shakerscan hunt --url https://scanner.example.com --token-file ./token start --help
```

`shakerscan hunt` forwards everything except its connection options to the runtime's product CLI
(`scripts/v2_cli.py … hunt`), so the subcommands are the runtime's: `start`, `get`, `list`,
`query`, `call`, `candidate`, `verify`, `finish`, `cancel`, `resume`, `permissions` and the
`skill-*` commands.
The connection options (`--url`, `--token-file`, `--timeout`) may come before or after the
subcommand, and everything after a `--` is forwarded untouched. `--timeout` is the seconds to
wait for each API answer (default 60). `shakerscan hunt --help`, or `hunt` with nothing after
it, prints the connection options and then that CLI's own help. `query` takes every kind the
engine's `/hunts/{id}/query` accepts, the same as the MCP tool, including `hypotheses`,
`graph_nodes`, `graph_edges`, `endpoint_groups` and `service_intelligence`, and `--cursor` to
follow `next_cursor`.
`hunt call` exits 0 only when the action was admitted: a refusal, a pending permission request
and an action replayed as `blocked` (its permission was denied, expired or withdrawn) exit 2, with
the server's answer still printed as JSON.

## Scan from a script

`shakerscan scan` forwards to the runtime's scan CLI; `shakerscan scan --help` (or `scan help`)
prints its options and submits nothing. Active testing and credential use need a target-bound
approval receipt. Without `--approval-receipt` the server uses the target's standing
authorization, the same one New Scan in the UI uses, and refuses with its reason when there is
none or it does not cover the request: authorize the target once (New Scan, or
`POST /targets/{id}/authorization`). Credential use may need a credential-tier receipt from
`POST /arsenal/approvals`, passed as `--approval-receipt`; an instance may reserve creating one
to administrators. `--active-testing` off a terminal also needs `--confirm-active`.

## Versioning and release

A client built from a repository checkout (a release, or `pipx install
"git+https://github.com/andriyze/shakerscan@<commit>#subdirectory=client"`) also records the commit
it was built from: `shakerscan version` prints `shakerscan client 0.12.0 (source 1a2b3c4d5e6f)`,
`doctor` shows the same, and the MCP `serverInfo.version` is `client-0.12.0+1a2b3c4d5e6f`.

The client has its own version (`client/src/shakerscan/__init__.py`), tagged `client-vX.Y.Z`,
independent of the engine release: because tool catalogues come from the live contracts, one
client works against any engine version that serves them. A tag runs `publish-client.yml`: build
the sdist and wheel, run the client tests, smoke the wheel (and a wheel rebuilt from the sdist) in
a clean environment and again on Python 3.10 and 3.11 (every module it carries compiled, the MCP
adapter started), publish to PyPI through trusted publishing (no stored token; the `pypi`
environment), attach the files to a GitHub release (never marked as the repository's "latest",
which stays the engine's), and render the Homebrew formula
(`client/homebrew/`), pushing it to the `andriyze/homebrew-shakerscan` tap with that repository's
write deploy key (the `HOMEBREW_TAP_DEPLOY_KEY` secret) and attaching it as an artifact otherwise.
Only a pushed `client-v*` tag publishes, and only when the tag sits on `main` and names the
client's own version: PyPI, the GitHub release and the formula all wait on those two checks. A
manual run of the workflow, from any branch or tag, builds and tests the client and publishes
nothing.
