diff --git a/CHANGELOG.md b/CHANGELOG.md
index 568e866c..347d14cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [Unreleased]
+
+### Fixed
+- Honor CLI socket overrides when auto-starting the daemon.
+- Disable log file output after stream errors to prevent daemon crashes.
+- Update MCP examples and debugging docs to use the `mcp` subcommand.
+- Stop routing tool commands through `sh` by default to avoid `spawn sh ENOENT` failures.
+
## [2.0.0] - 2026-01-28
### Breaking
diff --git a/README.md b/README.md
index 307bb310..94ae7cad 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
```json
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
```
@@ -26,7 +26,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
"mcpServers": {
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
}
}
@@ -34,7 +34,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
Or use the quick install link:
- [](https://cursor.com/en/install-mcp?name=XcodeBuildMCP&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IC15IHhjb2RlYnVpbGRtY3BAbGF0ZXN0IiwiZW52Ijp7IklOQ1JFTUVOVEFMX0JVSUxEU19FTkFCTEVEIjoiZmFsc2UiLCJYQ09ERUJVSUxETUNQX1NFTlRSWV9ESVNBQkxFRCI6ImZhbHNlIn19)
+ [](cursor://anysphere.cursor-deeplink/mcp/install?name=XcodeBuildMCP&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsInhjb2RlYnVpbGRtY3BAbGF0ZXN0IiwibWNwIl19)
@@ -44,7 +44,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
Run:
```bash
- claude mcp add XcodeBuildMCP -- npx -y xcodebuildmcp@latest
+ claude mcp add XcodeBuildMCP -- npx -y xcodebuildmcp@latest mcp
```
@@ -55,14 +55,14 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
Run:
```bash
- codex mcp add XcodeBuildMCP -- npx -y xcodebuildmcp@latest
+ codex mcp add XcodeBuildMCP -- npx -y xcodebuildmcp@latest mcp
```
Or add to `~/.codex/config.toml`:
```toml
[mcp_servers.XcodeBuildMCP]
command = "npx"
- args = ["-y", "xcodebuildmcp@latest"]
+ args = ["-y", "xcodebuildmcp@latest", "mcp"]
```
@@ -77,7 +77,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
"mcpServers": {
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
}
}
@@ -95,7 +95,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
"servers": {
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
}
}
@@ -103,8 +103,8 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
Or use the quick install links:
- [
](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%5D%7D)
- [
](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%5D%7D&quality=insiders)
+ [
](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%2C%22mcp%22%5D%7D)
+ [
](https://insiders.vscode.dev/redirect/mcp/install?name=XcodeBuildMCP&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22xcodebuildmcp%40latest%22%2C%22mcp%22%5D%7D&quality=insiders)
@@ -118,7 +118,7 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
"mcpServers": {
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
}
}
@@ -130,13 +130,13 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
Trae
- Add to `'~/Library/Application Support/Trae/User/mcp.json'`:
+ Add to `~/Library/Application Support/Trae/User/mcp.json`:
```json
{
"mcpServers": {
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": ["-y", "xcodebuildmcp@latest", "mcp"]
}
}
}
@@ -148,6 +148,8 @@ Add XcodeBuildMCP to your MCP client configuration. Most clients use JSON config
For other installation options see [Getting Started](docs/GETTING_STARTED.md)
+When configuring a client manually, ensure the command includes the `mcp` subcommand (for example, `npx -y xcodebuildmcp@latest mcp`).
+
## Requirements
- macOS 14.5 or later
@@ -174,9 +176,30 @@ For further information on how to install the skill, see: [docs/SKILLS.md](docs/
XcodeBuildMCP uses Sentry for error telemetry. For more information or to opt out of error telemetry see [docs/PRIVACY.md](docs/PRIVACY.md).
+## CLI
+
+XcodeBuildMCP provides a unified command-line interface. The `mcp` subcommand starts the MCP server, while all other commands provide direct terminal access to tools:
+
+```bash
+# Install globally
+npm install -g xcodebuildmcp
+
+# Start the MCP server (for MCP clients)
+xcodebuildmcp mcp
+
+# List available tools
+xcodebuildmcp tools
+
+# Build for simulator
+xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj
+```
+
+The CLI uses a per-workspace daemon for stateful operations (log capture, debugging, etc.) that auto-starts when needed. See [docs/CLI.md](docs/CLI.md) for full documentation.
+
## Documentation
- Getting started: [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md)
+- CLI usage: [docs/CLI.md](docs/CLI.md)
- Configuration and options: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
- Tools reference: [docs/TOOLS.md](docs/TOOLS.md)
- Troubleshooting: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)
diff --git a/docs/CLI.md b/docs/CLI.md
new file mode 100644
index 00000000..2023b3c2
--- /dev/null
+++ b/docs/CLI.md
@@ -0,0 +1,267 @@
+# XcodeBuildMCP CLI
+
+`xcodebuildmcp` is a unified command-line interface that provides both the MCP server and direct tool access. Use `xcodebuildmcp mcp` to start the MCP server, or invoke tools directly from your terminal.
+
+## Installation
+
+```bash
+# Install globally
+npm install -g xcodebuildmcp
+
+# Or run via npx
+npx xcodebuildmcp --help
+```
+
+## Quick Start
+
+```bash
+# Start MCP server (for MCP clients like Claude, Cursor, etc.)
+xcodebuildmcp mcp
+
+# List available tools
+xcodebuildmcp tools
+
+# Build for simulator
+xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj
+
+# List simulators
+xcodebuildmcp list-sims
+
+# Run tests
+xcodebuildmcp test-sim --scheme MyApp --simulator-name "iPhone 16 Pro"
+```
+
+## Per-Workspace Daemon
+
+The CLI uses a per-workspace daemon architecture for stateful operations (log capture, video recording, debugging). Each workspace gets its own daemon instance.
+
+### How It Works
+
+- **Workspace identity**: The workspace root is determined by the location of `.xcodebuildmcp/config.yaml`, or falls back to the current directory.
+- **Socket location**: Each daemon runs on a Unix socket at `~/.xcodebuildmcp/daemons//daemon.sock`
+- **Auto-start**: The daemon starts automatically when you invoke a stateful tool - no manual setup required.
+
+### Daemon Commands
+
+```bash
+# Check daemon status for current workspace
+xcodebuildmcp daemon status
+
+# Manually start the daemon
+xcodebuildmcp daemon start
+
+# Stop the daemon
+xcodebuildmcp daemon stop
+
+# Restart the daemon
+xcodebuildmcp daemon restart
+
+# List all daemons across workspaces
+xcodebuildmcp daemon list
+
+# List in JSON format
+xcodebuildmcp daemon list --json
+```
+
+### Daemon Status Output
+
+```
+Daemon Status: Running
+ PID: 12345
+ Workspace: /Users/you/Projects/MyApp
+ Socket: /Users/you/.xcodebuildmcp/daemons/c5da0cbe19a7/daemon.sock
+ Started: 2024-01-15T10:30:00.000Z
+ Tools: 94
+ Workflows: (default)
+```
+
+### Daemon List Output
+
+```
+Daemons:
+
+ [running] c5da0cbe19a7
+ Workspace: /Users/you/Projects/MyApp
+ PID: 12345
+ Started: 2024-01-15T10:30:00.000Z
+ Version: 1.15.0
+
+ [stale] a1b2c3d4e5f6
+ Workspace: /Users/you/Projects/OldProject
+ PID: 99999
+ Started: 2024-01-14T08:00:00.000Z
+ Version: 1.14.0
+
+Total: 2 (1 running, 1 stale)
+```
+
+## Global Options
+
+| Option | Description |
+|--------|-------------|
+| `--socket ` | Override the daemon socket path (hidden) |
+| `--daemon` | Force daemon execution for stateless tools (hidden) |
+| `--no-daemon` | Disable daemon usage; stateful tools will fail |
+| `-h, --help` | Show help |
+| `-v, --version` | Show version |
+
+## Tool Options
+
+Each tool supports `--help` for detailed options:
+
+```bash
+xcodebuildmcp build-sim --help
+```
+
+Common patterns:
+
+```bash
+# Pass options as flags
+xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj
+
+# Pass complex options as JSON
+xcodebuildmcp build-sim --json '{"scheme": "MyApp", "projectPath": "./MyApp.xcodeproj"}'
+
+# Control output format
+xcodebuildmcp list-sims --output json
+```
+
+## Stateful vs Stateless Tools
+
+### Stateless Tools (run in-process)
+Most tools run directly without the daemon:
+- `build-sim`, `test-sim`, `clean`
+- `list-sims`, `list-schemes`, `discover-projs`
+- `boot-sim`, `install-app-sim`, `launch-app-sim`
+
+### Stateful Tools (require daemon)
+Some tools maintain state and route through the daemon:
+- Log capture: `start-sim-log-cap`, `stop-sim-log-cap`
+- Video recording: `record-sim-video`
+- Debugging: `debug-attach-sim`, `debug-continue`, etc.
+- Background processes: `swift-package-run`, `swift-package-stop`
+
+When you invoke a stateful tool, the daemon auto-starts if needed.
+
+## Opting Out of Daemon
+
+If you want to disable daemon auto-start (stateful tools will error):
+
+```bash
+xcodebuildmcp build-sim --no-daemon --scheme MyApp
+```
+
+This is useful for CI environments or when you want explicit control.
+
+## Configuration
+
+The CLI respects the same configuration as the MCP server:
+
+```yaml
+# .xcodebuildmcp/config.yaml
+sessionDefaults:
+ scheme: MyApp
+ projectPath: ./MyApp.xcodeproj
+ simulatorName: iPhone 16 Pro
+
+enabledWorkflows:
+ - simulator
+ - project-discovery
+```
+
+See [CONFIGURATION.md](CONFIGURATION.md) for the full schema.
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `XCODEBUILDMCP_SOCKET` | Override socket path for all commands |
+| `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | Disable session defaults |
+
+## Examples
+
+### Build and Run Workflow
+
+```bash
+# Discover projects
+xcodebuildmcp discover-projs
+
+# List schemes
+xcodebuildmcp list-schemes --project-path ./MyApp.xcodeproj
+
+# Build
+xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj
+
+# Boot simulator
+xcodebuildmcp boot-sim --simulator-name "iPhone 16 Pro"
+
+# Install and launch
+xcodebuildmcp install-app-sim --simulator-id --app-path ./build/MyApp.app
+xcodebuildmcp launch-app-sim --simulator-id --bundle-id com.example.MyApp
+```
+
+### Log Capture Workflow
+
+```bash
+# Start log capture (daemon auto-starts)
+xcodebuildmcp start-sim-log-cap --simulator-id --bundle-id com.example.MyApp
+
+# ... use your app ...
+
+# Stop and retrieve logs
+xcodebuildmcp stop-sim-log-cap --session-id
+```
+
+### Testing
+
+```bash
+# Run all tests
+xcodebuildmcp test-sim --scheme MyAppTests --project-path ./MyApp.xcodeproj
+
+# Run with specific simulator
+xcodebuildmcp test-sim --scheme MyAppTests --simulator-name "iPhone 16 Pro"
+```
+
+## CLI vs MCP Mode
+
+| Feature | CLI (`xcodebuildmcp `) | MCP (`xcodebuildmcp mcp`) |
+|---------|------------------------------|---------------------------|
+| Invocation | Direct terminal | MCP client (Claude, etc.) |
+| Session state | Per-workspace daemon | In-process |
+| Use case | Scripts, CI, manual | AI-assisted development |
+| Configuration | Same config.yaml | Same config.yaml |
+
+Both share the same underlying tool implementations.
+
+## Troubleshooting
+
+### Daemon won't start
+
+```bash
+# Check for stale sockets
+xcodebuildmcp daemon list
+
+# Force restart
+xcodebuildmcp daemon restart
+
+# Run in foreground to see logs
+xcodebuildmcp daemon start --foreground
+```
+
+### Tool timeout
+
+Increase the daemon startup timeout:
+
+```bash
+# Default is 5 seconds
+export XCODEBUILDMCP_STARTUP_TIMEOUT_MS=10000
+```
+
+### Socket permission errors
+
+The socket directory (`~/.xcodebuildmcp/daemons/`) should have mode 0700. If you encounter permission issues:
+
+```bash
+chmod 700 ~/.xcodebuildmcp
+chmod -R 700 ~/.xcodebuildmcp/daemons
+```
diff --git a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md
index 19553cc2..9dce4c50 100644
--- a/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md
+++ b/docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md
@@ -532,7 +532,7 @@ Add a section “DAP Backend (lldb-dap)”:
1. Ensure `lldb-dap` is discoverable:
- `xcrun --find lldb-dap`
2. Run server with DAP enabled:
- - `XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/index.js`
+ - `XCODEBUILDMCP_DEBUGGER_BACKEND=dap node build/index.js mcp`
3. Use existing MCP tool flow:
- `debug_attach_sim` (attach by PID or bundleId)
- `debug_breakpoint_add` (with condition)
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index 201c7cd6..cbb66a3b 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -5,18 +5,46 @@
- Xcode 16.x or later
- Node.js 18.x or later
-## Installation
+## Choose Your Interface
+
+XcodeBuildMCP provides a unified CLI with two modes:
+
+| Command | Use Case |
+|---------|----------|
+| `xcodebuildmcp mcp` | Start MCP server for AI-assisted development |
+| `xcodebuildmcp ` | Direct terminal usage, scripts, CI pipelines |
+
+Both share the same tools and configuration.
+
+## MCP Server Installation
Most MCP clients use JSON configuration. Add the following server entry to your client's MCP config:
```json
"XcodeBuildMCP": {
"command": "npx",
- "args": ["-y", "xcodebuildmcp@latest"]
+ "args": [
+ "-y",
+ "xcodebuildmcp@latest",
+ "mcp"
+ ]
}
```
-See the main [README](../README.md#installation) for client-specific configuration paths and quick install links.
+## CLI Installation
+
+```bash
+# Install globally
+npm install -g xcodebuildmcp
+
+# Verify installation
+xcodebuildmcp --version
+
+# List available tools
+xcodebuildmcp tools
+```
+
+See [CLI.md](CLI.md) for full CLI documentation.
## Project config (optional)
For deterministic session defaults and runtime configuration, add a config file at:
@@ -35,7 +63,7 @@ Codex uses TOML for MCP configuration. Add this to `~/.codex/config.toml`:
```toml
[mcp_servers.XcodeBuildMCP]
command = "npx"
-args = ["-y", "xcodebuildmcp@latest"]
+args = ["-y", "xcodebuildmcp@latest", "mcp"]
env = { "XCODEBUILDMCP_SENTRY_DISABLED" = "false" }
```
@@ -51,10 +79,10 @@ https://github.com/openai/codex/blob/main/docs/config.md#connecting-to-mcp-serve
### Claude Code CLI
```bash
# Add XcodeBuildMCP server to Claude Code
-claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest
+claude mcp add XcodeBuildMCP -- npx -y xcodebuildmcp@latest mcp
# Or with environment variables
-claude mcp add XcodeBuildMCP npx xcodebuildmcp@latest -e XCODEBUILDMCP_SENTRY_DISABLED=false
+claude mcp add XcodeBuildMCP -e XCODEBUILDMCP_SENTRY_DISABLED=false -- npx -y xcodebuildmcp@latest mcp
```
Note: XcodeBuildMCP requests xcodebuild to skip macro validation to avoid Swift Macro build errors.
@@ -63,4 +91,5 @@ Note: XcodeBuildMCP requests xcodebuild to skip macro validation to avoid Swift
- Configuration options: [CONFIGURATION.md](CONFIGURATION.md)
- Session defaults and opt-out: [SESSION_DEFAULTS.md](SESSION_DEFAULTS.md)
- Tools reference: [TOOLS.md](TOOLS.md)
+- CLI guide: [CLI.md](CLI.md)
- Troubleshooting: [TROUBLESHOOTING.md](TROUBLESHOOTING.md)
diff --git a/docs/README.md b/docs/README.md
index b639733e..d1b2264d 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,7 @@
- [Product overview and rationale](OVERVIEW.md)
- [Session defaults and opt-out](SESSION_DEFAULTS.md)
- [Device code signing notes](DEVICE_CODE_SIGNING.md)
+- [CLI reference](CLI.md)
## Developer docs
- [Developer documentation index](dev/README.md)
diff --git a/docs/TOOLS.md b/docs/TOOLS.md
index 5b77e67f..8d187c5d 100644
--- a/docs/TOOLS.md
+++ b/docs/TOOLS.md
@@ -126,4 +126,4 @@ XcodeBuildMCP provides 72 tools organized into 14 workflow groups for comprehens
---
-*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-28*
+*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-02-02*
diff --git a/docs/dev/ARCHITECTURE.md b/docs/dev/ARCHITECTURE.md
index c0a1de14..f9c43010 100644
--- a/docs/dev/ARCHITECTURE.md
+++ b/docs/dev/ARCHITECTURE.md
@@ -30,7 +30,7 @@ XcodeBuildMCP is a Model Context Protocol (MCP) server that exposes Xcode operat
### Runtime Flow
1. **Initialization**
- - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/index.js` which executes the main logic from `src/index.ts`.
+ - The `xcodebuildmcp` executable, as defined in `package.json`, points to the compiled `build/index.js` (CLI entrypoint from `src/cli.ts`); the MCP server starts via the `mcp` subcommand which invokes `src/index.ts`.
- Sentry initialized for error tracking (optional)
- Version information loaded from `package.json`
diff --git a/docs/dev/CLI_CONVERSION_PLAN.md b/docs/dev/CLI_CONVERSION_PLAN.md
new file mode 100644
index 00000000..77f97d14
--- /dev/null
+++ b/docs/dev/CLI_CONVERSION_PLAN.md
@@ -0,0 +1,894 @@
+# XcodeBuildMCP CLI Conversion Plan
+
+This document outlines the architectural plan to convert XcodeBuildMCP into a first-class CLI tool (`xcodebuildcli`) while maintaining full MCP server compatibility.
+
+## Overview
+
+### Goals
+
+1. **First-class CLI**: Separate CLI binary (`xcodebuildcli`) that invokes tools and exits
+2. **MCP server unchanged**: `xcodebuildmcp` remains the long-lived stdio MCP server
+3. **Shared tool logic**: All three runtimes (MCP, CLI, daemon) invoke the same underlying tool handlers
+4. **Session defaults parity**: Identical behavior in all modes
+5. **Stateful operation support**: Full daemon architecture for log capture, video recording, debugging, SwiftPM background
+
+### Non-Goals
+
+- Breaking existing MCP client integrations
+- Changing the MCP protocol or tool schemas
+- Wrapping MCP inside CLI (architecturally wrong)
+
+---
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| CLI Framework | yargs | Better dynamic command generation, strict validation, array support |
+| Stateful Support | Full daemon | Unix domain socket for complete multi-step stateful operations |
+| Daemon Communication | Unix domain socket | macOS only, simple protocol, reliable |
+| Stateful Tools Priority | All equally | Logging, video, debugging, SwiftPM all route to daemon |
+| Tool Name Format | kebab-case | CLI-friendly, disambiguated when collisions exist |
+| CLI Binary Name | `xcodebuildcli` | Distinct from MCP server binary |
+
+---
+
+## Target Runtime Model
+
+### Entry Points
+
+| Binary | Entry Point | Description |
+|--------|-------------|-------------|
+| `xcodebuildmcp` | `src/index.ts` | MCP server (stdio, long-lived) - unchanged |
+| `xcodebuildcli` | `src/cli.ts` | CLI (short-lived, exits after action) |
+| Internal | `src/daemon.ts` | Daemon (Unix socket server, long-lived) |
+
+### Execution Modes
+
+- **Stateless tools**: CLI runs tools **in-process** by default (fast path)
+- **Stateful tools** (log capture, video, debugging, SwiftPM background): CLI routes to **daemon** over Unix domain socket
+
+### Naming Rules
+
+- CLI tool names are **kebab-case**
+- Internal MCP tool names remain **unchanged** (e.g., `build_sim`, `start_sim_log_cap`)
+- CLI tool names are **derived** from MCP tool names, **disambiguated** when duplicates exist
+
+**Disambiguation rule:**
+- If a tool's kebab-name is unique across enabled workflows: use it (e.g., `build-sim`)
+- If duplicated across workflows (e.g., `clean` exists in multiple): CLI name becomes `-` (e.g., `simulator-clean`, `device-clean`)
+
+---
+
+## Directory Structure
+
+### New Files
+
+```
+src/
+ cli.ts # xcodebuildcli entry point (yargs)
+ daemon.ts # daemon entry point (unix socket server)
+ runtime/
+ bootstrap-runtime.ts # shared runtime bootstrap (config + session defaults)
+ naming.ts # kebab-case + disambiguation + arg key transforms
+ tool-catalog.ts # loads workflows/tools, builds ToolCatalog with cliName mapping
+ tool-invoker.ts # shared "invoke tool by cliName" implementation
+ types.ts # shared core interfaces (ToolDefinition, ToolCatalog, Invoker)
+ daemon/
+ protocol.ts # daemon protocol types (request/response, errors)
+ framing.ts # length-prefixed framing helpers for net.Socket
+ socket-path.ts # resolves default socket path + ensures dirs + cleanup
+ daemon-server.ts # Unix socket server + request router
+ cli/
+ yargs-app.ts # builds yargs instance, registers commands
+ daemon-client.ts # CLI -> daemon client (unix socket, protocol)
+ commands/
+ daemon.ts # yargs commands: daemon start/stop/status/restart
+ tools.ts # yargs command: tools (list available tool commands)
+ register-tool-commands.ts # auto-register tool commands from schemas
+ schema-to-yargs.ts # converts Zod schema shape -> yargs options
+ output.ts # prints ToolResponse to terminal
+```
+
+### Modified Files
+
+- `src/server/bootstrap.ts` - Refactor to use shared runtime bootstrap
+- `src/core/plugin-types.ts` - Extend `PluginMeta` with optional CLI metadata
+- `tsup.config.ts` - Add `cli` and `daemon` entries
+- `package.json` - Add `xcodebuildcli` bin, add yargs dependency
+
+---
+
+## Core Interfaces
+
+### Tool Definition and Catalog
+
+**File:** `src/runtime/types.ts`
+
+```typescript
+import type * as z from 'zod';
+import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
+import type { ToolResponse } from '../types/common.ts';
+import type { ToolSchemaShape, PluginMeta } from '../core/plugin-types.ts';
+
+export type RuntimeKind = 'cli' | 'daemon' | 'mcp';
+
+export interface ToolDefinition {
+ /** Stable CLI command name (kebab-case, disambiguated) */
+ cliName: string;
+
+ /** Original MCP tool name as declared today (unchanged) */
+ mcpName: string;
+
+ /** Workflow directory name (e.g., "simulator", "device", "logging") */
+ workflow: string;
+
+ description?: string;
+ annotations?: ToolAnnotations;
+
+ /**
+ * Schema shape used to generate yargs flags for CLI.
+ * Must include ALL parameters (not the session-default-hidden version).
+ */
+ cliSchema: ToolSchemaShape;
+
+ /**
+ * Schema shape used for MCP registration (what you already have).
+ */
+ mcpSchema: ToolSchemaShape;
+
+ /**
+ * Whether CLI MUST route this tool to the daemon (stateful operations).
+ */
+ stateful: boolean;
+
+ /**
+ * Shared handler (same used by MCP today). No duplication.
+ */
+ handler: PluginMeta['handler'];
+}
+
+export interface ToolCatalog {
+ tools: ToolDefinition[];
+ getByCliName(name: string): ToolDefinition | null;
+ resolve(input: string): { tool?: ToolDefinition; ambiguous?: string[]; notFound?: boolean };
+}
+
+export interface InvokeOptions {
+ runtime: RuntimeKind;
+ enabledWorkflows?: string[];
+ forceDaemon?: boolean;
+ socketPath?: string;
+}
+
+export interface ToolInvoker {
+ invoke(toolName: string, args: Record, opts: InvokeOptions): Promise;
+}
+```
+
+### Plugin CLI Metadata Extension
+
+**File:** `src/core/plugin-types.ts` (modify)
+
+```typescript
+export interface PluginCliMeta {
+ /** Optional override of derived CLI name */
+ name?: string;
+ /** Full schema shape for CLI flag generation (legacy, includes session-managed fields) */
+ schema?: ToolSchemaShape;
+ /** Mark tool as requiring daemon routing */
+ stateful?: boolean;
+}
+
+export interface PluginMeta {
+ readonly name: string;
+ readonly schema: ToolSchemaShape;
+ readonly description?: string;
+ readonly annotations?: ToolAnnotations;
+ readonly cli?: PluginCliMeta; // NEW (optional)
+ handler(params: Record): Promise;
+}
+```
+
+### Daemon Protocol
+
+**File:** `src/daemon/protocol.ts`
+
+```typescript
+export const DAEMON_PROTOCOL_VERSION = 1 as const;
+
+export type DaemonMethod =
+ | 'daemon.status'
+ | 'daemon.stop'
+ | 'tool.list'
+ | 'tool.invoke';
+
+export interface DaemonRequest {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ method: DaemonMethod;
+ params?: TParams;
+}
+
+export type DaemonErrorCode =
+ | 'BAD_REQUEST'
+ | 'NOT_FOUND'
+ | 'AMBIGUOUS_TOOL'
+ | 'TOOL_FAILED'
+ | 'INTERNAL';
+
+export interface DaemonError {
+ code: DaemonErrorCode;
+ message: string;
+ data?: unknown;
+}
+
+export interface DaemonResponse {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ result?: TResult;
+ error?: DaemonError;
+}
+
+export interface ToolInvokeParams {
+ tool: string;
+ args: Record;
+}
+
+export interface ToolInvokeResult {
+ response: unknown;
+}
+
+export interface DaemonStatusResult {
+ pid: number;
+ socketPath: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ toolCount: number;
+}
+```
+
+---
+
+## Shared Runtime Bootstrap
+
+**File:** `src/runtime/bootstrap-runtime.ts`
+
+```typescript
+import process from 'node:process';
+import { initConfigStore, getConfig, type RuntimeConfigOverrides } from '../utils/config-store.ts';
+import { sessionStore } from '../utils/session-store.ts';
+import { getDefaultFileSystemExecutor } from '../utils/command.ts';
+import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
+import type { RuntimeKind } from './types.ts';
+
+export interface BootstrapRuntimeOptions {
+ runtime: RuntimeKind;
+ cwd?: string;
+ fs?: FileSystemExecutor;
+ configOverrides?: RuntimeConfigOverrides;
+}
+
+export interface BootstrappedRuntime {
+ runtime: RuntimeKind;
+ cwd: string;
+ config: ReturnType;
+}
+
+export async function bootstrapRuntime(opts: BootstrapRuntimeOptions): Promise {
+ const cwd = opts.cwd ?? process.cwd();
+ const fs = opts.fs ?? getDefaultFileSystemExecutor();
+
+ await initConfigStore({ cwd, fs, overrides: opts.configOverrides });
+
+ const config = getConfig();
+
+ const defaults = config.sessionDefaults ?? {};
+ if (Object.keys(defaults).length > 0) {
+ sessionStore.setDefaults(defaults);
+ }
+
+ return { runtime: opts.runtime, cwd, config };
+}
+```
+
+---
+
+## Tool Catalog
+
+**File:** `src/runtime/tool-catalog.ts`
+
+```typescript
+import { loadWorkflowGroups } from '../core/plugin-registry.ts';
+import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts';
+import type { ToolCatalog, ToolDefinition } from './types.ts';
+import { toKebabCase, disambiguateCliNames } from './naming.ts';
+
+export async function buildToolCatalog(opts: {
+ enabledWorkflows: string[];
+}): Promise {
+ const workflowGroups = await loadWorkflowGroups();
+ const selection = resolveSelectedWorkflows(opts.enabledWorkflows, workflowGroups);
+
+ const tools: ToolDefinition[] = [];
+
+ for (const wf of selection.selectedWorkflows) {
+ for (const tool of wf.tools) {
+ const baseCliName = tool.cli?.name ?? toKebabCase(tool.name);
+ tools.push({
+ cliName: baseCliName,
+ mcpName: tool.name,
+ workflow: wf.directoryName,
+ description: tool.description,
+ annotations: tool.annotations,
+ mcpSchema: tool.schema,
+ cliSchema: tool.cli?.schema ?? tool.schema,
+ stateful: Boolean(tool.cli?.stateful),
+ handler: tool.handler,
+ });
+ }
+ }
+
+ const disambiguated = disambiguateCliNames(tools);
+
+ return {
+ tools: disambiguated,
+ getByCliName(name) {
+ return disambiguated.find((t) => t.cliName === name) ?? null;
+ },
+ resolve(input) {
+ const exact = disambiguated.filter((t) => t.cliName === input);
+ if (exact.length === 1) return { tool: exact[0] };
+
+ const aliasMatches = disambiguated.filter((t) => toKebabCase(t.mcpName) === input);
+ if (aliasMatches.length === 1) return { tool: aliasMatches[0] };
+ if (aliasMatches.length > 1) return { ambiguous: aliasMatches.map((t) => t.cliName) };
+
+ return { notFound: true };
+ },
+ };
+}
+```
+
+**File:** `src/runtime/naming.ts`
+
+```typescript
+import type { ToolDefinition } from './types.ts';
+
+export function toKebabCase(name: string): string {
+ return name
+ .trim()
+ .replace(/_/g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/[A-Z]/g, (m) => m.toLowerCase())
+ .toLowerCase();
+}
+
+export function disambiguateCliNames(tools: ToolDefinition[]): ToolDefinition[] {
+ const groups = new Map();
+ for (const t of tools) {
+ groups.set(t.cliName, [...(groups.get(t.cliName) ?? []), t]);
+ }
+
+ return tools.map((t) => {
+ const same = groups.get(t.cliName) ?? [];
+ if (same.length <= 1) return t;
+ return { ...t, cliName: `${t.workflow}-${t.cliName}` };
+ });
+}
+```
+
+---
+
+## Daemon Architecture
+
+### Socket Path
+
+**File:** `src/daemon/socket-path.ts`
+
+```typescript
+import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+export function defaultSocketPath(): string {
+ return join(homedir(), '.xcodebuildcli', 'daemon.sock');
+}
+
+export function ensureSocketDir(socketPath: string): void {
+ const dir = socketPath.split('/').slice(0, -1).join('/');
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
+}
+
+export function removeStaleSocket(socketPath: string): void {
+ if (existsSync(socketPath)) unlinkSync(socketPath);
+}
+```
+
+### Length-Prefixed Framing
+
+**File:** `src/daemon/framing.ts`
+
+```typescript
+import type net from 'node:net';
+
+export function writeFrame(socket: net.Socket, obj: unknown): void {
+ const json = Buffer.from(JSON.stringify(obj), 'utf8');
+ const header = Buffer.alloc(4);
+ header.writeUInt32BE(json.length, 0);
+ socket.write(Buffer.concat([header, json]));
+}
+
+export function createFrameReader(onMessage: (msg: unknown) => void) {
+ let buffer = Buffer.alloc(0);
+
+ return (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk]);
+
+ while (buffer.length >= 4) {
+ const len = buffer.readUInt32BE(0);
+ if (buffer.length < 4 + len) return;
+
+ const payload = buffer.subarray(4, 4 + len);
+ buffer = buffer.subarray(4 + len);
+
+ const msg = JSON.parse(payload.toString('utf8'));
+ onMessage(msg);
+ }
+ };
+}
+```
+
+### Daemon Server
+
+**File:** `src/daemon/daemon-server.ts`
+
+```typescript
+import net from 'node:net';
+import { writeFrame, createFrameReader } from './framing.ts';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type { DaemonRequest, DaemonResponse, ToolInvokeParams } from './protocol.ts';
+import { DAEMON_PROTOCOL_VERSION } from './protocol.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+
+export interface DaemonServerContext {
+ socketPath: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ catalog: ToolCatalog;
+}
+
+export function startDaemonServer(ctx: DaemonServerContext): net.Server {
+ const invoker = new DefaultToolInvoker(ctx.catalog);
+
+ const server = net.createServer((socket) => {
+ const onData = createFrameReader(async (msg) => {
+ const req = msg as DaemonRequest;
+ const base = { v: DAEMON_PROTOCOL_VERSION, id: req?.id ?? 'unknown' };
+
+ try {
+ if (req.v !== DAEMON_PROTOCOL_VERSION) {
+ return writeFrame(socket, { ...base, error: { code: 'BAD_REQUEST', message: 'Unsupported protocol version' } });
+ }
+
+ switch (req.method) {
+ case 'daemon.status':
+ return writeFrame(socket, {
+ ...base,
+ result: {
+ pid: process.pid,
+ socketPath: ctx.socketPath,
+ startedAt: ctx.startedAt,
+ enabledWorkflows: ctx.enabledWorkflows,
+ toolCount: ctx.catalog.tools.length,
+ },
+ });
+
+ case 'daemon.stop':
+ writeFrame(socket, { ...base, result: { ok: true } });
+ server.close(() => process.exit(0));
+ return;
+
+ case 'tool.list':
+ return writeFrame(socket, {
+ ...base,
+ result: ctx.catalog.tools.map((t) => ({
+ name: t.cliName,
+ workflow: t.workflow,
+ description: t.description ?? '',
+ stateful: t.stateful,
+ })),
+ });
+
+ case 'tool.invoke': {
+ const params = req.params as ToolInvokeParams;
+ const response = await invoker.invoke(params.tool, params.args ?? {}, {
+ runtime: 'daemon',
+ enabledWorkflows: ctx.enabledWorkflows,
+ });
+ return writeFrame(socket, { ...base, result: { response } });
+ }
+
+ default:
+ return writeFrame(socket, { ...base, error: { code: 'BAD_REQUEST', message: `Unknown method` } });
+ }
+ } catch (error) {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'INTERNAL', message: error instanceof Error ? error.message : String(error) },
+ });
+ }
+ });
+
+ socket.on('data', onData);
+ });
+
+ return server;
+}
+```
+
+### Daemon Entry Point
+
+**File:** `src/daemon.ts`
+
+```typescript
+#!/usr/bin/env node
+import net from 'node:net';
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import { ensureSocketDir, defaultSocketPath, removeStaleSocket } from './daemon/socket-path.ts';
+import { startDaemonServer } from './daemon/daemon-server.ts';
+
+async function main(): Promise {
+ const runtime = await bootstrapRuntime({ runtime: 'daemon' });
+ const socketPath = process.env.XCODEBUILDCLI_SOCKET ?? defaultSocketPath();
+
+ ensureSocketDir(socketPath);
+
+ try {
+ await new Promise((resolve, reject) => {
+ const s = net.createConnection(socketPath, () => {
+ s.end();
+ reject(new Error('Daemon already running'));
+ });
+ s.on('error', () => resolve());
+ });
+ } catch (e) {
+ throw e;
+ }
+
+ removeStaleSocket(socketPath);
+
+ const catalog = await buildToolCatalog({ enabledWorkflows: runtime.config.enabledWorkflows });
+
+ const server = startDaemonServer({
+ socketPath,
+ startedAt: new Date().toISOString(),
+ enabledWorkflows: runtime.config.enabledWorkflows,
+ catalog,
+ });
+
+ server.listen(socketPath);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
+```
+
+---
+
+## CLI Architecture
+
+### CLI Entry Point
+
+**File:** `src/cli.ts`
+
+```typescript
+#!/usr/bin/env node
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import { buildYargsApp } from './cli/yargs-app.ts';
+
+async function main(): Promise {
+ const runtime = await bootstrapRuntime({ runtime: 'cli' });
+ const catalog = await buildToolCatalog({ enabledWorkflows: runtime.config.enabledWorkflows });
+
+ const yargsApp = buildYargsApp({ catalog, runtimeConfig: runtime.config });
+ await yargsApp.parseAsync();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
+```
+
+### Yargs App
+
+**File:** `src/cli/yargs-app.ts`
+
+```typescript
+import yargs from 'yargs';
+import { hideBin } from 'yargs/helpers';
+import type { ToolCatalog } from '../runtime/types.ts';
+import { registerDaemonCommands } from './commands/daemon.ts';
+import { registerToolsCommand } from './commands/tools.ts';
+import { registerToolCommands } from './register-tool-commands.ts';
+import { version } from '../version.ts';
+
+export function buildYargsApp(opts: {
+ catalog: ToolCatalog;
+ runtimeConfig: { enabledWorkflows: string[] };
+}) {
+ const app = yargs(hideBin(process.argv))
+ .scriptName('xcodebuildcli')
+ .strict()
+ .recommendCommands()
+ .wrap(Math.min(120, yargs.terminalWidth()))
+ .parserConfiguration({
+ 'camel-case-expansion': true,
+ 'strip-dashed': true,
+ })
+ .option('socket', {
+ type: 'string',
+ describe: 'Override daemon unix socket path',
+ default: process.env.XCODEBUILDCLI_SOCKET,
+ })
+ .option('daemon', {
+ type: 'boolean',
+ describe: 'Force daemon execution even for stateless tools',
+ default: false,
+ })
+ .version(version)
+ .help();
+
+ registerDaemonCommands(app);
+ registerToolsCommand(app, opts.catalog);
+ registerToolCommands(app, opts.catalog);
+
+ return app;
+}
+```
+
+### Schema to Yargs Conversion
+
+**File:** `src/cli/schema-to-yargs.ts`
+
+```typescript
+import * as z from 'zod';
+
+export type YargsOpt =
+ | { type: 'string'; array?: boolean; choices?: string[]; describe?: string }
+ | { type: 'number'; array?: boolean; describe?: string }
+ | { type: 'boolean'; describe?: string };
+
+function unwrap(t: z.ZodTypeAny): z.ZodTypeAny {
+ if (t instanceof z.ZodOptional) return unwrap(t.unwrap());
+ if (t instanceof z.ZodNullable) return unwrap(t.unwrap());
+ if (t instanceof z.ZodDefault) return unwrap(t.removeDefault());
+ if (t instanceof z.ZodEffects) return unwrap(t.innerType());
+ return t;
+}
+
+export function zodToYargsOption(t: z.ZodTypeAny): YargsOpt | null {
+ const u = unwrap(t);
+
+ if (u instanceof z.ZodString) return { type: 'string' };
+ if (u instanceof z.ZodNumber) return { type: 'number' };
+ if (u instanceof z.ZodBoolean) return { type: 'boolean' };
+
+ if (u instanceof z.ZodEnum) return { type: 'string', choices: u.options };
+ if (u instanceof z.ZodNativeEnum) return { type: 'string', choices: Object.values(u.enum) as string[] };
+
+ if (u instanceof z.ZodArray) {
+ const inner = unwrap(u.element);
+ if (inner instanceof z.ZodString) return { type: 'string', array: true };
+ if (inner instanceof z.ZodNumber) return { type: 'number', array: true };
+ return null;
+ }
+
+ return null;
+}
+```
+
+### Tool Command Registration
+
+**File:** `src/cli/register-tool-commands.ts`
+
+```typescript
+import type { Argv } from 'yargs';
+import type { ToolCatalog } from '../runtime/types.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { zodToYargsOption } from './schema-to-yargs.ts';
+import { toKebabCase } from '../runtime/naming.ts';
+import { printToolResponse } from './output.ts';
+
+export function registerToolCommands(app: Argv, catalog: ToolCatalog): void {
+ const invoker = new DefaultToolInvoker(catalog);
+
+ for (const tool of catalog.tools) {
+ app.command(
+ tool.cliName,
+ tool.description ?? '',
+ (y) => {
+ y.option('json', {
+ type: 'string',
+ describe: 'JSON object of tool args (merged with flags)',
+ });
+
+ for (const [key, zt] of Object.entries(tool.cliSchema)) {
+ const opt = zodToYargsOption(zt as z.ZodTypeAny);
+ if (!opt) continue;
+
+ const flag = toKebabCase(key);
+ y.option(flag, {
+ type: opt.type,
+ array: (opt as { array?: boolean }).array,
+ choices: (opt as { choices?: string[] }).choices,
+ describe: (opt as { describe?: string }).describe,
+ });
+ }
+
+ return y;
+ },
+ async (argv) => {
+ const { json, socket, daemon, _, $0, ...rest } = argv as Record;
+
+ const jsonArgs = json ? (JSON.parse(String(json)) as Record) : {};
+ const flagArgs = rest as Record;
+ const args = { ...flagArgs, ...jsonArgs };
+
+ const response = await invoker.invoke(tool.cliName, args, {
+ runtime: 'cli',
+ forceDaemon: Boolean(daemon),
+ socketPath: socket as string | undefined,
+ });
+
+ printToolResponse(response);
+ },
+ );
+ }
+}
+```
+
+### CLI Output
+
+**File:** `src/cli/output.ts`
+
+```typescript
+import type { ToolResponse } from '../types/common.ts';
+
+export function printToolResponse(res: ToolResponse): void {
+ for (const item of res.content ?? []) {
+ if (item.type === 'text') {
+ console.log(item.text);
+ } else if (item.type === 'image') {
+ console.log(`[image ${item.mimeType}, ${item.data.length} bytes base64]`);
+ }
+ }
+ if (res.isError) process.exitCode = 1;
+}
+```
+
+---
+
+## Build Configuration
+
+### tsup.config.ts
+
+```typescript
+export default defineConfig({
+ entry: {
+ index: 'src/index.ts',
+ 'doctor-cli': 'src/doctor-cli.ts',
+ cli: 'src/cli.ts',
+ daemon: 'src/daemon.ts',
+ },
+ // ...existing config...
+});
+```
+
+### package.json
+
+```json
+{
+ "bin": {
+ "xcodebuildmcp": "build/index.js",
+ "xcodebuildmcp-doctor": "build/doctor-cli.js",
+ "xcodebuildcli": "build/cli.js"
+ },
+ "dependencies": {
+ "yargs": "^17.7.2"
+ }
+}
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Foundation
+
+1. Add `src/runtime/bootstrap-runtime.ts`
+2. Refactor `src/server/bootstrap.ts` to call shared bootstrap
+3. Add `src/cli.ts`, `src/daemon.ts` entries to `tsup.config.ts`
+4. Add `xcodebuildcli` bin + `yargs` dependency in `package.json`
+
+**Result:** Builds produce `build/cli.js` and `build/daemon.js`, MCP server unchanged.
+
+### Phase 2: Tool Catalog + Direct CLI Invocation (Stateless)
+
+1. Implement `src/runtime/naming.ts`, `src/runtime/tool-catalog.ts`, `src/runtime/tool-invoker.ts`, `src/runtime/types.ts`
+2. Implement `src/cli/yargs-app.ts`, `src/cli/schema-to-yargs.ts`, `src/cli/register-tool-commands.ts`, `src/cli/output.ts`
+3. Add `xcodebuildcli tools` list command
+
+**Result:** `xcodebuildcli ` works for stateless tools in-process.
+
+### Phase 3: Daemon Protocol + Server + Client
+
+1. Implement `src/daemon/protocol.ts`, `src/daemon/framing.ts`, `src/daemon/socket-path.ts`
+2. Implement `src/daemon/daemon-server.ts` and wire into `src/daemon.ts`
+3. Implement `src/cli/daemon-client.ts`
+4. Implement `xcodebuildcli daemon start|stop|status|restart`
+
+**Result:** Daemon starts, responds to status, can invoke tools.
+
+### Phase 4: Stateful Routing
+
+1. Add `cli.stateful = true` metadata to all stateful tools (logging, video, debugging, swift-package background)
+2. Modify `DefaultToolInvoker` to require daemon when `tool.stateful === true`
+3. Add CLI auto-start behavior: if daemon required and not running, start it programmatically
+
+**Result:** Stateful commands run through daemon reliably; state persists across CLI invocations.
+
+### Phase 5: Full CLI Schema Coverage
+
+1. For all tools, ensure `tool.cli.schema` is present and complete
+2. Ensure schema-to-yargs supports all Zod types used (string/number/boolean/enum/array)
+3. Require complex/nested values via `--json` fallback
+
+**Result:** CLI is first-class with full native flags.
+
+---
+
+## Command Examples
+
+```bash
+# List available tools
+xcodebuildcli tools
+
+# Run stateless tool with native flags
+xcodebuildcli build-sim --scheme MyApp --project-path ./App.xcodeproj
+
+# Run tool with JSON input
+xcodebuildcli build-sim --json '{"scheme":"MyApp"}'
+
+# Daemon management
+xcodebuildcli daemon start
+xcodebuildcli daemon status
+xcodebuildcli daemon stop
+
+# Stateful tools (automatically route to daemon)
+xcodebuildcli start-sim-log-cap --simulator-id ABCD-1234
+xcodebuildcli stop-sim-log-cap --session-id xyz
+
+# Force daemon execution for any tool
+xcodebuildcli build-sim --daemon --scheme MyApp
+
+# Help
+xcodebuildcli --help
+xcodebuildcli build-sim --help
+```
+
+---
+
+## Invariants
+
+1. **MCP unchanged**: `xcodebuildmcp` continues to work exactly as before
+2. **Smithery unchanged**: `src/smithery.ts` continues to work
+3. **No code duplication**: CLI invokes same `PluginMeta.handler` functions
+4. **Session defaults identical**: All runtimes use `bootstrapRuntime()` → `sessionStore`
+5. **Tool logic shared**: `src/mcp/tools/*` remains single source of truth
+6. **Daemon is macOS-only**: Uses Unix domain sockets; CLI fails with clear error on non-macOS
diff --git a/docs/dev/CONTRIBUTING.md b/docs/dev/CONTRIBUTING.md
index 944eb72a..5c801c4b 100644
--- a/docs/dev/CONTRIBUTING.md
+++ b/docs/dev/CONTRIBUTING.md
@@ -64,7 +64,7 @@ brew install axe
```
4. Start the server:
```
- node build/index.js
+ node build/index.js mcp
```
### Configure your MCP client
@@ -77,7 +77,8 @@ Most MCP clients (Cursor, VS Code, Windsurf, Claude Desktop etc) have standardis
"XcodeBuildMCP": {
"command": "node",
"args": [
- "/path_to/XcodeBuildMCP/build/index.js"
+ "/path_to/XcodeBuildMCP/build/index.js",
+ "mcp"
]
}
}
@@ -109,7 +110,7 @@ npm run inspect
or if you prefer the explicit command:
```bash
-npx @modelcontextprotocol/inspector node build/index.js
+npx @modelcontextprotocol/inspector node build/index.js mcp
```
#### Reloaderoo (Advanced Debugging) - **RECOMMENDED**
@@ -126,7 +127,7 @@ Provides transparent hot-reloading without disconnecting your MCP client:
npm install -g reloaderoo
# Start XcodeBuildMCP through reloaderoo proxy
-reloaderoo -- node build/index.js
+reloaderoo -- node build/index.js mcp
```
**Benefits**:
@@ -139,7 +140,7 @@ reloaderoo -- node build/index.js
```json
"XcodeBuildMCP": {
"command": "reloaderoo",
- "args": ["--", "node", "/path/to/XcodeBuildMCP/build/index.js"],
+ "args": ["--", "node", "/path/to/XcodeBuildMCP/build/index.js", "mcp"],
"env": {
"XCODEBUILDMCP_DEBUG": "true"
}
@@ -151,7 +152,7 @@ Exposes debug tools for making raw MCP protocol calls and inspecting server resp
```bash
# Start reloaderoo in inspection mode
-reloaderoo inspect mcp -- node build/index.js
+reloaderoo inspect mcp -- node build/index.js mcp
```
**Available Debug Tools**:
@@ -173,7 +174,7 @@ reloaderoo inspect mcp -- node build/index.js
"inspect", "mcp",
"--working-dir", "/path/to/XcodeBuildMCP",
"--",
- "node", "/path/to/XcodeBuildMCP/build/index.js"
+ "node", "/path/to/XcodeBuildMCP/build/index.js", "mcp"
],
"env": {
"XCODEBUILDMCP_DEBUG": "true"
@@ -187,10 +188,10 @@ Test full vs. selective workflow registration during development:
```bash
# Test full tool registration (default)
-reloaderoo inspect mcp -- node build/index.js
+reloaderoo inspect mcp -- node build/index.js mcp
# Test selective workflow registration
-XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device reloaderoo inspect mcp -- node build/index.js
+XCODEBUILDMCP_ENABLED_WORKFLOWS=simulator,device reloaderoo inspect mcp -- node build/index.js mcp
```
**Key Differences to Test**:
- **Full Registration**: All tools are available immediately via `list_tools`
@@ -211,7 +212,7 @@ Running the XcodeBuildMCP server with the environmental variable `XCODEBUILDMCP_
1. **Start Development Session**:
```bash
# Terminal 1: Start in hot-reload mode
- reloaderoo -- node build/index.js
+ reloaderoo -- node build/index.js mcp
# Terminal 2: Start build watcher
npm run build:watch
@@ -337,7 +338,7 @@ When developing or testing changes to the templates:
```json
"XcodeBuildMCP": {
"command": "node",
- "args": ["/path_to/XcodeBuildMCP/build/index.js"],
+ "args": ["/path_to/XcodeBuildMCP/build/index.js", "mcp"],
"env": {
"XCODEBUILDMCP_IOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-iOS-Template",
"XCODEBUILDMCP_MACOS_TEMPLATE_PATH": "/path/to/XcodeBuildMCP-macOS-Template"
diff --git a/docs/dev/MANUAL_TESTING.md b/docs/dev/MANUAL_TESTING.md
index 77fa39ba..d2ae93a2 100644
--- a/docs/dev/MANUAL_TESTING.md
+++ b/docs/dev/MANUAL_TESTING.md
@@ -60,11 +60,11 @@ Black Box Testing means testing ONLY through external interfaces without any kno
**ABSOLUTE TESTING RULES - NO EXCEPTIONS:**
1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands**
- - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js`
- - `npx reloaderoo@latest inspect list-tools -- node build/index.js`
- - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js`
- - `npx reloaderoo@latest inspect server-info -- node build/index.js`
- - `npx reloaderoo@latest inspect ping -- node build/index.js`
+ - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect server-info -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect ping -- node build/index.js mcp`
2. **❌ COMPLETELY FORBIDDEN ACTIONS:**
- **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly
@@ -86,8 +86,8 @@ Black Box Testing means testing ONLY through external interfaces without any kno
const result = await doctor();
// ✅ CORRECT - Only through Reloaderoo inspect
- npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
- npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
+ npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
```
**WHY RELOADEROO INSPECT IS MANDATORY:**
@@ -160,7 +160,7 @@ grep "^ • " /tmp/tools_detailed.txt | sed 's/^ • //' > /tmp/tool_names.t
For EVERY tool in the list:
```bash
# Test each tool individually - NO BATCHING
-npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js mcp
# Mark tool as completed in TodoWrite IMMEDIATELY after testing
# Record result (success/failure/blocked) for each tool
@@ -414,7 +414,7 @@ echo "Tool schema reference created at /tmp/tool_schemas.md"
- Mark "completed" only after manual verification
2. **Test Each Tool Individually**
- - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js`
+ - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp`
- Wait for complete response before proceeding to next tool
- Read and verify each tool's output manually
- Record key outputs (UUIDs, paths, schemes) for dependent tools
@@ -438,16 +438,16 @@ echo "Tool schema reference created at /tmp/tool_schemas.md"
```bash
# Test server connectivity
-npx reloaderoo@latest inspect ping -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
# Get server information
-npx reloaderoo@latest inspect server-info -- node build/index.js
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
# Verify tool count manually
-npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null | jq '.tools | length'
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null | jq '.tools | length'
# Verify resource count manually
-npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null | jq '.resources | length'
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null | jq '.resources | length'
```
#### Phase 2: Resource Testing
@@ -456,7 +456,7 @@ npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null
# Test each resource systematically
while IFS= read -r resource_uri; do
echo "Testing resource: $resource_uri"
- npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js 2>/dev/null
+ npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js mcp 2>/dev/null
echo "---"
done < /tmp/resource_uris.txt
```
@@ -470,23 +470,23 @@ echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ==="
# 1. Test doctor (no dependencies)
echo "Testing doctor..."
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp 2>/dev/null
# 2. Collect device data
echo "Collecting device UUIDs..."
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js 2>/dev/null > /tmp/devices_output.json
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/devices_output.json
DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2)
echo "Device UUIDs captured: $DEVICE_UUIDS"
# 3. Collect simulator data
echo "Collecting simulator UUIDs..."
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js 2>/dev/null > /tmp/sims_output.json
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/sims_output.json
SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3)
echo "Simulator UUIDs captured: $SIMULATOR_UUIDS"
# 4. Collect project data
echo "Collecting project paths..."
-npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js 2>/dev/null > /tmp/projects_output.json
+npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js mcp 2>/dev/null > /tmp/projects_output.json
PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3)
WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2)
echo "Project paths captured: $PROJECT_PATHS"
@@ -508,7 +508,7 @@ echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ==="
while IFS= read -r project_path; do
if [ -n "$project_path" ]; then
echo "Getting schemes for: $project_path"
- npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js 2>/dev/null > /tmp/schemes_$$.json
+ npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/schemes_$$.json
SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme")
echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt
echo "Schemes captured for $project_path: $SCHEMES"
@@ -519,7 +519,7 @@ done < /tmp/project_paths.txt
while IFS= read -r workspace_path; do
if [ -n "$workspace_path" ]; then
echo "Getting schemes for: $workspace_path"
- npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js 2>/dev/null > /tmp/ws_schemes_$$.json
+ npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json
SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme")
echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt
echo "Schemes captured for $workspace_path: $SCHEMES"
@@ -544,29 +544,29 @@ done < /tmp/workspace_paths.txt
```bash
# STEP 1: Test foundation tools (no parameters required)
# Execute each command individually, wait for response, verify manually
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
# [Wait for response, read output, mark tool complete in task list]
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
# [Record device UUIDs from response for dependent tools]
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp
# [Record simulator UUIDs from response for dependent tools]
# STEP 2: Test project discovery (use discovered project paths)
-npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js mcp
# [Record scheme names from response for build tools]
# STEP 3: Test workspace tools (use discovered workspace paths)
-npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js mcp
# [Record scheme names from response for build tools]
# STEP 4: Test simulator tools (use captured simulator UUIDs from step 1)
-npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js mcp
# [Verify simulator boots successfully]
# STEP 5: Test build tools (requires project + scheme + simulator from previous steps)
-npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js mcp
# [Verify build succeeds and record app bundle path]
```
@@ -592,7 +592,7 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP
```bash
# ❌ IMMEDIATE TERMINATION - Using scripts to test tools
for tool in $(cat tool_list.txt); do
- npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js
+ npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js mcp
done
```
@@ -618,19 +618,19 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP
```bash
# ✅ CORRECT - Step-by-step manual execution via Reloaderoo
# Tool 1: Test doctor
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
# [Read response, verify, mark complete in TodoWrite]
# Tool 2: Test list_devices
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
# [Read response, capture UUIDs, mark complete in TodoWrite]
# Tool 3: Test list_sims
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp
# [Read response, capture UUIDs, mark complete in TodoWrite]
# Tool X: Test stateful tool (expected to fail)
-npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js mcp
# [Tool fails as expected - no in-memory state available]
# [Mark as "false negative - stateful tool limitation" in TodoWrite]
# [Continue to next tool without investigation]
@@ -655,15 +655,15 @@ echo "=== Error Testing ==="
# Test with invalid JSON parameters
echo "Testing invalid parameter types..."
-npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js mcp 2>/dev/null
# Test with non-existent paths
echo "Testing non-existent paths..."
-npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js mcp 2>/dev/null
# Test with invalid UUIDs
echo "Testing invalid UUIDs..."
-npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js mcp 2>/dev/null
```
## Testing Report Generation
@@ -699,12 +699,12 @@ echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md"
```bash
# Essential testing commands
-npx reloaderoo@latest inspect ping -- node build/index.js
-npx reloaderoo@latest inspect server-info -- node build/index.js
-npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools | length'
-npx reloaderoo@latest inspect list-resources -- node build/index.js | jq '.resources | length'
-npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js
-npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools | length'
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp | jq '.resources | length'
+npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js mcp
+npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js mcp
# Schema extraction
jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json
@@ -720,7 +720,7 @@ jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tm
**Cause**: Server startup issues or MCP protocol communication problems
**Resolution**:
- Verify server builds successfully: `npm run build`
-- Test direct server startup: `node build/index.js`
+- Test direct server startup: `node build/index.js mcp`
- Check for TypeScript compilation errors
#### 2. Tool Parameter Validation Errors
@@ -735,7 +735,7 @@ jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .description' /tm
**Symptoms**: Reloaderoo reports tool not found
**Cause**: Tool name mismatch or server registration issues
**Resolution**:
-- Verify tool exists in list: `npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools[].name'`
+- Verify tool exists in list: `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools[].name'`
- Check exact tool name spelling and case sensitivity
- Ensure server built successfully
diff --git a/docs/dev/RELOADEROO.md b/docs/dev/RELOADEROO.md
index 689425a7..3fc293db 100644
--- a/docs/dev/RELOADEROO.md
+++ b/docs/dev/RELOADEROO.md
@@ -36,44 +36,44 @@ Direct command-line access to MCP servers without client setup - perfect for tes
```bash
# List all available tools
-npx reloaderoo@latest inspect list-tools -- node build/index.js
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp
# Call any tool with parameters
-npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js
+npx reloaderoo@latest inspect call-tool --params '' -- node build/index.js mcp
# Get server information
-npx reloaderoo@latest inspect server-info -- node build/index.js
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
# List available resources
-npx reloaderoo@latest inspect list-resources -- node build/index.js
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp
# Read a specific resource
-npx reloaderoo@latest inspect read-resource "" -- node build/index.js
+npx reloaderoo@latest inspect read-resource "" -- node build/index.js mcp
# List available prompts
-npx reloaderoo@latest inspect list-prompts -- node build/index.js
+npx reloaderoo@latest inspect list-prompts -- node build/index.js mcp
# Get a specific prompt
-npx reloaderoo@latest inspect get-prompt --args '' -- node build/index.js
+npx reloaderoo@latest inspect get-prompt --args '' -- node build/index.js mcp
# Check server connectivity
-npx reloaderoo@latest inspect ping -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
```
**Example Tool Calls:**
```bash
# List connected devices
-npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp
# Get doctor information
-npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js mcp
# List iOS simulators
-npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js mcp
# Read devices resource
-npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js
+npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js mcp
```
### 🔄 **Proxy Mode** (Hot-Reload Development)
@@ -91,10 +91,10 @@ Transparent MCP proxy server that enables seamless hot-reloading during developm
```bash
# Start proxy mode (your AI client connects to this)
-npx reloaderoo@latest proxy -- node build/index.js
+npx reloaderoo@latest proxy -- node build/index.js mcp
# With debug logging
-npx reloaderoo@latest proxy --log-level debug -- node build/index.js
+npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp
# Then in your AI session, request:
# "Please restart the MCP server to load my latest changes"
@@ -108,7 +108,7 @@ Start CLI mode as a persistent MCP server for interactive debugging through MCP
```bash
# Start reloaderoo in CLI mode as an MCP server
-npx reloaderoo@latest inspect mcp -- node build/index.js
+npx reloaderoo@latest inspect mcp -- node build/index.js mcp
```
This runs CLI mode as a persistent MCP server, exposing 8 debug tools through the MCP protocol:
@@ -172,9 +172,9 @@ Options:
--dry-run Validate configuration without starting proxy
Examples:
- npx reloaderoo proxy -- node build/index.js
- npx reloaderoo -- node build/index.js # Same as above (proxy is default)
- npx reloaderoo proxy --log-level debug -- node build/index.js
+ npx reloaderoo proxy -- node build/index.js mcp
+ npx reloaderoo -- node build/index.js mcp # Same as above (proxy is default)
+ npx reloaderoo proxy --log-level debug -- node build/index.js mcp
```
### 🔍 **CLI Mode Commands**
@@ -193,9 +193,9 @@ Subcommands:
ping [options] Check server connectivity
Examples:
- npx reloaderoo@latest inspect list-tools -- node build/index.js
- npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js
- npx reloaderoo@latest inspect server-info -- node build/index.js
+ npx reloaderoo@latest inspect list-tools -- node build/index.js mcp
+ npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp
+ npx reloaderoo@latest inspect server-info -- node build/index.js mcp
```
### **Info Command**
@@ -260,14 +260,14 @@ Perfect for testing individual tools or debugging server issues without MCP clie
npm run build
# 2. Test your server quickly
-npx reloaderoo@latest inspect list-tools -- node build/index.js
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp
# 3. Call specific tools to verify behavior
-npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp
# 4. Check server health and resources
-npx reloaderoo@latest inspect ping -- node build/index.js
-npx reloaderoo@latest inspect list-resources -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp
```
### 🔄 **Proxy Mode Workflow** (Hot-Reload Development)
@@ -277,9 +277,9 @@ For full development sessions with AI clients that need persistent connections:
#### 1. **Start Development Session**
Configure your AI client to connect to reloaderoo proxy instead of your server directly:
```bash
-npx reloaderoo@latest proxy -- node build/index.js
+npx reloaderoo@latest proxy -- node build/index.js mcp
# or with debug logging:
-npx reloaderoo@latest proxy --log-level debug -- node build/index.js
+npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp
```
#### 2. **Develop Your MCP Server**
@@ -305,7 +305,7 @@ For interactive debugging through MCP clients:
```bash
# Start reloaderoo CLI mode as an MCP server
-npx reloaderoo@latest inspect mcp -- node build/index.js
+npx reloaderoo@latest inspect mcp -- node build/index.js mcp
# Then connect with an MCP client to access debug tools
# Available tools: list_tools, call_tool, list_resources, etc.
@@ -318,16 +318,16 @@ npx reloaderoo@latest inspect mcp -- node build/index.js
**Server won't start in proxy mode:**
```bash
# Check if XcodeBuildMCP runs independently first
-node build/index.js
+node build/index.js mcp
# Then try with reloaderoo proxy to validate configuration
-npx reloaderoo@latest proxy -- node build/index.js
+npx reloaderoo@latest proxy -- node build/index.js mcp
```
**Connection problems with MCP clients:**
```bash
# Enable debug logging to see what's happening
-npx reloaderoo@latest proxy --log-level debug -- node build/index.js
+npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp
# Check system info and configuration
npx reloaderoo@latest info --verbose
@@ -336,10 +336,10 @@ npx reloaderoo@latest info --verbose
**Restart failures in proxy mode:**
```bash
# Increase restart timeout
-npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js
+npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js mcp
# Check restart limits
-npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js
+npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js mcp
```
### 🔍 **CLI Mode Issues**
@@ -347,19 +347,19 @@ npx reloaderoo@latest proxy --max-restarts 5 -- node build/index.js
**CLI commands failing:**
```bash
# Test basic connectivity first
-npx reloaderoo@latest inspect ping -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
# Enable debug logging for CLI commands (via proxy debug mode)
-npx reloaderoo@latest proxy --log-level debug -- node build/index.js
+npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp
```
**JSON parsing errors:**
```bash
# Check server information for troubleshooting
-npx reloaderoo@latest inspect server-info -- node build/index.js
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
# Ensure your server outputs valid JSON
-node build/index.js | head -10
+node build/index.js mcp | head -10
```
### **General Issues**
@@ -376,15 +376,15 @@ npm install -g reloaderoo
**Parameter validation:**
```bash
# Ensure JSON parameters are properly quoted
-npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp
```
### **General Debug Mode**
```bash
# Get detailed information about what's happening
-npx reloaderoo@latest proxy --debug -- node build/index.js # For proxy mode
-npx reloaderoo@latest proxy --log-level debug -- node build/index.js # For detailed proxy logging
+npx reloaderoo@latest proxy --debug -- node build/index.js mcp # For proxy mode
+npx reloaderoo@latest proxy --log-level debug -- node build/index.js mcp # For detailed proxy logging
# View system information
npx reloaderoo@latest info --verbose
@@ -421,14 +421,14 @@ export MCPDEV_PROXY_CWD=/path/to/directory # Default working directory
### Custom Working Directory
```bash
-npx reloaderoo@latest proxy --working-dir /custom/path -- node build/index.js
-npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/index.js
+npx reloaderoo@latest proxy --working-dir /custom/path -- node build/index.js mcp
+npx reloaderoo@latest inspect list-tools --working-dir /custom/path -- node build/index.js mcp
```
### Timeout Configuration
```bash
-npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js
+npx reloaderoo@latest proxy --restart-timeout 60000 -- node build/index.js mcp
```
## Integration with XcodeBuildMCP
diff --git a/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md b/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md
index 6d6a9af5..49ad6d3b 100644
--- a/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md
+++ b/docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md
@@ -22,158 +22,158 @@ npx reloaderoo@latest --help
- **`build_device`**: Builds an app for a physical device.
```bash
- npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool build_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
- **`get_device_app_path`**: Gets the `.app` bundle path for a device build.
```bash
- npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool get_device_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
- **`install_app_device`**: Installs an app on a physical device.
```bash
- npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool install_app_device --params '{"deviceId": "DEVICE_UDID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp
```
- **`launch_app_device`**: Launches an app on a physical device.
```bash
- npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool launch_app_device --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`list_devices`**: Lists connected physical devices.
```bash
- npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool list_devices --params '{}' -- node build/index.js mcp
```
- **`stop_app_device`**: Stops an app on a physical device.
```bash
- npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool stop_app_device --params '{"deviceId": "DEVICE_UDID", "processId": 12345}' -- node build/index.js mcp
```
- **`test_device`**: Runs tests on a physical device.
```bash
- npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool test_device --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "deviceId": "DEVICE_UDID"}' -- node build/index.js mcp
```
### iOS Simulator Development
- **`boot_sim`**: Boots a simulator.
```bash
- npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorId": "SIMULATOR_UUID"}' -- node build/index.js mcp
```
- **`build_run_sim`**: Builds and runs an app on a simulator.
```bash
- npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool build_run_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp
```
- **`build_sim`**: Builds an app for a simulator.
```bash
- npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool build_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp
```
- **`get_sim_app_path`**: Gets the `.app` bundle path for a simulator build.
```bash
- npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool get_sim_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "platform": "iOS Simulator", "simulatorName": "iPhone 16"}' -- node build/index.js mcp
```
- **`install_app_sim`**: Installs an app on a simulator.
```bash
- npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool install_app_sim --params '{"simulatorId": "SIMULATOR_UUID", "appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp
```
- **`launch_app_logs_sim`**: Launches an app on a simulator with log capture.
```bash
- npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool launch_app_logs_sim --params '{"simulatorId": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`launch_app_sim`**: Launches an app on a simulator.
```bash
- npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool launch_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`list_sims`**: Lists available simulators.
```bash
- npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool list_sims --params '{}' -- node build/index.js mcp
```
- **`open_sim`**: Opens the Simulator application.
```bash
- npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool open_sim --params '{}' -- node build/index.js mcp
```
- **`stop_app_sim`**: Stops an app on a simulator.
```bash
- npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool stop_app_sim --params '{"simulatorName": "iPhone 16", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`test_sim`**: Runs tests on a simulator.
```bash
- npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool test_sim --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme", "simulatorName": "iPhone 16"}' -- node build/index.js mcp
```
### Log Capture & Management
- **`start_device_log_cap`**: Starts log capture for a physical device.
```bash
- npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool start_device_log_cap --params '{"deviceId": "DEVICE_UDID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`start_sim_log_cap`**: Starts log capture for a simulator.
```bash
- npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool start_sim_log_cap --params '{"simulatorUuid": "SIMULATOR_UUID", "bundleId": "com.example.MyApp"}' -- node build/index.js mcp
```
- **`stop_device_log_cap`**: Stops log capture for a physical device.
```bash
- npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool stop_device_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js mcp
```
- **`stop_sim_log_cap`**: Stops log capture for a simulator.
```bash
- npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool stop_sim_log_cap --params '{"logSessionId": "SESSION_ID"}' -- node build/index.js mcp
```
### macOS Development
- **`build_macos`**: Builds a macOS app.
```bash
- npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool build_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
- **`build_run_macos`**: Builds and runs a macOS app.
```bash
- npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool build_run_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
- **`get_mac_app_path`**: Gets the `.app` bundle path for a macOS build.
```bash
- npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool get_mac_app_path --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
- **`launch_mac_app`**: Launches a macOS app.
```bash
- npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool launch_mac_app --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js mcp
```
- **`stop_mac_app`**: Stops a macOS app.
```bash
- npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool stop_mac_app --params '{"appName": "Calculator"}' -- node build/index.js mcp
```
- **`test_macos`**: Runs tests for a macOS project.
```bash
- npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool test_macos --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
### Project Discovery
- **`discover_projs`**: Discovers Xcode projects and workspaces.
```bash
- npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool discover_projs --params '{"workspaceRoot": "/path/to/workspace"}' -- node build/index.js mcp
```
- **`get_app_bundle_id`**: Gets an app's bundle identifier.
```bash
- npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool get_app_bundle_id --params '{"appPath": "/path/to/MyApp.app"}' -- node build/index.js mcp
```
- **`get_mac_bundle_id`**: Gets a macOS app's bundle identifier.
```bash
- npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool get_mac_bundle_id --params '{"appPath": "/Applications/Calculator.app"}' -- node build/index.js mcp
```
- **`list_schemes`**: Lists schemes in a project or workspace.
```bash
- npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool list_schemes --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js mcp
```
- **`show_build_settings`**: Shows build settings for a scheme.
```bash
- npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool show_build_settings --params '{"projectPath": "/path/to/MyProject.xcodeproj", "scheme": "MyScheme"}' -- node build/index.js mcp
```
### Project Scaffolding
- **`scaffold_ios_project`**: Scaffolds a new iOS project.
```bash
- npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool scaffold_ios_project --params '{"projectName": "MyNewApp", "outputPath": "/path/to/projects"}' -- node build/index.js mcp
```
- **`scaffold_macos_project`**: Scaffolds a new macOS project.
```bash
- npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool scaffold_macos_project --params '{"projectName": "MyNewMacApp", "outputPath": "/path/to/projects"}' -- node build/index.js mcp
```
### Project Utilities
@@ -181,122 +181,122 @@ npx reloaderoo@latest --help
- **`clean`**: Cleans build artifacts.
```bash
# For a project
- npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool clean --params '{"projectPath": "/path/to/MyProject.xcodeproj"}' -- node build/index.js mcp
# For a workspace
- npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool clean --params '{"workspacePath": "/path/to/MyWorkspace.xcworkspace", "scheme": "MyScheme"}' -- node build/index.js mcp
```
### Simulator Management
- **`reset_sim_location`**: Resets a simulator's location.
```bash
- npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool reset_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp
```
- **`set_sim_appearance`**: Sets a simulator's appearance (dark/light mode).
```bash
- npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool set_sim_appearance --params '{"simulatorUuid": "SIMULATOR_UUID", "mode": "dark"}' -- node build/index.js mcp
```
- **`set_sim_location`**: Sets a simulator's GPS location.
```bash
- npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool set_sim_location --params '{"simulatorUuid": "SIMULATOR_UUID", "latitude": 37.7749, "longitude": -122.4194}' -- node build/index.js mcp
```
- **`sim_statusbar`**: Overrides a simulator's status bar.
```bash
- npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool sim_statusbar --params '{"simulatorUuid": "SIMULATOR_UUID", "dataNetwork": "wifi"}' -- node build/index.js mcp
```
### Swift Package Manager
- **`swift_package_build`**: Builds a Swift package.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_build --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp
```
- **`swift_package_clean`**: Cleans a Swift package.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_clean --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp
```
- **`swift_package_list`**: Lists running Swift package processes.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_list --params '{}' -- node build/index.js mcp
```
- **`swift_package_run`**: Runs a Swift package executable.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_run --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp
```
- **`swift_package_stop`**: Stops a running Swift package process.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_stop --params '{"pid": 12345}' -- node build/index.js mcp
```
- **`swift_package_test`**: Tests a Swift package.
```bash
- npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swift_package_test --params '{"packagePath": "/path/to/package"}' -- node build/index.js mcp
```
### System Doctor
- **`doctor`**: Runs system diagnostics.
```bash
- npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool doctor --params '{}' -- node build/index.js mcp
```
### UI Testing & Automation
- **`button`**: Simulates a hardware button press.
```bash
- npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool button --params '{"simulatorUuid": "SIMULATOR_UUID", "buttonType": "home"}' -- node build/index.js mcp
```
- **`snapshot_ui`**: Gets the UI hierarchy of the current screen.
```bash
- npx reloaderoo@latest inspect call-tool snapshot_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool snapshot_ui --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp
```
- **`gesture`**: Performs a pre-defined gesture.
```bash
- npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool gesture --params '{"simulatorUuid": "SIMULATOR_UUID", "preset": "scroll-up"}' -- node build/index.js mcp
```
- **`key_press`**: Simulates a key press.
```bash
- npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool key_press --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCode": 40}' -- node build/index.js mcp
```
- **`key_sequence`**: Simulates a sequence of key presses.
```bash
- npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool key_sequence --params '{"simulatorUuid": "SIMULATOR_UUID", "keyCodes": [40, 42, 44]}' -- node build/index.js mcp
```
- **`long_press`**: Performs a long press at coordinates.
```bash
- npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool long_press --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "duration": 1500}' -- node build/index.js mcp
```
- **`screenshot`**: Takes a screenshot.
```bash
- npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool screenshot --params '{"simulatorUuid": "SIMULATOR_UUID"}' -- node build/index.js mcp
```
- **`swipe`**: Performs a swipe gesture.
```bash
- npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool swipe --params '{"simulatorUuid": "SIMULATOR_UUID", "x1": 100, "y1": 200, "x2": 100, "y2": 400}' -- node build/index.js mcp
```
- **`tap`**: Performs a tap at coordinates.
```bash
- npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool tap --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200}' -- node build/index.js mcp
```
- **`touch`**: Simulates a touch down or up event.
```bash
- npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool touch --params '{"simulatorUuid": "SIMULATOR_UUID", "x": 100, "y": 200, "down": true}' -- node build/index.js mcp
```
- **`type_text`**: Types text into the focused element.
```bash
- npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool type_text --params '{"simulatorUuid": "SIMULATOR_UUID", "text": "Hello, World!"}' -- node build/index.js mcp
```
### Resources
- **Read devices resource**:
```bash
- npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js
+ npx reloaderoo@latest inspect read-resource "xcodebuildmcp://devices" -- node build/index.js mcp
```
- **Read simulators resource**:
```bash
- npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js
+ npx reloaderoo@latest inspect read-resource "xcodebuildmcp://simulators" -- node build/index.js mcp
```
- **Read doctor resource**:
```bash
- npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js
+ npx reloaderoo@latest inspect read-resource "xcodebuildmcp://doctor" -- node build/index.js mcp
```
diff --git a/docs/dev/TESTING.md b/docs/dev/TESTING.md
index 100a6f78..5495f4ae 100644
--- a/docs/dev/TESTING.md
+++ b/docs/dev/TESTING.md
@@ -555,11 +555,11 @@ Black Box Testing means testing ONLY through external interfaces without any kno
### ABSOLUTE TESTING RULES - NO EXCEPTIONS
1. **✅ ONLY ALLOWED: Reloaderoo Inspect Commands**
- - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js`
- - `npx reloaderoo@latest inspect list-tools -- node build/index.js`
- - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js`
- - `npx reloaderoo@latest inspect server-info -- node build/index.js`
- - `npx reloaderoo@latest inspect ping -- node build/index.js`
+ - `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect list-tools -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect read-resource "URI" -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect server-info -- node build/index.js mcp`
+ - `npx reloaderoo@latest inspect ping -- node build/index.js mcp`
2. **❌ COMPLETELY FORBIDDEN ACTIONS:**
- **NEVER** call `mcp__XcodeBuildMCP__tool_name()` functions directly
@@ -581,8 +581,8 @@ Black Box Testing means testing ONLY through external interfaces without any kno
const result = await doctor();
// ✅ CORRECT - Only through Reloaderoo inspect
- npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
- npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+ npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
+ npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
```
### WHY RELOADEROO INSPECT IS MANDATORY
@@ -631,7 +631,7 @@ Some tools rely on in-memory state within the MCP server and will fail when test
#### Step 1: Create Complete Tool Inventory
```bash
# Generate complete list of all tools
-npx reloaderoo@latest inspect list-tools -- node build/index.js > /tmp/all_tools.json
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp > /tmp/all_tools.json
TOTAL_TOOLS=$(jq '.tools | length' /tmp/all_tools.json)
echo "TOTAL TOOLS TO TEST: $TOTAL_TOOLS"
@@ -653,7 +653,7 @@ jq -r '.tools[].name' /tmp/all_tools.json > /tmp/tool_names.txt
For EVERY tool in the list:
```bash
# Test each tool individually - NO BATCHING
-npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'APPROPRIATE_PARAMS' -- node build/index.js mcp
# Mark tool as completed in TodoWrite IMMEDIATELY after testing
# Record result (success/failure/blocked) for each tool
@@ -777,7 +777,7 @@ Must capture and document these values for dependent tools:
```bash
# Generate complete tool list with accurate count
-npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null > /tmp/tools.json
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null > /tmp/tools.json
# Get accurate tool count
TOOL_COUNT=$(jq '.tools | length' /tmp/tools.json)
@@ -792,7 +792,7 @@ echo "Tool names saved to /tmp/tool_names.txt"
```bash
# Generate complete resource list
-npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null > /tmp/resources.json
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null > /tmp/resources.json
# Get accurate resource count
RESOURCE_COUNT=$(jq '.resources | length' /tmp/resources.json)
@@ -905,7 +905,7 @@ echo "Tool schema reference created at /tmp/tool_schemas.md"
- Mark "completed" only after manual verification
2. **Test Each Tool Individually**
- - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js`
+ - Execute ONLY via `npx reloaderoo@latest inspect call-tool "TOOL_NAME" --params 'JSON' -- node build/index.js mcp`
- Wait for complete response before proceeding to next tool
- Read and verify each tool's output manually
- Record key outputs (UUIDs, paths, schemes) for dependent tools
@@ -929,16 +929,16 @@ echo "Tool schema reference created at /tmp/tool_schemas.md"
```bash
# Test server connectivity
-npx reloaderoo@latest inspect ping -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
# Get server information
-npx reloaderoo@latest inspect server-info -- node build/index.js
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
# Verify tool count manually
-npx reloaderoo@latest inspect list-tools -- node build/index.js 2>/dev/null | jq '.tools | length'
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp 2>/dev/null | jq '.tools | length'
# Verify resource count manually
-npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null | jq '.resources | length'
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp 2>/dev/null | jq '.resources | length'
```
#### Phase 2: Resource Testing
@@ -947,7 +947,7 @@ npx reloaderoo@latest inspect list-resources -- node build/index.js 2>/dev/null
# Test each resource systematically
while IFS= read -r resource_uri; do
echo "Testing resource: $resource_uri"
- npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js 2>/dev/null
+ npx reloaderoo@latest inspect read-resource "$resource_uri" -- node build/index.js mcp 2>/dev/null
echo "---"
done < /tmp/resource_uris.txt
```
@@ -961,23 +961,23 @@ echo "=== FOUNDATION TOOL TESTING & DATA COLLECTION ==="
# 1. Test doctor (no dependencies)
echo "Testing doctor..."
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp 2>/dev/null
# 2. Collect device data
echo "Collecting device UUIDs..."
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js 2>/dev/null > /tmp/devices_output.json
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/devices_output.json
DEVICE_UUIDS=$(jq -r '.content[0].text' /tmp/devices_output.json | grep -E "UDID: [A-F0-9-]+" | sed 's/.*UDID: //' | head -2)
echo "Device UUIDs captured: $DEVICE_UUIDS"
# 3. Collect simulator data
echo "Collecting simulator UUIDs..."
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js 2>/dev/null > /tmp/sims_output.json
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp 2>/dev/null > /tmp/sims_output.json
SIMULATOR_UUIDS=$(jq -r '.content[0].text' /tmp/sims_output.json | grep -E "\([A-F0-9-]+\)" | sed 's/.*(\([A-F0-9-]*\)).*/\1/' | head -3)
echo "Simulator UUIDs captured: $SIMULATOR_UUIDS"
# 4. Collect project data
echo "Collecting project paths..."
-npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js 2>/dev/null > /tmp/projects_output.json
+npx reloaderoo@latest inspect call-tool "discover_projs" --params '{"workspaceRoot": "/Volumes/Developer/XcodeBuildMCP"}' -- node build/index.js mcp 2>/dev/null > /tmp/projects_output.json
PROJECT_PATHS=$(jq -r '.content[1].text' /tmp/projects_output.json | grep -E "\.xcodeproj$" | sed 's/.*- //' | head -3)
WORKSPACE_PATHS=$(jq -r '.content[2].text' /tmp/projects_output.json | grep -E "\.xcworkspace$" | sed 's/.*- //' | head -2)
echo "Project paths captured: $PROJECT_PATHS"
@@ -999,7 +999,7 @@ echo "=== DISCOVERY TOOL TESTING & METADATA COLLECTION ==="
while IFS= read -r project_path; do
if [ -n "$project_path" ]; then
echo "Getting schemes for: $project_path"
- npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js 2>/dev/null > /tmp/schemes_$$.json
+ npx reloaderoo@latest inspect call-tool "list_schems_proj" --params "{\"projectPath\": \"$project_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/schemes_$$.json
SCHEMES=$(jq -r '.content[1].text' /tmp/schemes_$$.json 2>/dev/null || echo "NoScheme")
echo "$project_path|$SCHEMES" >> /tmp/project_schemes.txt
echo "Schemes captured for $project_path: $SCHEMES"
@@ -1010,7 +1010,7 @@ done < /tmp/project_paths.txt
while IFS= read -r workspace_path; do
if [ -n "$workspace_path" ]; then
echo "Getting schemes for: $workspace_path"
- npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js 2>/dev/null > /tmp/ws_schemes_$$.json
+ npx reloaderoo@latest inspect call-tool "list_schemes" --params "{\"workspacePath\": \"$workspace_path\"}" -- node build/index.js mcp 2>/dev/null > /tmp/ws_schemes_$$.json
SCHEMES=$(jq -r '.content[1].text' /tmp/ws_schemes_$$.json 2>/dev/null || echo "NoScheme")
echo "$workspace_path|$SCHEMES" >> /tmp/workspace_schemes.txt
echo "Schemes captured for $workspace_path: $SCHEMES"
@@ -1035,29 +1035,29 @@ done < /tmp/workspace_paths.txt
```bash
# STEP 1: Test foundation tools (no parameters required)
# Execute each command individually, wait for response, verify manually
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
# [Wait for response, read output, mark tool complete in task list]
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
# [Record device UUIDs from response for dependent tools]
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp
# [Record simulator UUIDs from response for dependent tools]
# STEP 2: Test project discovery (use discovered project paths)
-npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_schems_proj" --params '{"projectPath": "/actual/path/from/discover_projs.xcodeproj"}' -- node build/index.js mcp
# [Record scheme names from response for build tools]
# STEP 3: Test workspace tools (use discovered workspace paths)
-npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_schemes" --params '{"workspacePath": "/actual/path/from/discover_projs.xcworkspace"}' -- node build/index.js mcp
# [Record scheme names from response for build tools]
# STEP 4: Test simulator tools (use captured simulator UUIDs from step 1)
-npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "boot_sim" --params '{"simulatorUuid": "ACTUAL_UUID_FROM_LIST_SIMS"}' -- node build/index.js mcp
# [Verify simulator boots successfully]
# STEP 5: Test build tools (requires project + scheme + simulator from previous steps)
-npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectPath": "/actual/project.xcodeproj", "scheme": "ActualSchemeName", "simulatorId": "ACTUAL_SIMULATOR_UUID"}' -- node build/index.js mcp
# [Verify build succeeds and record app bundle path]
```
@@ -1083,7 +1083,7 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP
```bash
# ❌ IMMEDIATE TERMINATION - Using scripts to test tools
for tool in $(cat tool_list.txt); do
- npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js
+ npx reloaderoo inspect call-tool "$tool" --params '{}' -- node build/index.js mcp
done
```
@@ -1109,19 +1109,19 @@ npx reloaderoo@latest inspect call-tool "build_sim_id_proj" --params '{"projectP
```bash
# ✅ CORRECT - Step-by-step manual execution via Reloaderoo
# Tool 1: Test doctor
-npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "doctor" --params '{}' -- node build/index.js mcp
# [Read response, verify, mark complete in TodoWrite]
# Tool 2: Test list_devices
-npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_devices" --params '{}' -- node build/index.js mcp
# [Read response, capture UUIDs, mark complete in TodoWrite]
# Tool 3: Test list_sims
-npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "list_sims" --params '{}' -- node build/index.js mcp
# [Read response, capture UUIDs, mark complete in TodoWrite]
# Tool X: Test stateful tool (expected to fail)
-npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js
+npx reloaderoo@latest inspect call-tool "swift_package_stop" --params '{"pid": 12345}' -- node build/index.js mcp
# [Tool fails as expected - no in-memory state available]
# [Mark as "false negative - stateful tool limitation" in TodoWrite]
# [Continue to next tool without investigation]
@@ -1146,15 +1146,15 @@ echo "=== Error Testing ==="
# Test with invalid JSON parameters
echo "Testing invalid parameter types..."
-npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": 123}' -- node build/index.js mcp 2>/dev/null
# Test with non-existent paths
echo "Testing non-existent paths..."
-npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool list_schems_proj --params '{"projectPath": "/nonexistent/path.xcodeproj"}' -- node build/index.js mcp 2>/dev/null
# Test with invalid UUIDs
echo "Testing invalid UUIDs..."
-npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js 2>/dev/null
+npx reloaderoo@latest inspect call-tool boot_sim --params '{"simulatorUuid": "invalid-uuid"}' -- node build/index.js mcp 2>/dev/null
```
### Step 5: Generate Testing Report
@@ -1190,12 +1190,12 @@ echo "Testing session template created: TESTING_SESSION_$(date +%Y-%m-%d).md"
```bash
# Essential testing commands
-npx reloaderoo@latest inspect ping -- node build/index.js
-npx reloaderoo@latest inspect server-info -- node build/index.js
-npx reloaderoo@latest inspect list-tools -- node build/index.js | jq '.tools | length'
-npx reloaderoo@latest inspect list-resources -- node build/index.js | jq '.resources | length'
-npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js
-npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js
+npx reloaderoo@latest inspect ping -- node build/index.js mcp
+npx reloaderoo@latest inspect server-info -- node build/index.js mcp
+npx reloaderoo@latest inspect list-tools -- node build/index.js mcp | jq '.tools | length'
+npx reloaderoo@latest inspect list-resources -- node build/index.js mcp | jq '.resources | length'
+npx reloaderoo@latest inspect call-tool TOOL_NAME --params '{}' -- node build/index.js mcp
+npx reloaderoo@latest inspect read-resource "xcodebuildmcp://RESOURCE" -- node build/index.js mcp
# Schema extraction
jq --arg tool "TOOL_NAME" '.tools[] | select(.name == $tool) | .inputSchema' /tmp/tools.json
diff --git a/docs/dev/oracle-prompt-workspace-daemon.md b/docs/dev/oracle-prompt-workspace-daemon.md
new file mode 100644
index 00000000..c409ff91
--- /dev/null
+++ b/docs/dev/oracle-prompt-workspace-daemon.md
@@ -0,0 +1,2608 @@
+
+/Volumes/Developer/XcodeBuildMCP
+├── docs
+│ ├── dev
+│ │ ├── CLI_CONVERSION_PLAN.md *
+│ │ ├── ARCHITECTURE.md
+│ │ ├── CODE_QUALITY.md
+│ │ ├── CONTRIBUTING.md
+│ │ ├── ESLINT_TYPE_SAFETY.md
+│ │ ├── MANUAL_TESTING.md
+│ │ ├── NODEJS_2025.md
+│ │ ├── PLUGIN_DEVELOPMENT.md
+│ │ ├── PROJECT_CONFIG_PLAN.md
+│ │ ├── README.md
+│ │ ├── RELEASE_PROCESS.md
+│ │ ├── RELOADEROO.md
+│ │ ├── RELOADEROO_FOR_XCODEBUILDMCP.md
+│ │ ├── RELOADEROO_XCODEBUILDMCP_PRIMER.md
+│ │ ├── SMITHERY.md
+│ │ ├── SMITHERY_PACKAGING_CONTEXT.md
+│ │ ├── TESTING.md
+│ │ ├── TEST_RUNNER_ENV_IMPLEMENTATION_PLAN.md
+│ │ ├── ZOD_MIGRATION_GUIDE.md
+│ │ ├── session-aware-migration-todo.md
+│ │ ├── session_management_plan.md
+│ │ ├── tools_cli_schema_audit_plan.md
+│ │ └── tools_schema_redundancy.md
+│ ├── investigations
+│ │ ├── issue-154-screenshot-downscaling.md
+│ │ ├── issue-163.md
+│ │ ├── issue-debugger-attach-stopped.md
+│ │ └── issue-describe-ui-empty-after-debugger-resume.md
+│ ├── CLI.md
+│ ├── CONFIGURATION.md
+│ ├── DAP_BACKEND_IMPLEMENTATION_PLAN.md
+│ ├── DEBUGGING_ARCHITECTURE.md
+│ ├── DEMOS.md
+│ ├── DEVICE_CODE_SIGNING.md
+│ ├── GETTING_STARTED.md
+│ ├── OVERVIEW.md
+│ ├── PRIVACY.md
+│ ├── README.md
+│ ├── SESSION_DEFAULTS.md
+│ ├── SKILLS.md
+│ ├── TOOLS.md
+│ └── TROUBLESHOOTING.md
+├── src
+│ ├── cli
+│ │ ├── commands
+│ │ │ ├── daemon.ts * +
+│ │ │ └── tools.ts +
+│ │ ├── daemon-client.ts * +
+│ │ ├── register-tool-commands.ts * +
+│ │ ├── yargs-app.ts * +
+│ │ ├── output.ts +
+│ │ └── schema-to-yargs.ts +
+│ ├── daemon
+│ │ ├── daemon-server.ts * +
+│ │ ├── socket-path.ts * +
+│ │ ├── framing.ts +
+│ │ └── protocol.ts +
+│ ├── runtime
+│ │ ├── naming.ts * +
+│ │ ├── tool-catalog.ts * +
+│ │ ├── tool-invoker.ts * +
+│ │ ├── types.ts * +
+│ │ └── bootstrap-runtime.ts +
+│ ├── core
+│ │ ├── __tests__
+│ │ │ └── resources.test.ts +
+│ │ ├── generated-plugins.ts +
+│ │ ├── generated-resources.ts +
+│ │ ├── plugin-registry.ts +
+│ │ ├── plugin-types.ts +
+│ │ └── resources.ts +
+│ ├── mcp
+│ │ ├── resources
+│ │ │ ├── __tests__
+│ │ │ │ └── ...
+│ │ │ ├── devices.ts +
+│ │ │ ├── doctor.ts +
+│ │ │ ├── session-status.ts +
+│ │ │ └── simulators.ts +
+│ │ └── tools
+│ │ ├── debugging
+│ │ │ └── ...
+│ │ ├── device
+│ │ │ └── ...
+│ │ ├── doctor
+│ │ │ └── ...
+│ │ ├── logging
+│ │ │ └── ...
+│ │ ├── macos
+│ │ │ └── ...
+│ │ ├── project-discovery
+│ │ │ └── ...
+│ │ ├── project-scaffolding
+│ │ │ └── ...
+│ │ ├── session-management
+│ │ │ └── ...
+│ │ ├── simulator
+│ │ │ └── ...
+│ │ ├── simulator-management
+│ │ │ └── ...
+│ │ ├── swift-package
+│ │ │ └── ...
+│ │ ├── ui-automation
+│ │ │ └── ...
+│ │ ├── utilities
+│ │ │ └── ...
+│ │ └── workflow-discovery
+│ │ └── ...
+│ ├── server
+│ │ ├── bootstrap.ts +
+│ │ ├── server-state.ts +
+│ │ └── server.ts +
+│ ├── test-utils
+│ │ └── mock-executors.ts +
+│ ├── types
+│ │ └── common.ts +
+│ ├── utils
+│ │ ├── __tests__
+│ │ │ ├── build-utils-suppress-warnings.test.ts +
+│ │ │ ├── build-utils.test.ts +
+│ │ │ ├── config-store.test.ts +
+│ │ │ ├── debugger-simctl.test.ts +
+│ │ │ ├── environment.test.ts +
+│ │ │ ├── log_capture.test.ts +
+│ │ │ ├── project-config.test.ts +
+│ │ │ ├── session-aware-tool-factory.test.ts +
+│ │ │ ├── session-store.test.ts +
+│ │ │ ├── simulator-utils.test.ts +
+│ │ │ ├── test-runner-env-integration.test.ts +
+│ │ │ ├── typed-tool-factory.test.ts +
+│ │ │ └── workflow-selection.test.ts +
+│ │ ├── axe
+│ │ │ └── index.ts +
+│ │ ├── build
+│ │ │ └── index.ts +
+│ │ ├── debugger
+│ │ │ ├── __tests__
+│ │ │ │ └── ...
+│ │ │ ├── backends
+│ │ │ │ └── ...
+│ │ │ ├── dap
+│ │ │ │ └── ...
+│ │ │ ├── debugger-manager.ts +
+│ │ │ ├── index.ts +
+│ │ │ ├── simctl.ts +
+│ │ │ ├── tool-context.ts +
+│ │ │ ├── types.ts +
+│ │ │ └── ui-automation-guard.ts +
+│ │ ├── execution
+│ │ │ ├── index.ts +
+│ │ │ └── interactive-process.ts +
+│ │ ├── log-capture
+│ │ │ ├── device-log-sessions.ts +
+│ │ │ └── index.ts +
+│ │ ├── logging
+│ │ │ └── index.ts +
+│ │ ├── plugin-registry
+│ │ │ └── index.ts +
+│ │ ├── responses
+│ │ │ └── index.ts +
+│ │ ├── template
+│ │ │ └── index.ts +
+│ │ ├── test
+│ │ │ └── index.ts +
+│ │ ├── validation
+│ │ │ └── index.ts +
+│ │ ├── version
+│ │ │ └── index.ts +
+│ │ ├── video-capture
+│ │ │ └── index.ts +
+│ │ ├── xcodemake
+│ │ │ └── index.ts +
+│ │ ├── CommandExecutor.ts +
+│ │ ├── FileSystemExecutor.ts +
+│ │ ├── axe-helpers.ts +
+│ │ ├── build-utils.ts +
+│ │ ├── capabilities.ts
+│ │ ├── command.ts +
+│ │ ├── config-store.ts +
+│ │ ├── environment.ts +
+│ │ ├── errors.ts +
+│ │ ├── log_capture.ts +
+│ │ ├── logger.ts +
+│ │ ├── project-config.ts +
+│ │ ├── remove-undefined.ts +
+│ │ ├── runtime-config-schema.ts +
+│ │ ├── runtime-config-types.ts +
+│ │ ├── schema-helpers.ts +
+│ │ ├── sentry.ts +
+│ │ ├── session-defaults-schema.ts +
+│ │ ├── session-status.ts +
+│ │ ├── session-store.ts +
+│ │ ├── simulator-utils.ts +
+│ │ ├── template-manager.ts +
+│ │ ├── test-common.ts +
+│ │ ├── tool-registry.ts +
+│ │ ├── typed-tool-factory.ts +
+│ │ ├── validation.ts +
+│ │ ├── video_capture.ts +
+│ │ ├── workflow-selection.ts +
+│ │ ├── xcode.ts +
+│ │ └── xcodemake.ts +
+│ ├── cli.ts * +
+│ ├── daemon.ts * +
+│ ├── doctor-cli.ts +
+│ ├── index.ts +
+│ ├── smithery.ts +
+│ └── version.ts +
+├── .claude
+│ ├── agents
+│ │ └── xcodebuild-mcp-qa-tester.md
+│ └── commands
+│ ├── rp-build-cli.md
+│ ├── rp-investigate-cli.md
+│ ├── rp-oracle-export-cli.md
+│ ├── rp-refactor-cli.md
+│ ├── rp-reminder-cli.md
+│ └── rp-review-cli.md
+├── .cursor
+│ ├── BUGBOT.md
+│ └── environment.json
+├── .github
+│ ├── ISSUE_TEMPLATE
+│ │ ├── bug_report.yml
+│ │ ├── config.yml
+│ │ └── feature_request.yml
+│ ├── workflows
+│ │ ├── README.md
+│ │ ├── ci.yml
+│ │ ├── release.yml
+│ │ ├── sentry.yml
+│ │ └── stale.yml
+│ └── FUNDING.yml
+├── build-plugins
+│ ├── plugin-discovery.js +
+│ ├── plugin-discovery.ts +
+│ └── tsconfig.json
+├── example_projects
+│ ├── iOS
+│ │ ├── .cursor
+│ │ │ └── rules
+│ │ │ └── ...
+│ │ ├── .xcodebuildmcp
+│ │ │ └── config.yaml
+│ │ ├── MCPTest
+│ │ │ ├── Assets.xcassets
+│ │ │ │ └── ...
+│ │ │ ├── Preview Content
+│ │ │ │ └── ...
+│ │ │ ├── ContentView.swift +
+│ │ │ └── MCPTestApp.swift +
+│ │ ├── MCPTest.xcodeproj
+│ │ │ ├── xcshareddata
+│ │ │ │ └── ...
+│ │ │ └── project.pbxproj
+│ │ └── MCPTestUITests
+│ │ └── MCPTestUITests.swift +
+│ ├── iOS_Calculator
+│ │ ├── .xcodebuildmcp
+│ │ │ └── config.yaml
+│ │ ├── CalculatorApp
+│ │ │ ├── Assets.xcassets
+│ │ │ │ └── ...
+│ │ │ ├── CalculatorApp.swift +
+│ │ │ └── CalculatorApp.xctestplan
+│ │ ├── CalculatorApp.xcodeproj
+│ │ │ ├── xcshareddata
+│ │ │ │ └── ...
+│ │ │ └── project.pbxproj
+│ │ ├── CalculatorApp.xcworkspace
+│ │ │ └── contents.xcworkspacedata
+│ │ ├── CalculatorAppPackage
+│ │ │ ├── Sources
+│ │ │ │ └── ...
+│ │ │ ├── Tests
+│ │ │ │ └── ...
+│ │ │ ├── .gitignore
+│ │ │ └── Package.swift +
+│ │ ├── CalculatorAppTests
+│ │ │ └── CalculatorAppTests.swift +
+│ │ ├── Config
+│ │ │ ├── Debug.xcconfig
+│ │ │ ├── Release.xcconfig
+│ │ │ ├── Shared.xcconfig
+│ │ │ └── Tests.xcconfig
+│ │ └── .gitignore
+│ ├── macOS
+│ │ ├── MCPTest
+│ │ │ ├── Assets.xcassets
+│ │ │ │ └── ...
+│ │ │ ├── Preview Content
+│ │ │ │ └── ...
+│ │ │ ├── ContentView.swift +
+│ │ │ ├── MCPTest.entitlements
+│ │ │ └── MCPTestApp.swift +
+│ │ ├── MCPTest.xcodeproj
+│ │ │ ├── xcshareddata
+│ │ │ │ └── ...
+│ │ │ └── project.pbxproj
+│ │ └── MCPTestTests
+│ │ └── MCPTestTests.swift +
+│ └── spm
+│ ├── Sources
+│ │ ├── TestLib
+│ │ │ └── ...
+│ │ ├── long-server
+│ │ │ └── ...
+│ │ ├── quick-task
+│ │ │ └── ...
+│ │ └── spm
+│ │ └── ...
+│ ├── Tests
+│ │ └── TestLibTests
+│ │ └── ...
+│ ├── .gitignore
+│ ├── Package.resolved
+│ └── Package.swift +
+├── scripts
+│ ├── analysis
+│ │ ├── tools-analysis.ts +
+│ │ └── tools-schema-audit.ts +
+│ ├── bundle-axe.sh
+│ ├── check-code-patterns.js +
+│ ├── generate-loaders.ts +
+│ ├── generate-version.ts +
+│ ├── install-skill.sh
+│ ├── release.sh
+│ ├── tools-cli.ts +
+│ ├── update-tools-docs.ts +
+│ └── verify-smithery-bundle.sh
+├── skills
+│ └── xcodebuildmcp
+│ └── SKILL.md
+├── .axe-version
+├── .gitignore
+├── .prettierignore
+├── .prettierrc.js
+├── .repomix-output.txt
+├── AGENTS.md
+├── CHANGELOG.md
+├── CLAUDE.md
+├── CODE_OF_CONDUCT.md
+├── LICENSE
+├── README.md
+├── XcodeBuildMCP.code-workspace
+├── banner.png
+├── config.example.yaml
+├── eslint.config.js +
+├── mcp-install-dark.png
+├── package-lock.json
+├── package.json
+├── server.json
+├── smithery.yaml
+├── tsconfig.json
+├── tsconfig.test.json
+├── tsconfig.tests.json
+├── tsup.config.ts +
+└── vitest.config.ts +
+
+/Users/cameroncooke/.codex/skills
+├── .claude
+│ └── commands
+│ ├── rp-build-cli.md
+│ ├── rp-investigate-cli.md
+│ ├── rp-oracle-export-cli.md
+│ ├── rp-refactor-cli.md
+│ ├── rp-reminder-cli.md
+│ └── rp-review-cli.md
+├── .system
+│ ├── skill-creator
+│ │ ├── scripts
+│ │ │ ├── init_skill.py +
+│ │ │ ├── package_skill.py +
+│ │ │ └── quick_validate.py +
+│ │ ├── SKILL.md
+│ │ └── license.txt
+│ ├── skill-installer
+│ │ ├── scripts
+│ │ │ ├── github_utils.py +
+│ │ │ ├── install-skill-from-github.py +
+│ │ │ └── list-curated-skills.py +
+│ │ ├── LICENSE.txt
+│ │ └── SKILL.md
+│ └── .codex-system-skills.marker
+└── public
+ ├── agent-browser
+ │ └── SKILL.md
+ ├── agents-md
+ │ └── SKILL.md
+ ├── app-store-changelog
+ │ ├── references
+ │ │ └── release-notes-guidelines.md
+ │ ├── scripts
+ │ │ └── collect_release_changes.sh
+ │ └── SKILL.md
+ ├── brand-guidelines
+ │ └── SKILL.md
+ ├── claude-settings-audit
+ │ └── SKILL.md
+ ├── code-review
+ │ └── SKILL.md
+ ├── code-simplifier
+ │ └── SKILL.md
+ ├── commit
+ │ └── SKILL.md
+ ├── create-pr
+ │ └── SKILL.md
+ ├── doc-coauthoring
+ │ └── SKILL.md
+ ├── find-bugs
+ │ └── SKILL.md
+ ├── gh-issue-fix-flow
+ │ └── SKILL.md
+ ├── ios-debugger-agent
+ │ └── SKILL.md
+ ├── iterate-pr
+ │ └── SKILL.md
+ ├── macos-spm-app-packaging
+ │ ├── assets
+ │ │ └── templates
+ │ │ └── ...
+ │ ├── references
+ │ │ ├── packaging.md
+ │ │ ├── release.md
+ │ │ └── scaffold.md
+ │ └── SKILL.md
+ ├── swift-concurrency-expert
+ │ ├── references
+ │ │ ├── approachable-concurrency.md
+ │ │ ├── swift-6-2-concurrency.md
+ │ │ └── swiftui-concurrency-tour-wwdc.md
+ │ └── SKILL.md
+ ├── swiftui-liquid-glass
+ │ ├── references
+ │ │ └── liquid-glass.md
+ │ └── SKILL.md
+ ├── swiftui-performance-audit
+ │ ├── references
+ │ │ ├── demystify-swiftui-performance-wwdc23.md
+ │ │ ├── optimizing-swiftui-performance-instruments.md
+ │ │ ├── understanding-hangs-in-your-app.md
+ │ │ └── understanding-improving-swiftui-performance.md
+ │ └── SKILL.md
+ ├── swiftui-ui-patterns
+ │ ├── references
+ │ │ ├── app-wiring.md
+ │ │ ├── components-index.md
+ │ │ ├── controls.md
+ │ │ ├── deeplinks.md
+ │ │ ├── focus.md
+ │ │ ├── form.md
+ │ │ ├── grids.md
+ │ │ ├── haptics.md
+ │ │ ├── input-toolbar.md
+ │ │ ├── lightweight-clients.md
+ │ │ ├── list.md
+ │ │ ├── loading-placeholders.md
+ │ │ ├── macos-settings.md
+ │ │ ├── matched-transitions.md
+ │ │ ├── media.md
+ │ │ ├── menu-bar.md
+ │ │ ├── navigationstack.md
+ │ │ ├── overlay.md
+ │ │ ├── scrollview.md
+ │ │ ├── searchable.md
+ │ │ ├── sheets.md
+ │ │ ├── split-views.md
+ │ │ ├── tabview.md
+ │ │ ├── theming.md
+ │ │ ├── title-menus.md
+ │ │ └── top-bar.md
+ │ └── SKILL.md
+ ├── swiftui-view-refactor
+ │ ├── references
+ │ │ └── mv-patterns.md
+ │ └── SKILL.md
+ └── xcodebuildmcp
+ └── SKILL.md
+
+/Users/cameroncooke/Developer/AGENT
+├── ideas
+│ ├── XcodeBuildMCP-installer.md
+│ └── XcodeBuildMCP.md
+└── improvements
+ ├── MCPLI.md
+ ├── Peekaboo.md
+ ├── Poltergeist.md
+ ├── XcodeBuildMCP.md
+ ├── macos-spm-app-packaging.md
+ ├── update_skills.md
+ ├── update_skills.sh.md
+ └── xcodebuildmcp-debugger-attach-stop.md
+
+
+(* denotes selected files)
+(+ denotes code-map available)
+Config: depth cap 3.
+
+
+File: /Volumes/Developer/XcodeBuildMCP/docs/dev/CLI_CONVERSION_PLAN.md
+```md
+# XcodeBuildMCP CLI Conversion Plan
+
+This document outlines the architectural plan to convert XcodeBuildMCP into a first-class CLI tool (`xcodebuildcli`) while maintaining full MCP server compatibility.
+
+## Overview
+
+### Goals
+
+1. **First-class CLI**: Separate CLI binary (`xcodebuildcli`) that invokes tools and exits
+2. **MCP server unchanged**: `xcodebuildmcp` remains the long-lived stdio MCP server
+3. **Shared tool logic**: All three runtimes (MCP, CLI, daemon) invoke the same underlying tool handlers
+4. **Session defaults parity**: Identical behavior in all modes
+5. **Stateful operation support**: Full daemon architecture for log capture, video recording, debugging, SwiftPM background
+
+### Non-Goals
+
+- Breaking existing MCP client integrations
+- Changing the MCP protocol or tool schemas
+- Wrapping MCP inside CLI (architecturally wrong)
+
+---
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| CLI Framework | yargs | Better dynamic command generation, strict validation, array support |
+| Stateful Support | Full daemon | Unix domain socket for complete multi-step stateful operations |
+| Daemon Communication | Unix domain socket | macOS only, simple protocol, reliable |
+| Stateful Tools Priority | All equally | Logging, video, debugging, SwiftPM all route to daemon |
+| Tool Name Format | kebab-case | CLI-friendly, disambiguated when collisions exist |
+| CLI Binary Name | `xcodebuildcli` | Distinct from MCP server binary |
+
+---
+
+## Target Runtime Model
+
+### Entry Points
+
+| Binary | Entry Point | Description |
+|--------|-------------|-------------|
+| `xcodebuildmcp` | `src/index.ts` | MCP server (stdio, long-lived) - unchanged |
+| `xcodebuildcli` | `src/cli.ts` | CLI (short-lived, exits after action) |
+| Internal | `src/daemon.ts` | Daemon (Unix socket server, long-lived) |
+
+### Execution Modes
+
+- **Stateless tools**: CLI runs tools **in-process** by default (fast path)
+- **Stateful tools** (log capture, video, debugging, SwiftPM background): CLI routes to **daemon** over Unix domain socket
+
+### Naming Rules
+
+- CLI tool names are **kebab-case**
+- Internal MCP tool names remain **unchanged** (e.g., `build_sim`, `start_sim_log_cap`)
+- CLI tool names are **derived** from MCP tool names, **disambiguated** when duplicates exist
+
+**Disambiguation rule:**
+- If a tool's kebab-name is unique across enabled workflows: use it (e.g., `build-sim`)
+- If duplicated across workflows (e.g., `clean` exists in multiple): CLI name becomes `-` (e.g., `simulator-clean`, `device-clean`)
+
+---
+
+## Directory Structure
+
+### New Files
+
+```
+src/
+ cli.ts # xcodebuildcli entry point (yargs)
+ daemon.ts # daemon entry point (unix socket server)
+ runtime/
+ bootstrap-runtime.ts # shared runtime bootstrap (config + session defaults)
+ naming.ts # kebab-case + disambiguation + arg key transforms
+ tool-catalog.ts # loads workflows/tools, builds ToolCatalog with cliName mapping
+ tool-invoker.ts # shared "invoke tool by cliName" implementation
+ types.ts # shared core interfaces (ToolDefinition, ToolCatalog, Invoker)
+ daemon/
+ protocol.ts # daemon protocol types (request/response, errors)
+ framing.ts # length-prefixed framing helpers for net.Socket
+ socket-path.ts # resolves default socket path + ensures dirs + cleanup
+ daemon-server.ts # Unix socket server + request router
+ cli/
+ yargs-app.ts # builds yargs instance, registers commands
+ daemon-client.ts # CLI -> daemon client (unix socket, protocol)
+ commands/
+ daemon.ts # yargs commands: daemon start/stop/status/restart
+ tools.ts # yargs command: tools (list available tool commands)
+ register-tool-commands.ts # auto-register tool commands from schemas
+ schema-to-yargs.ts # converts Zod schema shape -> yargs options
+ output.ts # prints ToolResponse to terminal
+```
+
+### Modified Files
+
+- `src/server/bootstrap.ts` - Refactor to use shared runtime bootstrap
+- `src/core/plugin-types.ts` - Extend `PluginMeta` with optional CLI metadata
+- `tsup.config.ts` - Add `cli` and `daemon` entries
+- `package.json` - Add `xcodebuildcli` bin, add yargs dependency
+
+---
+
+## Core Interfaces
+
+### Tool Definition and Catalog
+
+**File:** `src/runtime/types.ts`
+
+```typescript
+import type * as z from 'zod';
+import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
+import type { ToolResponse } from '../types/common.ts';
+import type { ToolSchemaShape, PluginMeta } from '../core/plugin-types.ts';
+
+export type RuntimeKind = 'cli' | 'daemon' | 'mcp';
+
+export interface ToolDefinition {
+ /** Stable CLI command name (kebab-case, disambiguated) */
+ cliName: string;
+
+ /** Original MCP tool name as declared today (unchanged) */
+ mcpName: string;
+
+ /** Workflow directory name (e.g., "simulator", "device", "logging") */
+ workflow: string;
+
+ description?: string;
+ annotations?: ToolAnnotations;
+
+ /**
+ * Schema shape used to generate yargs flags for CLI.
+ * Must include ALL parameters (not the session-default-hidden version).
+ */
+ cliSchema: ToolSchemaShape;
+
+ /**
+ * Schema shape used for MCP registration (what you already have).
+ */
+ mcpSchema: ToolSchemaShape;
+
+ /**
+ * Whether CLI MUST route this tool to the daemon (stateful operations).
+ */
+ stateful: boolean;
+
+ /**
+ * Shared handler (same used by MCP today). No duplication.
+ */
+ handler: PluginMeta['handler'];
+}
+
+export interface ToolCatalog {
+ tools: ToolDefinition[];
+ getByCliName(name: string): ToolDefinition | null;
+ resolve(input: string): { tool?: ToolDefinition; ambiguous?: string[]; notFound?: boolean };
+}
+
+export interface InvokeOptions {
+ runtime: RuntimeKind;
+ enabledWorkflows?: string[];
+ forceDaemon?: boolean;
+ socketPath?: string;
+}
+
+export interface ToolInvoker {
+ invoke(toolName: string, args: Record, opts: InvokeOptions): Promise;
+}
+```
+
+### Plugin CLI Metadata Extension
+
+**File:** `src/core/plugin-types.ts` (modify)
+
+```typescript
+export interface PluginCliMeta {
+ /** Optional override of derived CLI name */
+ name?: string;
+ /** Full schema shape for CLI flag generation (legacy, includes session-managed fields) */
+ schema?: ToolSchemaShape;
+ /** Mark tool as requiring daemon routing */
+ stateful?: boolean;
+}
+
+export interface PluginMeta {
+ readonly name: string;
+ readonly schema: ToolSchemaShape;
+ readonly description?: string;
+ readonly annotations?: ToolAnnotations;
+ readonly cli?: PluginCliMeta; // NEW (optional)
+ handler(params: Record): Promise;
+}
+```
+
+### Daemon Protocol
+
+**File:** `src/daemon/protocol.ts`
+
+```typescript
+export const DAEMON_PROTOCOL_VERSION = 1 as const;
+
+export type DaemonMethod =
+ | 'daemon.status'
+ | 'daemon.stop'
+ | 'tool.list'
+ | 'tool.invoke';
+
+export interface DaemonRequest {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ method: DaemonMethod;
+ params?: TParams;
+}
+
+export type DaemonErrorCode =
+ | 'BAD_REQUEST'
+ | 'NOT_FOUND'
+ | 'AMBIGUOUS_TOOL'
+ | 'TOOL_FAILED'
+ | 'INTERNAL';
+
+export interface DaemonError {
+ code: DaemonErrorCode;
+ message: string;
+ data?: unknown;
+}
+
+export interface DaemonResponse {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ result?: TResult;
+ error?: DaemonError;
+}
+
+export interface ToolInvokeParams {
+ tool: string;
+ args: Record;
+}
+
+export interface ToolInvokeResult {
+ response: unknown;
+}
+
+export interface DaemonStatusResult {
+ pid: number;
+ socketPath: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ toolCount: number;
+}
+```
+
+---
+
+## Shared Runtime Bootstrap
+
+**File:** `src/runtime/bootstrap-runtime.ts`
+
+```typescript
+import process from 'node:process';
+import { initConfigStore, getConfig, type RuntimeConfigOverrides } from '../utils/config-store.ts';
+import { sessionStore } from '../utils/session-store.ts';
+import { getDefaultFileSystemExecutor } from '../utils/command.ts';
+import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
+import type { RuntimeKind } from './types.ts';
+
+export interface BootstrapRuntimeOptions {
+ runtime: RuntimeKind;
+ cwd?: string;
+ fs?: FileSystemExecutor;
+ configOverrides?: RuntimeConfigOverrides;
+}
+
+export interface BootstrappedRuntime {
+ runtime: RuntimeKind;
+ cwd: string;
+ config: ReturnType;
+}
+
+export async function bootstrapRuntime(opts: BootstrapRuntimeOptions): Promise {
+ const cwd = opts.cwd ?? process.cwd();
+ const fs = opts.fs ?? getDefaultFileSystemExecutor();
+
+ await initConfigStore({ cwd, fs, overrides: opts.configOverrides });
+
+ const config = getConfig();
+
+ const defaults = config.sessionDefaults ?? {};
+ if (Object.keys(defaults).length > 0) {
+ sessionStore.setDefaults(defaults);
+ }
+
+ return { runtime: opts.runtime, cwd, config };
+}
+```
+
+---
+
+## Tool Catalog
+
+**File:** `src/runtime/tool-catalog.ts`
+
+```typescript
+import { loadWorkflowGroups } from '../core/plugin-registry.ts';
+import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts';
+import type { ToolCatalog, ToolDefinition } from './types.ts';
+import { toKebabCase, disambiguateCliNames } from './naming.ts';
+
+export async function buildToolCatalog(opts: {
+ enabledWorkflows: string[];
+}): Promise {
+ const workflowGroups = await loadWorkflowGroups();
+ const selection = resolveSelectedWorkflows(opts.enabledWorkflows, workflowGroups);
+
+ const tools: ToolDefinition[] = [];
+
+ for (const wf of selection.selectedWorkflows) {
+ for (const tool of wf.tools) {
+ const baseCliName = tool.cli?.name ?? toKebabCase(tool.name);
+ tools.push({
+ cliName: baseCliName,
+ mcpName: tool.name,
+ workflow: wf.directoryName,
+ description: tool.description,
+ annotations: tool.annotations,
+ mcpSchema: tool.schema,
+ cliSchema: tool.cli?.schema ?? tool.schema,
+ stateful: Boolean(tool.cli?.stateful),
+ handler: tool.handler,
+ });
+ }
+ }
+
+ const disambiguated = disambiguateCliNames(tools);
+
+ return {
+ tools: disambiguated,
+ getByCliName(name) {
+ return disambiguated.find((t) => t.cliName === name) ?? null;
+ },
+ resolve(input) {
+ const exact = disambiguated.filter((t) => t.cliName === input);
+ if (exact.length === 1) return { tool: exact[0] };
+
+ const aliasMatches = disambiguated.filter((t) => toKebabCase(t.mcpName) === input);
+ if (aliasMatches.length === 1) return { tool: aliasMatches[0] };
+ if (aliasMatches.length > 1) return { ambiguous: aliasMatches.map((t) => t.cliName) };
+
+ return { notFound: true };
+ },
+ };
+}
+```
+
+**File:** `src/runtime/naming.ts`
+
+```typescript
+import type { ToolDefinition } from './types.ts';
+
+export function toKebabCase(name: string): string {
+ return name
+ .trim()
+ .replace(/_/g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/[A-Z]/g, (m) => m.toLowerCase())
+ .toLowerCase();
+}
+
+export function disambiguateCliNames(tools: ToolDefinition[]): ToolDefinition[] {
+ const groups = new Map();
+ for (const t of tools) {
+ groups.set(t.cliName, [...(groups.get(t.cliName) ?? []), t]);
+ }
+
+ return tools.map((t) => {
+ const same = groups.get(t.cliName) ?? [];
+ if (same.length <= 1) return t;
+ return { ...t, cliName: `${t.workflow}-${t.cliName}` };
+ });
+}
+```
+
+---
+
+## Daemon Architecture
+
+### Socket Path
+
+**File:** `src/daemon/socket-path.ts`
+
+```typescript
+import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+export function defaultSocketPath(): string {
+ return join(homedir(), '.xcodebuildcli', 'daemon.sock');
+}
+
+export function ensureSocketDir(socketPath: string): void {
+ const dir = socketPath.split('/').slice(0, -1).join('/');
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
+}
+
+export function removeStaleSocket(socketPath: string): void {
+ if (existsSync(socketPath)) unlinkSync(socketPath);
+}
+```
+
+### Length-Prefixed Framing
+
+**File:** `src/daemon/framing.ts`
+
+```typescript
+import type net from 'node:net';
+
+export function writeFrame(socket: net.Socket, obj: unknown): void {
+ const json = Buffer.from(JSON.stringify(obj), 'utf8');
+ const header = Buffer.alloc(4);
+ header.writeUInt32BE(json.length, 0);
+ socket.write(Buffer.concat([header, json]));
+}
+
+export function createFrameReader(onMessage: (msg: unknown) => void) {
+ let buffer = Buffer.alloc(0);
+
+ return (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk]);
+
+ while (buffer.length >= 4) {
+ const len = buffer.readUInt32BE(0);
+ if (buffer.length < 4 + len) return;
+
+ const payload = buffer.subarray(4, 4 + len);
+ buffer = buffer.subarray(4 + len);
+
+ const msg = JSON.parse(payload.toString('utf8'));
+ onMessage(msg);
+ }
+ };
+}
+```
+
+### Daemon Server
+
+**File:** `src/daemon/daemon-server.ts`
+
+```typescript
+import net from 'node:net';
+import { writeFrame, createFrameReader } from './framing.ts';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type { DaemonRequest, DaemonResponse, ToolInvokeParams } from './protocol.ts';
+import { DAEMON_PROTOCOL_VERSION } from './protocol.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+
+export interface DaemonServerContext {
+ socketPath: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ catalog: ToolCatalog;
+}
+
+export function startDaemonServer(ctx: DaemonServerContext): net.Server {
+ const invoker = new DefaultToolInvoker(ctx.catalog);
+
+ const server = net.createServer((socket) => {
+ const onData = createFrameReader(async (msg) => {
+ const req = msg as DaemonRequest;
+ const base = { v: DAEMON_PROTOCOL_VERSION, id: req?.id ?? 'unknown' };
+
+ try {
+ if (req.v !== DAEMON_PROTOCOL_VERSION) {
+ return writeFrame(socket, { ...base, error: { code: 'BAD_REQUEST', message: 'Unsupported protocol version' } });
+ }
+
+ switch (req.method) {
+ case 'daemon.status':
+ return writeFrame(socket, {
+ ...base,
+ result: {
+ pid: process.pid,
+ socketPath: ctx.socketPath,
+ startedAt: ctx.startedAt,
+ enabledWorkflows: ctx.enabledWorkflows,
+ toolCount: ctx.catalog.tools.length,
+ },
+ });
+
+ case 'daemon.stop':
+ writeFrame(socket, { ...base, result: { ok: true } });
+ server.close(() => process.exit(0));
+ return;
+
+ case 'tool.list':
+ return writeFrame(socket, {
+ ...base,
+ result: ctx.catalog.tools.map((t) => ({
+ name: t.cliName,
+ workflow: t.workflow,
+ description: t.description ?? '',
+ stateful: t.stateful,
+ })),
+ });
+
+ case 'tool.invoke': {
+ const params = req.params as ToolInvokeParams;
+ const response = await invoker.invoke(params.tool, params.args ?? {}, {
+ runtime: 'daemon',
+ enabledWorkflows: ctx.enabledWorkflows,
+ });
+ return writeFrame(socket, { ...base, result: { response } });
+ }
+
+ default:
+ return writeFrame(socket, { ...base, error: { code: 'BAD_REQUEST', message: `Unknown method` } });
+ }
+ } catch (error) {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'INTERNAL', message: error instanceof Error ? error.message : String(error) },
+ });
+ }
+ });
+
+ socket.on('data', onData);
+ });
+
+ return server;
+}
+```
+
+### Daemon Entry Point
+
+**File:** `src/daemon.ts`
+
+```typescript
+#!/usr/bin/env node
+import net from 'node:net';
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import { ensureSocketDir, defaultSocketPath, removeStaleSocket } from './daemon/socket-path.ts';
+import { startDaemonServer } from './daemon/daemon-server.ts';
+
+async function main(): Promise {
+ const runtime = await bootstrapRuntime({ runtime: 'daemon' });
+ const socketPath = process.env.XCODEBUILDCLI_SOCKET ?? defaultSocketPath();
+
+ ensureSocketDir(socketPath);
+
+ try {
+ await new Promise((resolve, reject) => {
+ const s = net.createConnection(socketPath, () => {
+ s.end();
+ reject(new Error('Daemon already running'));
+ });
+ s.on('error', () => resolve());
+ });
+ } catch (e) {
+ throw e;
+ }
+
+ removeStaleSocket(socketPath);
+
+ const catalog = await buildToolCatalog({ enabledWorkflows: runtime.config.enabledWorkflows });
+
+ const server = startDaemonServer({
+ socketPath,
+ startedAt: new Date().toISOString(),
+ enabledWorkflows: runtime.config.enabledWorkflows,
+ catalog,
+ });
+
+ server.listen(socketPath);
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
+```
+
+---
+
+## CLI Architecture
+
+### CLI Entry Point
+
+**File:** `src/cli.ts`
+
+```typescript
+#!/usr/bin/env node
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import { buildYargsApp } from './cli/yargs-app.ts';
+
+async function main(): Promise {
+ const runtime = await bootstrapRuntime({ runtime: 'cli' });
+ const catalog = await buildToolCatalog({ enabledWorkflows: runtime.config.enabledWorkflows });
+
+ const yargsApp = buildYargsApp({ catalog, runtimeConfig: runtime.config });
+ await yargsApp.parseAsync();
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
+```
+
+### Yargs App
+
+**File:** `src/cli/yargs-app.ts`
+
+```typescript
+import yargs from 'yargs';
+import { hideBin } from 'yargs/helpers';
+import type { ToolCatalog } from '../runtime/types.ts';
+import { registerDaemonCommands } from './commands/daemon.ts';
+import { registerToolsCommand } from './commands/tools.ts';
+import { registerToolCommands } from './register-tool-commands.ts';
+import { version } from '../version.ts';
+
+export function buildYargsApp(opts: {
+ catalog: ToolCatalog;
+ runtimeConfig: { enabledWorkflows: string[] };
+}) {
+ const app = yargs(hideBin(process.argv))
+ .scriptName('xcodebuildcli')
+ .strict()
+ .recommendCommands()
+ .wrap(Math.min(120, yargs.terminalWidth()))
+ .parserConfiguration({
+ 'camel-case-expansion': true,
+ 'strip-dashed': true,
+ })
+ .option('socket', {
+ type: 'string',
+ describe: 'Override daemon unix socket path',
+ default: process.env.XCODEBUILDCLI_SOCKET,
+ })
+ .option('daemon', {
+ type: 'boolean',
+ describe: 'Force daemon execution even for stateless tools',
+ default: false,
+ })
+ .version(version)
+ .help();
+
+ registerDaemonCommands(app);
+ registerToolsCommand(app, opts.catalog);
+ registerToolCommands(app, opts.catalog);
+
+ return app;
+}
+```
+
+### Schema to Yargs Conversion
+
+**File:** `src/cli/schema-to-yargs.ts`
+
+```typescript
+import * as z from 'zod';
+
+export type YargsOpt =
+ | { type: 'string'; array?: boolean; choices?: string[]; describe?: string }
+ | { type: 'number'; array?: boolean; describe?: string }
+ | { type: 'boolean'; describe?: string };
+
+function unwrap(t: z.ZodTypeAny): z.ZodTypeAny {
+ if (t instanceof z.ZodOptional) return unwrap(t.unwrap());
+ if (t instanceof z.ZodNullable) return unwrap(t.unwrap());
+ if (t instanceof z.ZodDefault) return unwrap(t.removeDefault());
+ if (t instanceof z.ZodEffects) return unwrap(t.innerType());
+ return t;
+}
+
+export function zodToYargsOption(t: z.ZodTypeAny): YargsOpt | null {
+ const u = unwrap(t);
+
+ if (u instanceof z.ZodString) return { type: 'string' };
+ if (u instanceof z.ZodNumber) return { type: 'number' };
+ if (u instanceof z.ZodBoolean) return { type: 'boolean' };
+
+ if (u instanceof z.ZodEnum) return { type: 'string', choices: u.options };
+ if (u instanceof z.ZodNativeEnum) return { type: 'string', choices: Object.values(u.enum) as string[] };
+
+ if (u instanceof z.ZodArray) {
+ const inner = unwrap(u.element);
+ if (inner instanceof z.ZodString) return { type: 'string', array: true };
+ if (inner instanceof z.ZodNumber) return { type: 'number', array: true };
+ return null;
+ }
+
+ return null;
+}
+```
+
+### Tool Command Registration
+
+**File:** `src/cli/register-tool-commands.ts`
+
+```typescript
+import type { Argv } from 'yargs';
+import type { ToolCatalog } from '../runtime/types.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { zodToYargsOption } from './schema-to-yargs.ts';
+import { toKebabCase } from '../runtime/naming.ts';
+import { printToolResponse } from './output.ts';
+
+export function registerToolCommands(app: Argv, catalog: ToolCatalog): void {
+ const invoker = new DefaultToolInvoker(catalog);
+
+ for (const tool of catalog.tools) {
+ app.command(
+ tool.cliName,
+ tool.description ?? '',
+ (y) => {
+ y.option('json', {
+ type: 'string',
+ describe: 'JSON object of tool args (merged with flags)',
+ });
+
+ for (const [key, zt] of Object.entries(tool.cliSchema)) {
+ const opt = zodToYargsOption(zt as z.ZodTypeAny);
+ if (!opt) continue;
+
+ const flag = toKebabCase(key);
+ y.option(flag, {
+ type: opt.type,
+ array: (opt as { array?: boolean }).array,
+ choices: (opt as { choices?: string[] }).choices,
+ describe: (opt as { describe?: string }).describe,
+ });
+ }
+
+ return y;
+ },
+ async (argv) => {
+ const { json, socket, daemon, _, $0, ...rest } = argv as Record;
+
+ const jsonArgs = json ? (JSON.parse(String(json)) as Record) : {};
+ const flagArgs = rest as Record;
+ const args = { ...flagArgs, ...jsonArgs };
+
+ const response = await invoker.invoke(tool.cliName, args, {
+ runtime: 'cli',
+ forceDaemon: Boolean(daemon),
+ socketPath: socket as string | undefined,
+ });
+
+ printToolResponse(response);
+ },
+ );
+ }
+}
+```
+
+### CLI Output
+
+**File:** `src/cli/output.ts`
+
+```typescript
+import type { ToolResponse } from '../types/common.ts';
+
+export function printToolResponse(res: ToolResponse): void {
+ for (const item of res.content ?? []) {
+ if (item.type === 'text') {
+ console.log(item.text);
+ } else if (item.type === 'image') {
+ console.log(`[image ${item.mimeType}, ${item.data.length} bytes base64]`);
+ }
+ }
+ if (res.isError) process.exitCode = 1;
+}
+```
+
+---
+
+## Build Configuration
+
+### tsup.config.ts
+
+```typescript
+export default defineConfig({
+ entry: {
+ index: 'src/index.ts',
+ 'doctor-cli': 'src/doctor-cli.ts',
+ cli: 'src/cli.ts',
+ daemon: 'src/daemon.ts',
+ },
+ // ...existing config...
+});
+```
+
+### package.json
+
+```json
+{
+ "bin": {
+ "xcodebuildmcp": "build/index.js",
+ "xcodebuildmcp-doctor": "build/doctor-cli.js",
+ "xcodebuildcli": "build/cli.js"
+ },
+ "dependencies": {
+ "yargs": "^17.7.2"
+ }
+}
+```
+
+---
+
+## Implementation Phases
+
+### Phase 1: Foundation
+
+1. Add `src/runtime/bootstrap-runtime.ts`
+2. Refactor `src/server/bootstrap.ts` to call shared bootstrap
+3. Add `src/cli.ts`, `src/daemon.ts` entries to `tsup.config.ts`
+4. Add `xcodebuildcli` bin + `yargs` dependency in `package.json`
+
+**Result:** Builds produce `build/cli.js` and `build/daemon.js`, MCP server unchanged.
+
+### Phase 2: Tool Catalog + Direct CLI Invocation (Stateless)
+
+1. Implement `src/runtime/naming.ts`, `src/runtime/tool-catalog.ts`, `src/runtime/tool-invoker.ts`, `src/runtime/types.ts`
+2. Implement `src/cli/yargs-app.ts`, `src/cli/schema-to-yargs.ts`, `src/cli/register-tool-commands.ts`, `src/cli/output.ts`
+3. Add `xcodebuildcli tools` list command
+
+**Result:** `xcodebuildcli ` works for stateless tools in-process.
+
+### Phase 3: Daemon Protocol + Server + Client
+
+1. Implement `src/daemon/protocol.ts`, `src/daemon/framing.ts`, `src/daemon/socket-path.ts`
+2. Implement `src/daemon/daemon-server.ts` and wire into `src/daemon.ts`
+3. Implement `src/cli/daemon-client.ts`
+4. Implement `xcodebuildcli daemon start|stop|status|restart`
+
+**Result:** Daemon starts, responds to status, can invoke tools.
+
+### Phase 4: Stateful Routing
+
+1. Add `cli.stateful = true` metadata to all stateful tools (logging, video, debugging, swift-package background)
+2. Modify `DefaultToolInvoker` to require daemon when `tool.stateful === true`
+3. Add CLI auto-start behavior: if daemon required and not running, start it programmatically
+
+**Result:** Stateful commands run through daemon reliably; state persists across CLI invocations.
+
+### Phase 5: Full CLI Schema Coverage
+
+1. For all tools, ensure `tool.cli.schema` is present and complete
+2. Ensure schema-to-yargs supports all Zod types used (string/number/boolean/enum/array)
+3. Require complex/nested values via `--json` fallback
+
+**Result:** CLI is first-class with full native flags.
+
+---
+
+## Command Examples
+
+```bash
+# List available tools
+xcodebuildcli tools
+
+# Run stateless tool with native flags
+xcodebuildcli build-sim --scheme MyApp --project-path ./App.xcodeproj
+
+# Run tool with JSON input
+xcodebuildcli build-sim --json '{"scheme":"MyApp"}'
+
+# Daemon management
+xcodebuildcli daemon start
+xcodebuildcli daemon status
+xcodebuildcli daemon stop
+
+# Stateful tools (automatically route to daemon)
+xcodebuildcli start-sim-log-cap --simulator-id ABCD-1234
+xcodebuildcli stop-sim-log-cap --session-id xyz
+
+# Force daemon execution for any tool
+xcodebuildcli build-sim --daemon --scheme MyApp
+
+# Help
+xcodebuildcli --help
+xcodebuildcli build-sim --help
+```
+
+---
+
+## Invariants
+
+1. **MCP unchanged**: `xcodebuildmcp` continues to work exactly as before
+2. **Smithery unchanged**: `src/smithery.ts` continues to work
+3. **No code duplication**: CLI invokes same `PluginMeta.handler` functions
+4. **Session defaults identical**: All runtimes use `bootstrapRuntime()` → `sessionStore`
+5. **Tool logic shared**: `src/mcp/tools/*` remains single source of truth
+6. **Daemon is macOS-only**: Uses Unix domain sockets; CLI fails with clear error on non-macOS
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/daemon/socket-path.ts
+```ts
+import { mkdirSync, existsSync, unlinkSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join, dirname } from 'node:path';
+
+/**
+ * Get the default socket path for the daemon.
+ * Located in ~/.xcodebuildcli/daemon.sock
+ */
+export function defaultSocketPath(): string {
+ return join(homedir(), '.xcodebuildcli', 'daemon.sock');
+}
+
+/**
+ * Ensure the directory for the socket exists with proper permissions.
+ */
+export function ensureSocketDir(socketPath: string): void {
+ const dir = dirname(socketPath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+}
+
+/**
+ * Remove a stale socket file if it exists.
+ * Should only be called after confirming no daemon is running.
+ */
+export function removeStaleSocket(socketPath: string): void {
+ if (existsSync(socketPath)) {
+ unlinkSync(socketPath);
+ }
+}
+
+/**
+ * Get the socket path from environment or use default.
+ */
+export function getSocketPath(): string {
+ return process.env.XCODEBUILDCLI_SOCKET ?? defaultSocketPath();
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/runtime/tool-catalog.ts
+```ts
+import { loadWorkflowGroups } from '../core/plugin-registry.ts';
+import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts';
+import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts';
+import { toKebabCase, disambiguateCliNames } from './naming.ts';
+
+export async function buildToolCatalog(opts: {
+ enabledWorkflows: string[];
+}): Promise {
+ const workflowGroups = await loadWorkflowGroups();
+ const selection = resolveSelectedWorkflows(opts.enabledWorkflows, workflowGroups);
+
+ const tools: ToolDefinition[] = [];
+
+ for (const wf of selection.selectedWorkflows) {
+ for (const tool of wf.tools) {
+ const baseCliName = tool.cli?.name ?? toKebabCase(tool.name);
+ tools.push({
+ cliName: baseCliName, // Will be disambiguated below
+ mcpName: tool.name,
+ workflow: wf.directoryName,
+ description: tool.description,
+ annotations: tool.annotations,
+ mcpSchema: tool.schema,
+ cliSchema: tool.cli?.schema ?? tool.schema,
+ stateful: Boolean(tool.cli?.stateful),
+ handler: tool.handler,
+ });
+ }
+ }
+
+ const disambiguated = disambiguateCliNames(tools);
+
+ return createCatalog(disambiguated);
+}
+
+function createCatalog(tools: ToolDefinition[]): ToolCatalog {
+ // Build lookup maps for fast resolution
+ const byCliName = new Map();
+ const byMcpKebab = new Map();
+
+ for (const tool of tools) {
+ byCliName.set(tool.cliName, tool);
+
+ // Also index by the kebab-case of MCP name (for aliases)
+ const mcpKebab = toKebabCase(tool.mcpName);
+ const existing = byMcpKebab.get(mcpKebab) ?? [];
+ byMcpKebab.set(mcpKebab, [...existing, tool]);
+ }
+
+ return {
+ tools,
+
+ getByCliName(name: string): ToolDefinition | null {
+ return byCliName.get(name) ?? null;
+ },
+
+ resolve(input: string): ToolResolution {
+ const normalized = input.toLowerCase().trim();
+
+ // Try exact CLI name match first
+ const exact = byCliName.get(normalized);
+ if (exact) {
+ return { tool: exact };
+ }
+
+ // Try kebab-case of MCP name (alias)
+ const mcpKebab = toKebabCase(normalized);
+ const aliasMatches = byMcpKebab.get(mcpKebab);
+ if (aliasMatches && aliasMatches.length === 1) {
+ return { tool: aliasMatches[0] };
+ }
+ if (aliasMatches && aliasMatches.length > 1) {
+ return { ambiguous: aliasMatches.map((t) => t.cliName) };
+ }
+
+ // Try matching by MCP name directly (for underscore-style names)
+ const byMcpDirect = tools.find(
+ (t) => t.mcpName.toLowerCase() === normalized,
+ );
+ if (byMcpDirect) {
+ return { tool: byMcpDirect };
+ }
+
+ return { notFound: true };
+ },
+ };
+}
+
+/**
+ * Get a list of all available tool names for display.
+ */
+export function listToolNames(catalog: ToolCatalog): string[] {
+ return catalog.tools.map((t) => t.cliName).sort();
+}
+
+/**
+ * Get tools grouped by workflow for display.
+ */
+export function groupToolsByWorkflow(
+ catalog: ToolCatalog,
+): Map {
+ const groups = new Map();
+
+ for (const tool of catalog.tools) {
+ const existing = groups.get(tool.workflow) ?? [];
+ groups.set(tool.workflow, [...existing, tool]);
+ }
+
+ return groups;
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/cli.ts
+```ts
+#!/usr/bin/env node
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import { buildYargsApp } from './cli/yargs-app.ts';
+
+async function main(): Promise {
+ // CLI mode uses disableSessionDefaults to show all tool parameters as flags
+ const result = await bootstrapRuntime({
+ runtime: 'cli',
+ configOverrides: {
+ disableSessionDefaults: true,
+ },
+ });
+ const catalog = await buildToolCatalog({
+ enabledWorkflows: result.runtime.config.enabledWorkflows,
+ });
+
+ const yargsApp = buildYargsApp({
+ catalog,
+ runtimeConfig: result.runtime.config,
+ });
+
+ await yargsApp.parseAsync();
+}
+
+main().catch((err) => {
+ console.error(err instanceof Error ? err.message : String(err));
+ process.exit(1);
+});
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/runtime/naming.ts
+```ts
+import type { ToolDefinition } from './types.ts';
+
+/**
+ * Convert a tool name to kebab-case for CLI usage.
+ * Examples:
+ * build_sim -> build-sim
+ * startSimLogCap -> start-sim-log-cap
+ * BuildSimulator -> build-simulator
+ */
+export function toKebabCase(name: string): string {
+ return name
+ .trim()
+ // Replace underscores with hyphens
+ .replace(/_/g, '-')
+ // Insert hyphen before uppercase letters (for camelCase/PascalCase)
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
+ // Replace spaces with hyphens
+ .replace(/\s+/g, '-')
+ // Convert to lowercase
+ .toLowerCase()
+ // Remove any duplicate hyphens
+ .replace(/-+/g, '-')
+ // Trim leading/trailing hyphens
+ .replace(/^-|-$/g, '');
+}
+
+/**
+ * Convert kebab-case CLI flag back to camelCase for tool params.
+ * Examples:
+ * project-path -> projectPath
+ * simulator-name -> simulatorName
+ */
+export function toCamelCase(kebab: string): string {
+ return kebab.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
+}
+
+/**
+ * Disambiguate CLI names when duplicates exist across workflows.
+ * If multiple tools have the same kebab-case name, prefix with workflow name.
+ */
+export function disambiguateCliNames(tools: ToolDefinition[]): ToolDefinition[] {
+ // Group tools by their base CLI name
+ const groups = new Map();
+ for (const tool of tools) {
+ const existing = groups.get(tool.cliName) ?? [];
+ groups.set(tool.cliName, [...existing, tool]);
+ }
+
+ // Disambiguate tools that share the same CLI name
+ return tools.map((tool) => {
+ const sameNameTools = groups.get(tool.cliName) ?? [];
+ if (sameNameTools.length <= 1) {
+ return tool;
+ }
+
+ // Prefix with workflow name for disambiguation
+ const disambiguatedName = `${tool.workflow}-${tool.cliName}`;
+ return { ...tool, cliName: disambiguatedName };
+ });
+}
+
+/**
+ * Convert CLI argv keys (kebab-case) back to tool param keys (camelCase).
+ */
+export function convertArgvToToolParams(
+ argv: Record,
+): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(argv)) {
+ // Skip yargs internal keys
+ if (key === '_' || key === '$0') continue;
+ // Convert kebab-case to camelCase
+ const camelKey = toCamelCase(key);
+ result[camelKey] = value;
+ }
+ return result;
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/cli/commands/daemon.ts
+```ts
+import type { Argv } from 'yargs';
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { DaemonClient } from '../daemon-client.ts';
+import { getSocketPath } from '../../daemon/socket-path.ts';
+
+/**
+ * Get the path to the daemon executable.
+ */
+function getDaemonPath(): string {
+ // In the built output, daemon.js is in the same directory as cli.js
+ const currentFile = fileURLToPath(import.meta.url);
+ const buildDir = dirname(currentFile);
+ return resolve(buildDir, 'daemon.js');
+}
+
+/**
+ * Register daemon management commands.
+ */
+export function registerDaemonCommands(app: Argv): void {
+ app.command(
+ 'daemon ',
+ 'Manage the xcodebuildcli daemon',
+ (yargs) => {
+ return yargs
+ .positional('action', {
+ describe: 'Daemon action',
+ choices: ['start', 'stop', 'status', 'restart'] as const,
+ demandOption: true,
+ })
+ .option('foreground', {
+ alias: 'f',
+ type: 'boolean',
+ default: false,
+ describe: 'Run daemon in foreground (for debugging)',
+ });
+ },
+ async (argv) => {
+ const action = argv.action as string;
+ const socketPath = (argv.socket as string | undefined) ?? getSocketPath();
+ const client = new DaemonClient({ socketPath });
+
+ switch (action) {
+ case 'status':
+ await handleStatus(client);
+ break;
+ case 'stop':
+ await handleStop(client);
+ break;
+ case 'start':
+ await handleStart(socketPath, argv.foreground as boolean);
+ break;
+ case 'restart':
+ await handleRestart(client, socketPath, argv.foreground as boolean);
+ break;
+ }
+ },
+ );
+}
+
+async function handleStatus(client: DaemonClient): Promise {
+ try {
+ const status = await client.status();
+ console.log('Daemon Status: Running');
+ console.log(` PID: ${status.pid}`);
+ console.log(` Socket: ${status.socketPath}`);
+ console.log(` Started: ${status.startedAt}`);
+ console.log(` Tools: ${status.toolCount}`);
+ console.log(` Workflows: ${status.enabledWorkflows.join(', ') || '(default)'}`);
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('not running')) {
+ console.log('Daemon Status: Not running');
+ } else {
+ console.error('Error:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleStop(client: DaemonClient): Promise {
+ try {
+ await client.stop();
+ console.log('Daemon stopped');
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('not running')) {
+ console.log('Daemon is not running');
+ } else {
+ console.error('Error:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleStart(
+ socketPath: string,
+ foreground: boolean,
+): Promise {
+ const client = new DaemonClient({ socketPath });
+
+ // Check if already running
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ console.log('Daemon is already running');
+ return;
+ }
+
+ const daemonPath = getDaemonPath();
+
+ if (foreground) {
+ // Run in foreground (useful for debugging)
+ console.log('Starting daemon in foreground...');
+ console.log(`Socket: ${socketPath}`);
+ console.log('Press Ctrl+C to stop\n');
+
+ const child = spawn(process.execPath, [daemonPath], {
+ stdio: 'inherit',
+ env: {
+ ...process.env,
+ XCODEBUILDCLI_SOCKET: socketPath,
+ },
+ });
+
+ child.on('exit', (code) => {
+ process.exit(code ?? 0);
+ });
+ } else {
+ // Run in background (detached)
+ const child = spawn(process.execPath, [daemonPath], {
+ detached: true,
+ stdio: 'ignore',
+ env: {
+ ...process.env,
+ XCODEBUILDCLI_SOCKET: socketPath,
+ },
+ });
+
+ child.unref();
+
+ // Wait a bit and check if it started
+ await new Promise((resolve) => setTimeout(resolve, 500));
+
+ const started = await client.isRunning();
+ if (started) {
+ console.log('Daemon started');
+ console.log(`Socket: ${socketPath}`);
+ } else {
+ console.error('Failed to start daemon');
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleRestart(
+ client: DaemonClient,
+ socketPath: string,
+ foreground: boolean,
+): Promise {
+ // Try to stop existing daemon
+ try {
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ console.log('Stopping existing daemon...');
+ await client.stop();
+ // Wait for it to fully stop
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ } catch {
+ // Ignore errors during stop
+ }
+
+ // Start new daemon
+ await handleStart(socketPath, foreground);
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/daemon/daemon-server.ts
+```ts
+import net from 'node:net';
+import { writeFrame, createFrameReader } from './framing.ts';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type {
+ DaemonRequest,
+ DaemonResponse,
+ ToolInvokeParams,
+ DaemonStatusResult,
+ ToolListItem,
+} from './protocol.ts';
+import { DAEMON_PROTOCOL_VERSION } from './protocol.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { log } from '../utils/logger.ts';
+
+export interface DaemonServerContext {
+ socketPath: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ catalog: ToolCatalog;
+}
+
+/**
+ * Start the daemon server listening on a Unix domain socket.
+ */
+export function startDaemonServer(ctx: DaemonServerContext): net.Server {
+ const invoker = new DefaultToolInvoker(ctx.catalog);
+
+ const server = net.createServer((socket) => {
+ log('info', '[Daemon] Client connected');
+
+ const onData = createFrameReader(
+ async (msg) => {
+ const req = msg as DaemonRequest;
+ const base: Pick = {
+ v: DAEMON_PROTOCOL_VERSION,
+ id: req?.id ?? 'unknown',
+ };
+
+ try {
+ if (!req || typeof req !== 'object') {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: 'Invalid request format' },
+ });
+ }
+
+ if (req.v !== DAEMON_PROTOCOL_VERSION) {
+ return writeFrame(socket, {
+ ...base,
+ error: {
+ code: 'BAD_REQUEST',
+ message: `Unsupported protocol version: ${req.v}`,
+ },
+ });
+ }
+
+ switch (req.method) {
+ case 'daemon.status': {
+ const result: DaemonStatusResult = {
+ pid: process.pid,
+ socketPath: ctx.socketPath,
+ startedAt: ctx.startedAt,
+ enabledWorkflows: ctx.enabledWorkflows,
+ toolCount: ctx.catalog.tools.length,
+ };
+ return writeFrame(socket, { ...base, result });
+ }
+
+ case 'daemon.stop': {
+ log('info', '[Daemon] Stop requested');
+ writeFrame(socket, { ...base, result: { ok: true } });
+ // Close server and exit after a short delay to allow response to be sent
+ setTimeout(() => {
+ server.close(() => {
+ log('info', '[Daemon] Server closed, exiting');
+ process.exit(0);
+ });
+ }, 100);
+ return;
+ }
+
+ case 'tool.list': {
+ const result: ToolListItem[] = ctx.catalog.tools.map((t) => ({
+ name: t.cliName,
+ workflow: t.workflow,
+ description: t.description ?? '',
+ stateful: t.stateful,
+ }));
+ return writeFrame(socket, { ...base, result });
+ }
+
+ case 'tool.invoke': {
+ const params = req.params as ToolInvokeParams;
+ if (!params?.tool) {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: 'Missing tool parameter' },
+ });
+ }
+
+ log('info', `[Daemon] Invoking tool: ${params.tool}`);
+ const response = await invoker.invoke(params.tool, params.args ?? {}, {
+ runtime: 'daemon',
+ enabledWorkflows: ctx.enabledWorkflows,
+ });
+
+ return writeFrame(socket, { ...base, result: { response } });
+ }
+
+ default:
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: `Unknown method: ${req.method}` },
+ });
+ }
+ } catch (error) {
+ log('error', `[Daemon] Error handling request: ${error}`);
+ return writeFrame(socket, {
+ ...base,
+ error: {
+ code: 'INTERNAL',
+ message: error instanceof Error ? error.message : String(error),
+ },
+ });
+ }
+ },
+ (err) => {
+ log('error', `[Daemon] Frame parse error: ${err.message}`);
+ },
+ );
+
+ socket.on('data', onData);
+ socket.on('close', () => {
+ log('info', '[Daemon] Client disconnected');
+ });
+ socket.on('error', (err) => {
+ log('error', `[Daemon] Socket error: ${err.message}`);
+ });
+ });
+
+ server.on('error', (err) => {
+ log('error', `[Daemon] Server error: ${err.message}`);
+ });
+
+ return server;
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/cli/yargs-app.ts
+```ts
+import yargs from 'yargs';
+import { hideBin } from 'yargs/helpers';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type { ResolvedRuntimeConfig } from '../utils/config-store.ts';
+import { registerDaemonCommands } from './commands/daemon.ts';
+import { registerToolsCommand } from './commands/tools.ts';
+import { registerToolCommands } from './register-tool-commands.ts';
+import { version } from '../version.ts';
+
+export interface YargsAppOptions {
+ catalog: ToolCatalog;
+ runtimeConfig: ResolvedRuntimeConfig;
+}
+
+/**
+ * Build the main yargs application with all commands registered.
+ */
+export function buildYargsApp(opts: YargsAppOptions): ReturnType {
+ const app = yargs(hideBin(process.argv))
+ .scriptName('xcodebuildcli')
+ .strict()
+ .recommendCommands()
+ .wrap(Math.min(120, yargs().terminalWidth()))
+ .parserConfiguration({
+ // Accept --derived-data-path -> derivedDataPath
+ 'camel-case-expansion': true,
+ // Support kebab-case flags cleanly
+ 'strip-dashed': true,
+ })
+ .option('socket', {
+ type: 'string',
+ describe: 'Override daemon unix socket path',
+ global: true,
+ hidden: true,
+ })
+ .option('daemon', {
+ type: 'boolean',
+ describe: 'Force daemon execution even for stateless tools',
+ default: false,
+ global: true,
+ hidden: true,
+ })
+ .version(version)
+ .help()
+ .alias('h', 'help')
+ .alias('v', 'version')
+ .epilogue(
+ `Run 'xcodebuildcli tools' to see all available tools.\n` +
+ `Run 'xcodebuildcli --help' for tool-specific help.`,
+ );
+
+ // Register command groups
+ registerDaemonCommands(app);
+ registerToolsCommand(app, opts.catalog);
+ registerToolCommands(app, opts.catalog);
+
+ return app;
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/cli/register-tool-commands.ts
+```ts
+import type { Argv } from 'yargs';
+import type { ToolCatalog } from '../runtime/types.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { schemaToYargsOptions, getUnsupportedSchemaKeys } from './schema-to-yargs.ts';
+import { convertArgvToToolParams } from '../runtime/naming.ts';
+import { printToolResponse, type OutputFormat } from './output.ts';
+
+/**
+ * Register all tool commands from the catalog with yargs.
+ */
+export function registerToolCommands(
+ app: Argv,
+ catalog: ToolCatalog,
+): void {
+ const invoker = new DefaultToolInvoker(catalog);
+
+ for (const tool of catalog.tools) {
+ const yargsOptions = schemaToYargsOptions(tool.cliSchema);
+ const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema);
+
+ app.command(
+ tool.cliName,
+ tool.description ?? `Run the ${tool.mcpName} tool`,
+ (yargs) => {
+ // Add --json option for complex args or full override
+ yargs.option('json', {
+ type: 'string',
+ describe: 'JSON object of tool args (merged with flags)',
+ });
+
+ // Add --output option for format control
+ yargs.option('output', {
+ type: 'string',
+ choices: ['text', 'json'] as const,
+ default: 'text',
+ describe: 'Output format',
+ });
+
+ // Register schema-derived options
+ for (const [flagName, config] of yargsOptions) {
+ yargs.option(flagName, config);
+ }
+
+ // Add note about unsupported keys if any
+ if (unsupportedKeys.length > 0) {
+ yargs.epilogue(
+ `Note: Complex parameters (${unsupportedKeys.join(', ')}) must be passed via --json`,
+ );
+ }
+
+ return yargs;
+ },
+ async (argv) => {
+ // Extract our options
+ const jsonArg = argv.json as string | undefined;
+ const outputFormat = (argv.output as OutputFormat) ?? 'text';
+ const socketPath = argv.socket as string | undefined;
+ const forceDaemon = argv.daemon as boolean | undefined;
+
+ // Parse JSON args if provided
+ let jsonArgs: Record = {};
+ if (jsonArg) {
+ try {
+ jsonArgs = JSON.parse(jsonArg) as Record;
+ } catch {
+ console.error(`Error: Invalid JSON in --json argument`);
+ process.exitCode = 1;
+ return;
+ }
+ }
+
+ // Convert CLI argv to tool params (kebab-case -> camelCase)
+ // Remove our internal options first
+ const { json, output, socket, daemon, _, $0, ...flagArgs } = argv as Record;
+ const toolParams = convertArgvToToolParams(flagArgs);
+
+ // Merge: flag args first, then JSON overrides
+ const args = { ...toolParams, ...jsonArgs };
+
+ // Invoke the tool
+ const response = await invoker.invoke(tool.cliName, args, {
+ runtime: 'cli',
+ forceDaemon: Boolean(forceDaemon),
+ socketPath,
+ });
+
+ printToolResponse(response, outputFormat);
+ },
+ );
+ }
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/cli/daemon-client.ts
+```ts
+import net from 'node:net';
+import { randomUUID } from 'node:crypto';
+import { writeFrame, createFrameReader } from '../daemon/framing.ts';
+import {
+ DAEMON_PROTOCOL_VERSION,
+ type DaemonRequest,
+ type DaemonResponse,
+ type DaemonMethod,
+ type ToolInvokeParams,
+ type DaemonStatusResult,
+ type ToolListItem,
+} from '../daemon/protocol.ts';
+import type { ToolResponse } from '../types/common.ts';
+import { getSocketPath } from '../daemon/socket-path.ts';
+
+export interface DaemonClientOptions {
+ socketPath?: string;
+ timeout?: number;
+}
+
+export class DaemonClient {
+ private socketPath: string;
+ private timeout: number;
+
+ constructor(opts: DaemonClientOptions = {}) {
+ this.socketPath = opts.socketPath ?? getSocketPath();
+ this.timeout = opts.timeout ?? 30000;
+ }
+
+ /**
+ * Send a request to the daemon and wait for a response.
+ */
+ async request(
+ method: DaemonMethod,
+ params?: unknown,
+ ): Promise {
+ const id = randomUUID();
+ const req: DaemonRequest = {
+ v: DAEMON_PROTOCOL_VERSION,
+ id,
+ method,
+ params,
+ };
+
+ return new Promise((resolve, reject) => {
+ const socket = net.createConnection(this.socketPath);
+ let resolved = false;
+
+ const cleanup = () => {
+ if (!resolved) {
+ resolved = true;
+ socket.destroy();
+ }
+ };
+
+ const timeoutId = setTimeout(() => {
+ cleanup();
+ reject(new Error(`Daemon request timed out after ${this.timeout}ms`));
+ }, this.timeout);
+
+ socket.on('error', (err) => {
+ clearTimeout(timeoutId);
+ cleanup();
+ if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOENT')) {
+ reject(new Error('Daemon is not running. Start it with: xcodebuildcli daemon start'));
+ } else {
+ reject(err);
+ }
+ });
+
+ const onData = createFrameReader(
+ (msg) => {
+ const res = msg as DaemonResponse;
+ if (res.id !== id) return;
+
+ clearTimeout(timeoutId);
+ resolved = true;
+ socket.end();
+
+ if (res.error) {
+ reject(new Error(`${res.error.code}: ${res.error.message}`));
+ } else {
+ resolve(res.result as TResult);
+ }
+ },
+ (err) => {
+ clearTimeout(timeoutId);
+ cleanup();
+ reject(err);
+ },
+ );
+
+ socket.on('data', onData);
+ socket.on('connect', () => {
+ writeFrame(socket, req);
+ });
+ });
+ }
+
+ /**
+ * Get daemon status.
+ */
+ async status(): Promise {
+ return this.request('daemon.status');
+ }
+
+ /**
+ * Stop the daemon.
+ */
+ async stop(): Promise {
+ await this.request<{ ok: boolean }>('daemon.stop');
+ }
+
+ /**
+ * List available tools.
+ */
+ async listTools(): Promise {
+ return this.request('tool.list');
+ }
+
+ /**
+ * Invoke a tool.
+ */
+ async invokeTool(
+ tool: string,
+ args: Record,
+ ): Promise {
+ const result = await this.request<{ response: ToolResponse }>(
+ 'tool.invoke',
+ { tool, args } satisfies ToolInvokeParams,
+ );
+ return result.response;
+ }
+
+ /**
+ * Check if daemon is running by attempting to connect.
+ */
+ async isRunning(): Promise {
+ return new Promise((resolve) => {
+ const socket = net.createConnection(this.socketPath);
+
+ socket.on('connect', () => {
+ socket.end();
+ resolve(true);
+ });
+
+ socket.on('error', () => {
+ resolve(false);
+ });
+ });
+ }
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/runtime/tool-invoker.ts
+```ts
+import type { ToolCatalog, ToolInvoker, InvokeOptions } from './types.ts';
+import type { ToolResponse } from '../types/common.ts';
+import { createErrorResponse } from '../utils/responses/index.ts';
+import { DaemonClient } from '../cli/daemon-client.ts';
+
+export class DefaultToolInvoker implements ToolInvoker {
+ constructor(private catalog: ToolCatalog) {}
+
+ async invoke(
+ toolName: string,
+ args: Record,
+ opts: InvokeOptions,
+ ): Promise {
+ const resolved = this.catalog.resolve(toolName);
+
+ if (resolved.ambiguous) {
+ return createErrorResponse(
+ 'Ambiguous tool name',
+ `Multiple tools match '${toolName}'. Use one of:\n- ${resolved.ambiguous.join('\n- ')}`,
+ );
+ }
+
+ if (resolved.notFound || !resolved.tool) {
+ return createErrorResponse(
+ 'Tool not found',
+ `Unknown tool '${toolName}'. Run 'xcodebuildcli tools' to see available tools.`,
+ );
+ }
+
+ const tool = resolved.tool;
+
+ // Check if tool requires daemon routing
+ const mustUseDaemon = tool.stateful || Boolean(opts.forceDaemon);
+
+ if (mustUseDaemon && opts.runtime === 'cli') {
+ // Route through daemon
+ const client = new DaemonClient({ socketPath: opts.socketPath });
+
+ // Check if daemon is running
+ const isRunning = await client.isRunning();
+ if (!isRunning) {
+ return createErrorResponse(
+ 'Daemon not running',
+ `Tool '${tool.cliName}' requires the daemon for stateful operations.\n` +
+ `Start the daemon with: xcodebuildcli daemon start`,
+ );
+ }
+
+ try {
+ return await client.invokeTool(tool.cliName, args);
+ } catch (error) {
+ return createErrorResponse(
+ 'Daemon invocation failed',
+ error instanceof Error ? error.message : String(error),
+ );
+ }
+ }
+
+ // Direct invocation (CLI stateless or daemon internal)
+ try {
+ return await tool.handler(args);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return createErrorResponse('Tool execution failed', message);
+ }
+ }
+}
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/daemon.ts
+```ts
+#!/usr/bin/env node
+import net from 'node:net';
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import {
+ ensureSocketDir,
+ removeStaleSocket,
+ getSocketPath,
+} from './daemon/socket-path.ts';
+import { startDaemonServer } from './daemon/daemon-server.ts';
+import { log } from './utils/logger.ts';
+import { version } from './version.ts';
+
+async function checkExistingDaemon(socketPath: string): Promise {
+ return new Promise((resolve) => {
+ const socket = net.createConnection(socketPath);
+
+ socket.on('connect', () => {
+ socket.end();
+ resolve(true);
+ });
+
+ socket.on('error', () => {
+ resolve(false);
+ });
+ });
+}
+
+async function main(): Promise {
+ log('info', `[Daemon] xcodebuildcli daemon ${version} starting...`);
+
+ const socketPath = getSocketPath();
+ ensureSocketDir(socketPath);
+
+ // Check if daemon is already running
+ const isRunning = await checkExistingDaemon(socketPath);
+ if (isRunning) {
+ log('error', '[Daemon] Another daemon is already running');
+ console.error('Error: Daemon is already running');
+ process.exit(1);
+ }
+
+ // Remove stale socket file
+ removeStaleSocket(socketPath);
+
+ // Bootstrap runtime with full schema (disableSessionDefaults)
+ const result = await bootstrapRuntime({
+ runtime: 'daemon',
+ configOverrides: {
+ disableSessionDefaults: true,
+ },
+ });
+
+ // Build tool catalog
+ const catalog = await buildToolCatalog({
+ enabledWorkflows: result.runtime.config.enabledWorkflows,
+ });
+
+ log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
+
+ // Start server
+ const server = startDaemonServer({
+ socketPath,
+ startedAt: new Date().toISOString(),
+ enabledWorkflows: result.runtime.config.enabledWorkflows,
+ catalog,
+ });
+
+ server.listen(socketPath, () => {
+ log('info', `[Daemon] Listening on ${socketPath}`);
+ console.log(`Daemon started (PID: ${process.pid})`);
+ console.log(`Socket: ${socketPath}`);
+ console.log(`Tools: ${catalog.tools.length}`);
+ });
+
+ // Handle graceful shutdown
+ const shutdown = () => {
+ log('info', '[Daemon] Shutting down...');
+ server.close(() => {
+ removeStaleSocket(socketPath);
+ process.exit(0);
+ });
+ };
+
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+}
+
+main().catch((err) => {
+ console.error('Daemon error:', err instanceof Error ? err.message : String(err));
+ process.exit(1);
+});
+
+```
+
+File: /Volumes/Developer/XcodeBuildMCP/src/runtime/types.ts
+```ts
+import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
+import type { ToolResponse } from '../types/common.ts';
+import type { ToolSchemaShape, PluginMeta } from '../core/plugin-types.ts';
+
+export type RuntimeKind = 'cli' | 'daemon' | 'mcp';
+
+export interface ToolDefinition {
+ /** Stable CLI command name (kebab-case, disambiguated) */
+ cliName: string;
+
+ /** Original MCP tool name as declared (unchanged) */
+ mcpName: string;
+
+ /** Workflow directory name (e.g., "simulator", "device", "logging") */
+ workflow: string;
+
+ description?: string;
+ annotations?: ToolAnnotations;
+
+ /**
+ * Schema shape used to generate yargs flags for CLI.
+ * Must include ALL parameters (not the session-default-hidden version).
+ */
+ cliSchema: ToolSchemaShape;
+
+ /**
+ * Schema shape used for MCP registration.
+ */
+ mcpSchema: ToolSchemaShape;
+
+ /**
+ * Whether CLI MUST route this tool to the daemon (stateful operations).
+ */
+ stateful: boolean;
+
+ /**
+ * Shared handler (same used by MCP). No duplication.
+ */
+ handler: PluginMeta['handler'];
+}
+
+export interface ToolResolution {
+ tool?: ToolDefinition;
+ ambiguous?: string[];
+ notFound?: boolean;
+}
+
+export interface ToolCatalog {
+ tools: ToolDefinition[];
+
+ /** Exact match on cliName */
+ getByCliName(name: string): ToolDefinition | null;
+
+ /** Resolve user input, supporting aliases + ambiguity reporting */
+ resolve(input: string): ToolResolution;
+}
+
+export interface InvokeOptions {
+ runtime: RuntimeKind;
+ /** If present, overrides enabled workflows */
+ enabledWorkflows?: string[];
+ /** If true, route even stateless tools to daemon */
+ forceDaemon?: boolean;
+ /** Socket path override */
+ socketPath?: string;
+}
+
+export interface ToolInvoker {
+ invoke(
+ toolName: string,
+ args: Record,
+ opts: InvokeOptions,
+ ): Promise;
+}
+
+```
+
+
+
+Create a detailed implementation plan for per-workspace daemon architecture with auto-start by default for xcodebuildcli. Use current daemon/CLI implementation to propose where to change socket-path derivation, auto-start logic, new daemon list command, and --no-daemon opt-out behavior. Include design decisions (socket path strategy, timeout, lifecycle) and how manual daemon commands remain.
+
+- CLI entry: xcodebuildcli uses `src/cli.ts` -> `buildYargsApp` and `registerToolCommands`.
+- Tool invocation: `DefaultToolInvoker` routes to daemon when tool is stateful or forceDaemon is set; currently errors if daemon not running.
+- Daemon control: `registerDaemonCommands` provides start/stop/status/restart and uses `getSocketPath` from `src/daemon/socket-path.ts`.
+- Daemon runtime: `src/daemon.ts` sets up socket path, checks for existing daemon, removes stale socket, starts `startDaemonServer`.
+- Daemon client: `DaemonClient` connects to socket path, provides status/stop/isRunning/invokeTool.
+- Socket path: `getSocketPath` reads XCODEBUILDCLI_SOCKET env or defaults to `~/.xcodebuildcli/daemon.sock`.
+
+XcodeBuildMCP/src/daemon/socket-path.ts: global socket path helpers (default path, ensure dir, remove stale, env override).
+XcodeBuildMCP/src/runtime/tool-invoker.ts: daemon routing for stateful tools; current error path when daemon not running.
+XcodeBuildMCP/src/cli.ts: CLI bootstrap.
+XcodeBuildMCP/src/cli/commands/daemon.ts: start/stop/status/restart; spawn daemon.js, uses XCODEBUILDCLI_SOCKET; 500ms startup wait.
+XcodeBuildMCP/src/daemon.ts: daemon entry point, checks existing daemon, removes stale socket, starts server.
+XcodeBuildMCP/src/daemon/daemon-server.ts: daemon request router, status/stop/tool.list/tool.invoke.
+XcodeBuildMCP/src/cli/daemon-client.ts: request protocol, isRunning, timeouts, error messages.
+XcodeBuildMCP/src/cli/yargs-app.ts: global hidden flags --socket and --daemon.
+XcodeBuildMCP/src/cli/register-tool-commands.ts: constructs args, passes forceDaemon + socketPath to invoker.
+XcodeBuildMCP/src/runtime/naming.ts: argv conversion utilities.
+XcodeBuildMCP/src/runtime/tool-catalog.ts: tool catalog with stateful flag from plugin metadata.
+XcodeBuildMCP/src/runtime/types.ts: ToolDefinition/InvokeOptions.
+XcodeBuildMCP/docs/dev/CLI_CONVERSION_PLAN.md: historical plan; includes phase 4 auto-start note and overall architecture context.
+
+
+- CLI command -> `register-tool-commands` -> `DefaultToolInvoker.invoke()` -> `DaemonClient` for stateful tools in CLI runtime.
+- `registerDaemonCommands` + `DaemonClient` + `daemon.ts` all rely on `getSocketPath()` (global path unless env override).
+- Auto-start would be triggered from `DefaultToolInvoker` when daemon required, or earlier in CLI handler.
+- `--daemon` (forceDaemon) and `--socket` flags are passed into `DefaultToolInvoker` and `DaemonClient`.
+
+- Socket path strategy not decided (hash of cwd vs encoded path vs registry); need to choose and document.
+- Auto-start timeout and readiness detection not defined (currently hardcoded 500ms in daemon start command).
+- Daemon lifecycle/idle shutdown policy not defined.
+
+
+Notes: Potentially relevant but not selected: `src/daemon/protocol.ts`, `src/daemon/framing.ts`, `src/cli/commands/tools.ts`, `src/utils/config-store.ts`, `src/utils/session-store.ts` if plan needs broader runtime config or tool listing behaviors.
+
diff --git a/docs/dev/session_management_plan.md b/docs/dev/session_management_plan.md
index ce67d58f..dd0ccdf5 100644
--- a/docs/dev/session_management_plan.md
+++ b/docs/dev/session_management_plan.md
@@ -436,7 +436,7 @@ npm run build
2) Discover a scheme (optional helper):
```bash
-mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js
+mcpli --raw list-schemes --projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" -- node build/index.js mcp
```
3) Set the session defaults (project/workspace, scheme, and simulator):
@@ -446,30 +446,30 @@ mcpli --raw session-set-defaults \
--projectPath "/Volumes/Developer/XcodeBuildMCP/example_projects/iOS/MCPTest.xcodeproj" \
--scheme MCPTest \
--simulatorName "iPhone 16" \
- -- node build/index.js
+ -- node build/index.js mcp
```
4) Verify defaults are stored:
```bash
-mcpli --raw session-show-defaults -- node build/index.js
+mcpli --raw session-show-defaults -- node build/index.js mcp
```
5) Run a session‑aware tool with zero or minimal args (defaults are merged automatically):
```bash
# Optionally provide a scratch derived data path and a short timeout
-mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js
+mcpli --tool-timeout=60 --raw build-sim --derivedDataPath "/tmp/XBMCP_DD" -- node build/index.js mcp
```
Troubleshooting:
- If you see validation errors like “Missing required session defaults …”, (re)run step 3 with the missing keys.
- If you see connect ECONNREFUSED or the daemon appears flaky:
- - Check logs: `mcpli daemon log --since=10m -- node build/index.js`
- - Restart daemon: `mcpli daemon restart -- node build/index.js`
- - Clean daemon state: `mcpli daemon clean -- node build/index.js` then `mcpli daemon start -- node build/index.js`
- - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js`
+ - Check logs: `mcpli daemon log --since=10m -- node build/index.js mcp`
+ - Restart daemon: `mcpli daemon restart -- node build/index.js mcp`
+ - Clean daemon state: `mcpli daemon clean -- node build/index.js mcp` then `mcpli daemon start -- node build/index.js mcp`
+ - After code changes, always: `npm run build` then `mcpli daemon restart -- node build/index.js mcp`
Notes:
diff --git a/docs/investigations/daemon-log-missing.md b/docs/investigations/daemon-log-missing.md
new file mode 100644
index 00000000..63b8dce0
--- /dev/null
+++ b/docs/investigations/daemon-log-missing.md
@@ -0,0 +1,34 @@
+# Investigation: Daemon Log File Missing Entries
+
+## Summary
+Daemon log files only contained the init line because daemon start used the global CLI `--log-level` default (`none`), which was unintentionally passed through to the daemon. That set `clientLogLevel` to `none`, suppressing file writes in `log()`.
+
+## Symptoms
+- Daemon log file existed but only contained “Log file initialized”.
+- Foreground daemon printed logs to console, but file didn’t show them.
+
+## Investigation Log
+
+### 2026-02-02 - CLI Argument Collision
+**Hypothesis:** Daemon log level was being set to `none` inadvertently.
+**Findings:** `src/cli/yargs-app.ts` defines global `--log-level` default `none`. `src/cli/commands/daemon.ts` read the same flag and forwarded it to `XCODEBUILDMCP_DAEMON_LOG_LEVEL`, so daemon started with log level `none` unless explicitly overridden.
+**Evidence:** `src/cli/yargs-app.ts`, `src/cli/commands/daemon.ts`
+**Conclusion:** Confirmed. This explains “init line only” behavior.
+
+### 2026-02-02 - Logger File Guard
+**Hypothesis:** File logging is suppressed when `clientLogLevel === 'none'`.
+**Findings:** `log()` writes to file only when `logFileStream` exists and `clientLogLevel !== 'none'`, while `setLogFile()` writes the init line unconditionally.
+**Evidence:** `src/utils/logger.ts`
+**Conclusion:** Confirmed. This is why the file has only the init line.
+
+## Root Cause
+Daemon CLI reused the global `--log-level` option (default `none`) for daemon log level, which set `XCODEBUILDMCP_DAEMON_LOG_LEVEL=none` during daemon start. The logger then skipped all file writes after initialization.
+
+## Recommendations
+1. Use distinct daemon flags (`--daemon-log-level`, `--daemon-log-path`) to avoid collision.
+2. Log daemon startup errors via `log()` so they appear in the daemon log file.
+3. Keep daemon startup logs after log file setup to ensure they are captured.
+
+## Preventive Measures
+- Avoid reusing global CLI flags for subsystem-specific settings.
+- Treat `none` as “stderr only” for CLI but keep file logging explicitly controlled to avoid accidental suppression.
\ No newline at end of file
diff --git a/docs/investigations/launch-app-logs-sim.md b/docs/investigations/launch-app-logs-sim.md
new file mode 100644
index 00000000..792836cd
--- /dev/null
+++ b/docs/investigations/launch-app-logs-sim.md
@@ -0,0 +1,39 @@
+# Investigation: launch-app-logs-sim keeps CLI running
+
+## Summary
+The CLI remains alive because `launch_app_logs_sim` starts long-running log capture processes and keeps open streams in the same Node process, and the tool is not marked `cli.stateful` so it does not route through the daemon.
+
+## Symptoms
+- `node build/index.js simulator launch-app-logs-sim --simulator-id B38FE93D-578B-454B-BE9A-C6FA0CE5F096 --bundle-id com.example.calculatorapp` keeps the CLI process running while the app is running.
+
+## Investigation Log
+
+### 2026-02-01 21:54 UTC - Initial context build
+**Hypothesis:** The tool runs long-lived log streaming in the CLI process, preventing exit.
+**Findings:** `launch_app_logs_sim` delegates to `startLogCapture`, which spawns long-running log processes and keeps a writable stream open. The tool is not marked `cli.stateful`, so it runs in-process.
+**Evidence:** `src/mcp/tools/simulator/launch_app_logs_sim.ts`, `src/utils/log_capture.ts`, `src/utils/command.ts`, `src/runtime/tool-invoker.ts`, `src/runtime/tool-catalog.ts`.
+**Conclusion:** Confirmed.
+
+### 2026-02-01 21:54 UTC - Git history review
+**Hypothesis:** Recent changes introduced or reinforced CLI in-process routing for this tool.
+**Findings:** Recent commit history shows the CLI was introduced on 2026-01-31 (“Make CLI”) and tool refactors/command executor changes occurred in late January. No commit message indicates lifecycle changes for log capture sessions.
+**Evidence:** `git log -n 5 -- src/utils/log_capture.ts src/mcp/tools/simulator/launch_app_logs_sim.ts src/runtime/tool-invoker.ts`.
+**Conclusion:** Inconclusive for intent; indicates the CLI implementation is recent and likely inherited default in-process routing.
+
+### 2026-02-01 21:54 UTC - Docs/tests intent check
+**Hypothesis:** The tool is expected to return immediately, not block.
+**Findings:** `launch_app_logs_sim` returns text instructing the user to interact, then stop capture later, and includes `nextSteps` for `stop_sim_log_cap`. The docs list the tool but do not state lifecycle behavior.
+**Evidence:** `src/mcp/tools/simulator/launch_app_logs_sim.ts`, `src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts`, `docs/TOOLS.md`.
+**Conclusion:** Weak but supportive signal for a non-blocking start/stop flow; docs are ambiguous.
+
+## Root Cause
+`launch_app_logs_sim` starts log capture sessions that keep active event-loop handles (child process pipes and a log file stream). Because the tool lacks `cli.stateful: true`, the CLI invokes it in-process rather than routing through the daemon. The “detached” flag in `CommandExecutor` does not detach/unref the child, so the CLI cannot exit while capture is active.
+
+## Recommendations
+1. Mark `launch_app_logs_sim`, `start_sim_log_cap`, and `stop_sim_log_cap` as `cli.stateful: true` so they run through the daemon and return promptly.
+2. Clarify the `detached` flag semantics in `CommandExecutor` (rename or document) to avoid assuming it detaches the child process.
+3. Document the lifecycle expectation for log-capture tools (start returns immediately; stop ends capture).
+
+## Preventive Measures
+- Add explicit doc wording for stateful tools indicating daemon ownership and non-blocking behavior.
+- Add tests or assertions that stateful tools are routed via daemon when invoked from CLI.
diff --git a/docs/investigations/spawn-sh-enoent.md b/docs/investigations/spawn-sh-enoent.md
new file mode 100644
index 00000000..57b86d73
--- /dev/null
+++ b/docs/investigations/spawn-sh-enoent.md
@@ -0,0 +1,59 @@
+# Investigation: spawn sh ENOENT in build_sim/build_run_sim
+
+## Summary
+Root cause is the command executor’s shell mode: it rewrites every command to `['sh','-c', ...]` and spawns `sh` by name, so any runtime with missing/empty PATH fails before xcodebuild runs. build_sim and build_run_sim both force or default to shell mode, so they fail immediately with `spawn sh ENOENT`.
+
+## Symptoms
+- `build_run_sim` and `build_sim` fail immediately with `spawn sh ENOENT`.
+- `doctor` reports PATH looks normal, but this likely reflects the CLI environment, not necessarily the daemon/runtime used for tool execution.
+- Issue manifests on this branch; main reportedly unaffected.
+
+## Investigation Log
+
+### 2026-02-02 12:26:59 GMT - Phase 1/2 - Executor and build paths
+**Hypothesis:** The failure is triggered by shell spawning inside the command executor.
+**Findings:**
+- `defaultExecutor` defaults `useShell = true` and rewrites commands to `['sh','-c', commandString]`, then `spawn`s the executable (`sh`).
+- `executeXcodeBuildCommand` always passes `useShell = true` for xcodebuild, even though the command is already argv-style.
+- `build_run_sim` executes `xcodebuild -showBuildSettings` with `useShell = true`, and most other commands omit `useShell`, inheriting the default `true`.
+**Evidence:**
+- `src/utils/command.ts:27-84` (default `useShell = true`, rewrites to `['sh','-c', ...]`, spawns executable)
+- `src/utils/build-utils.ts:214-238` (xcodebuild executed with `useShell = true`)
+- `src/mcp/tools/simulator/build_run_sim.ts:124-192` and `src/mcp/tools/simulator/build_run_sim.ts:244-303` (explicit `useShell = true` and default executor usage)
+**Conclusion:** Confirmed. The error can be produced if PATH is missing/empty where executor runs.
+
+### 2026-02-02 12:26:59 GMT - Phase 3 - Env/daemon plumbing
+**Hypothesis:** CLI/daemon code drops PATH or replaces env.
+**Findings:**
+- Daemon startup merges `process.env` with overrides; no PATH removal observed.
+- Tool invoker only adds a few overrides (workflows/log level) and does not touch PATH.
+**Evidence:**
+- `src/cli/daemon-control.ts:23-114` (env merge preserves `process.env`)
+- `src/runtime/tool-invoker.ts:64-142` (only XCODEBUILDMCP_* overrides)
+**Conclusion:** Eliminated as direct cause. If PATH is missing, it originates in the host process environment.
+
+### 2026-02-02 12:26:59 GMT - Phase 4 - Other shell-dependent paths
+**Hypothesis:** Incremental build path also requires shell.
+**Findings:**
+- xcodemake uses `getDefaultCommandExecutor()` without explicit `useShell` and runs `['which','xcodemake']`.
+- `executeMakeCommand` uses `['cd', projectDir, '&&', 'make']`, which requires shell execution.
+**Evidence:**
+- `src/utils/xcodemake.ts:111-134` (which xcodemake uses default executor)
+- `src/utils/xcodemake.ts:218-225` (`cd && make`)
+**Conclusion:** Confirmed. This is a secondary path that would also fail under the same conditions.
+
+## Root Cause
+The command execution layer always defaults to shell mode and shells are invoked by name (`sh`) via PATH lookup. This makes tool execution dependent on PATH-based resolution of `sh`. In the provided log, `spawn('sh', ...)` fails with `ENOENT` even though `process.env.PATH` includes `/bin`, so the failure is on resolving `sh` via PATH at spawn time. This implicates the `useShell = true` + `spawn('sh', ...)` design directly; the failure disappears if shell usage is removed or `/bin/sh` is used explicitly.
+
+## Recommendations
+1. Use an absolute shell path when shelling out: replace `['sh','-c', ...]` with `['/bin/sh','-c', ...]` in `src/utils/command.ts`. This directly prevents `spawn sh ENOENT` and is the minimal hotfix.
+2. Default to direct spawn (`useShell = false`) and only opt-in to shell when needed. Update call sites in:
+ - `src/utils/build-utils.ts` (xcodebuild execution)
+ - `src/mcp/tools/simulator/build_run_sim.ts` (xcodebuild, xcrun, plutil, defaults, open)
+3. Remove shell operators and use `cwd` instead:
+ - `src/utils/xcodemake.ts` change `['cd', dir, '&&', 'make']` to `['make']` and pass `{ cwd: dir }`.
+4. Optional defensive fallback: if `process.env.PATH` is empty, set it to `/usr/bin:/bin:/usr/sbin:/sbin` at runtime bootstrap.
+
+## Preventive Measures
+- Add a targeted unit/integration test that executes a simple tool with `PATH` cleared and verifies the executor still runs (or fails with a clearer error).
+- Avoid defaulting to shell execution for argv-form commands; add lint/test to enforce `useShell` usage only where shell operators are needed.
diff --git a/example_projects/iOS/.xcodebuildmcp/config.yaml b/example_projects/iOS/.xcodebuildmcp/config.yaml
index 25c7713b..be3eb3f0 100644
--- a/example_projects/iOS/.xcodebuildmcp/config.yaml
+++ b/example_projects/iOS/.xcodebuildmcp/config.yaml
@@ -1,6 +1,9 @@
schemaVersion: 1
+enabledWorkflows: ["simulator", "ui-automation", "debugging", "logging"]
sessionDefaults:
projectPath: ./MCPTest.xcodeproj
scheme: MCPTest
+ simulatorId: B38FE93D-578B-454B-BE9A-C6FA0CE5F096
useLatestOS: true
platform: iOS Simulator
+ bundleId: com.cameroncooke.MCPTest
diff --git a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml
index a5b715dc..d74a770c 100644
--- a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml
+++ b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml
@@ -1,6 +1,7 @@
schemaVersion: 1
+enabledWorkflows: ["simulator", "ui-automation", "debugging"]
sessionDefaults:
- workspacePath: ./iOS_Calculator/CalculatorApp.xcworkspace
+ workspacePath: ./CalculatorApp.xcworkspace
scheme: CalculatorApp
configuration: Debug
simulatorId: B38FE93D-578B-454B-BE9A-C6FA0CE5F096
diff --git a/package-lock.json b/package-lock.json
index 0596d1be..457aeb3b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,9 +14,11 @@
"@sentry/node": "^10.37.0",
"uuid": "^11.1.0",
"yaml": "^2.4.5",
+ "yargs": "^17.7.2",
"zod": "^4.0.0"
},
"bin": {
+ "xcodebuildcli": "build/cli.js",
"xcodebuildmcp": "build/index.js",
"xcodebuildmcp-doctor": "build/doctor-cli.js"
},
@@ -26,6 +28,7 @@
"@eslint/js": "^9.23.0",
"@smithery/cli": "^3.4.0",
"@types/node": "^22.13.6",
+ "@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@vitest/coverage-v8": "^3.2.4",
@@ -3617,6 +3620,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/yargs": {
+ "version": "17.0.35",
+ "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
+ "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/yargs-parser": "*"
+ }
+ },
+ "node_modules/@types/yargs-parser": {
+ "version": "21.0.3",
+ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
+ "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz",
@@ -4216,7 +4236,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4607,6 +4626,78 @@
"node": ">= 12"
}
},
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/cliui/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/cliui/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/cliui/node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
"node_modules/clone": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz",
@@ -4621,7 +4712,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -4634,7 +4724,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "dev": true,
"license": "MIT"
},
"node_modules/commander": {
@@ -5000,6 +5089,15 @@
"@esbuild/win32-x64": "0.25.12"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -5819,6 +5917,15 @@
"node": ">= 12"
}
},
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
@@ -6410,7 +6517,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8075,6 +8181,15 @@
"node": ">= 0.10"
}
},
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -10039,6 +10154,15 @@
"node": ">=0.4"
}
},
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
@@ -10054,6 +10178,74 @@
"url": "https://github.com/sponsors/eemeli"
}
},
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/yargs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/yargs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
diff --git a/package.json b/package.json
index 75ae7639..fd1577cb 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"typecheck": "npx tsc --noEmit && npx tsc -p tsconfig.test.json",
"typecheck:tests": "npx tsc -p tsconfig.test.json",
"verify:smithery-bundle": "bash scripts/verify-smithery-bundle.sh",
- "inspect": "npx @modelcontextprotocol/inspector node build/index.js",
+ "inspect": "npx @modelcontextprotocol/inspector node build/index.js mcp",
"doctor": "node build/doctor-cli.js",
"tools": "npx tsx scripts/tools-cli.ts",
"tools:list": "npx tsx scripts/tools-cli.ts list",
@@ -74,6 +74,7 @@
"@sentry/node": "^10.37.0",
"uuid": "^11.1.0",
"yaml": "^2.4.5",
+ "yargs": "^17.7.2",
"zod": "^4.0.0"
},
"devDependencies": {
@@ -82,6 +83,7 @@
"@eslint/js": "^9.23.0",
"@smithery/cli": "^3.4.0",
"@types/node": "^22.13.6",
+ "@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@vitest/coverage-v8": "^3.2.4",
diff --git a/src/cli.ts b/src/cli.ts
new file mode 100644
index 00000000..cd7355e9
--- /dev/null
+++ b/src/cli.ts
@@ -0,0 +1,59 @@
+#!/usr/bin/env node
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { buildCliToolCatalog } from './cli/cli-tool-catalog.ts';
+import { buildYargsApp } from './cli/yargs-app.ts';
+import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/socket-path.ts';
+import { startMcpServer } from './server/start-mcp-server.ts';
+
+async function main(): Promise {
+ if (process.argv.includes('mcp')) {
+ await startMcpServer();
+ return;
+ }
+
+ // CLI mode uses disableSessionDefaults to show all tool parameters as flags
+ const result = await bootstrapRuntime({
+ runtime: 'cli',
+ configOverrides: {
+ disableSessionDefaults: true,
+ },
+ });
+
+ // CLI uses its own catalog with ALL workflows enabled (except session-management)
+ // This is independent of the enabledWorkflows config which is for MCP
+ const catalog = await buildCliToolCatalog();
+
+ // Compute workspace context for daemon routing
+ const workspaceRoot = resolveWorkspaceRoot({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ const defaultSocketPath = getSocketPath({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ const workspaceKey = getWorkspaceKey({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ const enabledWorkflows = [...new Set(catalog.tools.map((tool) => tool.workflow))];
+
+ const yargsApp = buildYargsApp({
+ catalog,
+ runtimeConfig: result.runtime.config,
+ defaultSocketPath,
+ workspaceRoot,
+ workspaceKey,
+ enabledWorkflows,
+ });
+
+ await yargsApp.parseAsync();
+}
+
+main().catch((err) => {
+ console.error(err instanceof Error ? err.message : String(err));
+ process.exit(1);
+});
diff --git a/src/cli/cli-tool-catalog.ts b/src/cli/cli-tool-catalog.ts
new file mode 100644
index 00000000..c4f8fac2
--- /dev/null
+++ b/src/cli/cli-tool-catalog.ts
@@ -0,0 +1,18 @@
+import { listWorkflowDirectoryNames } from '../core/plugin-registry.ts';
+import { buildToolCatalog } from '../runtime/tool-catalog.ts';
+import type { ToolCatalog } from '../runtime/types.ts';
+
+const CLI_EXCLUDED_WORKFLOWS = ['session-management', 'workflow-discovery'];
+
+/**
+ * Build a tool catalog for CLI usage.
+ * CLI shows ALL workflows (not config-driven) except session-management.
+ */
+export async function buildCliToolCatalog(): Promise {
+ const allWorkflows = listWorkflowDirectoryNames();
+
+ return buildToolCatalog({
+ enabledWorkflows: allWorkflows,
+ excludeWorkflows: CLI_EXCLUDED_WORKFLOWS,
+ });
+}
diff --git a/src/cli/commands/daemon.ts b/src/cli/commands/daemon.ts
new file mode 100644
index 00000000..8acc3079
--- /dev/null
+++ b/src/cli/commands/daemon.ts
@@ -0,0 +1,344 @@
+import type { Argv } from 'yargs';
+import { readFileSync } from 'node:fs';
+import { DaemonClient } from '../daemon-client.ts';
+import {
+ ensureDaemonRunning,
+ startDaemonForeground,
+ DEFAULT_DAEMON_STARTUP_TIMEOUT_MS,
+} from '../daemon-control.ts';
+import {
+ listDaemonRegistryEntries,
+ readDaemonRegistryEntry,
+} from '../../daemon/daemon-registry.ts';
+
+export interface DaemonCommandsOptions {
+ defaultSocketPath: string;
+ workspaceRoot: string;
+ workspaceKey: string;
+}
+
+function writeLine(text: string): void {
+ process.stdout.write(`${text}\n`);
+}
+
+/**
+ * Register daemon management commands.
+ */
+export function registerDaemonCommands(app: Argv, opts: DaemonCommandsOptions): void {
+ app.command(
+ 'daemon ',
+ 'Manage the xcodebuildmcp daemon',
+ (yargs) => {
+ return yargs
+ .positional('action', {
+ describe: 'Daemon action',
+ choices: ['start', 'stop', 'status', 'restart', 'list', 'logs'] as const,
+ demandOption: true,
+ })
+ .option('daemon-log-path', {
+ type: 'string',
+ describe: 'Override daemon log file path (start/restart only)',
+ })
+ .option('daemon-log-level', {
+ type: 'string',
+ describe: 'Set daemon log level (start/restart only)',
+ choices: [
+ 'none',
+ 'emergency',
+ 'alert',
+ 'critical',
+ 'error',
+ 'warning',
+ 'notice',
+ 'info',
+ 'debug',
+ ] as const,
+ })
+ .option('tail', {
+ type: 'number',
+ default: 200,
+ describe: 'Number of log lines to show (logs action)',
+ })
+ .option('foreground', {
+ alias: 'f',
+ type: 'boolean',
+ default: false,
+ describe: 'Run daemon in foreground (for debugging)',
+ })
+ .option('json', {
+ type: 'boolean',
+ default: false,
+ describe: 'Output in JSON format (for list command)',
+ })
+ .option('all', {
+ type: 'boolean',
+ default: true,
+ describe: 'Include stale daemons in list',
+ });
+ },
+ async (argv) => {
+ const action = argv.action as string;
+ // Socket path comes from global --socket which defaults to workspace socket
+ const socketPath = argv.socket as string;
+ const client = new DaemonClient({ socketPath });
+
+ const logPath = argv['daemon-log-path'] as string | undefined;
+ const logLevel = argv['daemon-log-level'] as string | undefined;
+ const tail = argv.tail as number | undefined;
+
+ switch (action) {
+ case 'status':
+ await handleStatus(client, opts.workspaceRoot, opts.workspaceKey);
+ break;
+ case 'stop':
+ await handleStop(client);
+ break;
+ case 'start':
+ await handleStart(socketPath, opts.workspaceRoot, argv.foreground as boolean, {
+ logPath,
+ logLevel,
+ });
+ break;
+ case 'restart':
+ await handleRestart(client, socketPath, opts.workspaceRoot, argv.foreground as boolean, {
+ logPath,
+ logLevel,
+ });
+ break;
+ case 'list':
+ await handleList(argv.json as boolean, argv.all as boolean);
+ break;
+ case 'logs':
+ await handleLogs(opts.workspaceKey, tail ?? 200);
+ break;
+ }
+ },
+ );
+}
+
+async function handleStatus(
+ client: DaemonClient,
+ workspaceRoot: string,
+ workspaceKey: string,
+): Promise {
+ try {
+ const status = await client.status();
+ writeLine('Daemon Status: Running');
+ writeLine(` PID: ${status.pid}`);
+ writeLine(` Workspace: ${status.workspaceRoot ?? workspaceRoot}`);
+ writeLine(` Socket: ${status.socketPath}`);
+ if (status.logPath) {
+ writeLine(` Logs: ${status.logPath}`);
+ }
+ writeLine(` Started: ${status.startedAt}`);
+ writeLine(` Tools: ${status.toolCount}`);
+ writeLine(` Workflows: ${status.enabledWorkflows.join(', ') || '(default)'}`);
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('not running')) {
+ writeLine('Daemon Status: Not running');
+ writeLine(` Workspace: ${workspaceRoot}`);
+ const entry = readDaemonRegistryEntry(workspaceKey);
+ if (entry?.logPath) {
+ writeLine(` Logs: ${entry.logPath}`);
+ }
+ } else {
+ console.error('Error:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleStop(client: DaemonClient): Promise {
+ try {
+ await client.stop();
+ writeLine('Daemon stopped');
+ } catch (err) {
+ if (err instanceof Error && err.message.includes('not running')) {
+ writeLine('Daemon is not running');
+ } else {
+ console.error('Error:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleStart(
+ socketPath: string,
+ workspaceRoot: string,
+ foreground: boolean,
+ logOpts: { logPath?: string; logLevel?: string },
+): Promise {
+ const client = new DaemonClient({ socketPath });
+
+ // Check if already running
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ writeLine('Daemon is already running');
+ return;
+ }
+
+ const envOverrides: Record = {};
+ if (logOpts.logPath) {
+ envOverrides.XCODEBUILDMCP_DAEMON_LOG_PATH = logOpts.logPath;
+ }
+ if (logOpts.logLevel) {
+ envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = logOpts.logLevel;
+ }
+
+ if (foreground) {
+ // Run in foreground (useful for debugging)
+ writeLine('Starting daemon in foreground...');
+ writeLine(`Workspace: ${workspaceRoot}`);
+ writeLine(`Socket: ${socketPath}`);
+ writeLine('Press Ctrl+C to stop\n');
+
+ const exitCode = await startDaemonForeground({
+ socketPath,
+ workspaceRoot,
+ env: Object.keys(envOverrides).length > 0 ? envOverrides : undefined,
+ });
+ process.exit(exitCode);
+ } else {
+ // Run in background with auto-start helper
+ try {
+ await ensureDaemonRunning({
+ socketPath,
+ workspaceRoot,
+ startupTimeoutMs: DEFAULT_DAEMON_STARTUP_TIMEOUT_MS,
+ env: Object.keys(envOverrides).length > 0 ? envOverrides : undefined,
+ });
+ writeLine('Daemon started');
+ writeLine(`Workspace: ${workspaceRoot}`);
+ writeLine(`Socket: ${socketPath}`);
+ } catch (err) {
+ console.error('Failed to start daemon:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ }
+ }
+}
+
+async function handleRestart(
+ client: DaemonClient,
+ socketPath: string,
+ workspaceRoot: string,
+ foreground: boolean,
+ logOpts: { logPath?: string; logLevel?: string },
+): Promise {
+ // Try to stop existing daemon
+ try {
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ writeLine('Stopping existing daemon...');
+ await client.stop();
+ // Wait for it to fully stop
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ }
+ } catch {
+ // Ignore errors during stop
+ }
+
+ // Start new daemon
+ await handleStart(socketPath, workspaceRoot, foreground, logOpts);
+}
+
+interface DaemonListEntry {
+ workspaceKey: string;
+ workspaceRoot: string;
+ socketPath: string;
+ pid: number;
+ startedAt: string;
+ version: string;
+ status: 'running' | 'stale';
+}
+
+async function handleLogs(workspaceKey: string, tail: number): Promise {
+ const entry = readDaemonRegistryEntry(workspaceKey);
+ const logPath = entry?.logPath;
+
+ if (!logPath) {
+ writeLine('No daemon log path available for this workspace.');
+ return;
+ }
+
+ let content = '';
+ try {
+ content = readFileSync(logPath, 'utf8');
+ } catch (err) {
+ console.error('Error:', err instanceof Error ? err.message : String(err));
+ process.exitCode = 1;
+ return;
+ }
+
+ const lines = content.split(/\r?\n/);
+ const limited = lines.slice(Math.max(0, lines.length - Math.max(1, tail)));
+ writeLine(limited.join('\n'));
+}
+
+async function handleList(jsonOutput: boolean, includeStale: boolean): Promise {
+ const registryEntries = listDaemonRegistryEntries();
+
+ if (registryEntries.length === 0) {
+ if (jsonOutput) {
+ writeLine(JSON.stringify([]));
+ } else {
+ writeLine('No daemons found');
+ }
+ return;
+ }
+
+ // Check each daemon's status
+ const entries: DaemonListEntry[] = [];
+
+ for (const entry of registryEntries) {
+ const client = new DaemonClient({
+ socketPath: entry.socketPath,
+ timeout: 1000, // Short timeout for status check
+ });
+
+ let status: 'running' | 'stale' = 'stale';
+ try {
+ await client.status();
+ status = 'running';
+ } catch {
+ status = 'stale';
+ }
+
+ if (status === 'stale' && !includeStale) {
+ continue;
+ }
+
+ entries.push({
+ workspaceKey: entry.workspaceKey,
+ workspaceRoot: entry.workspaceRoot,
+ socketPath: entry.socketPath,
+ pid: entry.pid,
+ startedAt: entry.startedAt,
+ version: entry.version,
+ status,
+ });
+ }
+
+ if (jsonOutput) {
+ writeLine(JSON.stringify(entries, null, 2));
+ } else {
+ if (entries.length === 0) {
+ writeLine('No daemons found');
+ return;
+ }
+
+ writeLine('Daemons:\n');
+ for (const entry of entries) {
+ const statusLabel = entry.status === 'running' ? '[running]' : '[stale]';
+ writeLine(` ${statusLabel} ${entry.workspaceKey}`);
+ writeLine(` Workspace: ${entry.workspaceRoot}`);
+ writeLine(` PID: ${entry.pid}`);
+ writeLine(` Started: ${entry.startedAt}`);
+ writeLine(` Version: ${entry.version}`);
+ writeLine('');
+ }
+
+ const runningCount = entries.filter((e) => e.status === 'running').length;
+ const staleCount = entries.filter((e) => e.status === 'stale').length;
+ writeLine(`Total: ${entries.length} (${runningCount} running, ${staleCount} stale)`);
+ }
+}
diff --git a/src/cli/commands/mcp.ts b/src/cli/commands/mcp.ts
new file mode 100644
index 00000000..8fb48cf3
--- /dev/null
+++ b/src/cli/commands/mcp.ts
@@ -0,0 +1,11 @@
+import type { Argv } from 'yargs';
+import { startMcpServer } from '../../server/start-mcp-server.ts';
+
+/**
+ * Register the `mcp` command to start the MCP server.
+ */
+export function registerMcpCommand(app: Argv): void {
+ app.command('mcp', 'Start the MCP server (for use with MCP clients)', {}, async () => {
+ await startMcpServer();
+ });
+}
diff --git a/src/cli/commands/tools.ts b/src/cli/commands/tools.ts
new file mode 100644
index 00000000..1d7db12e
--- /dev/null
+++ b/src/cli/commands/tools.ts
@@ -0,0 +1,71 @@
+import type { Argv } from 'yargs';
+import type { ToolCatalog } from '../../runtime/types.ts';
+import { formatToolList } from '../output.ts';
+
+function writeLine(text: string): void {
+ process.stdout.write(`${text}\n`);
+}
+
+/**
+ * Register the 'tools' command for listing available tools.
+ */
+export function registerToolsCommand(app: Argv, catalog: ToolCatalog): void {
+ app.command(
+ 'tools',
+ 'List available tools',
+ (yargs) => {
+ return yargs
+ .option('flat', {
+ alias: 'f',
+ type: 'boolean',
+ default: false,
+ describe: 'Show flat list instead of grouped by workflow',
+ })
+ .option('verbose', {
+ alias: 'v',
+ type: 'boolean',
+ default: false,
+ describe: 'Show full descriptions',
+ })
+ .option('json', {
+ type: 'boolean',
+ default: false,
+ describe: 'Output as JSON',
+ })
+ .option('workflow', {
+ alias: 'w',
+ type: 'string',
+ describe: 'Filter by workflow name',
+ });
+ },
+ (argv) => {
+ let tools = catalog.tools.map((t) => ({
+ cliName: t.cliName,
+ mcpName: t.mcpName,
+ workflow: t.workflow,
+ description: t.description,
+ stateful: t.stateful,
+ }));
+
+ // Filter by workflow if specified
+ if (argv.workflow) {
+ const workflowFilter = (argv.workflow as string).toLowerCase();
+ tools = tools.filter((t) => t.workflow.toLowerCase().includes(workflowFilter));
+ }
+
+ if (argv.json) {
+ writeLine(JSON.stringify(tools, null, 2));
+ } else {
+ const count = tools.length;
+ writeLine(`Available tools (${count}):\n`);
+ // Default to grouped view (use --flat for flat list)
+ writeLine(
+ formatToolList(tools, {
+ grouped: !argv.flat,
+ verbose: argv.verbose as boolean,
+ }),
+ );
+ }
+ },
+ );
+}
diff --git a/src/cli/daemon-client.ts b/src/cli/daemon-client.ts
new file mode 100644
index 00000000..db4dc604
--- /dev/null
+++ b/src/cli/daemon-client.ts
@@ -0,0 +1,163 @@
+import net from 'node:net';
+import { randomUUID } from 'node:crypto';
+import { writeFrame, createFrameReader } from '../daemon/framing.ts';
+import {
+ DAEMON_PROTOCOL_VERSION,
+ type DaemonRequest,
+ type DaemonResponse,
+ type DaemonMethod,
+ type ToolInvokeParams,
+ type DaemonStatusResult,
+ type ToolListItem,
+} from '../daemon/protocol.ts';
+import type { ToolResponse } from '../types/common.ts';
+import { getSocketPath } from '../daemon/socket-path.ts';
+
+export interface DaemonClientOptions {
+ socketPath?: string;
+ timeout?: number;
+}
+
+export class DaemonClient {
+ private socketPath: string;
+ private timeout: number;
+
+ constructor(opts: DaemonClientOptions = {}) {
+ this.socketPath = opts.socketPath ?? getSocketPath();
+ this.timeout = opts.timeout ?? 30000;
+ }
+
+ /**
+ * Send a request to the daemon and wait for a response.
+ */
+ async request(method: DaemonMethod, params?: unknown): Promise {
+ const id = randomUUID();
+ const req: DaemonRequest = {
+ v: DAEMON_PROTOCOL_VERSION,
+ id,
+ method,
+ params,
+ };
+
+ return new Promise((resolve, reject) => {
+ const socket = net.createConnection(this.socketPath);
+ let resolved = false;
+
+ const cleanup = (): void => {
+ if (!resolved) {
+ resolved = true;
+ socket.destroy();
+ }
+ };
+
+ const timeoutId = setTimeout(() => {
+ cleanup();
+ reject(new Error(`Daemon request timed out after ${this.timeout}ms`));
+ }, this.timeout);
+
+ socket.on('error', (err) => {
+ clearTimeout(timeoutId);
+ cleanup();
+ if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOENT')) {
+ reject(new Error('Daemon is not running. Start it with: xcodebuildmcp daemon start'));
+ } else {
+ reject(err);
+ }
+ });
+
+ const onData = createFrameReader(
+ (msg) => {
+ const res = msg as DaemonResponse;
+ if (res.id !== id) return;
+
+ clearTimeout(timeoutId);
+ resolved = true;
+ socket.end();
+
+ if (res.error) {
+ reject(new Error(`${res.error.code}: ${res.error.message}`));
+ } else {
+ resolve(res.result as TResult);
+ }
+ },
+ (err) => {
+ clearTimeout(timeoutId);
+ cleanup();
+ reject(err);
+ },
+ );
+
+ socket.on('data', onData);
+ socket.on('connect', () => {
+ writeFrame(socket, req);
+ });
+ });
+ }
+
+ /**
+ * Get daemon status.
+ */
+ async status(): Promise {
+ return this.request('daemon.status');
+ }
+
+ /**
+ * Stop the daemon.
+ */
+ async stop(): Promise {
+ await this.request<{ ok: boolean }>('daemon.stop');
+ }
+
+ /**
+ * List available tools.
+ */
+ async listTools(): Promise {
+ return this.request('tool.list');
+ }
+
+ /**
+ * Invoke a tool.
+ */
+ async invokeTool(tool: string, args: Record): Promise {
+ const result = await this.request<{ response: ToolResponse }>('tool.invoke', {
+ tool,
+ args,
+ } satisfies ToolInvokeParams);
+ return result.response;
+ }
+
+ /**
+ * Check if daemon is running by attempting to connect.
+ */
+ async isRunning(): Promise {
+ return new Promise((resolve) => {
+ const socket = net.createConnection(this.socketPath);
+ let settled = false;
+
+ const finish = (value: boolean): void => {
+ if (settled) return;
+ settled = true;
+ try {
+ socket.destroy();
+ } catch {
+ // ignore
+ }
+ resolve(value);
+ };
+
+ const timeoutId = setTimeout(() => {
+ finish(false);
+ }, this.timeout);
+
+ socket.on('connect', () => {
+ clearTimeout(timeoutId);
+ finish(true);
+ });
+
+ socket.on('error', () => {
+ clearTimeout(timeoutId);
+ finish(false);
+ });
+ });
+ }
+}
diff --git a/src/cli/daemon-control.ts b/src/cli/daemon-control.ts
new file mode 100644
index 00000000..a08b7220
--- /dev/null
+++ b/src/cli/daemon-control.ts
@@ -0,0 +1,161 @@
+import { spawn } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import { dirname, resolve } from 'node:path';
+import { DaemonClient } from './daemon-client.ts';
+
+/**
+ * Default timeout for daemon startup in milliseconds.
+ */
+export const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 5000;
+
+/**
+ * Default polling interval when waiting for daemon to be ready.
+ */
+export const DEFAULT_POLL_INTERVAL_MS = 100;
+
+/**
+ * Get the path to the daemon executable.
+ */
+export function getDaemonExecutablePath(): string {
+ // In the built output, daemon.js is in the same directory as cli.js
+ const currentFile = fileURLToPath(import.meta.url);
+ const buildDir = dirname(currentFile);
+ return resolve(buildDir, 'daemon.js');
+}
+
+export interface StartDaemonBackgroundOptions {
+ socketPath: string;
+ workspaceRoot?: string;
+ env?: Record;
+}
+
+/**
+ * Start the daemon in the background (detached mode).
+ * Does not wait for the daemon to be ready.
+ */
+export function startDaemonBackground(opts: StartDaemonBackgroundOptions): void {
+ const daemonPath = getDaemonExecutablePath();
+
+ const child = spawn(process.execPath, [daemonPath], {
+ detached: true,
+ stdio: 'ignore',
+ cwd: opts.workspaceRoot,
+ env: {
+ ...process.env,
+ ...opts.env,
+ XCODEBUILDMCP_SOCKET: opts.socketPath,
+ XCODEBUILDCLI_SOCKET: opts.socketPath,
+ },
+ });
+
+ child.unref();
+}
+
+export interface WaitForDaemonReadyOptions {
+ socketPath: string;
+ timeoutMs: number;
+ pollIntervalMs?: number;
+}
+
+/**
+ * Wait for the daemon to be ready by polling status.
+ * Throws if the daemon doesn't respond within the timeout.
+ */
+export async function waitForDaemonReady(opts: WaitForDaemonReadyOptions): Promise {
+ const client = new DaemonClient({
+ socketPath: opts.socketPath,
+ timeout: Math.min(opts.timeoutMs, 2000), // Short timeout for each status check
+ });
+
+ const pollInterval = opts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
+ const startTime = Date.now();
+
+ while (Date.now() - startTime < opts.timeoutMs) {
+ try {
+ // Use status() to confirm protocol handler is ready (not just connect)
+ await client.status();
+ return; // Success
+ } catch {
+ // Not ready yet, wait and retry
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
+ }
+ }
+
+ throw new Error(
+ `Daemon failed to start within ${opts.timeoutMs}ms. ` +
+ `Check if another daemon is running or if there are permission issues.`,
+ );
+}
+
+export interface EnsureDaemonRunningOptions {
+ socketPath: string;
+ workspaceRoot?: string;
+ startupTimeoutMs?: number;
+ env?: Record;
+}
+
+/**
+ * Ensure the daemon is running, starting it if necessary.
+ * Returns when the daemon is ready to accept requests.
+ *
+ * This is the main entry point for auto-start behavior.
+ */
+export async function ensureDaemonRunning(opts: EnsureDaemonRunningOptions): Promise {
+ const client = new DaemonClient({ socketPath: opts.socketPath });
+ const timeoutMs = opts.startupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS;
+
+ // Check if already running
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ return;
+ }
+
+ // Start daemon in background
+ const startOptions: StartDaemonBackgroundOptions = {
+ socketPath: opts.socketPath,
+ workspaceRoot: opts.workspaceRoot,
+ };
+
+ if (opts.env) {
+ startOptions.env = { ...opts.env };
+ }
+
+ startDaemonBackground(startOptions);
+
+ // Wait for it to be ready
+ await waitForDaemonReady({
+ socketPath: opts.socketPath,
+ timeoutMs,
+ });
+}
+
+export interface StartDaemonForegroundOptions {
+ socketPath: string;
+ workspaceRoot?: string;
+ env?: Record;
+}
+
+/**
+ * Start the daemon in the foreground (blocking).
+ * Used for debugging. The function returns when the daemon exits.
+ */
+export function startDaemonForeground(opts: StartDaemonForegroundOptions): Promise {
+ const daemonPath = getDaemonExecutablePath();
+
+ return new Promise((resolve) => {
+ const child = spawn(process.execPath, [daemonPath], {
+ stdio: 'inherit',
+ cwd: opts.workspaceRoot,
+ env: {
+ ...process.env,
+ ...opts.env,
+ XCODEBUILDMCP_SOCKET: opts.socketPath,
+ XCODEBUILDCLI_SOCKET: opts.socketPath,
+ },
+ });
+
+ child.on('exit', (code) => {
+ resolve(code ?? 0);
+ });
+ });
+}
diff --git a/src/cli/output.ts b/src/cli/output.ts
new file mode 100644
index 00000000..1ea740b3
--- /dev/null
+++ b/src/cli/output.ts
@@ -0,0 +1,136 @@
+import type { ToolResponse, OutputStyle } from '../types/common.ts';
+import { processToolResponse } from '../utils/responses/index.ts';
+
+export type OutputFormat = 'text' | 'json';
+
+export interface PrintToolResponseOptions {
+ format?: OutputFormat;
+ style?: OutputStyle;
+}
+
+function writeLine(text: string): void {
+ process.stdout.write(`${text}\n`);
+}
+
+/**
+ * Print a tool response to the terminal.
+ * Applies runtime-aware rendering of next steps for CLI output.
+ */
+export function printToolResponse(
+ response: ToolResponse,
+ options: PrintToolResponseOptions = {},
+): void {
+ const { format = 'text', style = 'normal' } = options;
+
+ // Apply next steps rendering for CLI runtime
+ const processed = processToolResponse(response, 'cli', style);
+
+ if (format === 'json') {
+ writeLine(JSON.stringify(processed, null, 2));
+ } else {
+ printToolResponseText(processed);
+ }
+
+ if (response.isError) {
+ process.exitCode = 1;
+ }
+}
+
+/**
+ * Print tool response content as text.
+ */
+function printToolResponseText(response: ToolResponse): void {
+ for (const item of response.content ?? []) {
+ if (item.type === 'text') {
+ writeLine(item.text);
+ } else if (item.type === 'image') {
+ // For images, show a placeholder with metadata
+ const sizeKb = Math.round((item.data.length * 3) / 4 / 1024);
+ writeLine(`[Image: ${item.mimeType}, ~${sizeKb}KB base64]`);
+ writeLine(' Use --output json to get the full image data');
+ }
+ }
+}
+
+/**
+ * Get the base tool name without workflow prefix.
+ * For disambiguated tools, strips the workflow prefix.
+ */
+function getBaseToolName(cliName: string, workflow: string): string {
+ const prefix = `${workflow}-`;
+ if (cliName.startsWith(prefix)) {
+ return cliName.slice(prefix.length);
+ }
+ return cliName;
+}
+
+/**
+ * Format a tool list for display.
+ */
+export function formatToolList(
+ tools: Array<{ cliName: string; workflow: string; description?: string; stateful: boolean }>,
+ options: { grouped?: boolean; verbose?: boolean } = {},
+): string {
+ const lines: string[] = [];
+
+ if (options.grouped) {
+ // Group by workflow - show subcommand names
+ const byWorkflow = new Map();
+ for (const tool of tools) {
+ const existing = byWorkflow.get(tool.workflow) ?? [];
+ byWorkflow.set(tool.workflow, [...existing, tool]);
+ }
+
+ const sortedWorkflows = [...byWorkflow.keys()].sort();
+ for (const workflow of sortedWorkflows) {
+ lines.push(`\n${workflow}:`);
+ const workflowTools = byWorkflow.get(workflow) ?? [];
+ // Sort by base name (without prefix)
+ const sortedTools = workflowTools.sort((a, b) => {
+ const aBase = getBaseToolName(a.cliName, a.workflow);
+ const bBase = getBaseToolName(b.cliName, b.workflow);
+ return aBase.localeCompare(bBase);
+ });
+
+ for (const tool of sortedTools) {
+ // Show subcommand name (without workflow prefix)
+ const toolName = getBaseToolName(tool.cliName, tool.workflow);
+ const statefulMarker = tool.stateful ? ' [stateful]' : '';
+ if (options.verbose && tool.description) {
+ lines.push(` ${toolName}${statefulMarker}`);
+ lines.push(` ${tool.description}`);
+ } else {
+ const desc = tool.description ? ` - ${truncate(tool.description, 60)}` : '';
+ lines.push(` ${toolName}${statefulMarker}${desc}`);
+ }
+ }
+ }
+ } else {
+ // Flat list - show full workflow-scoped command
+ const sortedTools = [...tools].sort((a, b) => {
+ const aFull = `${a.workflow} ${getBaseToolName(a.cliName, a.workflow)}`;
+ const bFull = `${b.workflow} ${getBaseToolName(b.cliName, b.workflow)}`;
+ return aFull.localeCompare(bFull);
+ });
+
+ for (const tool of sortedTools) {
+ const toolName = getBaseToolName(tool.cliName, tool.workflow);
+ const fullCommand = `${tool.workflow} ${toolName}`;
+ const statefulMarker = tool.stateful ? ' [stateful]' : '';
+ if (options.verbose && tool.description) {
+ lines.push(`${fullCommand}${statefulMarker}`);
+ lines.push(` ${tool.description}`);
+ } else {
+ const desc = tool.description ? ` - ${truncate(tool.description, 60)}` : '';
+ lines.push(`${fullCommand}${statefulMarker}${desc}`);
+ }
+ }
+ }
+
+ return lines.join('\n');
+}
+
+function truncate(str: string, maxLength: number): string {
+ if (str.length <= maxLength) return str;
+ return str.slice(0, maxLength - 3) + '...';
+}
diff --git a/src/cli/register-tool-commands.ts b/src/cli/register-tool-commands.ts
new file mode 100644
index 00000000..4252232f
--- /dev/null
+++ b/src/cli/register-tool-commands.ts
@@ -0,0 +1,189 @@
+import type { Argv } from 'yargs';
+import type { ToolCatalog, ToolDefinition } from '../runtime/types.ts';
+import type { OutputStyle } from '../types/common.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { schemaToYargsOptions, getUnsupportedSchemaKeys } from './schema-to-yargs.ts';
+import { convertArgvToToolParams } from '../runtime/naming.ts';
+import { printToolResponse, type OutputFormat } from './output.ts';
+import { groupToolsByWorkflow } from '../runtime/tool-catalog.ts';
+import { WORKFLOW_METADATA, type WorkflowName } from '../core/generated-plugins.ts';
+
+export interface RegisterToolCommandsOptions {
+ workspaceRoot: string;
+ enabledWorkflows?: string[];
+}
+
+/**
+ * Register all tool commands from the catalog with yargs, grouped by workflow.
+ */
+export function registerToolCommands(
+ app: Argv,
+ catalog: ToolCatalog,
+ opts: RegisterToolCommandsOptions,
+): void {
+ const invoker = new DefaultToolInvoker(catalog);
+ const toolsByWorkflow = groupToolsByWorkflow(catalog);
+ const enabledWorkflows = opts.enabledWorkflows ?? [...toolsByWorkflow.keys()];
+
+ for (const [workflowName, tools] of toolsByWorkflow) {
+ const workflowMeta = WORKFLOW_METADATA[workflowName as WorkflowName];
+ const workflowDescription = workflowMeta?.name ?? workflowName;
+
+ app.command(
+ workflowName,
+ workflowDescription,
+ (yargs) => {
+ // Hide root-level options from workflow help
+ yargs
+ .option('no-daemon', { hidden: true })
+ .option('log-level', { hidden: true })
+ .option('style', { hidden: true });
+
+ // Register each tool as a subcommand under this workflow
+ for (const tool of tools) {
+ registerToolSubcommand(yargs, tool, invoker, opts, enabledWorkflows);
+ }
+
+ return yargs.demandCommand(1, '').help();
+ },
+ () => {
+ // No-op handler - subcommands handle execution
+ },
+ );
+ }
+}
+
+/**
+ * Register a single tool as a subcommand.
+ */
+function registerToolSubcommand(
+ yargs: Argv,
+ tool: ToolDefinition,
+ invoker: DefaultToolInvoker,
+ opts: RegisterToolCommandsOptions,
+ enabledWorkflows: string[],
+): void {
+ const yargsOptions = schemaToYargsOptions(tool.cliSchema);
+ const unsupportedKeys = getUnsupportedSchemaKeys(tool.cliSchema);
+
+ // Use the base CLI name without workflow prefix since it's already scoped
+ const commandName = getBaseToolName(tool);
+
+ yargs.command(
+ commandName,
+ tool.description ?? `Run the ${tool.mcpName} tool`,
+ (subYargs) => {
+ // Hide root-level options from tool help
+ subYargs
+ .option('no-daemon', { hidden: true })
+ .option('log-level', { hidden: true })
+ .option('style', { hidden: true });
+
+ // Register schema-derived options (tool arguments)
+ const toolArgNames: string[] = [];
+ for (const [flagName, config] of yargsOptions) {
+ subYargs.option(flagName, config);
+ toolArgNames.push(flagName);
+ }
+
+ // Add --json option for complex args or full override
+ subYargs.option('json', {
+ type: 'string',
+ describe: 'JSON object of tool args (merged with flags)',
+ });
+
+ // Add --output option for format control
+ subYargs.option('output', {
+ type: 'string',
+ choices: ['text', 'json'] as const,
+ default: 'text',
+ describe: 'Output format',
+ });
+
+ // Group options for cleaner help display
+ if (toolArgNames.length > 0) {
+ subYargs.group(toolArgNames, 'Tool Arguments:');
+ }
+ subYargs.group(['json', 'output'], 'Output Options:');
+
+ // Add note about unsupported keys if any
+ if (unsupportedKeys.length > 0) {
+ subYargs.epilogue(
+ `Note: Complex parameters (${unsupportedKeys.join(', ')}) must be passed via --json`,
+ );
+ }
+
+ return subYargs;
+ },
+ async (argv) => {
+ // Extract our options
+ const jsonArg = argv.json as string | undefined;
+ const outputFormat = (argv.output as OutputFormat) ?? 'text';
+ const outputStyle = (argv.style as OutputStyle) ?? 'normal';
+ const socketPath = argv.socket as string;
+ const forceDaemon = argv.daemon as boolean | undefined;
+ const noDaemon = argv.noDaemon as boolean | undefined;
+ const logLevel = argv['log-level'] as string | undefined;
+
+ // Parse JSON args if provided
+ let jsonArgs: Record = {};
+ if (jsonArg) {
+ try {
+ jsonArgs = JSON.parse(jsonArg) as Record;
+ } catch {
+ console.error(`Error: Invalid JSON in --json argument`);
+ process.exitCode = 1;
+ return;
+ }
+ }
+
+ // Convert CLI argv to tool params (kebab-case -> camelCase)
+ // Filter out internal CLI options before converting
+ const internalKeys = new Set([
+ 'json',
+ 'output',
+ 'style',
+ 'socket',
+ 'daemon',
+ 'noDaemon',
+ '_',
+ '$0',
+ ]);
+ const flagArgs: Record = {};
+ for (const [key, value] of Object.entries(argv as Record)) {
+ if (!internalKeys.has(key)) {
+ flagArgs[key] = value;
+ }
+ }
+ const toolParams = convertArgvToToolParams(flagArgs);
+
+ // Merge: flag args first, then JSON overrides
+ const args = { ...toolParams, ...jsonArgs };
+
+ // Invoke the tool
+ const response = await invoker.invoke(tool.cliName, args, {
+ runtime: 'cli',
+ enabledWorkflows,
+ forceDaemon: Boolean(forceDaemon),
+ disableDaemon: Boolean(noDaemon),
+ socketPath,
+ workspaceRoot: opts.workspaceRoot,
+ logLevel,
+ });
+
+ printToolResponse(response, { format: outputFormat, style: outputStyle });
+ },
+ );
+}
+
+/**
+ * Get the base tool name without any workflow prefix.
+ * For tools that were disambiguated with workflow prefix, strip it.
+ */
+function getBaseToolName(tool: ToolDefinition): string {
+ const prefix = `${tool.workflow}-`;
+ if (tool.cliName.startsWith(prefix)) {
+ return tool.cliName.slice(prefix.length);
+ }
+ return tool.cliName;
+}
diff --git a/src/cli/schema-to-yargs.ts b/src/cli/schema-to-yargs.ts
new file mode 100644
index 00000000..252fa966
--- /dev/null
+++ b/src/cli/schema-to-yargs.ts
@@ -0,0 +1,245 @@
+import * as z from 'zod';
+import type { Options } from 'yargs';
+import { toKebabCase } from '../runtime/naming.ts';
+import type { ToolSchemaShape } from '../core/plugin-types.ts';
+
+export interface YargsOptionConfig extends Options {
+ type: 'string' | 'number' | 'boolean' | 'array';
+}
+
+/**
+ * Check the Zod type kind using the internal _zod property.
+ * This is more reliable than instanceof checks which can fail
+ * across module boundaries or with different Zod versions.
+ */
+function getZodTypeName(t: z.ZodType): string | undefined {
+ // Zod 4 uses _zod.def.type
+ const zod4Def = (t as { _zod?: { def?: { type?: string } } })._zod?.def;
+ if (zod4Def?.type) return zod4Def.type;
+
+ // Zod 3 fallback uses _def.typeName
+ const zod3Def = (t as { _def?: { typeName?: string } })._def;
+ return zod3Def?.typeName;
+}
+
+/**
+ * Get the inner type from wrapper types (optional, nullable, default, transform, pipe).
+ */
+function getInnerType(t: z.ZodType): z.ZodType | undefined {
+ // Use unknown as intermediate to avoid type conflicts
+ const tAny = t as unknown as Record;
+ const zod4Def = (tAny._zod as Record | undefined)?.def as
+ | Record
+ | undefined;
+
+ // ZodOptional, ZodNullable, ZodDefault use innerType
+ if (zod4Def?.innerType) return zod4Def.innerType as z.ZodType;
+ // ZodPipe uses 'in'
+ if (zod4Def?.in) return zod4Def.in as z.ZodType;
+ // ZodTransform uses 'type' as inner type (when it's an object/ZodType)
+ if (zod4Def?.type && typeof zod4Def.type === 'object') return zod4Def.type as z.ZodType;
+
+ // Zod 3 fallback
+ const zod3Def = tAny._def as Record | undefined;
+ return zod3Def?.innerType as z.ZodType | undefined;
+}
+
+/**
+ * Unwrap Zod wrapper types to get the underlying type.
+ */
+function unwrap(t: z.ZodType): z.ZodType {
+ const typeName = getZodTypeName(t);
+
+ // Wrapper types that should be unwrapped
+ const wrapperTypes = [
+ 'optional',
+ 'nullable',
+ 'default',
+ 'transform',
+ 'pipe',
+ 'prefault',
+ 'catch',
+ 'readonly',
+ ];
+
+ if (typeName && wrapperTypes.includes(typeName)) {
+ const inner = getInnerType(t);
+ if (inner) return unwrap(inner);
+ }
+
+ return t;
+}
+
+/**
+ * Check if a Zod type is optional/nullable/has default.
+ */
+function isOptional(t: z.ZodType): boolean {
+ const typeName = getZodTypeName(t);
+
+ if (
+ typeName === 'optional' ||
+ typeName === 'nullable' ||
+ typeName === 'default' ||
+ typeName === 'prefault'
+ ) {
+ return true;
+ }
+
+ // Check wrapper types recursively
+ const inner = getInnerType(t);
+ if (inner) return isOptional(inner);
+
+ return false;
+}
+
+/**
+ * Get description from a Zod type if available.
+ */
+function getDescription(t: z.ZodType): string | undefined {
+ // Zod 4 uses _zod.def.description
+ const def = (t as { _zod?: { def?: { description?: string } } })._zod?.def;
+ if (def?.description) return def.description;
+
+ // Zod 3 fallback
+ const legacyDef = (t as { _def?: { description?: string } })._def;
+ return legacyDef?.description;
+}
+
+/**
+ * Get enum values from a Zod enum type.
+ */
+function getEnumValues(t: z.ZodType): string[] | undefined {
+ const def = (t as { _zod?: { def?: { entries?: Record; values?: string[] } } })
+ ._zod?.def;
+ if (def?.entries) return Object.values(def.entries);
+ if (def?.values) return def.values;
+
+ // Zod 3 fallback
+ const legacyDef = (t as { _def?: { values?: string[] } })._def;
+ return legacyDef?.values;
+}
+
+/**
+ * Get the element type from an array type.
+ */
+function getArrayElement(t: z.ZodType): z.ZodType | undefined {
+ const tAny = t as unknown as Record;
+ const zod4Def = (tAny._zod as Record | undefined)?.def as
+ | Record
+ | undefined;
+ if (zod4Def?.element) return zod4Def.element as z.ZodType;
+
+ // Zod 3 fallback
+ const zod3Def = tAny._def as Record | undefined;
+ return zod3Def?.type as z.ZodType | undefined;
+}
+
+/**
+ * Get the literal value from a literal type.
+ */
+function getLiteralValue(t: z.ZodType): unknown {
+ const def = (t as { _zod?: { def?: { value?: unknown } } })._zod?.def;
+ if (def?.value !== undefined) return def.value;
+
+ // Zod 3 fallback
+ const legacyDef = (t as { _def?: { value?: unknown } })._def;
+ return legacyDef?.value;
+}
+
+/**
+ * Convert a Zod type to yargs option configuration.
+ * Returns null for types that can't be represented as CLI flags.
+ */
+export function zodToYargsOption(t: z.ZodType): YargsOptionConfig | null {
+ const unwrapped = unwrap(t);
+ const description = getDescription(t);
+ const demandOption = !isOptional(t);
+ const typeName = getZodTypeName(unwrapped);
+
+ if (typeName === 'string') {
+ return { type: 'string', describe: description, demandOption };
+ }
+
+ if (typeName === 'number' || typeName === 'int' || typeName === 'bigint') {
+ return { type: 'number', describe: description, demandOption };
+ }
+
+ if (typeName === 'boolean') {
+ return { type: 'boolean', describe: description, demandOption: false };
+ }
+
+ if (typeName === 'enum' || typeName === 'nativeEnum') {
+ const values = getEnumValues(unwrapped);
+ if (values) {
+ return {
+ type: 'string',
+ choices: values,
+ describe: description,
+ demandOption,
+ };
+ }
+ }
+
+ if (typeName === 'array') {
+ const element = getArrayElement(unwrapped);
+ if (element) {
+ const elemTypeName = getZodTypeName(unwrap(element));
+ if (elemTypeName === 'string' || elemTypeName === 'number') {
+ return { type: 'array', describe: description, demandOption: false };
+ }
+ }
+ // Complex array types - use --json fallback
+ return null;
+ }
+
+ if (typeName === 'literal') {
+ const value = getLiteralValue(unwrapped);
+ if (typeof value === 'string') {
+ return { type: 'string', default: value, describe: description, demandOption: false };
+ }
+ if (typeof value === 'number') {
+ return { type: 'number', default: value, describe: description, demandOption: false };
+ }
+ if (typeof value === 'boolean') {
+ return { type: 'boolean', default: value, describe: description, demandOption: false };
+ }
+ }
+
+ // Complex types (objects, unions, etc.) - use --json fallback
+ return null;
+}
+
+/**
+ * Convert a tool schema shape to yargs options.
+ * Returns a map of flag names (kebab-case) to yargs options.
+ */
+export function schemaToYargsOptions(schema: ToolSchemaShape): Map {
+ const options = new Map();
+
+ for (const [key, zodType] of Object.entries(schema)) {
+ const opt = zodToYargsOption(zodType);
+ if (opt) {
+ const flagName = toKebabCase(key);
+ options.set(flagName, opt);
+ }
+ }
+
+ return options;
+}
+
+/**
+ * Get list of schema keys that couldn't be converted to CLI flags.
+ * These need to be passed via --json.
+ */
+export function getUnsupportedSchemaKeys(schema: ToolSchemaShape): string[] {
+ const unsupported: string[] = [];
+
+ for (const [key, zodType] of Object.entries(schema)) {
+ const opt = zodToYargsOption(zodType);
+ if (!opt) {
+ unsupported.push(key);
+ }
+ }
+
+ return unsupported;
+}
diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts
new file mode 100644
index 00000000..79610fc3
--- /dev/null
+++ b/src/cli/yargs-app.ts
@@ -0,0 +1,95 @@
+import yargs from 'yargs';
+import { hideBin } from 'yargs/helpers';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type { ResolvedRuntimeConfig } from '../utils/config-store.ts';
+import { registerDaemonCommands } from './commands/daemon.ts';
+import { registerMcpCommand } from './commands/mcp.ts';
+import { registerToolsCommand } from './commands/tools.ts';
+import { registerToolCommands } from './register-tool-commands.ts';
+import { version } from '../version.ts';
+import { setLogLevel, type LogLevel } from '../utils/logger.ts';
+
+export interface YargsAppOptions {
+ catalog: ToolCatalog;
+ runtimeConfig: ResolvedRuntimeConfig;
+ defaultSocketPath: string;
+ workspaceRoot: string;
+ workspaceKey: string;
+ enabledWorkflows: string[];
+}
+
+/**
+ * Build the main yargs application with all commands registered.
+ */
+export function buildYargsApp(opts: YargsAppOptions): ReturnType {
+ const app = yargs(hideBin(process.argv))
+ .scriptName('')
+ .usage('Usage: xcodebuildmcp [options]')
+ .strict()
+ .recommendCommands()
+ .wrap(Math.min(120, yargs().terminalWidth()))
+ .parserConfiguration({
+ // Accept --derived-data-path -> derivedDataPath
+ 'camel-case-expansion': true,
+ })
+ .option('socket', {
+ type: 'string',
+ describe: 'Override daemon unix socket path',
+ default: opts.defaultSocketPath,
+ hidden: true,
+ })
+ .option('daemon', {
+ type: 'boolean',
+ describe: 'Force daemon execution even for stateless tools',
+ default: false,
+ hidden: true,
+ })
+ .option('no-daemon', {
+ type: 'boolean',
+ describe: 'Disable daemon usage and auto-start (stateful tools will fail)',
+ default: false,
+ })
+ .option('log-level', {
+ type: 'string',
+ describe: 'Set log verbosity level',
+ choices: ['none', 'error', 'warning', 'info', 'debug'] as const,
+ default: 'none',
+ })
+ .option('style', {
+ type: 'string',
+ describe: 'Output verbosity (minimal hides next steps)',
+ choices: ['normal', 'minimal'] as const,
+ default: 'normal',
+ })
+ .middleware((argv) => {
+ const level = argv['log-level'] as LogLevel | undefined;
+ if (level) {
+ setLogLevel(level);
+ }
+ })
+ .version(version)
+ .help()
+ .alias('h', 'help')
+ .alias('v', 'version')
+ .demandCommand(1, '')
+ .epilogue(
+ `Run 'xcodebuildmcp mcp' to start the MCP server.\n` +
+ `Run 'xcodebuildmcp tools' to see all available tools.\n` +
+ `Run 'xcodebuildmcp --help' for tool-specific help.`,
+ );
+
+ // Register command groups with workspace context
+ registerMcpCommand(app);
+ registerDaemonCommands(app, {
+ defaultSocketPath: opts.defaultSocketPath,
+ workspaceRoot: opts.workspaceRoot,
+ workspaceKey: opts.workspaceKey,
+ });
+ registerToolsCommand(app, opts.catalog);
+ registerToolCommands(app, opts.catalog, {
+ workspaceRoot: opts.workspaceRoot,
+ enabledWorkflows: opts.enabledWorkflows,
+ });
+
+ return app;
+}
diff --git a/src/core/plugin-types.ts b/src/core/plugin-types.ts
index 4077682a..da0e35ab 100644
--- a/src/core/plugin-types.ts
+++ b/src/core/plugin-types.ts
@@ -4,11 +4,23 @@ import { ToolResponse } from '../types/common.ts';
export type ToolSchemaShape = Record;
+export interface PluginCliMeta {
+ /** Optional override of derived CLI name */
+ readonly name?: string;
+ /** Full schema shape for CLI flag generation (legacy, includes session-managed fields) */
+ readonly schema?: ToolSchemaShape;
+ /** Mark tool as requiring daemon routing */
+ readonly stateful?: boolean;
+ /** Prefer daemon routing when available (without forcing auto-start) */
+ readonly daemonAffinity?: 'preferred' | 'required';
+}
+
export interface PluginMeta {
readonly name: string; // Verb used by MCP
readonly schema: ToolSchemaShape; // Zod validation schema (object schema)
readonly description?: string; // One-liner shown in help
readonly annotations?: ToolAnnotations; // MCP tool annotations for LLM behavior hints
+ readonly cli?: PluginCliMeta; // CLI-specific metadata (optional)
handler(params: Record): Promise;
}
diff --git a/src/daemon.ts b/src/daemon.ts
new file mode 100644
index 00000000..42e81f57
--- /dev/null
+++ b/src/daemon.ts
@@ -0,0 +1,251 @@
+#!/usr/bin/env node
+import net from 'node:net';
+import { dirname } from 'node:path';
+import { existsSync, mkdirSync, renameSync, statSync } from 'node:fs';
+import { bootstrapRuntime } from './runtime/bootstrap-runtime.ts';
+import { listWorkflowDirectoryNames } from './core/plugin-registry.ts';
+import { buildToolCatalog } from './runtime/tool-catalog.ts';
+import {
+ ensureSocketDir,
+ removeStaleSocket,
+ getSocketPath,
+ getWorkspaceKey,
+ resolveWorkspaceRoot,
+ logPathForWorkspaceKey,
+} from './daemon/socket-path.ts';
+import { startDaemonServer } from './daemon/daemon-server.ts';
+import {
+ writeDaemonRegistryEntry,
+ removeDaemonRegistryEntry,
+ cleanupWorkspaceDaemonFiles,
+} from './daemon/daemon-registry.ts';
+import { log, setLogFile, setLogLevel, type LogLevel } from './utils/logger.ts';
+import { version } from './version.ts';
+
+async function checkExistingDaemon(socketPath: string): Promise {
+ return new Promise((resolve) => {
+ const socket = net.createConnection(socketPath);
+
+ socket.on('connect', () => {
+ socket.end();
+ resolve(true);
+ });
+
+ socket.on('error', () => {
+ resolve(false);
+ });
+ });
+}
+
+function writeLine(text: string): void {
+ process.stdout.write(`${text}\n`);
+}
+
+const MAX_LOG_BYTES = 10 * 1024 * 1024;
+const MAX_LOG_ROTATIONS = 3;
+
+function rotateLogIfNeeded(logPath: string): void {
+ if (!existsSync(logPath)) {
+ return;
+ }
+
+ const size = statSync(logPath).size;
+ if (size < MAX_LOG_BYTES) {
+ return;
+ }
+
+ for (let index = MAX_LOG_ROTATIONS - 1; index >= 1; index -= 1) {
+ const from = `${logPath}.${index}`;
+ const to = `${logPath}.${index + 1}`;
+ if (existsSync(from)) {
+ renameSync(from, to);
+ }
+ }
+
+ renameSync(logPath, `${logPath}.1`);
+}
+
+function resolveDaemonLogPath(workspaceKey: string): string | null {
+ const override = process.env.XCODEBUILDMCP_DAEMON_LOG_PATH?.trim();
+ if (override) {
+ return override;
+ }
+
+ return logPathForWorkspaceKey(workspaceKey);
+}
+
+function ensureLogDir(logPath: string): void {
+ const dir = dirname(logPath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+}
+
+function resolveLogLevel(): LogLevel | null {
+ const raw = process.env.XCODEBUILDMCP_DAEMON_LOG_LEVEL?.trim().toLowerCase();
+ if (!raw) {
+ return null;
+ }
+
+ const knownLevels: LogLevel[] = [
+ 'none',
+ 'emergency',
+ 'alert',
+ 'critical',
+ 'error',
+ 'warning',
+ 'notice',
+ 'info',
+ 'debug',
+ ];
+
+ if (knownLevels.includes(raw as LogLevel)) {
+ return raw as LogLevel;
+ }
+
+ return null;
+}
+
+async function main(): Promise {
+ // Bootstrap runtime first to get config and workspace info
+ const result = await bootstrapRuntime({
+ runtime: 'daemon',
+ configOverrides: {
+ disableSessionDefaults: true,
+ },
+ });
+
+ // Compute workspace context
+ const workspaceRoot = resolveWorkspaceRoot({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ const workspaceKey = getWorkspaceKey({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ const logPath = resolveDaemonLogPath(workspaceKey);
+ if (logPath) {
+ ensureLogDir(logPath);
+ rotateLogIfNeeded(logPath);
+ setLogFile(logPath);
+
+ const requestedLogLevel = resolveLogLevel();
+ if (requestedLogLevel) {
+ setLogLevel(requestedLogLevel);
+ } else {
+ setLogLevel('info');
+ }
+ }
+
+ log('info', `[Daemon] xcodebuildmcp daemon ${version} starting...`);
+
+ // Get socket path (env override or workspace-derived)
+ const socketPath = getSocketPath({
+ cwd: result.runtime.cwd,
+ projectConfigPath: result.configPath,
+ });
+
+ log('info', `[Daemon] Workspace: ${workspaceRoot}`);
+ log('info', `[Daemon] Socket: ${socketPath}`);
+ if (logPath) {
+ log('info', `[Daemon] Logs: ${logPath}`);
+ }
+
+ ensureSocketDir(socketPath);
+
+ // Check if daemon is already running
+ const isRunning = await checkExistingDaemon(socketPath);
+ if (isRunning) {
+ log('error', '[Daemon] Another daemon is already running for this workspace');
+ console.error('Error: Daemon is already running for this workspace');
+ process.exit(1);
+ }
+
+ // Remove stale socket file
+ removeStaleSocket(socketPath);
+
+ const excludedWorkflows = new Set(['session-management', 'workflow-discovery']);
+ const allWorkflows = listWorkflowDirectoryNames();
+ const daemonWorkflows = allWorkflows.filter((workflow) => !excludedWorkflows.has(workflow));
+
+ // Build tool catalog (CLI daemon always loads all workflows except MCP-only ones)
+ const catalog = await buildToolCatalog({
+ enabledWorkflows: allWorkflows,
+ excludeWorkflows: [...excludedWorkflows],
+ });
+
+ log('info', `[Daemon] Loaded ${catalog.tools.length} tools`);
+
+ const startedAt = new Date().toISOString();
+
+ // Unified shutdown handler
+ const shutdown = (): void => {
+ log('info', '[Daemon] Shutting down...');
+
+ // Close the server
+ server.close(() => {
+ log('info', '[Daemon] Server closed');
+
+ // Remove registry entry and socket
+ removeDaemonRegistryEntry(workspaceKey);
+ removeStaleSocket(socketPath);
+
+ log('info', '[Daemon] Cleanup complete');
+ process.exit(0);
+ });
+
+ // Force exit if server doesn't close in time
+ setTimeout(() => {
+ log('warn', '[Daemon] Forced shutdown after timeout');
+ cleanupWorkspaceDaemonFiles(workspaceKey);
+ process.exit(1);
+ }, 5000);
+ };
+
+ // Start server
+ const server = startDaemonServer({
+ socketPath,
+ logPath: logPath ?? undefined,
+ startedAt,
+ enabledWorkflows: daemonWorkflows,
+ catalog,
+ workspaceRoot,
+ workspaceKey,
+ requestShutdown: shutdown,
+ });
+
+ server.listen(socketPath, () => {
+ log('info', `[Daemon] Listening on ${socketPath}`);
+
+ // Write registry entry after successful listen
+ writeDaemonRegistryEntry({
+ workspaceKey,
+ workspaceRoot,
+ socketPath,
+ logPath: logPath ?? undefined,
+ pid: process.pid,
+ startedAt,
+ enabledWorkflows: daemonWorkflows,
+ version,
+ });
+
+ writeLine(`Daemon started (PID: ${process.pid})`);
+ writeLine(`Workspace: ${workspaceRoot}`);
+ writeLine(`Socket: ${socketPath}`);
+ writeLine(`Tools: ${catalog.tools.length}`);
+ });
+
+ // Handle graceful shutdown
+ process.on('SIGTERM', shutdown);
+ process.on('SIGINT', shutdown);
+}
+
+main().catch((err) => {
+ const message = err instanceof Error ? err.message : String(err);
+ log('error', `Daemon error: ${message}`);
+ console.error('Daemon error:', message);
+ process.exit(1);
+});
diff --git a/src/daemon/daemon-registry.ts b/src/daemon/daemon-registry.ts
new file mode 100644
index 00000000..5a18d141
--- /dev/null
+++ b/src/daemon/daemon-registry.ts
@@ -0,0 +1,137 @@
+import {
+ existsSync,
+ mkdirSync,
+ readdirSync,
+ readFileSync,
+ unlinkSync,
+ writeFileSync,
+} from 'node:fs';
+import { join, dirname } from 'node:path';
+import {
+ daemonsDir,
+ daemonDirForWorkspaceKey,
+ registryPathForWorkspaceKey,
+} from './socket-path.ts';
+
+/**
+ * Metadata stored for each running daemon.
+ */
+export interface DaemonRegistryEntry {
+ workspaceKey: string;
+ workspaceRoot: string;
+ socketPath: string;
+ logPath?: string;
+ pid: number;
+ startedAt: string;
+ enabledWorkflows: string[];
+ version: string;
+}
+
+/**
+ * Write a daemon registry entry.
+ * Creates the daemon directory if it doesn't exist.
+ */
+export function writeDaemonRegistryEntry(entry: DaemonRegistryEntry): void {
+ const registryPath = registryPathForWorkspaceKey(entry.workspaceKey);
+ const dir = dirname(registryPath);
+
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+
+ writeFileSync(registryPath, JSON.stringify(entry, null, 2), {
+ mode: 0o600,
+ });
+}
+
+/**
+ * Remove a daemon registry entry.
+ */
+export function removeDaemonRegistryEntry(workspaceKey: string): void {
+ const registryPath = registryPathForWorkspaceKey(workspaceKey);
+
+ if (existsSync(registryPath)) {
+ unlinkSync(registryPath);
+ }
+}
+
+/**
+ * Read a daemon registry entry by workspace key.
+ * Returns null if the entry doesn't exist.
+ */
+export function readDaemonRegistryEntry(workspaceKey: string): DaemonRegistryEntry | null {
+ const registryPath = registryPathForWorkspaceKey(workspaceKey);
+
+ if (!existsSync(registryPath)) {
+ return null;
+ }
+
+ try {
+ const content = readFileSync(registryPath, 'utf8');
+ return JSON.parse(content) as DaemonRegistryEntry;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * List all daemon registry entries.
+ * Enumerates the daemons directory and reads each daemon.json file.
+ */
+export function listDaemonRegistryEntries(): DaemonRegistryEntry[] {
+ const dir = daemonsDir();
+
+ if (!existsSync(dir)) {
+ return [];
+ }
+
+ const entries: DaemonRegistryEntry[] = [];
+
+ try {
+ const subdirs = readdirSync(dir, { withFileTypes: true });
+
+ for (const subdir of subdirs) {
+ if (!subdir.isDirectory()) continue;
+
+ const workspaceKey = subdir.name;
+ const registryPath = join(daemonDirForWorkspaceKey(workspaceKey), 'daemon.json');
+
+ if (!existsSync(registryPath)) continue;
+
+ try {
+ const content = readFileSync(registryPath, 'utf8');
+ const entry = JSON.parse(content) as DaemonRegistryEntry;
+ entries.push(entry);
+ } catch {
+ // Skip malformed entries
+ }
+ }
+ } catch {
+ // Directory read error, return empty
+ }
+
+ return entries;
+}
+
+/**
+ * Remove all registry files for a workspace key (socket + registry).
+ */
+export function cleanupWorkspaceDaemonFiles(workspaceKey: string): void {
+ const daemonDir = daemonDirForWorkspaceKey(workspaceKey);
+
+ if (!existsSync(daemonDir)) {
+ return;
+ }
+
+ // Remove daemon.json
+ const registryPath = join(daemonDir, 'daemon.json');
+ if (existsSync(registryPath)) {
+ unlinkSync(registryPath);
+ }
+
+ // Remove daemon.sock
+ const socketPath = join(daemonDir, 'daemon.sock');
+ if (existsSync(socketPath)) {
+ unlinkSync(socketPath);
+ }
+}
diff --git a/src/daemon/daemon-server.ts b/src/daemon/daemon-server.ts
new file mode 100644
index 00000000..a68caa0a
--- /dev/null
+++ b/src/daemon/daemon-server.ts
@@ -0,0 +1,150 @@
+import net from 'node:net';
+import { writeFrame, createFrameReader } from './framing.ts';
+import type { ToolCatalog } from '../runtime/types.ts';
+import type {
+ DaemonRequest,
+ DaemonResponse,
+ ToolInvokeParams,
+ DaemonStatusResult,
+ ToolListItem,
+} from './protocol.ts';
+import { DAEMON_PROTOCOL_VERSION } from './protocol.ts';
+import { DefaultToolInvoker } from '../runtime/tool-invoker.ts';
+import { log } from '../utils/logger.ts';
+
+export interface DaemonServerContext {
+ socketPath: string;
+ logPath?: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ catalog: ToolCatalog;
+ workspaceRoot: string;
+ workspaceKey: string;
+ /** Callback to request graceful shutdown (used instead of direct process.exit) */
+ requestShutdown: () => void;
+}
+
+/**
+ * Start the daemon server listening on a Unix domain socket.
+ */
+export function startDaemonServer(ctx: DaemonServerContext): net.Server {
+ const invoker = new DefaultToolInvoker(ctx.catalog);
+
+ const server = net.createServer((socket) => {
+ log('info', '[Daemon] Client connected');
+
+ const onData = createFrameReader(
+ async (msg) => {
+ const req = msg as DaemonRequest;
+ const base: Pick = {
+ v: DAEMON_PROTOCOL_VERSION,
+ id: req?.id ?? 'unknown',
+ };
+
+ try {
+ if (!req || typeof req !== 'object') {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: 'Invalid request format' },
+ });
+ }
+
+ if (req.v !== DAEMON_PROTOCOL_VERSION) {
+ return writeFrame(socket, {
+ ...base,
+ error: {
+ code: 'BAD_REQUEST',
+ message: `Unsupported protocol version: ${req.v}`,
+ },
+ });
+ }
+
+ switch (req.method) {
+ case 'daemon.status': {
+ const result: DaemonStatusResult = {
+ pid: process.pid,
+ socketPath: ctx.socketPath,
+ logPath: ctx.logPath,
+ startedAt: ctx.startedAt,
+ enabledWorkflows: ctx.enabledWorkflows,
+ toolCount: ctx.catalog.tools.length,
+ workspaceRoot: ctx.workspaceRoot,
+ workspaceKey: ctx.workspaceKey,
+ };
+ return writeFrame(socket, { ...base, result });
+ }
+
+ case 'daemon.stop': {
+ log('info', '[Daemon] Stop requested');
+ // Send response before initiating shutdown
+ writeFrame(socket, { ...base, result: { ok: true } });
+ // Request shutdown through callback (allows proper cleanup)
+ setTimeout(() => ctx.requestShutdown(), 100);
+ return;
+ }
+
+ case 'tool.list': {
+ const result: ToolListItem[] = ctx.catalog.tools.map((t) => ({
+ name: t.cliName,
+ workflow: t.workflow,
+ description: t.description ?? '',
+ stateful: t.stateful,
+ }));
+ return writeFrame(socket, { ...base, result });
+ }
+
+ case 'tool.invoke': {
+ const params = req.params as ToolInvokeParams;
+ if (!params?.tool) {
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: 'Missing tool parameter' },
+ });
+ }
+
+ log('info', `[Daemon] Invoking tool: ${params.tool}`);
+ const response = await invoker.invoke(params.tool, params.args ?? {}, {
+ runtime: 'daemon',
+ enabledWorkflows: ctx.enabledWorkflows,
+ });
+
+ return writeFrame(socket, { ...base, result: { response } });
+ }
+
+ default:
+ return writeFrame(socket, {
+ ...base,
+ error: { code: 'BAD_REQUEST', message: `Unknown method: ${req.method}` },
+ });
+ }
+ } catch (error) {
+ log('error', `[Daemon] Error handling request: ${error}`);
+ return writeFrame(socket, {
+ ...base,
+ error: {
+ code: 'INTERNAL',
+ message: error instanceof Error ? error.message : String(error),
+ },
+ });
+ }
+ },
+ (err) => {
+ log('error', `[Daemon] Frame parse error: ${err.message}`);
+ },
+ );
+
+ socket.on('data', onData);
+ socket.on('close', () => {
+ log('info', '[Daemon] Client disconnected');
+ });
+ socket.on('error', (err) => {
+ log('error', `[Daemon] Socket error: ${err.message}`);
+ });
+ });
+
+ server.on('error', (err) => {
+ log('error', `[Daemon] Server error: ${err.message}`);
+ });
+
+ return server;
+}
diff --git a/src/daemon/framing.ts b/src/daemon/framing.ts
new file mode 100644
index 00000000..ded554fa
--- /dev/null
+++ b/src/daemon/framing.ts
@@ -0,0 +1,58 @@
+import type net from 'node:net';
+
+/**
+ * Write a length-prefixed JSON frame to a socket.
+ * Format: 4-byte big-endian length + JSON payload
+ */
+export function writeFrame(socket: net.Socket, obj: unknown): void {
+ const json = Buffer.from(JSON.stringify(obj), 'utf8');
+ const header = Buffer.alloc(4);
+ header.writeUInt32BE(json.length, 0);
+ socket.write(Buffer.concat([header, json]));
+}
+
+/**
+ * Create a frame reader that buffers incoming data and emits complete messages.
+ * Returns a function to be used as the 'data' event handler.
+ */
+export function createFrameReader(
+ onMessage: (msg: unknown) => void,
+ onError?: (err: Error) => void,
+): (chunk: Buffer) => void {
+ let buffer = Buffer.alloc(0);
+
+ return (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk]);
+
+ while (buffer.length >= 4) {
+ const len = buffer.readUInt32BE(0);
+
+ // Sanity check: reject messages larger than 100MB
+ if (len > 100 * 1024 * 1024) {
+ const err = new Error(`Message too large: ${len} bytes`);
+ if (onError) {
+ onError(err);
+ }
+ buffer = Buffer.alloc(0);
+ return;
+ }
+
+ if (buffer.length < 4 + len) {
+ // Not enough data yet, wait for more
+ return;
+ }
+
+ const payload = buffer.subarray(4, 4 + len);
+ buffer = buffer.subarray(4 + len);
+
+ try {
+ const msg = JSON.parse(payload.toString('utf8')) as unknown;
+ onMessage(msg);
+ } catch (err) {
+ if (onError) {
+ onError(err instanceof Error ? err : new Error(String(err)));
+ }
+ }
+ }
+ };
+}
diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts
new file mode 100644
index 00000000..0d100e2e
--- /dev/null
+++ b/src/daemon/protocol.ts
@@ -0,0 +1,59 @@
+export const DAEMON_PROTOCOL_VERSION = 1 as const;
+
+export type DaemonMethod = 'daemon.status' | 'daemon.stop' | 'tool.list' | 'tool.invoke';
+
+export interface DaemonRequest {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ method: DaemonMethod;
+ params?: TParams;
+}
+
+export type DaemonErrorCode =
+ | 'BAD_REQUEST'
+ | 'NOT_FOUND'
+ | 'AMBIGUOUS_TOOL'
+ | 'TOOL_FAILED'
+ | 'INTERNAL';
+
+export interface DaemonError {
+ code: DaemonErrorCode;
+ message: string;
+ data?: unknown;
+}
+
+export interface DaemonResponse {
+ v: typeof DAEMON_PROTOCOL_VERSION;
+ id: string;
+ result?: TResult;
+ error?: DaemonError;
+}
+
+export interface ToolInvokeParams {
+ tool: string;
+ args: Record;
+}
+
+export interface ToolInvokeResult {
+ response: unknown;
+}
+
+export interface DaemonStatusResult {
+ pid: number;
+ socketPath: string;
+ logPath?: string;
+ startedAt: string;
+ enabledWorkflows: string[];
+ toolCount: number;
+ /** Workspace root this daemon is serving */
+ workspaceRoot: string;
+ /** Short hash key identifying this workspace */
+ workspaceKey: string;
+}
+
+export interface ToolListItem {
+ name: string;
+ workflow: string;
+ description: string;
+ stateful: boolean;
+}
diff --git a/src/daemon/socket-path.ts b/src/daemon/socket-path.ts
new file mode 100644
index 00000000..fd4fce23
--- /dev/null
+++ b/src/daemon/socket-path.ts
@@ -0,0 +1,147 @@
+import { createHash } from 'node:crypto';
+import { mkdirSync, existsSync, unlinkSync, realpathSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join, dirname } from 'node:path';
+
+/**
+ * Base directory for all daemon-related files.
+ */
+export function daemonBaseDir(): string {
+ return join(homedir(), '.xcodebuildmcp');
+}
+
+/**
+ * Directory containing all workspace daemons.
+ */
+export function daemonsDir(): string {
+ return join(daemonBaseDir(), 'daemons');
+}
+
+/**
+ * Resolve the workspace root from the given context.
+ *
+ * If a project config was found (path to .xcodebuildmcp/config.yaml), use its parent directory.
+ * Otherwise, use realpath(cwd).
+ */
+export function resolveWorkspaceRoot(opts: { cwd: string; projectConfigPath?: string }): string {
+ if (opts.projectConfigPath) {
+ // Config is at .xcodebuildmcp/config.yaml, so parent of parent is workspace root
+ const configDir = dirname(opts.projectConfigPath);
+ return dirname(configDir);
+ }
+ try {
+ return realpathSync(opts.cwd);
+ } catch {
+ return opts.cwd;
+ }
+}
+
+/**
+ * Generate a short, stable key from a workspace root path.
+ * Uses first 12 characters of SHA-256 hash.
+ */
+export function workspaceKeyForRoot(workspaceRoot: string): string {
+ const hash = createHash('sha256').update(workspaceRoot).digest('hex');
+ return hash.slice(0, 12);
+}
+
+/**
+ * Get the daemon directory for a specific workspace key.
+ */
+export function daemonDirForWorkspaceKey(key: string): string {
+ return join(daemonsDir(), key);
+}
+
+/**
+ * Get the socket path for a specific workspace root.
+ */
+export function socketPathForWorkspaceRoot(workspaceRoot: string): string {
+ const key = workspaceKeyForRoot(workspaceRoot);
+ return join(daemonDirForWorkspaceKey(key), 'daemon.sock');
+}
+
+/**
+ * Get the registry file path for a specific workspace key.
+ */
+export function registryPathForWorkspaceKey(key: string): string {
+ return join(daemonDirForWorkspaceKey(key), 'daemon.json');
+}
+
+/**
+ * Get the log file path for a specific workspace key.
+ */
+export function logPathForWorkspaceKey(key: string): string {
+ return join(daemonDirForWorkspaceKey(key), 'daemon.log');
+}
+
+export interface GetSocketPathOptions {
+ cwd?: string;
+ projectConfigPath?: string;
+ env?: NodeJS.ProcessEnv;
+}
+
+/**
+ * Get the socket path from environment or compute per-workspace.
+ *
+ * Resolution order:
+ * 1. If env.XCODEBUILDMCP_SOCKET is set, use it (explicit override)
+ * 2. If cwd is provided, compute workspace root and return per-workspace socket
+ * 3. Fall back to process.cwd() and compute workspace socket from that
+ */
+export function getSocketPath(opts?: GetSocketPathOptions): string {
+ const env = opts?.env ?? process.env;
+
+ // Explicit override takes precedence
+ if (env.XCODEBUILDMCP_SOCKET) {
+ return env.XCODEBUILDMCP_SOCKET;
+ }
+
+ // Compute workspace-derived socket path
+ const cwd = opts?.cwd ?? process.cwd();
+ const workspaceRoot = resolveWorkspaceRoot({
+ cwd,
+ projectConfigPath: opts?.projectConfigPath,
+ });
+
+ return socketPathForWorkspaceRoot(workspaceRoot);
+}
+
+/**
+ * Get the workspace key for the current context.
+ */
+export function getWorkspaceKey(opts?: GetSocketPathOptions): string {
+ const cwd = opts?.cwd ?? process.cwd();
+ const workspaceRoot = resolveWorkspaceRoot({
+ cwd,
+ projectConfigPath: opts?.projectConfigPath,
+ });
+ return workspaceKeyForRoot(workspaceRoot);
+}
+
+/**
+ * Ensure the directory for the socket exists with proper permissions.
+ */
+export function ensureSocketDir(socketPath: string): void {
+ const dir = dirname(socketPath);
+ if (!existsSync(dir)) {
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+}
+
+/**
+ * Remove a stale socket file if it exists.
+ * Should only be called after confirming no daemon is running.
+ */
+export function removeStaleSocket(socketPath: string): void {
+ if (existsSync(socketPath)) {
+ unlinkSync(socketPath);
+ }
+}
+
+/**
+ * Legacy: Get the default socket path for the daemon.
+ * @deprecated Use getSocketPath() with workspace context instead.
+ */
+export function defaultSocketPath(): string {
+ return getSocketPath();
+}
diff --git a/src/index.ts b/src/index.ts
deleted file mode 100644
index c10f0039..00000000
--- a/src/index.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-#!/usr/bin/env node
-
-/**
- * XcodeBuildMCP - Main entry point
- *
- * This file serves as the entry point for the XcodeBuildMCP server, importing and registering
- * all tool modules with the MCP server. It follows the platform-specific approach for Xcode tools.
- *
- * Responsibilities:
- * - Creating and starting the MCP server
- * - Registering all platform-specific tool modules
- * - Configuring server options and logging
- * - Handling server lifecycle events
- */
-
-// Import server components
-import { createServer, startServer } from './server/server.ts';
-
-// Import utilities
-import { log } from './utils/logger.ts';
-import { initSentry } from './utils/sentry.ts';
-import { getDefaultDebuggerManager } from './utils/debugger/index.ts';
-
-// Import version
-import { version } from './version.ts';
-
-// Import process for stdout configuration
-import process from 'node:process';
-
-import { bootstrapServer } from './server/bootstrap.ts';
-
-/**
- * Main function to start the server
- */
-async function main(): Promise {
- try {
- initSentry();
-
- // Create the server
- const server = createServer();
-
- await bootstrapServer(server);
-
- // Start the server
- await startServer(server);
-
- // Clean up on exit
- process.on('SIGTERM', async () => {
- await getDefaultDebuggerManager().disposeAll();
- await server.close();
- process.exit(0);
- });
-
- process.on('SIGINT', async () => {
- await getDefaultDebuggerManager().disposeAll();
- await server.close();
- process.exit(0);
- });
-
- // Log successful startup
- log('info', `XcodeBuildMCP server (version ${version}) started successfully`);
- } catch (error) {
- console.error('Fatal error in main():', error);
- process.exit(1);
- }
-}
-
-// Start the server
-main().catch((error) => {
- console.error('Unhandled exception:', error);
- // Give Sentry a moment to send the error before exiting
- setTimeout(() => process.exit(1), 1000);
-});
diff --git a/src/mcp/resources/__tests__/simulators.test.ts b/src/mcp/resources/__tests__/simulators.test.ts
index ad5c3599..eadc7a99 100644
--- a/src/mcp/resources/__tests__/simulators.test.ts
+++ b/src/mcp/resources/__tests__/simulators.test.ts
@@ -171,7 +171,7 @@ describe('simulators resource', () => {
expect(result.contents[0].text).not.toContain('iPhone 14');
});
- it('should include next steps guidance', async () => {
+ it('should include hint about setting defaults', async () => {
const mockExecutor = createMockExecutor({
success: true,
output: JSON.stringify({
@@ -190,11 +190,10 @@ describe('simulators resource', () => {
const result = await simulatorsResourceLogic(mockExecutor);
- expect(result.contents[0].text).toContain('Next Steps:');
- expect(result.contents[0].text).toContain('boot_sim');
- expect(result.contents[0].text).toContain('open_sim');
- expect(result.contents[0].text).toContain('build_sim');
- expect(result.contents[0].text).toContain('get_sim_app_path');
+ // The resource returns text content with simulator list and hint
+ expect(result.contents[0].text).toContain('iPhone 15 Pro');
+ expect(result.contents[0].text).toContain('ABC123-DEF456-GHI789');
+ expect(result.contents[0].text).toContain('session-set-defaults');
});
});
});
diff --git a/src/mcp/tools/debugging/debug_attach_sim.ts b/src/mcp/tools/debugging/debug_attach_sim.ts
index a1de791b..48f18c0c 100644
--- a/src/mcp/tools/debugging/debug_attach_sim.ts
+++ b/src/mcp/tools/debugging/debug_attach_sim.ts
@@ -1,7 +1,7 @@
import * as z from 'zod';
import { ToolResponse } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
-import { createErrorResponse, createTextResponse } from '../../../utils/responses/index.ts';
+import { createErrorResponse } from '../../../utils/responses/index.ts';
import { nullifyEmptyStrings } from '../../../utils/schema-helpers.ts';
import { determineSimulatorUuid } from '../../../utils/simulator-utils.ts';
import {
@@ -134,16 +134,39 @@ export async function debug_attach_simLogic(
const backendLabel = session.backend === 'dap' ? 'DAP debugger' : 'LLDB';
- return createTextResponse(
- `${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` +
- `Debug session ID: ${session.id}\n` +
- `${currentText}\n` +
- `${resumeText}\n\n` +
- `Next steps:\n` +
- `1. debug_breakpoint_add({ debugSessionId: "${session.id}", file: "...", line: 123 })\n` +
- `2. debug_continue({ debugSessionId: "${session.id}" })\n` +
- `3. debug_stack({ debugSessionId: "${session.id}" })`,
- );
+ return {
+ content: [
+ {
+ type: 'text',
+ text:
+ `${warningText}✅ Attached ${backendLabel} to simulator process ${pid} (${simulatorId}).\n\n` +
+ `Debug session ID: ${session.id}\n` +
+ `${currentText}\n` +
+ `${resumeText}`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'debug_breakpoint_add',
+ label: 'Add a breakpoint',
+ params: { debugSessionId: session.id, file: '...', line: 123 },
+ priority: 1,
+ },
+ {
+ tool: 'debug_continue',
+ label: 'Continue execution',
+ params: { debugSessionId: session.id },
+ priority: 2,
+ },
+ {
+ tool: 'debug_stack',
+ label: 'Show call stack',
+ params: { debugSessionId: session.id },
+ priority: 3,
+ },
+ ],
+ isError: false,
+ };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log('error', `Failed to attach LLDB: ${message}`);
@@ -161,6 +184,9 @@ const publicSchemaObject = z.strictObject(
export default {
name: 'debug_attach_sim',
description: 'Attach LLDB to sim app.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
diff --git a/src/mcp/tools/debugging/debug_breakpoint_add.ts b/src/mcp/tools/debugging/debug_breakpoint_add.ts
index fdd817c1..66c01e7c 100644
--- a/src/mcp/tools/debugging/debug_breakpoint_add.ts
+++ b/src/mcp/tools/debugging/debug_breakpoint_add.ts
@@ -56,6 +56,9 @@ export async function debug_breakpoint_addLogic(
export default {
name: 'debug_breakpoint_add',
description: 'Add breakpoint.',
+ cli: {
+ stateful: true,
+ },
schema: baseSchemaObject.shape,
handler: createTypedToolWithContext(
debugBreakpointAddSchema as unknown as z.ZodType,
diff --git a/src/mcp/tools/debugging/debug_breakpoint_remove.ts b/src/mcp/tools/debugging/debug_breakpoint_remove.ts
index 5e03477e..656d9d9b 100644
--- a/src/mcp/tools/debugging/debug_breakpoint_remove.ts
+++ b/src/mcp/tools/debugging/debug_breakpoint_remove.ts
@@ -30,6 +30,9 @@ export async function debug_breakpoint_removeLogic(
export default {
name: 'debug_breakpoint_remove',
description: 'Remove breakpoint.',
+ cli: {
+ stateful: true,
+ },
schema: debugBreakpointRemoveSchema.shape,
handler: createTypedToolWithContext(
debugBreakpointRemoveSchema,
diff --git a/src/mcp/tools/debugging/debug_continue.ts b/src/mcp/tools/debugging/debug_continue.ts
index e7cc7f16..b6d67a88 100644
--- a/src/mcp/tools/debugging/debug_continue.ts
+++ b/src/mcp/tools/debugging/debug_continue.ts
@@ -31,6 +31,9 @@ export async function debug_continueLogic(
export default {
name: 'debug_continue',
description: 'Continue debug session.',
+ cli: {
+ stateful: true,
+ },
schema: debugContinueSchema.shape,
handler: createTypedToolWithContext(
debugContinueSchema,
diff --git a/src/mcp/tools/debugging/debug_detach.ts b/src/mcp/tools/debugging/debug_detach.ts
index 25a568c7..3543eccf 100644
--- a/src/mcp/tools/debugging/debug_detach.ts
+++ b/src/mcp/tools/debugging/debug_detach.ts
@@ -31,6 +31,9 @@ export async function debug_detachLogic(
export default {
name: 'debug_detach',
description: 'Detach debugger.',
+ cli: {
+ stateful: true,
+ },
schema: debugDetachSchema.shape,
handler: createTypedToolWithContext(
debugDetachSchema,
diff --git a/src/mcp/tools/debugging/debug_lldb_command.ts b/src/mcp/tools/debugging/debug_lldb_command.ts
index 6368e273..9e7b71bd 100644
--- a/src/mcp/tools/debugging/debug_lldb_command.ts
+++ b/src/mcp/tools/debugging/debug_lldb_command.ts
@@ -36,6 +36,9 @@ export async function debug_lldb_commandLogic(
export default {
name: 'debug_lldb_command',
description: 'Run LLDB command.',
+ cli: {
+ stateful: true,
+ },
schema: baseSchemaObject.shape,
handler: createTypedToolWithContext(
debugLldbCommandSchema as unknown as z.ZodType,
diff --git a/src/mcp/tools/debugging/debug_stack.ts b/src/mcp/tools/debugging/debug_stack.ts
index 90e06f4d..c3f3ef1b 100644
--- a/src/mcp/tools/debugging/debug_stack.ts
+++ b/src/mcp/tools/debugging/debug_stack.ts
@@ -34,6 +34,9 @@ export async function debug_stackLogic(
export default {
name: 'debug_stack',
description: 'Get backtrace.',
+ cli: {
+ stateful: true,
+ },
schema: debugStackSchema.shape,
handler: createTypedToolWithContext(
debugStackSchema,
diff --git a/src/mcp/tools/debugging/debug_variables.ts b/src/mcp/tools/debugging/debug_variables.ts
index 18b7f820..c60c6341 100644
--- a/src/mcp/tools/debugging/debug_variables.ts
+++ b/src/mcp/tools/debugging/debug_variables.ts
@@ -32,6 +32,9 @@ export async function debug_variablesLogic(
export default {
name: 'debug_variables',
description: 'Get frame variables.',
+ cli: {
+ stateful: true,
+ },
schema: debugVariablesSchema.shape,
handler: createTypedToolWithContext(
debugVariablesSchema,
diff --git a/src/mcp/tools/device/__tests__/build_device.test.ts b/src/mcp/tools/device/__tests__/build_device.test.ts
index 67bc7e59..592b78c5 100644
--- a/src/mcp/tools/device/__tests__/build_device.test.ts
+++ b/src/mcp/tools/device/__tests__/build_device.test.ts
@@ -178,7 +178,7 @@ describe('build_device plugin', () => {
'build',
],
logPrefix: 'iOS Device Build',
- silent: true,
+ silent: false,
opts: { cwd: '/path/to' },
});
});
@@ -230,7 +230,7 @@ describe('build_device plugin', () => {
'build',
],
logPrefix: 'iOS Device Build',
- silent: true,
+ silent: false,
opts: { cwd: '/path/to' },
});
});
@@ -345,7 +345,7 @@ describe('build_device plugin', () => {
'build',
],
logPrefix: 'iOS Device Build',
- silent: true,
+ silent: false,
opts: { cwd: '/path/to' },
});
});
diff --git a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts
index bca7363c..fc4935ef 100644
--- a/src/mcp/tools/device/__tests__/get_device_app_path.test.ts
+++ b/src/mcp/tools/device/__tests__/get_device_app_path.test.ts
@@ -136,7 +136,7 @@ describe('get_device_app_path plugin', () => {
'generic/platform=iOS',
],
logPrefix: 'Get App Path',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -191,7 +191,7 @@ describe('get_device_app_path plugin', () => {
'generic/platform=watchOS',
],
logPrefix: 'Get App Path',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -245,7 +245,7 @@ describe('get_device_app_path plugin', () => {
'generic/platform=iOS',
],
logPrefix: 'Get App Path',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -271,9 +271,25 @@ describe('get_device_app_path plugin', () => {
type: 'text',
text: '✅ App path retrieved successfully: /path/to/build/Debug-iphoneos/MyApp.app',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "/path/to/build/Debug-iphoneos/MyApp.app" })\n3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })',
+ tool: 'get_app_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath: '/path/to/build/Debug-iphoneos/MyApp.app' },
+ priority: 1,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install app on device',
+ params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/build/Debug-iphoneos/MyApp.app' },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch app on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' },
+ priority: 3,
},
],
});
@@ -379,7 +395,7 @@ describe('get_device_app_path plugin', () => {
'generic/platform=iOS',
],
logPrefix: 'Get App Path',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/device/__tests__/install_app_device.test.ts b/src/mcp/tools/device/__tests__/install_app_device.test.ts
index cccad73d..489d2955 100644
--- a/src/mcp/tools/device/__tests__/install_app_device.test.ts
+++ b/src/mcp/tools/device/__tests__/install_app_device.test.ts
@@ -95,7 +95,7 @@ describe('install_app_device plugin', () => {
'/path/to/test.app',
]);
expect(capturedDescription).toBe('Install app on device');
- expect(capturedUseShell).toBe(true);
+ expect(capturedUseShell).toBe(false);
expect(capturedEnv).toBe(undefined);
});
diff --git a/src/mcp/tools/device/__tests__/launch_app_device.test.ts b/src/mcp/tools/device/__tests__/launch_app_device.test.ts
index 1606c6c7..7f4a83a5 100644
--- a/src/mcp/tools/device/__tests__/launch_app_device.test.ts
+++ b/src/mcp/tools/device/__tests__/launch_app_device.test.ts
@@ -102,7 +102,7 @@ describe('launch_app_device plugin (device-shared)', () => {
'com.example.app',
]);
expect(calls[0].logPrefix).toBe('Launch app on device');
- expect(calls[0].useShell).toBe(true);
+ expect(calls[0].useShell).toBe(false);
expect(calls[0].env).toBeUndefined();
});
@@ -167,6 +167,7 @@ describe('launch_app_device plugin (device-shared)', () => {
text: '✅ App launched successfully\n\nApp launched successfully',
},
],
+ nextSteps: [],
});
});
@@ -192,6 +193,7 @@ describe('launch_app_device plugin (device-shared)', () => {
text: '✅ App launched successfully\n\nLaunch succeeded with detailed output',
},
],
+ nextSteps: [],
});
});
@@ -226,7 +228,15 @@ describe('launch_app_device plugin (device-shared)', () => {
content: [
{
type: 'text',
- text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nNext Steps:\n1. Interact with your app on the device\n2. Stop the app: stop_app_device({ deviceId: "test-device-123", processId: 12345 })',
+ text: '✅ App launched successfully\n\nApp launched successfully\n\nProcess ID: 12345\n\nInteract with your app on the device.',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'stop_app_device',
+ label: 'Stop the app',
+ params: { deviceId: 'test-device-123', processId: 12345 },
+ priority: 1,
},
],
});
@@ -254,6 +264,7 @@ describe('launch_app_device plugin (device-shared)', () => {
text: '✅ App launched successfully\n\nApp "com.example.app" launched on device "test-device-123"',
},
],
+ nextSteps: [],
});
});
});
diff --git a/src/mcp/tools/device/__tests__/list_devices.test.ts b/src/mcp/tools/device/__tests__/list_devices.test.ts
index de8f9fcc..4b4e2bf4 100644
--- a/src/mcp/tools/device/__tests__/list_devices.test.ts
+++ b/src/mcp/tools/device/__tests__/list_devices.test.ts
@@ -114,7 +114,7 @@ describe('list_devices plugin (device-shared)', () => {
'/tmp/devicectl-123.json',
]);
expect(commandCalls[0].logPrefix).toBe('List Devices (devicectl with JSON)');
- expect(commandCalls[0].useShell).toBe(true);
+ expect(commandCalls[0].useShell).toBe(false);
expect(commandCalls[0].env).toBeUndefined();
});
@@ -175,7 +175,7 @@ describe('list_devices plugin (device-shared)', () => {
expect(commandCalls).toHaveLength(2);
expect(commandCalls[1].command).toEqual(['xcrun', 'xctrace', 'list', 'devices']);
expect(commandCalls[1].logPrefix).toBe('List Devices (xctrace)');
- expect(commandCalls[1].useShell).toBe(true);
+ expect(commandCalls[1].useShell).toBe(false);
expect(commandCalls[1].env).toBeUndefined();
});
});
@@ -229,7 +229,27 @@ describe('list_devices plugin (device-shared)', () => {
content: [
{
type: 'text',
- text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNext Steps:\n1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n",
+ text: "Connected Devices:\n\n✅ Available Devices:\n\n📱 Test iPhone\n UDID: test-device-123\n Model: iPhone15,2\n Product Type: iPhone15,2\n Platform: iOS 17.0\n Connection: USB\n\nNote: Use the device ID/UDID from above when required by other tools.\nHint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n",
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'build_device',
+ label: 'Build for device',
+ params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
+ priority: 1,
+ },
+ {
+ tool: 'test_device',
+ label: 'Run tests on device',
+ params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
+ priority: 2,
+ },
+ {
+ tool: 'get_device_app_path',
+ label: 'Get app path',
+ params: { scheme: 'SCHEME' },
+ priority: 3,
},
],
});
diff --git a/src/mcp/tools/device/__tests__/stop_app_device.test.ts b/src/mcp/tools/device/__tests__/stop_app_device.test.ts
index cfa32bef..6d870297 100644
--- a/src/mcp/tools/device/__tests__/stop_app_device.test.ts
+++ b/src/mcp/tools/device/__tests__/stop_app_device.test.ts
@@ -94,7 +94,7 @@ describe('stop_app_device plugin', () => {
'12345',
]);
expect(capturedDescription).toBe('Stop app on device');
- expect(capturedUseShell).toBe(true);
+ expect(capturedUseShell).toBe(false);
expect(capturedEnv).toBe(undefined);
});
diff --git a/src/mcp/tools/device/get_device_app_path.ts b/src/mcp/tools/device/get_device_app_path.ts
index 387bbfb2..46c81e86 100644
--- a/src/mcp/tools/device/get_device_app_path.ts
+++ b/src/mcp/tools/device/get_device_app_path.ts
@@ -104,7 +104,7 @@ export async function get_device_app_pathLogic(
command.push('-destination', destinationString);
// Execute the command directly
- const result = await executor(command, 'Get App Path', true);
+ const result = await executor(command, 'Get App Path', false);
if (!result.success) {
return createTextResponse(`Failed to get app path: ${result.error}`, true);
@@ -129,20 +129,31 @@ export async function get_device_app_pathLogic(
const fullProductName = fullProductNameMatch[1].trim();
const appPath = `${builtProductsDir}/${fullProductName}`;
- const nextStepsText = `Next Steps:
-1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
-2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" })
-3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`;
-
return {
content: [
{
type: 'text',
text: `✅ App path retrieved successfully: ${appPath}`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: nextStepsText,
+ tool: 'get_app_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install app on device',
+ params: { deviceId: 'DEVICE_UDID', appPath },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch app on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' },
+ priority: 3,
},
],
};
diff --git a/src/mcp/tools/device/install_app_device.ts b/src/mcp/tools/device/install_app_device.ts
index b3cca6f5..00682207 100644
--- a/src/mcp/tools/device/install_app_device.ts
+++ b/src/mcp/tools/device/install_app_device.ts
@@ -44,7 +44,7 @@ export async function install_app_deviceLogic(
const result = await executor(
['xcrun', 'devicectl', 'device', 'install', 'app', '--device', deviceId, appPath],
'Install app on device',
- true, // useShell
+ false, // useShell
undefined, // env
);
diff --git a/src/mcp/tools/device/launch_app_device.ts b/src/mcp/tools/device/launch_app_device.ts
index f9dbffdb..1bcb41df 100644
--- a/src/mcp/tools/device/launch_app_device.ts
+++ b/src/mcp/tools/device/launch_app_device.ts
@@ -70,7 +70,7 @@ export async function launch_app_deviceLogic(
bundleId,
],
'Launch app on device',
- true, // useShell
+ false, // useShell
undefined, // env
);
@@ -119,9 +119,23 @@ export async function launch_app_deviceLogic(
if (processId) {
responseText += `\n\nProcess ID: ${processId}`;
- responseText += `\n\nNext Steps:`;
- responseText += `\n1. Interact with your app on the device`;
- responseText += `\n2. Stop the app: stop_app_device({ deviceId: "${deviceId}", processId: ${processId} })`;
+ responseText += `\n\nInteract with your app on the device.`;
+ }
+
+ const nextSteps: Array<{
+ tool: string;
+ label: string;
+ params: Record;
+ priority?: number;
+ }> = [];
+
+ if (processId) {
+ nextSteps.push({
+ tool: 'stop_app_device',
+ label: 'Stop the app',
+ params: { deviceId, processId },
+ priority: 1,
+ });
}
return {
@@ -131,6 +145,7 @@ export async function launch_app_deviceLogic(
text: responseText,
},
],
+ nextSteps,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
diff --git a/src/mcp/tools/device/list_devices.ts b/src/mcp/tools/device/list_devices.ts
index 9efd6855..82106cf4 100644
--- a/src/mcp/tools/device/list_devices.ts
+++ b/src/mcp/tools/device/list_devices.ts
@@ -49,7 +49,7 @@ export async function list_devicesLogic(
const result = await executor(
['xcrun', 'devicectl', 'list', 'devices', '--json-output', tempJsonPath],
'List Devices (devicectl with JSON)',
- true,
+ false,
undefined,
);
@@ -290,7 +290,7 @@ export async function list_devicesLogic(
const result = await executor(
['xcrun', 'xctrace', 'list', 'devices'],
'List Devices (xctrace)',
- true,
+ false,
undefined,
);
@@ -388,15 +388,38 @@ export async function list_devicesLogic(
(d) => d.state === 'Available' || d.state === 'Available (WiFi)' || d.state === 'Connected',
);
+ const nextSteps: Array<{
+ tool: string;
+ label: string;
+ params: Record;
+ priority?: number;
+ }> = [];
+
if (availableDevicesExist) {
- responseText += 'Next Steps:\n';
- responseText +=
- "1. Build for device: build_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n";
- responseText += "2. Run tests: test_device({ scheme: 'SCHEME', deviceId: 'DEVICE_UDID' })\n";
- responseText += "3. Get app path: get_device_app_path({ scheme: 'SCHEME' })\n\n";
responseText += 'Note: Use the device ID/UDID from above when required by other tools.\n';
responseText +=
"Hint: Save a default device with session-set-defaults { deviceId: 'DEVICE_UDID' }.\n";
+
+ nextSteps.push(
+ {
+ tool: 'build_device',
+ label: 'Build for device',
+ params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
+ priority: 1,
+ },
+ {
+ tool: 'test_device',
+ label: 'Run tests on device',
+ params: { scheme: 'SCHEME', deviceId: 'DEVICE_UDID' },
+ priority: 2,
+ },
+ {
+ tool: 'get_device_app_path',
+ label: 'Get app path',
+ params: { scheme: 'SCHEME' },
+ priority: 3,
+ },
+ );
} else if (uniqueDevices.length > 0) {
responseText +=
'Note: No devices are currently available for testing. Make sure devices are:\n';
@@ -412,6 +435,7 @@ export async function list_devicesLogic(
text: responseText,
},
],
+ nextSteps,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
diff --git a/src/mcp/tools/device/stop_app_device.ts b/src/mcp/tools/device/stop_app_device.ts
index 2cbea4f1..6fb9ccf8 100644
--- a/src/mcp/tools/device/stop_app_device.ts
+++ b/src/mcp/tools/device/stop_app_device.ts
@@ -48,7 +48,7 @@ export async function stop_app_deviceLogic(
processId.toString(),
],
'Stop app on device',
- true, // useShell
+ false, // useShell
undefined, // env
);
diff --git a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts
index 9b721862..03dc2019 100644
--- a/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts
+++ b/src/mcp/tools/logging/__tests__/start_device_log_cap.test.ts
@@ -151,8 +151,9 @@ describe('start_device_log_cap plugin', () => {
mockFileSystemExecutor,
);
- expect(result.content[0].text).toContain('Next Steps:');
- expect(result.content[0].text).toContain('Use stop_device_log_cap');
+ expect(result.content[0].text).toContain('Interact with your app');
+ expect(result.nextSteps).toBeDefined();
+ expect(result.nextSteps![0].tool).toBe('stop_device_log_cap');
});
it('should surface early launch failures when process exits immediately', async () => {
diff --git a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts
index 073b42d6..d0a77114 100644
--- a/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts
+++ b/src/mcp/tools/logging/__tests__/start_sim_log_cap.test.ts
@@ -116,8 +116,16 @@ describe('start_sim_log_cap plugin', () => {
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toBe(
- "Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID 'test-uuid-123' to stop capture and retrieve logs.",
+ 'Log capture started successfully. Session ID: test-uuid-123.\n\nOnly structured logs from the app subsystem are being captured.\n\nInteract with your simulator and app, then stop capture to retrieve logs.',
);
+ expect(result.nextSteps).toEqual([
+ {
+ tool: 'stop_sim_log_cap',
+ label: 'Stop capture and retrieve logs',
+ params: { logSessionId: 'test-uuid-123' },
+ priority: 1,
+ },
+ ]);
});
it('should indicate swiftui capture when subsystemFilter is swiftui', async () => {
diff --git a/src/mcp/tools/logging/start_device_log_cap.ts b/src/mcp/tools/logging/start_device_log_cap.ts
index b95e1eaa..750df756 100644
--- a/src/mcp/tools/logging/start_device_log_cap.ts
+++ b/src/mcp/tools/logging/start_device_log_cap.ts
@@ -657,7 +657,15 @@ export async function start_device_log_capLogic(
content: [
{
type: 'text',
- text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nNext Steps:\n1. Interact with your app on the device\n2. Use stop_device_log_cap({ logSessionId: '${sessionId}' }) to stop capture and retrieve logs`,
+ text: `✅ Device log capture started successfully\n\nSession ID: ${sessionId}\n\nNote: The app has been launched on the device with console output capture enabled.\n\nInteract with your app on the device, then stop capture to retrieve logs.`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'stop_device_log_cap',
+ label: 'Stop capture and retrieve logs',
+ params: { logSessionId: sessionId },
+ priority: 1,
},
],
};
@@ -666,6 +674,9 @@ export async function start_device_log_capLogic(
export default {
name: 'start_device_log_cap',
description: 'Start device log capture.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: startDeviceLogCapSchema,
diff --git a/src/mcp/tools/logging/start_sim_log_cap.ts b/src/mcp/tools/logging/start_sim_log_cap.ts
index 5eae2eb7..4b82093b 100644
--- a/src/mcp/tools/logging/start_sim_log_cap.ts
+++ b/src/mcp/tools/logging/start_sim_log_cap.ts
@@ -73,9 +73,17 @@ export async function start_sim_log_capLogic(
return {
content: [
createTextContent(
- `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nNext Steps:\n1. Interact with your simulator and app.\n2. Use 'stop_sim_log_cap' with session ID '${sessionId}' to stop capture and retrieve logs.`,
+ `Log capture started successfully. Session ID: ${sessionId}.\n\n${captureConsole ? 'Note: Your app was relaunched to capture console output.\n' : ''}${filterDescription}\n\nInteract with your simulator and app, then stop capture to retrieve logs.`,
),
],
+ nextSteps: [
+ {
+ tool: 'stop_sim_log_cap',
+ label: 'Stop capture and retrieve logs',
+ params: { logSessionId: sessionId },
+ priority: 1,
+ },
+ ],
};
}
@@ -86,6 +94,9 @@ const publicSchemaObject = z.strictObject(
export default {
name: 'start_sim_log_cap',
description: 'Start sim log capture.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: startSimLogCapSchema,
diff --git a/src/mcp/tools/logging/stop_device_log_cap.ts b/src/mcp/tools/logging/stop_device_log_cap.ts
index 1b7d86a2..7dda1471 100644
--- a/src/mcp/tools/logging/stop_device_log_cap.ts
+++ b/src/mcp/tools/logging/stop_device_log_cap.ts
@@ -329,6 +329,9 @@ export async function stopDeviceLogCapture(
export default {
name: 'stop_device_log_cap',
description: 'Stop device app and return logs.',
+ cli: {
+ stateful: true,
+ },
schema: stopDeviceLogCapSchema.shape, // MCP SDK compatibility
annotations: {
title: 'Stop Device and Return Logs',
diff --git a/src/mcp/tools/logging/stop_sim_log_cap.ts b/src/mcp/tools/logging/stop_sim_log_cap.ts
index 94538fd6..3a2ce23e 100644
--- a/src/mcp/tools/logging/stop_sim_log_cap.ts
+++ b/src/mcp/tools/logging/stop_sim_log_cap.ts
@@ -55,6 +55,9 @@ export async function stop_sim_log_capLogic(
export default {
name: 'stop_sim_log_cap',
description: 'Stop sim app and return logs.',
+ cli: {
+ stateful: true,
+ },
schema: stopSimLogCapSchema.shape, // MCP SDK compatibility
annotations: {
title: 'Stop Simulator and Return Logs',
diff --git a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts
index 679277d9..64d0b113 100644
--- a/src/mcp/tools/macos/__tests__/build_run_macos.test.ts
+++ b/src/mcp/tools/macos/__tests__/build_run_macos.test.ts
@@ -128,7 +128,7 @@ describe('build_run_macos', () => {
'build',
],
description: 'macOS Build',
- logOutput: true,
+ logOutput: false,
opts: { cwd: '/path/to' },
});
@@ -145,7 +145,7 @@ describe('build_run_macos', () => {
'Debug',
],
description: 'Get Build Settings for Launch',
- logOutput: true,
+ logOutput: false,
opts: undefined,
});
@@ -228,7 +228,7 @@ describe('build_run_macos', () => {
'build',
],
description: 'macOS Build',
- logOutput: true,
+ logOutput: false,
opts: { cwd: '/path/to' },
});
@@ -245,7 +245,7 @@ describe('build_run_macos', () => {
'Debug',
],
description: 'Get Build Settings for Launch',
- logOutput: true,
+ logOutput: false,
opts: undefined,
});
@@ -513,7 +513,7 @@ describe('build_run_macos', () => {
'build',
],
description: 'macOS Build',
- logOutput: true,
+ logOutput: false,
opts: { cwd: '/path/to' },
});
});
diff --git a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts
index 9d0217ad..4dcfa168 100644
--- a/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts
+++ b/src/mcp/tools/macos/__tests__/get_mac_app_path.test.ts
@@ -136,7 +136,7 @@ describe('get_mac_app_path plugin', () => {
'Debug',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -174,7 +174,7 @@ describe('get_mac_app_path plugin', () => {
'Debug',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -216,7 +216,7 @@ describe('get_mac_app_path plugin', () => {
'platform=macOS,arch=arm64',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -258,7 +258,7 @@ describe('get_mac_app_path plugin', () => {
'platform=macOS,arch=x86_64',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -302,7 +302,7 @@ describe('get_mac_app_path plugin', () => {
'--verbose',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -343,7 +343,7 @@ describe('get_mac_app_path plugin', () => {
'platform=macOS,arch=arm64',
],
'Get App Path',
- true,
+ false,
undefined,
]);
});
@@ -383,9 +383,25 @@ FULL_PRODUCT_NAME = MyApp.app
type: 'text',
text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })',
+ tool: 'get_mac_bundle_id',
+ label: 'Get bundle ID',
+ params: {
+ appPath:
+ '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
+ },
+ priority: 1,
+ },
+ {
+ tool: 'launch_mac_app',
+ label: 'Launch app',
+ params: {
+ appPath:
+ '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
+ },
+ priority: 2,
},
],
});
@@ -414,9 +430,25 @@ FULL_PRODUCT_NAME = MyApp.app
type: 'text',
text: '✅ App path retrieved successfully: /Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: 'Next Steps:\n1. Get bundle ID: get_app_bundle_id({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })\n2. Launch app: launch_mac_app({ appPath: "/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app" })',
+ tool: 'get_mac_bundle_id',
+ label: 'Get bundle ID',
+ params: {
+ appPath:
+ '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
+ },
+ priority: 1,
+ },
+ {
+ tool: 'launch_mac_app',
+ label: 'Launch app',
+ params: {
+ appPath:
+ '/Users/test/Library/Developer/Xcode/DerivedData/MyApp-abc123/Build/Products/Debug/MyApp.app',
+ },
+ priority: 2,
},
],
});
diff --git a/src/mcp/tools/macos/__tests__/test_macos.test.ts b/src/mcp/tools/macos/__tests__/test_macos.test.ts
index a0cdfe7f..d64345ac 100644
--- a/src/mcp/tools/macos/__tests__/test_macos.test.ts
+++ b/src/mcp/tools/macos/__tests__/test_macos.test.ts
@@ -353,7 +353,7 @@ describe('test_macos plugin (unified)', () => {
'test',
]);
expect(commandCalls[0].logPrefix).toBe('Test Run');
- expect(commandCalls[0].useShell).toBe(true);
+ expect(commandCalls[0].useShell).toBe(false);
// Verify xcresulttool was called
expect(commandCalls[1].command).toEqual([
diff --git a/src/mcp/tools/macos/build_run_macos.ts b/src/mcp/tools/macos/build_run_macos.ts
index 91f0a2b3..e9be9d8f 100644
--- a/src/mcp/tools/macos/build_run_macos.ts
+++ b/src/mcp/tools/macos/build_run_macos.ts
@@ -111,7 +111,7 @@ async function _getAppPathFromBuildSettings(
}
// Execute the command directly
- const result = await executor(command, 'Get Build Settings for Launch', true, undefined);
+ const result = await executor(command, 'Get Build Settings for Launch', false, undefined);
if (!result.success) {
return {
@@ -176,7 +176,7 @@ export async function buildRunMacOSLogic(
log('info', `App path determined as: ${appPath}`);
// 4. Launch the app using CommandExecutor
- const launchResult = await executor(['open', appPath], 'Launch macOS App', true);
+ const launchResult = await executor(['open', appPath], 'Launch macOS App', false);
if (!launchResult.success) {
log('error', `Build succeeded, but failed to launch app ${appPath}: ${launchResult.error}`);
diff --git a/src/mcp/tools/macos/get_mac_app_path.ts b/src/mcp/tools/macos/get_mac_app_path.ts
index e8c21422..8ad3d8a6 100644
--- a/src/mcp/tools/macos/get_mac_app_path.ts
+++ b/src/mcp/tools/macos/get_mac_app_path.ts
@@ -111,7 +111,7 @@ export async function get_mac_app_pathLogic(
}
// Execute the command directly with executor
- const result = await executor(command, 'Get App Path', true, undefined);
+ const result = await executor(command, 'Get App Path', false, undefined);
if (!result.success) {
return {
@@ -157,20 +157,25 @@ export async function get_mac_app_pathLogic(
const fullProductName = fullProductNameMatch[1].trim();
const appPath = `${builtProductsDir}/${fullProductName}`;
- // Include next steps guidance (following workspace pattern)
- const nextStepsText = `Next Steps:
-1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
-2. Launch app: launch_mac_app({ appPath: "${appPath}" })`;
-
return {
content: [
{
type: 'text',
text: `✅ App path retrieved successfully: ${appPath}`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: nextStepsText,
+ tool: 'get_mac_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'launch_mac_app',
+ label: 'Launch app',
+ params: { appPath },
+ priority: 2,
},
],
};
diff --git a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts
index 177e3fbe..63cbcf15 100644
--- a/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts
+++ b/src/mcp/tools/project-discovery/__tests__/get_app_bundle_id.test.ts
@@ -118,11 +118,31 @@ describe('get_app_bundle_id plugin', () => {
type: 'text',
text: '✅ Bundle ID: com.example.MyApp',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Simulator: install_app_sim + launch_app_sim
-- Device: install_app_device + launch_app_device`,
+ tool: 'install_app_sim',
+ label: 'Install on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' },
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'com.example.MyApp' },
+ priority: 2,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install on device',
+ params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: 'com.example.MyApp' },
+ priority: 4,
},
],
isError: false,
@@ -153,11 +173,31 @@ describe('get_app_bundle_id plugin', () => {
type: 'text',
text: '✅ Bundle ID: com.example.MyApp',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Simulator: install_app_sim + launch_app_sim
-- Device: install_app_device + launch_app_device`,
+ tool: 'install_app_sim',
+ label: 'Install on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', appPath: '/path/to/MyApp.app' },
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'com.example.MyApp' },
+ priority: 2,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install on device',
+ params: { deviceId: 'DEVICE_UDID', appPath: '/path/to/MyApp.app' },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: 'com.example.MyApp' },
+ priority: 4,
},
],
isError: false,
diff --git a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts
index 7d7a15af..012399d8 100644
--- a/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts
+++ b/src/mcp/tools/project-discovery/__tests__/get_mac_bundle_id.test.ts
@@ -97,11 +97,19 @@ describe('get_mac_bundle_id plugin', () => {
type: 'text',
text: '✅ Bundle ID: com.example.MyMacApp',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
-- Build again: build_macos({ scheme: "SCHEME_NAME" })`,
+ tool: 'launch_mac_app',
+ label: 'Launch the app',
+ params: { appPath: '/Applications/MyApp.app' },
+ priority: 1,
+ },
+ {
+ tool: 'build_macos',
+ label: 'Build again',
+ params: { scheme: 'SCHEME_NAME' },
+ priority: 2,
},
],
isError: false,
@@ -132,11 +140,19 @@ describe('get_mac_bundle_id plugin', () => {
type: 'text',
text: '✅ Bundle ID: com.example.MyMacApp',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Launch: launch_mac_app({ appPath: "/Applications/MyApp.app" })
-- Build again: build_macos({ scheme: "SCHEME_NAME" })`,
+ tool: 'launch_mac_app',
+ label: 'Launch the app',
+ params: { appPath: '/Applications/MyApp.app' },
+ priority: 1,
+ },
+ {
+ tool: 'build_macos',
+ label: 'Build again',
+ params: { scheme: 'SCHEME_NAME' },
+ priority: 2,
},
],
isError: false,
diff --git a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts
index deb24914..12c79f53 100644
--- a/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts
+++ b/src/mcp/tools/project-discovery/__tests__/list_schemes.test.ts
@@ -74,14 +74,31 @@ describe('list_schemes plugin', () => {
},
{
type: 'text',
- text: `Next Steps:
-1. Build the app: build_macos({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })
- or for iOS: build_sim({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject", simulatorName: "iPhone 16" })
-2. Show build settings: show_build_settings({ projectPath: "/path/to/MyProject.xcodeproj", scheme: "MyProject" })`,
+ text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyProject" } to avoid repeating it.',
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: {
+ projectPath: '/path/to/MyProject.xcodeproj',
+ scheme: 'MyProject',
+ simulatorName: 'iPhone 16',
+ },
+ priority: 2,
+ },
+ {
+ tool: 'show_build_settings',
+ label: 'Show build settings',
+ params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyProject' },
+ priority: 3,
},
],
isError: false,
@@ -153,11 +170,8 @@ describe('list_schemes plugin', () => {
type: 'text',
text: '',
},
- {
- type: 'text',
- text: '',
- },
],
+ nextSteps: [],
isError: false,
});
});
@@ -227,7 +241,7 @@ describe('list_schemes plugin', () => {
[
['xcodebuild', '-list', '-project', '/path/to/MyProject.xcodeproj'],
'List Schemes',
- true,
+ false,
undefined,
],
]);
@@ -298,14 +312,31 @@ describe('list_schemes plugin', () => {
},
{
type: 'text',
- text: `Next Steps:
-1. Build the app: build_macos({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })
- or for iOS: build_sim({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp", simulatorName: "iPhone 16" })
-2. Show build settings: show_build_settings({ workspacePath: "/path/to/MyProject.xcworkspace", scheme: "MyApp" })`,
+ text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: 'Hint: Consider saving a default scheme with session-set-defaults { scheme: "MyApp" } to avoid repeating it.',
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: {
+ workspacePath: '/path/to/MyProject.xcworkspace',
+ scheme: 'MyApp',
+ simulatorName: 'iPhone 16',
+ },
+ priority: 2,
+ },
+ {
+ tool: 'show_build_settings',
+ label: 'Show build settings',
+ params: { workspacePath: '/path/to/MyProject.xcworkspace', scheme: 'MyApp' },
+ priority: 3,
},
],
isError: false,
@@ -338,7 +369,7 @@ describe('list_schemes plugin', () => {
[
['xcodebuild', '-list', '-workspace', '/path/to/MyProject.xcworkspace'],
'List Schemes',
- true,
+ false,
undefined,
],
]);
diff --git a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts
index 4a987e51..a2912026 100644
--- a/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts
+++ b/src/mcp/tools/project-discovery/__tests__/show_build_settings.test.ts
@@ -100,7 +100,7 @@ describe('show_build_settings plugin', () => {
'MyScheme',
],
'Show Build Settings',
- true,
+ false,
]);
expect(result).toEqual({
@@ -121,6 +121,30 @@ describe('show_build_settings plugin', () => {
SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
},
],
+ nextSteps: [
+ {
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: {
+ projectPath: '/path/to/MyProject.xcodeproj',
+ scheme: 'MyScheme',
+ simulatorName: 'iPhone 16',
+ },
+ priority: 2,
+ },
+ {
+ tool: 'list_schemes',
+ label: 'List schemes',
+ params: { projectPath: '/path/to/MyProject.xcodeproj' },
+ priority: 3,
+ },
+ ],
isError: false,
});
});
@@ -284,7 +308,7 @@ describe('show_build_settings plugin', () => {
'MyScheme',
],
'Show Build Settings',
- true,
+ false,
]);
expect(result).toEqual({
@@ -305,6 +329,30 @@ describe('show_build_settings plugin', () => {
SUPPORTED_PLATFORMS = iphoneos iphonesimulator`,
},
],
+ nextSteps: [
+ {
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { projectPath: '/path/to/MyProject.xcodeproj', scheme: 'MyScheme' },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: {
+ projectPath: '/path/to/MyProject.xcodeproj',
+ scheme: 'MyScheme',
+ simulatorName: 'iPhone 16',
+ },
+ priority: 2,
+ },
+ {
+ tool: 'list_schemes',
+ label: 'List schemes',
+ params: { projectPath: '/path/to/MyProject.xcodeproj' },
+ priority: 3,
+ },
+ ],
isError: false,
});
});
diff --git a/src/mcp/tools/project-discovery/get_app_bundle_id.ts b/src/mcp/tools/project-discovery/get_app_bundle_id.ts
index 48fc745b..e5d14019 100644
--- a/src/mcp/tools/project-discovery/get_app_bundle_id.ts
+++ b/src/mcp/tools/project-discovery/get_app_bundle_id.ts
@@ -90,11 +90,31 @@ export async function get_app_bundle_idLogic(
type: 'text',
text: `✅ Bundle ID: ${bundleId}`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Simulator: install_app_sim + launch_app_sim
-- Device: install_app_device + launch_app_device`,
+ tool: 'install_app_sim',
+ label: 'Install on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', appPath },
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch on simulator',
+ params: { simulatorId: 'SIMULATOR_UUID', bundleId: bundleId.trim() },
+ priority: 2,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install on device',
+ params: { deviceId: 'DEVICE_UDID', appPath },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: bundleId.trim() },
+ priority: 4,
},
],
isError: false,
diff --git a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts
index b7b99941..0e537a16 100644
--- a/src/mcp/tools/project-discovery/get_mac_bundle_id.ts
+++ b/src/mcp/tools/project-discovery/get_mac_bundle_id.ts
@@ -87,11 +87,19 @@ export async function get_mac_bundle_idLogic(
type: 'text',
text: `✅ Bundle ID: ${bundleId}`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-- Launch: launch_mac_app({ appPath: "${appPath}" })
-- Build again: build_macos({ scheme: "SCHEME_NAME" })`,
+ tool: 'launch_mac_app',
+ label: 'Launch the app',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'build_macos',
+ label: 'Build again',
+ params: { scheme: 'SCHEME_NAME' },
+ priority: 2,
},
],
isError: false,
diff --git a/src/mcp/tools/project-discovery/list_schemes.ts b/src/mcp/tools/project-discovery/list_schemes.ts
index a49a9c60..2530842f 100644
--- a/src/mcp/tools/project-discovery/list_schemes.ts
+++ b/src/mcp/tools/project-discovery/list_schemes.ts
@@ -63,7 +63,7 @@ export async function listSchemesLogic(
command.push('-workspace', params.workspacePath!);
}
- const result = await executor(command, 'List Schemes', true);
+ const result = await executor(command, 'List Schemes', false);
if (!result.success) {
return createTextResponse(`Failed to list schemes: ${result.error}`, true);
@@ -80,33 +80,55 @@ export async function listSchemesLogic(
const schemes = schemeLines.map((line) => line.trim()).filter((line) => line);
// Prepare next steps with the first scheme if available
- let nextStepsText = '';
+ const nextSteps: Array<{
+ tool: string;
+ label: string;
+ params: Record;
+ priority?: number;
+ }> = [];
let hintText = '';
+
if (schemes.length > 0) {
const firstScheme = schemes[0];
- // Note: After Phase 2, these will be unified tool names too
- nextStepsText = `Next Steps:
-1. Build the app: build_macos({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })
- or for iOS: build_sim({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}", simulatorName: "iPhone 16" })
-2. Show build settings: show_build_settings({ ${projectOrWorkspace}Path: "${path}", scheme: "${firstScheme}" })`;
+ nextSteps.push(
+ {
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: {
+ [`${projectOrWorkspace}Path`]: path!,
+ scheme: firstScheme,
+ simulatorName: 'iPhone 16',
+ },
+ priority: 2,
+ },
+ {
+ tool: 'show_build_settings',
+ label: 'Show build settings',
+ params: { [`${projectOrWorkspace}Path`]: path!, scheme: firstScheme },
+ priority: 3,
+ },
+ );
hintText =
`Hint: Consider saving a default scheme with session-set-defaults ` +
`{ scheme: "${firstScheme}" } to avoid repeating it.`;
}
- const content = [
- createTextBlock('✅ Available schemes:'),
- createTextBlock(schemes.join('\n')),
- createTextBlock(nextStepsText),
- ];
+ const content = [createTextBlock('✅ Available schemes:'), createTextBlock(schemes.join('\n'))];
if (hintText.length > 0) {
content.push(createTextBlock(hintText));
}
return {
content,
+ nextSteps,
isError: false,
};
} catch (error) {
diff --git a/src/mcp/tools/project-discovery/show_build_settings.ts b/src/mcp/tools/project-discovery/show_build_settings.ts
index aab3b737..8a3e6394 100644
--- a/src/mcp/tools/project-discovery/show_build_settings.ts
+++ b/src/mcp/tools/project-discovery/show_build_settings.ts
@@ -64,13 +64,13 @@ export async function showBuildSettingsLogic(
command.push('-scheme', params.scheme);
// Execute the command directly
- const result = await executor(command, 'Show Build Settings', true);
+ const result = await executor(command, 'Show Build Settings', false);
if (!result.success) {
return createTextResponse(`Failed to show build settings: ${result.error}`, true);
}
- // Create response based on which type was used (similar to workspace version with next steps)
+ // Create response based on which type was used
const content: Array<{ type: 'text'; text: string }> = [
{
type: 'text',
@@ -84,19 +84,41 @@ export async function showBuildSettingsLogic(
},
];
- // Add next steps for workspace (similar to original workspace implementation)
- if (!hasProjectPath && path) {
- content.push({
- type: 'text',
- text: `Next Steps:
-- Build the workspace: build_macos({ workspacePath: "${path}", scheme: "${params.scheme}" })
-- For iOS: build_sim({ workspacePath: "${path}", scheme: "${params.scheme}", simulatorName: "iPhone 16" })
-- List schemes: list_schemes({ workspacePath: "${path}" })`,
- });
+ // Build next steps
+ const nextSteps: Array<{
+ tool: string;
+ label: string;
+ params: Record;
+ priority?: number;
+ }> = [];
+
+ if (path) {
+ const pathKey = hasProjectPath ? 'projectPath' : 'workspacePath';
+ nextSteps.push(
+ {
+ tool: 'build_macos',
+ label: 'Build for macOS',
+ params: { [pathKey]: path, scheme: params.scheme },
+ priority: 1,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for iOS Simulator',
+ params: { [pathKey]: path, scheme: params.scheme, simulatorName: 'iPhone 16' },
+ priority: 2,
+ },
+ {
+ tool: 'list_schemes',
+ label: 'List schemes',
+ params: { [pathKey]: path },
+ priority: 3,
+ },
+ );
}
return {
content,
+ nextSteps,
isError: false,
};
} catch (error) {
diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts
index 4d5b1b69..eccdb166 100644
--- a/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts
+++ b/src/mcp/tools/simulator-management/__tests__/set_sim_appearance.test.ts
@@ -138,7 +138,7 @@ describe('set_sim_appearance plugin', () => {
[
['xcrun', 'simctl', 'ui', 'test-uuid-123', 'appearance', 'dark'],
'Set Simulator Appearance',
- true,
+ false,
undefined,
],
]);
diff --git a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts
index acbc9c76..89bcfd93 100644
--- a/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts
+++ b/src/mcp/tools/simulator-management/__tests__/set_sim_location.test.ts
@@ -380,7 +380,7 @@ describe('set_sim_location tool', () => {
expect(capturedArgs).toEqual([
['xcrun', 'simctl', 'location', 'test-uuid-123', 'set', '37.7749,-122.4194'],
'Set Simulator Location',
- true,
+ false,
{},
]);
});
diff --git a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts
index edc4b963..ae812972 100644
--- a/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts
+++ b/src/mcp/tools/simulator-management/__tests__/sim_statusbar.test.ts
@@ -204,7 +204,7 @@ describe('sim_statusbar tool', () => {
'wifi',
],
operationDescription: 'Set Status Bar',
- keepAlive: true,
+ keepAlive: false,
opts: undefined,
});
});
@@ -245,7 +245,7 @@ describe('sim_statusbar tool', () => {
expect(calls[0]).toEqual({
command: ['xcrun', 'simctl', 'status_bar', 'test-uuid-123', 'clear'],
operationDescription: 'Set Status Bar',
- keepAlive: true,
+ keepAlive: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/simulator-management/reset_sim_location.ts b/src/mcp/tools/simulator-management/reset_sim_location.ts
index 9de8bb28..30b3b34d 100644
--- a/src/mcp/tools/simulator-management/reset_sim_location.ts
+++ b/src/mcp/tools/simulator-management/reset_sim_location.ts
@@ -35,7 +35,7 @@ async function executeSimctlCommandAndRespond(
try {
const command = ['xcrun', 'simctl', ...simctlSubCommand];
- const result = await executor(command, operationDescriptionForXcodeCommand, true, {});
+ const result = await executor(command, operationDescriptionForXcodeCommand, false, {});
if (!result.success) {
const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`;
diff --git a/src/mcp/tools/simulator-management/set_sim_appearance.ts b/src/mcp/tools/simulator-management/set_sim_appearance.ts
index 99da8087..97c5bf9e 100644
--- a/src/mcp/tools/simulator-management/set_sim_appearance.ts
+++ b/src/mcp/tools/simulator-management/set_sim_appearance.ts
@@ -36,7 +36,7 @@ async function executeSimctlCommandAndRespond(
try {
const command = ['xcrun', 'simctl', ...simctlSubCommand];
- const result = await executor(command, operationDescriptionForXcodeCommand, true, undefined);
+ const result = await executor(command, operationDescriptionForXcodeCommand, false, undefined);
if (!result.success) {
const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`;
diff --git a/src/mcp/tools/simulator-management/set_sim_location.ts b/src/mcp/tools/simulator-management/set_sim_location.ts
index e5edbee8..6a4a7cac 100644
--- a/src/mcp/tools/simulator-management/set_sim_location.ts
+++ b/src/mcp/tools/simulator-management/set_sim_location.ts
@@ -37,7 +37,7 @@ async function executeSimctlCommandAndRespond(
try {
const command = ['xcrun', 'simctl', ...simctlSubCommand];
- const result = await executor(command, operationDescriptionForXcodeCommand, true, {});
+ const result = await executor(command, operationDescriptionForXcodeCommand, false, {});
if (!result.success) {
const fullFailureMessage = `${failureMessagePrefix}: ${result.error}`;
diff --git a/src/mcp/tools/simulator-management/sim_statusbar.ts b/src/mcp/tools/simulator-management/sim_statusbar.ts
index 50ee79e0..11b3a2a2 100644
--- a/src/mcp/tools/simulator-management/sim_statusbar.ts
+++ b/src/mcp/tools/simulator-management/sim_statusbar.ts
@@ -60,7 +60,7 @@ export async function sim_statusbarLogic(
successMessage = `Successfully set simulator ${params.simulatorId} status bar data network to ${params.dataNetwork}`;
}
- const result = await executor(command, 'Set Status Bar', true, undefined);
+ const result = await executor(command, 'Set Status Bar', false, undefined);
if (!result.success) {
const failureMessage = `Failed to set status bar: ${result.error}`;
diff --git a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts
index 7f101a5a..23974e46 100644
--- a/src/mcp/tools/simulator/__tests__/boot_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/boot_sim.test.ts
@@ -62,7 +62,27 @@ describe('boot_sim tool', () => {
content: [
{
type: 'text',
- text: `✅ Simulator booted successfully. To make it visible, use: open_sim()\n\nNext steps:\n1. Open the Simulator app (makes it visible): open_sim()\n2. Install an app: install_app_sim({ simulatorId: "test-uuid-123", appPath: "PATH_TO_YOUR_APP" })\n3. Launch an app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`,
+ text: 'Simulator booted successfully.',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'open_sim',
+ label: 'Open the Simulator app (makes it visible)',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'install_app_sim',
+ label: 'Install an app',
+ params: { simulatorId: 'test-uuid-123', appPath: 'PATH_TO_YOUR_APP' },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch an app',
+ params: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 3,
},
],
});
@@ -149,7 +169,7 @@ describe('boot_sim tool', () => {
expect(calls[0]).toEqual({
command: ['xcrun', 'simctl', 'boot', 'test-uuid-123'],
description: 'Boot Simulator',
- allowStderr: true,
+ allowStderr: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts
index c9075bb0..dc65db7d 100644
--- a/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts
+++ b/src/mcp/tools/simulator/__tests__/get_sim_app_path.test.ts
@@ -151,7 +151,7 @@ describe('get_sim_app_path tool', () => {
expect(callHistory).toHaveLength(1);
expect(callHistory[0].logPrefix).toBe('Get App Path');
- expect(callHistory[0].useShell).toBe(true);
+ expect(callHistory[0].useShell).toBe(false);
expect(callHistory[0].command).toEqual([
'xcodebuild',
'-showBuildSettings',
diff --git a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts
index 86495e39..a509450f 100644
--- a/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/install_app_sim.test.ts
@@ -95,7 +95,7 @@ describe('install_app_sim tool', () => {
[
['xcrun', 'simctl', 'install', 'test-uuid-123', '/path/to/app.app'],
'Install App in Simulator',
- true,
+ false,
undefined,
],
[
@@ -136,7 +136,7 @@ describe('install_app_sim tool', () => {
[
['xcrun', 'simctl', 'install', 'different-uuid-456', '/different/path/MyApp.app'],
'Install App in Simulator',
- true,
+ false,
undefined,
],
[
@@ -218,13 +218,21 @@ describe('install_app_sim tool', () => {
content: [
{
type: 'text',
- text: 'App installed successfully in simulator test-uuid-123',
+ text: 'App installed successfully in simulator test-uuid-123.',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-1. Open the Simulator app: open_sim({})
-2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "YOUR_APP_BUNDLE_ID" })`,
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch the app',
+ params: { simulatorId: 'test-uuid-123', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 2,
},
],
});
@@ -274,13 +282,21 @@ describe('install_app_sim tool', () => {
content: [
{
type: 'text',
- text: 'App installed successfully in simulator test-uuid-123',
+ text: 'App installed successfully in simulator test-uuid-123.',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-1. Open the Simulator app: open_sim({})
-2. Launch the app: launch_app_sim({ simulatorId: "test-uuid-123", bundleId: "com.example.myapp" })`,
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch the app',
+ params: { simulatorId: 'test-uuid-123', bundleId: 'com.example.myapp' },
+ priority: 2,
},
],
});
diff --git a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts
index 785170d9..33e0adc5 100644
--- a/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/launch_app_logs_sim.test.ts
@@ -90,7 +90,15 @@ describe('launch_app_logs_sim tool', () => {
content: [
{
type: 'text',
- text: `App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "test-session-123" })' to stop capture and retrieve logs.`,
+ text: 'App launched successfully in simulator test-uuid-123 with log capture enabled.\n\nLog capture session ID: test-session-123\n\nInteract with your app in the simulator, then stop capture to retrieve logs.',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'stop_sim_log_cap',
+ label: 'Stop capture and retrieve logs',
+ params: { logSessionId: 'test-session-123' },
+ priority: 1,
},
],
isError: false,
diff --git a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts
index 5aa297df..51b536a0 100644
--- a/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/launch_app_sim.test.ts
@@ -106,7 +106,31 @@ describe('launch_app_sim tool', () => {
content: [
{
type: 'text',
- text: `✅ App launched successfully in simulator test-uuid-123.\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ simulatorId: "test-uuid-123", bundleId: "com.example.testapp" })\n With console: start_sim_log_cap({ simulatorId: "test-uuid-123", bundleId: "com.example.testapp", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
+ text: 'App launched successfully in simulator test-uuid-123.',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'open_sim',
+ label: 'Open Simulator app to see it',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId: 'test-uuid-123', bundleId: 'com.example.testapp' },
+ priority: 2,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: {
+ simulatorId: 'test-uuid-123',
+ bundleId: 'com.example.testapp',
+ captureConsole: true,
+ },
+ priority: 3,
},
],
});
@@ -341,7 +365,31 @@ describe('launch_app_sim tool', () => {
content: [
{
type: 'text',
- text: `✅ App launched successfully in simulator "iPhone 16" (resolved-uuid).\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp" })\n With console: start_sim_log_cap({ simulatorName: "iPhone 16", bundleId: "com.example.testapp", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
+ text: 'App launched successfully in simulator "iPhone 16" (resolved-uuid).',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'open_sim',
+ label: 'Open Simulator app to see it',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId: 'resolved-uuid', bundleId: 'com.example.testapp' },
+ priority: 2,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: {
+ simulatorId: 'resolved-uuid',
+ bundleId: 'com.example.testapp',
+ captureConsole: true,
+ },
+ priority: 3,
},
],
});
diff --git a/src/mcp/tools/simulator/__tests__/list_sims.test.ts b/src/mcp/tools/simulator/__tests__/list_sims.test.ts
index 16ca2993..49147c3c 100644
--- a/src/mcp/tools/simulator/__tests__/list_sims.test.ts
+++ b/src/mcp/tools/simulator/__tests__/list_sims.test.ts
@@ -101,13 +101,13 @@ describe('list_sims tool', () => {
expect(callHistory[0]).toEqual({
command: ['xcrun', 'simctl', 'list', 'devices', '--json'],
logPrefix: 'List Simulators (JSON)',
- useShell: true,
+ useShell: false,
env: undefined,
});
expect(callHistory[1]).toEqual({
command: ['xcrun', 'simctl', 'list', 'devices'],
logPrefix: 'List Simulators (Text)',
- useShell: true,
+ useShell: false,
env: undefined,
});
@@ -120,14 +120,39 @@ describe('list_sims tool', () => {
iOS 17.0:
- iPhone 15 (test-uuid-123)
-Next Steps:
-1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
-2. Open the simulator UI: open_sim({})
-3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
-4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
},
],
+ nextSteps: [
+ {
+ tool: 'boot_sim',
+ label: 'Boot a simulator',
+ params: { simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 1,
+ },
+ {
+ tool: 'open_sim',
+ label: 'Open the simulator UI',
+ params: {},
+ priority: 2,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for simulator',
+ params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 3,
+ },
+ {
+ tool: 'get_sim_app_path',
+ label: 'Get app path',
+ params: {
+ scheme: 'YOUR_SCHEME',
+ platform: 'iOS Simulator',
+ simulatorId: 'UUID_FROM_ABOVE',
+ },
+ priority: 4,
+ },
+ ],
});
});
@@ -175,14 +200,39 @@ Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FR
iOS 17.0:
- iPhone 15 (test-uuid-123) [Booted]
-Next Steps:
-1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
-2. Open the simulator UI: open_sim({})
-3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
-4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
},
],
+ nextSteps: [
+ {
+ tool: 'boot_sim',
+ label: 'Boot a simulator',
+ params: { simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 1,
+ },
+ {
+ tool: 'open_sim',
+ label: 'Open the simulator UI',
+ params: {},
+ priority: 2,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for simulator',
+ params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 3,
+ },
+ {
+ tool: 'get_sim_app_path',
+ label: 'Get app path',
+ params: {
+ scheme: 'YOUR_SCHEME',
+ platform: 'iOS Simulator',
+ simulatorId: 'UUID_FROM_ABOVE',
+ },
+ priority: 4,
+ },
+ ],
});
});
@@ -236,14 +286,39 @@ iOS 18.6:
iOS 26.0:
- iPhone 17 Pro (text-uuid-456)
-Next Steps:
-1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
-2. Open the simulator UI: open_sim({})
-3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
-4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
},
],
+ nextSteps: [
+ {
+ tool: 'boot_sim',
+ label: 'Boot a simulator',
+ params: { simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 1,
+ },
+ {
+ tool: 'open_sim',
+ label: 'Open the simulator UI',
+ params: {},
+ priority: 2,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for simulator',
+ params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 3,
+ },
+ {
+ tool: 'get_sim_app_path',
+ label: 'Get app path',
+ params: {
+ scheme: 'YOUR_SCHEME',
+ platform: 'iOS Simulator',
+ simulatorId: 'UUID_FROM_ABOVE',
+ },
+ priority: 4,
+ },
+ ],
});
});
@@ -302,14 +377,39 @@ Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FR
iOS 17.0:
- iPhone 15 (test-uuid-456)
-Next Steps:
-1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })
-2. Open the simulator UI: open_sim({})
-3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })
-4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })
Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).`,
},
],
+ nextSteps: [
+ {
+ tool: 'boot_sim',
+ label: 'Boot a simulator',
+ params: { simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 1,
+ },
+ {
+ tool: 'open_sim',
+ label: 'Open the simulator UI',
+ params: {},
+ priority: 2,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for simulator',
+ params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 3,
+ },
+ {
+ tool: 'get_sim_app_path',
+ label: 'Get app path',
+ params: {
+ scheme: 'YOUR_SCHEME',
+ platform: 'iOS Simulator',
+ simulatorId: 'UUID_FROM_ABOVE',
+ },
+ priority: 4,
+ },
+ ],
});
});
diff --git a/src/mcp/tools/simulator/__tests__/open_sim.test.ts b/src/mcp/tools/simulator/__tests__/open_sim.test.ts
index 808c344a..2d853bdf 100644
--- a/src/mcp/tools/simulator/__tests__/open_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/open_sim.test.ts
@@ -61,20 +61,33 @@ describe('open_sim tool', () => {
content: [
{
type: 'text',
- text: 'Simulator app opened successfully',
+ text: 'Simulator app opened successfully.',
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-1. Boot a simulator if needed: boot_sim({ simulatorId: 'UUID_FROM_LIST_SIMULATORS' })
-2. Launch your app and interact with it
-3. Log capture options:
- - Option 1: Capture structured logs only (app continues running):
- start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })
- - Option 2: Capture both console and structured logs (app will restart):
- start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true })
- - Option 3: Launch app with logs in one step:
- launch_app_logs_sim({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`,
+ tool: 'boot_sim',
+ label: 'Boot a simulator if needed',
+ params: { simulatorId: 'UUID_FROM_LIST_SIMS' },
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 2,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_logs_sim',
+ label: 'Launch app with logs in one step',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 4,
},
],
});
@@ -162,7 +175,7 @@ describe('open_sim tool', () => {
expect(calls[0]).toEqual({
command: ['open', '-a', 'Simulator'],
description: 'Open Simulator',
- hideOutput: true,
+ hideOutput: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts
index 2556172b..b7e8b384 100644
--- a/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts
+++ b/src/mcp/tools/simulator/__tests__/record_sim_video.test.ts
@@ -76,12 +76,15 @@ describe('record_sim_video logic - start behavior', () => {
expect(res.isError).toBe(false);
const texts = (res.content ?? []).map((c: any) => c.text).join('\n');
- expect(texts).toContain('🎥');
expect(texts).toMatch(/30\s*fps/i);
expect(texts.toLowerCase()).toContain('outputfile is ignored');
- expect(texts).toContain('Next Steps');
- expect(texts).toContain('stop: true');
- expect(texts).toContain('outputFile');
+
+ // Check nextSteps array instead of embedded text
+ expect(res.nextSteps).toBeDefined();
+ expect(res.nextSteps!.length).toBeGreaterThan(0);
+ expect(res.nextSteps![0].tool).toBe('record_sim_video');
+ expect(res.nextSteps![0].params).toHaveProperty('stop', true);
+ expect(res.nextSteps![0].params).toHaveProperty('outputFile');
});
});
diff --git a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts
index 362eccb5..228d79cb 100644
--- a/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts
+++ b/src/mcp/tools/simulator/__tests__/stop_app_sim.test.ts
@@ -275,7 +275,7 @@ describe('stop_app_sim tool', () => {
{
command: ['xcrun', 'simctl', 'terminate', 'test-uuid', 'com.example.App'],
logPrefix: 'Stop App in Simulator',
- useShell: true,
+ useShell: false,
opts: undefined,
detached: undefined,
},
diff --git a/src/mcp/tools/simulator/boot_sim.ts b/src/mcp/tools/simulator/boot_sim.ts
index 5235de67..eb61d52d 100644
--- a/src/mcp/tools/simulator/boot_sim.ts
+++ b/src/mcp/tools/simulator/boot_sim.ts
@@ -28,7 +28,7 @@ export async function boot_simLogic(
try {
const command = ['xcrun', 'simctl', 'boot', params.simulatorId];
- const result = await executor(command, 'Boot Simulator', true);
+ const result = await executor(command, 'Boot Simulator', false);
if (!result.success) {
return {
@@ -45,12 +45,27 @@ export async function boot_simLogic(
content: [
{
type: 'text',
- text: `✅ Simulator booted successfully. To make it visible, use: open_sim()
-
-Next steps:
-1. Open the Simulator app (makes it visible): open_sim()
-2. Install an app: install_app_sim({ simulatorId: "${params.simulatorId}", appPath: "PATH_TO_YOUR_APP" })
-3. Launch an app: launch_app_sim({ simulatorId: "${params.simulatorId}", bundleId: "YOUR_APP_BUNDLE_ID" })`,
+ text: `Simulator booted successfully.`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'open_sim',
+ label: 'Open the Simulator app (makes it visible)',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'install_app_sim',
+ label: 'Install an app',
+ params: { simulatorId: params.simulatorId, appPath: 'PATH_TO_YOUR_APP' },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch an app',
+ params: { simulatorId: params.simulatorId, bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 3,
},
],
};
diff --git a/src/mcp/tools/simulator/build_run_sim.ts b/src/mcp/tools/simulator/build_run_sim.ts
index 04ecf76b..39a85fb0 100644
--- a/src/mcp/tools/simulator/build_run_sim.ts
+++ b/src/mcp/tools/simulator/build_run_sim.ts
@@ -187,7 +187,7 @@ export async function build_run_simLogic(
}
// Execute the command directly
- const result = await executor(command, 'Get App Path', true, undefined);
+ const result = await executor(command, 'Get App Path', false, undefined);
// If there was an error with the command execution, return it
if (!result.success) {
@@ -461,20 +461,27 @@ export async function build_run_simLogic(
content: [
{
type: 'text',
- text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.
-
-The app (${bundleId}) is now running in the iOS Simulator.
-If you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.
-
-Next Steps:
-- Option 1: Capture structured logs only (app continues running):
- start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' })
-- Option 2: Capture both console and structured logs (app will restart):
- start_simulator_log_capture({ simulatorId: '${simulatorId}', bundleId: '${bundleId}', captureConsole: true })
-- Option 3: Launch app with logs in one step (for a fresh start):
- launch_app_with_logs_in_simulator({ simulatorId: '${simulatorId}', bundleId: '${bundleId}' })
-
-When done with any option, use: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
+ text: `✅ iOS simulator build and run succeeded for scheme ${params.scheme} from ${sourceType} ${sourcePath} targeting ${target}.\n\nThe app (${bundleId}) is now running in the iOS Simulator.\nIf you don't see the simulator window, it may be hidden behind other windows. The Simulator app should be open.`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId, bundleId },
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: { simulatorId, bundleId, captureConsole: true },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_logs_sim',
+ label: 'Launch app with logs in one step',
+ params: { simulatorId, bundleId },
+ priority: 3,
},
],
isError: false,
diff --git a/src/mcp/tools/simulator/get_sim_app_path.ts b/src/mcp/tools/simulator/get_sim_app_path.ts
index 56b52350..65b8f3df 100644
--- a/src/mcp/tools/simulator/get_sim_app_path.ts
+++ b/src/mcp/tools/simulator/get_sim_app_path.ts
@@ -215,7 +215,7 @@ export async function get_sim_app_pathLogic(
command.push('-destination', destinationString);
// Execute the command directly
- const result = await executor(command, 'Get App Path', true, undefined);
+ const result = await executor(command, 'Get App Path', false, undefined);
if (!result.success) {
return createTextResponse(`Failed to get app path: ${result.error}`, true);
@@ -240,17 +240,56 @@ export async function get_sim_app_pathLogic(
const fullProductName = fullProductNameMatch[1].trim();
const appPath = `${builtProductsDir}/${fullProductName}`;
- let nextStepsText = '';
+ // Build nextSteps based on platform
+ let nextSteps: Array<{
+ tool: string;
+ label: string;
+ params: Record;
+ priority?: number;
+ }> = [];
+
if (platform === XcodePlatform.macOS) {
- nextStepsText = `Next Steps:
-1. Get bundle ID: get_mac_bundle_id({ appPath: "${appPath}" })
-2. Launch the app: launch_mac_app({ appPath: "${appPath}" })`;
+ nextSteps = [
+ {
+ tool: 'get_mac_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'launch_mac_app',
+ label: 'Launch the app',
+ params: { appPath },
+ priority: 2,
+ },
+ ];
} else if (isSimulatorPlatform) {
- nextStepsText = `Next Steps:
-1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
-2. Boot simulator: boot_sim({ simulatorId: "SIMULATOR_UUID" })
-3. Install app: install_app_sim({ simulatorId: "SIMULATOR_UUID", appPath: "${appPath}" })
-4. Launch app: launch_app_sim({ simulatorId: "SIMULATOR_UUID", bundleId: "BUNDLE_ID" })`;
+ nextSteps = [
+ {
+ tool: 'get_app_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'boot_sim',
+ label: 'Boot simulator',
+ params: { simulatorId: 'SIMULATOR_UUID' },
+ priority: 2,
+ },
+ {
+ tool: 'install_app_sim',
+ label: 'Install app',
+ params: { simulatorId: 'SIMULATOR_UUID', appPath },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch app',
+ params: { simulatorId: 'SIMULATOR_UUID', bundleId: 'BUNDLE_ID' },
+ priority: 4,
+ },
+ ];
} else if (
[
XcodePlatform.iOS,
@@ -259,15 +298,26 @@ export async function get_sim_app_pathLogic(
XcodePlatform.visionOS,
].includes(platform)
) {
- nextStepsText = `Next Steps:
-1. Get bundle ID: get_app_bundle_id({ appPath: "${appPath}" })
-2. Install app on device: install_app_device({ deviceId: "DEVICE_UDID", appPath: "${appPath}" })
-3. Launch app on device: launch_app_device({ deviceId: "DEVICE_UDID", bundleId: "BUNDLE_ID" })`;
- } else {
- // For other platforms
- nextStepsText = `Next Steps:
-1. The app has been built for ${platform}
-2. Use platform-specific deployment tools to install and run the app`;
+ nextSteps = [
+ {
+ tool: 'get_app_bundle_id',
+ label: 'Get bundle ID',
+ params: { appPath },
+ priority: 1,
+ },
+ {
+ tool: 'install_app_device',
+ label: 'Install app on device',
+ params: { deviceId: 'DEVICE_UDID', appPath },
+ priority: 2,
+ },
+ {
+ tool: 'launch_app_device',
+ label: 'Launch app on device',
+ params: { deviceId: 'DEVICE_UDID', bundleId: 'BUNDLE_ID' },
+ priority: 3,
+ },
+ ];
}
return {
@@ -276,11 +326,8 @@ export async function get_sim_app_pathLogic(
type: 'text',
text: `✅ App path retrieved successfully: ${appPath}`,
},
- {
- type: 'text',
- text: nextStepsText,
- },
],
+ nextSteps,
isError: false,
};
} catch (error) {
diff --git a/src/mcp/tools/simulator/install_app_sim.ts b/src/mcp/tools/simulator/install_app_sim.ts
index 98acddab..9a5df615 100644
--- a/src/mcp/tools/simulator/install_app_sim.ts
+++ b/src/mcp/tools/simulator/install_app_sim.ts
@@ -36,7 +36,7 @@ export async function install_app_simLogic(
try {
const command = ['xcrun', 'simctl', 'install', params.simulatorId, params.appPath];
- const result = await executor(command, 'Install App in Simulator', true, undefined);
+ const result = await executor(command, 'Install App in Simulator', false, undefined);
if (!result.success) {
return {
@@ -68,15 +68,24 @@ export async function install_app_simLogic(
content: [
{
type: 'text',
- text: `App installed successfully in simulator ${params.simulatorId}`,
+ text: `App installed successfully in simulator ${params.simulatorId}.`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-1. Open the Simulator app: open_sim({})
-2. Launch the app: launch_app_sim({ simulatorId: "${params.simulatorId}"${
- bundleId ? `, bundleId: "${bundleId}"` : ', bundleId: "YOUR_APP_BUNDLE_ID"'
- } })`,
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'launch_app_sim',
+ label: 'Launch the app',
+ params: {
+ simulatorId: params.simulatorId,
+ bundleId: bundleId || 'YOUR_APP_BUNDLE_ID',
+ },
+ priority: 2,
},
],
};
diff --git a/src/mcp/tools/simulator/launch_app_logs_sim.ts b/src/mcp/tools/simulator/launch_app_logs_sim.ts
index 0f16cef7..d0d354ed 100644
--- a/src/mcp/tools/simulator/launch_app_logs_sim.ts
+++ b/src/mcp/tools/simulator/launch_app_logs_sim.ts
@@ -59,9 +59,17 @@ export async function launch_app_logs_simLogic(
return {
content: [
createTextContent(
- `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nNext Steps:\n1. Interact with your app in the simulator.\n2. Use 'stop_and_get_simulator_log({ logSessionId: "${sessionId}" })' to stop capture and retrieve logs.`,
+ `App launched successfully in simulator ${params.simulatorId} with log capture enabled.\n\nLog capture session ID: ${sessionId}\n\nInteract with your app in the simulator, then stop capture to retrieve logs.`,
),
],
+ nextSteps: [
+ {
+ tool: 'stop_sim_log_cap',
+ label: 'Stop capture and retrieve logs',
+ params: { logSessionId: sessionId },
+ priority: 1,
+ },
+ ],
isError: false,
};
}
@@ -69,6 +77,9 @@ export async function launch_app_logs_simLogic(
export default {
name: 'launch_app_logs_sim',
description: 'Launch sim app with logs.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: launchAppLogsSimSchemaObject,
diff --git a/src/mcp/tools/simulator/launch_app_sim.ts b/src/mcp/tools/simulator/launch_app_sim.ts
index 2caa0730..5f9181db 100644
--- a/src/mcp/tools/simulator/launch_app_sim.ts
+++ b/src/mcp/tools/simulator/launch_app_sim.ts
@@ -52,7 +52,7 @@ export async function launch_app_simLogic(
const simulatorListResult = await executor(
['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
'List Simulators',
- true,
+ false,
);
if (!simulatorListResult.success) {
return {
@@ -122,7 +122,7 @@ export async function launch_app_simLogic(
const getAppContainerResult = await executor(
getAppContainerCmd,
'Check App Installed',
- true,
+ false,
undefined,
);
if (!getAppContainerResult.success) {
@@ -154,7 +154,7 @@ export async function launch_app_simLogic(
command.push(...params.args);
}
- const result = await executor(command, 'Launch App in Simulator', true, undefined);
+ const result = await executor(command, 'Launch App in Simulator', false, undefined);
if (!result.success) {
return {
@@ -167,14 +167,31 @@ export async function launch_app_simLogic(
};
}
- const userParamName = params.simulatorId ? 'simulatorId' : 'simulatorName';
- const userParamValue = params.simulatorId ?? params.simulatorName ?? simulatorId;
-
return {
content: [
{
type: 'text',
- text: `✅ App launched successfully in simulator ${simulatorDisplayName || simulatorId}.\n\nNext Steps:\n1. To see simulator: open_sim()\n2. Log capture: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}" })\n With console: start_sim_log_cap({ ${userParamName}: "${userParamValue}", bundleId: "${params.bundleId}", captureConsole: true })\n3. Stop logs: stop_sim_log_cap({ logSessionId: 'SESSION_ID' })`,
+ text: `App launched successfully in simulator ${simulatorDisplayName || simulatorId}.`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'open_sim',
+ label: 'Open Simulator app to see it',
+ params: {},
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId, bundleId: params.bundleId },
+ priority: 2,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: { simulatorId, bundleId: params.bundleId, captureConsole: true },
+ priority: 3,
},
],
};
diff --git a/src/mcp/tools/simulator/list_sims.ts b/src/mcp/tools/simulator/list_sims.ts
index 0969d36a..89de957e 100644
--- a/src/mcp/tools/simulator/list_sims.ts
+++ b/src/mcp/tools/simulator/list_sims.ts
@@ -108,7 +108,7 @@ export async function list_simsLogic(
try {
// Try JSON first for structured data
const jsonCommand = ['xcrun', 'simctl', 'list', 'devices', '--json'];
- const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', true);
+ const jsonResult = await executor(jsonCommand, 'List Simulators (JSON)', false);
if (!jsonResult.success) {
return {
@@ -134,7 +134,7 @@ export async function list_simsLogic(
// Fallback to text parsing for Apple simctl bugs (duplicate runtime IDs in iOS 26.0 beta)
const textCommand = ['xcrun', 'simctl', 'list', 'devices'];
- const textResult = await executor(textCommand, 'List Simulators (Text)', true);
+ const textResult = await executor(textCommand, 'List Simulators (Text)', false);
const textDevices = textResult.success ? parseTextOutput(textResult.output) : [];
@@ -183,13 +183,6 @@ export async function list_simsLogic(
responseText += '\n';
}
- responseText += 'Next Steps:\n';
- responseText += "1. Boot a simulator: boot_sim({ simulatorId: 'UUID_FROM_ABOVE' })\n";
- responseText += '2. Open the simulator UI: open_sim({})\n';
- responseText +=
- "3. Build for simulator: build_sim({ scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' })\n";
- responseText +=
- "4. Get app path: get_sim_app_path({ scheme: 'YOUR_SCHEME', platform: 'iOS Simulator', simulatorId: 'UUID_FROM_ABOVE' })\n";
responseText +=
"Hint: Save a default simulator with session-set-defaults { simulatorId: 'UUID_FROM_ABOVE' } (or simulatorName).";
@@ -200,6 +193,36 @@ export async function list_simsLogic(
text: responseText,
},
],
+ nextSteps: [
+ {
+ tool: 'boot_sim',
+ label: 'Boot a simulator',
+ params: { simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 1,
+ },
+ {
+ tool: 'open_sim',
+ label: 'Open the simulator UI',
+ params: {},
+ priority: 2,
+ },
+ {
+ tool: 'build_sim',
+ label: 'Build for simulator',
+ params: { scheme: 'YOUR_SCHEME', simulatorId: 'UUID_FROM_ABOVE' },
+ priority: 3,
+ },
+ {
+ tool: 'get_sim_app_path',
+ label: 'Get app path',
+ params: {
+ scheme: 'YOUR_SCHEME',
+ platform: 'iOS Simulator',
+ simulatorId: 'UUID_FROM_ABOVE',
+ },
+ priority: 4,
+ },
+ ],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
diff --git a/src/mcp/tools/simulator/open_sim.ts b/src/mcp/tools/simulator/open_sim.ts
index fdb8cb1d..4e2d61ce 100644
--- a/src/mcp/tools/simulator/open_sim.ts
+++ b/src/mcp/tools/simulator/open_sim.ts
@@ -19,7 +19,7 @@ export async function open_simLogic(
try {
const command = ['open', '-a', 'Simulator'];
- const result = await executor(command, 'Open Simulator', true);
+ const result = await executor(command, 'Open Simulator', false);
if (!result.success) {
return {
@@ -36,20 +36,33 @@ export async function open_simLogic(
content: [
{
type: 'text',
- text: `Simulator app opened successfully`,
+ text: `Simulator app opened successfully.`,
},
+ ],
+ nextSteps: [
{
- type: 'text',
- text: `Next Steps:
-1. Boot a simulator if needed: boot_sim({ simulatorId: 'UUID_FROM_LIST_SIMULATORS' })
-2. Launch your app and interact with it
-3. Log capture options:
- - Option 1: Capture structured logs only (app continues running):
- start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })
- - Option 2: Capture both console and structured logs (app will restart):
- start_sim_log_cap({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true })
- - Option 3: Launch app with logs in one step:
- launch_app_logs_sim({ simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' })`,
+ tool: 'boot_sim',
+ label: 'Boot a simulator if needed',
+ params: { simulatorId: 'UUID_FROM_LIST_SIMS' },
+ priority: 1,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture structured logs (app continues running)',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 2,
+ },
+ {
+ tool: 'start_sim_log_cap',
+ label: 'Capture console + structured logs (app restarts)',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID', captureConsole: true },
+ priority: 3,
+ },
+ {
+ tool: 'launch_app_logs_sim',
+ label: 'Launch app with logs in one step',
+ params: { simulatorId: 'UUID', bundleId: 'YOUR_APP_BUNDLE_ID' },
+ priority: 4,
},
],
};
diff --git a/src/mcp/tools/simulator/record_sim_video.ts b/src/mcp/tools/simulator/record_sim_video.ts
index 62c9a25b..787e6274 100644
--- a/src/mcp/tools/simulator/record_sim_video.ts
+++ b/src/mcp/tools/simulator/record_sim_video.ts
@@ -112,15 +112,11 @@ export async function record_sim_videoLogic(
notes.push(startRes.warning);
}
- const nextSteps = `Next Steps:
-Stop and save the recording:
-record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile: "/path/to/output.mp4" })`;
-
return {
content: [
{
type: 'text',
- text: `🎥 Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`,
+ text: `Video recording started for simulator ${params.simulatorId} at ${fpsUsed} fps.\nSession: ${startRes.sessionId}`,
},
...(notes.length > 0
? [
@@ -130,9 +126,17 @@ record_sim_video({ simulatorId: "${params.simulatorId}", stop: true, outputFile:
},
]
: []),
+ ],
+ nextSteps: [
{
- type: 'text',
- text: nextSteps,
+ tool: 'record_sim_video',
+ label: 'Stop and save the recording',
+ params: {
+ simulatorId: params.simulatorId,
+ stop: true,
+ outputFile: '/path/to/output.mp4',
+ },
+ priority: 1,
},
],
isError: false,
@@ -224,6 +228,9 @@ const publicSchemaObject = z.strictObject(
export default {
name: 'record_sim_video',
description: 'Record sim video.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: recordSimVideoSchemaObject,
diff --git a/src/mcp/tools/simulator/stop_app_sim.ts b/src/mcp/tools/simulator/stop_app_sim.ts
index 2e1d4970..628a4aa1 100644
--- a/src/mcp/tools/simulator/stop_app_sim.ts
+++ b/src/mcp/tools/simulator/stop_app_sim.ts
@@ -51,7 +51,7 @@ export async function stop_app_simLogic(
const simulatorListResult = await executor(
['xcrun', 'simctl', 'list', 'devices', 'available', '--json'],
'List Simulators',
- true,
+ false,
);
if (!simulatorListResult.success) {
return {
@@ -111,7 +111,7 @@ export async function stop_app_simLogic(
try {
const command = ['xcrun', 'simctl', 'terminate', simulatorId, params.bundleId];
- const result = await executor(command, 'Stop App in Simulator', true, undefined);
+ const result = await executor(command, 'Stop App in Simulator', false, undefined);
if (!result.success) {
return {
diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts
index b2a04a60..8da9cfdd 100644
--- a/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts
+++ b/src/mcp/tools/swift-package/__tests__/swift_package_build.test.ts
@@ -88,7 +88,7 @@ describe('swift_package_build plugin', () => {
{
args: ['swift', 'build', '--package-path', '/test/package'],
description: 'Swift Package Build',
- useShell: true,
+ useShell: false,
cwd: undefined,
},
]);
@@ -116,7 +116,7 @@ describe('swift_package_build plugin', () => {
{
args: ['swift', 'build', '--package-path', '/test/package', '-c', 'release'],
description: 'Swift Package Build',
- useShell: true,
+ useShell: false,
cwd: undefined,
},
]);
@@ -162,7 +162,7 @@ describe('swift_package_build plugin', () => {
'-parse-as-library',
],
description: 'Swift Package Build',
- useShell: true,
+ useShell: false,
cwd: undefined,
},
]);
diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts
index 1c24ad84..5a3d0360 100644
--- a/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts
+++ b/src/mcp/tools/swift-package/__tests__/swift_package_clean.test.ts
@@ -68,7 +68,7 @@ describe('swift_package_clean plugin', () => {
expect(calls[0]).toEqual({
command: ['swift', 'package', '--package-path', '/test/package', 'clean'],
description: 'Swift Package Clean',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts
index 663c6ac6..6f730417 100644
--- a/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts
+++ b/src/mcp/tools/swift-package/__tests__/swift_package_run.test.ts
@@ -105,7 +105,7 @@ describe('swift_package_run plugin', () => {
expect(executorCalls[0]).toEqual({
command: ['swift', 'run', '--package-path', '/test/package'],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -133,7 +133,7 @@ describe('swift_package_run plugin', () => {
expect(executorCalls[0]).toEqual({
command: ['swift', 'run', '--package-path', '/test/package', '-c', 'release'],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -161,7 +161,7 @@ describe('swift_package_run plugin', () => {
expect(executorCalls[0]).toEqual({
command: ['swift', 'run', '--package-path', '/test/package', 'MyApp'],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -189,7 +189,7 @@ describe('swift_package_run plugin', () => {
expect(executorCalls[0]).toEqual({
command: ['swift', 'run', '--package-path', '/test/package', '--', 'arg1', 'arg2'],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -224,7 +224,7 @@ describe('swift_package_run plugin', () => {
'-parse-as-library',
],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
@@ -267,7 +267,7 @@ describe('swift_package_run plugin', () => {
'arg1',
],
logPrefix: 'Swift Package Run',
- useShell: true,
+ useShell: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts
index c09ac011..e553d040 100644
--- a/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts
+++ b/src/mcp/tools/swift-package/__tests__/swift_package_test.test.ts
@@ -102,7 +102,7 @@ describe('swift_package_test plugin', () => {
expect(calls[0]).toEqual({
args: ['swift', 'test', '--package-path', '/test/package'],
name: 'Swift Package Test',
- hideOutput: true,
+ hideOutput: false,
opts: undefined,
});
});
@@ -155,7 +155,7 @@ describe('swift_package_test plugin', () => {
'-parse-as-library',
],
name: 'Swift Package Test',
- hideOutput: true,
+ hideOutput: false,
opts: undefined,
});
});
diff --git a/src/mcp/tools/swift-package/active-processes.ts b/src/mcp/tools/swift-package/active-processes.ts
index eefa4afb..c125705c 100644
--- a/src/mcp/tools/swift-package/active-processes.ts
+++ b/src/mcp/tools/swift-package/active-processes.ts
@@ -11,6 +11,8 @@ export interface ProcessInfo {
pid?: number;
};
startedAt: Date;
+ executableName?: string;
+ packagePath?: string;
}
// Global map to track active processes
diff --git a/src/mcp/tools/swift-package/swift_package_build.ts b/src/mcp/tools/swift-package/swift_package_build.ts
index ebc54de9..a1cc75bb 100644
--- a/src/mcp/tools/swift-package/swift_package_build.ts
+++ b/src/mcp/tools/swift-package/swift_package_build.ts
@@ -55,7 +55,7 @@ export async function swift_package_buildLogic(
log('info', `Running swift ${swiftArgs.join(' ')}`);
try {
- const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', true, undefined);
+ const result = await executor(['swift', ...swiftArgs], 'Swift Package Build', false, undefined);
if (!result.success) {
const errorMessage = result.error ?? result.output ?? 'Unknown error';
return createErrorResponse('Swift package build failed', errorMessage);
diff --git a/src/mcp/tools/swift-package/swift_package_clean.ts b/src/mcp/tools/swift-package/swift_package_clean.ts
index 0a6195c4..be191bf0 100644
--- a/src/mcp/tools/swift-package/swift_package_clean.ts
+++ b/src/mcp/tools/swift-package/swift_package_clean.ts
@@ -24,7 +24,7 @@ export async function swift_package_cleanLogic(
log('info', `Running swift ${swiftArgs.join(' ')}`);
try {
- const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', true, undefined);
+ const result = await executor(['swift', ...swiftArgs], 'Swift Package Clean', false, undefined);
if (!result.success) {
const errorMessage = result.error ?? result.output ?? 'Unknown error';
return createErrorResponse('Swift package clean failed', errorMessage);
diff --git a/src/mcp/tools/swift-package/swift_package_list.ts b/src/mcp/tools/swift-package/swift_package_list.ts
index 9afc368c..9ed1b960 100644
--- a/src/mcp/tools/swift-package/swift_package_list.ts
+++ b/src/mcp/tools/swift-package/swift_package_list.ts
@@ -7,20 +7,19 @@ import * as z from 'zod';
import { ToolResponse, createTextContent } from '../../../types/common.ts';
import { createTypedTool } from '../../../utils/typed-tool-factory.ts';
import { getDefaultCommandExecutor } from '../../../utils/command.ts';
-
-interface ProcessInfo {
- executableName?: string;
- startedAt: Date;
- packagePath: string;
-}
-
-const activeProcesses = new Map();
+import { activeProcesses } from './active-processes.ts';
/**
* Process list dependencies for dependency injection
*/
+type ListProcessInfo = {
+ executableName?: string;
+ packagePath?: string;
+ startedAt: Date;
+};
+
export interface ProcessListDependencies {
- processMap?: Map;
+ processMap?: Map;
arrayFrom?: typeof Array.from;
dateNow?: typeof Date.now;
}
@@ -35,7 +34,18 @@ export async function swift_package_listLogic(
params?: unknown,
dependencies?: ProcessListDependencies,
): Promise {
- const processMap = dependencies?.processMap ?? activeProcesses;
+ const processMap =
+ dependencies?.processMap ??
+ new Map(
+ Array.from(activeProcesses.entries()).map(([pid, info]) => [
+ pid,
+ {
+ executableName: info.executableName,
+ packagePath: info.packagePath,
+ startedAt: info.startedAt,
+ },
+ ]),
+ );
const arrayFrom = dependencies?.arrayFrom ?? Array.from;
const dateNow = dependencies?.dateNow ?? Date.now;
@@ -57,10 +67,9 @@ export async function swift_package_listLogic(
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const executableName = info.executableName || 'default';
const runtime = Math.max(1, Math.round((dateNow() - info.startedAt.getTime()) / 1000));
+ const packagePath = info.packagePath ?? 'unknown package';
content.push(
- createTextContent(
- ` • PID ${pid}: ${executableName} (${info.packagePath}) - running ${runtime}s`,
- ),
+ createTextContent(` • PID ${pid}: ${executableName} (${packagePath}) - running ${runtime}s`),
);
}
@@ -78,6 +87,9 @@ type SwiftPackageListParams = z.infer;
export default {
name: 'swift_package_list',
description: 'List SwiftPM processes.',
+ cli: {
+ stateful: true,
+ },
schema: swiftPackageListSchema.shape, // MCP SDK compatibility
annotations: {
title: 'Swift Package List',
diff --git a/src/mcp/tools/swift-package/swift_package_run.ts b/src/mcp/tools/swift-package/swift_package_run.ts
index 23265850..43684c04 100644
--- a/src/mcp/tools/swift-package/swift_package_run.ts
+++ b/src/mcp/tools/swift-package/swift_package_run.ts
@@ -89,7 +89,7 @@ export async function swift_package_runLogic(
const result = await executor(
command,
'Swift Package Run (Background)',
- true,
+ false,
cleanEnv,
true,
);
@@ -112,6 +112,8 @@ export async function swift_package_runLogic(
pid: result.process.pid,
},
startedAt: new Date(),
+ executableName: params.executableName,
+ packagePath: resolvedPath,
});
return {
@@ -138,7 +140,7 @@ export async function swift_package_runLogic(
const command = ['swift', ...swiftArgs];
// Create a promise that will either complete with the command result or timeout
- const commandPromise = executor(command, 'Swift Package Run', true, undefined);
+ const commandPromise = executor(command, 'Swift Package Run', false, undefined);
const timeoutPromise = new Promise<{
success: boolean;
@@ -219,6 +221,9 @@ export async function swift_package_runLogic(
export default {
name: 'swift_package_run',
description: 'swift package target run.',
+ cli: {
+ stateful: true,
+ },
schema: getSessionAwareToolSchemaShape({
sessionAware: publicSchemaObject,
legacy: baseSchemaObject,
diff --git a/src/mcp/tools/swift-package/swift_package_stop.ts b/src/mcp/tools/swift-package/swift_package_stop.ts
index a6c468b5..57115145 100644
--- a/src/mcp/tools/swift-package/swift_package_stop.ts
+++ b/src/mcp/tools/swift-package/swift_package_stop.ts
@@ -104,6 +104,9 @@ export async function swift_package_stopLogic(
export default {
name: 'swift_package_stop',
description: 'Stop SwiftPM run.',
+ cli: {
+ stateful: true,
+ },
schema: swiftPackageStopSchema.shape, // MCP SDK compatibility
annotations: {
title: 'Swift Package Stop',
diff --git a/src/mcp/tools/swift-package/swift_package_test.ts b/src/mcp/tools/swift-package/swift_package_test.ts
index 1ce0b02d..3eee4323 100644
--- a/src/mcp/tools/swift-package/swift_package_test.ts
+++ b/src/mcp/tools/swift-package/swift_package_test.ts
@@ -65,7 +65,7 @@ export async function swift_package_testLogic(
log('info', `Running swift ${swiftArgs.join(' ')}`);
try {
- const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', true, undefined);
+ const result = await executor(['swift', ...swiftArgs], 'Swift Package Test', false, undefined);
if (!result.success) {
const errorMessage = result.error ?? result.output ?? 'Unknown error';
return createErrorResponse('Swift package tests failed', errorMessage);
diff --git a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
index 8d1ac302..d7f68dad 100644
--- a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
@@ -272,16 +272,8 @@ describe('Screenshot Plugin', () => {
mockFileSystemExecutor,
);
- expect(result).toEqual({
- content: [
- {
- type: 'image',
- data: 'fake-image-data',
- mimeType: 'image/jpeg',
- },
- ],
- isError: false,
- });
+ expect(result.isError).toBe(false);
+ expect(result.content[0].type).toBe('image');
});
it('should handle command execution failure', async () => {
@@ -326,6 +318,7 @@ describe('Screenshot Plugin', () => {
const result = await screenshotLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
+ returnFormat: 'base64',
},
mockExecutor,
mockFileSystemExecutor,
@@ -360,6 +353,7 @@ describe('Screenshot Plugin', () => {
const result = await screenshotLogic(
{
simulatorId: '12345678-1234-4234-8234-123456789012',
+ returnFormat: 'base64',
},
mockExecutor,
mockFileSystemExecutor,
@@ -875,7 +869,7 @@ describe('Screenshot Plugin', () => {
});
const result = await screenshotLogic(
- { simulatorId: '12345678-1234-4234-8234-123456789012' },
+ { simulatorId: '12345678-1234-4234-8234-123456789012', returnFormat: 'base64' },
trackingExecutor,
mockFileSystemExecutor,
{ tmpdir: () => '/tmp', join: (...paths) => paths.join('/') },
diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
index 3ba5303b..1bf8c1a6 100644
--- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
@@ -106,11 +106,27 @@ describe('Snapshot UI Plugin', () => {
},
{
type: 'text' as const,
- text: `Next Steps:
-- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)
-- Re-run snapshot_ui after layout changes
-- If a debugger is attached, ensure the app is running (not stopped on breakpoints)
-- Screenshots are for visual verification only`,
+ text: 'Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only',
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'snapshot_ui',
+ label: 'Refresh after layout changes',
+ params: { simulatorId: '12345678-1234-4234-8234-123456789012' },
+ priority: 1,
+ },
+ {
+ tool: 'tap_coordinate',
+ label: 'Tap on element',
+ params: { simulatorId: '12345678-1234-4234-8234-123456789012', x: 0, y: 0 },
+ priority: 2,
+ },
+ {
+ tool: 'take_screenshot',
+ label: 'Take screenshot for verification',
+ params: { simulatorId: '12345678-1234-4234-8234-123456789012' },
+ priority: 3,
},
],
});
diff --git a/src/mcp/tools/ui-automation/button.ts b/src/mcp/tools/ui-automation/button.ts
index 8dab02a6..23c72ff7 100644
--- a/src/mcp/tools/ui-automation/button.ts
+++ b/src/mcp/tools/ui-automation/button.ts
@@ -111,6 +111,9 @@ export default {
title: 'Hardware Button',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: buttonSchema as unknown as z.ZodType,
logicFunction: (params: ButtonParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/gesture.ts b/src/mcp/tools/ui-automation/gesture.ts
index 6564a177..2622fb25 100644
--- a/src/mcp/tools/ui-automation/gesture.ts
+++ b/src/mcp/tools/ui-automation/gesture.ts
@@ -180,6 +180,9 @@ export default {
title: 'Gesture',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: gestureSchema as unknown as z.ZodType,
logicFunction: (params: GestureParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/key_press.ts b/src/mcp/tools/ui-automation/key_press.ts
index b40ce04e..c2c47fd1 100644
--- a/src/mcp/tools/ui-automation/key_press.ts
+++ b/src/mcp/tools/ui-automation/key_press.ts
@@ -121,6 +121,9 @@ export default {
title: 'Key Press',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: keyPressSchema as unknown as z.ZodType,
logicFunction: (params: KeyPressParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/key_sequence.ts b/src/mcp/tools/ui-automation/key_sequence.ts
index 57eb952c..b865d08f 100644
--- a/src/mcp/tools/ui-automation/key_sequence.ts
+++ b/src/mcp/tools/ui-automation/key_sequence.ts
@@ -124,6 +124,9 @@ export default {
title: 'Key Sequence',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: keySequenceSchema as unknown as z.ZodType,
logicFunction: (params: KeySequenceParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/long_press.ts b/src/mcp/tools/ui-automation/long_press.ts
index 2ca289b5..ccdc8a15 100644
--- a/src/mcp/tools/ui-automation/long_press.ts
+++ b/src/mcp/tools/ui-automation/long_press.ts
@@ -141,6 +141,9 @@ export default {
title: 'Long Press',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: longPressSchema as unknown as z.ZodType,
logicFunction: (params: LongPressParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/screenshot.ts b/src/mcp/tools/ui-automation/screenshot.ts
index 9455f575..e63847a7 100644
--- a/src/mcp/tools/ui-automation/screenshot.ts
+++ b/src/mcp/tools/ui-automation/screenshot.ts
@@ -12,7 +12,11 @@ import * as z from 'zod';
import { v4 as uuidv4 } from 'uuid';
import { ToolResponse, createImageContent } from '../../../types/common.ts';
import { log } from '../../../utils/logging/index.ts';
-import { createErrorResponse, SystemError } from '../../../utils/responses/index.ts';
+import {
+ createErrorResponse,
+ createTextResponse,
+ SystemError,
+} from '../../../utils/responses/index.ts';
import type { CommandExecutor, FileSystemExecutor } from '../../../utils/execution/index.ts';
import {
getDefaultFileSystemExecutor,
@@ -164,6 +168,10 @@ export async function rotateImage(
// Define schema as ZodObject
const screenshotSchema = z.object({
simulatorId: z.uuid({ message: 'Invalid Simulator UUID format' }),
+ returnFormat: z
+ .enum(['path', 'base64'])
+ .optional()
+ .describe('Return image path or base64 data (path|base64)'),
});
// Use z.infer for type safety
@@ -181,6 +189,9 @@ export async function screenshotLogic(
uuidUtils: { v4: () => string } = { v4: uuidv4 },
): Promise {
const { simulatorId } = params;
+ const runtime = process.env.XCODEBUILDMCP_RUNTIME;
+ const defaultFormat = runtime === 'cli' || runtime === 'daemon' ? 'path' : 'base64';
+ const returnFormat = params.returnFormat ?? defaultFormat;
const tempDir = pathUtils.tmpdir();
const screenshotFilename = `screenshot_${uuidUtils.v4()}.png`;
const screenshotPath = pathUtils.join(tempDir, screenshotFilename);
@@ -242,42 +253,59 @@ export async function screenshotLogic(
if (!optimizeResult.success) {
log('warning', `${LOG_PREFIX}/screenshot: Image optimization failed, using original PNG`);
- // Fallback to original PNG if optimization fails
- const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64');
+ if (returnFormat === 'base64') {
+ // Fallback to original PNG if optimization fails
+ const base64Image = await fileSystemExecutor.readFile(screenshotPath, 'base64');
+
+ // Clean up
+ try {
+ await fileSystemExecutor.rm(screenshotPath);
+ } catch (err) {
+ log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`);
+ }
+
+ return {
+ content: [createImageContent(base64Image, 'image/png')],
+ isError: false,
+ };
+ }
+
+ return createTextResponse(
+ `Screenshot captured: ${screenshotPath} (image/png, optimization failed)`,
+ );
+ }
- // Clean up
+ log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`);
+
+ if (returnFormat === 'base64') {
+ // Read the optimized image file as base64
+ const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64');
+
+ log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`);
+
+ // Clean up both temporary files
try {
await fileSystemExecutor.rm(screenshotPath);
+ await fileSystemExecutor.rm(optimizedPath);
} catch (err) {
- log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`);
+ log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`);
}
+ // Return the optimized image (JPEG format, smaller size)
return {
- content: [createImageContent(base64Image, 'image/png')],
+ content: [createImageContent(base64Image, 'image/jpeg')],
isError: false,
};
}
- log('info', `${LOG_PREFIX}/screenshot: Image optimized successfully`);
-
- // Read the optimized image file as base64
- const base64Image = await fileSystemExecutor.readFile(optimizedPath, 'base64');
-
- log('info', `${LOG_PREFIX}/screenshot: Successfully encoded image as Base64`);
-
- // Clean up both temporary files
+ // Keep optimized file on disk for path-based return
try {
await fileSystemExecutor.rm(screenshotPath);
- await fileSystemExecutor.rm(optimizedPath);
} catch (err) {
- log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temporary files: ${err}`);
+ log('warning', `${LOG_PREFIX}/screenshot: Failed to delete temp file: ${err}`);
}
- // Return the optimized image (JPEG format, smaller size)
- return {
- content: [createImageContent(base64Image, 'image/jpeg')],
- isError: false,
- };
+ return createTextResponse(`Screenshot captured: ${optimizedPath} (image/jpeg)`);
} catch (fileError) {
log('error', `${LOG_PREFIX}/screenshot: Failed to process image file: ${fileError}`);
return createErrorResponse(
diff --git a/src/mcp/tools/ui-automation/snapshot_ui.ts b/src/mcp/tools/ui-automation/snapshot_ui.ts
index c75e62d5..5f4128e7 100644
--- a/src/mcp/tools/ui-automation/snapshot_ui.ts
+++ b/src/mcp/tools/ui-automation/snapshot_ui.ts
@@ -83,11 +83,27 @@ export async function snapshot_uiLogic(
},
{
type: 'text',
- text: `Next Steps:
-- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)
-- Re-run snapshot_ui after layout changes
-- If a debugger is attached, ensure the app is running (not stopped on breakpoints)
-- Screenshots are for visual verification only`,
+ text: `Tips:\n- Use frame coordinates for tap/swipe (center: x+width/2, y+height/2)\n- If a debugger is attached, ensure the app is running (not stopped on breakpoints)\n- Screenshots are for visual verification only`,
+ },
+ ],
+ nextSteps: [
+ {
+ tool: 'snapshot_ui',
+ label: 'Refresh after layout changes',
+ params: { simulatorId },
+ priority: 1,
+ },
+ {
+ tool: 'tap_coordinate',
+ label: 'Tap on element',
+ params: { simulatorId, x: 0, y: 0 },
+ priority: 2,
+ },
+ {
+ tool: 'take_screenshot',
+ label: 'Take screenshot for verification',
+ params: { simulatorId },
+ priority: 3,
},
],
};
@@ -132,6 +148,9 @@ export default {
title: 'Snapshot UI',
readOnlyHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: snapshotUiSchema as unknown as z.ZodType,
logicFunction: (params: SnapshotUiParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/swipe.ts b/src/mcp/tools/ui-automation/swipe.ts
index 3941af43..366d098a 100644
--- a/src/mcp/tools/ui-automation/swipe.ts
+++ b/src/mcp/tools/ui-automation/swipe.ts
@@ -158,6 +158,9 @@ export default {
title: 'Swipe',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: swipeSchema as unknown as z.ZodType,
logicFunction: (params: SwipeParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/tap.ts b/src/mcp/tools/ui-automation/tap.ts
index b53b8904..378d631b 100644
--- a/src/mcp/tools/ui-automation/tap.ts
+++ b/src/mcp/tools/ui-automation/tap.ts
@@ -191,6 +191,9 @@ export default {
title: 'Tap',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: tapSchema as unknown as z.ZodType,
logicFunction: (params: TapParams, executor: CommandExecutor) =>
diff --git a/src/mcp/tools/ui-automation/touch.ts b/src/mcp/tools/ui-automation/touch.ts
index c7c89596..eee8f32a 100644
--- a/src/mcp/tools/ui-automation/touch.ts
+++ b/src/mcp/tools/ui-automation/touch.ts
@@ -140,6 +140,9 @@ export default {
title: 'Touch',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: touchSchema as unknown as z.ZodType,
logicFunction: (params: TouchParams, executor: CommandExecutor) => touchLogic(params, executor),
diff --git a/src/mcp/tools/ui-automation/type_text.ts b/src/mcp/tools/ui-automation/type_text.ts
index 3e675d77..ef652507 100644
--- a/src/mcp/tools/ui-automation/type_text.ts
+++ b/src/mcp/tools/ui-automation/type_text.ts
@@ -112,6 +112,9 @@ export default {
title: 'Type Text',
destructiveHint: true,
},
+ cli: {
+ daemonAffinity: 'preferred',
+ },
handler: createSessionAwareTool({
internalSchema: typeTextSchema as unknown as z.ZodType,
logicFunction: (params: TypeTextParams, executor: CommandExecutor) =>
diff --git a/src/runtime/bootstrap-runtime.ts b/src/runtime/bootstrap-runtime.ts
new file mode 100644
index 00000000..4d02148c
--- /dev/null
+++ b/src/runtime/bootstrap-runtime.ts
@@ -0,0 +1,71 @@
+import process from 'node:process';
+import {
+ initConfigStore,
+ getConfig,
+ type RuntimeConfigOverrides,
+ type ResolvedRuntimeConfig,
+} from '../utils/config-store.ts';
+import { sessionStore } from '../utils/session-store.ts';
+import { getDefaultFileSystemExecutor } from '../utils/command.ts';
+import { log } from '../utils/logger.ts';
+import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
+
+export type RuntimeKind = 'cli' | 'daemon' | 'mcp';
+
+export interface BootstrapRuntimeOptions {
+ runtime: RuntimeKind;
+ cwd?: string;
+ fs?: FileSystemExecutor;
+ configOverrides?: RuntimeConfigOverrides;
+}
+
+export interface BootstrappedRuntime {
+ runtime: RuntimeKind;
+ cwd: string;
+ config: ResolvedRuntimeConfig;
+}
+
+export interface BootstrapRuntimeResult {
+ runtime: BootstrappedRuntime;
+ configFound: boolean;
+ configPath?: string;
+ notices: string[];
+}
+
+export async function bootstrapRuntime(
+ opts: BootstrapRuntimeOptions,
+): Promise {
+ process.env.XCODEBUILDMCP_RUNTIME = opts.runtime;
+ const cwd = opts.cwd ?? process.cwd();
+ const fs = opts.fs ?? getDefaultFileSystemExecutor();
+
+ const configResult = await initConfigStore({
+ cwd,
+ fs,
+ overrides: opts.configOverrides,
+ });
+
+ if (configResult.found) {
+ log('info', `Loaded project config from ${configResult.path} (cwd: ${cwd})`);
+ } else {
+ log('info', `No project config found (cwd: ${cwd}).`);
+ }
+
+ const config = getConfig();
+
+ const defaults = config.sessionDefaults ?? {};
+ if (Object.keys(defaults).length > 0) {
+ sessionStore.setDefaults(defaults);
+ }
+
+ return {
+ runtime: {
+ runtime: opts.runtime,
+ cwd,
+ config,
+ },
+ configFound: configResult.found,
+ configPath: configResult.path,
+ notices: configResult.notices,
+ };
+}
diff --git a/src/runtime/naming.ts b/src/runtime/naming.ts
new file mode 100644
index 00000000..90d053bc
--- /dev/null
+++ b/src/runtime/naming.ts
@@ -0,0 +1,77 @@
+import type { ToolDefinition } from './types.ts';
+
+/**
+ * Convert a tool name to kebab-case for CLI usage.
+ * Examples:
+ * build_sim -> build-sim
+ * startSimLogCap -> start-sim-log-cap
+ * BuildSimulator -> build-simulator
+ */
+export function toKebabCase(name: string): string {
+ return (
+ name
+ .trim()
+ // Replace underscores with hyphens
+ .replace(/_/g, '-')
+ // Insert hyphen before uppercase letters (for camelCase/PascalCase)
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
+ // Replace spaces with hyphens
+ .replace(/\s+/g, '-')
+ // Convert to lowercase
+ .toLowerCase()
+ // Remove any duplicate hyphens
+ .replace(/-+/g, '-')
+ // Trim leading/trailing hyphens
+ .replace(/^-|-$/g, '')
+ );
+}
+
+/**
+ * Convert kebab-case CLI flag back to camelCase for tool params.
+ * Examples:
+ * project-path -> projectPath
+ * simulator-name -> simulatorName
+ */
+export function toCamelCase(kebab: string): string {
+ return kebab.replace(/-([a-z])/g, (_match: string, letter: string) => letter.toUpperCase());
+}
+
+/**
+ * Disambiguate CLI names when duplicates exist across workflows.
+ * If multiple tools have the same kebab-case name, prefix with workflow name.
+ */
+export function disambiguateCliNames(tools: ToolDefinition[]): ToolDefinition[] {
+ // Group tools by their base CLI name
+ const groups = new Map();
+ for (const tool of tools) {
+ const existing = groups.get(tool.cliName) ?? [];
+ groups.set(tool.cliName, [...existing, tool]);
+ }
+
+ // Disambiguate tools that share the same CLI name
+ return tools.map((tool) => {
+ const sameNameTools = groups.get(tool.cliName) ?? [];
+ if (sameNameTools.length <= 1) {
+ return tool;
+ }
+
+ // Prefix with workflow name for disambiguation
+ const disambiguatedName = `${tool.workflow}-${tool.cliName}`;
+ return { ...tool, cliName: disambiguatedName };
+ });
+}
+
+/**
+ * Convert CLI argv keys (kebab-case) back to tool param keys (camelCase).
+ */
+export function convertArgvToToolParams(argv: Record): Record {
+ const result: Record = {};
+ for (const [key, value] of Object.entries(argv)) {
+ // Skip yargs internal keys
+ if (key === '_' || key === '$0') continue;
+ // Convert kebab-case to camelCase
+ const camelKey = toCamelCase(key);
+ result[camelKey] = value;
+ }
+ return result;
+}
diff --git a/src/runtime/tool-catalog.ts b/src/runtime/tool-catalog.ts
new file mode 100644
index 00000000..fc9dc643
--- /dev/null
+++ b/src/runtime/tool-catalog.ts
@@ -0,0 +1,118 @@
+import { loadWorkflowGroups } from '../core/plugin-registry.ts';
+import { resolveSelectedWorkflows } from '../utils/workflow-selection.ts';
+import type { ToolCatalog, ToolDefinition, ToolResolution } from './types.ts';
+import { toKebabCase, disambiguateCliNames } from './naming.ts';
+
+export async function buildToolCatalog(opts: {
+ enabledWorkflows: string[];
+ excludeWorkflows?: string[];
+}): Promise {
+ const workflowGroups = await loadWorkflowGroups();
+ const selection = resolveSelectedWorkflows(opts.enabledWorkflows, workflowGroups);
+
+ const excludeSet = new Set(opts.excludeWorkflows?.map((w) => w.toLowerCase()) ?? []);
+ const tools: ToolDefinition[] = [];
+
+ for (const wf of selection.selectedWorkflows) {
+ if (excludeSet.has(wf.directoryName.toLowerCase())) {
+ continue;
+ }
+ for (const tool of wf.tools) {
+ const baseCliName = tool.cli?.name ?? toKebabCase(tool.name);
+ tools.push({
+ cliName: baseCliName, // Will be disambiguated below
+ mcpName: tool.name,
+ workflow: wf.directoryName,
+ description: tool.description,
+ annotations: tool.annotations,
+ mcpSchema: tool.schema,
+ cliSchema: tool.cli?.schema ?? tool.schema,
+ stateful: Boolean(tool.cli?.stateful),
+ daemonAffinity: tool.cli?.daemonAffinity,
+ handler: tool.handler,
+ });
+ }
+ }
+
+ const disambiguated = disambiguateCliNames(tools);
+
+ return createCatalog(disambiguated);
+}
+
+function createCatalog(tools: ToolDefinition[]): ToolCatalog {
+ // Build lookup maps for fast resolution
+ const byCliName = new Map();
+ const byMcpName = new Map();
+ const byMcpKebab = new Map();
+
+ for (const tool of tools) {
+ byCliName.set(tool.cliName, tool);
+ byMcpName.set(tool.mcpName.toLowerCase(), tool);
+
+ // Also index by the kebab-case of MCP name (for aliases)
+ const mcpKebab = toKebabCase(tool.mcpName);
+ const existing = byMcpKebab.get(mcpKebab) ?? [];
+ byMcpKebab.set(mcpKebab, [...existing, tool]);
+ }
+
+ return {
+ tools,
+
+ getByCliName(name: string): ToolDefinition | null {
+ return byCliName.get(name) ?? null;
+ },
+
+ getByMcpName(name: string): ToolDefinition | null {
+ return byMcpName.get(name.toLowerCase().trim()) ?? null;
+ },
+
+ resolve(input: string): ToolResolution {
+ const normalized = input.toLowerCase().trim();
+
+ // Try exact CLI name match first
+ const exact = byCliName.get(normalized);
+ if (exact) {
+ return { tool: exact };
+ }
+
+ // Try kebab-case of MCP name (alias)
+ const mcpKebab = toKebabCase(normalized);
+ const aliasMatches = byMcpKebab.get(mcpKebab);
+ if (aliasMatches && aliasMatches.length === 1) {
+ return { tool: aliasMatches[0] };
+ }
+ if (aliasMatches && aliasMatches.length > 1) {
+ return { ambiguous: aliasMatches.map((t) => t.cliName) };
+ }
+
+ // Try matching by MCP name directly (for underscore-style names)
+ const byMcpDirect = tools.find((t) => t.mcpName.toLowerCase() === normalized);
+ if (byMcpDirect) {
+ return { tool: byMcpDirect };
+ }
+
+ return { notFound: true };
+ },
+ };
+}
+
+/**
+ * Get a list of all available tool names for display.
+ */
+export function listToolNames(catalog: ToolCatalog): string[] {
+ return catalog.tools.map((t) => t.cliName).sort();
+}
+
+/**
+ * Get tools grouped by workflow for display.
+ */
+export function groupToolsByWorkflow(catalog: ToolCatalog): Map {
+ const groups = new Map();
+
+ for (const tool of catalog.tools) {
+ const existing = groups.get(tool.workflow) ?? [];
+ groups.set(tool.workflow, [...existing, tool]);
+ }
+
+ return groups;
+}
diff --git a/src/runtime/tool-invoker.ts b/src/runtime/tool-invoker.ts
new file mode 100644
index 00000000..176ba85c
--- /dev/null
+++ b/src/runtime/tool-invoker.ts
@@ -0,0 +1,164 @@
+import type { ToolCatalog, ToolInvoker, InvokeOptions } from './types.ts';
+import type { ToolResponse } from '../types/common.ts';
+import { createErrorResponse } from '../utils/responses/index.ts';
+import { DaemonClient } from '../cli/daemon-client.ts';
+import { ensureDaemonRunning, DEFAULT_DAEMON_STARTUP_TIMEOUT_MS } from '../cli/daemon-control.ts';
+
+/**
+ * Enrich nextSteps for CLI rendering.
+ * Resolves MCP tool names to their workflow and CLI command name.
+ */
+function enrichNextStepsForCli(response: ToolResponse, catalog: ToolCatalog): ToolResponse {
+ if (!response.nextSteps || response.nextSteps.length === 0) {
+ return response;
+ }
+
+ return {
+ ...response,
+ nextSteps: response.nextSteps.map((step) => {
+ const target = catalog.getByMcpName(step.tool);
+ if (!target) {
+ return step;
+ }
+
+ return {
+ ...step,
+ workflow: target.workflow,
+ cliTool: target.cliName,
+ };
+ }),
+ };
+}
+
+export class DefaultToolInvoker implements ToolInvoker {
+ constructor(private catalog: ToolCatalog) {}
+
+ async invoke(
+ toolName: string,
+ args: Record,
+ opts: InvokeOptions,
+ ): Promise {
+ const resolved = this.catalog.resolve(toolName);
+
+ if (resolved.ambiguous) {
+ return createErrorResponse(
+ 'Ambiguous tool name',
+ `Multiple tools match '${toolName}'. Use one of:\n- ${resolved.ambiguous.join('\n- ')}`,
+ );
+ }
+
+ if (resolved.notFound || !resolved.tool) {
+ return createErrorResponse(
+ 'Tool not found',
+ `Unknown tool '${toolName}'. Run 'xcodebuildmcp tools' to see available tools.`,
+ );
+ }
+
+ const tool = resolved.tool;
+
+ const daemonAffinity = tool.daemonAffinity;
+ const mustUseDaemon =
+ tool.stateful || daemonAffinity === 'required' || Boolean(opts.forceDaemon);
+ const prefersDaemon = daemonAffinity === 'preferred';
+
+ if (opts.runtime === 'cli') {
+ // Check for conflicting options
+ if (opts.disableDaemon && opts.forceDaemon) {
+ return createErrorResponse(
+ 'Conflicting options',
+ `Cannot use both --daemon and --no-daemon flags together.`,
+ );
+ }
+
+ if (mustUseDaemon) {
+ // Check if daemon is disabled
+ if (opts.disableDaemon) {
+ return createErrorResponse(
+ 'Daemon required',
+ `Tool '${tool.cliName}' is stateful and requires the daemon.\n` +
+ `Remove the --no-daemon flag, or start the daemon manually:\n` +
+ ` xcodebuildmcp daemon start`,
+ );
+ }
+
+ // Route through daemon with auto-start
+ const socketPath = opts.socketPath;
+ if (!socketPath) {
+ return createErrorResponse(
+ 'Socket path required',
+ `No socket path configured for daemon communication.`,
+ );
+ }
+
+ const client = new DaemonClient({ socketPath });
+ const enabledWorkflows = opts.enabledWorkflows;
+ const envOverrides: Record = {};
+ if (enabledWorkflows && enabledWorkflows.length > 0) {
+ envOverrides.XCODEBUILDMCP_ENABLED_WORKFLOWS = enabledWorkflows.join(',');
+ }
+ if (opts.logLevel) {
+ envOverrides.XCODEBUILDMCP_DAEMON_LOG_LEVEL = opts.logLevel;
+ }
+ const envOverrideValue = Object.keys(envOverrides).length > 0 ? envOverrides : undefined;
+
+ // Check if daemon is running; auto-start if not
+ const isRunning = await client.isRunning();
+ if (!isRunning) {
+ try {
+ await ensureDaemonRunning({
+ socketPath,
+ workspaceRoot: opts.workspaceRoot,
+ startupTimeoutMs: opts.daemonStartupTimeoutMs ?? DEFAULT_DAEMON_STARTUP_TIMEOUT_MS,
+ env: envOverrideValue,
+ });
+ } catch (error) {
+ return createErrorResponse(
+ 'Daemon auto-start failed',
+ (error instanceof Error ? error.message : String(error)) +
+ `\n\nYou can try starting the daemon manually:\n` +
+ ` xcodebuildmcp daemon start`,
+ );
+ }
+ }
+
+ try {
+ const response = await client.invokeTool(tool.cliName, args);
+ return opts.runtime === 'cli' ? enrichNextStepsForCli(response, this.catalog) : response;
+ } catch (error) {
+ return createErrorResponse(
+ 'Daemon invocation failed',
+ error instanceof Error ? error.message : String(error),
+ );
+ }
+ }
+
+ if (prefersDaemon && !opts.disableDaemon && opts.socketPath) {
+ const client = new DaemonClient({ socketPath: opts.socketPath, timeout: 1000 });
+ try {
+ const isRunning = await client.isRunning();
+ if (isRunning) {
+ const tools = await client.listTools();
+ const hasTool = tools.some((item) => item.name === tool.cliName);
+ if (hasTool) {
+ const response = await client.invokeTool(tool.cliName, args);
+ return opts.runtime === 'cli'
+ ? enrichNextStepsForCli(response, this.catalog)
+ : response;
+ }
+ }
+ } catch {
+ // Fall back to direct invocation
+ }
+ }
+ }
+
+ // Direct invocation (CLI stateless or daemon internal)
+ try {
+ const response = await tool.handler(args);
+ return opts.runtime === 'cli' ? enrichNextStepsForCli(response, this.catalog) : response;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ return createErrorResponse('Tool execution failed', message);
+ }
+ }
+}
diff --git a/src/runtime/types.ts b/src/runtime/types.ts
new file mode 100644
index 00000000..e3b456ca
--- /dev/null
+++ b/src/runtime/types.ts
@@ -0,0 +1,90 @@
+import type { ToolAnnotations } from '@modelcontextprotocol/sdk/types.js';
+import type { ToolResponse } from '../types/common.ts';
+import type { ToolSchemaShape, PluginMeta } from '../core/plugin-types.ts';
+
+export type RuntimeKind = 'cli' | 'daemon' | 'mcp';
+
+export interface ToolDefinition {
+ /** Stable CLI command name (kebab-case, disambiguated) */
+ cliName: string;
+
+ /** Original MCP tool name as declared (unchanged) */
+ mcpName: string;
+
+ /** Workflow directory name (e.g., "simulator", "device", "logging") */
+ workflow: string;
+
+ description?: string;
+ annotations?: ToolAnnotations;
+
+ /**
+ * Schema shape used to generate yargs flags for CLI.
+ * Must include ALL parameters (not the session-default-hidden version).
+ */
+ cliSchema: ToolSchemaShape;
+
+ /**
+ * Schema shape used for MCP registration.
+ */
+ mcpSchema: ToolSchemaShape;
+
+ /**
+ * Whether CLI MUST route this tool to the daemon (stateful operations).
+ */
+ stateful: boolean;
+
+ /**
+ * Daemon routing preference for CLI (optional).
+ */
+ daemonAffinity?: 'preferred' | 'required';
+
+ /**
+ * Shared handler (same used by MCP). No duplication.
+ */
+ handler: PluginMeta['handler'];
+}
+
+export interface ToolResolution {
+ tool?: ToolDefinition;
+ ambiguous?: string[];
+ notFound?: boolean;
+}
+
+export interface ToolCatalog {
+ tools: ToolDefinition[];
+
+ /** Exact match on cliName */
+ getByCliName(name: string): ToolDefinition | null;
+
+ /** Exact match on MCP name */
+ getByMcpName(name: string): ToolDefinition | null;
+
+ /** Resolve user input, supporting aliases + ambiguity reporting */
+ resolve(input: string): ToolResolution;
+}
+
+export interface InvokeOptions {
+ runtime: RuntimeKind;
+ /** If present, overrides enabled workflows */
+ enabledWorkflows?: string[];
+ /** If true, route even stateless tools to daemon */
+ forceDaemon?: boolean;
+ /** Socket path override */
+ socketPath?: string;
+ /** If true, disable daemon usage entirely (stateful tools will error) */
+ disableDaemon?: boolean;
+ /** Timeout in ms for daemon startup when auto-starting (default: 5000) */
+ daemonStartupTimeoutMs?: number;
+ /** Workspace root for daemon auto-start context */
+ workspaceRoot?: string;
+ /** Log level override for daemon auto-start */
+ logLevel?: string;
+}
+
+export interface ToolInvoker {
+ invoke(
+ toolName: string,
+ args: Record,
+ opts: InvokeOptions,
+ ): Promise;
+}
diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts
index 5c9db298..441dabd4 100644
--- a/src/server/bootstrap.ts
+++ b/src/server/bootstrap.ts
@@ -1,13 +1,11 @@
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js';
-import process from 'node:process';
import { registerResources } from '../core/resources.ts';
-import { getDefaultFileSystemExecutor } from '../utils/command.ts';
import type { FileSystemExecutor } from '../utils/FileSystemExecutor.ts';
import { log, setLogLevel, type LogLevel } from '../utils/logger.ts';
-import { getConfig, initConfigStore, type RuntimeConfigOverrides } from '../utils/config-store.ts';
-import { sessionStore } from '../utils/session-store.ts';
+import type { RuntimeConfigOverrides } from '../utils/config-store.ts';
import { registerWorkflows } from '../utils/tool-registry.ts';
+import { bootstrapRuntime } from '../runtime/bootstrap-runtime.ts';
export interface BootstrapOptions {
enabledWorkflows?: string[];
@@ -27,9 +25,6 @@ export async function bootstrapServer(
return {};
});
- const cwd = options.cwd ?? process.cwd();
- const fileSystemExecutor = options.fileSystemExecutor ?? getDefaultFileSystemExecutor();
-
const hasLegacyEnabledWorkflows = Object.prototype.hasOwnProperty.call(
options,
'enabledWorkflows',
@@ -43,24 +38,20 @@ export async function bootstrapServer(
overrides.enabledWorkflows = options.enabledWorkflows ?? [];
}
- const configResult = await initConfigStore({
- cwd,
- fs: fileSystemExecutor,
- overrides,
+ const result = await bootstrapRuntime({
+ runtime: 'mcp',
+ cwd: options.cwd,
+ fs: options.fileSystemExecutor,
+ configOverrides: overrides,
});
- if (configResult.found) {
- for (const notice of configResult.notices) {
+
+ if (result.configFound) {
+ for (const notice of result.notices) {
log('info', `[ProjectConfig] ${notice}`);
}
}
- const config = getConfig();
- const defaults = config.sessionDefaults ?? {};
- if (Object.keys(defaults).length > 0) {
- sessionStore.setDefaults(defaults);
- }
-
- const enabledWorkflows = config.enabledWorkflows;
+ const enabledWorkflows = result.runtime.config.enabledWorkflows;
log('info', `🚀 Initializing server...`);
await registerWorkflows(enabledWorkflows);
diff --git a/src/server/start-mcp-server.ts b/src/server/start-mcp-server.ts
new file mode 100644
index 00000000..b88afdfb
--- /dev/null
+++ b/src/server/start-mcp-server.ts
@@ -0,0 +1,50 @@
+#!/usr/bin/env node
+
+/**
+ * MCP Server Startup Module
+ *
+ * This module provides the logic to start the XcodeBuildMCP server.
+ * It can be invoked from the CLI via the `mcp` subcommand.
+ */
+
+import { createServer, startServer } from './server.ts';
+import { log } from '../utils/logger.ts';
+import { initSentry } from '../utils/sentry.ts';
+import { getDefaultDebuggerManager } from '../utils/debugger/index.ts';
+import { version } from '../version.ts';
+import process from 'node:process';
+import { bootstrapServer } from './bootstrap.ts';
+
+/**
+ * Start the MCP server.
+ * This function initializes Sentry, creates and bootstraps the server,
+ * sets up signal handlers for graceful shutdown, and starts the server.
+ */
+export async function startMcpServer(): Promise {
+ try {
+ initSentry();
+
+ const server = createServer();
+
+ await bootstrapServer(server);
+
+ await startServer(server);
+
+ process.on('SIGTERM', async () => {
+ await getDefaultDebuggerManager().disposeAll();
+ await server.close();
+ process.exit(0);
+ });
+
+ process.on('SIGINT', async () => {
+ await getDefaultDebuggerManager().disposeAll();
+ await server.close();
+ process.exit(0);
+ });
+
+ log('info', `XcodeBuildMCP server (version ${version}) started successfully`);
+ } catch (error) {
+ console.error('Fatal error in startMcpServer():', error);
+ process.exit(1);
+ }
+}
diff --git a/src/types/common.ts b/src/types/common.ts
index 96ff1914..93e88179 100644
--- a/src/types/common.ts
+++ b/src/types/common.ts
@@ -12,6 +12,31 @@
* - Supporting error handling with standardized error response types
*/
+/**
+ * Represents a suggested next step that can be rendered for CLI or MCP.
+ */
+export interface NextStep {
+ /** MCP tool name (e.g., "boot_sim") */
+ tool: string;
+ /** CLI tool name (kebab-case, disambiguated) */
+ cliTool?: string;
+ /** Workflow name for CLI grouping (e.g., "simulator") */
+ workflow?: string;
+ /** Human-readable description of the action */
+ label: string;
+ /** Parameters to pass to the tool */
+ params: Record;
+ /** Lower priority values appear first (default: 0) */
+ priority?: number;
+}
+
+/**
+ * Output style controls verbosity of tool responses.
+ * - 'normal': Full output including next steps
+ * - 'minimal': Essential result only, no next steps
+ */
+export type OutputStyle = 'normal' | 'minimal';
+
/**
* Enum representing Xcode build platforms.
*/
@@ -35,6 +60,8 @@ export interface ToolResponse {
content: ToolResponseContent[];
isError?: boolean;
_meta?: Record;
+ /** Structured next steps that get rendered differently for CLI vs MCP */
+ nextSteps?: NextStep[];
[key: string]: unknown; // Index signature to match CallToolResult
}
diff --git a/src/utils/CommandExecutor.ts b/src/utils/CommandExecutor.ts
index 177c5cad..e2964011 100644
--- a/src/utils/CommandExecutor.ts
+++ b/src/utils/CommandExecutor.ts
@@ -8,6 +8,10 @@ export interface CommandExecOptions {
/**
* Command executor function type for dependency injection
*/
+/**
+ * NOTE: `detached` only changes when the promise resolves; it does not detach/unref
+ * the OS process. Callers must still manage lifecycle and open streams.
+ */
export type CommandExecutor = (
command: string[],
logPrefix?: string,
diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts
index 7f7893c8..c0b7029f 100644
--- a/src/utils/__tests__/project-config.test.ts
+++ b/src/utils/__tests__/project-config.test.ts
@@ -103,6 +103,27 @@ describe('project-config', () => {
expect(result.config.macosTemplatePath).toBe('/opt/templates/macos');
});
+ it('should resolve file URLs in session defaults and top-level paths', async () => {
+ const yaml = [
+ 'schemaVersion: 1',
+ 'axePath: "file:///repo/bin/axe"',
+ 'sessionDefaults:',
+ ' workspacePath: "file:///repo/App.xcworkspace"',
+ ' derivedDataPath: "file:///repo/.derivedData"',
+ '',
+ ].join('\n');
+
+ const { fs } = createFsFixture({ exists: true, readFile: yaml });
+ const result = await loadProjectConfig({ fs, cwd });
+
+ if (!result.found) throw new Error('expected config to be found');
+
+ expect(result.config.axePath).toBe('/repo/bin/axe');
+ const defaults = result.config.sessionDefaults ?? {};
+ expect(defaults.workspacePath).toBe('/repo/App.xcworkspace');
+ expect(defaults.derivedDataPath).toBe('/repo/.derivedData');
+ });
+
it('should return an error result when schemaVersion is unsupported', async () => {
const yaml = ['schemaVersion: 2', 'sessionDefaults:', ' scheme: "App"', ''].join('\n');
const { fs } = createFsFixture({ exists: true, readFile: yaml });
diff --git a/src/utils/build-utils.ts b/src/utils/build-utils.ts
index 4701a100..d2f053f8 100644
--- a/src/utils/build-utils.ts
+++ b/src/utils/build-utils.ts
@@ -228,7 +228,7 @@ export async function executeXcodeBuildCommand(
} else {
// Use standard xcodebuild
// Pass projectDir as cwd to ensure CocoaPods relative paths resolve correctly
- result = await executor(command, platformOptions.logPrefix, true, {
+ result = await executor(command, platformOptions.logPrefix, false, {
...execOpts,
cwd: projectDir,
});
diff --git a/src/utils/command.ts b/src/utils/command.ts
index ca80b7ff..01757c9e 100644
--- a/src/utils/command.ts
+++ b/src/utils/command.ts
@@ -27,20 +27,20 @@ export { FileSystemExecutor } from './FileSystemExecutor.ts';
* @param logPrefix Prefix for logging
* @param useShell Whether to use shell execution (true) or direct execution (false)
* @param opts Optional execution options (env: environment variables to merge with process.env, cwd: working directory)
- * @param detached Whether to spawn process without waiting for completion (for streaming/background processes)
+ * @param detached Whether to resolve without waiting for completion (does not detach/unref the process)
* @returns Promise resolving to command response with the process
*/
async function defaultExecutor(
command: string[],
logPrefix?: string,
- useShell: boolean = true,
+ useShell: boolean = false,
opts?: CommandExecOptions,
detached: boolean = false,
): Promise {
// Properly escape arguments for shell
let escapedCommand = command;
if (useShell) {
- // For shell execution, we need to format as ['sh', '-c', 'full command string']
+ // For shell execution, we need to format as ['/bin/sh', '-c', 'full command string']
const commandString = command
.map((arg) => {
// Shell metacharacters that require quoting: space, quotes, equals, dollar, backticks, semicolons, pipes, etc.
@@ -52,17 +52,25 @@ async function defaultExecutor(
})
.join(' ');
- escapedCommand = ['sh', '-c', commandString];
+ escapedCommand = ['/bin/sh', '-c', commandString];
}
- // Log the actual command that will be executed
- const displayCommand =
- useShell && escapedCommand.length === 3 ? escapedCommand[2] : escapedCommand.join(' ');
- log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`);
-
return new Promise((resolve, reject) => {
- const executable = escapedCommand[0];
- const args = escapedCommand.slice(1);
+ let executable = escapedCommand[0];
+ let args = escapedCommand.slice(1);
+
+ if (!useShell && executable === 'xcodebuild') {
+ const xcrunPath = '/usr/bin/xcrun';
+ if (existsSync(xcrunPath)) {
+ executable = xcrunPath;
+ args = ['xcodebuild', ...args];
+ }
+ }
+
+ // Log the actual command that will be executed
+ const displayCommand =
+ useShell && escapedCommand.length === 3 ? escapedCommand[2] : [executable, ...args].join(' ');
+ log('info', `Executing ${logPrefix ?? ''} command: ${displayCommand}`);
const spawnOpts: Parameters[2] = {
stdio: ['ignore', 'pipe', 'pipe'], // ignore stdin, pipe stdout/stderr
@@ -70,6 +78,21 @@ async function defaultExecutor(
cwd: opts?.cwd,
};
+ log('info', `defaultExecutor PATH: ${process.env.PATH ?? ''}`);
+
+ const logSpawnError = (err: Error): void => {
+ const errnoErr = err as NodeJS.ErrnoException & { spawnargs?: string[] };
+ const errorDetails = {
+ code: errnoErr.code,
+ errno: errnoErr.errno,
+ syscall: errnoErr.syscall,
+ path: errnoErr.path,
+ spawnargs: errnoErr.spawnargs,
+ stack: errnoErr.stack,
+ };
+ log('error', `Spawn error details: ${JSON.stringify(errorDetails, null, 2)}`);
+ };
+
const childProcess = spawn(executable, args, spawnOpts);
let stdout = '';
@@ -91,6 +114,7 @@ async function defaultExecutor(
childProcess.on('error', (err) => {
if (!resolved) {
resolved = true;
+ logSpawnError(err);
reject(err);
}
});
@@ -131,6 +155,7 @@ async function defaultExecutor(
});
childProcess.on('error', (err) => {
+ logSpawnError(err);
reject(err);
});
}
diff --git a/src/utils/logger.ts b/src/utils/logger.ts
index 66cec50e..759ccf26 100644
--- a/src/utils/logger.ts
+++ b/src/utils/logger.ts
@@ -17,6 +17,7 @@
* It's used by virtually all other modules for status reporting and error logging.
*/
+import { createWriteStream, type WriteStream } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
// Note: Removed "import * as Sentry from '@sentry/node'" to prevent native module loading at import time
@@ -31,6 +32,7 @@ const sentryEnabled = !isSentryDisabledFromEnv();
// Log levels in order of severity (lower number = more severe)
const LOG_LEVELS = {
+ none: -1,
emergency: 0,
alert: 1,
critical: 2,
@@ -50,8 +52,11 @@ export interface LogContext {
sentry?: boolean;
}
-// Client-requested log level (null means no filtering)
-let clientLogLevel: LogLevel | null = null;
+// Client-requested log level ("none" means no output unless explicitly enabled)
+let clientLogLevel: LogLevel = 'none';
+
+let logFileStream: WriteStream | null = null;
+let logFilePath: string | null = null;
function isTestEnv(): boolean {
return (
@@ -99,11 +104,57 @@ export function setLogLevel(level: LogLevel): void {
log('debug', `Log level set to: ${level}`);
}
+export function setLogFile(path: string | null): void {
+ if (!path) {
+ if (logFileStream) {
+ try {
+ logFileStream.end();
+ } catch {
+ // ignore
+ }
+ }
+ logFileStream = null;
+ logFilePath = null;
+ return;
+ }
+
+ if (logFilePath === path && logFileStream) {
+ return;
+ }
+
+ if (logFileStream) {
+ try {
+ logFileStream.end();
+ } catch {
+ // ignore
+ }
+ }
+
+ try {
+ const stream = createWriteStream(path, { flags: 'a' });
+ stream.on('error', (error) => {
+ if (stream !== logFileStream) return;
+ logFileStream = null;
+ logFilePath = null;
+ const message = error instanceof Error ? error.message : String(error);
+ const timestamp = new Date().toISOString();
+ console.error(`[${timestamp}] [ERROR] Log file disabled after error: ${message}`);
+ });
+ logFileStream = stream;
+ logFilePath = path;
+ const timestamp = new Date().toISOString();
+ logFileStream.write(`[${timestamp}] [INFO] Log file initialized\n`);
+ } catch {
+ logFileStream = null;
+ logFilePath = null;
+ }
+}
+
/**
* Get the current client-requested log level
- * @returns The current log level or null if no filtering is active
+ * @returns The current log level
*/
-export function getLogLevel(): LogLevel | null {
+export function getLogLevel(): LogLevel {
return clientLogLevel;
}
@@ -114,13 +165,12 @@ export function getLogLevel(): LogLevel | null {
*/
function shouldLog(level: string): boolean {
// Suppress logging during tests to keep test output clean
- if (isTestEnv()) {
+ if (isTestEnv() && !logFileStream) {
return false;
}
- // If no client level set, log everything
- if (clientLogLevel === null) {
- return true;
+ if (clientLogLevel === 'none') {
+ return false;
}
// Check if the level is valid
@@ -140,11 +190,6 @@ function shouldLog(level: string): boolean {
* @param context Optional context to control Sentry capture and other behavior
*/
export function log(level: string, message: string, context?: LogContext): void {
- // Check if we should log this level
- if (!shouldLog(level)) {
- return;
- }
-
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
@@ -156,6 +201,19 @@ export function log(level: string, message: string, context?: LogContext): void
withSentry((s) => s.captureMessage(logMessage));
}
+ if (logFileStream && clientLogLevel !== 'none') {
+ try {
+ logFileStream.write(`${logMessage}\n`);
+ } catch {
+ // ignore file logging failures
+ }
+ }
+
+ // Check if we should log this level to stderr
+ if (!shouldLog(level)) {
+ return;
+ }
+
// It's important to use console.error here to ensure logs don't interfere with MCP protocol communication
// see https://modelcontextprotocol.io/docs/tools/debugging#server-side-logging
console.error(logMessage);
diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts
index dec09e31..c45d6dbb 100644
--- a/src/utils/project-config.ts
+++ b/src/utils/project-config.ts
@@ -1,4 +1,5 @@
import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import type { FileSystemExecutor } from './FileSystemExecutor.ts';
import type { SessionDefaults } from './session-store.ts';
@@ -74,6 +75,32 @@ function normalizeMutualExclusivity(defaults: Partial): {
return { normalized, notices };
}
+function tryFileUrlToPath(value: string): string | null {
+ if (!value.startsWith('file:')) {
+ return null;
+ }
+
+ try {
+ return fileURLToPath(value);
+ } catch (error) {
+ log('warning', `Failed to parse file URL path: ${value}. ${String(error)}`);
+ return null;
+ }
+}
+
+function normalizePathValue(value: string, cwd: string): string {
+ const fileUrlPath = tryFileUrlToPath(value);
+ if (fileUrlPath) {
+ return fileUrlPath;
+ }
+
+ if (path.isAbsolute(value)) {
+ return value;
+ }
+
+ return path.resolve(cwd, value);
+}
+
function resolveRelativeSessionPaths(
defaults: Partial,
cwd: string,
@@ -83,8 +110,8 @@ function resolveRelativeSessionPaths(
for (const key of pathKeys) {
const value = resolved[key];
- if (typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)) {
- resolved[key] = path.resolve(cwd, value);
+ if (typeof value === 'string' && value.length > 0) {
+ resolved[key] = normalizePathValue(value, cwd);
}
}
@@ -116,8 +143,8 @@ function resolveRelativeTopLevelPaths(config: ProjectConfig, cwd: string): Proje
for (const key of pathKeys) {
const value = resolved[key];
- if (typeof value === 'string' && value.length > 0 && !path.isAbsolute(value)) {
- resolved[key] = path.resolve(cwd, value);
+ if (typeof value === 'string' && value.length > 0) {
+ resolved[key] = normalizePathValue(value, cwd);
}
}
diff --git a/src/utils/responses/__tests__/next-steps-renderer.test.ts b/src/utils/responses/__tests__/next-steps-renderer.test.ts
new file mode 100644
index 00000000..cdd157c8
--- /dev/null
+++ b/src/utils/responses/__tests__/next-steps-renderer.test.ts
@@ -0,0 +1,299 @@
+import { describe, it, expect } from 'vitest';
+import {
+ renderNextStep,
+ renderNextStepsSection,
+ processToolResponse,
+} from '../next-steps-renderer.ts';
+import type { NextStep, ToolResponse } from '../../../types/common.ts';
+
+describe('next-steps-renderer', () => {
+ describe('renderNextStep', () => {
+ it('should format step for CLI with workflow and no params', () => {
+ const step: NextStep = {
+ tool: 'open_sim',
+ workflow: 'simulator',
+ label: 'Open the Simulator app',
+ params: {},
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe('Open the Simulator app: xcodebuildmcp simulator open-sim');
+ });
+
+ it('should format step for CLI with workflow and params', () => {
+ const step: NextStep = {
+ tool: 'install_app_sim',
+ workflow: 'simulator',
+ label: 'Install an app',
+ params: { simulatorId: 'ABC123', appPath: '/path/to/app' },
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe(
+ 'Install an app: xcodebuildmcp simulator install-app-sim --simulator-id "ABC123" --app-path "/path/to/app"',
+ );
+ });
+
+ it('should prefer cliTool when provided', () => {
+ const step: NextStep = {
+ tool: 'install_app_sim',
+ cliTool: 'install-app',
+ workflow: 'simulator',
+ label: 'Install an app',
+ params: { simulatorId: 'ABC123' },
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe(
+ 'Install an app: xcodebuildmcp simulator install-app --simulator-id "ABC123"',
+ );
+ });
+
+ it('should format step for CLI without workflow (backwards compat)', () => {
+ const step: NextStep = {
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe('Open the Simulator app: xcodebuildmcp open-sim');
+ });
+
+ it('should format step for CLI with boolean param (true)', () => {
+ const step: NextStep = {
+ tool: 'some_tool',
+ label: 'Do something',
+ params: { verbose: true },
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe('Do something: xcodebuildmcp some-tool --verbose');
+ });
+
+ it('should format step for CLI with boolean param (false)', () => {
+ const step: NextStep = {
+ tool: 'some_tool',
+ label: 'Do something',
+ params: { verbose: false },
+ };
+
+ const result = renderNextStep(step, 'cli');
+ expect(result).toBe('Do something: xcodebuildmcp some-tool');
+ });
+
+ it('should format step for MCP with no params', () => {
+ const step: NextStep = {
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ };
+
+ const result = renderNextStep(step, 'mcp');
+ expect(result).toBe('Open the Simulator app: open_sim()');
+ });
+
+ it('should format step for MCP with params', () => {
+ const step: NextStep = {
+ tool: 'install_app_sim',
+ label: 'Install an app',
+ params: { simulatorId: 'ABC123', appPath: '/path/to/app' },
+ };
+
+ const result = renderNextStep(step, 'mcp');
+ expect(result).toBe(
+ 'Install an app: install_app_sim({ simulatorId: "ABC123", appPath: "/path/to/app" })',
+ );
+ });
+
+ it('should format step for MCP with numeric param', () => {
+ const step: NextStep = {
+ tool: 'some_tool',
+ label: 'Do something',
+ params: { count: 42 },
+ };
+
+ const result = renderNextStep(step, 'mcp');
+ expect(result).toBe('Do something: some_tool({ count: 42 })');
+ });
+
+ it('should format step for MCP with boolean param', () => {
+ const step: NextStep = {
+ tool: 'some_tool',
+ label: 'Do something',
+ params: { verbose: true },
+ };
+
+ const result = renderNextStep(step, 'mcp');
+ expect(result).toBe('Do something: some_tool({ verbose: true })');
+ });
+
+ it('should handle daemon runtime same as MCP', () => {
+ const step: NextStep = {
+ tool: 'open_sim',
+ label: 'Open the Simulator app',
+ params: {},
+ };
+
+ const result = renderNextStep(step, 'daemon');
+ expect(result).toBe('Open the Simulator app: open_sim()');
+ });
+ });
+
+ describe('renderNextStepsSection', () => {
+ it('should return empty string for empty steps', () => {
+ const result = renderNextStepsSection([], 'cli');
+ expect(result).toBe('');
+ });
+
+ it('should render numbered list for CLI', () => {
+ const steps: NextStep[] = [
+ { tool: 'open_sim', label: 'Open Simulator', params: {} },
+ { tool: 'install_app_sim', label: 'Install app', params: { simulatorId: 'X' } },
+ ];
+
+ const result = renderNextStepsSection(steps, 'cli');
+ expect(result).toBe(
+ '\n\nNext steps:\n' +
+ '1. Open Simulator: xcodebuildmcp open-sim\n' +
+ '2. Install app: xcodebuildmcp install-app-sim --simulator-id "X"',
+ );
+ });
+
+ it('should render numbered list for MCP', () => {
+ const steps: NextStep[] = [
+ { tool: 'open_sim', label: 'Open Simulator', params: {} },
+ { tool: 'install_app_sim', label: 'Install app', params: { simulatorId: 'X' } },
+ ];
+
+ const result = renderNextStepsSection(steps, 'mcp');
+ expect(result).toBe(
+ '\n\nNext steps:\n' +
+ '1. Open Simulator: open_sim()\n' +
+ '2. Install app: install_app_sim({ simulatorId: "X" })',
+ );
+ });
+
+ it('should sort by priority', () => {
+ const steps: NextStep[] = [
+ { tool: 'third', label: 'Third', params: {}, priority: 3 },
+ { tool: 'first', label: 'First', params: {}, priority: 1 },
+ { tool: 'second', label: 'Second', params: {}, priority: 2 },
+ ];
+
+ const result = renderNextStepsSection(steps, 'mcp');
+ expect(result).toContain('1. First: first()');
+ expect(result).toContain('2. Second: second()');
+ expect(result).toContain('3. Third: third()');
+ });
+
+ it('should handle missing priority (defaults to 0)', () => {
+ const steps: NextStep[] = [
+ { tool: 'later', label: 'Later', params: {}, priority: 1 },
+ { tool: 'first', label: 'First', params: {} },
+ ];
+
+ const result = renderNextStepsSection(steps, 'mcp');
+ expect(result).toContain('1. First: first()');
+ expect(result).toContain('2. Later: later()');
+ });
+ });
+
+ describe('processToolResponse', () => {
+ it('should pass through response with no nextSteps', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Success!' }],
+ };
+
+ const result = processToolResponse(response, 'cli', 'normal');
+ expect(result).toEqual({
+ content: [{ type: 'text', text: 'Success!' }],
+ });
+ });
+
+ it('should strip nextSteps in minimal style', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Success!' }],
+ nextSteps: [{ tool: 'foo', label: 'Do foo', params: {} }],
+ };
+
+ const result = processToolResponse(response, 'cli', 'minimal');
+ expect(result).toEqual({
+ content: [{ type: 'text', text: 'Success!' }],
+ });
+ expect(result.nextSteps).toBeUndefined();
+ });
+
+ it('should append next steps to last text content in normal style', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Simulator booted.' }],
+ nextSteps: [{ tool: 'open_sim', label: 'Open Simulator', params: {}, priority: 1 }],
+ };
+
+ const result = processToolResponse(response, 'cli', 'normal');
+ expect(result.content[0].text).toBe(
+ 'Simulator booted.\n\nNext steps:\n1. Open Simulator: xcodebuildmcp open-sim',
+ );
+ expect(result.nextSteps).toBeUndefined();
+ });
+
+ it('should render MCP-style for MCP runtime', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Simulator booted.' }],
+ nextSteps: [{ tool: 'open_sim', label: 'Open Simulator', params: {}, priority: 1 }],
+ };
+
+ const result = processToolResponse(response, 'mcp', 'normal');
+ expect(result.content[0].text).toBe(
+ 'Simulator booted.\n\nNext steps:\n1. Open Simulator: open_sim()',
+ );
+ });
+
+ it('should handle response with empty nextSteps array', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Done.' }],
+ nextSteps: [],
+ };
+
+ const result = processToolResponse(response, 'cli', 'normal');
+ expect(result).toEqual({
+ content: [{ type: 'text', text: 'Done.' }],
+ });
+ });
+
+ it('should preserve other response properties', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Error!' }],
+ isError: true,
+ _meta: { foo: 'bar' },
+ nextSteps: [{ tool: 'retry', label: 'Retry', params: {} }],
+ };
+
+ const result = processToolResponse(response, 'cli', 'minimal');
+ expect(result.isError).toBe(true);
+ expect(result._meta).toEqual({ foo: 'bar' });
+ });
+
+ it('should not mutate original response', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Original' }],
+ nextSteps: [{ tool: 'foo', label: 'Foo', params: {} }],
+ };
+
+ processToolResponse(response, 'cli', 'normal');
+
+ expect(response.content[0].text).toBe('Original');
+ expect(response.nextSteps).toHaveLength(1);
+ });
+
+ it('should default to normal style when not specified', () => {
+ const response: ToolResponse = {
+ content: [{ type: 'text', text: 'Success!' }],
+ nextSteps: [{ tool: 'foo', label: 'Do foo', params: {} }],
+ };
+
+ const result = processToolResponse(response, 'cli');
+ expect(result.content[0].text).toContain('Next steps:');
+ });
+ });
+});
diff --git a/src/utils/responses/index.ts b/src/utils/responses/index.ts
index ef740dcc..1707ea06 100644
--- a/src/utils/responses/index.ts
+++ b/src/utils/responses/index.ts
@@ -10,6 +10,11 @@ export {
SystemError,
ValidationError,
} from '../errors.ts';
+export {
+ processToolResponse,
+ renderNextStep,
+ renderNextStepsSection,
+} from './next-steps-renderer.ts';
// Types
-export type { ToolResponse } from '../../types/common.ts';
+export type { ToolResponse, NextStep, OutputStyle } from '../../types/common.ts';
diff --git a/src/utils/responses/next-steps-renderer.ts b/src/utils/responses/next-steps-renderer.ts
new file mode 100644
index 00000000..045db633
--- /dev/null
+++ b/src/utils/responses/next-steps-renderer.ts
@@ -0,0 +1,119 @@
+import type { RuntimeKind } from '../../runtime/types.ts';
+import type { NextStep, OutputStyle, ToolResponse } from '../../types/common.ts';
+import { toKebabCase } from '../../runtime/naming.ts';
+
+/**
+ * Format a single next step for CLI output.
+ * Example: xcodebuildmcp simulator open-sim
+ * Example: xcodebuildmcp simulator install-app-sim --simulator-id "ABC123" --app-path "PATH"
+ */
+function formatNextStepForCli(step: NextStep): string {
+ const cliName = step.cliTool ?? toKebabCase(step.tool);
+ const parts = ['xcodebuildmcp'];
+
+ // Include workflow as subcommand if provided
+ if (step.workflow) {
+ parts.push(step.workflow);
+ }
+
+ parts.push(cliName);
+
+ for (const [key, value] of Object.entries(step.params)) {
+ const flagName = toKebabCase(key);
+ if (typeof value === 'boolean') {
+ if (value) {
+ parts.push(`--${flagName}`);
+ }
+ } else {
+ parts.push(`--${flagName} "${String(value)}"`);
+ }
+ }
+
+ return parts.join(' ');
+}
+
+/**
+ * Format a single next step for MCP output.
+ * Example: open_sim()
+ * Example: install_app_sim({ simulatorId: "ABC123", appPath: "PATH" })
+ */
+function formatNextStepForMcp(step: NextStep): string {
+ const paramEntries = Object.entries(step.params);
+ if (paramEntries.length === 0) {
+ return `${step.tool}()`;
+ }
+
+ const paramsStr = paramEntries
+ .map(([key, value]) => {
+ if (typeof value === 'string') {
+ return `${key}: "${value}"`;
+ }
+ return `${key}: ${String(value)}`;
+ })
+ .join(', ');
+
+ return `${step.tool}({ ${paramsStr} })`;
+}
+
+/**
+ * Render a single next step based on runtime.
+ */
+export function renderNextStep(step: NextStep, runtime: RuntimeKind): string {
+ const formatted = runtime === 'cli' ? formatNextStepForCli(step) : formatNextStepForMcp(step);
+ return `${step.label}: ${formatted}`;
+}
+
+/**
+ * Render the full next steps section.
+ * Returns empty string if no steps.
+ */
+export function renderNextStepsSection(steps: NextStep[], runtime: RuntimeKind): string {
+ if (steps.length === 0) {
+ return '';
+ }
+
+ const sorted = [...steps].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
+ const lines = sorted.map((step, index) => `${index + 1}. ${renderNextStep(step, runtime)}`);
+
+ return `\n\nNext steps:\n${lines.join('\n')}`;
+}
+
+/**
+ * Process a tool response, applying next steps rendering based on runtime and style.
+ *
+ * - In 'minimal' style, nextSteps are stripped entirely
+ * - In 'normal' style, nextSteps are rendered and appended to text content
+ *
+ * Returns a new response object (does not mutate the original).
+ */
+export function processToolResponse(
+ response: ToolResponse,
+ runtime: RuntimeKind,
+ style: OutputStyle = 'normal',
+): ToolResponse {
+ const { nextSteps, ...rest } = response;
+
+ // If no nextSteps or minimal style, strip nextSteps and return
+ if (!nextSteps || nextSteps.length === 0 || style === 'minimal') {
+ return { ...rest };
+ }
+
+ // Render next steps section
+ const nextStepsSection = renderNextStepsSection(nextSteps, runtime);
+
+ // Append to the last text content item
+ const processedContent = response.content.map((item, index) => {
+ if (item.type === 'text' && index === response.content.length - 1) {
+ return { ...item, text: item.text + nextStepsSection };
+ }
+ return item;
+ });
+
+ // If no text content existed, add one with just the next steps
+ const hasTextContent = response.content.some((item) => item.type === 'text');
+ if (!hasTextContent && nextStepsSection) {
+ processedContent.push({ type: 'text', text: nextStepsSection.trim() });
+ }
+
+ return { ...rest, content: processedContent };
+}
diff --git a/src/utils/tool-registry.ts b/src/utils/tool-registry.ts
index 1fd4fcdd..5995e016 100644
--- a/src/utils/tool-registry.ts
+++ b/src/utils/tool-registry.ts
@@ -4,6 +4,7 @@ import { ToolResponse } from '../types/common.ts';
import { log } from './logger.ts';
import { loadWorkflowGroups } from '../core/plugin-registry.ts';
import { resolveSelectedWorkflows } from './workflow-selection.ts';
+import { processToolResponse } from './responses/index.ts';
export interface RuntimeToolInfo {
enabledWorkflows: string[];
@@ -51,7 +52,11 @@ export async function applyWorkflowSelection(workflowNames: string[]): Promise => handler(args as Record),
+ async (args: unknown): Promise => {
+ const response = await handler(args as Record);
+ // Apply MCP-style next steps rendering
+ return processToolResponse(response, 'mcp', 'normal');
+ },
);
registryState.tools.set(name, registeredTool);
}
diff --git a/src/utils/video_capture.ts b/src/utils/video_capture.ts
index ba6063b4..4fb25e28 100644
--- a/src/utils/video_capture.ts
+++ b/src/utils/video_capture.ts
@@ -100,7 +100,7 @@ export async function startSimulatorVideoCapture(
log('info', `Starting AXe video recording for simulator ${simulatorUuid} at ${fps} fps`);
- const result = await executor(command, 'Start Simulator Video Capture', true, { env }, true);
+ const result = await executor(command, 'Start Simulator Video Capture', false, { env }, true);
if (!result.success || !result.process) {
return {
diff --git a/src/utils/xcodemake.ts b/src/utils/xcodemake.ts
index 5affbf53..9f4b026b 100644
--- a/src/utils/xcodemake.ts
+++ b/src/utils/xcodemake.ts
@@ -232,6 +232,6 @@ export async function executeMakeCommand(
projectDir: string,
logPrefix: string,
): Promise {
- const command = ['cd', projectDir, '&&', 'make'];
- return getDefaultCommandExecutor()(command, logPrefix);
+ const command = ['make'];
+ return getDefaultCommandExecutor()(command, logPrefix, false, { cwd: projectDir });
}
diff --git a/tsup.config.ts b/tsup.config.ts
index 0b117513..925b1701 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -4,8 +4,9 @@ import { createPluginDiscoveryPlugin } from './build-plugins/plugin-discovery.js
export default defineConfig({
entry: {
- index: 'src/index.ts',
+ index: 'src/cli.ts',
'doctor-cli': 'src/doctor-cli.ts',
+ daemon: 'src/daemon.ts',
},
format: ['esm'],
target: 'node18',
@@ -15,7 +16,7 @@ export default defineConfig({
sourcemap: true, // Enable source maps for debugging
dts: {
entry: {
- index: 'src/index.ts',
+ index: 'src/cli.ts',
},
},
splitting: false,
@@ -27,11 +28,11 @@ export default defineConfig({
console.log('✅ Build complete!');
// Set executable permissions for built files
- if (existsSync('build/index.js')) {
- chmodSync('build/index.js', '755');
- }
- if (existsSync('build/doctor-cli.js')) {
- chmodSync('build/doctor-cli.js', '755');
+ const executables = ['build/index.js', 'build/doctor-cli.js', 'build/daemon.js'];
+ for (const file of executables) {
+ if (existsSync(file)) {
+ chmodSync(file, '755');
+ }
}
},
});