Skip to content

DX-2998: give the CLI parity with the SDK - #234

Merged
CahidArda merged 7 commits into
mainfrom
DX-2998
Sep 4, 2026
Merged

CahidArda merged 7 commits into
mainfrom
DX-2998

Conversation

@alitariksahin

Copy link
Copy Markdown
Collaborator

Gives the CLI parity with the SDK, so an agent driving a terminal can reach everything a program can. This is what the Box skill will document, and today it has to tell agents to fall back to the SDK for a third of what a box does.

71 of 72 SDK capabilities now have a non-interactive command, checked by script rather than by eye.

What's new

box browser open|goto|tabs|content|screenshot|act|observe|extract|live-url|close|cdp-url
box browser recordings start|stop|list|get|download
box schedule exec|agent|list|get|update|pause|resume|delete
box skills add|remove|list
box config model|harness|network|init-command get|set|delete
box code <src> --lang js|ts|python
box status runs|logs        box cancel <run-id>
box snapshot list|delete    box from-snapshot --no-repl
box resume

The browser is the one part of a box with no shell fallback. Everything else degrades to box exec (no files read? use cat), but Chromium is driven through the coordinator rather than from inside the container, so nothing running in the box can reach it. Same for schedules: they are registered on the coordinator, so there was no workaround at all.

from-snapshot --no-repl is small but load-bearing. Restore always opened a REPL, so a script could take a snapshot and never restore one, which left snapshot list/delete write-only.

Decisions worth a look

  • --tab is optional with one tab open and required with several. Acting on the wrong page is worse than asking which one.
  • screenshot and recordings download require -o. stdout carries text a caller may pipe; image and video bytes would corrupt it.
  • extract takes a flat JSON Schema file and refuses nested schemas rather than dropping fields. A Zod schema cannot cross a command line, and a dropped field returns as a missing key, which reads as "the page did not have it".
  • config network rejects --allow-domain alongside allow-all, which would otherwise read as narrowing access while doing nothing.
  • schedule update sends only the fields named, since a partial update that also sent the command would clear it.

SDK change

Box.cancelRun(runId) in both SDKs (cancel_run in Python, with the PARITY row, generated sync client, unit test and changelog entry). Run.cancel() only works while holding the object the original call returned, which a separate process never is, so an agent that started a long run had no way to stop it.

The Python bump also closes a pre-existing drift: pyproject.toml moved to 0.3.1 in the git-namespace release while __version__ stayed at 0.3.0.

Deliberately not included

  • exec.session is a live WebSocket with on/send/close; a one-shot command has nowhere to hold it. This is the one gap, and it belongs to the REPL.
  • preview — getPreviewUrl and listPreviews are deprecated aliases that call the public-URL methods, so box public-url is already the same feature.
  • git create-issue — handled separately.

Testing

470 CLI tests (up from 416 on main) and 431 SDK tests. Mutation-tested the guards that matter: the multi-tab refusal, the tab projection, the headless restore, both timeout conversions, and box code's exit status all fail their tests when reverted.

Two bugs found in review and fixed here: both new --timeout flags passed seconds into millisecond options, so --timeout 30 meant 30ms; and box code always reported exit_code: 0, making a failing snippet look successful to && and to --json. box run had the only correct copy of that conversion, so it is now a shared helper all three use.

Follow-up: the skills repo needs a pass to document this surface, alongside the existing box list / box use / --keep-alive omissions.

Pass one of the SDK-to-CLI gap: the things an agent cannot do any other way.

box browser open|tabs|content|screenshot|act|close|cdp-url. The browser is the
only part of a box with no shell fallback. Everything else degrades to box exec
(no files read, use cat), but Chromium is driven through the coordinator rather
than from inside the container, so there is no command in the box that reaches
it. --tab is optional while one tab is open and required once there are
several: acting on the wrong page is worse than asking which one. screenshot
writes to --out instead of stdout, because stdout carries text a caller may
pipe and PNG bytes would corrupt it.

box status runs and box status logs. A run id was previously unobtainable from
the CLI, so a failed run could be seen but not investigated, and nothing could
be cancelled by id.

box cancel <run-id>, with Box.cancelRun() added to the SDK. Run.cancel() only
works while holding the object the call returned, which a separate process
never is, so an agent that started a long run had no way to stop it.

box snapshot list and box snapshot delete, and box from-snapshot --no-repl.
The flag is the load-bearing part: restore always opened a REPL, so a script
could create a snapshot and never restore one, which left listing and deleting
them write-only. Same headless rule as box create, an explicit flag, --json, or
no terminal on either stream.

Left for later: schedule, skills, configure-model, network policy, and the
code/session APIs. Provisioning a browser already exists as box create
--browser; only driving it was missing.
Everything a program can do to a box, a terminal can now do too, so the skill
can document one surface instead of telling agents to fall back to the SDK.

