From c2d115806840fff5f256e5d9814c1896173bbb5c Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 6 Aug 2026 09:34:16 -0700 Subject: [PATCH 1/2] ci: add cargo publishing Signed-off-by: cpendery --- .github/scripts/release/publish-crate.mjs | 95 +++++++++++++++++++++++ .github/workflows/release.yml | 42 ++++++++++ README.md | 47 +++++++++-- SKILL.md | 83 ++++++++++++++------ 4 files changed, 239 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/release/publish-crate.mjs diff --git a/.github/scripts/release/publish-crate.mjs b/.github/scripts/release/publish-crate.mjs new file mode 100644 index 0000000..42d157a --- /dev/null +++ b/.github/scripts/release/publish-crate.mjs @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +import { run } from "./utils.mjs"; + +const crateName = "shell-use"; +const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const usage = [ + "Usage: node .github/scripts/release/publish-crate.mjs [--dry-run]", + "", + "Publishing uses Cargo's configured crates.io credentials.", +].join("\n"); + +function readPackageVersion() { + const metadata = JSON.parse( + execFileSync( + "cargo", + ["metadata", "--locked", "--no-deps", "--format-version", "1"], + { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], + }, + ), + ); + const cratePackage = metadata.packages.find( + (candidate) => candidate.name === crateName, + ); + if (!cratePackage) { + throw new Error(`Could not find the ${crateName} package`); + } + return cratePackage.version; +} + +async function isPublished(version) { + const response = await fetch( + `https://crates.io/api/v1/crates/${encodeURIComponent(crateName)}/${encodeURIComponent(version)}`, + { + headers: { + accept: "application/json", + "user-agent": + "shell-use release script (https://github.com/microsoft/shell-use)", + }, + }, + ); + + if (response.status === 404) { + return false; + } + if (!response.ok) { + const details = (await response.text()).trim(); + throw new Error( + `Could not check crates.io for ${crateName}@${version}: ${response.status} ${response.statusText}${details ? `: ${details}` : ""}`, + ); + } + return true; +} + +async function main() { + const args = process.argv.slice(2); + if (args.includes("--help")) { + console.log(usage); + return; + } + + const unknownArgs = args.filter((argument) => argument !== "--dry-run"); + if (unknownArgs.length > 0) { + throw new Error(`${usage}\nUnknown argument: ${unknownArgs[0]}`); + } + + const dryRun = args.includes("--dry-run"); + const version = readPackageVersion(); + const releaseVersion = process.env.RELEASE_TAG?.replace(/^v/, ""); + if (releaseVersion && releaseVersion !== version) { + throw new Error( + `RELEASE_TAG has version ${releaseVersion}; ${crateName} has version ${version}`, + ); + } + + if (!dryRun && (await isPublished(version))) { + console.log(`${crateName}@${version} is already published`); + return; + } + + console.log( + `${dryRun ? "Verifying" : "Publishing"} ${crateName}@${version}`, + ); + const cargoArgs = ["publish", "--locked", "-p", crateName]; + if (dryRun) { + cargoArgs.push("--dry-run"); + } + run("cargo", cargoArgs, { cwd: repoRoot }); +} + +await main(); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0df645b..3049e5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,6 +29,10 @@ jobs: with: ref: ${{ env.RELEASE_TAG }} + - run: | + rustup toolchain install stable --profile default + rustup default stable + - uses: actions/setup-node@v4 with: node-version: "24" @@ -36,6 +40,9 @@ jobs: - name: Verify release versions run: node .github/scripts/release/verify-versions.mjs + - name: Verify shell-use crate package + run: node .github/scripts/release/publish-crate.mjs --dry-run + build-cli: needs: verify if: github.event_name == 'push' @@ -358,6 +365,41 @@ jobs: with: skip-existing: true + publish-crates: + needs: + - verify + - release + if: always() && needs.verify.result == 'success' && (needs.release.result == 'success' || github.event_name == 'workflow_dispatch') + runs-on: ubuntu-latest + environment: + name: crates-io + url: https://crates.io/crates/shell-use + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ env.RELEASE_TAG }} + + - run: | + rustup toolchain install stable --profile default + rustup default stable + + - uses: actions/setup-node@v4 + with: + node-version: "24" + + # Match this workflow and environment in the crate's trusted publisher settings. + - name: Authenticate with crates.io + id: auth + uses: rust-lang/crates-io-auth-action@v1 + + - name: Publish shell-use to crates.io + run: node .github/scripts/release/publish-crate.mjs + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + release: needs: - build-cli diff --git a/README.md b/README.md index 7a53063..22eeac1 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ shell-use wait exit - `shell-use agent-context` prints versioned JSON for every command, flag, enum, default, and exit code. It is generated from the cli, so it cannot drift from the real surface. - `shell-use usage` prints a one-screen cheatsheet. -- `shell-use skill` prints the full workflow guide ([SKILL.md](SKILL.md)). +- `shell-use skill` prints the full workflow guide ([SKILL.md](https://github.com/microsoft/shell-use/blob/main/SKILL.md)). ### Skill quick start @@ -81,9 +81,46 @@ Each command returns a stable exit code (see [Exit codes](#exit-codes)), so an a ## Programmatic usage -`shell-use` python & node client libraries that drive shell-use with the same commands as the cli. The clients manage the sessions for you without a daemon. +`shell-use` provides a Rust library plus Python and Node client libraries. These libraries manage in-process sessions without the cli daemon. -### Python ([`shell-use`](bindings/python/README.md)) +### Rust ([`shell-use`](https://crates.io/crates/shell-use)) + +```sh +cargo add shell-use +``` + +```rust +use shell_use::{OpenOptions, Operation, Session}; + +fn main() -> Result<(), Box> { + let session = Session::new(format!("rust-example-{}", std::process::id())); + session.open(OpenOptions::default())?; + session.execute(Operation::Submit { + data: Some("echo hello".into()), + })?; + session.execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + })?; + session.execute(Operation::ExpectText { + text: "hello".into(), + regex: false, + full: false, + strict: false, + not: false, + fg: None, + bg: None, + timeout_ms: Some(5_000), + })?; + session.execute(Operation::ExpectExitCode { + code: 0, + timeout_ms: Some(5_000), + })?; + session.close()?; + Ok(()) +} +``` + +### Python ([`shell-use`](https://github.com/microsoft/shell-use/blob/main/bindings/python/README.md)) ```sh pip install shell-use @@ -104,7 +141,7 @@ async def main(): asyncio.run(main()) ``` -### Node ([`@microsoft/shell-use`](bindings/js/README.md)) +### Node ([`@microsoft/shell-use`](https://github.com/microsoft/shell-use/blob/main/bindings/js/README.md)) ```sh npm install @microsoft/shell-use # Node 20+ @@ -280,7 +317,7 @@ re-fits the frame. | --------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `usage` | Compact command cheatsheet. | | `agent-context` | Versioned JSON describing every command, flag, enum, default, and the exit-code taxonomy (generated from the cli, so it can't drift). | -| `skill` | Long-form workflow guide ([SKILL.md](SKILL.md)). | +| `skill` | Long-form workflow guide ([SKILL.md](https://github.com/microsoft/shell-use/blob/main/SKILL.md)). | ### Exit codes diff --git a/SKILL.md b/SKILL.md index d673019..1893c68 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: shell-use -description: "Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use cli. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live cli session while an agent drives it; or driving process-local sessions from Python or Node with the shell-use bindings." +description: "Drive, inspect, assert on, record, and watch a real terminal from the command line with the shell-use cli. Use when running shells (bash, zsh, fish, PowerShell, pwsh, cmd, xonsh, elvish, nushell) or TUI programs (vim, less, top, etc.) in a headless PTY; sending keystrokes, key combos, or mouse input; resizing, writing raw bytes, or signaling the child; waiting for a command to finish or the screen to settle; asserting on terminal text, colors, exit codes, output, or snapshots; capturing text or full-color SVG screenshots; recording and replaying asciinema sessions; watching a live cli session while an agent drives it; or driving process-local sessions from Rust, Python, or Node." --- # shell-use @@ -228,28 +228,62 @@ the commands the agent runs; resizing the window re-fits the frame. This works only with standalone CLI sessions. -## Programmatic use (Python and JavaScript) +## Programmatic use (Rust, Python, and JavaScript) -The Python and JavaScript packages bind the Rust terminal engine directly and -run sessions in-process. Session names, registries, and recordings are -process-local. A native session cannot be listed, attached to, controlled, or -monitored from another process, including by the standalone CLI. +The Rust crate and the Python and JavaScript packages run the terminal engine +in-process. Session names, registries, and recordings are process-local. A +native session cannot be listed, attached to, controlled, or monitored from +another process, including by the standalone CLI. -Language packages do not install or require the `shell-use` CLI. Only the -standalone CLI uses the daemon and JSON-over-local-socket protocol described -elsewhere in this guide. +These programmatic APIs do not install or require the `shell-use` CLI. Only +the standalone CLI uses the daemon and JSON-over-local-socket protocol +described elsewhere in this guide. Node is the supported JavaScript runtime. Bun and Deno compatibility is best effort and does not gate releases. Deno requires a local `node_modules` directory and `--allow-ffi` in addition to read/write permissions. ```sh +cargo add shell-use # Rust 1.88+ pip install shell-use # Python 3.8+, imported as `shell_use` npm install @microsoft/shell-use # Node 20+ (ESM only) bun add @microsoft/shell-use # Bun (best effort) deno add npm:@microsoft/shell-use # Deno 2 (best effort) ``` +Rust: + +```rust +use shell_use::{OpenOptions, Operation, Session}; + +fn main() -> Result<(), Box> { + let session = Session::new(format!("rust-example-{}", std::process::id())); + session.open(OpenOptions::default())?; + session.execute(Operation::Submit { + data: Some("echo hello".into()), + })?; + session.execute(Operation::WaitCommand { + timeout_ms: Some(30_000), + })?; + session.execute(Operation::ExpectText { + text: "hello".into(), + regex: false, + full: false, + strict: false, + not: false, + fg: None, + bg: None, + timeout_ms: Some(5_000), + })?; + session.execute(Operation::ExpectExitCode { + code: 0, + timeout_ms: Some(5_000), + })?; + session.close()?; + Ok(()) +} +``` + Python: ```python @@ -281,16 +315,19 @@ await su.expectExitCode(0); await su.close(); ``` -Methods mirror the cli commands: `open` / `run`, `submit` / `type` / `write`, -`press` / `keys`, `mouse.click|move|down|up|drag|scroll`, `resize`, `signal` / -`kill`, `state`, `text`, `cells`, the dedicated `get_command` / `get_output` / -`get_exit_code` / `get_cwd` / `get_cursor` / `get_size` methods, -`screenshot`, `wait_text` / `wait_idle` / `wait_command` / `wait_exit` / -`wait_ready`, `expect_text` / `expect_exit_code` / `expect_output` / -`expect_snapshot`, and `close`. Python module-level helpers are `sessions`, -`close_all`, and `get_recording`; JavaScript exports `sessions`, `closeAll`, -and `getRecording`. The JavaScript client otherwise uses the same names in -camelCase (`waitCommand`, `expectText`, `getExitCode`, etc.). +The Rust crate exposes `Session` and `SessionRegistry` for terminal ownership, +plus the `Operation` and `OperationResult` enums for the command surface. + +Python and JavaScript methods mirror the cli commands: `open` / `run`, `submit` +/ `type` / `write`, `press` / `keys`, `mouse.click|move|down|up|drag|scroll`, +`resize`, `signal` / `kill`, `state`, `text`, `cells`, the dedicated +`get_command` / `get_output` / `get_exit_code` / `get_cwd` / `get_cursor` / +`get_size` methods, `screenshot`, `wait_text` / `wait_idle` / `wait_command` / +`wait_exit` / `wait_ready`, `expect_text` / `expect_exit_code` / +`expect_output` / `expect_snapshot`, and `close`. Python module-level helpers +are `sessions`, `close_all`, and `get_recording`; JavaScript exports `sessions`, +`closeAll`, and `getRecording`. The JavaScript client otherwise uses the same +names in camelCase (`waitCommand`, `expectText`, `getExitCode`, etc.). The constructors accept a session name plus timeout and artifact options: `ShellUse(session="default", *, timeouts=None, artifacts=None)` in Python and @@ -298,10 +335,10 @@ The constructors accept a session name plus timeout and artifact options: the program then its arguments (`await su.run("vim", "file.txt")` in Python, `await su.run("vim", ["file.txt"])` in JavaScript). -Failures raise typed errors instead of returning exit codes, one class per row of -the applicable [exit-code table](#exit-codes): `ExpectationError` (1), -`UsageError` (2), `NoSessionError` (3), and `InternalError` (5), all subclasses -of `ShellUseError`. +Python and JavaScript failures raise typed errors instead of returning exit +codes, one class per row of the applicable [exit-code table](#exit-codes): +`ExpectationError` (1), `UsageError` (2), `NoSessionError` (3), and +`InternalError` (5), all subclasses of `ShellUseError`. ## Supported shells & integration From 230e3377deb82ccdcb606d05fee1a6841275ce79 Mon Sep 17 00:00:00 2001 From: cpendery Date: Thu, 6 Aug 2026 17:35:33 -0700 Subject: [PATCH 2/2] fix: feedback Signed-off-by: cpendery --- .github/scripts/release/publish-crate.mjs | 95 ----------------------- .github/workflows/release.yml | 8 +- 2 files changed, 2 insertions(+), 101 deletions(-) delete mode 100644 .github/scripts/release/publish-crate.mjs diff --git a/.github/scripts/release/publish-crate.mjs b/.github/scripts/release/publish-crate.mjs deleted file mode 100644 index 42d157a..0000000 --- a/.github/scripts/release/publish-crate.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -import { run } from "./utils.mjs"; - -const crateName = "shell-use"; -const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); -const usage = [ - "Usage: node .github/scripts/release/publish-crate.mjs [--dry-run]", - "", - "Publishing uses Cargo's configured crates.io credentials.", -].join("\n"); - -function readPackageVersion() { - const metadata = JSON.parse( - execFileSync( - "cargo", - ["metadata", "--locked", "--no-deps", "--format-version", "1"], - { - cwd: repoRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "inherit"], - }, - ), - ); - const cratePackage = metadata.packages.find( - (candidate) => candidate.name === crateName, - ); - if (!cratePackage) { - throw new Error(`Could not find the ${crateName} package`); - } - return cratePackage.version; -} - -async function isPublished(version) { - const response = await fetch( - `https://crates.io/api/v1/crates/${encodeURIComponent(crateName)}/${encodeURIComponent(version)}`, - { - headers: { - accept: "application/json", - "user-agent": - "shell-use release script (https://github.com/microsoft/shell-use)", - }, - }, - ); - - if (response.status === 404) { - return false; - } - if (!response.ok) { - const details = (await response.text()).trim(); - throw new Error( - `Could not check crates.io for ${crateName}@${version}: ${response.status} ${response.statusText}${details ? `: ${details}` : ""}`, - ); - } - return true; -} - -async function main() { - const args = process.argv.slice(2); - if (args.includes("--help")) { - console.log(usage); - return; - } - - const unknownArgs = args.filter((argument) => argument !== "--dry-run"); - if (unknownArgs.length > 0) { - throw new Error(`${usage}\nUnknown argument: ${unknownArgs[0]}`); - } - - const dryRun = args.includes("--dry-run"); - const version = readPackageVersion(); - const releaseVersion = process.env.RELEASE_TAG?.replace(/^v/, ""); - if (releaseVersion && releaseVersion !== version) { - throw new Error( - `RELEASE_TAG has version ${releaseVersion}; ${crateName} has version ${version}`, - ); - } - - if (!dryRun && (await isPublished(version))) { - console.log(`${crateName}@${version} is already published`); - return; - } - - console.log( - `${dryRun ? "Verifying" : "Publishing"} ${crateName}@${version}`, - ); - const cargoArgs = ["publish", "--locked", "-p", crateName]; - if (dryRun) { - cargoArgs.push("--dry-run"); - } - run("cargo", cargoArgs, { cwd: repoRoot }); -} - -await main(); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3049e5b..aadd4cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: run: node .github/scripts/release/verify-versions.mjs - name: Verify shell-use crate package - run: node .github/scripts/release/publish-crate.mjs --dry-run + run: cargo publish --locked --package shell-use --dry-run build-cli: needs: verify @@ -386,17 +386,13 @@ jobs: rustup toolchain install stable --profile default rustup default stable - - uses: actions/setup-node@v4 - with: - node-version: "24" - # Match this workflow and environment in the crate's trusted publisher settings. - name: Authenticate with crates.io id: auth uses: rust-lang/crates-io-auth-action@v1 - name: Publish shell-use to crates.io - run: node .github/scripts/release/publish-crate.mjs + run: cargo publish --locked --package shell-use env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}