[STG-2275] feat(cli): default screenshot to file output (--base64 for legacy stdout)
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 screenshotnow prints{ "saved": "<path>" }instead of{ "base64": "..." }. Migration: pass--base64to 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 tosrc/commands/screenshot.ts. - Default filename respects
--type(.jpegvs.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 onEEXIST— 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. --base64is mutually exclusive with--pathvia oclif'sexclusiveoption.- Changeset:
browseminor (per review) — the bare-invocation stdout contract change is called out in the changeset body with the--base64migration note. - Bundled skill doc:
skills/browse/SKILL.mdscreenshot snippet updated in this PR (moved from #2245 per review) to document the new default: bare saves a file,--pathchooses it,--base64is 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 / flow | Observed output | Confidence / sufficiency |
|---|---|---|
node bin/run.js open https://example.com | { "title": "Example Domain", "url": "https://example.com/", ... }, exit 0 | Session 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 only | Proves 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 0 | Proves 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 bytes | Proves 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 file | Proves failed runs do not leave empty screenshot-*.png placeholders behind. |
node bin/run.js screenshot --base64 --path /tmp/x.png | Error: --path=/tmp/x.png cannot also be provided when using --base64, exit 2 | Proves the oclif mutual exclusion works. |
node bin/run.js stop | { "stopped": true, "session": "default" }, exit 0 | Clean session teardown. |
pnpm test (packages/cli) | Test Files 15 passed (15), Tests 213 passed (213) — re-run after the race fix | Supporting: no regressions in the existing CLI suite (includes the screenshot --help surface test). |
pnpm lint (packages/cli) | tsc/eslint/prettier clean | Supporting only. |
🤖 Generated with Claude Code