feat: add release automation with GitHub Releases and npm distribution

Automated release pipeline using release-please for gated Release PRs,
conventional commits for SemVer version bumps, and npm distribution
with postinstall binary download from GitHub Releases.
This commit is contained in:
Lahfir 2026-02-23 00:49:55 -08:00
parent aa3b210708
commit 1d1a7fee3b
8 changed files with 654 additions and 11 deletions

160
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,160 @@
name: Release
on:
push:
branches: [main]
env:
CARGO_TERM_COLOR: always
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
build:
name: Build (${{ matrix.target }})
needs: release-please
if: needs.release-please.outputs.release_created == 'true'
runs-on: ${{ matrix.runner }}
strategy:
matrix:
include:
- target: aarch64-apple-darwin
runner: macos-latest
- target: x86_64-apple-darwin
runner: macos-13
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- name: Install Rust toolchain
run: |
rustup show
rustup target add ${{ matrix.target }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.toml') }}
restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo-
- name: Build release binary
run: cargo build --release --target ${{ matrix.target }}
- name: Check binary size
run: |
SIZE=$(stat -f%z target/${{ matrix.target }}/release/agent-desktop)
LIMIT=$((15 * 1024 * 1024))
echo "Binary size: $(du -sh target/${{ matrix.target }}/release/agent-desktop | cut -f1)"
if [ "$SIZE" -gt "$LIMIT" ]; then
echo "FAIL: binary exceeds 15MB limit (${SIZE} bytes)"
exit 1
fi
- name: Create tarball
run: |
VERSION=${{ needs.release-please.outputs.version }}
TARBALL="agent-desktop-v${VERSION}-${{ matrix.target }}.tar.gz"
tar -czf "${TARBALL}" -C target/${{ matrix.target }}/release agent-desktop
shasum -a 256 "${TARBALL}" > "${TARBALL}.sha256"
echo "TARBALL=${TARBALL}" >> "$GITHUB_ENV"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.target }}
path: |
agent-desktop-v*.tar.gz
agent-desktop-v*.tar.gz.sha256
publish-github:
name: Publish to GitHub Release
needs: [release-please, build]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
pattern: binary-*
merge-multiple: true
- name: Create checksums file
run: |
cat *.sha256 > checksums.txt
echo "Checksums:"
cat checksums.txt
- name: Upload assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
gh release upload ${{ needs.release-please.outputs.tag_name }} \
*.tar.gz checksums.txt \
--clobber
publish-npm:
name: Publish to npm
needs: [release-please, publish-github]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.release-please.outputs.tag_name }}
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: https://registry.npmjs.org
- name: Verify GitHub Release assets
env:
GH_TOKEN: ${{ github.token }}
run: |
ASSETS=$(gh release view ${{ needs.release-please.outputs.tag_name }} --json assets --jq '.assets | length')
if [ "$ASSETS" -lt 3 ]; then
echo "FAIL: expected at least 3 assets (2 tarballs + checksums), found ${ASSETS}"
exit 1
fi
echo "OK: ${ASSETS} assets found on release"
- name: Sync version to package.json
run: |
VERSION=${{ needs.release-please.outputs.version }}
cd npm
node -e "
const pkg = require('./package.json');
pkg.version = '${VERSION}';
require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
echo "npm package version set to ${VERSION}"
- name: Publish to npm
run: npm publish --provenance --access public
working-directory: npm
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

5
.gitignore vendored
View file

@ -62,6 +62,11 @@ dist/
*.rpm
*.snap
# npm (platform binaries downloaded by postinstall)
npm/bin/agent-desktop-*
npm/node_modules/
node_modules/
# Compound Engineering
docs/
todos/

View file

@ -0,0 +1,3 @@
{
".": "0.1.0"
}

View file

