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
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ jobs:
- run: make build
- run: bun run test:smoke

test-compiled-binaries-macos:
name: Compiled Binary Smoke Tests (macOS)
runs-on: macos-15
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: make build
- run: bun run test:smoke

security:
name: Security Audit
runs-on: ubuntu-latest
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
name: Release

on:
push:
tags: ['v*']

jobs:
check-version:
name: Check Tag Matches Version
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Compare the tag with packages/cli/package.json
run: |
package_version=$(jq -r .version packages/cli/package.json)
if [ "${GITHUB_REF_NAME#v}" != "$package_version" ]; then
echo "::error::Tag $GITHUB_REF_NAME does not match packages/cli/package.json version $package_version"
exit 1
fi

build:
name: Build macOS ${{ matrix.arch }}
needs: check-version
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- runner: macos-15
arch: arm64
- runner: macos-15-intel
arch: x86_64
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- run: bun install --frozen-lockfile
- run: make build
- run: bun run test:smoke
- name: Package the build folder
run: |
version=${GITHUB_REF_NAME#v}
name="skillwalker-$version-darwin-${{ matrix.arch }}"
mkdir -p "dist/$name"
cp build/skillwalker build/skillwalker-web build/duckdb.node build/libduckdb.dylib \
build/sandbox-run.sh build/sandbox-extract.sh "dist/$name/"
tar -czf "dist/$name.tar.gz" -C dist "$name"
shasum -a 256 "dist/$name.tar.gz" | tee "dist/$name.tar.gz.sha256"
- uses: actions/upload-artifact@v4
with:
name: darwin-${{ matrix.arch }}
path: dist/*.tar.gz*

release:
name: Draft GitHub Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Create a draft release with the archives
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" dist/* \
--repo "$GITHUB_REPOSITORY" \
--draft \
--generate-notes \
--title "Skillwalker $GITHUB_REF_NAME"
14 changes: 7 additions & 7 deletions docs/claude-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,15 @@ The `--print` flag is always placed last in the argument array, after all other

### Sandbox Script Resolution

The `sandbox-run.sh` script path is resolved at module load time using `resolveRelativePath` from `@testdouble/bun-helpers`. This utility handles cross-runtime path resolution across Bun, Vitest, and the compiled binaries. In a compiled binary the script is read from beside the executable, where `scripts/build.ts` copies it:
The `sandbox-run.sh` and `sandbox-extract.sh` paths are resolved once, at module load, by `resolveSandboxScripts(process.env)` in `packages/claude-integration/src/sandbox-scripts.ts`.

When `SKILLWALKER_SCRIPTS_DIR` is set, both scripts are read from that folder, resolved to an absolute path. If either script is missing there, loading the module throws an error naming the variable, so every command fails at startup rather than at the first sandbox run. An installer uses this to keep the scripts in a folder whose path stays the same across upgrades: `sandbox create` mounts `sandboxScriptsDir` into the sandbox, and a Homebrew install's own folder changes with each version.

Otherwise the paths come from `resolveRelativePath` in `@testdouble/bun-helpers`, which handles cross-runtime path resolution across Bun, Vitest, and the compiled binaries. In a compiled binary the scripts are read from beside the executable, where `scripts/build.ts` copies them:

```typescript
// packages/claude-integration/src/run-claude.ts
const sandboxRunScript = resolveRelativePath(
import.meta,
'../sandbox-run.sh',
'sandbox-run.sh',
)
// packages/claude-integration/src/sandbox-scripts.ts
const runScript = resolveRelativePath(import.meta, '../sandbox-run.sh', 'sandbox-run.sh')
```

### Sandbox Script Behavior
Expand Down
18 changes: 17 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This page documents the CLI boundary: the six top-level commands the `skillwalke

The `@testdouble/skillwalker-cli` package is the command-line entry point for Skillwalker. It is a thin Yargs wrapper that parses arguments, resolves paths from `process.cwd()`, and delegates all pipeline orchestration to `@testdouble/skillwalker-execution`.

- **Last Updated:** 2026-05-15
- **Last Updated:** 2026-09-23
- **Authors:**
- River Bailey (river.bailey@testdouble.com)

Expand Down Expand Up @@ -122,10 +122,26 @@ The CLI catches `SkillwalkerError` at the top level (`index.ts`) and writes the
| `--apply` | `scil`, `acil` | Auto-apply best description | `false` |
| `--output-dir` | `update-analytics-data` | Path to test output directory | `tests/output/` |
| `--data-dir` | `update-analytics-data` | Path to analytics data directory | `tests/analytics/` |
| `--version` | all | Print the CLI version: `packages/cli/package.json`'s version in a compiled binary, `dev` from source | — |

| Environment variable | Description | Default |
|----------------------|-------------|---------|
| `SKILLWALKER_SCRIPTS_DIR` | Folder holding `sandbox-run.sh` and `sandbox-extract.sh`. An installer sets it so the folder mounted into the sandbox keeps the same path across upgrades. See [Claude Integration](./claude-integration.md#sandbox-script-resolution). | beside the executable |

### Build and Release

`make build` runs `scripts/build.ts`, which compiles `skillwalker` and `skillwalker-web` into `build/` and copies the DuckDB native files and sandbox scripts beside them. Two details matter for a distributable build:

- **Version.** The build passes `packages/cli/package.json`'s `version` to `Bun.build` as the `SKILLWALKER_VERSION` define. `packages/cli/src/version.ts` reads it and falls back to `dev` when running from source.
- **Signing.** On macOS, `bun build --compile` appends the bundle after the linker has signed the executable, so the signature no longer matches the file and recent macOS releases kill it on launch. The build strips that signature, signs each binary ad hoc, and runs `codesign --verify`, failing the build if any step fails.

Pushing a `v*` tag runs `.github/workflows/release.yml`. It fails unless the tag matches `packages/cli/package.json`'s version, then builds and smoke tests on arm64 and x86_64 macOS runners. Each build folder is packaged as `skillwalker-<version>-darwin-<arch>.tar.gz` with a `.sha256` file, and all of them are attached to a draft GitHub Release.

## Testing

- `packages/cli/src/paths.test.ts` — Tests `createPathConfig` and `getAllEvals`
- `packages/cli/src/version.test.ts` — Tests the `dev` version fallback when running from source
- `packages/cli/src/compiled-binary.smoke.test.ts` — Runs the binaries in `build/` (`make build && bun run test:smoke`): `--version`, DuckDB loading, a Homebrew-style `bin` symlink into `libexec`, `SKILLWALKER_SCRIPTS_DIR`, and `codesign --verify` on macOS
- `packages/cli/src/commands/test-run.test.ts` — Tests `test-run` command builder and handler
- `packages/cli/src/commands/test-eval.test.ts` — Tests `test-eval` command builder and handler
- `packages/cli/src/commands/scil.test.ts` — Tests `scil` command builder and handler
Expand Down
6 changes: 3 additions & 3 deletions docs/sandbox-integration-package.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class SandboxError extends Error {
async function ensureSandboxExists(requiredPaths: string[] = []): Promise<void>
```

Pre-flight check that the sandbox exists and mounts what the run needs. Runs `sbx ls --json`, finds the entry named `SANDBOX_NAME`, and checks that every path in `requiredPaths` is inside one of its workspaces (a trailing `:ro` on a listed workspace is ignored). `runEvals` and the SCIL and ACIL loops pass `[sandboxScriptsDir]`. A sandbox created before the scripts mount was added keeps its old workspaces, so this check fails it before any test runs instead of at the first `sbx exec`. Throws `SandboxError` with `exitCode: null` if the sandbox is not found, with a message directing the user to run `./build/skillwalker sandbox create`.
Pre-flight check that the sandbox exists and mounts what the run needs. Runs `sbx ls --json`, finds the entry named `SANDBOX_NAME`, and checks that every path in `requiredPaths` is inside one of its workspaces (a trailing `:ro` on a listed workspace is ignored). `runEvals` and the SCIL and ACIL loops pass `[sandboxScriptsDir]`. A sandbox created before the scripts mount was added keeps its old workspaces, so this check fails it before any test runs instead of at the first `sbx exec`. Throws `SandboxError` with `exitCode: null` if the sandbox is not found, with a message directing the user to run `skillwalker sandbox create`.

**Consumers:**
- `cli/src/commands/test-run.ts` -- before the per-eval test loop
Expand Down Expand Up @@ -112,7 +112,7 @@ async function createSandbox(repoRoot: string, extraWorkspaces: string[] = []):

Checks whether the sandbox already exists via an internal `sandboxExists()` helper (runs `sbx ls --quiet`). If found, prints a help message to stderr explaining how to recreate it, and returns early. Otherwise, spawns `sbx run --name claude-skills-skillwalker claude <repoRoot> [<extraWorkspace>:ro ...]` with inherited stdio for interactive OAuth login. Prints progress messages to stderr. Throws `SandboxError` if `sbx run` exits non-zero.

`extraWorkspaces` are mounted read-only after `repoRoot` (`<path>:ro`); any already inside `repoRoot` are skipped. The CLI passes the directory holding `sandbox-run.sh` and `sandbox-extract.sh` (`sandboxScriptsDir` from `@testdouble/claude-integration`). `execInSandbox` runs those scripts by their host path, and the sandbox only sees host paths under a mounted workspace, so without this mount every test run fails whenever the target repo is not the skillwalker repo.
`extraWorkspaces` are mounted read-only after `repoRoot` (`<path>:ro`); any already inside `repoRoot` are skipped. The CLI passes the directory holding `sandbox-run.sh` and `sandbox-extract.sh` (`sandboxScriptsDir` from `@testdouble/claude-integration`, or `SKILLWALKER_SCRIPTS_DIR` when set). `execInSandbox` runs those scripts by their host path, and the sandbox only sees host paths under a mounted workspace, so without this mount every test run fails whenever the target repo is not the skillwalker repo.

**Consumer:** `cli/src/commands/sandbox/create.ts`

Expand Down Expand Up @@ -210,7 +210,7 @@ flowchart TB

| Scenario | Error Type | Behavior |
|----------|------------|----------|
| Sandbox not found by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown with message suggesting `./build/skillwalker sandbox create` |
| Sandbox not found by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown with message suggesting `skillwalker sandbox create` |
| Required path not mounted, checked by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown naming the unmounted path, with a hint to run `skillwalker sandbox update` from the target repo |
| `sbx rm` fails | `SandboxError` (exitCode: process code) | Thrown with stdout+stderr in message |
| `sbx exec` prints `OCI runtime exec failed` (exits 0) | `SandboxError` (exitCode: process code) | Thrown with the sbx output and a hint to run `skillwalker sandbox update` from the target repo |
Expand Down
4 changes: 2 additions & 2 deletions docs/sandbox-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export async function createSandbox(repoRoot: string, extraWorkspaces: string[]

Checks if the sandbox already exists via an internal `sandboxExists()` helper. If it does, prints a help message to stderr and returns. Otherwise, spawns `sbx run --name claude-skills-skillwalker claude <repoRoot> [<extraWorkspace>:ro ...]` with inherited stdio for interactive OAuth login. If `sbx run` exits non-zero, it throws `SandboxError` instead of reporting the sandbox as ready.

`extraWorkspaces` are mounted read-only after `repoRoot` (`<path>:ro`); any already inside `repoRoot` are skipped. The CLI passes the directory holding `sandbox-run.sh` and `sandbox-extract.sh` (`sandboxScriptsDir` from `@testdouble/claude-integration`). `execInSandbox` runs those scripts by their host path, and the sandbox only sees host paths under a mounted workspace, so without this mount every test run fails whenever the target repo is not the skillwalker repo.
`extraWorkspaces` are mounted read-only after `repoRoot` (`<path>:ro`); any already inside `repoRoot` are skipped. The CLI passes the directory holding `sandbox-run.sh` and `sandbox-extract.sh` (`sandboxScriptsDir` from `@testdouble/claude-integration`, or `SKILLWALKER_SCRIPTS_DIR` when set). `execInSandbox` runs those scripts by their host path, and the sandbox only sees host paths under a mounted workspace, so without this mount every test run fails whenever the target repo is not the skillwalker repo.

Called by `commands/sandbox/create.ts`.

Expand Down Expand Up @@ -226,7 +226,7 @@ See [Cross-Runtime Meta Property Resolution](coding-standards/cross-runtime-meta

| Scenario | Error Type | Behavior |
|----------|------------|----------|
| Sandbox not found by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown with message suggesting `./build/skillwalker sandbox create` |
| Sandbox not found by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown with message suggesting `skillwalker sandbox create` |
| Required path not mounted, checked by `ensureSandboxExists` | `SandboxError` (exitCode: `null`) | Thrown naming the unmounted path, with a hint to run `skillwalker sandbox update` from the target repo |
| `sbx rm` fails | `SandboxError` (exitCode: process code) | Thrown with stdout+stderr in message |
| `sbx exec` prints `OCI runtime exec failed` (exits 0) | `SandboxError` (exitCode: process code) | Thrown with the sbx output and a hint to run `skillwalker sandbox update` from the target repo |
Expand Down
58 changes: 58 additions & 0 deletions packages/claude-integration/src/sandbox-scripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { resolveSandboxScripts } from './sandbox-scripts.js'

const packageDir = fileURLToPath(new URL('..', import.meta.url))

let scriptsDir: string

beforeEach(async () => {
scriptsDir = await mkdtemp(path.join(tmpdir(), 'sandbox-scripts-'))
})

afterEach(async () => {
await rm(scriptsDir, { recursive: true, force: true })
})

describe('resolveSandboxScripts', () => {
it('uses the scripts bundled with the package when SKILLWALKER_SCRIPTS_DIR is unset', () => {
const scripts = resolveSandboxScripts({})

expect(scripts.runScript).toBe(path.join(packageDir, 'sandbox-run.sh'))
expect(scripts.extractScript).toBe(path.join(packageDir, 'sandbox-extract.sh'))
expect(scripts.scriptsDir).toBe(path.resolve(packageDir))
})

it('uses the scripts in SKILLWALKER_SCRIPTS_DIR when it is set', async () => {
await writeFile(path.join(scriptsDir, 'sandbox-run.sh'), '')
await writeFile(path.join(scriptsDir, 'sandbox-extract.sh'), '')

const scripts = resolveSandboxScripts({ SKILLWALKER_SCRIPTS_DIR: scriptsDir })

expect(scripts).toEqual({
runScript: path.join(scriptsDir, 'sandbox-run.sh'),
extractScript: path.join(scriptsDir, 'sandbox-extract.sh'),
scriptsDir,
})
})

it('resolves a relative SKILLWALKER_SCRIPTS_DIR to an absolute folder for the sandbox mount', async () => {
await writeFile(path.join(scriptsDir, 'sandbox-run.sh'), '')
await writeFile(path.join(scriptsDir, 'sandbox-extract.sh'), '')

const scripts = resolveSandboxScripts({ SKILLWALKER_SCRIPTS_DIR: path.relative(process.cwd(), scriptsDir) })

expect(scripts.scriptsDir).toBe(scriptsDir)
})

it('names SKILLWALKER_SCRIPTS_DIR when that folder is missing a script', async () => {
await writeFile(path.join(scriptsDir, 'sandbox-run.sh'), '')

expect(() => resolveSandboxScripts({ SKILLWALKER_SCRIPTS_DIR: scriptsDir })).toThrow(
`SKILLWALKER_SCRIPTS_DIR is set to ${scriptsDir}, but it has no sandbox-extract.sh`,
)
})
})
47 changes: 39 additions & 8 deletions packages/claude-integration/src/sandbox-scripts.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,43 @@
import fs from 'node:fs'
import path from 'node:path'
import { resolveRelativePath } from '@testdouble/bun-helpers'

export const sandboxRunScript = resolveRelativePath(import.meta, '../sandbox-run.sh', 'sandbox-run.sh')
export const sandboxExtractScript = resolveRelativePath(import.meta, '../sandbox-extract.sh', 'sandbox-extract.sh')
export interface SandboxScripts {
runScript: string
extractScript: string
/**
* Directory holding the scripts `sbx exec` runs by their host path. The sandbox
* only sees host paths under a mounted workspace, so this directory must be
* mounted alongside the target repo.
*/
scriptsDir: string
}

