Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
15 changes: 15 additions & 0 deletions src/cli/__tests__/output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { formatToolList } from '../output.ts';

describe('formatToolList', () => {
it('formats ungrouped tool list', () => {
const tools = [
{ cliName: 'build', workflow: 'xcode', description: 'Build project', stateful: false },
{ cliName: 'test', workflow: 'xcode', description: 'Run tests', stateful: true },
];
const output = formatToolList(tools);
expect(output).toContain('xcode build');
expect(output).toContain('xcode test');
expect(output).toContain('[stateful]');
});
});
133 changes: 101 additions & 32 deletions src/cli/__tests__/register-tool-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ function createTool(overrides: Partial<ToolDefinition> = {}): ToolDefinition {
scheme: z.string().optional(),
},
stateful: false,
handler: vi.fn(async () => ({
content: [createTextContent('ok')],
isError: false,
})),
handler: vi.fn(async () => {}) as ToolDefinition['handler'],
...overrides,
};
}
Expand Down Expand Up @@ -97,10 +94,9 @@ describe('registerToolCommands', () => {
});

it('hydrates required args from the active defaults profile', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
Expand All @@ -126,10 +122,9 @@ describe('registerToolCommands', () => {
it('hydrates required args from the explicit --profile override', async () => {
process.argv = ['node', 'xcodebuildmcp', 'simulator', 'run-tool', '--profile', 'qa'];

const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
Expand Down Expand Up @@ -159,6 +154,8 @@ describe('registerToolCommands', () => {
});

it('keeps the normal missing-argument error when no hydrated default exists', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

const tool = createTool();
const app = createApp(createCatalog([tool]), {
...baseRuntimeConfig,
Expand All @@ -167,22 +164,16 @@ describe('registerToolCommands', () => {
activeSessionDefaultsProfile: undefined,
});

let error: Error | undefined;
try {
await app.parseAsync(['simulator', 'run-tool']);
} catch (thrown) {
error = thrown as Error;
}
await expect(app.parseAsync(['simulator', 'run-tool'])).resolves.toBeDefined();

expect(error?.message).toContain('Missing required argument: workspace-path');
expect(error?.message).not.toMatch(/session defaults/i);
expect(consoleError).toHaveBeenCalledWith('Missing required argument: workspace-path');
expect(process.exitCode).toBe(1);
});

it('hydrates args before daemon-routed invocation', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool({ stateful: true });
Expand All @@ -202,10 +193,9 @@ describe('registerToolCommands', () => {
});

it('lets explicit args override conflicting defaults before invocation', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool({
Expand Down Expand Up @@ -253,10 +243,9 @@ describe('registerToolCommands', () => {
});

it('lets --json override configured defaults', async () => {
const invokeDirect = vi.spyOn(DefaultToolInvoker.prototype, 'invokeDirect').mockResolvedValue({
content: [createTextContent('ok')],
isError: false,
});
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
Expand All @@ -281,4 +270,84 @@ describe('registerToolCommands', () => {

stdoutWrite.mockRestore();
});

it('allows --json to satisfy required arguments', async () => {
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool();
const app = createApp(createCatalog([tool]), {
...baseRuntimeConfig,
sessionDefaults: undefined,
sessionDefaultsProfiles: undefined,
activeSessionDefaultsProfile: undefined,
});

await expect(
app.parseAsync([
'simulator',
'run-tool',
'--json',
JSON.stringify({ workspacePath: 'FromJson.xcworkspace' }),
]),
).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'FromJson.xcworkspace',
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});

it('allows array args that begin with a dash', async () => {
const invokeDirect = vi
.spyOn(DefaultToolInvoker.prototype, 'invokeDirect')
.mockResolvedValue(undefined);
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);

const tool = createTool({
cliSchema: {
workspacePath: z.string().describe('Workspace path'),
extraArgs: z.array(z.string()).optional().describe('Extra args'),
},
mcpSchema: {
workspacePath: z.string().describe('Workspace path'),
extraArgs: z.array(z.string()).optional().describe('Extra args'),
},
});
const app = createApp(createCatalog([tool]), {
...baseRuntimeConfig,
sessionDefaults: undefined,
sessionDefaultsProfiles: undefined,
activeSessionDefaultsProfile: undefined,
});

await expect(
app.parseAsync([
'simulator',
'run-tool',
'--workspace-path',
'App.xcworkspace',
'--extra-args',
'-only-testing:AppTests',
]),
).resolves.toBeDefined();

expect(invokeDirect).toHaveBeenCalledWith(
tool,
{
workspacePath: 'App.xcworkspace',
extraArgs: ['-only-testing:AppTests'],
},
expect.any(Object),
);

stdoutWrite.mockRestore();
});
});
30 changes: 24 additions & 6 deletions src/cli/cli-tool-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { DaemonClient } from './daemon-client.ts';
import { buildCliToolCatalogFromManifest, createToolCatalog } from '../runtime/tool-catalog.ts';
import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts';
import { toKebabCase } from '../runtime/naming.ts';
import type { ToolResponse } from '../types/common.ts';
import type { ToolHandlerContext } from '../rendering/types.ts';
import type { PipelineEvent } from '../types/pipeline-events.ts';
import { jsonSchemaToZod } from '../integrations/xcode-tools-bridge/jsonschema-to-zod.ts';
import { XcodeIdeToolService } from '../integrations/xcode-tools-bridge/tool-service.ts';
import { toLocalToolName } from '../integrations/xcode-tools-bridge/registry.ts';
import { log } from '../utils/logging/index.ts';
import { statusLine } from '../utils/tool-event-builders.ts';

interface BuildCliToolCatalogOptions {
socketPath: string;
Expand Down Expand Up @@ -52,12 +54,28 @@ function jsonSchemaToToolSchemaShape(inputSchema: unknown): ToolSchemaShape {
async function invokeRemoteToolOneShot(
remoteToolName: string,
args: Record<string, unknown>,
): Promise<ToolResponse> {
ctx: ToolHandlerContext,
): Promise<void> {
const service = new XcodeIdeToolService();
service.setWorkflowEnabled(true);
try {
const response = await service.invokeTool(remoteToolName, args);
return response as unknown as ToolResponse;
const response = (await service.invokeTool(remoteToolName, args)) as unknown as {
content?: Array<{ type: string; text: string }>;
isError?: boolean;
_meta?: Record<string, unknown>;
};
const events = response._meta?.events;
if (Array.isArray(events)) {
for (const event of events as PipelineEvent[]) {
ctx.emit(event);
}
} else if (response.content) {
for (const item of response.content) {
if (item.type === 'text') {
ctx.emit(statusLine(response.isError ? 'error' : 'success', item.text));
}
}
}
} finally {
await service.disconnect();
}
Expand All @@ -83,8 +101,8 @@ function createCliXcodeProxyTool(remoteTool: DynamicBridgeTool): ToolDefinition
cliSchema,
stateful: false,
xcodeIdeRemoteToolName: remoteTool.name,
handler: async (params): Promise<ToolResponse> => {
return invokeRemoteToolOneShot(remoteTool.name, params);
handler: async (params, ctx): Promise<void> => {
return invokeRemoteToolOneShot(remoteTool.name, params, ctx);
},
};
}
Expand Down
26 changes: 20 additions & 6 deletions src/cli/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type DaemonRequest,
type DaemonResponse,
type DaemonMethod,
type DaemonToolResult,
type ToolInvokeParams,
type ToolInvokeResult,
type DaemonStatusResult,
Expand All @@ -16,9 +17,15 @@ import {
type XcodeIdeInvokeParams,
type XcodeIdeInvokeResult,
} from '../daemon/protocol.ts';
import type { ToolResponse } from '../types/common.ts';
import { getSocketPath } from '../daemon/socket-path.ts';

export class DaemonVersionMismatchError extends Error {
constructor(message: string) {
super(message);
this.name = 'DaemonVersionMismatchError';
}
}

export interface DaemonClientOptions {
socketPath?: string;
timeout?: number;
Expand Down Expand Up @@ -81,7 +88,14 @@ export class DaemonClient {
socket.end();

if (res.error) {
reject(new Error(`${res.error.code}: ${res.error.message}`));
if (
res.error.code === 'BAD_REQUEST' &&
res.error.message.startsWith('Unsupported protocol version')
) {
reject(new DaemonVersionMismatchError(res.error.message));
} else {
reject(new Error(`${res.error.code}: ${res.error.message}`));
}
} else {
resolve(res.result as TResult);
}
Expand Down Expand Up @@ -124,12 +138,12 @@ export class DaemonClient {
/**
* Invoke a tool.
*/
async invokeTool(tool: string, args: Record<string, unknown>): Promise<ToolResponse> {
async invokeTool(tool: string, args: Record<string, unknown>): Promise<DaemonToolResult> {
const result = await this.request<ToolInvokeResult>('tool.invoke', {
tool,
args,
} satisfies ToolInvokeParams);
return result.response;
return result.result;
}

/**
Expand All @@ -146,12 +160,12 @@ export class DaemonClient {
async invokeXcodeIdeTool(
remoteTool: string,
args: Record<string, unknown>,
): Promise<ToolResponse> {
): Promise<DaemonToolResult> {
const result = await this.request<XcodeIdeInvokeResult>('xcode-ide.invoke', {
remoteTool,
args,
} satisfies XcodeIdeInvokeParams);
return result.response as ToolResponse;
return result.result;
}

/**
Expand Down
Loading