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
6 changes: 3 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,10 +169,10 @@ jobs:
run: sed -i -E 's/^version = ".*"$/version = "${{ needs.version.outputs.version }}"/' Cargo.toml
- name: Snapshot CLI version + hashes for build.rs
run: bash scripts/snapshot-bundled-cli-version.sh
- name: Verify snapshot file exists
- name: Verify cli-version.txt exists
run: |
if [[ ! -f bundled_cli_version.txt ]]; then
echo "::error::bundled_cli_version.txt was not generated. The Snapshot step must run before packaging."
if [[ ! -f cli-version.txt ]]; then
echo "::error::cli-version.txt was not generated. The Snapshot step must run before packaging."
exit 1
fi
- name: Package (dry run)
Expand Down
29 changes: 26 additions & 3 deletions .github/workflows/rust-sdk-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,18 +73,38 @@ jobs:
prefix-key: v1-rust-no-bin
cache-bin: false

- name: Read pinned @github/copilot CLI version
id: cli-version
working-directory: ./nodejs
run: |
version=$(node -p "require('./package-lock.json').packages['node_modules/@github/copilot'].version")
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Pinned CLI version: $version"

# Share the bundled-CLI archive cache with the `bundle` job: build.rs
# now downloads in both modes (embed for `bundle`, extract-to-cache
# for this `test` job's `--no-default-features` build).
- name: Cache bundled CLI tarball
uses: actions/cache@v4
with:
path: ./rust/.bundled-cli-cache
key: bundled-cli-${{ matrix.os }}-${{ steps.cli-version.outputs.version }}

- name: cargo fmt --check (nightly)
if: runner.os == 'Linux'
run: cargo +nightly-2026-04-14 fmt --all -- --config-path .rustfmt.nightly.toml --check

- name: cargo clippy
if: runner.os == 'Linux'
env:
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
run: cargo clippy --all-targets --features test-support -- --no-deps -D warnings -D clippy::unwrap_used -D clippy::disallowed_macros -D clippy::await_holding_invalid_type

- name: cargo doc
if: runner.os == 'Linux'
env:
RUSTDOCFLAGS: "-D warnings"
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
run: cargo doc --no-deps --all-features

- name: Install test harness dependencies
Expand All @@ -101,9 +121,12 @@ jobs:
RUST_E2E_CONCURRENCY: 4
COPILOT_HMAC_KEY: ${{ secrets.COPILOT_DEVELOPER_CLI_INTEGRATION_HMAC_KEY }}
COPILOT_CLI_PATH: ${{ steps.setup-copilot.outputs.cli-path }}
# `--no-default-features` disables the bundled-cli download; the
# tests use the CLI provided by setup-copilot via COPILOT_CLI_PATH.
# The dedicated `bundle` job below exercises the bundling pipeline.
BUNDLED_CLI_CACHE_DIR: ${{ github.workspace }}/rust/.bundled-cli-cache
# `--no-default-features` selects dev mode: build.rs still downloads
# + verifies + extracts the CLI to the per-user cache, but doesn't
# embed it. Tests exec against the setup-copilot CLI via
# COPILOT_CLI_PATH (the env override wins over the dev cache).
# The dedicated `bundle` job below exercises the embed pipeline.
run: cargo test --no-default-features --features test-support -- --test-threads=4 --nocapture

# Validates the bundled-CLI build path on all three supported
Expand Down
2 changes: 1 addition & 1 deletion rust/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/target
Cargo.lock.bak
bundled_cli_version.txt
cli-version.txt
7 changes: 4 additions & 3 deletions rust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ include = [
"Cargo.toml",
"README.md",
"LICENSE",
"bundled_cli_version.txt",
"cli-version.txt",
]

[lib]
name = "github_copilot_sdk"

[features]
default = ["bundled-cli"]
bundled-cli = ["dep:dirs", "dep:tar", "dep:flate2", "dep:zip"]
bundled-cli = ["dep:tar", "dep:flate2", "dep:zip"]
derive = ["dep:schemars"]
test-support = []

