Skip to content
Open
12 changes: 9 additions & 3 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,15 @@ if [ -f scripts/build-binaries.sh ] && [ -f packages/cli/src/cli.ts ]; then
packages/cli/src/cli.ts

# Smoke test: the binary must start and exit 0 on a safe, non-interactive command.
# `version` or `--help` are both acceptable — pick one that does NOT touch the
# network, database, or require env vars.
if ! "$TMP_BINARY" version > /tmp/archon-preflight.log 2>&1; then
# Use `--help` (NOT `version`). The `version` command's compiled-binary code
# path depends on BUNDLED_IS_BINARY=true, which is set by scripts/build-binaries.sh
# — but we're doing a bare `bun build --compile` here to keep the smoke fast,
# so BUNDLED_IS_BINARY is still `false`. That sends `version` down the dev
# branch of version.ts which tries to read package.json from a path that only
# exists in node_modules, producing a false-positive ENOENT. `--help` has no
# such dev/binary branch and exercises the same module-init graph we're
# actually testing. Must NOT touch network, database, or require env vars.
if ! "$TMP_BINARY" --help > /tmp/archon-preflight.log 2>&1; then
echo "ERROR: compiled binary crashed at startup"
cat /tmp/archon-preflight.log
echo ""
Expand Down
33 changes: 33 additions & 0 deletions .claude/skills/test-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ About to test:
Path: brew (Homebrew tap on macOS)
Version: 0.3.1 (expected)
Cleanup: will uninstall after tests (brew uninstall + untap)
If `archon-stable` symlink is detected in Phase 2, it will be
restored at the end of Phase 5 by reinstalling the tap formula.

Proceed? (y/N)
```
Expand Down Expand Up @@ -112,6 +114,18 @@ gh release view v<version> --repo coleam00/Archon --json tagName,assets --jq '{t

If the release does not exist or has no assets, abort with a clear message. Do not proceed to install a non-existent release.

4. **Detect persistent `archon-stable` install (brew path only).** If the user has renamed a prior brew install to `archon-stable` (the dual-homebrew pattern — see `~/.config/fish/functions/brew-upgrade-archon.fish`), Phase 5's `brew uninstall` will wipe it. Capture the state so Phase 5b can restore it:

```bash
ARCHON_STABLE_WAS_INSTALLED=""
if [ -L /opt/homebrew/bin/archon-stable ] || [ -L /usr/local/bin/archon-stable ]; then
ARCHON_STABLE_WAS_INSTALLED="yes"
echo "Detected persistent archon-stable — will restore after Phase 5 uninstall."
fi
```

Export `ARCHON_STABLE_WAS_INSTALLED` into the environment used by Phase 5b. Only applies to the `brew` path — `curl-mac` and `curl-vps` don't go through brew and don't disturb `archon-stable`.

## Phase 3 — Install

### Path: brew
Expand Down Expand Up @@ -352,6 +366,25 @@ archon version | head -1
# should match the dev version captured in Phase 2
```

**Restore `archon-stable` if it existed before the test** (dual-homebrew pattern — see Phase 2 item 4):

```bash
if [ -n "$ARCHON_STABLE_WAS_INSTALLED" ]; then
echo "Restoring archon-stable (detected before test)..."
brew tap coleam00/archon
brew install coleam00/archon/archon
BREW_BIN="$(brew --prefix)/bin"
if [ -e "$BREW_BIN/archon" ]; then
mv "$BREW_BIN/archon" "$BREW_BIN/archon-stable"
echo "archon-stable restored: $(archon-stable version 2>/dev/null | head -1)"
else
echo "WARNING: brew install succeeded but $BREW_BIN/archon missing — check formula"
fi
fi
```

> **Note on the restored version**: this reinstalls from whatever the tap currently ships, which is typically the release you just tested (so `archon-stable` ends up at the newly-tested version). That's usually what the operator wants — you just verified the new release works, and you want `archon-stable` pointed at it. If you were testing an older version for back-version QA, the restored `archon-stable` will be the *current* tap formula, not the pre-test version. For that rare case, the operator should re-run `brew-upgrade-archon` manually after the test.

### Path: curl-mac