box schedule exec|agent|list|get|update|pause|resume|delete. Cron on a box was
reachable only from the SDK and the console, and nothing inside the container
can register one, so there was no workaround. update sends only the fields
named: a partial update that also sent the command would clear it.

box skills add|remove|list, and box config model|harness|network|init-command.
The network policy modes are exclusive, so a list passed with allow-all or
deny-all is refused rather than accepted and ignored, which would read as a
policy that narrowed access while doing nothing.

box code <source> --lang js|ts|python, with - reading stdin so a program does
not have to survive the shell's quoting.

The rest of the browser: goto, observe, extract, live-url, and recordings
start|stop|list|get|download. extract takes a flat JSON Schema file, because a
Zod schema cannot travel through a command line, and refuses nested schemas
instead of dropping fields, since a dropped field returns as a missing key
rather than as an error.

box resume is included for completeness even though every other command
resumes a paused box on its own.

Two deliberate omissions. exec.session is a live WebSocket with on/send/close,
and a one-shot command has nowhere to hold it. getPreviewUrl and listPreviews
are deprecated aliases that call the public-URL methods, so box public-url is
already the same feature.
…rity

Two new flags said seconds and passed the number to an SDK option measured in
milliseconds, so `box code --timeout 30` killed the process after 30ms and
`box schedule agent --timeout 60` was no better. `box run` already converted and
clamped; that logic is now a shared timeoutMs helper the three of them use, and
both call sites assert the SDK receives 30000 and 60000.

box code reported exit_code: 0 whatever happened. exec.code returns a Run with
the real exitCode and stderr, so a failing snippet looked successful to `&&` and
to --json. It now mirrors box exec: stdout to stdout, stderr to stderr, the
remote status passed through, and 125 kept for failures of the CLI itself.

cancelRun took listRuns' JSDoc with it when it was inserted above the method.
Moved back.

The Python SDK had no cancel_run, so check_parity.py would have failed on the
next run that saw this client.ts. Added to the async client, regenerated the
sync client, with the PARITY row and a unit test. It versions separately from
the npm packages, so it is not in the changeset.

status logs --limit nope parsed to NaN and was forwarded into the query string,
where it is ignored, so the caller silently got a default page. Both paging
flags now have to be whole numbers.
… Python changelog

box run kept its own copy of the seconds-to-milliseconds conversion and the
timer clamp, which is how the two new flags came to miss it. It now calls
timeoutMs, so the next flag that takes a timeout inherits both. Its existing
tests already assert 30000 and the out-of-range rejection, and they fail when
the helper is broken, so the refactor is covered by tests written before it.

AGENTS.md asks for a CHANGELOG entry alongside the PARITY row and the unit
test, and cancel_run had none. Added, with the version bump RELEASE.md asks for
in the same change.

That bump also closes a drift: pyproject.toml went to 0.3.1 in the git-namespace
release while __version__ stayed at 0.3.0, so the package has been reporting a
version it was not. Both are 0.3.2 now.
@linear-code

linear-code Bot commented Sep 4, 2026

Copy link
Copy Markdown

DX-2998

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Moderate issues remain in schema validation, browser output, snapshot restoration, agent webhooks, and log-limit handling.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Expands the non-interactive CLI toward SDK parity across browser automation, schedules, configuration, runs, snapshots, and code execution, while adding run cancellation to both SDKs.

Changes:

  • Adds browser, recording, schedule, configuration, run, snapshot, and code commands.
  • Adds run cancellation to the JavaScript and Python SDKs.
  • Adds tests, parity documentation, version updates, and release notes.
File summaries
File Description
packages/sdk/src/client.ts Adds run cancellation by ID.
packages/python-sdk/upstash_box/_version.py Updates the package version.
packages/python-sdk/upstash_box/_sync/client.py Adds synchronous cancellation.
packages/python-sdk/upstash_box/_async/client.py Adds asynchronous cancellation.
packages/python-sdk/tests/_async/test_box_misc.py Tests asynchronous cancellation.
packages/python-sdk/pyproject.toml Updates the published version.
packages/python-sdk/PARITY.md Records cancellation parity.
packages/python-sdk/CHANGELOG.md Documents the Python release.
packages/cli/src/core/io.ts Centralizes timeout conversion.
packages/cli/src/commands/status.ts Adds runs, logs, and cancellation commands.
packages/cli/src/commands/snapshot.ts Adds snapshot listing and deletion.
packages/cli/src/commands/schedule.ts Implements schedule management.
packages/cli/src/commands/run.ts Uses shared timeout conversion.
packages/cli/src/commands/from-snapshot.ts Supports headless restoration.
packages/cli/src/commands/exec.ts Adds inline code execution.
packages/cli/src/commands/config.ts Adds configuration, skills, and resume commands.
packages/cli/src/commands/browser.ts Adds browser and recording commands.
packages/cli/src/cli.ts Registers the expanded command surface.
packages/cli/src/__tests__/commands/schedule-config.test.ts Tests schedule and configuration behavior.
packages/cli/src/__tests__/commands/runs-and-snapshots.test.ts Tests run and snapshot commands.
packages/cli/src/__tests__/commands/from-snapshot.test.ts Tests headless restoration.
packages/cli/src/__tests__/commands/code-and-extract.test.ts Tests code execution and schema extraction.
packages/cli/src/__tests__/commands/browser.test.ts Tests core browser commands.
.changeset/cli-browser-runs-snapshots.md Describes published package changes.
Review details