@ -1,4 +1,25 @@
# agent-desktop
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Common Commands
```bash
cargo build # Debug build
cargo build --release # Release build (<15MB target)
cargo test --lib --workspace # Run all unit tests
cargo test --lib -p agent-desktop-core # Test core crate only
cargo test --lib -p agent-desktop-macos # Test macOS crate only
cargo test test_name # Run a single test by name
cargo clippy --all-targets -- -D warnings # Lint (must pass, zero warnings)
cargo fmt --all -- --check # Format check
cargo fmt --all # Auto-format
cargo tree -p agent-desktop-core # Verify no platform crate leaks (CI enforces)
```
Run the binary: `./target/release/agent-desktop snapshot --app Finder -i`
## Project Overview
Cross-platform Rust CLI + MCP server enabling AI agents to observe and control desktop applications via native OS accessibility trees.
@ -7,7 +28,19 @@ Cross-platform Rust CLI + MCP server enabling AI agents to observe and control d
- All commits are authored by **Lahfir**
- NEVER add `Co-Authored-By` lines, AI attribution badges, or "Generated with" footers
- NEVER include co-committers of any kind
- Commit messages: concise, imperative mood, focus on "why" not "what"
- **Conventional Commits required.** Every commit message must use a type prefix:
- `feat:` — new feature (triggers minor version bump)
- `fix:` — bug fix (triggers patch version bump)
- `feat!:` or `BREAKING CHANGE:` footer — breaking change (triggers major version bump)
- `docs:` — documentation only
- `style:` — formatting, no code change
- `refactor:` — code change that neither fixes a bug nor adds a feature
- `chore:` — maintenance tasks, dependencies
- `ci:` — CI/CD changes
- `test:` — adding or fixing tests
- Format: `type: concise imperative description` (lowercase type, no capital after colon)
- Focus on "why" not "what"
- Examples: `feat: add scroll-to command`, `fix: prevent stale ref on window resize`, `ci: add binary size check`
## Core Principle
@ -28,8 +61,11 @@ agent-desktop/
│ ├── windows/ # agent-desktop-windows (stub → Phase 2)
│ └── linux/ # agent-desktop-linux (stub → Phase 2)
├── src/ # agent-desktop binary (entry point)
│ ├── main.rs # mode detection, dispatch
│ └── cli.rs # clap derive structs
│ ├── main.rs # entry point, permission check, JSON envelope
│ ├── cli.rs # clap derive enum (Commands)
│ ├── cli_args.rs # all command argument structs
│ ├── dispatch.rs # command dispatcher + parse helpers
│ └── batch_dispatch.rs # batch command execution
└── tests/
├── fixtures/ # golden JSON snapshots
└── integration/ # macOS CI integration tests
@ -370,17 +406,20 @@ Target binary size: <15MB per platform.
- `cargo test --workspace`
- Binary size check: fail if release binary exceeds 15MB
## Phase 1 Command Scope (30 commands)
## Implemented Commands (50)
| Category | Commands |
|----------|----------|
| App/Window (5) | `launch`, `close-app`, `list-windows`, `list-apps`, `focus-window` |
| Observation (15) | `snapshot`, `screenshot`, `find`, `get` (text, value, title, bounds, role, states), `is` (visible, enabled, checked, focused, expanded) |
| Interaction (11) | `click`, `double-click`, `right-click`, `type`, `set-value`, `focus`, `select`, `toggle`, `expand`, `collapse`, `scroll` |
| Keyboard (1) | `press` |
| Clipboard (2) | `clipboard get`, `clipboard set` |
| Wait (3) | `wait` (ms), `wait --element`, `wait --window` |
| App/Window (10) | `launch`, `close-app`, `list-windows`, `list-apps`, `focus-window`, `resize-window`, `move-window`, `minimize`, `maximize`, `restore` |
| Observation (6) | `snapshot`, `screenshot`, `find`, `get`, `is`, `list-surfaces` |
| Interaction (14) | `click`, `double-click`, `triple-click`, `right-click`, `type`, `set-value`, `clear`, `focus`, `select`, `toggle`, `check`, `uncheck`, `expand`, `collapse` |
| Scroll (2) | `scroll`, `scroll-to` |
| Keyboard (3) | `press`, `key-down`, `key-up` |
| Mouse (5) | `hover`, `drag`, `mouse-move`, `mouse-click`, `mouse-down`, `mouse-up` |
| Clipboard (3) | `clipboard-get`, `clipboard-set`, `clipboard-clear` |
| Wait (1) | `wait` (with `--element`, `--window`, `--text`, `--menu` flags) |
| System (3) | `status`, `permissions`, `version` |
| Batch (1) | `batch` |
## Non-Goals