```bash
Expand Down
10 changes: 5 additions & 5 deletions homebrew/archon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,28 @@
class Archon < Formula
desc "Remote agentic coding platform - control AI assistants from anywhere"
homepage "https://github.com/coleam00/Archon"
version "0.3.6"
version "0.3.9"
license "MIT"

on_macos do
on_arm do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-arm64"
sha256 "96b6dac50b046eece9eddbb988a0c39b4f9a0e2faac66e49b977ba6360069e86"
sha256 "b617f85a2181938b793b25ad816a9f6b3149d184f64b2e9e2ea2430f27778d64"
end
on_intel do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-darwin-x64"
sha256 "09f1dbe12417b4300b7b07b531eb7391a286305f8d4eafc11e7f61f5d26eb8eb"
sha256 "5a928af5e0e67ffe084159161a9ea3994a9304cc39bd06132719cd89cc715e86"
end
end

on_linux do
on_arm do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-arm64"
sha256 "80b06a6ff699ec57cd4a3e49cfe7b899a3e8212688d70285f5a887bf10086731"
sha256 "567bfca9175e10d9b4fd748e3862bbd34141a234766a7ecf0a714d9c27b8c92e"
end
on_intel do
url "https://github.com/coleam00/Archon/releases/download/v#{version}/archon-linux-x64"
sha256 "09f5dac6db8037ed6f3e5b7e9c5eb8e37f19822a4ed2bf4cd7e654780f9d00de"
sha256 "c918218df2f0f853d107e6b1727dcd9accc034b183ffbccea93a331d8d376ed8"
end
end

Expand Down
47 changes: 46 additions & 1 deletion packages/providers/src/claude/binary-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,52 @@ describe('resolveClaudeBinaryPath (binary mode)', () => {
expect(result).toBe('/env/cli.js');
});

test('throws with install instructions when nothing configured', async () => {
test('autodetects native installer path when env and config are unset', async () => {
const home = process.env.HOME ?? '/Users/test';
const expected =
process.platform === 'win32'
? `${home}\\.local\\bin\\claude.exe`
: `${home}/.local/bin/claude`;
// File exists only at the native-installer path.
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === expected
);

const result = await resolver.resolveClaudeBinaryPath();
expect(result).toBe(expected);
// Log must mark this as autodetect, not 'env' or 'config' — the source
// string is load-bearing for debug triage.
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: expected, source: 'autodetect' },
'claude.binary_resolved'
);
});

test('env var takes precedence over autodetect when both would match', async () => {
process.env.CLAUDE_BIN_PATH = '/custom/env/claude';
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);

const result = await resolver.resolveClaudeBinaryPath();
expect(result).toBe('/custom/env/claude');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/custom/env/claude', source: 'env' },
'claude.binary_resolved'
);
});

test('config takes precedence over autodetect when both would match', async () => {
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(true);

const result = await resolver.resolveClaudeBinaryPath('/custom/config/claude');
expect(result).toBe('/custom/config/claude');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/custom/config/claude', source: 'config' },
'claude.binary_resolved'
);
});

test('throws with install instructions when nothing is configured and autodetect misses', async () => {
// Every probe returns false — env unset, config unset, native path absent.
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);

const promise = resolver.resolveClaudeBinaryPath();
Expand Down
26 changes: 24 additions & 2 deletions packages/providers/src/claude/binary-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@
* Resolution order (binary mode only):
* 1. `CLAUDE_BIN_PATH` environment variable
* 2. `assistants.claude.claudeBinaryPath` in config
* 3. Throw with install instructions
* 3. Autodetect canonical install path (native installer default)
* 4. Throw with install instructions
*
* In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the caller
* omits `pathToClaudeCodeExecutable` entirely and the SDK resolves via its
* normal node_modules lookup.
*/
import { existsSync as _existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { BUNDLED_IS_BINARY, createLogger } from '@archon/paths';

/** Wrapper for existsSync — enables spyOn in tests (direct imports can't be spied on). */
Expand Down Expand Up @@ -89,6 +92,25 @@ export async function resolveClaudeBinaryPath(
return configClaudeBinaryPath;
}

// 3. Not found — throw with install instructions
// 3. Autodetect — the Anthropic native installer
// (`curl -fsSL https://claude.ai/install.sh | bash` on macOS/Linux,
// `irm https://claude.ai/install.ps1 | iex` on Windows) writes the
// executable to a fixed location relative to $HOME. Users who follow
// the recommended install path don't need any env var or config entry;
// users who deviate (npm global, custom path, etc.) still set one of
// the higher-priority sources above.
const nativeInstallerPath =
process.platform === 'win32'
? join(homedir(), '.local', 'bin', 'claude.exe')
: join(homedir(), '.local', 'bin', 'claude');
if (fileExists(nativeInstallerPath)) {
getLog().info(
{ binaryPath: nativeInstallerPath, source: 'autodetect' },
'claude.binary_resolved'
);
return nativeInstallerPath;
}