/**
* Directory holding the scripts `sbx exec` runs by their host path. The sandbox
* only sees host paths under a mounted workspace, so this directory must be
* mounted alongside the target repo.
*/
export const sandboxScriptsDir = path.dirname(sandboxRunScript)
function overrideScript(dir: string, name: string): string {
const script = path.join(dir, name)
if (!fs.existsSync(script)) {
throw new Error(`SKILLWALKER_SCRIPTS_DIR is set to ${dir}, but it has no ${name}`)
}
return script
}

export function resolveSandboxScripts(env: NodeJS.ProcessEnv): SandboxScripts {
if (env.SKILLWALKER_SCRIPTS_DIR) {
const overrideDir = path.resolve(env.SKILLWALKER_SCRIPTS_DIR)
return {
runScript: overrideScript(overrideDir, 'sandbox-run.sh'),
extractScript: overrideScript(overrideDir, 'sandbox-extract.sh'),
scriptsDir: overrideDir,
}
}

const runScript = resolveRelativePath(import.meta, '../sandbox-run.sh', 'sandbox-run.sh')
const extractScript = resolveRelativePath(import.meta, '../sandbox-extract.sh', 'sandbox-extract.sh')
return { runScript, extractScript, scriptsDir: path.dirname(runScript) }
}

const scripts = resolveSandboxScripts(process.env)

export const sandboxRunScript = scripts.runScript
export const sandboxExtractScript = scripts.extractScript
export const sandboxScriptsDir = scripts.scriptsDir
2 changes: 2 additions & 0 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { SandboxError } from '@testdouble/sandbox-integration'
import { SkillwalkerError } from '@testdouble/skillwalker-execution'
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
import { skillwalkerVersion } from './src/version.js'

try {
await yargs(hideBin(process.argv))
.scriptName('skillwalker')
.version(skillwalkerVersion)
.command(await import('./src/commands/test-run.js'))
.command(await import('./src/commands/test-eval.js'))
.command(await import('./src/commands/sandbox.js'))
Expand Down
Loading
Loading