diff --git a/README.md b/README.md
index a558dc93..a076271b 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,31 @@ claude mcp remove apra-fleet --scope user
+
+Running multiple fleet instances on the same machine
+
+Use `--instance` (recommended) or `--data-dir` to keep fleet data isolated per project.
+
+**Named instance** — registers the MCP server as `apra-fleet-` with data under `~/.apra-fleet/workspaces/`:
+```bash
+apra-fleet install --instance my-project
+```
+
+**Custom data directory:**
+```bash
+apra-fleet install --data-dir ~/fleet-data/my-project
+```
+
+**Manage instances with the workspace command:**
+```bash
+apra-fleet workspace list # show all instances
+apra-fleet workspace status # current instance info
+```
+
+Credentials stored in one instance are not readable by another — each instance has its own encryption salt.
+
+
+
## Register your first member
A "member" is any machine (or workspace) that fleet manages. There are two types:
diff --git a/feedback.md b/feedback.md
new file mode 100644
index 00000000..cd936742
--- /dev/null
+++ b/feedback.md
@@ -0,0 +1,51 @@
+# PR #231 — feat/per-instance-data-dir — Code Review
+
+## Verdict: APPROVED
+
+Build: ✅ pass | Tests: ✅ 1138 passed, 6 skipped, 0 failures
+
+---
+
+## Findings
+
+### MEDIUM — `mcp remove` still uses shell-interpolated `execSync` (inconsistency)
+
+**File:** `src/cli/install.ts:595`
+
+The security fix correctly switches `claude mcp add` to `execFileSync` with an argv array, eliminating shell injection. However, `claude mcp remove` still goes through the `run()` helper which uses `execSync` with string interpolation:
+
+```ts
+run(`claude mcp remove ${serverName} --scope user`, { stdio: 'ignore' });
+```
+
+This is **not exploitable** because `serverName` is derived from `instanceName` which is validated against `/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/` — no shell metacharacters can pass. But for consistency and defense-in-depth, consider switching this to `execFileSync` as well.
+
+### LOW — `workspace.ts` duplicates path constants from `paths.ts`
+
+**File:** `src/cli/workspace.ts:5-9`
+
+`APRA_BASE`, `WORKSPACES_DIR`, and `WORKSPACES_INDEX` are defined identically in both `src/paths.ts` and `src/cli/workspace.ts`. Should import from `../paths.ts` to avoid drift.
+
+### LOW — `cmdUse` eval suggestion could confuse users
+
+**File:** `src/cli/workspace.ts:151`
+
+The output suggests `eval "$(apra-fleet workspace use )"` but the command also prints a comment line (`# To activate...`) which would be harmless but noisy in eval context. Consider emitting the export-only line when stdout is not a TTY, or documenting that eval will work despite the comment.
+
+---
+
+## Security Assessment
+
+- **Shell injection:** Fully eliminated for the `mcp add` path (the attack vector described in #193). The `mcp remove` path is safe due to input validation but could be hardened for consistency.
+- **Path traversal:** `--instance` name is alphanumeric-only (regex validated), `--data-dir` resolves `~` safely. No directory traversal possible via instance names.
+- **No user input flows unvalidated into file system paths** — workspace names are validated, data-dir is used as-is (user controls their own filesystem).
+
+## Backward Compatibility
+
+- No `--data-dir` / `--instance` → old behavior unchanged: server name remains `apra-fleet`, data dir defaults to `~/.apra-fleet/data`.
+- `FLEET_DIR` in `paths.ts` still falls back to the old default when `APRA_FLEET_DATA_DIR` is unset.
+- Existing MCP registrations are unaffected.
+
+## Summary
+
+Clean implementation. The --instance/--data-dir design is intuitive, input validation is solid, the security fix addresses the reported shell injection, tests cover flag parsing + multi-provider + permissions correctly. The two MEDIUM/LOW items are non-blocking improvements.
diff --git a/llms-full.txt b/llms-full.txt
index 039125f3..e7f53c50 100644
--- a/llms-full.txt
+++ b/llms-full.txt
@@ -174,6 +174,31 @@ claude mcp remove apra-fleet --scope user
+
+Running multiple fleet instances on the same machine
+
+Use `--instance` (recommended) or `--data-dir` to keep fleet data isolated per project.
+
+**Named instance** — registers the MCP server as `apra-fleet-` with data under `~/.apra-fleet/workspaces/`:
+```bash
+apra-fleet install --instance my-project
+```
+
+**Custom data directory:**
+```bash
+apra-fleet install --data-dir ~/fleet-data/my-project
+```
+
+**Manage instances with the workspace command:**
+```bash
+apra-fleet workspace list # show all instances
+apra-fleet workspace status # current instance info
+```
+
+Credentials stored in one instance are not readable by another — each instance has its own encryption salt.
+
+
+
## Register your first member
A "member" is any machine (or workspace) that fleet manages. There are two types:
diff --git a/skills/fleet/SKILL.md b/skills/fleet/SKILL.md
index a994da02..77b335fe 100644
--- a/skills/fleet/SKILL.md
+++ b/skills/fleet/SKILL.md
@@ -37,6 +37,8 @@ This skill defines how to interact with fleet infrastructure: registering and on
| `credential_store_update` | Update credential metadata (members, TTL, network policy) without re-entering the secret |
| `stop_prompt` | Kill the active LLM process on a member. **Always call `TaskStop` after calling `stop_prompt`**.
**Use when:** a member is hung, working on the wrong thing, or needs to be cancelled. |
+**Multiple instances on the same machine:** Use `--instance ` when installing to isolate fleet data per project (registers the MCP server as `apra-fleet-`, data under `~/.apra-fleet/workspaces/`). Example: `apra-fleet install --instance my-project`. Each instance has its own credential store and member registry.
+
See sub-documents for detailed usage:
- `onboarding.md` — full 8-step member onboarding sequence
- `permissions.md` — permission composition and denial handling
diff --git a/src/cli/install.ts b/src/cli/install.ts
index 1fed4127..552708a6 100644
--- a/src/cli/install.ts
+++ b/src/cli/install.ts
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
-import { execSync } from 'node:child_process';
+import { execSync, execFileSync } from 'node:child_process';
import { parse, stringify } from 'smol-toml';
import { serverVersion } from '../version.js';
import type { LlmProvider } from '../types.js';
@@ -240,11 +240,11 @@ function mergeHooksConfig(paths: ProviderInstallConfig, hooksConfig: any): void
writeConfig(paths, settings);
}
-function mergePermissions(paths: ProviderInstallConfig): void {
+function mergePermissions(paths: ProviderInstallConfig, serverName: string = 'apra-fleet'): void {
const settings = readConfig(paths);
const requiredPerms = [
- 'mcp__apra-fleet__*',
+ `mcp__${serverName}__*`,
'Agent(*)',
`Read(${paths.skillsDir.replace(/\\/g, '/')}/**)`,
`Read(${paths.fleetSkillsDir.replace(/\\/g, '/')}/**)`,
@@ -273,11 +273,12 @@ function configureStatusline(paths: ProviderInstallConfig, scriptPath: string):
writeConfig(paths, settings);
}
-function mergeGeminiConfig(paths: ProviderInstallConfig, mcpConfig: any): void {
+function mergeGeminiConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void {
const settings = readConfig(paths);
settings.mcpServers = settings.mcpServers || {};
- settings.mcpServers['apra-fleet'] = {
+ settings.mcpServers[serverName] = {
...mcpConfig,
+ ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}),
trust: true,
};
@@ -297,20 +298,24 @@ function writeDefaultModel(paths: ProviderInstallConfig, standardModel: string):
writeConfig(paths, settings);
}
-function mergeCopilotConfig(paths: ProviderInstallConfig, mcpConfig: any): void {
+function mergeCopilotConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void {
const settings = readConfig(paths);
settings.mcpServers = settings.mcpServers || {};
- settings.mcpServers['apra-fleet'] = mcpConfig;
+ settings.mcpServers[serverName] = {
+ ...mcpConfig,
+ ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}),
+ };
writeConfig(paths, settings);
}
-function mergeCodexConfig(paths: ProviderInstallConfig, mcpConfig: any): void {
+function mergeCodexConfig(paths: ProviderInstallConfig, mcpConfig: any, serverName: string, envVars?: Record): void {
const settings = readConfig(paths);
settings.mcp_servers = settings.mcp_servers || {};
- settings.mcp_servers['apra-fleet'] = {
+ settings.mcp_servers[serverName] = {
command: mcpConfig.command.replace(/\\/g, '/'),
args: mcpConfig.args.map((a: string) => a.replace(/\\/g, '/')),
+ ...(envVars && Object.keys(envVars).length > 0 ? { env: envVars } : {}),
};
writeConfig(paths, settings);
@@ -361,15 +366,17 @@ export async function runInstall(args: string[]): Promise {
Install the apra-fleet binary, hooks, MCP server registration, and skills.
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 before installing
- apra-fleet install --llm Target LLM provider: claude (default), gemini, codex, copilot
- apra-fleet install --help Show this help
+ 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 before installing
+ apra-fleet install --llm Target LLM provider: claude (default), gemini, codex, copilot
+ apra-fleet install --data-dir Use a custom data directory (isolates registry, statusline, etc.)
+ apra-fleet install --instance Shorthand: --data-dir ~/.apra-fleet/workspaces/, registers as apra-fleet-
+ apra-fleet install --help Show this help
Options:
--llm LLM provider to configure. Supported: claude, gemini, codex, copilot.
@@ -378,7 +385,9 @@ Options:
run sequentially rather than in parallel.
--skill Which skills to install: all (default), fleet, pm, or none.
--no-skill Alias for --skill none.
- --force Stop a running apra-fleet server before installing (SEA mode only).`);
+ --force Stop a running apra-fleet server before installing (SEA mode only).
+ --data-dir Use a custom data directory (isolates registry, statusline, etc.).
+ --instance Shorthand for --data-dir ~/.apra-fleet/workspaces/.`);
process.exit(0);
return;
}
@@ -401,6 +410,48 @@ Options:
process.exit(1);
}
+ // Parse --instance flag (shorthand: sets data-dir to workspaces/ + server name to apra-fleet-)
+ let instanceName: string | undefined;
+ const instanceEqualArg = args.find(a => a.startsWith('--instance='));
+ if (instanceEqualArg) {
+ instanceName = instanceEqualArg.split('=').slice(1).join('=');
+ } else {
+ const idx = args.indexOf('--instance');
+ if (idx >= 0 && idx < args.length - 1 && !args[idx + 1].startsWith('--')) {
+ instanceName = args[idx + 1];
+ }
+ }
+ if (instanceName !== undefined && !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(instanceName)) {
+ console.error(`Error: --instance name must be alphanumeric (with optional - or _), max 64 chars.`);
+ process.exit(1);
+ }
+
+ // Parse --data-dir flag
+ let dataDir: string | undefined;
+ const dataDirEqualArg = args.find(a => a.startsWith('--data-dir='));
+ if (dataDirEqualArg) {
+ dataDir = dataDirEqualArg.split('=').slice(1).join('=');
+ } else {
+ const idx = args.indexOf('--data-dir');
+ if (idx >= 0 && idx < args.length - 1 && !args[idx + 1].startsWith('--')) {
+ dataDir = args[idx + 1];
+ }
+ }
+
+ // --instance expands to --data-dir ~/.apra-fleet/workspaces/ if --data-dir not also set
+ if (instanceName && !dataDir) {
+ dataDir = path.join(home, '.apra-fleet', 'workspaces', instanceName);
+ }
+
+ // Resolve ~ in --data-dir
+ if (dataDir) {
+ dataDir = dataDir.replace(/^~(?=$|\/)/, home);
+ }
+
+ // Derive MCP server name: apra-fleet- for --instance, otherwise apra-fleet
+ const serverName = instanceName ? `apra-fleet-${instanceName}` : 'apra-fleet';
+ const envVars: Record = dataDir ? { APRA_FLEET_DATA_DIR: dataDir } : {};
+
const paths = getProviderInstallConfig(llm);
// Parse --skill flag: default (no flag) = all; accepts all|fleet|pm|none; --no-skill = synonym for none
@@ -437,8 +488,8 @@ Options:
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']);
+ const knownFlagPrefixes = ['--llm=', '--skill=', '--data-dir=', '--instance='];
+ const knownFlagExact = new Set(['--llm', '--skill', '--no-skill', '--force', '--help', '-h', '--data-dir', '--instance']);
for (const a of args) {
if (knownFlagExact.has(a)) continue;
if (knownFlagPrefixes.some(p => a.startsWith(p))) continue;
@@ -541,19 +592,42 @@ ${killHint}
if (llm === 'claude') {
try {
- run('claude mcp remove apra-fleet --scope user', { stdio: 'ignore' });
+ run(`claude mcp remove ${serverName} --scope user`, { stdio: 'ignore' });
} catch { /* not registered */ }
-
- const cmd = mcpConfig.command === 'node'
- ? `claude mcp add --scope user apra-fleet -- node "${mcpConfig.args[0]}"`
- : `claude mcp add --scope user apra-fleet -- "${mcpConfig.command}"`;
- run(cmd);
+
+ const envArgs = Object.entries(envVars).flatMap(([k, v]) => ['-e', `${k}=${v}`]);
+ const serverArgs = mcpConfig.command === 'node'
+ ? ['node', mcpConfig.args[0]]
+ : [mcpConfig.command];
+ const addArgs = ['mcp', 'add', '--scope', 'user', ...envArgs, serverName, '--', ...serverArgs];
+ const shellOpt = process.platform === 'win32' ? { shell: 'cmd.exe' as const } : {};
+ execFileSync('claude', addArgs, { stdio: 'inherit', ...shellOpt });
} else if (llm === 'gemini') {
- mergeGeminiConfig(paths, mcpConfig);
+ mergeGeminiConfig(paths, mcpConfig, serverName, envVars);
} else if (llm === 'codex') {
- mergeCodexConfig(paths, mcpConfig);
+ mergeCodexConfig(paths, mcpConfig, serverName, envVars);
} else if (llm === 'copilot') {
- mergeCopilotConfig(paths, mcpConfig);
+ mergeCopilotConfig(paths, mcpConfig, serverName, envVars);
+ }
+
+ // Register named workspace in the workspaces index when --instance is used
+ if (instanceName && dataDir) {
+ const workspacesIndexPath = path.join(home, '.apra-fleet', 'workspaces.json');
+ let index: { workspaces: Array<{ name: string; path: string; created: string }> } = { workspaces: [] };
+ if (fs.existsSync(workspacesIndexPath)) {
+ try { index = JSON.parse(fs.readFileSync(workspacesIndexPath, 'utf-8')); } catch { /* ignore */ }
+ }
+ index.workspaces = index.workspaces || [];
+ const existing = index.workspaces.findIndex(w => w.name === instanceName);
+ const entry = { name: instanceName, path: dataDir, created: new Date().toISOString() };
+ if (existing >= 0) {
+ index.workspaces[existing] = entry;
+ } else {
+ index.workspaces.push(entry);
+ }
+ fs.mkdirSync(path.dirname(workspacesIndexPath), { recursive: true });
+ fs.mkdirSync(dataDir, { recursive: true });
+ fs.writeFileSync(workspacesIndexPath, JSON.stringify(index, null, 2) + '\n');
}
// --- Step 6: Install fleet skill (optional) ---
@@ -598,7 +672,7 @@ ${killHint}
}
// Finalize permissions
- mergePermissions(paths);
+ mergePermissions(paths, serverName);
// Write install-config.json
const installConfig = { llm, skill: skillMode };
@@ -607,14 +681,16 @@ ${killHint}
fs.writeFileSync(path.join(configDir, 'install-config.json'), JSON.stringify(installConfig, null, 2), { mode: 0o600 });
// --- Done ---
- const instructions = llm === 'claude' ? 'Run /mcp in Claude Code to load the server.' : `Restart ${paths.name} to load the server.`;
+ const instructions = llm === 'claude' ? `Run /mcp in Claude Code to load the server (server name: ${serverName}).` : `Restart ${paths.name} to load the server.`;
const forceNote = force ? '\nRestart Claude Code to reload the MCP server.' : '';
+ const dataDirNote = dataDir ? `\n Data Dir: ${dataDir}` : '';
+ const instanceNote = serverName !== 'apra-fleet' ? `\n MCP Server: ${serverName}` : '';
console.log(`
Apra Fleet ${serverVersion} installed successfully for ${paths.name}.
Binary: ${BIN_DIR}
Hooks: ${HOOKS_DIR}
Scripts: ${SCRIPTS_DIR}
- Settings: ${paths.settingsFile}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''}
+ Settings: ${paths.settingsFile}${instanceNote}${dataDirNote}${installFleet ? `\n Fleet Skill: ${paths.fleetSkillsDir}` : ''}${installPm ? `\n PM Skill: ${paths.skillsDir}` : ''}
${instructions}${forceNote}
`);
diff --git a/src/cli/workspace.ts b/src/cli/workspace.ts
new file mode 100644
index 00000000..6b889afd
--- /dev/null
+++ b/src/cli/workspace.ts
@@ -0,0 +1,281 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+
+const home = os.homedir();
+const APRA_BASE = path.join(home, '.apra-fleet');
+const WORKSPACES_DIR = path.join(APRA_BASE, 'workspaces');
+const WORKSPACES_INDEX = path.join(APRA_BASE, 'workspaces.json');
+const DEFAULT_DATA_DIR = path.join(APRA_BASE, 'data');
+
+interface WorkspaceEntry {
+ name: string;
+ path: string;
+ created: string;
+}
+
+interface WorkspacesIndex {
+ workspaces: WorkspaceEntry[];
+}
+
+function readIndex(): WorkspacesIndex {
+ if (!fs.existsSync(WORKSPACES_INDEX)) return { workspaces: [] };
+ try {
+ return JSON.parse(fs.readFileSync(WORKSPACES_INDEX, 'utf-8'));
+ } catch {
+ return { workspaces: [] };
+ }
+}
+
+function writeIndex(index: WorkspacesIndex): void {
+ fs.mkdirSync(path.dirname(WORKSPACES_INDEX), { recursive: true });
+ fs.writeFileSync(WORKSPACES_INDEX, JSON.stringify(index, null, 2) + '\n');
+}
+
+function getDefaultWorkspace(): WorkspaceEntry {
+ return { name: 'default', path: DEFAULT_DATA_DIR, created: '' };
+}
+
+function allWorkspaces(index: WorkspacesIndex): WorkspaceEntry[] {
+ const hasDefault = index.workspaces.some(w => w.name === 'default');
+ const base: WorkspaceEntry[] = hasDefault ? [] : [getDefaultWorkspace()];
+ return [...base, ...index.workspaces];
+}
+
+function activePath(): string {
+ return process.env.APRA_FLEET_DATA_DIR ?? DEFAULT_DATA_DIR;
+}
+
+function memberCount(dataDir: string): number {
+ const registryPath = path.join(dataDir, 'registry.json');
+ if (!fs.existsSync(registryPath)) return 0;
+ try {
+ const reg = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
+ return Array.isArray(reg) ? reg.length : 0;
+ } catch {
+ return 0;
+ }
+}
+
+function statuslineAge(dataDir: string): string {
+ const slPath = path.join(dataDir, 'statusline.txt');
+ if (!fs.existsSync(slPath)) return '—';
+ try {
+ const stat = fs.statSync(slPath);
+ const ageMs = Date.now() - stat.mtimeMs;
+ const ageSec = Math.floor(ageMs / 1000);
+ if (ageSec < 60) return `${ageSec}s ago`;
+ if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
+ return `${Math.floor(ageSec / 3600)}h ago`;
+ } catch {
+ return '—';
+ }
+}
+
+function isActive(ws: WorkspaceEntry): boolean {
+ const current = activePath();
+ const resolved = ws.path.replace(/^~/, home);
+ return path.resolve(resolved) === path.resolve(current);
+}
+
+// --- Commands ---
+
+function cmdList(): void {
+ const index = readIndex();
+ const workspaces = allWorkspaces(index);
+ const current = activePath();
+
+ const col1 = Math.max(4, ...workspaces.map(w => w.name.length));
+ const col2 = Math.max(4, ...workspaces.map(w => w.path.length));
+
+ const header = `${'NAME'.padEnd(col1)} ${'PATH'.padEnd(col2)} MEMBERS ACTIVE`;
+ console.log(header);
+ console.log('-'.repeat(header.length));
+
+ for (const ws of workspaces) {
+ const active = isActive(ws) ? '✅' : '—';
+ const members = memberCount(ws.path.replace(/^~/, home));
+ const resolved = ws.path.replace(/^~/, home);
+ const display = resolved.startsWith(home) ? ws.path.replace(home, '~') : ws.path;
+ console.log(`${ws.name.padEnd(col1)} ${display.padEnd(col2)} ${String(members).padStart(7)} ${active}`);
+ }
+ void current;
+}
+
+function cmdAdd(args: string[]): void {
+ const withInstall = args.includes('--install');
+ const nameArgs = args.filter(a => !a.startsWith('--'));
+ if (nameArgs.length === 0) {
+ console.error('Error: workspace add requires a name. Usage: apra-fleet workspace add [--install]');
+ process.exit(1);
+ }
+ const name = nameArgs[0];
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(name)) {
+ console.error('Error: workspace name must be alphanumeric (with optional - or _), max 64 chars.');
+ process.exit(1);
+ }
+ if (name === 'default') {
+ console.error('Error: "default" is reserved. Use "apra-fleet workspace status default" to inspect it.');
+ process.exit(1);
+ }
+
+ const wsPath = path.join(WORKSPACES_DIR, name);
+ const index = readIndex();
+
+ const existing = index.workspaces.findIndex(w => w.name === name);
+ const entry: WorkspaceEntry = {
+ name,
+ path: wsPath,
+ created: existing >= 0 ? index.workspaces[existing].created : new Date().toISOString(),
+ };
+
+ if (existing >= 0) {
+ index.workspaces[existing] = entry;
+ console.log(`Workspace "${name}" already registered — updated path.`);
+ } else {
+ index.workspaces.push(entry);
+ console.log(`Workspace "${name}" registered at ${wsPath}`);
+ }
+
+ fs.mkdirSync(wsPath, { recursive: true });
+ writeIndex(index);
+
+ if (withInstall) {
+ console.log(`\nRunning install for workspace "${name}"...`);
+ import('./install.js')
+ .then(m => m.runInstall(['--instance', name]))
+ .catch(err => { console.error('Install failed:', err.message); process.exit(1); });
+ } else {
+ console.log(`\nTo install MCP registration for this workspace:\n apra-fleet install --instance ${name}`);
+ console.log(`\nTo activate in current shell:\n export APRA_FLEET_DATA_DIR="${wsPath}"`);
+ }
+}
+
+function cmdRemove(args: string[]): void {
+ const force = args.includes('--force');
+ const nameArgs = args.filter(a => !a.startsWith('--'));
+ if (nameArgs.length === 0) {
+ console.error('Error: workspace remove requires a name. Usage: apra-fleet workspace remove [--force]');
+ process.exit(1);
+ }
+ const name = nameArgs[0];
+ if (name === 'default') {
+ console.error('Error: cannot remove the default workspace.');
+ process.exit(1);
+ }
+
+ const index = readIndex();
+ const idx = index.workspaces.findIndex(w => w.name === name);
+ if (idx < 0) {
+ console.error(`Error: workspace "${name}" not found.`);
+ process.exit(1);
+ }
+
+ const ws = index.workspaces[idx];
+ const wsPath = ws.path.replace(/^~/, home);
+
+ // Check for members
+ const members = memberCount(wsPath);
+ if (members > 0 && !force) {
+ console.error(`Error: workspace "${name}" has ${members} registered member(s). Use --force to remove anyway.`);
+ process.exit(1);
+ }
+
+ index.workspaces.splice(idx, 1);
+ writeIndex(index);
+ console.log(`Workspace "${name}" removed from index.`);
+ console.log(`Data directory preserved at: ${wsPath}`);
+ console.log(`To also delete the data: rm -rf "${wsPath}"`);
+}
+
+function cmdUse(args: string[]): void {
+ const nameArgs = args.filter(a => !a.startsWith('--'));
+ if (nameArgs.length === 0) {
+ console.error('Error: workspace use requires a name. Usage: apra-fleet workspace use ');
+ process.exit(1);
+ }
+ const name = nameArgs[0];
+
+ const index = readIndex();
+ const workspaces = allWorkspaces(index);
+ const ws = workspaces.find(w => w.name === name);
+ if (!ws) {
+ console.error(`Error: workspace "${name}" not found. Run "apra-fleet workspace list" to see available workspaces.`);
+ process.exit(1);
+ }
+
+ const wsPath = ws.path.replace(/^~/, home);
+ console.log(`# To activate workspace "${name}", run:`);
+ console.log(`export APRA_FLEET_DATA_DIR="${wsPath}"`);
+ console.log(`\n# Or eval directly:`);
+ console.log(`eval "$(apra-fleet workspace use ${name})"`);
+}
+
+function cmdStatus(args: string[]): void {
+ const nameArgs = args.filter(a => !a.startsWith('--'));
+
+ const index = readIndex();
+ const workspaces = allWorkspaces(index);
+
+ const targets = nameArgs.length > 0
+ ? workspaces.filter(w => nameArgs.includes(w.name))
+ : workspaces;
+
+ if (targets.length === 0) {
+ console.error(`Error: workspace(s) not found: ${nameArgs.join(', ')}`);
+ process.exit(1);
+ }
+
+ for (const ws of targets) {
+ const wsPath = ws.path.replace(/^~/, home);
+ const exists = fs.existsSync(wsPath);
+ const members = exists ? memberCount(wsPath) : 0;
+ const age = exists ? statuslineAge(wsPath) : '—';
+ const saltExists = exists && fs.existsSync(path.join(wsPath, 'salt'));
+ const active = isActive(ws) ? ' [active]' : '';
+
+ console.log(`\nWorkspace: ${ws.name}${active}`);
+ console.log(` Path: ${wsPath}`);
+ console.log(` Exists: ${exists ? 'yes' : 'no'}`);
+ console.log(` Members: ${members}`);
+ console.log(` Status: ${age}`);
+ console.log(` Salt: ${saltExists ? 'present' : 'missing'}`);
+ }
+}
+
+export async function runWorkspace(args: string[]): Promise {
+ const subcommand = args[0];
+ const rest = args.slice(1);
+
+ if (!subcommand || subcommand === '--help' || subcommand === '-h') {
+ console.log(`apra-fleet workspace — manage isolated fleet data directories
+
+Usage:
+ apra-fleet workspace list
+ apra-fleet workspace add [--install]
+ apra-fleet workspace remove [--force]
+ apra-fleet workspace use
+ apra-fleet workspace status []
+
+Commands:
+ list Show all workspaces with member count and active state
+ add Create workspace ~/.apra-fleet/workspaces/, register in index
+ --install Also run MCP registration (apra-fleet install --instance )
+ remove Remove workspace from index (data dir preserved unless deleted manually)
+ --force Remove even if members are registered
+ use Print export command to activate workspace in current shell
+ status [] Show health: data dir exists, member count, statusline age, salt`);
+ return;
+ }
+
+ switch (subcommand) {
+ case 'list': cmdList(); break;
+ case 'add': cmdAdd(rest); break;
+ case 'remove': cmdRemove(rest); break;
+ case 'use': cmdUse(rest); break;
+ case 'status': cmdStatus(rest); break;
+ default:
+ console.error(`Error: unknown workspace subcommand "${subcommand}". Run "apra-fleet workspace --help" for usage.`);
+ process.exit(1);
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 287033e5..11e936c8 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -15,18 +15,25 @@ if (arg === '--help' || arg === '-h') {
console.log(`apra-fleet ${serverVersion}
Usage:
- apra-fleet Start MCP server (stdio)
- apra-fleet update Check for and install latest update
- apra-fleet update --check Check for update
- 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`);
+ apra-fleet Start MCP server (stdio)
+ apra-fleet update Check for and install latest update
+ apra-fleet update --check Check for update
+ 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 --data-dir Use a custom isolated data directory
+ apra-fleet install --instance Shorthand: --data-dir ~/.apra-fleet/workspaces/, registers as apra-fleet-
+ apra-fleet workspace list List all workspaces
+ apra-fleet workspace add Create and register a named workspace
+ apra-fleet workspace remove Remove workspace from index
+ apra-fleet workspace use Print export command to activate workspace
+ apra-fleet workspace status [] Show workspace health
+ apra-fleet auth Provide password for pending registration (auto-launched)
+ apra-fleet --version Print version
+ apra-fleet --help Show this help`);
process.exit(0);
}
@@ -35,6 +42,10 @@ if (arg === 'install') {
import('./cli/install.js')
.then(m => m.runInstall(process.argv.slice(3)))
.catch(err => { logError('cli', `Install failed: ${err.message}`); process.exit(1); });
+} else if (arg === 'workspace') {
+ import('./cli/workspace.js')
+ .then(m => m.runWorkspace(process.argv.slice(3)))
+ .catch(err => { logError('cli', `Workspace command failed: ${err.message}`); process.exit(1); });
} else if (arg === 'auth') {
import('./cli/auth.js')
.then(m => m.runAuth(process.argv.slice(3)))
diff --git a/src/paths.ts b/src/paths.ts
index 040363f0..5a65322a 100644
--- a/src/paths.ts
+++ b/src/paths.ts
@@ -2,3 +2,7 @@ import path from 'node:path';
import os from 'node:os';
export const FLEET_DIR = process.env.APRA_FLEET_DATA_DIR ?? path.join(os.homedir(), '.apra-fleet', 'data');
+
+export const APRA_BASE = path.join(os.homedir(), '.apra-fleet');
+export const WORKSPACES_DIR = path.join(APRA_BASE, 'workspaces');
+export const WORKSPACES_INDEX = path.join(APRA_BASE, 'workspaces.json');
diff --git a/tests/install-data-dir.test.ts b/tests/install-data-dir.test.ts
new file mode 100644
index 00000000..3c20798d
--- /dev/null
+++ b/tests/install-data-dir.test.ts
@@ -0,0 +1,257 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { execSync, execFileSync } from 'node:child_process';
+import { runInstall } 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 setupMocks() {
+ vi.mocked(os.homedir).mockReturnValue(mockHome);
+ 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.3' });
+ 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);
+
+ return fileState;
+}
+
+describe('runInstall --data-dir / --instance', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // --- Claude + --data-dir ---
+
+ it('--data-dir passes -e APRA_FLEET_DATA_DIR to claude mcp add', async () => {
+ await runInstall(['--data-dir', '/custom/data']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ const args = addCall![1] as string[];
+ expect(args.join(' ')).toContain('-e APRA_FLEET_DATA_DIR=/custom/data');
+ expect(args).toContain('apra-fleet');
+ });
+
+ it('--data-dir with equals form works', async () => {
+ await runInstall(['--data-dir=/my/dir']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ const args = addCall![1] as string[];
+ expect(args.join(' ')).toContain('-e APRA_FLEET_DATA_DIR=/my/dir');
+ });
+
+ it('no --data-dir → no -e env flag in claude mcp add', async () => {
+ await runInstall([]);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ const args = addCall![1] as string[];
+ expect(args.join(' ')).not.toContain('APRA_FLEET_DATA_DIR');
+ });
+
+ // --- Claude + --instance ---
+
+ it('--instance sets server name to apra-fleet-', async () => {
+ await runInstall(['--instance', 'odm']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ expect(addCall![1] as string[]).toContain('apra-fleet-odm');
+ });
+
+ it('--instance sets APRA_FLEET_DATA_DIR to workspaces/', async () => {
+ await runInstall(['--instance', 'myproject']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ const args = addCall![1] as string[];
+ const expectedPath = path.join(mockHome, '.apra-fleet', 'workspaces', 'myproject');
+ expect(args.join(' ')).toContain(expectedPath);
+ });
+
+ it('--instance equals form works', async () => {
+ await runInstall(['--instance=proj']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ expect(addCall![1] as string[]).toContain('apra-fleet-proj');
+ });
+
+ it('--instance removes the old server name before adding new', async () => {
+ await runInstall(['--instance', 'odm']);
+
+ const calls = vi.mocked(execSync).mock.calls.map(c => c[0].toString());
+ const removeCall = calls.find(c => c.includes('mcp remove') && c.includes('apra-fleet-odm'));
+ expect(removeCall).toBeDefined();
+ });
+
+ it('--instance with invalid chars errors and exits 1', async () => {
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); });
+
+ await expect(runInstall(['--instance', 'my instance!'])).rejects.toThrow('exit');
+ expect(exitSpy).toHaveBeenCalledWith(1);
+ exitSpy.mockRestore();
+ });
+
+ // --- Instance + workspaces.json registration ---
+
+ it('--instance writes workspaces.json with the new workspace', async () => {
+ 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.3' });
+ 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);
+
+ await runInstall(['--instance', 'odm']);
+
+ const workspacesIndexPath = path.join(mockHome, '.apra-fleet', 'workspaces.json');
+ const wsWrite = vi.mocked(fs.writeFileSync).mock.calls.find(c =>
+ c[0].toString() === workspacesIndexPath
+ );
+ expect(wsWrite).toBeDefined();
+ const parsed = JSON.parse(wsWrite![1].toString());
+ expect(parsed.workspaces).toBeDefined();
+ const odm = parsed.workspaces.find((w: any) => w.name === 'odm');
+ expect(odm).toBeDefined();
+ expect(odm.path).toContain('odm');
+ });
+
+ it('--data-dir alone does NOT write workspaces.json', async () => {
+ await runInstall(['--data-dir', '/custom/data']);
+
+ const workspacesIndexPath = path.join(mockHome, '.apra-fleet', 'workspaces.json');
+ const wsWrite = vi.mocked(fs.writeFileSync).mock.calls.find(c =>
+ c[0].toString() === workspacesIndexPath
+ );
+ expect(wsWrite).toBeUndefined();
+ });
+
+ // --- Gemini + --data-dir / --instance ---
+
+ it('Gemini + --data-dir embeds env in mcpServers config', async () => {
+ await runInstall(['--llm', 'gemini', '--data-dir', '/custom/data']);
+
+ const geminiSettings = path.join(mockHome, '.gemini', 'settings.json');
+ const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c =>
+ c[0].toString().includes(geminiSettings)
+ );
+ expect(writes.length).toBeGreaterThan(0);
+ const lastContent = writes.at(-1)![1].toString();
+ const parsed = JSON.parse(lastContent);
+ expect(parsed.mcpServers?.['apra-fleet']?.env?.APRA_FLEET_DATA_DIR).toBe('/custom/data');
+ });
+
+ it('Gemini + --instance uses apra-fleet- as server key', async () => {
+ await runInstall(['--llm', 'gemini', '--instance', 'odm']);
+
+ const geminiSettings = path.join(mockHome, '.gemini', 'settings.json');
+ const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c =>
+ c[0].toString().includes(geminiSettings)
+ );
+ const lastContent = writes.at(-1)![1].toString();
+ const parsed = JSON.parse(lastContent);
+ expect(parsed.mcpServers?.['apra-fleet-odm']).toBeDefined();
+ expect(parsed.mcpServers?.['apra-fleet']).toBeUndefined();
+ });
+
+ it('Gemini + no --data-dir does NOT embed env', async () => {
+ await runInstall(['--llm', 'gemini']);
+
+ const geminiSettings = path.join(mockHome, '.gemini', 'settings.json');
+ const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c =>
+ c[0].toString().includes(geminiSettings)
+ );
+ const lastContent = writes.at(-1)![1].toString();
+ const parsed = JSON.parse(lastContent);
+ expect(parsed.mcpServers?.['apra-fleet']?.env).toBeUndefined();
+ });
+
+ // --- Permissions use correct server name ---
+
+ it('permissions use mcp__apra-fleet-__* for --instance', async () => {
+ await runInstall(['--instance', 'odm']);
+
+ const claudeSettings = path.join(mockHome, '.claude', 'settings.json');
+ const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c =>
+ c[0].toString().includes(claudeSettings)
+ );
+ const permWrite = writes.find(c => c[1].toString().includes('mcp__apra-fleet'));
+ expect(permWrite).toBeDefined();
+ expect(permWrite![1].toString()).toContain('mcp__apra-fleet-odm__*');
+ expect(permWrite![1].toString()).not.toContain('mcp__apra-fleet__*');
+ });
+
+ it('permissions use mcp__apra-fleet__* without --instance', async () => {
+ await runInstall([]);
+
+ const claudeSettings = path.join(mockHome, '.claude', 'settings.json');
+ const writes = vi.mocked(fs.writeFileSync).mock.calls.filter(c =>
+ c[0].toString().includes(claudeSettings)
+ );
+ const permWrite = writes.find(c => c[1].toString().includes('mcp__apra-fleet'));
+ expect(permWrite).toBeDefined();
+ expect(permWrite![1].toString()).toContain('mcp__apra-fleet__*');
+ });
+
+ // --- Tilde expansion in --data-dir ---
+
+ it('--data-dir with ~ expands to home dir', async () => {
+ await runInstall(['--data-dir', '~/custom/data']);
+
+ const calls = vi.mocked(execFileSync).mock.calls;
+ const addCall = calls.find(c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add'));
+ expect(addCall).toBeDefined();
+ const argStr = (addCall![1] as string[]).join(' ');
+ expect(argStr).toContain(`${mockHome}/custom/data`);
+ expect(argStr).not.toContain('~');
+ });
+});
diff --git a/tests/install-multi-provider.test.ts b/tests/install-multi-provider.test.ts
index c2679d52..97488eee 100644
--- a/tests/install-multi-provider.test.ts
+++ b/tests/install-multi-provider.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
-import { execSync } from 'node:child_process';
+import { execSync, execFileSync } from 'node:child_process';
import { parse as parseToml } from 'smol-toml';
import { runInstall } from '../src/cli/install.js';
@@ -59,10 +59,10 @@ describe('runInstall multi-provider', () => {
);
// Check if Claude MCP command is run
- expect(vi.mocked(execSync)).toHaveBeenCalledWith(
- expect.stringContaining('claude mcp add'),
- expect.any(Object)
+ const claudeAddCall = vi.mocked(execFileSync).mock.calls.find(
+ c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')
);
+ expect(claudeAddCall).toBeDefined();
});
it('installs for Gemini when --llm gemini is passed', async () => {
@@ -76,7 +76,9 @@ describe('runInstall multi-provider', () => {
);
// Should NOT run claude mcp add
- const claudeCmd = vi.mocked(execSync).mock.calls.find(c => c[0].toString().includes('claude mcp add'));
+ const claudeCmd = vi.mocked(execFileSync).mock.calls.find(
+ c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')
+ );
expect(claudeCmd).toBeUndefined();
// Should have written to Gemini settings with trust: true
@@ -229,10 +231,14 @@ describe('runInstall multi-provider', () => {
it('Claude MCP registration uses --scope user flag', async () => {
await runInstall([]);
- const calls = vi.mocked(execSync).mock.calls.map(c => c[0].toString());
- const addCall = calls.find(c => c.includes('claude mcp add'));
+ const addCall = vi.mocked(execFileSync).mock.calls.find(
+ c => c[0] === 'claude' && Array.isArray(c[1]) && (c[1] as string[]).includes('add')
+ );
expect(addCall).toBeDefined();
- expect(addCall).toContain('--scope user');
+ const args = addCall![1] as string[];
+ const scopeIdx = args.indexOf('--scope');
+ expect(scopeIdx).toBeGreaterThanOrEqual(0);
+ expect(args[scopeIdx + 1]).toBe('user');
});
it('Gemini MCP registration embeds mcpServers.apra-fleet with trust:true', async () => {
diff --git a/tests/update.test.ts b/tests/update.test.ts
index 913b3847..a938881f 100644
--- a/tests/update.test.ts
+++ b/tests/update.test.ts
@@ -7,7 +7,13 @@ import { runUpdate } from '../src/cli/update.js';
import { serverVersion } from '../src/version.js';
vi.mock('node:fs');
-vi.mock('node:os');
+vi.mock('node:os', () => ({
+ default: {
+ homedir: vi.fn(() => '/mock/home'),
+ tmpdir: vi.fn(() => '/tmp'),
+ platform: vi.fn(() => 'linux'),
+ }
+}));
vi.mock('node:child_process', () => ({
spawn: vi.fn(() => ({
unref: vi.fn(),