Suppressed comments (4)

packages/cli/src/commands/browser.ts:200

  • JSON Schema properties are optional unless their names appear in the top-level required array, but every entry here becomes a required Zod field. A valid flat schema with an optional field is therefore sent to the API as requiring that field and can make extraction fail when it is absent. Parse required and mark all other property schemas with .optional().
  const shape: Record<string, z.ZodTypeAny> = {};
  for (const [key, prop] of Object.entries(parsed.properties)) {

packages/cli/src/commands/browser.ts:208

  • The integer JSON Schema type is converted to an unrestricted z.number(), so fractional extraction results pass validation despite violating the supplied schema. Preserve integer semantics with Zod's integer constraint.
      case "number":
      case "integer":
        shape[key] = z.number();
        break;

packages/cli/src/commands/browser.ts:263

  • The new recordings command surface has no CLI tests: browser.test.ts covers tabs, screenshots, actions, and CDP, but never calls any recording*Command. Add coverage for start option conversion/validation, stop/list/get output, and download path forwarding so this multi-command feature is exercised at the CLI boundary.
export async function recordingStartCommand(flags: BrowserFlags): Promise<void> {
  const box = await open(flags);
  const seconds = flags.maxSeconds === undefined ? undefined : Number(flags.maxSeconds);
  if (seconds !== undefined && (!Number.isFinite(seconds) || seconds <= 0)) {
    throw new CliError("--max-seconds must be a positive number");

packages/cli/src/commands/schedule.ts:127

  • ScheduleFlags exposes both timeout and webhookUrl, and the SDK's update contract supports both, but this payload silently drops them. In addition, the CLI does not register corresponding update options, so box schedule update cannot reach those SDK capabilities. Register the flags and forward only those explicitly supplied, taking care that timeout 0 is the SDK's clear operation.
  const changes = {
    ...(flags.cron === undefined ? {} : { cron: flags.cron }),
    ...(command.length === 0 ? {} : { command }),
    ...(flags.prompt === undefined ? {} : { prompt: flags.prompt }),
    ...(flags.folder === undefined ? {} : { folder: flags.folder }),
    ...(flags.model === undefined ? {} : { model: flags.model }),
  };
  • Files reviewed: 24/24 changed files
  • Comments generated: 6
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/cli/src/commands/browser.ts Outdated
Comment thread packages/cli/src/commands/browser.ts
Comment thread packages/cli/src/commands/from-snapshot.ts Outdated
Comment thread packages/cli/src/commands/schedule.ts
Comment thread packages/cli/src/commands/status.ts Outdated
Comment thread packages/sdk/src/client.ts Outdated
Six findings, all real.

box browser content advertised links and printed none: only --json carried a
destination URL, so the default output did not match what the command says it
reads.

A schema file holding JSON null parsed successfully and then threw a raw
TypeError on the property read, past the validation meant to explain the
problem.

box from-snapshot lost the box id when the working directory could not be
written. The box exists and is billing by that point, so the id is the one
thing that must survive; headless create already catches this and reports it.

Agent schedules accept webhookUrl in the SDK and the flag was neither
registered nor forwarded, so only exec schedules could call back. Same for
update.

--limit 0 was accepted here and then dropped by the SDK, which only sends the
limit when it is truthy, so the caller silently got a full default page. Limit
now has to be at least 1; offset still allows 0.

cancelRun had no SDK-level test. The CLI tests mock the SDK, so nothing pinned
the method or the path and a typo would only have surfaced against a live box.
…t method

Reverts Box.cancelRun() and its Python port, so packages/sdk and
packages/python-sdk are byte-identical to main and this is a CLI-only change.

box cancel now calls box._request("POST", "/v2/box/:id/runs/:runId/cancel").
That is the same method Tab already uses across class boundaries, so the base
URL, auth headers, timeout and error handling stay in the SDK instead of being
rebuilt in the CLI, which is what a direct fetch would have required.

The cost is that nothing typechecks the path any more: with no cancel-by-id on
Box, a typo would only surface against a live box. The command test now asserts
the method and the path for that reason.

The changeset drops @upstash/box, leaving only @upstash/box-cli.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Multiple moderate SDK-parity, validation, schema-handling, and release-metadata issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (5)

Previously missed (3) — in code that hasn't changed since the last review.

.changeset/cli-browser-runs-snapshots.md:3

  • The PR description says the Python version drift is fixed, but the reviewed tree still has pyproject.toml at 0.3.1 and upstash_box/_version.py at 0.3.0, while this changeset only releases the CLI. Include the promised Python version correction and release metadata.
    packages/cli/src/commands/browser.ts:284
  • The SDK contract caps recordings at 600 seconds, but this validation accepts larger values and forwards an invalid request to the API. Reject values above 600 locally so callers get a deterministic CLI error.
    packages/cli/src/commands/status.ts:141
  • The PR description promises public Box.cancelRun() / cancel_run() APIs, but this code still bypasses the SDK through _request, and neither SDK currently defines those methods. Add the public methods (including the generated Python sync client, parity row, tests, and changelog) and call box.cancelRun(runId) here so the advertised SDK capability is actually delivered.

packages/cli/src/cli.ts:175

  • The new command tree is only tested by calling handler functions directly; no added integration test invokes the built CLI to verify Commander parses nested verbs, repeatable options, -- forwarding, and global/local flag merging. Per the repository testing requirement, add process-boundary integration coverage for representative new command families (especially status/snapshot subcommands and schedule exec -- ...).
// `box status` keeps its own action; runs and logs hang off it as subcommands.
const statusGroup = program.commands.find((command) => command.name() === "status")!;
statusGroup
  .command("runs")

packages/cli/src/commands/browser.ts:228

  • An integer schema is silently widened to number, so the API receives a number schema and client-side validation accepts fractional values. Keep the integer constraint in the generated Zod schema.
      case "number":
      case "integer":
        shape[key] = z.number();
        break;
  • Files reviewed: 16/16 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread packages/cli/src/cli.ts
Comment thread packages/cli/src/commands/browser.ts
Comment thread packages/cli/src/commands/schedule.ts
…change the timeout

Two more from Copilot, both real.

schemaFromFile marked every declared property required, so extract threw on a
page that was simply missing an optional field. tab.extract() ends in
schema.parse(), which means that failure is client-side and looks like the
extraction failing rather than the schema being wrong. JSON Schema treats a
property as optional unless it is named in `required`, so the converter follows
that and wraps the rest in .optional().

schedule update could not touch the timeout, although UpdateScheduleOptions
takes one and creation already exposed --timeout. Added, with the same
seconds-to-milliseconds conversion, plus an allowZero option on the helper so
0 survives as the SDK's way of clearing the field instead of being rejected
with every other non-positive value.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Five moderate issues remain in schema validation, recording limits, timeout handling, headless output, and the missing public cancellation SDK API.

Review details

Suppressed comments (5)

Previously missed (5) — in code that hasn't changed since the last review.

packages/cli/src/commands/browser.ts:226

  • A malformed property definition such as {"properties":{"title":null}} passes the top-level checks and then throws a raw TypeError when prop.type is read. Since this file is untrusted CLI input, validate each property before dereferencing it so callers receive a CliError like the other unsupported schemas.
    packages/cli/src/commands/browser.ts:290
  • The SDK contract caps recordings at 600 seconds, but this validation accepts larger values and forwards them to the endpoint. Reject values above 600 locally so --max-seconds 601 produces an actionable CLI error rather than an invalid recording request.
    packages/cli/src/commands/exec.ts:89
  • For timeouts above 600 seconds, the backend receives the requested value but the SDK aborts this synchronous request at its default 600,000 ms (packages/sdk/src/client.ts:1747 does not override _request's timeout). Thus box code ... --timeout 900 fails roughly five minutes earlier than requested. Propagate the execution timeout plus a small request grace period through the SDK request or configure this Box request accordingly.
    packages/cli/src/commands/from-snapshot.ts:126
  • Headless restore no longer leaves stdout as a reusable box ID when pinning succeeds: the default path prints both the ID and Pinned to ..., so ID=$(box from-snapshot --no-repl ...) captures a multi-line invalid ID. Keep the pin notice on stderr via note, matching commands/create.ts:224-225.
    packages/cli/src/commands/status.ts:141
  • The PR description promises a public Box.cancelRun(runId)/cancel_run SDK API, but the repository still exposes only Run.cancel() and this command calls _request directly. This leaves the advertised SDK parity work, Python parity row/generated sync client/test/changelog, and version drift fix absent, while coupling the CLI to an internal method. Add the public APIs in both SDKs and call box.cancelRun(runId) here.
  • Files reviewed: 16/16 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

@CahidArda
CahidArda merged commit 5a0dff9 into main Sep 4, 2026
3 of 4 checks passed
@CahidArda
CahidArda deleted the DX-2998 branch September 4, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants