Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/setup/bundled-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ await client.stop()
<summary><strong>Go</strong></summary>

> [!NOTE]
> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. With no explicit path, `NewClient(nil)` uses an embedded CLI when available, then falls back to `copilot` on `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also set `COPILOT_CLI_PATH` or point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details.
> Unlike Node.js, Python, and .NET, the Go SDK does not include a CLI as an automatic dependency. `NewClient(nil)` uses `COPILOT_CLI_PATH` when set, then an embedded CLI when available; it does not scan `PATH`. To embed a CLI, run the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli) at build time. You can also point a `Connection` at an existing binary. See [Local CLI Setup](./local-cli.md) for details.

<!-- docs-validate: hidden -->
```go
Expand Down
2 changes: 1 addition & 1 deletion docs/setup/local-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ await client.stop()
<summary><strong>Go</strong></summary>

> [!NOTE]
> The Go SDK does not ship a CLI automatically. Install `copilot` on `PATH`, set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary.
> The Go SDK does not ship a CLI automatically or scan `PATH`. Set the `COPILOT_CLI_PATH` environment variable, embed a CLI with the [bundler tool](../../go/README.md#distributing-your-application-with-an-embedded-github-copilot-cli), or point `StdioConnection.Path` at an installed binary.

<!-- docs-validate: hidden -->
```go
Expand Down
29 changes: 28 additions & 1 deletion go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ A Go SDK for programmatic access to the GitHub Copilot CLI.
To use the SDK, you'll need:

- Go 1.24 or later
- GitHub Copilot CLI installed and in `PATH` (or set `COPILOT_CLI_PATH`)
- A compatible Copilot runtime provided through `COPILOT_CLI_PATH` or embedded
with the bundler described below

## Installation

Expand Down Expand Up @@ -108,6 +109,32 @@ That's it! When your application calls `copilot.NewClient` without a `Connection

The bundler prepares the native runtime library required by the [in-process transport](#in-process-transport-experimental). It is included in the application only when building with the `copilot_inprocess` build tag.

Downstream modules can prepare a compatible CLI for integration tests without
generating embedded assets or invoking npm. Call `testcli.Setup` from the test
package's `TestMain`:

```go
import (
"log"
"os"
"testing"

"github.com/github/copilot-sdk/go/testcli"
)

func TestMain(m *testing.M) {
if err := testcli.Setup(); err != nil {
log.Fatal(err)
}
os.Exit(m.Run())
}
```

This test-only helper resolves the platform package pinned by the SDK version,
downloads it directly with Go, verifies its lockfile SHA-512 integrity, caches
the extracted runtime, and sets `COPILOT_CLI_PATH`. An existing
`COPILOT_CLI_PATH` is honored.

## In-process transport (Experimental)

> **Experimental:** the in-process API may change in a future release.
Expand Down
94 changes: 11 additions & 83 deletions go/cmd/bundler/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ import (
"runtime"
"strings"

"github.com/github/copilot-sdk/go/internal/npmregistry"
"github.com/klauspost/compress/zstd"
)

const (
// Keep these URLs centralized so reviewers can verify all outbound calls in one place.
sdkModule = "github.com/github/copilot-sdk/go"
packageLockURLFmt = "https://raw.githubusercontent.com/github/copilot-sdk/%s/nodejs/package-lock.json"
tarballURLFmt = "https://registry.npmjs.org/@github/copilot-%s/-/copilot-%s-%s.tgz"
licenseTarballFmt = "https://registry.npmjs.org/@github/copilot/-/copilot-%s.tgz"
defaultPackageName = "main"
)
Expand Down Expand Up @@ -430,11 +430,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle
}

rawLibPath := filepath.Join(tempDir, "runtime.node")
if err := extractFileFromTarball(
if err := npmregistry.ExtractFile(
tarballPath,
tempDir,
"package/prebuilds/"+info.npmPlatform+"/runtime.node",
"runtime.node",
rawLibPath,
); err != nil {
return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/runtime.node: %w", info.npmPlatform, err)
}
Expand All @@ -448,11 +447,10 @@ func buildBundle(info platformInfo, cliVersion, outputPath, goos string) (bundle

wrapperName := runtimeWrapperName(info.binaryName)
rawWrapperPath := filepath.Join(tempDir, wrapperName)
if err := extractFileFromTarball(
if err := npmregistry.ExtractFile(
tarballPath,
tempDir,
"package/prebuilds/"+info.npmPlatform+"/"+wrapperName,
wrapperName,
rawWrapperPath,
); err != nil {
return bundleArtifacts{}, fmt.Errorf("runtime package is missing prebuilds/%s/%s: %w", info.npmPlatform, wrapperName, err)
}
Expand Down Expand Up @@ -896,19 +894,12 @@ func mustDecodeBase64(s string) []byte {
// returns the extracted binary path and the downloaded tarball path (retained so
// callers can extract additional files, such as the runtime library).
func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (string, string, error) {
tarballURL := fmt.Sprintf(tarballURLFmt, npmPlatform, npmPlatform, cliVersion)

fmt.Printf("Downloading from %s...\n", tarballURL)

resp, err := http.Get(tarballURL)
tarballURL, err := npmregistry.TarballURL("@github/copilot-"+npmPlatform, cliVersion)
if err != nil {
return "", "", fmt.Errorf("failed to download: %w", err)
return "", "", err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("failed to download: %s", resp.Status)
}
fmt.Printf("Downloading from %s...\n", tarballURL)

// Save tarball to temp file
tarballPath := filepath.Join(destDir, fmt.Sprintf("copilot-%s-%s.tgz", npmPlatform, cliVersion))
Expand All @@ -917,17 +908,17 @@ func downloadCLIBinary(npmPlatform, binaryName, cliVersion, destDir string) (str
return "", "", fmt.Errorf("failed to create tarball file: %w", err)
}

if _, err := io.Copy(tarballFile, resp.Body); err != nil {
if err := npmregistry.Download(http.DefaultClient, tarballURL, "", tarballFile); err != nil {
tarballFile.Close()
return "", "", fmt.Errorf("failed to save tarball: %w", err)
return "", "", fmt.Errorf("failed to download: %w", err)
}
if err := tarballFile.Close(); err != nil {
return "", "", fmt.Errorf("failed to close tarball file: %w", err)
}

// Extract only the CLI binary to avoid unpacking the full package tree.
binaryPath := filepath.Join(destDir, binaryName)
if err := extractFileFromTarball(tarballPath, destDir, "package/"+binaryName, binaryName); err != nil {
if err := npmregistry.ExtractFile(tarballPath, "package/"+binaryName, binaryPath); err != nil {
return "", "", fmt.Errorf("failed to extract binary: %w", err)
}

Expand Down Expand Up @@ -1033,69 +1024,6 @@ func extractFileFromTarballStream(r io.Reader, destDir, outputName string, mode
return outFile.Close()
}

// extractFileFromTarball extracts a single file from a .tgz into destDir with a new name.
func extractFileFromTarball(tarballPath, destDir, targetPath, outputName string) error {
file, err := os.Open(tarballPath)
if err != nil {
return err
}
defer file.Close()

gzReader, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzReader.Close()

tarReader := tar.NewReader(gzReader)

for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}

if header.Name == targetPath {
outPath := filepath.Join(destDir, outputName)
outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}

if _, err := io.Copy(outFile, tarReader); err != nil {
if cerr := outFile.Close(); cerr != nil {
return fmt.Errorf("failed to extract binary (copy error: %v, close error: %v)", err, cerr)
}
return fmt.Errorf("failed to extract binary: %w", err)
}
if err := outFile.Close(); err != nil {
return fmt.Errorf("failed to close output file: %w", err)
}
return nil
}
}

return fmt.Errorf("file %q not found in tarball", targetPath)
}

// extractOptionalFileFromTarball extracts a single file from a .tgz into destDir
// like extractFileFromTarball, but returns (false, nil) instead of an error when
// the file is absent. Used for the runtime library, which older CLI packages do
// not ship.
func extractOptionalFileFromTarball(tarballPath, destDir, targetPath, outputName string) (bool, error) {
err := extractFileFromTarball(tarballPath, destDir, targetPath, outputName)
if err == nil {
return true, nil
}
if strings.Contains(err.Error(), "not found in tarball") {
return false, nil
}
return false, err
}

// compressZstdFile compresses src into dst using zstd.
func compressZstdFile(src, dst string) error {
srcFile, err := os.Open(src)
Expand Down
Loading
Loading