86
npm/bin/agent-desktop.js Executable file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const { existsSync, accessSync, chmodSync, constants } = require('fs');
const { dirname, join } = require('path');
const { platform, arch } = require('os');
const binDir = __dirname;
function getBinaryName() {
const os = platform();
const cpuArch = arch();
const platformMap = {
'darwin-arm64': 'agent-desktop-darwin-arm64',
'darwin-x64': 'agent-desktop-darwin-x64',
'linux-x64': 'agent-desktop-linux-x64',
'linux-arm64': 'agent-desktop-linux-arm64',
'win32-x64': 'agent-desktop-win32-x64.exe',
};
return platformMap[`${os}-${cpuArch}`] || null;
}
function main() {
const binaryName = getBinaryName();
if (!binaryName) {
console.error(`Error: Unsupported platform: ${platform()}-${arch()}`);
console.error('agent-desktop currently supports: macOS (ARM64, x64)');
console.error('Windows and Linux support is coming in Phase 2.');
console.error('See: https://github.com/lahfir/agent-desktop');
process.exit(1);
}
const binaryPath = join(binDir, binaryName);
if (!binaryPath || !existsSync(binaryPath)) {
console.error(`Error: Native binary not found for ${platform()}-${arch()}`);
console.error(`Expected: ${binaryPath}`);
console.error('');
console.error('Try reinstalling:');
console.error(' npm install -g agent-desktop');
console.error('');
console.error('Or download directly from:');
console.error(' https://github.com/lahfir/agent-desktop/releases');
if (typeof process.versions.bun !== 'undefined') {
console.error('');
console.error('Bun detected — postinstall scripts require --trust:');
console.error(' bun install -g --trust agent-desktop');
}
process.exit(1);
}
if (platform() !== 'win32') {
try {
accessSync(binaryPath, constants.X_OK);
} catch {
try {
chmodSync(binaryPath, 0o755);
} catch (err) {
console.error(`Error: Cannot make binary executable: ${err.message}`);
console.error('Try running: chmod +x ' + binaryPath);
process.exit(1);
}
}
}
const child = spawn(binaryPath, process.argv.slice(2), {
stdio: 'inherit',
windowsHide: false,
});
child.on('error', (err) => {
console.error(`Error executing binary: ${err.message}`);
process.exit(1);
});
child.on('close', (code) => {
process.exit(code ?? 0);
});
}
main();

33
npm/package.json Normal file
View file

@ -0,0 +1,33 @@
{
"name": "agent-desktop",
"version": "0.0.0-development",
"description": "AI agent tool for observing and controlling desktop applications via native OS accessibility trees",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/lahfir/agent-desktop.git"
},
"homepage": "https://github.com/lahfir/agent-desktop",
"bin": {
"agent-desktop": "./bin/agent-desktop.js"
},
"scripts": {
"postinstall": "node scripts/postinstall.js"
},
"files": [
"bin",
"scripts"
],
"engines": {
"node": ">=18"
},
"keywords": [
"desktop",
"automation",
"accessibility",
"cli",
"agent",
"macos",
"a11y"
]
}

288
npm/scripts/postinstall.js Normal file
View file

