[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)

Merged
SH
4mo ago
+102-17

Summary

Bare browse screenshot now writes a file by default — screenshot-<yyyymmdd-hhmmss>.<type> in the current directory, never overwriting (atomic collision counter) — and prints the small { "saved": "<path>" } JSON. A new --base64 flag preserves the legacy stdout contract ({ "base64": "..." }); it is mutually exclusive with --path. Explicit --path behavior is unchanged.

Linear: STG-2275

Impact if merged

screenshot is one of the two commands in the runaway agent retry loops (the loop population generates 92.3% of all CLI telemetry) and is used by 1,337 users/30d (89.9% success last-7d). Every bare invocation today prints ~22KB of base64 JSON directly into the calling agent's context window — a per-call token tax on exactly the ICP population (claude-code/codex agents driving the CLI; agent-tagged usage is 90%-successful and growing). Defaulting to a file write makes the common case agent-safe at zero cost to --path users; --base64 preserves the old contract for scripts. Stdout contract change for bare invocations is called out in the changeset with a --base64 migration note (minor bump — the bare-invocation stdout contract changes; browse is <1.0.0 so this is advisory).

Implementation notes

  • Breaking change (stdout contract): bare browse screenshot now prints { "saved": "<path>" } instead of { "base64": "..." }. Migration: pass --base64 to restore the old output.
  • Command-layer only: the driver handler (runtime.ts) already supported both branches ({ saved } when a path is given, { base64 } otherwise), so the change is confined to src/commands/screenshot.ts.
  • Default filename respects --type (.jpeg vs .png) and resolves against the invoking shell's cwd (absolute path passed to the driver so a daemon with a different cwd can't misplace the file).
  • The default filename is atomically reserved via exclusive create (openSync(path, "wx")), advancing a -2, -3, ... counter on EEXIST — concurrent same-second invocations can never claim the same file (addresses cubic's race-condition review). If the command fails afterward, the empty placeholder is removed best-effort.
  • --base64 is mutually exclusive with --path via oclif's exclusive option.
  • Changeset: browse minor (per review) — the bare-invocation stdout contract change is called out in the changeset body with the --base64 migration note.
  • Bundled skill doc: skills/browse/SKILL.md screenshot snippet updated in this PR (moved from #2245 per review) to document the new default: bare saves a file, --path chooses it, --base64 is the legacy stdout form.

E2E Test Matrix

All rows ran against the local build (pnpm build in packages/cli, invoked as node bin/run.js) from a <scratch dir>.

Command / flowObserved outputConfidence / sufficiency
node bin/run.js open https://example.com{ "title": "Example Domain", "url": "https://example.com/", ... }, exit 0Session setup for the runs below; proves the local build is functional end-to-end.
node bin/run.js screenshot (bare){ "saved": "<scratch dir>/screenshot-20260612-123732.png" }, exit 0; ls -la shows the file at 15,988 bytes; stdout is the small JSON onlyProves the new default: file written to cwd with timestamped name, no base64 on stdout.
node bin/run.js screenshot --base64 | head -c 200{ "base64": "iVBORw0KGgoAAAANSUhEUgAABQgAAALH..." (PNG magic in base64), exit 0Proves the legacy stdout contract is fully preserved behind --base64.
node bin/run.js screenshot --path /tmp/custom.png{ "saved": "/tmp/custom.png" }, exit 0; file exists at 15,238 bytesProves explicit --path behavior is unchanged.
Two concurrent bare runs (same second, shell & + wait){ "saved": ".../screenshot-20260612-123741.png" } and { "saved": ".../screenshot-20260612-123741-2.png" }; both files present and non-empty (4,202 bytes each)Proves the atomic no-overwrite reservation under true same-second concurrency — the exact race cubic flagged.
node bin/run.js screenshot --cdp http://127.0.0.1:1 (forced failure)TypeError: fetch failed, nonzero exit; directory left empty — no placeholder fileProves failed runs do not leave empty screenshot-*.png placeholders behind.
node bin/run.js screenshot --base64 --path /tmp/x.pngError: --path=/tmp/x.png cannot also be provided when using --base64, exit 2Proves the oclif mutual exclusion works.
node bin/run.js stop{ "stopped": true, "session": "default" }, exit 0Clean session teardown.
pnpm test (packages/cli)Test Files 15 passed (15), Tests 213 passed (213) — re-run after the race fixSupporting: no regressions in the existing CLI suite (includes the screenshot --help surface test).
pnpm lint (packages/cli)tsc/eslint/prettier cleanSupporting only.

🤖 Generated with Claude Code