Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ On failure, `success` is `false` and an `error` field is included:
}
```

Error codes: `AGENT_NOT_FOUND`, `AGENT_UNSUPPORTED`, `CLAUDE_NOT_FOUND`, `CODEX_NOT_FOUND`.
Error codes: `AGENT_NOT_FOUND`, `AGENT_UNSUPPORTED`, `CLAUDE_NOT_FOUND`, `CODEX_NOT_FOUND`, `OPENCODE_NOT_FOUND`, `DROID_NOT_FOUND`.

Supported agent types: `claude-code`, `codex`.
Supported agent types: `claude-code`, `codex`, `opencode`, `factory-droid`. The `send` command supports all four.

### Listing Sessions

Expand Down Expand Up @@ -241,5 +241,5 @@ The `send` command always outputs JSON (no `--json` flag needed).

## Requirements

- At least one AI agent CLI must be installed and in PATH (Claude Code, Codex, or OpenCode)
- At least one AI agent CLI must be installed and in PATH (Claude Code, Codex, OpenCode, or Factory Droid)
- Maestro config files must exist (created automatically when you use the GUI)
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

96 changes: 96 additions & 0 deletions src/__tests__/cli/services/agent-spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ vi.mock('os', () => ({

// Mock storage service
const mockGetAgentCustomPath = vi.fn();
const mockGetAgentConfigValues = vi.fn(() => ({}));
vi.mock('../../../cli/services/storage', () => ({
getAgentCustomPath: (...args: unknown[]) => mockGetAgentCustomPath(...args),
getAgentConfigValues: (...args: unknown[]) => mockGetAgentConfigValues(...args),
}));

import {
Expand Down Expand Up @@ -678,6 +680,100 @@ Some text with [x] in it that's not a checkbox
});
});

describe('detectOpenCode', () => {
beforeEach(() => {
vi.resetModules();
});

it('should detect OpenCode via PATH detection', async () => {
mockGetAgentCustomPath.mockReturnValue(undefined);
mockSpawn.mockReturnValue(mockChild);

const { detectOpenCode: freshDetectOpenCode } =
await import('../../../cli/services/agent-spawner');

const resultPromise = freshDetectOpenCode();

await new Promise((resolve) => setTimeout(resolve, 0));
mockStdout.emit('data', Buffer.from('/usr/local/bin/opencode\n'));
await new Promise((resolve) => setTimeout(resolve, 0));
mockChild.emit('close', 0);

const result = await resultPromise;

expect(result.available).toBe(true);
expect(result.path).toBe('/usr/local/bin/opencode');
expect(result.source).toBe('path');
});

it('should preserve cached source when override matches cached path', async () => {
mockGetAgentCustomPath.mockReturnValue(undefined);
mockSpawn.mockReturnValue(mockChild);

const { detectOpenCode: freshDetectOpenCode } =
await import('../../../cli/services/agent-spawner');

const resultPromise = freshDetectOpenCode();

await new Promise((resolve) => setTimeout(resolve, 0));
mockStdout.emit('data', Buffer.from('/usr/local/bin/opencode\n'));
await new Promise((resolve) => setTimeout(resolve, 0));
mockChild.emit('close', 0);

const result1 = await resultPromise;
expect(result1.source).toBe('path');

const result2 = await freshDetectOpenCode('/usr/local/bin/opencode');
expect(result2.source).toBe('path');
});
});

describe('detectDroid', () => {
beforeEach(() => {
vi.resetModules();
});

it('should detect Factory Droid via PATH detection', async () => {
mockGetAgentCustomPath.mockReturnValue(undefined);
mockSpawn.mockReturnValue(mockChild);

const { detectDroid: freshDetectDroid } = await import('../../../cli/services/agent-spawner');

const resultPromise = freshDetectDroid();

await new Promise((resolve) => setTimeout(resolve, 0));
mockStdout.emit('data', Buffer.from('/usr/local/bin/droid\n'));
await new Promise((resolve) => setTimeout(resolve, 0));
mockChild.emit('close', 0);

const result = await resultPromise;

expect(result.available).toBe(true);
expect(result.path).toBe('/usr/local/bin/droid');
expect(result.source).toBe('path');
});

it('should preserve cached source when override matches cached path', async () => {
mockGetAgentCustomPath.mockReturnValue(undefined);
mockSpawn.mockReturnValue(mockChild);

const { detectDroid: freshDetectDroid } = await import('../../../cli/services/agent-spawner');

const resultPromise = freshDetectDroid();

await new Promise((resolve) => setTimeout(resolve, 0));
mockStdout.emit('data', Buffer.from('/usr/local/bin/droid\n'));
await new Promise((resolve) => setTimeout(resolve, 0));
mockChild.emit('close', 0);

const result1 = await resultPromise;
expect(result1.source).toBe('path');

const result2 = await freshDetectDroid('/usr/local/bin/droid');
expect(result2.source).toBe('path');
});
});

describe('spawnAgent', () => {
beforeEach(() => {
mockSpawn.mockReturnValue(mockChild);
Expand Down
26 changes: 23 additions & 3 deletions src/cli/commands/run-playbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { getSessionById } from '../services/storage';
import { findPlaybookById } from '../services/playbooks';
import { runPlaybook as executePlaybook } from '../services/batch-processor';
import { detectClaude, detectCodex } from '../services/agent-spawner';
import { detectClaude, detectCodex, detectOpenCode, detectDroid } from '../services/agent-spawner';
import { emitError } from '../output/jsonl';
import {
formatRunEvent,
Expand Down Expand Up @@ -149,7 +149,7 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption

// Check if agent CLI is available
if (agent.toolType === 'codex') {
const codex = await detectCodex();
const codex = await detectCodex(agent.customPath);
if (!codex.available) {
if (useJson) {
emitError('Codex CLI not found. Please install codex CLI.', 'CODEX_NOT_FOUND');
Expand All @@ -159,7 +159,7 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption
process.exit(1);
}
} else if (agent.toolType === 'claude-code') {
const claude = await detectClaude();
const claude = await detectClaude(agent.customPath);
if (!claude.available) {
if (useJson) {
emitError('Claude Code not found. Please install claude-code CLI.', 'CLAUDE_NOT_FOUND');
Expand All @@ -168,6 +168,26 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption
}
process.exit(1);
}
} else if (agent.toolType === 'opencode') {
const opencode = await detectOpenCode(agent.customPath);
if (!opencode.available) {
if (useJson) {
emitError('OpenCode CLI not found. Please install opencode CLI.', 'OPENCODE_NOT_FOUND');
} else {
console.error(formatError('OpenCode CLI not found. Please install opencode CLI.'));
}
process.exit(1);
}
} else if (agent.toolType === 'factory-droid') {
const droid = await detectDroid(agent.customPath);
if (!droid.available) {
if (useJson) {
emitError('Factory Droid CLI not found. Please install droid CLI.', 'DROID_NOT_FOUND');
} else {
console.error(formatError('Factory Droid CLI not found. Please install droid CLI.'));
}
process.exit(1);
}
} else {
const message = `Agent type "${agent.toolType}" is not supported in CLI batch mode yet.`;
if (useJson) {
Expand Down
51 changes: 46 additions & 5 deletions src/cli/commands/send.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
// Send command - send a message to an agent and get a JSON response
// Requires a Maestro agent ID. Optionally resumes an existing agent session.

import { spawnAgent, detectClaude, detectCodex, type AgentResult } from '../services/agent-spawner';
import {
spawnAgent,
detectClaude,
detectCodex,
detectOpenCode,
detectDroid,
type AgentResult,
} from '../services/agent-spawner';
import { resolveAgentId, getSessionById } from '../services/storage';
import { estimateContextUsage } from '../../main/parsers/usage-aggregator';
import type { ToolType } from '../../shared/types';
Expand Down Expand Up @@ -88,7 +95,7 @@ export async function send(
}

// Validate agent type is supported for CLI spawning
const supportedTypes: ToolType[] = ['claude-code', 'codex'];
const supportedTypes: ToolType[] = ['claude-code', 'codex', 'opencode', 'factory-droid'];
if (!supportedTypes.includes(agent.toolType)) {
emitErrorJson(
`Agent type "${agent.toolType}" is not supported for send mode. Supported: ${supportedTypes.join(', ')}`,
Expand All @@ -99,7 +106,7 @@ export async function send(

// Verify agent CLI is available
if (agent.toolType === 'claude-code') {
const claude = await detectClaude();
const claude = await detectClaude(agent.customPath);
if (!claude.available) {
emitErrorJson(
'Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code',
Expand All @@ -108,18 +115,52 @@ export async function send(
process.exit(1);
}
} else if (agent.toolType === 'codex') {
const codex = await detectCodex();
const codex = await detectCodex(agent.customPath);
if (!codex.available) {
emitErrorJson(
'Codex CLI not found. Install with: npm install -g @openai/codex',
'CODEX_NOT_FOUND'
);
process.exit(1);
}
} else if (agent.toolType === 'opencode') {
const opencode = await detectOpenCode(agent.customPath);
if (!opencode.available) {
emitErrorJson(
'OpenCode CLI not found. Install with: npm install -g opencode',
'OPENCODE_NOT_FOUND'
);
process.exit(1);
}
} else if (agent.toolType === 'factory-droid') {
const droid = await detectDroid(agent.customPath);
if (!droid.available) {
emitErrorJson(
'Factory Droid CLI not found. Install with: https://factory.ai/product/cli',
'DROID_NOT_FOUND'
);
process.exit(1);
}
}

const hasOverrides =
agent.customPath !== undefined ||
agent.customArgs !== undefined ||
agent.customEnvVars !== undefined ||
agent.customModel !== undefined;
const overrides = hasOverrides
? {
customPath: agent.customPath,
customArgs: agent.customArgs,
customEnvVars: agent.customEnvVars,
customModel: agent.customModel,
}
: undefined;

// Spawn agent — spawnAgent handles --resume vs --session-id internally
const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session);
const result = overrides
? await spawnAgent(agent.toolType, agent.cwd, message, options.session, overrides)
: await spawnAgent(agent.toolType, agent.cwd, message, options.session);
const response = buildResponse(agentId, agent.name, result, agent.toolType);

console.log(JSON.stringify(response, null, 2));
Expand Down
Loading