Expand All @@ -48,7 +48,7 @@ tokio = { version = "1", features = ["io-util", "sync", "rt", "process", "net",
tokio-stream = { version = "0.1", features = ["sync"] }
tokio-util = { version = "0.7", default-features = false }
tracing = "0.1"
dirs = { version = "5", optional = true }
dirs = "5"
parking_lot = "0.12"
regex = "1"
getrandom = "0.2"
Expand All @@ -69,6 +69,7 @@ tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread"] }

[build-dependencies]
dirs = "5"
flate2 = "1"
sha2 = "0.10"
tar = "0.4"
Expand Down
80 changes: 64 additions & 16 deletions rust/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -756,30 +756,59 @@ none of them are scheduled for removal.

## Embedded CLI

The SDK bundles the Copilot CLI binary inside the consumer's compiled crate by default. No env var setup, no separate installjust `cargo build` and you get a self-contained binary.
The SDK provisions the Copilot CLI binary at build time. By default the `bundled-cli` feature embeds the verified binary directly in your compiled crate, so end-user binaries are self-contained — no env var setup, no separate install, just `cargo build`.

To opt out (e.g. for binary-size-sensitive consumers, or environments that provide the CLI via PATH), set `default-features = false`:
For builds that prefer a smaller artifact, disable the `bundled-cli` feature:

```toml
github-copilot-sdk = { version = "0.1", default-features = false }
```

> **You become responsible for supplying the CLI at runtime.** With
> `bundled-cli` disabled, the produced binary does not contain the CLI
> and will not search the system for one. You must point it at a
> compatible CLI via [`CliProgram::Path`] (on `ClientOptions`) or the
> `COPILOT_CLI_PATH` environment variable, and you are responsible for
> guaranteeing the supplied CLI version is compatible with this SDK
> release. Do **not** assume that whatever CLI happens to be installed
> on the target system will work — the SDK and CLI are versioned
> together.
>
> **Convenience on the build machine only.** As a special case,
> `build.rs` downloads and SHA-verifies the compatible CLI version and
> drops it into the build machine's per-user cache; the runtime
> resolver on that same machine will pick it up automatically. This
> makes local development and CI ergonomic, but it does **not** carry
> over when you copy the built binary to another machine — distributed
> builds (release artifacts, signed installers, container images, etc.)
> must either keep `bundled-cli` enabled or ship the CLI alongside and
> set `CliProgram::Path` / `COPILOT_CLI_PATH`.

Comment thread
tclem marked this conversation as resolved.
### How it works

1. **Pinned at publish time.** When the rust crate is published, a workflow step writes `bundled_cli_version.txt` (CLI version + per-platform SHA-256 hashes) into the crate from the in-effect `nodejs/package-lock.json` and the matching GitHub Release's `SHA256SUMS.txt`. This file is gitignored locally; it only exists in the published crate tarball.
1. **Version pin.** `build.rs` reads the CLI version from one of two sources:
- `cli-version.txt` at the crate root (present in published crate tarballs and vendored slots).
- Otherwise, `../nodejs/package-lock.json` (contributor build inside the github/copilot-sdk repo — matches the .NET and Go SDK conventions here).

The resolved version is baked into the crate via `cargo:rustc-env=COPILOT_SDK_CLI_VERSION` regardless of mode. The runtime resolver consumes it to recompute the on-disk path by convention, so no absolute paths leak into the rlib.

2. **Build time:** `build.rs` downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), live-fetches the matching `SHA256SUMS.txt`, and verifies the archive hash. Then:
- **`bundled-cli` on (default, release):** embeds the raw archive bytes via `include_bytes!()`. Runtime extracts on first `Client::start()`.
- **`bundled-cli` off:** extracts the binary directly into the platform cache (staging file + atomic rename), idempotent across rebuilds. If the extracted binary is already present at the expected path, the download is skipped entirely — the extracted binary *is* the cache.

2. **Build time:** The SDK's `build.rs` resolves the version + per-platform SHA-256:
- `COPILOT_CLI_VERSION` env var (advanced override; fetches live `SHA256SUMS.txt`).
- Otherwise, `bundled_cli_version.txt` from the published crate.
- Otherwise (mono-repo contributor build), live read from `../nodejs/package-lock.json` + live fetch of `SHA256SUMS.txt`.
3. **Runtime:** in both modes the binary lives at:

It then downloads the platform-appropriate archive from the [`github/copilot-cli` GitHub Releases](https://github.com/github/copilot-cli/releases) (`copilot-{platform}.tar.gz` on macOS/Linux, `.zip` on Windows), verifies the SHA-256, extracts the `copilot` binary, compresses it with zstd, and embeds via `include_bytes!()`.
| OS | Path |
|----|------|
| macOS | `~/Library/Caches/github-copilot-sdk/cli/<version>/copilot` |
| Linux | `${XDG_CACHE_HOME:-~/.cache}/github-copilot-sdk/cli/<version>/copilot` |
| Windows | `%LOCALAPPDATA%\github-copilot-sdk\cli\<version>\copilot.exe` |

3. **Runtime:** On the first call to `github_copilot_sdk::Client::start()`, the embedded archive is lazily extracted to the platform cache dir (`%LOCALAPPDATA%\github-copilot-sdk-{version}\` on Windows, `~/Library/Caches/github-copilot-sdk-{version}/` on macOS, `$XDG_CACHE_HOME/github-copilot-sdk-{version}/` (or `~/.cache/...`) on Linux). Subsequent runs reuse the extracted binary.
Old version directories accumulate in siblings; clean them up at your leisure.

### Overriding the extraction location

Use [`ClientOptions::with_bundled_cli_extract_dir`] when you need to place the extracted binary somewhere other than the platform cache dir (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.):
[`ClientOptions::with_bundled_cli_extract_dir`] redirects embed-mode extraction to a custom directory (CI runners with ephemeral homes, sandboxes that disallow cache paths, etc.):

```rust,ignore
use std::path::PathBuf;
Expand All @@ -790,15 +819,34 @@ let options = ClientOptions::new()
let client = Client::start(options).await?;
```

With `bundled-cli` disabled the equivalent knob is the **`COPILOT_CLI_EXTRACT_DIR`** environment variable, which is honored symmetrically at build time (where `build.rs` writes the binary) and at runtime (where the resolver reads it). When set, the binary lives directly under the named directory (no per-version subdir). The most ergonomic way to pin it from a consumer crate is `.cargo/config.toml`:

```toml
# .cargo/config.toml at the consumer's repo root
[env]
COPILOT_CLI_EXTRACT_DIR = { value = "vendor/copilot", relative = true, force = true }
```

`relative = true` resolves the path against the config file's directory, so the value is stable regardless of where `cargo build` is invoked from. `force = true` makes the value visible to invocations of the produced binary under `cargo run` / `cargo test`, keeping build and runtime in sync. For runtime invocations outside cargo (e.g. a deploy script running the binary directly), either export the same env var or use [`CliProgram::Path`] / `COPILOT_CLI_PATH` at runtime.

### Skipping the bundle entirely

Set `COPILOT_SKIP_CLI_DOWNLOAD=1` at build time to disable the entire download / bundle / cache mechanism — `build.rs` returns immediately without touching the network. Use this when you always supply the CLI at runtime via `ClientOptions::program = CliProgram::Path(...)` or `COPILOT_CLI_PATH`. Works regardless of the `bundled-cli` feature state; runtime resolution falls through to `Error::BinaryNotFound` unless one of those explicit sources resolves.

### Resolution priority

`copilot_binary()` checks these sources in order:
`Client::start` resolves the CLI in this order:

1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`.
2. `COPILOT_CLI_PATH` environment variable, if it points at a real file.
3. **`bundled-cli` on:** the embedded archive, lazily extracted on first call.
4. **`bundled-cli` off:** the build-time-extracted binary in the per-user cache, located by recomputing the convention from `COPILOT_SDK_CLI_VERSION` + OS + optional `COPILOT_CLI_EXTRACT_DIR`.

There is no PATH scanning. If none of the above resolves, `Client::start` returns `Error::BinaryNotFound`.

1. Explicit `CliProgram::Path(path)` on `ClientOptions::program`
2. `COPILOT_CLI_PATH` environment variable
3. Embedded CLI (when the `bundled-cli` feature is enabled, which it is by default)
### Download cache (build-time, embed mode)

There is no PATH scanning. If both 1+2 are unset and the SDK was built with `default-features = false`, `Client::start` returns `Error::BinaryNotFound`.
In embed mode `build.rs` re-downloads on every clean build by default. Set `BUNDLED_CLI_CACHE_DIR=<path>` to cache the verified archive between builds (CI keys this on `<os>-<version>` for ~zero-cost rebuilds on cache hits). With `bundled-cli` disabled there is no separate archive cache — the extracted binary itself is the cache.

### Platforms

Expand All @@ -808,7 +856,7 @@ Supported: `darwin-arm64`, `darwin-x64`, `linux-x64`, `linux-arm64`, `win32-x64`

| Feature | Default | Description |
| -------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bundled-cli` | ✓ | Build-time CLI embedding. Pulls in `dirs`, `tar`+`flate2` (Linux/macOS), or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). |
| `bundled-cli` | ✓ | Build-time CLI embedding. Pulls in `tar`+`flate2` (Linux/macOS) or `zip` (Windows). Disable via `default-features = false` to opt out (e.g. when shipping a smaller binary or when always supplying the CLI via `CliProgram::Path` / `COPILOT_CLI_PATH`). |
| `derive` | — | `schema_for::<T>()` for generating JSON Schema from Rust types (adds `schemars`). Enable when defining [tool parameters](#tool-registration). |

```toml
Expand Down
Loading
Loading