Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Publish to PyPI & Homebrew
name: Publish to PyPI, npm & Homebrew

on:
release:
Expand Down Expand Up @@ -90,3 +90,26 @@ jobs:
"url": "${{ steps.pypi.outputs.url }}",
"sha256": "${{ steps.pypi.outputs.sha256 }}"
}

publish-npm:
# npm launcher (npm/): Trusted Publishing via OIDC, no long-lived token.
# Runs after PyPI so `npx quicksilverpro` never points at an unpublished version.
needs: publish
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Ensure npm supports trusted publishing
run: npm install -g npm@latest
- name: Set launcher version from the release tag
working-directory: npm
run: npm version "${GITHUB_REF_NAME#v}" --no-git-tag-version --allow-same-version
- name: Publish
working-directory: npm
run: npm publish --access public
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ See [quicksilverpro.io/dashboard#quickstart](https://quicksilverpro.io/dashboard

## QuickSilver boxes and agent setup (beta)

The 0.5.0 client adds remote Linux boxes with QSP inference preinstalled. The
The 0.5.2 client adds remote Linux boxes with QSP inference preinstalled. The
Machines backend and approval page must be enabled for your account; endpoint
routing and backend integration are release gates. This checkout can be installed
with `pip install -e '.[mcp,shell]'` (Python 3.10+ for MCP). After release, bootstrap
Expand Down Expand Up @@ -154,7 +154,7 @@ No-TTY setup needs an existing Machines key and explicit local-write choices;
`--yes` does not grant browser authorization, sharing or billing consent. Setup
creates no box by default. Optional `--create NAME` requires a separate interactive
confirmation after displaying effective prices/limits. If uv is unavailable,
install persistently with pipx (`pipx install 'quicksilverpro[mcp,shell]==0.5.0'`)
install persistently with pipx (`pipx install 'quicksilverpro[mcp,shell]==0.5.2'`)
or pip, then pass `--no-install-cli`. Skills and MCP configs reference the absolute
persistent executable for GUI PATH compatibility. Use `--store-key` to explicitly
persist an environment-supplied Machines key for GUI agents; otherwise those
Expand All @@ -181,12 +181,14 @@ unchanged owned assets/entries, preserving unrelated settings and edits.
remote key revocation remains a browser action.

```bash
qsp box ls # compact table plus account summary
qsp box ls --json # inspect existing boxes, prices and effective limits
# Obtain user consent before billable creation/resume.
qsp box create --name work --wait --json # prompts for billable creation approval
# For automation, use --accept-spend only after explicit user approval.
qsp box get MID --json
qsp box wait MID --timeout 180 --json
qsp box exec MID -- python3 --version
qsp box exec MID --argv-json '["python3","--version"]' --json
qsp box exec MID --command 'printf hello' --timeout 30 --stream --json
qsp box cp ./app.py MID:app.py --json
Expand All @@ -205,18 +207,28 @@ Use real 12-character hexadecimal machine IDs in place of `MID`. Wait requires
Create reports its request ID before sending and stores it across lost responses.
Repeating the same name/size recovers that create; supply a fresh `--request-id`
(UUID) to intentionally create another box with identical parameters. Mutations
and execs are never automatically retried. Unknown-outcome execs must be inspected
are never automatically retried. Exec retries an explicit HTTP 409
"Machine is not ready." or "Guest runner is not ready." rejection before execution
with bounded backoff for up to 30 seconds. Generic conflicts, transport failures and
unknown outcomes are never retried. Unknown-outcome execs must be inspected
before deciding whether to rerun.

Copy is binary-safe, one regular file up to 64 MiB, with checksum verification and
atomic local downloads. Paths stay inside `/home/user`; `--overwrite` is explicit.
atomic local downloads. Oversized uploads fail locally before requesting a token.
Transfers use scoped host/token grants; downloads verify `Content-Length` and
`X-Checksum-SHA256`. Paths stay inside `/home/user`; `--overwrite` is explicit.
Recursive and box-to-box copy are deferred. Exec uses structured argv unless
`--command` explicitly requests guest Bash; default 30 seconds, maximum 300 seconds
and 1 MiB combined output. Guest stdout/stderr remain separate in human mode.
`exec MID -- cmd args...`
preserves argv literally; put CLI options before `--`. Choose exactly one of this
form, `--argv-json`, or `--command`.

Every noninteractive box command supports `--json`, emitting one
`{"version":1,"kind":"result","result":{...}}` object. Errors use
`kind:"cli_error"` and a structured `error`; guest exits use `kind:"guest_exit"`.
`kind:"cli_error"` and a structured `error`, including the server message (with
credentials redacted); human errors print the same message. Guest exits use
`kind:"guest_exit"`.
`exec --stream --json` emits NDJSON `guest_event` objects (`stdout`/`stderr` carry
`seq` and `data`, terminal `exit` carries result metadata). Missing terminal evidence
is an unknown outcome. Diagnostics go to stderr; file JSON contains metadata only.
Expand Down
2 changes: 1 addition & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "quicksilverpro",
"version": "0.5.1",
"version": "0.5.2",
"description": "QuickSilver Pro CLI (qsp): one API for frontier models, and cloud dev boxes your agents can run. Thin npm launcher for the Python CLI.",
"bin": {
"quicksilverpro": "bin/qsp.js",
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "quicksilverpro"
version = "0.5.1"
version = "0.5.2"
description = "CLI for QuickSilver Pro — one OpenAI-compatible API for Claude, GPT, Gemini, Grok, GLM, DeepSeek, Qwen, Kimi and Muse chat plus FLUX image generation, on a single balance."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion src/quicksilverpro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
See https://quicksilverpro.io for docs. `qsp --help` for quick reference.
"""

__version__ = "0.5.1"
__version__ = "0.5.2"
92 changes: 69 additions & 23 deletions src/quicksilverpro/box.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ def emit(result, as_json=False, kind="result"):

class BoxGroup(click.Group):
def parse_args(self, ctx, args):
ctx.meta["box_json"] = "--json" in args
ctx.meta["box_stream"] = "--stream" in args
options = args[: args.index("--")] if "--" in args else args
ctx.meta["box_json"] = "--json" in options
ctx.meta["box_stream"] = "--stream" in options
return super().parse_args(ctx, args)

def invoke(self, ctx):
Expand All @@ -42,7 +43,10 @@ def invoke(self, ctx):
if isinstance(error, MachineError):
code, payload = error.exit_code, error.payload()
elif isinstance(error, click.UsageError):
code, payload = 2, {"code": "usage_error", "message": error.format_message()}
code, payload = (
2,
{"code": "usage_error", "message": error.format_message()},
)
elif isinstance(error, (KeyboardInterrupt, click.Abort)):
code, payload = (
130,
Expand All @@ -54,7 +58,10 @@ def invoke(self, ctx):
else:
code, payload = (
1,
{"code": "local_error", "message": "Local file or terminal operation failed."},
{
"code": "local_error",
"message": "Local file or terminal operation failed.",
},
)
if ctx.meta.get("box_json"):
data = envelope("cli_error", error=payload)
Expand All @@ -67,7 +74,8 @@ def invoke(self, ctx):


@click.group(
cls=BoxGroup, help="Control QuickSilver boxes with a separate Machines key (beta API)."
cls=BoxGroup,
help="Control QuickSilver boxes with a separate Machines key (beta API).",
)
def box():
pass
Expand All @@ -93,33 +101,62 @@ def confirm_spend(client, operation, accept_spend):
f"Billable {operation} requires interactive approval or --accept-spend.",
2,
)
prompt = "Create a billable box" if operation == "creation" else "Resume billing for this box"
prompt = (
"Create a billable box"
if operation == "creation"
else "Resume billing for this box"
)
if not click.confirm(
prompt + " under the displayed prices and limits?", default=False, err=True
):
raise MachineError("consent_required", f"Billable {operation} was declined.", 2)
raise MachineError(
"consent_required", f"Billable {operation} was declined.", 2
)


@box.command("ls")
@box.command("ls", help="List boxes and account usage.")
@json_option
def listing(as_json):
with MachinesClient() as client:
emit(client.list(), as_json)
result = client.list()
if as_json:
emit(result, True)
return
rows = [["ID", "NAME", "STATE", "SIZE", "HOURS THIS MONTH"]]
for machine in result.get("machines", []):
rows.append(
[str(machine.get(k, "-")) for k in ("id", "name", "state", "size")]
+ [f"{machine.get('hours_month', 0):.2f}"]
)
widths = [max(len(row[i]) for row in rows) for i in range(5)]
for row in rows:
click.echo(
" ".join(
value.ljust(width) for value, width in zip(row, widths)
).rstrip()
)
hours = result.get("monthly_hours", {})
click.echo(
f"Account: {result.get('plan', '-')} | balance ${result.get('available_balance', 0):.2f} | "
f"boxes {len(rows) - 1}/{result.get('limit', '-')} | "
f"monthly hours {hours.get('used', '-')}/{hours.get('cap', '-')}"
)


@box.command()
@box.command(help="Show box details.")
@click.argument("mid")
@json_option
def get(mid, as_json):
with MachinesClient() as client:
emit(client.get(mid), as_json)


@box.command()
@box.command(help="Create a billable box.")
@click.option("--name", required=True)
@click.option("--size", type=click.Choice(["2", "4"]), default="2", show_default=True)
@click.option(
"--request-id", help="Reuse for recovery; use a fresh ID for a new box with the same name/size."
"--request-id",
help="Reuse for recovery; use a fresh ID for a new box with the same name/size.",
)
@click.option("--wait", "do_wait", is_flag=True)
@click.option("--timeout", type=click.IntRange(1, 180), default=180)
Expand Down Expand Up @@ -152,7 +189,7 @@ def create(name, size, request_id, do_wait, timeout, accept_spend, as_json):
emit(result, as_json)


@box.command()
@box.command(help="Wait for a box to become ready.")
@click.argument("mid")
@click.option("--timeout", type=click.IntRange(1, 180), default=180)
@json_option
Expand All @@ -161,21 +198,28 @@ def wait(mid, timeout, as_json):
emit(client.wait(mid, timeout), as_json)


@box.command("exec")
@box.command("exec", help="Execute argv or an explicit shell command in a box.")
@click.argument("mid")
@click.argument("guest_argv", nargs=-1, type=click.UNPROCESSED)
@click.option("--argv-json")
@click.option("--command", help="Explicitly run guest /bin/bash -lc.")
@click.option("--cwd")
@click.option("--timeout", type=click.IntRange(1, 300), default=30)
@click.option("--stream", is_flag=True)
@json_option
def execute(mid, argv_json, command, cwd, timeout, stream, as_json):
def execute(mid, guest_argv, argv_json, command, cwd, timeout, stream, as_json):
try:
argv = json.loads(argv_json) if argv_json is not None else None
except ValueError:
raise click.UsageError("--argv-json must be valid JSON.")
if argv_json is not None and argv is None:
raise click.UsageError("--argv-json must be an array.")
if guest_argv:
if argv_json is not None or command is not None:
raise click.UsageError(
"Choose argv after --, --argv-json, or --command, exclusively."
)
argv = list(guest_argv)
with MachinesClient() as client:
args = dict(argv=argv, command=command, cwd=cwd, timeout_seconds=timeout)
if stream:
Expand All @@ -200,7 +244,7 @@ def execute(mid, argv_json, command, cwd, timeout, stream, as_json):
click.get_current_context().exit(exec_exit(result))


@box.command()
@box.command(help="Copy one file between local disk and a box.")
@click.argument("source")
@click.argument("destination")
@click.option("--overwrite", is_flag=True)
Expand Down Expand Up @@ -252,7 +296,7 @@ def remote(value):
emit(result, as_json)


@box.command()
@box.command(help="Get a desktop or terminal link.")
@click.argument("mid")
@click.option("--view", type=click.Choice(["desktop", "terminal"]), default="desktop")
@click.option("--share", is_flag=True, help="Explicitly mint a read-only bearer share link.")
Expand All @@ -270,15 +314,15 @@ def open(mid, view, share, browser, expires_in_seconds, as_json):
click.echo(result["url"])


@box.command()
@box.command(help="Pause a box.")
@click.argument("mid")
@json_option
def pause(mid, as_json):
with MachinesClient() as client:
emit(client.action(mid, "pause"), as_json)


@box.command()
@box.command(help="Resume a billable box.")
@click.argument("mid")
@click.option("--wait", "do_wait", is_flag=True)
@click.option("--timeout", type=click.IntRange(1, 180), default=180)
Expand All @@ -296,7 +340,7 @@ def resume(mid, do_wait, timeout, accept_spend, as_json):
emit(client.wait(mid, timeout) if do_wait else result, as_json)


@box.command()
@box.command(help="Permanently delete a box.")
@click.argument("mid")
@click.option("--yes", is_flag=True)
@click.option("--wait", "do_wait", is_flag=True)
Expand All @@ -314,7 +358,7 @@ def rm(mid, yes, do_wait, timeout, as_json):
emit(client.wait(mid, timeout, deleted=True) if do_wait else result, as_json)


@box.command()
@box.command(help="Open an interactive terminal.")
@click.argument("mid")
@json_option
def shell(mid, as_json):
Expand All @@ -326,8 +370,10 @@ def shell(mid, as_json):
bridge(client.grant(mid, "terminal")["url"])


@box.command()
@click.option("--mid", help="Also check an existing box's readiness without changing its state.")
@box.command(help="Check Machines access and local setup.")
@click.option(
"--mid", help="Also check an existing box's readiness without changing its state."
)
@json_option
def doctor(mid, as_json):
from .setup import discovery
Expand Down
Loading