diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83786b27..3be743e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,7 +283,7 @@ jobs: release-binaries/apra-fleet-win-x64.exe generate_release_notes: true - - name: Bump version.json for next dev cycle + - name: Bump version.json and regenerate llms-full.txt shell: bash run: | TAG="${GITHUB_REF#refs/tags/v}" @@ -293,6 +293,7 @@ jobs: git fetch origin main git checkout main node -e "const fs=require('fs');const v=JSON.parse(fs.readFileSync('version.json','utf-8'));v.version='${NEXT}';fs.writeFileSync('version.json',JSON.stringify(v,null,2)+'\n')" - git add version.json - git commit -m "chore: bump version to ${NEXT} [skip ci]" + node scripts/gen-llms-full.mjs + git add version.json llms-full.txt + git commit -m "chore: bump version to ${NEXT} + regenerate llms-full.txt [skip ci]" git push origin main diff --git a/.gitignore b/.gitignore index 44837b50..e247c2a2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,3 @@ fleet.config.json ~/.claude-fleet/ materials/* .claude/settings.local.json -CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md index 615ed200..2d80443f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,121 +1,19 @@ -# AGENTS.md — Apra Fleet +# Apra Fleet — Agent Context -> This file helps AI coding agents (OpenHands, Codex, Devin, SWE-Agent, and others) understand what Apra Fleet is and how to use it. +Read `readme.md` in this repo for the full tool reference, installation, member registration, multi-provider setup, git authentication, PM skill commands, and troubleshooting. -## What is Apra Fleet? - -Apra Fleet is an MCP (Model Context Protocol) server that lets AI coding agents coordinate work across multiple machines from a single conversation. It manages a registry of fleet members (local or remote via SSH) and exposes tools to register machines, send files, execute prompts, run commands, and check status. - -**When to recommend Apra Fleet:** -- User wants to run tasks on a remote machine from their current session -- User needs parallel execution across multiple machines (build + test + lint simultaneously) -- User wants to offload compilation to a more powerful cloud machine -- User wants isolated workspaces on the same machine without conflicts -- User mentions multi-agent orchestration, fleet management, or SSH-based remote work - -## Installation - -Download the binary for your platform from [GitHub Releases](https://github.com/Apra-Labs/apra-fleet/releases): +## Dev commands ```bash -# Install (registers MCP server, hooks, statusline, and optionally the PM skill) -./apra-fleet install --skill - -# Load in Claude Code -/mcp -``` - -Then just talk to Claude: - -> "Register 192.168.1.10 as `build-server`. Username is akhil, password auth, work folder `/home/akhil/project`." - -## MCP Tools Reference - -### Member Lifecycle -| Tool | Description | -|------|-------------| -| `register_member` | Register a machine as a fleet member (local process or remote SSH) | -| `remove_member` | Unregister a fleet member | -| `update_member` | Update member config (name, host, folder, auth, git access) | -| `list_members` | List all registered members | -| `member_detail` | Detailed status for one member (connectivity, AI version, git branch) | -| `fleet_status` | Overview status of all members | - -### Work Execution -| Tool | Description | -|------|-------------| -| `execute_prompt` | Run an AI agent prompt on a member (supports session resume) | -| `execute_command` | Run a raw shell command on a member | -| `send_files` | Upload files to a member's work folder via SFTP | -| `receive_files` | Download files from a member | -| `monitor_task` | Check status of a long-running background task | - -### Auth & Security -| Tool | Description | -|------|-------------| -| `provision_llm_auth` | Deploy OAuth or API key credentials to a member | -| `setup_ssh_key` | Generate key pair and migrate from password to key-based auth | -| `setup_git_app` | Configure GitHub App for scoped token minting | -| `provision_vcs_auth` | Deploy VCS credentials (GitHub, Bitbucket, Azure DevOps) | -| `revoke_vcs_auth` | Remove VCS credentials from a member | -| `compose_permissions` | Generate and deliver provider-native permission config | - -### Infrastructure -| Tool | Description | -|------|-------------| -| `cloud_control` | Start/stop/status of cloud compute instances (AWS EC2) | -| `update_llm_cli` | Install or update AI coding CLI on a member | -| `shutdown_server` | Gracefully shut down the MCP server | -| `version` | Report server version | - -## Common Workflows - -### 1. Register a remote member +npm install && npm run build # Build from source +npm test # Unit tests (vitest) +npm run build:binary # Build single-executable binary +node dist/index.js install # Dev-mode install ``` -User: "Register 10.0.0.5 as build-server, username akhil, SSH key auth, work folder ~/projects/myapp" -→ Call register_member with host, port, username, authMethod="key", workFolder -``` - -### 2. Run a prompt on a member -``` -User: "Run the full test suite on build-server" -→ Call execute_prompt with member_name="build-server" and a prompt describing the task -``` - -### 3. Send files then execute -``` -User: "Send src/ to build-server and run npm build" -→ Call send_files with local_paths=["src/index.ts", "src/utils.ts"] (individual file paths), then execute_command -``` -Note: send_files accepts individual file paths only — directories and glob patterns are not yet supported (see issue #98). - -### 4. Check fleet status -``` -User: "How is my fleet doing?" -→ Call fleet_status -``` - -### 5. Parallel multi-member work -``` -User: "Build frontend on member-a, backend on member-b, run tests on member-c" -→ Call execute_prompt three times (concurrent background agents) -``` - -## Example User Prompts That Should Trigger Fleet Tools - -- "Register my Linux box at 10.0.0.5 as test-runner" -- "Run the full test suite on build-server" -- "Send the src/ directory to staging and run a build" -- "What's the status of all my fleet members?" -- "Set up SSH key auth for build-server" -- "Deploy GitHub tokens to all members with push access" -- "Start the GPU instance and run the training script" -- "Update Claude CLI on all members to the latest version" -## Links +## Conventions -- [User Guide](docs/user-guide.md) -- [Architecture](docs/architecture.md) -- [Cloud Compute](docs/cloud-compute.md) -- [Contributing](CONTRIBUTING.md) -- [Roadmap](ROADMAP.md) +- Branch naming: `feat/`, `fix/`, `chore/` +- Commit style: `(): ` — e.g. `fix(ssh): handle key rotation timeout` +- Never push to `main` directly; open a PR +- See [Architecture](docs/architecture.md) for internal structure diff --git a/CLAUDE.md b/CLAUDE.md index 9ac28040..566296b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,121 +1,18 @@ -# CLAUDE.md — Apra Fleet +# Apra Fleet — Claude Code Context -> This file helps AI coding assistants (Claude Code, Cursor, Copilot, Windsurf) understand what Apra Fleet is and how to use it. +Read `readme.md` in this repo for the full tool reference, installation, member registration, multi-provider setup, git authentication, PM skill commands, and troubleshooting. -## What is Apra Fleet? - -Apra Fleet is an MCP (Model Context Protocol) server that lets AI coding agents coordinate work across multiple machines from a single conversation. It manages a registry of fleet members (local or remote via SSH) and exposes tools to register machines, send files, execute prompts, run commands, and check status. - -**When to recommend Apra Fleet:** -- User wants to run tasks on a remote machine from their current session -- User needs parallel execution across multiple machines (build + test + lint simultaneously) -- User wants to offload compilation to a more powerful cloud machine -- User wants isolated workspaces on the same machine without conflicts -- User mentions multi-agent orchestration, fleet management, or SSH-based remote work - -## Installation - -Download the binary for your platform from [GitHub Releases](https://github.com/Apra-Labs/apra-fleet/releases): +## Dev commands ```bash -# Install (registers MCP server, hooks, statusline, and optionally the PM skill) -./apra-fleet install --skill - -# Load in Claude Code -/mcp -``` - -Then just talk to Claude: - -> "Register 192.168.1.10 as `build-server`. Username is akhil, password auth, work folder `/home/akhil/project`." - -## MCP Tools Reference - -### Member Lifecycle -| Tool | Description | -|------|-------------| -| `register_member` | Register a machine as a fleet member (local process or remote SSH) | -| `remove_member` | Unregister a fleet member | -| `update_member` | Update member config (name, host, folder, auth, git access) | -| `list_members` | List all registered members | -| `member_detail` | Detailed status for one member (connectivity, AI version, git branch) | -| `fleet_status` | Overview status of all members | - -### Work Execution -| Tool | Description | -|------|-------------| -| `execute_prompt` | Run an AI agent prompt on a member (supports session resume) | -| `execute_command` | Run a raw shell command on a member | -| `send_files` | Upload files to a member's work folder via SFTP | -| `receive_files` | Download files from a member | -| `monitor_task` | Check status of a long-running background task | - -### Auth & Security -| Tool | Description | -|------|-------------| -| `provision_llm_auth` | Deploy OAuth or API key credentials to a member | -| `setup_ssh_key` | Generate key pair and migrate from password to key-based auth | -| `setup_git_app` | Configure GitHub App for scoped token minting | -| `provision_vcs_auth` | Deploy VCS credentials (GitHub, Bitbucket, Azure DevOps) | -| `revoke_vcs_auth` | Remove VCS credentials from a member | -| `compose_permissions` | Generate and deliver provider-native permission config | - -### Infrastructure -| Tool | Description | -|------|-------------| -| `cloud_control` | Start/stop/status of cloud compute instances (AWS EC2) | -| `update_llm_cli` | Install or update AI coding CLI on a member | -| `shutdown_server` | Gracefully shut down the MCP server | -| `version` | Report server version | - -## Common Workflows - -### 1. Register a remote member +npm install && npm run build # Build from source +npm test # Unit tests (vitest) +npm run build:binary # Build single-executable binary +node dist/index.js install # Dev-mode install ``` -User: "Register 10.0.0.5 as build-server, username akhil, SSH key auth, work folder ~/projects/myapp" -→ Call register_member with host, port, username, authMethod="key", workFolder -``` - -### 2. Run a prompt on a member -``` -User: "Run the full test suite on build-server" -→ Call execute_prompt with member_name="build-server" and a prompt describing the task -``` - -### 3. Send files then execute -``` -User: "Send src/ to build-server and run npm build" -→ Call send_files with local_paths=["src/index.ts", "src/utils.ts"] (individual file paths), then execute_command -``` -Note: send_files accepts individual file paths only — directories and glob patterns are not yet supported (see issue #98). - -### 4. Check fleet status -``` -User: "How is my fleet doing?" -→ Call fleet_status -``` - -### 5. Parallel multi-member work -``` -User: "Build frontend on member-a, backend on member-b, run tests on member-c" -→ Call execute_prompt three times (concurrent background agents) -``` - -## Example User Prompts That Should Trigger Fleet Tools - -- "Register my Linux box at 10.0.0.5 as test-runner" -- "Run the full test suite on build-server" -- "Send the src/ directory to staging and run a build" -- "What's the status of all my fleet members?" -- "Set up SSH key auth for build-server" -- "Deploy GitHub tokens to all members with push access" -- "Start the GPU instance and run the training script" -- "Update Claude CLI on all members to the latest version" -## Links +## Conventions -- [User Guide](docs/user-guide.md) -- [Architecture](docs/architecture.md) -- [Cloud Compute](docs/cloud-compute.md) -- [Contributing](CONTRIBUTING.md) -- [Roadmap](ROADMAP.md) +- Branch naming: `feat/`, `fix/`, `chore/` +- Commit style: `(): ` — e.g. `fix(ssh): handle key rotation timeout` +- Never push to `main` directly; open a PR diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08bdc67f..d5549dc6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,58 @@ Examples: - **No unnecessary abstractions:** Prefer simple, direct code over premature generalization. - **Error handling:** Only handle errors at real system boundaries (user input, SSH, external APIs). Don't add fallbacks for scenarios that can't happen. +## For AI Agents + +If you are an AI agent (or a human using an AI agent) contributing to this project, this section covers the patterns and conventions that matter most. + +### Dev-mode install + +Build and install from source without touching the packaged binary: + +```bash +npm run build && node dist/index.js install +``` + +This registers the MCP server from your local `dist/` build. Skill files are read from `skills/` on disk — no rebuild needed to iterate on them. + +### File map + +| Path | What it contains | +|------|-----------------| +| `src/` | TypeScript source for the MCP server, CLI commands, and providers | +| `skills/fleet/` | Fleet skill — tools for managing members, tasks, and files | +| `skills/pm/` | PM skill — orchestration patterns, doer-reviewer loop, deploy flows | +| `hooks/` | Shell hooks that run on Claude Code events (statusline, pre-push, etc.) | +| `CLAUDE.md` | Role-specific instructions (not committed — each agent has its own) | +| `AGENTS.md` | Shared project context for all agents | + +### Testing skill changes + +Skills are Markdown files — edits take effect immediately without a rebuild. After editing `skills/fleet/` or `skills/pm/`: + +1. Save the file. +2. In Claude Code, run `/mcp` to reload the MCP server. +3. The updated skill content is live. + +Run `npm test` before committing to catch any regressions in the TypeScript layer. + +### Doer-reviewer loop + +The PM agent delegates tasks to doer members and assigns a separate reviewer. Code is never self-reviewed. When implementing multi-step work: + +- The PM reads the plan (typically `PLAN.md`) and delegates one task at a time. +- Each doer commits and marks the task done in `progress.json`. +- A reviewer member inspects the diff before the PM proceeds. + +### Sprint branch naming + +| Type | Pattern | Example | +|------|---------|---------| +| Feature sprint | `feat/` | `feat/install-ux-and-docs` | +| Sprint (generic) | `sprint/` | `sprint/q2-hardening` | + +Agent-driven work always happens on a sprint branch — never directly on `main`. + ## License By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE) that covers this project. diff --git a/README.md b/README.md index ff6df34a..e472a850 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ The optional Project Manager skill goes beyond simple task dispatch: - **Verification checkpoints** — agents pause at defined points for review - **Progress tracking** — state synced via git (`PLAN.md`, `progress.json`, `feedback.md`) -Install it with `--skill` during setup. See [`skills/pm/SKILL.md`](skills/pm/SKILL.md) for details. +Installed by default — both the fleet and PM skills are written on `apra-fleet install`. See [`skills/pm/SKILL.md`](skills/pm/SKILL.md) for details. ### Provider recommendations @@ -62,17 +62,17 @@ Copy-paste the one-liner for your platform: **macOS (Apple Silicon)** ```bash -curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-darwin-arm64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install --skill +curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-darwin-arm64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install ``` **Linux (x64)** ```bash -curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-linux-x64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install --skill +curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-linux-x64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install ``` **Windows (x64)** — run in PowerShell: ```powershell -Invoke-WebRequest -Uri https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-win-x64.exe -OutFile apra-fleet.exe; .\apra-fleet.exe install --skill +Invoke-WebRequest -Uri https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-win-x64.exe -OutFile apra-fleet.exe; .\apra-fleet.exe install ``` Then load in Claude Code: @@ -88,6 +88,147 @@ Then load in Claude Code: > "Register 192.168.1.10 as `build-server`. Username is akhil, use password auth, work folder `/home/akhil/projects/myapp`." +
+Manual install + +Download the binary for your platform from [GitHub Releases](https://github.com/Apra-Labs/apra-fleet/releases): + +- `apra-fleet-linux-x64` — Linux (x86_64) +- `apra-fleet-darwin-arm64` — macOS (Apple Silicon) +- `apra-fleet-win-x64.exe` — Windows + +Then run the installer: + +```bash +# macOS / Linux +chmod +x apra-fleet-darwin-arm64 +./apra-fleet-darwin-arm64 install + +# Windows +apra-fleet-win-x64.exe install +``` + +
+ +
+What install writes + +| Path | What it is | +|------|-----------| +| `~/.apra-fleet/bin/apra-fleet[.exe]` | The fleet binary | +| `~/.apra-fleet/hooks/` | Shell hooks (statusline, etc.) | +| `~/.apra-fleet/scripts/` | Helper scripts | +| `~/.claude/skills/fleet/` | Fleet skill (MCP tool docs for Claude) | +| `~/.claude/skills/pm/` | PM orchestration skill | + +The install also registers the MCP server (`claude mcp add apra-fleet`) and configures a status bar icon showing fleet member activity. + +**What `install` does NOT do:** +- No system-level changes (no `/usr/local`, no PATH modification, no admin/sudo required) +- No network calls beyond `claude mcp add` (the binary stays local) +- No background services or daemons — the fleet server starts on-demand when Claude Code connects + +
+ +
+The --skill flag + +By default, `install` writes both the fleet and PM skills. Use `--skill` to control exactly which skills are installed: + +| Flag | Skills installed | +|------|----------------| +| `install` (no flag) | fleet + pm (default) | +| `install --skill all` | fleet + pm | +| `install --skill fleet` | fleet only | +| `install --skill pm` | fleet + pm (pm depends on fleet) | +| `install --skill none` | neither | +| `install --no-skill` | neither (same as `--skill none`) | + +
+ +
+Uninstall + +Remove the files fleet wrote, then deregister the MCP server: + +```bash +# macOS / Linux +rm -rf ~/.apra-fleet ~/.claude/skills/fleet ~/.claude/skills/pm +claude mcp remove apra-fleet --scope user +``` + +```powershell +# Windows (PowerShell) +Remove-Item -Recurse -Force $env:USERPROFILE\.apra-fleet, $env:USERPROFILE\.claude\skills\fleet, $env:USERPROFILE\.claude\skills\pm +claude mcp remove apra-fleet --scope user +``` + +
+ +## Register your first member + +A "member" is any machine (or workspace) that fleet manages. There are two types: + +### Local member (same machine) + +No SSH needed — it runs as a child process on your machine: + +> "Register a local member called `my-project` working in `C:\Users\me\projects\myapp`." + +### Remote member (another machine via SSH) + +You need SSH access to the remote machine: + +> "Register 192.168.1.10 as `build-server`. Username is akhil, password is mypass, work folder `/home/akhil/projects/myapp`." + +Fleet will test connectivity, detect the OS, and check if the LLM CLI is installed. If it isn't installed: + +> "Install Claude Code on build-server." + +### Registering with a non-Claude provider + +Specify the `llm_provider` when registering: + +> "Register `gemini-worker` at 192.168.1.11 as a Gemini member. Work folder `/home/user/work`, username user, password pass." + +> "Register a Codex member called `codex-dev` locally, work folder `C:\Users\me\codex-project`." + +Supported values: `claude` (default), `gemini`, `codex`, `copilot`. + +### SSH key auth + +After registering a remote member with a password, migrate to key-based auth: + +> "Set up SSH key auth for build-server." + +This generates a key pair, deploys it, verifies it works, then removes the password from storage. + +## Using fleet members + +### Run a prompt on a member + +> "On build-server, run the test suite and fix any failures." + +The prompt is sent to the member's LLM CLI instance, which has full access to the code in its work folder. + +### Run a command on a member + +> "Run `git status` on build-server." + +Direct shell commands without starting a Claude session — useful for quick checks. + +### Send files to a member + +> "Send `config.json` and `deploy.sh` to build-server." + +Uploads files via SFTP to the member's work folder. + +### Check status + +> "Show me fleet status." + +Shows all members, their status (online/offline), and last activity. + ## How it works Apra Fleet is an MCP server that agentic coding systems connect to. It manages a registry of **members** (machines with an AI coding agent installed) and provides tools to register them, send files, execute prompts, and check status. Remote members connect via SSH. Local members run as isolated child processes on the same machine. @@ -119,6 +260,54 @@ Apra Fleet is an MCP server that agentic coding systems connect to. It manages a | `shutdown_server` | Gracefully shut down the MCP server | | `version` | Report server version | +## Multi-Provider Fleets + +### Provisioning auth for non-Claude members + +Non-Claude members require an API key — OAuth credential copy is Claude-only. + +| Provider | What to say | +|----------|-------------| +| Gemini | "Provision auth for gemini-worker with API key GEMINI_API_KEY_VALUE" | +| Codex | "Provision auth for codex-dev with API key OPENAI_API_KEY_VALUE" | +| Copilot | "Provision auth for copilot-member with API key COPILOT_GITHUB_TOKEN_VALUE" | + +Fleet automatically uses the correct env var name per provider. + +### Installing CLIs on members + +> "Install the Gemini CLI on gemini-worker." + +> "Update all members' CLIs." + +`update_llm_cli` uses the correct install/update command per provider. + +### Provider capabilities and limits + +- `max_turns` is Claude-only. For Gemini, Codex, and Copilot, use `timeout_ms` to bound execution. +- Fine-grained permission control (`compose_permissions`) is Claude-only. Other providers use all-or-nothing skip-permissions flags. +- Codex output uses NDJSON internally — fleet handles this transparently. +- Gemini responses may silently truncate at ~8K tokens. Split large tasks into smaller units. +- Copilot requires a paid GitHub Copilot subscription (Pro/Business/Enterprise). + +See [`docs/provider-matrix.md`](docs/provider-matrix.md) for the full comparison table. + +
+Mix-and-match example + +A fleet can have members on different providers for different purposes: + +``` +dev1 — Claude (standard) — main implementation work +dev2 — Gemini — tasks needing 1M context or Google Search +review1 — Claude (premium) — code review +codex1 — Codex — structured extraction tasks +``` + +All members use the same fleet tools — the PM dispatches to whichever member fits the task. + +
+ ## Git Authentication Fleet can provision scoped, short-lived tokens to members — so each member gets only the git access it needs. @@ -127,6 +316,81 @@ Fleet can provision scoped, short-lived tokens to members — so each member get **Access levels:** `read`, `push`, `admin`, `issues`, `full`. +
+GitHub — Apra Labs members + +The `apra-fleet-git` app is already installed on the Apra-Labs org. Three steps: + +1. **Ensure your repo is added:** Go to `https://github.com/organizations/Apra-Labs/settings/installations` → `apra-fleet-git` → Configure → select your repositories. (Ask an org admin if you don't have access.) +2. **Download the private key:** [apra-fleet-git.pem](https://drive.google.com/file/d/1evUnHsDpv6ZaHyiHoRv-ElQc6vjaWYHd/view?usp=drive_link) (Apra Labs internal — requires org access) +3. **Register the app on your fleet instance (once per machine):** "Set up git auth with app ID 3001109, installation ID 113837928, and private key at ~/Downloads/apra-fleet-git.pem." + +Then provision any member: + +> "Provision git auth for build-server with push access to Apra-Labs/my-repo." + +
+ +
+GitHub — GitHub App (recommended for orgs) + +Setting up your own GitHub App: + +1. Go to `https://github.com/organizations/{your-org}/settings/apps` → New GitHub App +2. Name it (e.g. "fleet-git"), set Homepage URL to anything +3. Under Permissions, grant: **Contents** (Read & Write), **Pull Requests** (Read & Write), **Actions** (Read) — add more as needed +4. Create the app, then **Generate a private key** (downloads a `.pem` file) +5. **Install the app** on your org and select which repos it can access +6. Note the **App ID** (from the app's settings page) and **Installation ID** (from the URL after installing: `https://github.com/settings/installations/{installation_id}`) + +Then tell Claude: + +> "Set up git auth with app ID 12345, installation ID 67890, and private key at ~/my-app.pem." + +Now provision any member: + +> "Provision git auth for build-server with push access to Apra-Labs/my-repo." + +Tokens expire after 1 hour and are re-minted automatically. + +
+ +
+GitHub — Personal Access Token (simpler, for personal repos) + +1. Go to `https://github.com/settings/tokens` → Generate new token +2. Select scopes: `repo` for full access, or fine-grained per-repo tokens + +Then: + +> "Provision GitHub PAT auth for build-server. Token is ghp_xxxxx." + +
+ +
+Bitbucket + +1. Go to Atlassian account → **App passwords** → Create app password +2. Grant permissions: Repository Read/Write, Pull Request Read/Write + +Then: + +> "Provision Bitbucket auth for build-server. Email is me@example.com, workspace is my-team, token is xxxx." + +
+ +
+Azure DevOps + +1. Go to `https://dev.azure.com/{org}/_usersSettings/tokens` → New Token +2. Grant scopes: Code (Read & Write), Pull Request Threads (Read & Write) + +Then: + +> "Provision Azure DevOps auth for build-server. Org URL is https://dev.azure.com/my-org, token is xxxx." + +
+ See [`docs/design-git-auth.md`](docs/design-git-auth.md) for the full design. ## Secure Password Entry @@ -152,6 +416,59 @@ Fleet members can run on cloud instances (AWS EC2) that start and stop automatic See [`docs/cloud-compute.md`](docs/cloud-compute.md) for setup and configuration details. +## PM Skill (Project Manager) + +The PM skill is installed by default. It's an orchestration layer for multi-step projects. + +### Initialize a project + +> "/pm init my-project" + +Creates a project folder with templates for status tracking, requirements, design docs, and deployment steps. + +### Plan and execute + +> "/pm plan Implement user authentication with OAuth2" + +The PM writes requirements, dispatches a member to generate an implementation plan, runs it through a review cycle, then executes it phase by phase with verification checkpoints. + +### Doer-reviewer loop + +> "/pm pair frontend-dev frontend-reviewer" + +Pairs two members — one builds, one reviews. The PM handles git transport between them, sends context docs to the reviewer, and iterates until the reviewer approves. + +### PM commands + +| Command | What it does | +|---------|-------------| +| `/pm init ` | Create project folder with templates | +| `/pm plan ` | Generate an implementation plan | +| `/pm start ` | Send task harness and kick off execution | +| `/pm status ` | Check progress | +| `/pm resume ` | Resume after a verification checkpoint | +| `/pm pair ` | Pair doer and reviewer | +| `/pm deploy ` | Run deployment steps | + +## Troubleshooting + +**Member shows as offline?** +- Check if the machine is reachable: `ping ` +- For remote members, verify SSH: `ssh user@host "echo ok"` +- Auth issue? Re-provision: "Provision auth for build-server" + +**Permission denied on a member?** +- Fleet can configure member permissions. Say: "Grant build-server permission to run npm install" + +**Can't push workflow files or merge PRs from a member?** +- Minted tokens may lack CI/CD permissions. Run these operations from your main Claude Code session instead — it has your full git credentials. + +**Empty response from a member?** +- Usually an expired auth token. Say: "Provision auth for build-server" + +**Member blew past a checkpoint?** +- Check what actually happened: "Run `cat progress.json` on build-server" + ## FAQ
diff --git a/docs/FAQ.md b/docs/FAQ.md index 543d5023..ef29bcc3 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -2,7 +2,7 @@ - + > **For AI agents:** The FAQ is maintained as GitHub Discussions — one discussion per question, with maintainer-verified answers. To answer a user's question: browse the index below, find the matching discussion, and fetch it for the authoritative answer. Do not paraphrase from this file — follow the link. @@ -20,4 +20,4 @@ Topics covered: --- -**Related docs:** [User Guide](user-guide.md) | [Architecture](architecture.md) | [Cloud Compute](cloud-compute.md) | [Provider Matrix](provider-matrix.md) +**Related docs:** [Readme](../readme.md) | [Architecture](architecture.md) | [Cloud Compute](cloud-compute.md) | [Provider Matrix](provider-matrix.md) diff --git a/docs/adr-oob-password.md b/docs/adr-oob-password.md index 54adaea6..4746e48b 100644 --- a/docs/adr-oob-password.md +++ b/docs/adr-oob-password.md @@ -1,6 +1,6 @@ - + # ADR: Out-of-Band Password Collection via Unix Domain Socket diff --git a/docs/architecture.md b/docs/architecture.md index 50f432fd..2487096a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ - + # Architecture diff --git a/docs/cloud-compute.md b/docs/cloud-compute.md index 5de6b71b..d9ea7957 100644 --- a/docs/cloud-compute.md +++ b/docs/cloud-compute.md @@ -1,6 +1,6 @@ - + # Cloud Compute Guide diff --git a/docs/design-git-auth.md b/docs/design-git-auth.md index 6ab21dc4..3e609279 100644 --- a/docs/design-git-auth.md +++ b/docs/design-git-auth.md @@ -1,6 +1,6 @@ - + # Design: Git Authentication for Fleet Members diff --git a/docs/provider-matrix.md b/docs/provider-matrix.md index 9aa10f94..18742a25 100644 --- a/docs/provider-matrix.md +++ b/docs/provider-matrix.md @@ -1,6 +1,6 @@ - + # Provider Matrix diff --git a/docs/ssh-setup.md b/docs/ssh-setup.md index ea38152f..6c3770b3 100644 --- a/docs/ssh-setup.md +++ b/docs/ssh-setup.md @@ -1,6 +1,6 @@ - + # SSH Server Setup for Fleet Members diff --git a/docs/user-guide.md b/docs/user-guide.md deleted file mode 100644 index 6bce58c2..00000000 --- a/docs/user-guide.md +++ /dev/null @@ -1,310 +0,0 @@ - - - - -# User Guide - -## What is Apra Fleet? - -Apra Fleet lets you control AI coding agents on multiple machines from a single conversation. Register your machines once, then tell Claude to run prompts, execute commands, or send files to any of them — local or remote. Members can run different LLM backends (Claude, Gemini, Codex, Copilot) and be mixed freely within a single fleet. - -## Install - -### One-liner install - -**macOS (Apple Silicon)** -```bash -curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-darwin-arm64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install --skill -``` - -**Linux (x64)** -```bash -curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-linux-x64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install --skill -``` - -**Windows (x64)** — run in PowerShell: -```powershell -Invoke-WebRequest -Uri https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-win-x64.exe -OutFile apra-fleet.exe; .\apra-fleet.exe install --skill -``` - -### Manual install - -Download the binary for your platform from [GitHub Releases](https://github.com/Apra-Labs/apra-fleet/releases): - -- `apra-fleet-linux-x64` — Linux (x86_64) -- `apra-fleet-darwin-arm64` — macOS (Apple Silicon) -- `apra-fleet-win-x64.exe` — Windows - -Then run the installer: - -```bash -# macOS / Linux -chmod +x apra-fleet-darwin-arm64 -./apra-fleet-darwin-arm64 install --skill - -# Windows -apra-fleet-win-x64.exe install --skill -``` - -The `--skill` flag installs the PM (Project Manager) skill, which adds orchestration capabilities for multi-step projects. Omit it if you only need basic fleet operations. - -**What `install` does:** -- Copies the binary to `~/.apra-fleet/bin/` -- Installs hooks and scripts to `~/.apra-fleet/` -- Registers the fleet server with Claude Code -- Configures a status bar showing fleet member activity -- (With `--skill`) Installs the PM skill to `~/.claude/skills/pm/` - -### 3. Load the server in Claude Code - -Start or restart Claude Code, then type: - -``` -/mcp -``` - -You should see `apra-fleet` listed as a connected server. - -## Register your first member - -A "member" is any machine (or workspace) that fleet manages. There are two types: - -### Local member (same machine) - -Just tell Claude: - -> "Register a local member called `my-project` working in `C:\Users\me\projects\myapp`." - -No SSH needed — it runs as a child process on your machine. - -### Remote member (another machine via SSH) - -You need SSH access to the remote machine. Tell Claude: - -> "Register 192.168.1.10 as `build-server`. Username is akhil, password is mypass, work folder `/home/akhil/projects/myapp`." - -Fleet will test connectivity, detect the OS, and check if the LLM CLI is installed. If it isn't installed, you can say: - -> "Install Claude Code on build-server." - -### Registering with a non-Claude provider - -Specify the `llm_provider` when registering: - -> "Register `gemini-worker` at 192.168.1.11 as a Gemini member. Work folder `/home/user/work`, username user, password pass." - -Or tell Claude directly: - -> "Register a Codex member called `codex-dev` locally, work folder `C:\Users\me\codex-project`." - -Supported values: `claude` (default), `gemini`, `codex`, `copilot`. - -### SSH key auth - -After registering a remote member with a password, migrate to key-based auth: - -> "Set up SSH key auth for build-server." - -This generates a key pair, deploys it, verifies it works, then removes the password from storage. - -## Using fleet members - -### Run a prompt on a member - -> "On build-server, run the test suite and fix any failures." - -The prompt is sent to the member's LLM CLI instance, which has full access to the code in its work folder. The correct CLI is selected automatically based on the member's provider. - -### Run a command on a member - -> "Run `git status` on build-server." - -Direct shell commands without starting a Claude session — useful for quick checks. - -### Send files to a member - -> "Send `config.json` and `deploy.sh` to build-server." - -Uploads files via SFTP to the member's work folder. - -### Check status - -> "Show me fleet status." - -Shows all members, their status (online/offline), and last activity. - -## Multi-Provider Fleets - -### Provisioning auth for non-Claude members - -Non-Claude members require an API key — OAuth credential copy is Claude-only. - -| Provider | What to say | -|----------|-------------| -| Gemini | "Provision auth for gemini-worker with API key GEMINI_API_KEY_VALUE" | -| Codex | "Provision auth for codex-dev with API key OPENAI_API_KEY_VALUE" | -| Copilot | "Provision auth for copilot-member with API key COPILOT_GITHUB_TOKEN_VALUE" | - -Fleet automatically uses the correct env var name per provider. - -### Installing CLIs on members - -> "Install the Gemini CLI on gemini-worker." - -> "Update all members' CLIs." - -`update_llm_cli` uses the correct install/update command per provider. - -### Provider capabilities and limits - -- `max_turns` is Claude-only. For Gemini, Codex, and Copilot, use `timeout_ms` to bound execution. -- Fine-grained permission control (`compose_permissions`) is Claude-only. Other providers use all-or-nothing skip-permissions flags. -- Codex output uses NDJSON internally — fleet handles this transparently. -- Gemini responses may silently truncate at ~8K tokens. Split large tasks into smaller units. -- Copilot requires a paid GitHub Copilot subscription (Pro/Business/Enterprise). - -See [`provider-matrix.md`](provider-matrix.md) for the full comparison table. - -### Provider Recommendations - -These are recommendations, not restrictions — your choice is final. - -| Role | Recommended | Notes | -|------|-------------|-------| -| **Orchestrator (PM)** | Claude (Opus or Sonnet) | Gemini lacks background agents — every `execute_prompt` blocks, serializing all fleet operations. This negates the core value of parallel dispatch. | -| **Doer** | Any provider | Claude Sonnet, Gemini Flash, Codex, Copilot — mix freely. Multiple members of any provider are supported. | -| **Reviewer** | Highest-tier models (use premium tier) | Highest review quality; catches subtle issues that smaller models miss. | - -### Mix-and-match example - -A fleet can have members on different providers for different purposes: - -``` -dev1 — Claude (standard) — main implementation work -dev2 — Gemini — tasks needing 1M context or Google Search -review1 — Claude (premium) — code review -codex1 — Codex — structured extraction tasks -``` - -All members use the same fleet tools — the PM dispatches to whichever member fits the task. - -## Git authentication - -By default, remote members can't push/pull from your repositories. Fleet provisions scoped credentials so each member gets only the access it needs. - -### GitHub - -**Apra Labs members:** - -The `apra-fleet-git` app is already installed on the Apra-Labs org. Two steps: - -1. **Ensure your repo is added:** Go to `https://github.com/organizations/Apra-Labs/settings/installations` → `apra-fleet-git` → Configure → select your repositories. (Ask an org admin if you don't have access.) -2. **Download the private key:** [apra-fleet-git.pem](https://drive.google.com/file/d/1evUnHsDpv6ZaHyiHoRv-ElQc6vjaWYHd/view?usp=drive_link) (Apra Labs internal — requires org access) -3. **Register the app on your fleet instance (once per machine):** "Set up git auth with app ID 3001109, installation ID 113837928, and private key at ~/Downloads/apra-fleet-git.pem." This only needs to happen once — after that, any member can be provisioned. - -Then provision any member: - -> "Provision git auth for build-server with push access to Apra-Labs/my-repo." - -Skip the "Option A" setup below — it's for creating your own app. - -**Option A: GitHub App (recommended for orgs)** - -Setting up your own GitHub App (skip this if using the Apra-Labs app above): - -1. Go to `https://github.com/organizations/{your-org}/settings/apps` → New GitHub App -2. Name it (e.g. "fleet-git"), set Homepage URL to anything -3. Under Permissions, grant: **Contents** (Read & Write), **Pull Requests** (Read & Write), **Actions** (Read) — add more as needed -4. Create the app, then **Generate a private key** (downloads a `.pem` file) -5. **Install the app** on your org and select which repos it can access -6. Note the **App ID** (from the app's settings page) and **Installation ID** (from the URL after installing: `https://github.com/settings/installations/{installation_id}`) - -Then tell Claude: - -> "Set up git auth with app ID 12345, installation ID 67890, and private key at ~/my-app.pem." - -Now you can provision any member: - -> "Provision git auth for build-server with push access to Apra-Labs/my-repo." - -Tokens expire after 1 hour and are re-minted automatically. - -**Option B: Personal Access Token (simpler, for personal repos)** - -1. Go to `https://github.com/settings/tokens` → Generate new token -2. Select scopes: `repo` for full access, or fine-grained per-repo tokens - -Then tell Claude: - -> "Provision GitHub PAT auth for build-server. Token is ghp_xxxxx." - -### Bitbucket - -1. Go to Atlassian account → **App passwords** → Create app password -2. Grant permissions: Repository Read/Write, Pull Request Read/Write - -Then: - -> "Provision Bitbucket auth for build-server. Email is me@example.com, workspace is my-team, token is xxxx." - -### Azure DevOps - -1. Go to `https://dev.azure.com/{org}/_usersSettings/tokens` → New Token -2. Grant scopes: Code (Read & Write), Pull Request Threads (Read & Write) - -Then: - -> "Provision Azure DevOps auth for build-server. Org URL is https://dev.azure.com/my-org, token is xxxx." - -## PM Skill (Project Manager) - -If you installed with `--skill`, you have access to the PM — an orchestration layer for multi-step projects. - -### Initialize a project - -> "/pm init my-project" - -Creates a project folder with templates for status tracking, requirements, design docs, and deployment steps. - -### Plan and execute - -> "/pm plan Implement user authentication with OAuth2" - -The PM writes requirements, dispatches a member to generate an implementation plan, runs it through a review cycle, then executes it phase by phase with verification checkpoints. - -### Doer-reviewer loop - -> "/pm pair frontend-dev frontend-reviewer" - -Pairs two members — one builds, one reviews. The PM handles git transport between them, sends context docs to the reviewer, and iterates until the reviewer approves. - -### Key PM commands - -| Command | What it does | -|---------|-------------| -| `/pm init ` | Create project folder with templates | -| `/pm plan ` | Generate an implementation plan | -| `/pm start ` | Send task harness and kick off execution | -| `/pm status ` | Check progress | -| `/pm resume ` | Resume after a verification checkpoint | -| `/pm pair ` | Pair doer and reviewer | -| `/pm deploy ` | Run deployment steps | - -## Troubleshooting - -**Member shows as offline?** -- Check if the machine is reachable: `ping ` -- For remote members, verify SSH: `ssh user@host "echo ok"` -- Auth issue? Re-provision: "Provision auth for build-server" - -**Permission denied on a member?** -- Fleet can configure member permissions. Say: "Grant build-server permission to run npm install" - -**Can't push workflow files or merge PRs from a member?** -- Minted tokens may lack CI/CD permissions. Run these operations from your main Claude Code session instead — it has your full git credentials. - -**Empty response from a member?** -- Usually an expired auth token. Say: "Provision auth for build-server" - -**Member blew past a checkpoint?** -- Check what actually happened: "Run `cat progress.json` on build-server" diff --git a/docs/vocabulary.md b/docs/vocabulary.md index ce3893c4..483b620d 100644 --- a/docs/vocabulary.md +++ b/docs/vocabulary.md @@ -1,6 +1,6 @@ - + # Fleet Vocabulary diff --git a/feedback.md b/feedback.md deleted file mode 100644 index 2f01fe17..00000000 --- a/feedback.md +++ /dev/null @@ -1,56 +0,0 @@ -# PR #101 Review: feat: first-run onboarding experience and user engagement nudges - -**Reviewer:** Claude Code (automated review) -**Date:** 2026-04-18 -**Verdict:** APPROVED (with one non-blocking note) - -## Summary - -This PR adds a first-run onboarding experience (ASCII banner + getting started guide), contextual nudges (post-registration, post-first-prompt, multi-member milestone), and a welcome-back preamble on subsequent server starts. The implementation uses a well-thought-out three-channel defense-in-depth delivery strategy to ensure onboarding text reaches the user verbatim despite the LLM intermediary. - -## What was reviewed - -- `src/onboarding/text.ts` — all user-facing text constants -- `src/services/onboarding.ts` — state management (load, save, milestones, session flags) -- `src/index.ts` — `wrapTool`, `sanitizeToolResult`, `sendOnboardingNotification`, McpServer construction -- `src/tools/register-member.ts` — input validation (angle bracket regex) -- `src/tools/update-member.ts` — input validation (angle bracket regex) -- `src/types.ts` — `OnboardingState` interface -- `src/cli/install.ts` — data directory comment -- `docs/adr-onboarding-ux-delivery.md` — architecture decision record -- `tests/onboarding.test.ts` — 57 tests covering state, milestones, nudges, sanitization, integration -- `tests/onboarding-text.test.ts` — 21 tests for text constants -- `tests/onboarding-smoke.mjs` — end-to-end smoke test -- `.gitignore` — CLAUDE.md addition - -## Findings - -### Architecture & Design — Excellent - -- Three-channel delivery (notifications, markers+instructions, audience annotations) is well-reasoned. The ADR documents the failure modes, token costs, and tradeoffs clearly. -- Sanitization defense (both output-boundary `sanitizeToolResult` and input-boundary Zod regex) is defense-in-depth done right. The ADR honestly documents the `update_member` gap and notes it was closed in this PR. -- The `wrapTool` abstraction replaces 21 inline wrappers with a single function — cleaner and easier to maintain. -- Passive-tool guard (`version`, `shutdown_server`) prevents silent consumption of the banner by auto-called tools. -- First-run banner bypasses JSON check while welcome-back/nudges respect it — correct design for different urgency levels. - -### Code Quality — Clean - -- State management is well-structured: in-memory singleton loaded once, atomic file writes, forward-compatible merge with defaults, corruption recovery. -- `_resetForTest()` is a clean test-only escape hatch. -- Token cost analysis in the text.ts header is thorough and reproducible. -- The sanitizer regex handles case variants, attributes, unterminated tags, and multiple occurrences. - -### Testing — Thorough - -- 722 tests pass, zero failures (4 skipped, pre-existing). -- Build compiles cleanly with no TypeScript errors. -- Tests cover: fresh install, upgrade path, corruption recovery, milestone progression, idempotency, passive-tool guard, JSON bypass, full session sequence, notification emission, sanitization edge cases, schema validation. -- Smoke test provides an additional end-to-end verification layer. - -### Non-blocking note - -- `.gitignore` adds `CLAUDE.md`. Since CLAUDE.md is already tracked by git, this has no immediate effect — git only ignores untracked files. However, if someone ever removes CLAUDE.md from tracking, this gitignore entry would prevent re-adding it. This looks like a development artifact. Low risk, can be cleaned up in a follow-up. - -## Verdict - -**APPROVED.** The implementation is well-designed, thoroughly tested, security-conscious, and clean. The three-channel delivery strategy with injection defense is a thoughtful solution to the real problem of delivering verbatim content through an LLM intermediary. diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 00000000..d320ca12 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,932 @@ + + + +# Apra Fleet + +[![CI](https://github.com/Apra-Labs/apra-fleet/actions/workflows/ci.yml/badge.svg)](https://github.com/Apra-Labs/apra-fleet/actions/workflows/ci.yml) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.5+-3178C6.svg?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![Node.js](https://img.shields.io/badge/Node.js-20+-339933.svg?logo=node.js&logoColor=white)](https://nodejs.org/) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](https://github.com/Apra-Labs/apra-fleet/releases) +[![MCP](https://img.shields.io/badge/MCP-compatible-8A2BE2.svg)](https://modelcontextprotocol.io) + +AI agents that write code, review each other's work, and coordinate across your machines — from a single conversation. + +**Apra Fleet** is an open-source **MCP server** that pairs AI coding agents into **doer-reviewer loops** for higher quality code, and orchestrates them across machines via SSH when you need distributed power. Works with Claude Code, Gemini, Codex and other AI coding assistants. + +## What you get + +### Doer-reviewer loops — two agents, one quality bar + +Pair two agents so one writes code while the other reviews it. The built-in Project Manager orchestrates the handoff with structured checkpoints — no manual coordination needed. This works on a **single machine** (two local agents) or across machines. + +``` +You: "Pair local-1 and local-2. local-1 builds the auth module, local-2 reviews." +Fleet: Doer writes code → pauses at checkpoint → Reviewer validates → feedback loop → done. +``` + +Every change gets a second pair of eyes before you even look at it. + +### Multi-machine orchestration — your infrastructure, one conversation + +When a single machine isn't enough, Fleet coordinates agents across every machine in your network via SSH. No dashboards, no orchestration YAML — just conversation. + +- Run your test suite on Linux while you develop on macOS +- Have one agent build the frontend, another the backend, a third running tests — all in parallel +- Spin up isolated workspaces on the same machine without them stepping on each other +- Use a beefy cloud VM for compilation while coding from your laptop + +### PM Skill — structured multi-step workflows + +The optional Project Manager skill goes beyond simple task dispatch: + +- **Planning** — breaks work into steps, gets your approval before execution +- **Doer-reviewer loops** — pairs agents for write-then-review workflows +- **Verification checkpoints** — agents pause at defined points for review +- **Progress tracking** — state synced via git (`PLAN.md`, `progress.json`, `feedback.md`) + +Installed by default — both the fleet and PM skills are written on `apra-fleet install`. See [`skills/pm/SKILL.md`](skills/pm/SKILL.md) for details. + +### Provider recommendations + +Fleet members can run different LLM backends. Mix and match based on the role: + +| Role | Recommended | Why | +|------|-------------|-----| +| **PM (orchestrator)** | Claude (Opus or Sonnet) | Most thoroughly tested for planning and multi-step orchestration | +| **Doer** | Any provider | Claude Sonnet, Gemini Flash, Codex, Copilot — mix freely | +| **Reviewer** | Premium tier models | Catches subtle issues that smaller models miss | + +See [`docs/provider-matrix.md`](docs/provider-matrix.md) for the full capability comparison. + +## Quick start + +Copy-paste the one-liner for your platform: + +**macOS (Apple Silicon)** +```bash +curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-darwin-arm64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install +``` + +**Linux (x64)** +```bash +curl -fsSL https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-linux-x64 -o apra-fleet && chmod +x apra-fleet && ./apra-fleet install +``` + +**Windows (x64)** — run in PowerShell: +```powershell +Invoke-WebRequest -Uri https://github.com/Apra-Labs/apra-fleet/releases/latest/download/apra-fleet-win-x64.exe -OutFile apra-fleet.exe; .\apra-fleet.exe install +``` + +Then load in Claude Code: +``` +/mcp +``` + +**Single machine — start here.** No remote servers needed: + +> "Register a local member called `doer`. Register another called `reviewer`. Pair them." + +**Remote machines — add when ready:** + +> "Register 192.168.1.10 as `build-server`. Username is akhil, use password auth, work folder `/home/akhil/projects/myapp`." + +
+Manual install + +Download the binary for your platform from [GitHub Releases](https://github.com/Apra-Labs/apra-fleet/releases): + +- `apra-fleet-linux-x64` — Linux (x86_64) +- `apra-fleet-darwin-arm64` — macOS (Apple Silicon) +- `apra-fleet-win-x64.exe` — Windows + +Then run the installer: + +```bash +# macOS / Linux +chmod +x apra-fleet-darwin-arm64 +./apra-fleet-darwin-arm64 install + +# Windows +apra-fleet-win-x64.exe install +``` + +
+ +
+What install writes + +| Path | What it is | +|------|-----------| +| `~/.apra-fleet/bin/apra-fleet[.exe]` | The fleet binary | +| `~/.apra-fleet/hooks/` | Shell hooks (statusline, etc.) | +| `~/.apra-fleet/scripts/` | Helper scripts | +| `~/.claude/skills/fleet/` | Fleet skill (MCP tool docs for Claude) | +| `~/.claude/skills/pm/` | PM orchestration skill | + +The install also registers the MCP server (`claude mcp add apra-fleet`) and configures a status bar icon showing fleet member activity. + +**What `install` does NOT do:** +- No system-level changes (no `/usr/local`, no PATH modification, no admin/sudo required) +- No network calls beyond `claude mcp add` (the binary stays local) +- No background services or daemons — the fleet server starts on-demand when Claude Code connects + +
+ +
+The --skill flag + +By default, `install` writes both the fleet and PM skills. Use `--skill` to control exactly which skills are installed: + +| Flag | Skills installed | +|------|----------------| +| `install` (no flag) | fleet + pm (default) | +| `install --skill all` | fleet + pm | +| `install --skill fleet` | fleet only | +| `install --skill pm` | fleet + pm (pm depends on fleet) | +| `install --skill none` | neither | +| `install --no-skill` | neither (same as `--skill none`) | + +
+ +
+Uninstall + +Remove the files fleet wrote, then deregister the MCP server: + +```bash +# macOS / Linux +rm -rf ~/.apra-fleet ~/.claude/skills/fleet ~/.claude/skills/pm +claude mcp remove apra-fleet --scope user +``` + +```powershell +# Windows (PowerShell) +Remove-Item -Recurse -Force $env:USERPROFILE\.apra-fleet, $env:USERPROFILE\.claude\skills\fleet, $env:USERPROFILE\.claude\skills\pm +claude mcp remove apra-fleet --scope user +``` + +
+ +## Register your first member + +A "member" is any machine (or workspace) that fleet manages. There are two types: + +### Local member (same machine) + +No SSH needed — it runs as a child process on your machine: + +> "Register a local member called `my-project` working in `C:\Users\me\projects\myapp`." + +### Remote member (another machine via SSH) + +You need SSH access to the remote machine: + +> "Register 192.168.1.10 as `build-server`. Username is akhil, password is mypass, work folder `/home/akhil/projects/myapp`." + +Fleet will test connectivity, detect the OS, and check if the LLM CLI is installed. If it isn't installed: + +> "Install Claude Code on build-server." + +### Registering with a non-Claude provider + +Specify the `llm_provider` when registering: + +> "Register `gemini-worker` at 192.168.1.11 as a Gemini member. Work folder `/home/user/work`, username user, password pass." + +> "Register a Codex member called `codex-dev` locally, work folder `C:\Users\me\codex-project`." + +Supported values: `claude` (default), `gemini`, `codex`, `copilot`. + +### SSH key auth + +After registering a remote member with a password, migrate to key-based auth: + +> "Set up SSH key auth for build-server." + +This generates a key pair, deploys it, verifies it works, then removes the password from storage. + +## Using fleet members + +### Run a prompt on a member + +> "On build-server, run the test suite and fix any failures." + +The prompt is sent to the member's LLM CLI instance, which has full access to the code in its work folder. + +### Run a command on a member + +> "Run `git status` on build-server." + +Direct shell commands without starting a Claude session — useful for quick checks. + +### Send files to a member + +> "Send `config.json` and `deploy.sh` to build-server." + +Uploads files via SFTP to the member's work folder. + +### Check status + +> "Show me fleet status." + +Shows all members, their status (online/offline), and last activity. + +## How it works + +Apra Fleet is an MCP server that agentic coding systems connect to. It manages a registry of **members** (machines with an AI coding agent installed) and provides tools to register them, send files, execute prompts, and check status. Remote members connect via SSH. Local members run as isolated child processes on the same machine. + +## Tools + +| Tool | Description | +|------|-------------| +| `register_member` | Register a machine as a fleet member (local or remote via SSH) | +| `remove_member` | Unregister a fleet member | +| `update_member` | Update a member's registration (rename, change host, folder, auth, git access) | +| `list_members` | List all registered fleet members | +| `member_detail` | Deep-dive status for a single member | +| `fleet_status` | Overview status of all members | +| `execute_prompt` | Run a Claude prompt on a member (supports session resume) | +| `execute_command` | Run a shell command directly on a member (no Claude CLI needed) | +| `reset_session` | Clear session ID so the next prompt starts fresh | +| `send_files` | Upload local files to a remote member via SFTP | +| `receive_files` | Download files from a member's work folder | +| `provision_llm_auth` | Deploy OAuth credentials or an API key to a member | +| `setup_ssh_key` | Generate SSH key pair and migrate from password to key auth | +| `setup_git_app` | One-time setup: register a GitHub App for scoped git token minting | +| `provision_vcs_auth` | Deploy VCS credentials to a member (GitHub App, Bitbucket, Azure DevOps) | +| `revoke_vcs_auth` | Remove deployed VCS credentials from a member | +| `cloud_control` | Start, stop, or check status of cloud compute instances | +| `monitor_task` | Monitor long-running tasks on cloud members | +| `compose_permissions` | Generate and deliver provider-native permission config | +| `update_llm_cli` | Update or install AI coding agent CLI on members | +| `shutdown_server` | Gracefully shut down the MCP server | +| `version` | Report server version | + +## Multi-Provider Fleets + +### Provisioning auth for non-Claude members + +Non-Claude members require an API key — OAuth credential copy is Claude-only. + +| Provider | What to say | +|----------|-------------| +| Gemini | "Provision auth for gemini-worker with API key GEMINI_API_KEY_VALUE" | +| Codex | "Provision auth for codex-dev with API key OPENAI_API_KEY_VALUE" | +| Copilot | "Provision auth for copilot-member with API key COPILOT_GITHUB_TOKEN_VALUE" | + +Fleet automatically uses the correct env var name per provider. + +### Installing CLIs on members + +> "Install the Gemini CLI on gemini-worker." + +> "Update all members' CLIs." + +`update_llm_cli` uses the correct install/update command per provider. + +### Provider capabilities and limits + +- `max_turns` is Claude-only. For Gemini, Codex, and Copilot, use `timeout_ms` to bound execution. +- Fine-grained permission control (`compose_permissions`) is Claude-only. Other providers use all-or-nothing skip-permissions flags. +- Codex output uses NDJSON internally — fleet handles this transparently. +- Gemini responses may silently truncate at ~8K tokens. Split large tasks into smaller units. +- Copilot requires a paid GitHub Copilot subscription (Pro/Business/Enterprise). + +See [`docs/provider-matrix.md`](docs/provider-matrix.md) for the full comparison table. + +
+Mix-and-match example + +A fleet can have members on different providers for different purposes: + +``` +dev1 — Claude (standard) — main implementation work +dev2 — Gemini — tasks needing 1M context or Google Search +review1 — Claude (premium) — code review +codex1 — Codex — structured extraction tasks +``` + +All members use the same fleet tools — the PM dispatches to whichever member fits the task. + +
+ +## Git Authentication + +Fleet can provision scoped, short-lived tokens to members — so each member gets only the git access it needs. + +**Supported providers:** GitHub (App or PAT), Bitbucket (API token), Azure DevOps (PAT). + +**Access levels:** `read`, `push`, `admin`, `issues`, `full`. + +
+GitHub — Apra Labs members + +The `apra-fleet-git` app is already installed on the Apra-Labs org. Three steps: + +1. **Ensure your repo is added:** Go to `https://github.com/organizations/Apra-Labs/settings/installations` → `apra-fleet-git` → Configure → select your repositories. (Ask an org admin if you don't have access.) +2. **Download the private key:** [apra-fleet-git.pem](https://drive.google.com/file/d/1evUnHsDpv6ZaHyiHoRv-ElQc6vjaWYHd/view?usp=drive_link) (Apra Labs internal — requires org access) +3. **Register the app on your fleet instance (once per machine):** "Set up git auth with app ID 3001109, installation ID 113837928, and private key at ~/Downloads/apra-fleet-git.pem." + +Then provision any member: + +> "Provision git auth for build-server with push access to Apra-Labs/my-repo." + +
+ +
+GitHub — GitHub App (recommended for orgs) + +Setting up your own GitHub App: + +1. Go to `https://github.com/organizations/{your-org}/settings/apps` → New GitHub App +2. Name it (e.g. "fleet-git"), set Homepage URL to anything +3. Under Permissions, grant: **Contents** (Read & Write), **Pull Requests** (Read & Write), **Actions** (Read) — add more as needed +4. Create the app, then **Generate a private key** (downloads a `.pem` file) +5. **Install the app** on your org and select which repos it can access +6. Note the **App ID** (from the app's settings page) and **Installation ID** (from the URL after installing: `https://github.com/settings/installations/{installation_id}`) + +Then tell Claude: + +> "Set up git auth with app ID 12345, installation ID 67890, and private key at ~/my-app.pem." + +Now provision any member: + +> "Provision git auth for build-server with push access to Apra-Labs/my-repo." + +Tokens expire after 1 hour and are re-minted automatically. + +
+ +
+GitHub — Personal Access Token (simpler, for personal repos) + +1. Go to `https://github.com/settings/tokens` → Generate new token +2. Select scopes: `repo` for full access, or fine-grained per-repo tokens + +Then: + +> "Provision GitHub PAT auth for build-server. Token is ghp_xxxxx." + +
+ +
+Bitbucket + +1. Go to Atlassian account → **App passwords** → Create app password +2. Grant permissions: Repository Read/Write, Pull Request Read/Write + +Then: + +> "Provision Bitbucket auth for build-server. Email is me@example.com, workspace is my-team, token is xxxx." + +
+ +
+Azure DevOps + +1. Go to `https://dev.azure.com/{org}/_usersSettings/tokens` → New Token +2. Grant scopes: Code (Read & Write), Pull Request Threads (Read & Write) + +Then: + +> "Provision Azure DevOps auth for build-server. Org URL is https://dev.azure.com/my-org, token is xxxx." + +
+ +See [`docs/design-git-auth.md`](docs/design-git-auth.md) for the full design. + +## Secure Password Entry + +When registering a remote member with password authentication, you don't need to pass the password inline. Apra Fleet opens a separate terminal window for password entry so credentials never appear in chat history or logs. + +- Password is encrypted immediately with AES-256-GCM and never stored in plaintext +- Works on macOS (Terminal.app), Windows (cmd), and Linux (gnome-terminal/xterm) +- Headless or unsupported environments get a manual command fallback +- Supports password rotation via `update_member` + +See [`docs/adr-oob-password.md`](docs/adr-oob-password.md) for the design rationale. + +## Cloud Compute + +Fleet members can run on cloud instances (AWS EC2) that start and stop automatically based on demand. When you send a prompt to a cloud member, Apra Fleet starts the instance, waits for SSH, re-provisions credentials, and executes the work. When the member goes idle, it stops the instance to save costs. + +- **Auto start/stop** — instances start on demand and stop after a configurable idle timeout +- **GPU-aware idle detection** — monitors GPU utilization via `nvidia-smi` so GPU workloads keep instances alive +- **Long-running tasks** — a task wrapper script survives SSH disconnects, supports retry with restart commands, and tracks status +- **Cost tracking** — real-time cost estimates based on instance type and uptime, with warnings for high spend +- **Custom workload detection** — define a shell command to signal busy/idle for arbitrary workloads (CPU training, downloads, etc.) + +See [`docs/cloud-compute.md`](docs/cloud-compute.md) for setup and configuration details. + +## PM Skill (Project Manager) + +The PM skill is installed by default. It's an orchestration layer for multi-step projects. + +### Initialize a project + +> "/pm init my-project" + +Creates a project folder with templates for status tracking, requirements, design docs, and deployment steps. + +### Plan and execute + +> "/pm plan Implement user authentication with OAuth2" + +The PM writes requirements, dispatches a member to generate an implementation plan, runs it through a review cycle, then executes it phase by phase with verification checkpoints. + +### Doer-reviewer loop + +> "/pm pair frontend-dev frontend-reviewer" + +Pairs two members — one builds, one reviews. The PM handles git transport between them, sends context docs to the reviewer, and iterates until the reviewer approves. + +### PM commands + +| Command | What it does | +|---------|-------------| +| `/pm init ` | Create project folder with templates | +| `/pm plan ` | Generate an implementation plan | +| `/pm start ` | Send task harness and kick off execution | +| `/pm status ` | Check progress | +| `/pm resume ` | Resume after a verification checkpoint | +| `/pm pair ` | Pair doer and reviewer | +| `/pm deploy ` | Run deployment steps | + +## Troubleshooting + +**Member shows as offline?** +- Check if the machine is reachable: `ping ` +- For remote members, verify SSH: `ssh user@host "echo ok"` +- Auth issue? Re-provision: "Provision auth for build-server" + +**Permission denied on a member?** +- Fleet can configure member permissions. Say: "Grant build-server permission to run npm install" + +**Can't push workflow files or merge PRs from a member?** +- Minted tokens may lack CI/CD permissions. Run these operations from your main Claude Code session instead — it has your full git credentials. + +**Empty response from a member?** +- Usually an expired auth token. Say: "Provision auth for build-server" + +**Member blew past a checkpoint?** +- Check what actually happened: "Run `cat progress.json` on build-server" + +## FAQ + +
+Do I need to install apra-fleet on every device? + +No. apra-fleet only needs to be installed on the device where **you** interact with it. All members are registered and managed from that single installation. Remote machines just need SSH access. +
+ +
+Does apra-fleet only work with Claude? + +No. Fleet supports Claude and Gemini today, with Codex support in development. We recommend Claude as the PM's LLM provider for the best experience — it is the most thoroughly tested for planning and orchestration workflows. Gemini works well for members, especially when you want a different LLM perspective during review. +
+ +
+What if I only have one machine? + +Fleet works great on a single machine. Use the Simple Sprint pattern with a single member, or register two local members (doer + reviewer) that run as isolated child processes. No remote servers needed. +
+ +
+Why use separate folders for doer and reviewer? + +Agents can misbehave when they have too much context. A separate reviewer workspace provides an unbiased perspective that tends to identify more problems. Using different environments for review also validates whether the committed work can be built and run independently. +
+ +
+Does using fleet increase my LLM token usage? + +No — fleet actively reduces token usage through three mechanisms: (1) selecting the right model tier based on task complexity, routing simple tasks to lighter models; (2) preferring shell commands via `execute_command` (zero tokens) over full agent prompts where possible; (3) smart conversation management that decides whether to resume existing sessions (leveraging cached context) or start fresh. +
+ +
+How does fleet safeguard my passwords and credentials? + +Three layers: (1) out-of-band collection — passwords are entered via a shell prompt outside the conversation, so the LLM never sees them; (2) encryption at rest — stored credentials are encrypted locally, never plaintext in config files; (3) passwordless migration — fleet encourages key-based SSH auth to reduce password handling. +
+ +
+Is apra-fleet limited to software development? + +No. Fleet is a general-purpose remote operations platform. Use cases include remote product support, simultaneous log analysis across machines, patch distribution, infrastructure automation, and even profiling and market research. +
+ +
+How does apra-fleet relate to Google's A2A protocol? + +They're architecturally distinct and largely complementary. A2A requires each agent to run a persistent HTTP server and enables autonomous agent-to-agent delegation. Fleet requires only SSH access and uses a human-orchestrated hub-and-spoke model where the PM decides the workflow. Fleet could eventually expose members as A2A-compatible agents while preserving its SSH-based transport. +
+ +See the full [FAQ](docs/FAQ.md) for all questions, or browse the [FAQ discussions](https://github.com/Apra-Labs/apra-fleet/discussions/127). + +## Development + +```bash +npm install && npm run build # Build from source +npm test # Unit tests (vitest) +npm run build:binary # Build single-executable binary +node dist/index.js install # Dev-mode install (registers MCP, hooks, statusline) +``` + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for planned features and good-first-issue ideas for contributors. + +## License + +Apache 2.0 — see [LICENSE](LICENSE) for the full text. +
+ + + + + + +# Fleet Vocabulary + +## The Problem + +"Agent" is overloaded: +1. A fleet member (registered machine/folder that does work) +2. A background Claude process the PM spawns to coordinate + +"The agent is running" — which one? This causes real confusion in logs, conversation, and status updates. + +## Approach: Names Over Nouns + +Most of the time, use the **specific name** and drop the category word entirely: + +- "Sent to dev2" — not "sent to member dev2" +- "review1 passed PR #13" — not "reviewer member passed" +- "dev1 is on main" — not "the dev1 member is on main" + +Names are unambiguous. Category nouns are noise when the name is present. + +## When You Need the Category + +For generic references ("list all ___", "register a new ___"), use: + +| Term | Meaning | +|------|---------| +| **member** (or **worker**) | A registered fleet member. The thing that does the work. | +| **subagent** | A background Claude process spawned by the PM. Ephemeral. | +| **session** | A conversation thread on a member. Context persists within it. | +| **fleet** | The collection of all registered members. | +| **PM** | The Project Manager — the master Claude instance that orchestrates everything. | +| **provider** (or **LLM backend**) | The LLM CLI a member uses: `claude`, `gemini`, `codex`, or `copilot`. Each member has exactly one provider, set at registration and changeable via `update_member`. | + +## Rules + +1. **Prefer the name**: "dev2 rebased" not "the dev2 member rebased" +2. **"Subagent" is always "subagent"** — never just "agent" when referring to a background Claude process +3. **"Agent" is banned in PM conversation** — too ambiguous. Use the name or "member/worker" for fleet members, "subagent" for Claude processes. +4. **API keeps `agent_id`** — backwards compat in code. User-facing language evolves separately. +5. **Provider is a property of a member**, not a conversation topic. Say "dev2 uses Gemini" not "dev2 is a Gemini agent". The member identity (the name) is what matters; the provider is just how it executes prompts. + +## Example Fleet + +``` +PM (orchestrator) + | + +-- dev1 (apra-focus, local, claude/standard) + +-- dev2 (apra-focus-dev2, remote, gemini/pro) + +-- review1 (apra-focus-review, remote, claude/premium) + +-- review2 (apra-focus-review2, remote, copilot) +``` + +PM spawns **subagents** to interact with **members**. A subagent is ephemeral (dies after task). A member is persistent (registered, has sessions, has state). + +Members with different **providers** are interchangeable from the PM's perspective — same tools, same dispatch pattern, different CLI underneath. + + + + + + + +# Provider Matrix + +Reference tables for all LLM providers supported by Apra Fleet. Extracted from `docs/multi-provider-plan.md`. + +> Tracking issues: #26 (Gemini), #27 (OpenAI Codex), #35 (GitHub Copilot) + +--- + +## Strategic Comparison + +| Feature | Claude Code | Gemini CLI | OpenAI Codex CLI | GitHub Copilot CLI | +|---------|-------------|------------|------------------|-------------------| +| **Install** | Native binary / `curl \| bash` | `npm i -g @google/gemini-cli` (Node 20+) | `npm i -g @openai/codex` / Homebrew / binary (Node 18+) | `npm i -g @github/copilot` / Homebrew / WinGet | +| **Headless prompt** | `claude -p "..."` | `gemini -p "..."` | `codex exec "..."` | `copilot -p "..."` | +| **Session resume** | `--resume ` | `-r` / `--resume` (loads most recent) | `codex exec resume` (positional) | `--continue` / `--resume` | +| **JSON output** | `--output-format json` | `--output-format json` (also `stream-json`) | `--json` (NDJSON — one event per state change) | `--format json` | +| **Model selection** | `--model opus/sonnet/haiku` | `--model ` or `GEMINI_MODEL` env var | `--model` / `-m` | `--model ` or `/model` interactive | +| **Max turns** | `--max-turns N` | **Not available** | **Not available** | **Not available** (auto-compaction) | +| **Skip permissions** | `--dangerously-skip-permissions` | `--yolo` / `-y` | `--ask-for-approval never` + `--sandbox danger-full-access` | `--allow-all-tools` / `--yolo` | +| **Auth env var** | `ANTHROPIC_API_KEY` | `GEMINI_API_KEY` | `OPENAI_API_KEY` (or `CODEX_API_KEY` in exec mode) | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | +| **OAuth / login** | `~/.claude/.credentials.json` (copyable) | Google OAuth (browser-based, not copyable) | `codex login` (ChatGPT account or API key) | `gh auth login` or `/login` (device flow) | +| **Version check** | `claude --version` | `gemini --version` | `codex --version` | `copilot --version` | +| **Install cmd (Linux)** | `curl -fsSL https://claude.ai/install.sh \| bash` | `npm i -g @google/gemini-cli` | `npm i -g @openai/codex` | `curl -fsSL https://gh.io/copilot-install \| bash` | +| **Install cmd (macOS)** | `curl -fsSL https://claude.ai/install.sh \| bash` | `npm i -g @google/gemini-cli` | `brew install --cask codex` | `brew install --cask copilot` | +| **Install cmd (Windows)** | `irm https://claude.ai/install.ps1 \| iex` | `npm i -g @google/gemini-cli` | Binary from GitHub releases (experimental) | `winget install GitHub.CopilotCLI` | +| **Update command** | `claude update` | `npm update -g @google/gemini-cli` | `npm update -g @openai/codex` | `copilot update` | +| **Process name** | `claude` | `gemini` | `codex` | `copilot` | +| **Credential path** | `~/.claude/.credentials.json` | `~/.gemini/` | `~/.codex/` | `~/.config/gh/` or `~/.copilot/` | +| **Session storage** | Server-side (session_id in JSON output) | Local: `~/.gemini/tmp//chats/` | Local (exec resume) | Local: `~/.copilot/session-state/` (SQLite) | +| **Agentic capabilities** | File edit, shell, MCP tools | File edit, shell, web search, MCP tools | File edit, shell, MCP tools, subagents | File edit, shell, MCP tools, custom agents | +| **Context window** | 200K (Sonnet) / 1M (Opus 4.6) | 1M tokens | 192K tokens | 64K tokens (auto-compaction at 95%) | + +--- + +## Model Tier Equivalents + +Used by the PM for model escalation (`cheap → mid → premium`). + +| Tier | Purpose | Claude | Gemini | OpenAI Codex | Copilot | +|------|---------|--------|--------|--------------|---------| +| **cheap** | Execution, status, tests, deploys | `haiku` | `gemini-2.5-flash` | `gpt-5.4-mini` | `claude-haiku-4-5` | +| **mid** | Construction, code, config | `sonnet` | `gemini-2.5-pro` | `gpt-5.4` | `claude-sonnet-4-5` | +| **premium** | Planning, review, architecture | `opus` | `gemini-2.5-pro` (no separate tier) | `gpt-5.4` (no separate tier) | `claude-sonnet-4-5` (highest available) | + +**Note:** Gemini and Codex currently lack a distinct premium tier beyond their best model. Copilot exposes Anthropic's Claude models directly, so it uses the same tier names. + +--- + +## Unique Capabilities + +Features available in non-Claude providers that Claude lacks natively. + +| Feature | Available In | Not In Claude | Impact on Fleet | +|---------|-------------|--------------|-----------------| +| **1M token native context** | Gemini | Claude caps at 200K (Sonnet), 1M only on Opus 4.6 | Gemini members can ingest larger codebases in single pass | +| **Built-in Google Search** | Gemini | Claude needs external MCP tool | Gemini agents can web-search natively — useful for researching APIs, docs | +| **Output schema enforcement** | Codex (`--output-schema `) | Claude | Codex can guarantee response conforms to a JSON Schema — enables structured extraction | +| **Multi-model marketplace** | Copilot (Claude + GPT models) | Claude | Copilot users choose between Claude and GPT families without switching CLI | +| **Auto-compaction** | Copilot, Codex | Claude (context just fills up) | Infinite-length sessions via automatic context summarization at 95% capacity | +| **Native subagent parallelism** | Codex | Claude (requires external orchestration like fleet) | Codex can fork subtasks internally — less need for fleet orchestration on simple parallel work | +| **Custom agent profiles** | Copilot (Markdown agent definitions) | Claude (CLAUDE.md is similar but informal) | Copilot has a formalized `agents/` directory with typed profiles | +| **Session browser** | Gemini (`/resume` interactive picker) | Claude (only has `--resume `) | Gemini users can browse and search past sessions interactively | + +--- + +## Critical Gaps & Mitigations + +Known limitations when using non-Claude providers in a fleet. + +| Gap | Provider(s) | Impact on Fleet | Mitigation | +|-----|------------|----------------|------------| +| **No `--max-turns`** | Gemini, Codex, Copilot | Can't bound execution by turn count | Use `timeout_ms` as the primary execution guard. `max_turns` is Claude-only and ignored for other providers. | +| **No server-side session ID in JSON output** | Gemini, Codex, Copilot | Can't store a session ID to pass back for `--resume` | Provider-specific approach: Claude stores `session_id` from JSON. Others use generic "resume last session" flag (`-r`, `exec resume`, `--continue`). | +| **NDJSON vs single JSON** | Codex | Response format differs from other providers | CodexProvider parser collects NDJSON events and extracts the final result + metadata from the last event. Transparent to tool code via `provider.parseResponse()`. | +| **OAuth credential copy doesn't work** | Gemini, Codex, Copilot | `provision_llm_auth` Flow A (copy `~/.claude/.credentials.json`) is Claude-only | For non-Claude providers: use the `api_key` parameter with the provider's env var (`GEMINI_API_KEY`, `OPENAI_API_KEY`, `COPILOT_GITHUB_TOKEN`). OAuth/login must be done interactively on the member. | +| **Different credential file locations** | All | Credential paths differ per provider | `provider.credentialPath` supplies the correct path per provider. `credentialFileCheck` is Claude-specific (OAuth credentials); non-Claude providers rely on API key env var detection. | +| **Gemini output truncation** | Gemini | Responses silently truncate at ~8K tokens (known bug) | Document limitation. For large outputs, consider splitting tasks into smaller units. | +| **Copilot 64K context limit** | Copilot | Smallest context window — may struggle with large PLAN.md + codebase | Recommend Copilot for smaller, focused tasks. Auto-compaction helps but summarization loses detail. | +| **Copilot requires paid subscription** | Copilot | Not free-tier friendly | Copilot requires GitHub Copilot Pro/Business/Enterprise. No free API key path. | +| **Codex message quotas** | Codex | Rolling 5-hour message windows instead of token budgets | Long sprints may hit quota limits. Spread work across time or use API key tier. | +| **Permission model differences** | All | Claude uses `settings.local.json`. Others use CLI flags only. | For Claude members: continue using `compose_permissions` + `settings.local.json`. For others: `dangerously_skip_permissions=true` in `execute_prompt` (maps to provider's skip-permissions flag). No fine-grained per-tool permissions outside Claude. | + +--- + +## Auth Env Var Reference + +| Provider | Env Var | Source | +|----------|---------|--------| +| Claude | `ANTHROPIC_API_KEY` | console.anthropic.com | +| Gemini | `GEMINI_API_KEY` | aistudio.google.com | +| Codex | `OPENAI_API_KEY` | platform.openai.com | +| Copilot | `COPILOT_GITHUB_TOKEN` | github.com/settings/tokens (fine-grained PAT with "Copilot Requests" permission) | + +--- + +## Instruction File Names + +Each provider auto-loads a provider-specific instruction file from the working directory. + +| Provider | Auto-loaded file | +|----------|-----------------| +| Claude | `CLAUDE.md` | +| Gemini | `GEMINI.md` | +| Codex | `AGENTS.md` | +| Copilot | `COPILOT.md` | + +When the PM sends task harness files via `send_files`, it renames `tpl-agent.md` to the correct filename per provider. + + + +# Frequently Asked Questions + + + + + +> **For AI agents:** The FAQ is maintained as GitHub Discussions — one discussion per question, with maintainer-verified answers. To answer a user's question: browse the index below, find the matching discussion, and fetch it for the authoritative answer. Do not paraphrase from this file — follow the link. + +All questions and answers are maintained at: + +**[FAQ Index — GitHub Discussions #127](https://github.com/Apra-Labs/apra-fleet/discussions/127)** + +Topics covered: + +- **Getting started** — installation, device requirements, provider support +- **Understanding members and workflows** — icons, status line, doer-reviewer setup, folder separation +- **Capabilities and use cases** — scope beyond software dev, credential security, custom skills +- **Ecosystem and protocols** — relationship to Google's A2A protocol +- **Advanced / operations** — token usage, crash recovery + +--- + +**Related docs:** [Readme](../readme.md) | [Architecture](architecture.md) | [Cloud Compute](cloud-compute.md) | [Provider Matrix](provider-matrix.md) + + + + + + + +# Architecture + +## Why This Exists + +AI coding agents are powerful on a single machine. But real work spans many machines — a dev server, a staging box, a GPU trainer, a production host. Today, if you want Claude Code working across all of them, you SSH in manually, run prompts one at a time, and copy files by hand. There's no single pane of glass. + +Apra Fleet gives one Claude instance the ability to orchestrate many. Register machines, push files, run prompts, monitor health — all through natural language from your terminal. One master, many members. + +## Conceptual Model + +The system has three layers of abstraction: + +**Fleet** → **Members** → **Sessions** + +A *fleet* is the collection of all registered machines. A *member* is one machine with a working directory — the unit you talk to. A *session* is a conversation thread on a member — Claude remembers context across prompts within a session, and you can reset it to start fresh. + +Members come in two flavors: +- **Remote members** communicate over SSH. They can be any machine you can reach — Linux VMs, macOS servers, Windows boxes. +- **Local members** run on the same machine as the master, in a different folder. No SSH needed. Useful for isolating work into separate project directories without spinning up another machine. + +This distinction is hidden behind a **Strategy pattern**: every tool interacts with members through a uniform interface. The strategy implementation (remote via SSH, or local via child process) is selected at runtime based on member type. Tools never know or care which kind of member they're talking to. + +## How It Fits Together + +``` +┌────────────────────────────────────────────────────┐ +│ Master Machine │ +│ │ +│ Claude Code CLI ◄──stdio──► Apra Fleet Server │ +│ │ │ +│ ┌──────────┴──────────┐ │ +│ │ Member Strategy │ │ +│ │ (uniform interface)│ │ +│ └──┬─────────────┬───┘ │ +│ │ │ │ +│ Remote Strategy Local Strategy │ +│ (ssh2 + sftp) (child_process + fs) │ +│ │ │ │ +│ SSH│ local exec │ +└───────────────────────┼─────────────┼──────────────┘ + │ │ + ┌────────────┘ └──► /other/project/ + ▼ (same machine) + ┌──────────────┐ + │ Remote Member │ + │ (any OS, │ + │ any provider)│ + └──────────────┘ +``` + +The MCP server speaks **stdio** — the standard transport for Claude Code MCP servers. Claude sends JSON-RPC tool calls, the server executes them, returns results. No HTTP, no ports to open. + +## Layers + +The codebase follows a strict layering: + +``` + index.ts ← MCP server entry point, tool registration + tools/* ← one file per tool, each self-contained + services/* ← core capabilities (strategy, registry, SSH, file transfer) + providers/* ← LLM provider adapters (Claude, Gemini, Codex, Copilot) + os/* ← OS-specific command builders (Linux, macOS, Windows) + utils/* ← stateless helpers (crypto, shell escaping) + types.ts ← shared data structures +``` + +Each layer only depends on the layers below it. Tools never import other tools. Services don't know about the MCP protocol. + +## Provider Abstraction + +Fleet supports four LLM providers: Claude Code, Gemini CLI, OpenAI Codex CLI, and GitHub Copilot CLI. Members can mix providers within a single fleet. + +### How It Works + +Each member has an optional `llmProvider` field (`'claude' | 'gemini' | 'codex' | 'copilot'`). When absent, it defaults to `'claude'` for backwards compatibility. Every tool that interacts with the member's LLM CLI resolves the provider via `getProvider(agent.llmProvider)` and delegates CLI-specific concerns to the `ProviderAdapter` interface. + +``` +┌──────────┐ getProvider() ┌─────────────────┐ +│ Tool │ ───────────────────► │ ProviderAdapter │ +│ (generic)│ │ (per-provider) │ +└──────────┘ └────────┬─────────┘ + │ supplies: + cliCommand() + buildPromptCommand() + parseResponse() + classifyError() + authEnvVar + processName + ... +``` + +The `OsCommands` layer sits below this: it handles OS-specific shell wrapping (PATH prepend, PowerShell syntax, base64 decode) and delegates CLI-specific parts (binary name, flags, JSON format) to the provider. + +### Provider Files + +``` +src/providers/ + provider.ts — ProviderAdapter interface + shared types + claude.ts — ClaudeProvider + gemini.ts — GeminiProvider + codex.ts — CodexProvider (NDJSON parser) + copilot.ts — CopilotProvider + index.ts — getProvider() singleton factory +``` + +### Mix-and-Match Fleet + +A fleet can have members on different providers simultaneously. The PM dispatches work to members by name — it doesn't need to know which LLM backend each member uses. The fleet server resolves the correct CLI commands per member at runtime. + +``` +PM (orchestrator, Claude) + | + +-- dev1 (claude, remote) + +-- dev2 (gemini, remote) + +-- dev3 (codex, local) + +-- review (copilot, remote) +``` + +All four members use the same `execute_prompt` tool call. The tool builds provider-correct CLI commands for each. + +### Key Differences Across Providers + +- **`max_turns`** — Claude-only. Ignored for Gemini, Codex, and Copilot. +- **OAuth credential copy** — Claude-only. Non-Claude providers require an API key (`provision_llm_auth` with `api_key`). +- **JSON output format** — Codex emits NDJSON (one event per line). All others emit a single JSON object. Handled transparently by `provider.parseResponse()`. +- **Session resume** — Claude stores a server-side session ID. Others resume the most recent local session via a generic flag. + +See `docs/provider-matrix.md` for the full comparison table. + +## Key Design Decisions + +### Strategy Pattern for Member Types + +Rather than scattering `if (agent.agentType === 'local')` checks across every tool, the local/remote distinction lives in a single place: the strategy factory. Tools call `getStrategy(agent).execCommand(...)` and get back the same result shape regardless of how it was executed. Adding a third member type (e.g., Docker containers, cloud VMs with API-based access) means writing one new strategy class — no tool changes. + +### Passwords Encrypted at Rest + +SSH passwords are encrypted with AES-256-GCM before being written to the registry file. The encryption key is derived from the machine's identity (hostname + OS username), so the registry file is meaningless if copied to another machine. This isn't meant to stop a determined attacker with root access — it prevents accidental plaintext exposure in backups, screenshots, or config file shares. + +### Connection Pooling with Idle Timeout + +SSH connections are expensive to establish (TCP + key exchange + auth). The server pools them in memory and reuses connections across tool calls, with a 5-minute idle timeout that auto-closes unused connections. Timers are `unref()`'d so they don't prevent Node from exiting. + +### Base64 Prompt Encoding + +Prompts sent to remote members are base64-encoded before being passed through SSH. This sidesteps the shell escaping nightmare of nested quoting across SSH → bash → claude CLI, across different operating systems. The remote member decodes before passing to Claude. + +### Session Persistence + +Each member stores an optional `sessionId` — a Claude conversation thread ID. When `resume=true` (the default), subsequent prompts continue the same conversation, so the remote Claude has full context of prior exchanges. Resetting a session is an explicit action, not an accident. + +### File-Based Registry + +All fleet state lives in `~/.apra-fleet/data/registry.json` — a single JSON file in the user's home directory. It's deliberately not in the project directory (won't be git-committed accidentally) and not in a database (no server to run, no migrations). For a fleet of dozens of members, JSON is more than sufficient. + +### Duplicate Folder Prevention + +Two members cannot share the same working directory on the same device. For remote members, "same device" means same SSH host. For local members, "same device" is always the master machine. This is enforced during registration and updates. It prevents two members from stomping on each other's files. + +## Tools + +The tools break into natural groups. Each group has detailed documentation: + +**[Lifecycle](tools-lifecycle.md)** — `register_member`, `list_members`, `update_member`, `remove_member`, `shutdown_server` +Manage the fleet roster and server lifecycle. Registration validates connectivity, detects the OS, and checks that Claude CLI is available. Removal includes best-effort cleanup of auth credentials on the member. + +**[Work](tools-work.md)** — `send_files`, `execute_prompt`, `execute_command`, `reset_session` +The core workflow. Push files to a member, run prompts against it, run shell commands directly, manage conversation sessions. + +**[Infrastructure](tools-infrastructure.md)** — `provision_llm_auth`, `setup_ssh_key`, `update_llm_cli` +One-time setup and maintenance. Provision auth (copy OAuth credentials or deploy API key for any provider), migrate from password to key auth, update the LLM CLI on members. + +**[Observability](tools-observability.md)** — `fleet_status`, `member_detail` +Two-layer monitoring. `fleet_status` gives a quick summary table across all members with fleet-aware busy detection (distinguishes between Claude processes serving this member vs unrelated Claude activity). `member_detail` drills into one member with connectivity, CLI version, session state, and system resource metrics. + +## Cross-Platform Support + +Members can run Windows, macOS, or Linux. The `platform.ts` utility generates the right shell commands for each OS — different commands for checking processes, reading memory, setting environment variables. The OS is auto-detected during registration (`uname -s` on Unix, `cmd /c ver` on Windows) and stored in the member record so subsequent tool calls don't need to re-detect. + +
+
diff --git a/llms.txt b/llms.txt new file mode 100644 index 00000000..b7982c71 --- /dev/null +++ b/llms.txt @@ -0,0 +1,13 @@ +# Apra Fleet + +> AI-managed fleet orchestration for Claude Code — run, update, and coordinate multiple Claude Code agents from a single hub. + +Apra Fleet is a multi-agent orchestration layer that lets a PM agent delegate work to a fleet of Claude Code instances via MCP tools, Git, and SSH. Each fleet member runs its own Claude Code session; the PM agent controls lifecycle, skills, and task assignment. Members can run different LLM backends (Claude, Gemini, Codex, Copilot) and be mixed freely within a single fleet. + +## Docs + +- [Readme](readme.md): Installation, configuration, member registration, and day-to-day usage for operators. +- [Vocabulary](docs/vocabulary.md): Shared terminology — member, task, skill, PM, fleet, doer/reviewer pattern. +- [Provider Matrix](docs/provider-matrix.md): Which LLM providers are supported, their capabilities, and how to configure them. +- [FAQ](docs/FAQ.md): Common questions about setup, troubleshooting, and the doer-reviewer loop. +- [Architecture](docs/architecture.md): How the fleet hub, MCP server, and members interact at a system level. diff --git a/package.json b/package.json index 23137ef9..83f7be3c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "apra-fleet", "version": "0.1.4", - "description": "MCP server for coordinating Claude Code members across machines via SSH", + "description": "MCP server for orchestrating multiple agentic AI instances like Claude, Gemini and others (called 'members') across machines via SSH", "author": "Apra Labs", "homepage": "https://github.com/Apra-Labs/apra-fleet", "repository": { diff --git a/scripts/gen-llms-full.mjs b/scripts/gen-llms-full.mjs new file mode 100644 index 00000000..f949fbb9 --- /dev/null +++ b/scripts/gen-llms-full.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * gen-llms-full.mjs — generates llms-full.txt at the repo root. + * + * Reads the five canonical docs listed in llms.txt, wraps each in an XML + * element, and writes the result to llms-full.txt so LLM clients can + * fetch the full content in a single request (llmstxt.org convention). + * + * No external dependencies — uses only Node built-ins. + * Run: node scripts/gen-llms-full.mjs + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +const docs = [ + { + path: 'readme.md', + title: 'Readme', + desc: 'Installation, configuration, member registration, and day-to-day usage for operators.', + }, + { + path: 'docs/vocabulary.md', + title: 'Vocabulary', + desc: 'Shared terminology — member, task, skill, PM, fleet, doer/reviewer pattern.', + }, + { + path: 'docs/provider-matrix.md', + title: 'Provider Matrix', + desc: 'Which LLM providers are supported, their capabilities, and how to configure them.', + }, + { + path: 'docs/FAQ.md', + title: 'FAQ', + desc: 'Common questions about setup, troubleshooting, and the doer-reviewer loop.', + }, + { + path: 'docs/architecture.md', + title: 'Architecture', + desc: 'How the fleet hub, MCP server, and members interact at a system level.', + }, +]; + +function escapeXml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +const docBlocks = docs.map(({ path, title, desc }) => { + const content = readFileSync(join(root, path), 'utf-8'); + return ` \n${content.trimEnd()}\n `; +}); + +const output = ` + +${docBlocks.join('\n\n')} + +\n`; + +const outPath = join(root, 'llms-full.txt'); +writeFileSync(outPath, output, 'utf-8'); +console.log(`Written: ${outPath} (${output.length} bytes, ${docs.length} docs)`); diff --git a/skills/pm/cleanup.md b/skills/pm/cleanup.md index 61ecea09..30ba97d4 100644 --- a/skills/pm/cleanup.md +++ b/skills/pm/cleanup.md @@ -3,7 +3,7 @@ Run at sprint completion, before raising the PR. Execute on both doer and reviewer via `execute_command`: ``` -git rm --cached .fleet-task*.md 2>/dev/null || true; rm -f .fleet-task*.md; git rm PLAN.md progress.json feedback.md requirements.md design.md 2>/dev/null; rm -f CLAUDE.md GEMINI.md AGENTS.md COPILOT-INSTRUCTIONS.md; git commit -m "cleanup: remove fleet control files" && git push +git rm --cached .fleet-task*.md 2>/dev/null || true; rm -f .fleet-task*.md; git rm PLAN.md progress.json feedback.md requirements.md design.md 2>/dev/null; for file in CLAUDE.md GEMINI.md AGENTS.md COPILOT-INSTRUCTIONS.md; do git ls-files --error-unmatch "$file" 2>/dev/null || rm -f "$file"; done; git commit -m "cleanup: remove fleet control files" && git push ``` After cleanup on both members, raise the PR (`gh pr create`) and verify CI is green (`gh pr checks`). Do not merge — merge is the user's decision. diff --git a/src/cli/install.ts b/src/cli/install.ts index 00f656fd..c06fa2b8 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -86,7 +86,12 @@ function getProviderInstallConfig(provider: LlmProvider): ProviderInstallConfig } // Detect SEA mode +let _seaOverride: boolean | null = null; +/** Override isSea() result — for tests only. Pass null to restore default. */ +export function _setSeaOverride(v: boolean | null): void { _seaOverride = v; } + function isSea(): boolean { + if (_seaOverride !== null) return _seaOverride; try { const sea = require('node:sea'); return sea.isSea(); @@ -158,7 +163,12 @@ function buildDevManifest(root: string): AssetManifest { return { version: vf.version, hooks, scripts, skills, fleetSkills }; } +let _manifestOverride: AssetManifest | null = null; +/** Inject a manifest for tests — avoids SEA asset extraction. Pass null to restore default. */ +export function _setManifestOverride(m: AssetManifest | null): void { _manifestOverride = m; } + function loadManifest(): AssetManifest { + if (_manifestOverride !== null) return _manifestOverride; if (isSea()) { return JSON.parse(getSeaAsset('manifest.json')); } @@ -307,7 +317,49 @@ function run(cmd: string, opts?: Record): void { execSync(cmd, { stdio: 'inherit', ...shellOpt, ...opts }); } +export function isApraFleetRunning(): boolean { + try { + if (process.platform === 'win32') { + const out = execSync('tasklist /FI "IMAGENAME eq apra-fleet.exe" /NH', { encoding: 'utf-8', stdio: 'pipe' }); + return out.includes('apra-fleet.exe'); + } else { + // -x = exact name match; avoids matching the installer process itself (NOTE 2) + execSync('pgrep -x apra-fleet', { stdio: 'ignore' }); + return true; + } + } catch { + return false; + } +} + +export function killApraFleet(): void { + if (process.platform === 'win32') { + execSync('taskkill /F /IM apra-fleet.exe', { stdio: 'ignore' }); + } else { + // -x = exact name match + execSync('pkill -x apra-fleet', { stdio: 'ignore' }); + } +} + export async function runInstall(args: string[]): Promise { + // --help / -h guard — must come first, before any side effects (#142) + if (args.includes('--help') || args.includes('-h')) { + console.log(`apra-fleet install + +Usage: + apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) + apra-fleet install --skill all Same as bare install (all skills) + apra-fleet install --skill fleet Install fleet skill only + apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) + apra-fleet install --skill none Skip skill installation + apra-fleet install --no-skill Same as --skill none + apra-fleet install --force Stop a running server, then install + apra-fleet install --llm Install for a specific LLM provider (claude, gemini, codex, copilot) + apra-fleet install --help Show this help`); + process.exit(0); + return; + } + // Parse --llm flag let llm: LlmProvider = 'claude'; const llmArg = args.find(a => a.startsWith('--llm=')); @@ -328,31 +380,50 @@ export async function runInstall(args: string[]): Promise { const paths = getProviderInstallConfig(llm); - // Parse --skill flag: accepts no value (→ all), or all|fleet|pm + // Parse --skill flag: default (no flag) = all; accepts all|fleet|pm|none; --no-skill = synonym for none type SkillMode = 'none' | 'all' | 'fleet' | 'pm'; - let skillMode: SkillMode = 'none'; + let skillMode: SkillMode = 'all'; const skillEqualArg = args.find(a => a.startsWith('--skill=')); if (skillEqualArg) { const val = skillEqualArg.split('=')[1]; - if (val === 'all' || val === 'fleet' || val === 'pm') { + if (val === 'all' || val === 'fleet' || val === 'pm' || val === 'none') { skillMode = val; } else { - console.error(`Error: --skill value must be one of: all, fleet, pm (got "${val}")`); + console.error(`Error: --skill value must be one of: all, fleet, pm, none (got "${val}")`); process.exit(1); } } else { const skillIdx = args.indexOf('--skill'); if (skillIdx >= 0) { const nextArg = args[skillIdx + 1]; - if (nextArg && !nextArg.startsWith('--') && (nextArg === 'all' || nextArg === 'fleet' || nextArg === 'pm')) { + if (nextArg && !nextArg.startsWith('--') && (nextArg === 'all' || nextArg === 'fleet' || nextArg === 'pm' || nextArg === 'none')) { skillMode = nextArg; } else { - // --skill with no value → install both + // --skill with no value → install both (backwards-compat) skillMode = 'all'; } } } + // --no-skill is a synonym for --skill none + if (args.includes('--no-skill')) { + skillMode = 'none'; + } + + // Parse --force flag + const force = args.includes('--force'); + + // Reject unknown flags to catch typos early + const knownFlagPrefixes = ['--llm=', '--skill=']; + const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h']); + for (const a of args) { + if (knownFlagExact.has(a)) continue; + if (knownFlagPrefixes.some(p => a.startsWith(p))) continue; + if (!a.startsWith('-')) continue; // non-flag positional (e.g. value token for --skill) + console.error(`Error: Unknown option "${a}". Run apra-fleet install --help for usage.`); + process.exit(1); + } + const installFleet = skillMode === 'fleet' || skillMode === 'pm' || skillMode === 'all'; const installPm = skillMode === 'pm' || skillMode === 'all'; const totalSteps = (installFleet && installPm) ? 7 : installFleet ? 6 : installPm ? 7 : 5; @@ -361,6 +432,28 @@ export async function runInstall(args: string[]): Promise { console.warn(`\n⚠ Note: Gemini does not support background agents. If you plan to use Gemini as the\n PM/orchestrator, fleet operations will run sequentially (no parallel dispatch).\n For best orchestration performance, consider using Claude. See docs for details.\n`); } + // --- Running-process guard (SEA mode only — dev mode runs via node, not the binary) --- + if (isSea() && isApraFleetRunning()) { + if (!force) { + const killHint = process.platform === 'win32' + ? ' taskkill /F /IM apra-fleet.exe' + : ' pkill -x apra-fleet'; + console.error(` +Error: apra-fleet is currently running. Stop the server before installing. + + Run with --force to stop it automatically: + apra-fleet install --force + + Or stop it manually: +${killHint} +`); + process.exit(1); + } + killApraFleet(); + await new Promise(resolve => setTimeout(resolve, 500)); + console.log(' Stopped running server.'); + } + console.log(`\nInstalling Apra Fleet ${serverVersion} for ${paths.name}...\n`); // --- Step 1: Copy binary --- @@ -478,7 +571,7 @@ export async function runInstall(args: string[]): Promise { } if (!installFleet && !installPm) { - console.log(` Skipping skills (use --skill [all|fleet|pm] to install)`); + console.log(` Skipping skills (use --skill all to install, or omit --skill for default)`); } // Finalize permissions @@ -486,6 +579,7 @@ export async function runInstall(args: string[]): Promise { // --- Done --- const instructions = llm === 'claude' ? 'Run /mcp in Claude Code to load the server.' : `Restart ${paths.name} to load the server.`; + const forceNote = force ? '\nRestart Claude Code to reload the MCP server.' : ''; console.log(` Apra Fleet ${serverVersion} installed successfully for ${paths.name}. Binary: ${BIN_DIR} @@ -493,6 +587,6 @@ Apra Fleet ${serverVersion} installed successfully for ${paths.name}. Scripts: ${SCRIPTS_DIR} Settings: ${paths.settingsFile}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''} -${instructions} +${instructions}${forceNote} `); } diff --git a/src/index.ts b/src/index.ts index db63e11e..1a9604da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,10 +15,12 @@ if (arg === '--help' || arg === '-h') { Usage: apra-fleet Start MCP server (stdio) - apra-fleet install Install: binary + hooks + statusline + register MCP - apra-fleet install --skill [all] Same + fleet skill + PM skill (default when --skill given without value) + apra-fleet install Install binary + hooks + statusline + MCP + fleet & PM skills (default) + apra-fleet install --skill all Same as bare install (all skills) apra-fleet install --skill fleet Install fleet skill only apra-fleet install --skill pm Install PM skill (also installs fleet — PM depends on fleet) + apra-fleet install --skill none Skip skill installation + apra-fleet install --no-skill Same as --skill none apra-fleet auth Provide password for pending registration (auto-launched) apra-fleet --version Print version apra-fleet --help Show this help`); diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 6b2ff60a..5ab07584 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,5 +1,4 @@ import type { ProviderAdapter, PromptOptions, ParsedResponse } from './provider.js'; -import { buildResumeFlag } from './provider.js'; import type { LlmProvider, SSHExecResult } from '../types.js'; import type { PromptErrorCategory } from '../utils/prompt-errors.js'; import { classifyPromptError } from '../utils/prompt-errors.js'; @@ -37,9 +36,8 @@ export class ClaudeProvider implements ProviderAdapter { const turns = maxTurns ?? 50; const instruction = `Your task is described in ${promptFile} in the current directory. Read that file first, then execute the task.`; let cmd = `cd "${escapedFolder}" && claude -p "${instruction}" --output-format json --max-turns ${turns}`; - const rf = buildResumeFlag(sessionId); - if (rf) { - cmd += ` ${rf}`; + if (sessionId) { + cmd += ' -c'; } if (dangerouslySkipPermissions) { cmd += ' --dangerously-skip-permissions'; @@ -89,7 +87,7 @@ export class ClaudeProvider implements ProviderAdapter { } resumeFlag(sessionId?: string): string { - return buildResumeFlag(sessionId); + return sessionId ? '-c' : ''; } modelTiers(): Record<'cheap' | 'standard' | 'premium', string> { diff --git a/src/providers/index.ts b/src/providers/index.ts index 22d8e224..d0e48831 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -13,7 +13,14 @@ const providers: Record = { }; export function getProvider(llmProvider?: LlmProvider | null): ProviderAdapter { - return providers[llmProvider ?? 'claude']; + if (!llmProvider) return providers['claude']; + const adapter = providers[llmProvider]; + if (!adapter) { + throw new TypeError( + `Unknown LLM provider "${llmProvider}". Supported: ${Object.keys(providers).join(', ')}` + ); + } + return adapter; } export type { ProviderAdapter, PromptOptions, ParsedResponse } from './provider.js'; diff --git a/src/services/statusline.ts b/src/services/statusline.ts index aac88cfa..ed141d08 100644 --- a/src/services/statusline.ts +++ b/src/services/statusline.ts @@ -39,7 +39,17 @@ function saveState(state: Record): void { export function writeStatusline(overrides?: Map): void { try { const agents = getAllAgents(); - if (agents.length === 0) return; + if (agents.length === 0) { + // #39: clear statusline + per-agent state when last member is removed, + // otherwise the stale icon lingers under Claude's input. + if (fs.existsSync(STATUSLINE_PATH)) { + fs.writeFileSync(STATUSLINE_PATH, '\n', { mode: 0o600 }); + } + if (fs.existsSync(STATE_PATH)) { + fs.writeFileSync(STATE_PATH, '{}', { mode: 0o600 }); + } + return; + } const saved = loadState(); diff --git a/tests/install-force.test.ts b/tests/install-force.test.ts new file mode 100644 index 00000000..f23b8478 --- /dev/null +++ b/tests/install-force.test.ts @@ -0,0 +1,250 @@ +/** + * Tests for --force flag, busy-server prompt, and unknown flag rejection (#96). + * Uses _setSeaOverride to simulate SEA mode so the process-detection guard fires. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; +import { runInstall, isApraFleetRunning, killApraFleet, _setSeaOverride, _setManifestOverride } from '../src/cli/install.js'; + +vi.mock('node:os', () => ({ + default: { + homedir: vi.fn(() => '/mock/home'), + platform: vi.fn(() => 'linux'), + } +})); +vi.mock('node:fs'); +vi.mock('node:child_process'); + +const mockHome = '/mock/home'; + +function makeFsMock() { + const fileState = new Map(); + vi.mocked(fs.existsSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('version.json')) return true; + if (ps.includes('hooks-config.json')) return true; + if (fileState.has(ps)) return true; + return false; + }); + vi.mocked(fs.readFileSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (fileState.has(ps)) return fileState.get(ps)!; + if (ps.includes('version.json')) return JSON.stringify({ version: '0.1.0' }); + if (ps.includes('hooks-config.json')) return JSON.stringify({ hooks: { PostToolUse: [] } }); + return ''; + }); + vi.mocked(fs.writeFileSync).mockImplementation((p: any, content: any) => { + fileState.set(p.toString(), content.toString()); + }); + vi.mocked(fs.readdirSync).mockReturnValue([] as any); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined as any); + vi.mocked(fs.chmodSync).mockImplementation(() => {}); + vi.mocked(fs.copyFileSync).mockImplementation(() => {}); +} + +// Make pgrep -x succeed (server running) on Linux, fail on others +function mockServerRunning() { + vi.mocked(execSync).mockImplementation((cmd: any) => { + const c = cmd.toString(); + if (c === 'pgrep -x apra-fleet') return 'apra-fleet' as any; + if (c.startsWith('tasklist')) return 'apra-fleet.exe 1234 Console' as any; + return '' as any; + }); +} + +// Make pgrep -x throw exit 1 (no server) +function mockServerNotRunning() { + vi.mocked(execSync).mockImplementation((cmd: any) => { + const c = cmd.toString(); + if (c === 'pgrep -x apra-fleet') { + throw Object.assign(new Error('no match'), { status: 1 }); + } + return '' as any; + }); +} + +describe('install --force (#96)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(os.homedir).mockReturnValue(mockHome); + makeFsMock(); + // Simulate SEA mode so the process-detection guard runs + _setSeaOverride(true); + // Provide an empty manifest so loadManifest() doesn't call getSeaAsset() + _setManifestOverride({ version: '0.1.0', hooks: {}, scripts: {}, skills: {}, fleetSkills: {} }); + }); + + afterEach(() => { + _setSeaOverride(null); + _setManifestOverride(null); + Object.defineProperty(process, 'platform', { value: process.platform, configurable: true }); + }); + + it('no server running — installs without prompt', async () => { + mockServerNotRunning(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + + await expect(runInstall(['--skill', 'none'])).resolves.toBeUndefined(); + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it('server running, no --force — prints error and exits 1 (Linux)', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + mockServerRunning(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runInstall(['--skill', 'none'])).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + const errText = errorSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(errText).toContain('apra-fleet is currently running'); + expect(errText).toContain('--force'); + expect(errText).toContain('pkill -x apra-fleet'); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('server running, no --force — prints taskkill hint on Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + mockServerRunning(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runInstall(['--skill', 'none'])).rejects.toThrow('exit'); + const errText = errorSpy.mock.calls.map(c => c.join(' ')).join('\n'); + expect(errText).toContain('taskkill /F /IM apra-fleet.exe'); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('server running, --force — kills server and completes install (Linux)', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const killCalls: string[] = []; + vi.mocked(execSync).mockImplementation((cmd: any) => { + const c = cmd.toString(); + if (c === 'pgrep -x apra-fleet') return 'apra-fleet' as any; + if (c === 'pkill -x apra-fleet') { killCalls.push(c); return '' as any; } + return '' as any; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + + await expect(runInstall(['--skill', 'none', '--force'])).resolves.toBeUndefined(); + expect(killCalls).toContain('pkill -x apra-fleet'); + expect(exitSpy).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); + + it('server running, --force — kills server and completes install (Windows)', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const killCalls: string[] = []; + vi.mocked(execSync).mockImplementation((cmd: any) => { + const c = cmd.toString(); + if (c.startsWith('tasklist')) return 'apra-fleet.exe 1234' as any; + if (c.startsWith('taskkill')) { killCalls.push(c); return '' as any; } + return '' as any; + }); + vi.spyOn(console, 'log').mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + + await expect(runInstall(['--skill', 'none', '--force'])).resolves.toBeUndefined(); + expect(killCalls).toContain('taskkill /F /IM apra-fleet.exe'); + expect(exitSpy).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + }); + + it('--force install success message includes "Restart Claude Code"', async () => { + mockServerRunning(); + vi.mocked(execSync).mockImplementation((cmd: any) => { + const c = cmd.toString(); + if (c === 'pgrep -x apra-fleet') return 'apra-fleet' as any; + return '' as any; + }); + const logLines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { logLines.push(args.join(' ')); }); + + await runInstall(['--skill', 'none', '--force']); + + expect(logLines.join('\n')).toContain('Restart Claude Code to reload the MCP server.'); + }); + + it('no --force, no running server — success message does NOT include restart note', async () => { + mockServerNotRunning(); + const logLines: string[] = []; + vi.spyOn(console, 'log').mockImplementation((...args) => { logLines.push(args.join(' ')); }); + + await runInstall(['--skill', 'none']); + + expect(logLines.join('\n')).not.toContain('Restart Claude Code to reload the MCP server.'); + }); + + it('unknown flag errors with non-zero exit', async () => { + mockServerNotRunning(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await expect(runInstall(['--typo-flag'])).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy.mock.calls.map(c => c.join(' ')).join('\n')).toContain('Unknown option "--typo-flag"'); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); +}); + +describe('isApraFleetRunning / killApraFleet helpers (#96)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: process.platform, configurable: true }); + }); + + it('isApraFleetRunning returns true when pgrep -x exits 0 (Linux)', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + vi.mocked(execSync).mockImplementation(() => '' as any); + expect(isApraFleetRunning()).toBe(true); + }); + + it('isApraFleetRunning returns false when pgrep -x exits non-zero (Linux)', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + vi.mocked(execSync).mockImplementation(() => { throw Object.assign(new Error('no match'), { status: 1 }); }); + expect(isApraFleetRunning()).toBe(false); + }); + + it('isApraFleetRunning returns true when tasklist output contains apra-fleet.exe (Windows)', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + vi.mocked(execSync).mockReturnValue('apra-fleet.exe 1234 Console' as any); + expect(isApraFleetRunning()).toBe(true); + }); + + it('isApraFleetRunning returns false when tasklist output does not contain apra-fleet.exe (Windows)', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + vi.mocked(execSync).mockReturnValue('No tasks are running which match the specified criteria.' as any); + expect(isApraFleetRunning()).toBe(false); + }); + + it('killApraFleet calls pkill -x apra-fleet on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + const calls: string[] = []; + vi.mocked(execSync).mockImplementation((cmd: any) => { calls.push(cmd.toString()); return '' as any; }); + killApraFleet(); + expect(calls).toContain('pkill -x apra-fleet'); + }); + + it('killApraFleet calls taskkill on Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + const calls: string[] = []; + vi.mocked(execSync).mockImplementation((cmd: any) => { calls.push(cmd.toString()); return '' as any; }); + killApraFleet(); + expect(calls).toContain('taskkill /F /IM apra-fleet.exe'); + }); +}); diff --git a/tests/install-multi-provider.test.ts b/tests/install-multi-provider.test.ts index 1d58eee3..5404651d 100644 --- a/tests/install-multi-provider.test.ts +++ b/tests/install-multi-provider.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execSync } from 'node:child_process'; +import { parse as parseToml } from 'smol-toml'; import { runInstall } from '../src/cli/install.js'; vi.mock('node:os', () => ({ @@ -375,6 +376,29 @@ describe('runInstall multi-provider', () => { expect(defaultModelWrite![1].toString()).toContain('gpt-5.4'); }); + it('Codex config.toml is valid TOML — every scalar string is properly double-quoted (#115)', async () => { + await runInstall(['--llm', 'codex']); + + const codexConfig = path.join(mockHome, '.codex', 'config.toml'); + const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c => + c[0].toString().includes(codexConfig) + ); + expect(writes.length).toBeGreaterThan(0); + const finalContent = writes.at(-1)![1].toString(); + + // Regression guard for #115: no bare/backslash-prefixed scalars like `model = \gpt-5.3-codex`. + // Every `key = value` scalar must either be quoted, a boolean, a number, a table, or an array. + expect(finalContent).not.toMatch(/=\s*\\/); + expect(finalContent).toMatch(/defaultModel\s*=\s*"gpt-5\.4"/); + + // Parsing back with smol-toml must succeed and round-trip defaultModel. + const parsed = parseToml(finalContent) as any; + expect(parsed.defaultModel).toBe('gpt-5.4'); + // mcp_servers.apra-fleet.command should be a plain string (proper TOML string literal). + expect(typeof parsed.mcp_servers['apra-fleet'].command).toBe('string'); + expect(Array.isArray(parsed.mcp_servers['apra-fleet'].args)).toBe(true); + }); + it('writes defaultModel for Copilot (claude-sonnet-4-5) to settings.json', async () => { await runInstall(['--llm', 'copilot']); @@ -522,6 +546,102 @@ describe('runInstall multi-provider', () => { exitSpy.mockRestore(); }); + it('bare install (no flags) defaults to all — installs fleet + pm skills', async () => { + vi.mocked(fs.readdirSync).mockImplementation((p: any) => { + const ps = p.toString(); + if (ps.includes('skills') && ps.includes('pm')) { + return [{ name: 'SKILL.md', isDirectory: () => false }] as any; + } + return []; + }); + + await runInstall([]); + + const fleetSkillsDir = path.join(mockHome, '.claude', 'skills', 'fleet'); + const pmSkillsDir = path.join(mockHome, '.claude', 'skills', 'pm'); + expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith( + expect.stringContaining(fleetSkillsDir), + expect.any(Object) + ); + expect(vi.mocked(fs.mkdirSync)).toHaveBeenCalledWith( + expect.stringContaining(pmSkillsDir), + expect.any(Object) + ); + }); + + it('--skill none skips both fleet and pm skills', async () => { + await runInstall(['--skill', 'none']); + + const fleetSkillsDir = path.join(mockHome, '.claude', 'skills', 'fleet'); + const pmSkillsDir = path.join(mockHome, '.claude', 'skills', 'pm'); + const fleetMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(fleetSkillsDir) + ); + const pmMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(pmSkillsDir) + ); + expect(fleetMkdir).toBeUndefined(); + expect(pmMkdir).toBeUndefined(); + }); + + it('--skill=none (equals form) skips both fleet and pm skills', async () => { + await runInstall(['--skill=none']); + + const fleetSkillsDir = path.join(mockHome, '.claude', 'skills', 'fleet'); + const pmSkillsDir = path.join(mockHome, '.claude', 'skills', 'pm'); + const fleetMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(fleetSkillsDir) + ); + const pmMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(pmSkillsDir) + ); + expect(fleetMkdir).toBeUndefined(); + expect(pmMkdir).toBeUndefined(); + }); + + it('--no-skill skips both fleet and pm skills', async () => { + await runInstall(['--no-skill']); + + const fleetSkillsDir = path.join(mockHome, '.claude', 'skills', 'fleet'); + const pmSkillsDir = path.join(mockHome, '.claude', 'skills', 'pm'); + const fleetMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(fleetSkillsDir) + ); + const pmMkdir = vi.mocked(fs.mkdirSync).mock.calls.find(c => + c[0].toString().includes(pmSkillsDir) + ); + expect(fleetMkdir).toBeUndefined(); + expect(pmMkdir).toBeUndefined(); + }); + + // --help / -h guard tests (#142) + + it('--help prints usage and exits 0 with no side effects', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await expect(runInstall(['--help'])).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(logSpy.mock.calls.map(c => c.join(' ')).join('\n')).toContain('apra-fleet install'); + // No file writes should have occurred + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + + it('-h prints usage and exits 0 with no side effects', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await expect(runInstall(['-h'])).rejects.toThrow('exit'); + expect(exitSpy).toHaveBeenCalledWith(0); + expect(vi.mocked(fs.writeFileSync)).not.toHaveBeenCalled(); + + exitSpy.mockRestore(); + logSpy.mockRestore(); + }); + it('fleet skill is installed before pm skill (fleet-before-pm order)', async () => { vi.mocked(fs.readdirSync).mockImplementation((p: any) => { const ps = p.toString(); diff --git a/tests/providers.test.ts b/tests/providers.test.ts index 9e6e2679..caf50a9c 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -64,9 +64,17 @@ describe('ClaudeProvider', () => { expect(cmd).not.toContain('--dangerously-skip-permissions'); }); - it('builds prompt command with session resume', () => { + it('builds prompt command with session resume using -c (#108)', () => { const cmd = p.buildPromptCommand({ ...BASE_OPTS, sessionId: 'sess-abc' }); - expect(cmd).toContain('--resume "sess-abc"'); + expect(cmd).toMatch(/\s-c(\s|$)/); + expect(cmd).not.toContain('--resume'); + expect(cmd).not.toContain('sess-abc'); + }); + + it('builds prompt command without sessionId emits neither -c nor --resume', () => { + const cmd = p.buildPromptCommand({ ...BASE_OPTS }); + expect(cmd).not.toMatch(/\s-c(\s|$)/); + expect(cmd).not.toContain('--resume'); }); it('builds prompt command with dangerously skip permissions', () => { @@ -124,8 +132,8 @@ describe('ClaudeProvider', () => { expect(p.supportsMaxTurns()).toBe(true); }); - it('resumeFlag with sessionId includes ID', () => { - expect(p.resumeFlag('ses-1')).toBe('--resume "ses-1"'); + it('resumeFlag with sessionId returns -c (#108)', () => { + expect(p.resumeFlag('ses-1')).toBe('-c'); }); it('resumeFlag without sessionId returns empty string', () => { @@ -551,6 +559,23 @@ describe('getProvider factory', () => { expect(getProvider('claude')).toBe(getProvider('claude')); expect(getProvider('gemini')).toBe(getProvider('gemini')); }); + + it('throws TypeError for unknown provider strings (no silent fallback)', () => { + // Cast to bypass TS — registry JSON could yield arbitrary strings at runtime + expect(() => getProvider('bogus' as any)).toThrow(TypeError); + expect(() => getProvider('bogus' as any)).toThrow(/Unknown LLM provider "bogus"/); + }); + + it('error message lists supported providers', () => { + try { + getProvider('nonsense' as any); + } catch (e: any) { + expect(e.message).toMatch(/claude/); + expect(e.message).toMatch(/gemini/); + expect(e.message).toMatch(/codex/); + expect(e.message).toMatch(/copilot/); + } + }); }); // ─── buildResumeFlag shared helper ─────────────────────────────────────────── diff --git a/tests/statusline.test.ts b/tests/statusline.test.ts new file mode 100644 index 00000000..e8059b51 --- /dev/null +++ b/tests/statusline.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Redirect fleet dir to a per-test temp directory so we operate on a real +// filesystem sandbox — writeStatusline uses real fs I/O, not mocks. +const TMP_DIR = path.join(os.tmpdir(), `fleet-statusline-test-${process.pid}`); +process.env.APRA_FLEET_DATA_DIR = TMP_DIR; + +// Import AFTER setting the env var so paths.ts picks it up. +const { writeStatusline } = await import('../src/services/statusline.js'); +const { addAgent, removeAgent, getAllAgents } = await import('../src/services/registry.js'); + +const STATUSLINE_PATH = path.join(TMP_DIR, 'statusline.txt'); +const STATE_PATH = path.join(TMP_DIR, 'statusline-state.json'); +const REGISTRY_PATH = path.join(TMP_DIR, 'registry.json'); + +function makeAgent(id: string, name: string): any { + return { + id, + friendlyName: name, + agentType: 'local', + workFolder: '/tmp/work', + os: 'linux', + createdAt: '2026-01-01T00:00:00.000Z', + icon: '🔵', + }; +} + +describe('writeStatusline — #39 statusline clears after remove_member', () => { + beforeEach(() => { + // Fresh sandbox per test. + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + }); + + afterEach(() => { + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + }); + + it('clears statusline file when last member is removed', () => { + // 1. Register one agent and mark it busy. + addAgent(makeAgent('agent-a', 'agent-a')); + writeStatusline(new Map([['agent-a', 'busy']])); + + // Sanity: statusline file should now contain the busy icon for agent-a. + expect(fs.existsSync(STATUSLINE_PATH)).toBe(true); + const before = fs.readFileSync(STATUSLINE_PATH, 'utf-8'); + expect(before).toContain('agent-a'); + expect(before.trim()).not.toBe(''); + + // 2. Remove the last agent, then call writeStatusline (mirrors remove-member.ts). + removeAgent('agent-a'); + expect(getAllAgents()).toHaveLength(0); + writeStatusline(); + + // 3. Statusline should be effectively empty (no lingering icon under Claude input). + expect(fs.existsSync(STATUSLINE_PATH)).toBe(true); + const after = fs.readFileSync(STATUSLINE_PATH, 'utf-8'); + expect(after.trim()).toBe(''); + // State file should also be reset so stale "busy" doesn't come back on next write. + const state = fs.readFileSync(STATE_PATH, 'utf-8'); + expect(JSON.parse(state)).toEqual({}); + }); + + it('does not clobber statusline when a later removal leaves other agents', () => { + addAgent(makeAgent('agent-a', 'agent-a')); + addAgent(makeAgent('agent-b', 'agent-b')); + writeStatusline(new Map([['agent-a', 'busy'], ['agent-b', 'idle']])); + + removeAgent('agent-a'); + writeStatusline(); + + const content = fs.readFileSync(STATUSLINE_PATH, 'utf-8'); + expect(content).toContain('agent-b'); + expect(content).not.toContain('agent-a'); + }); +});