// 4. Not found — throw with install instructions
throw new Error(INSTALL_INSTRUCTIONS);
}
63 changes: 63 additions & 0 deletions packages/providers/src/codex/binary-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,70 @@ describe('resolveCodexBinaryPath (binary mode)', () => {
expect(normalized).toContain('/tmp/test-archon-home/vendor/codex/');
});

test('autodetects npm global install at ~/.npm-global/bin/codex (POSIX)', async () => {
if (process.platform === 'win32') return; // POSIX-only probe
const home = process.env.HOME ?? '/Users/test';
const expected = `${home}/.npm-global/bin/codex`;
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === expected
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe(expected);
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: expected, source: 'autodetect' },
'codex.binary_resolved'
);
});

test('autodetects homebrew install on Apple Silicon', async () => {
if (process.platform !== 'darwin' || process.arch !== 'arm64') {
// `/opt/homebrew/bin/codex` is only probed on darwin-arm64; on other
// hosts this test has nothing to assert (the probe list excludes it).
return;
}
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === '/opt/homebrew/bin/codex'
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe('/opt/homebrew/bin/codex');
expect(mockLogger.info).toHaveBeenCalledWith(
{ binaryPath: '/opt/homebrew/bin/codex', source: 'autodetect' },
'codex.binary_resolved'
);
});

test('autodetects system install at /usr/local/bin/codex', async () => {
if (process.platform === 'win32') {
// /usr/local/bin is not probed on Windows.
return;
}
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation(
(path: string) => path === '/usr/local/bin/codex'
);

const result = await resolver.resolveCodexBinaryPath();
expect(result).toBe('/usr/local/bin/codex');
});

test('vendor directory takes precedence over autodetect', async () => {
// Both vendor and npm-global would match; vendor must win (lower tier #).
fileExistsSpy = spyOn(resolver, 'fileExists').mockImplementation((path: string) => {
const normalized = path.replace(/\\/g, '/');
return normalized.includes('vendor/codex') || normalized.includes('.npm-global');
});

const result = await resolver.resolveCodexBinaryPath();
expect(result!.replace(/\\/g, '/')).toContain('/vendor/codex/');
expect(mockLogger.info).toHaveBeenCalledWith(
expect.objectContaining({ source: 'vendor' }),
'codex.binary_resolved'
);
});