@ -0,0 +1,288 @@
#!/usr/bin/env node
const { existsSync, mkdirSync, chmodSync, createWriteStream, unlinkSync, renameSync, writeFileSync, symlinkSync, lstatSync } = require('fs');
const { readFileSync } = require('fs');
const { dirname, join } = require('path');
const { platform, arch } = require('os');
const { get } = require('https');
const { execSync } = require('child_process');
const { createHash } = require('crypto');
const projectRoot = join(__dirname, '..');
const binDir = join(projectRoot, 'bin');
const packageJson = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
const version = packageJson.version;
const GITHUB_REPO = 'lahfir/agent-desktop';
const MAX_RETRIES = 3;
const TIMEOUT_MS = 60000;
const TARGET_MAP = {
'darwin-arm64': 'aarch64-apple-darwin',
'darwin-x64': 'x86_64-apple-darwin',
'linux-x64': 'x86_64-unknown-linux-gnu',
'linux-arm64': 'aarch64-unknown-linux-gnu',
'win32-x64': 'x86_64-pc-windows-msvc',
};
const BINARY_NAME_MAP = {
'darwin-arm64': 'agent-desktop-darwin-arm64',
'darwin-x64': 'agent-desktop-darwin-x64',
'linux-x64': 'agent-desktop-linux-x64',
'linux-arm64': 'agent-desktop-linux-arm64',
'win32-x64': 'agent-desktop-win32-x64.exe',
};
const SUPPORTED_PLATFORMS = ['darwin'];
function log(msg) {
process.stderr.write(`agent-desktop: ${msg}\n`);
}
function getPlatformKey() {
return `${platform()}-${arch()}`;
}
function downloadWithRedirects(url, dest, timeout) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`Download timed out after ${timeout}ms`));
}, timeout);
const doRequest = (requestUrl) => {
const parsedUrl = new URL(requestUrl);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
headers: { 'User-Agent': `agent-desktop/${version}` },
};
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy;
if (proxy) {
log(`Using proxy: ${proxy}`);
}
get(requestUrl, options, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
doRequest(response.headers.location);
return;
}
if (response.statusCode !== 200) {
clearTimeout(timer);
reject(new Error(`HTTP ${response.statusCode} downloading ${requestUrl}`));
return;
}
const tmpDest = dest + '.tmp';
const file = createWriteStream(tmpDest);
response.pipe(file);
file.on('finish', () => {
file.close();
clearTimeout(timer);
try {
renameSync(tmpDest, dest);
resolve();
} catch (err) {
reject(err);
}
});
}).on('error', (err) => {
clearTimeout(timer);
reject(err);
});
};
doRequest(url);
});
}
async function downloadWithRetry(url, dest) {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
await downloadWithRedirects(url, dest, TIMEOUT_MS);
return;
} catch (err) {
if (attempt === MAX_RETRIES) throw err;
const delay = Math.pow(2, attempt) * 1000;
log(`Download failed (attempt ${attempt}/${MAX_RETRIES}): ${err.message}`);
log(`Retrying in ${delay / 1000}s...`);
await new Promise((r) => setTimeout(r, delay));
}
}
}
function readFileContent(path) {
return readFileSync(path, 'utf8');
}
function verifyChecksum(filePath, expectedHash) {
const fileBuffer = readFileSync(filePath);
const hash = createHash('sha256').update(fileBuffer).digest('hex');
return hash === expectedHash;
}
async function fixGlobalInstallBin() {
if (platform() === 'win32') return;
let npmBinDir;
try {
const prefix = execSync('npm prefix -g', { encoding: 'utf8' }).trim();
npmBinDir = join(prefix, 'bin');
} catch {
return;
}
const symlinkPath = join(npmBinDir, 'agent-desktop');
const platformKey = getPlatformKey();
const binaryName = BINARY_NAME_MAP[platformKey];
if (!binaryName) return;
const binaryPath = join(binDir, binaryName);
try {
const stat = lstatSync(symlinkPath);
if (!stat.isSymbolicLink()) return;
} catch {
return;
}
try {
unlinkSync(symlinkPath);
symlinkSync(binaryPath, symlinkPath);
log('Optimized: symlink points to native binary (zero overhead)');
} catch (err) {
log(`Could not optimize symlink: ${err.message}`);
log('CLI will work via Node.js wrapper (slightly slower startup)');
}
}
async function main() {
if (process.env.AGENT_DESKTOP_SKIP_DOWNLOAD === '1') {
log('Skipping binary download (AGENT_DESKTOP_SKIP_DOWNLOAD=1)');
return;
}
const platformKey = getPlatformKey();
const target = TARGET_MAP[platformKey];
const binaryName = BINARY_NAME_MAP[platformKey];
if (!target || !binaryName) {
if (!SUPPORTED_PLATFORMS.includes(platform())) {
log(`agent-desktop currently supports macOS only.`);
log(`Windows and Linux support is coming in Phase 2.`);
log(`See: https://github.com/${GITHUB_REPO}`);
return;
}
log(`Unsupported architecture: ${platformKey}`);
return;
}
if (!SUPPORTED_PLATFORMS.includes(platform())) {
log(`agent-desktop currently supports macOS only.`);
log(`${platform()} support is coming in Phase 2.`);
log(`See: https://github.com/${GITHUB_REPO}`);
return;
}
const binaryPath = join(binDir, binaryName);
if (process.env.AGENT_DESKTOP_BINARY_PATH) {
const customPath = process.env.AGENT_DESKTOP_BINARY_PATH;
if (existsSync(customPath)) {
try {
const content = readFileSync(customPath);
writeFileSync(binaryPath, content);
chmodSync(binaryPath, 0o755);
log(`Using binary from AGENT_DESKTOP_BINARY_PATH: ${customPath}`);
await fixGlobalInstallBin();
return;
} catch (err) {
log(`Failed to copy from AGENT_DESKTOP_BINARY_PATH: ${err.message}`);
}
} else {
log(`AGENT_DESKTOP_BINARY_PATH not found: ${customPath}`);
}
}
if (existsSync(binaryPath)) {
if (platform() !== 'win32') {
chmodSync(binaryPath, 0o755);
}
log(`Native binary ready: ${binaryName}`);
await fixGlobalInstallBin();
return;
}
if (!existsSync(binDir)) {
mkdirSync(binDir, { recursive: true });
}
const tarball = `agent-desktop-v${version}-${target}.tar.gz`;
const tarballUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/${tarball}`;
const checksumsUrl = `https://github.com/${GITHUB_REPO}/releases/download/v${version}/checksums.txt`;
const tarballPath = join(binDir, tarball);
const checksumsPath = join(binDir, 'checksums.txt');
log(`Downloading native binary for ${platformKey}...`);
try {
await downloadWithRetry(tarballUrl, tarballPath);
log(`Downloaded: ${tarball}`);
try {
await downloadWithRetry(checksumsUrl, checksumsPath);
const checksums = readFileContent(checksumsPath);
const expectedLine = checksums.split('\n').find((line) => line.includes(tarball));
if (expectedLine) {
const expectedHash = expectedLine.split(/\s+/)[0];
if (!verifyChecksum(tarballPath, expectedHash)) {
log('WARNING: Checksum verification failed. Binary may be corrupted.');
log('Try reinstalling: npm install -g agent-desktop');
unlinkSync(tarballPath);
unlinkSync(checksumsPath);
return;
}
log('Checksum verified');
}
unlinkSync(checksumsPath);
} catch (err) {
log(`Could not verify checksum: ${err.message}`);
}
execSync(`tar -xzf "${tarballPath}" -C "${binDir}"`, { stdio: 'pipe' });
const extractedBinary = join(binDir, 'agent-desktop');
if (existsSync(extractedBinary) && extractedBinary !== binaryPath) {
renameSync(extractedBinary, binaryPath);
}
if (platform() !== 'win32') {
chmodSync(binaryPath, 0o755);
}
unlinkSync(tarballPath);
log(`Installed native binary: ${binaryName}`);
} catch (err) {
log(`Could not download native binary: ${err.message}`);
log('');
log('You can download manually from:');
log(` ${tarballUrl}`);
log('');
log(`Then place the binary at: ${binaryPath}`);
try {
if (existsSync(tarballPath)) unlinkSync(tarballPath);
if (existsSync(checksumsPath)) unlinkSync(checksumsPath);
} catch {}
return;
}
await fixGlobalInstallBin();
}
main().catch((err) => {
log(`Postinstall error: ${err.message}`);
process.exit(0);
});

View file

@ -0,0 +1,29 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "rust",
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": true,
"packages": {
".": {
"package-name": "agent-desktop",
"changelog-path": "CHANGELOG.md",
"extra-files": [
"npm/package.json"
]
}
},
"plugins": [
"cargo-workspace"
],
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactoring", "hidden": true },
{ "type": "docs", "section": "Documentation", "hidden": true },
{ "type": "style", "hidden": true },
{ "type": "chore", "hidden": true },
{ "type": "ci", "hidden": true },
{ "type": "test", "hidden": true }
]
}