test('throws with install instructions when binary not found anywhere', async () => {
// Env unset, config unset, vendor dir empty, every autodetect path missing.
fileExistsSpy = spyOn(resolver, 'fileExists').mockReturnValue(false);

await expect(resolver.resolveCodexBinaryPath()).rejects.toThrow('Codex CLI binary not found');
Expand Down
62 changes: 60 additions & 2 deletions packages/providers/src/codex/binary-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
* 1. `CODEX_BIN_PATH` environment variable
* 2. `assistants.codex.codexBinaryPath` in config
* 3. `~/.archon/vendor/codex/<platform-binary>` (user-placed)
* 4. Throw with install instructions
* 4. Autodetect canonical install paths (npm prefix defaults per platform)
* 5. Throw with install instructions
*
* In dev mode (BUNDLED_IS_BINARY=false), returns undefined so the SDK
* uses its normal node_modules-based resolution.
*/
import { existsSync as _existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { BUNDLED_IS_BINARY, getArchonHome, createLogger } from '@archon/paths';

Expand Down Expand Up @@ -89,7 +91,19 @@ export async function resolveCodexBinaryPath(
}
}

// 4. Not found — throw with install instructions
// 4. Autodetect — probe the handful of paths Codex typically lands at
// when installed via the documented package managers. Users who install
// somewhere else (custom npm prefix, etc.) still set one of the higher-
// priority sources above. Order: most specific → least specific.
const autodetectPaths = getAutodetectPaths();
for (const probePath of autodetectPaths) {
if (fileExists(probePath)) {
getLog().info({ binaryPath: probePath, source: 'autodetect' }, 'codex.binary_resolved');
return probePath;
}
}

// 5. Not found — throw with install instructions
const vendorPath = `~/.archon/${CODEX_VENDOR_DIR}/`;
throw new Error(
'Codex CLI binary not found. The Codex provider requires a native binary\n' +
Expand All @@ -105,3 +119,47 @@ export async function resolveCodexBinaryPath(
' codexBinaryPath: /path/to/codex\n'
);
}

/**
* Canonical install locations probed by tier 4 autodetect. Grounded in
* the official @openai/codex README and the npm global-install contract
* (npm writes the binary to `{npm_prefix}/bin/<name>` on POSIX and
* `{npm_prefix}\<name>.cmd` on Windows). The probes cover the npm prefix
* a default install lands at on each platform:
*
* - `$HOME/.npm-global/bin/codex` — common when the user ran
* `npm config set prefix ~/.npm-global` to avoid root writes
* - `/opt/homebrew/bin/codex` — mac Apple Silicon with homebrew-node
* (homebrew sets npm prefix to /opt/homebrew)
* - `/usr/local/bin/codex` — mac Intel with homebrew-node, or linux
* with system-installed node (npm prefix defaults to /usr/local)
* - `%AppData%\npm\codex.cmd` — Windows npm global default
*
* Not covered (explicit override required via CODEX_BIN_PATH or config):
* - users with other custom npm prefixes — `npm root -g` would spawn
* a subprocess per resolve, too heavy for a probe helper
* - Homebrew cask install (`brew install --cask codex`) — cask layout
* isn't a PATH binary; users should symlink or set the path
* - manual GitHub Releases extract — placement is user-determined
*/
function getAutodetectPaths(): string[] {
const paths: string[] = [];

if (process.platform === 'win32') {
const appData = process.env.APPDATA;
if (appData) paths.push(join(appData, 'npm', 'codex.cmd'));
paths.push(join(homedir(), '.npm-global', 'codex.cmd'));
return paths;
}

// POSIX (macOS + Linux)
paths.push(join(homedir(), '.npm-global', 'bin', 'codex'));

if (process.platform === 'darwin' && process.arch === 'arm64') {
paths.push('/opt/homebrew/bin/codex');
}

paths.push('/usr/local/bin/codex');

return paths;
}
15 changes: 15 additions & 0 deletions packages/providers/src/community/pi/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,21 @@ describe('PiProvider', () => {
expect(new PiProvider().getCapabilities()).toEqual(PI_CAPABILITIES);
});

test('sendQuery installs PI_PACKAGE_DIR shim before Pi SDK loads', async () => {
// Runtime-safety regression: Pi's config.js reads `getPackageJsonPath()` at
// its module init, which resolves to a non-existent path inside compiled
// archon binaries. The shim writes a stub package.json to tmpdir and sets
// PI_PACKAGE_DIR so Pi's short-circuit kicks in. Must run BEFORE the
// dynamic imports in sendQuery — we verify by calling the fast-fail "no
// model" path (which returns before any Pi SDK logic executes) and
// asserting the env var was set regardless.
delete process.env.PI_PACKAGE_DIR;
expect(process.env.PI_PACKAGE_DIR).toBeUndefined();
await consume(new PiProvider().sendQuery('hi', '/tmp'));
expect(process.env.PI_PACKAGE_DIR).toBeDefined();
expect(process.env.PI_PACKAGE_DIR).toContain('archon-pi-shim');
});

test('throws when no model is configured', async () => {
const { error } = await consume(new PiProvider().sendQuery('hi', '/tmp'));
expect(error?.message).toContain('Pi provider requires a model');
Expand Down
Loading