From 4be7c19e7edc0e58b4cf5e998f875fd749461d76 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Wed, 21 Jan 2026 12:24:54 +0000 Subject: [PATCH 1/3] Add CLI report --- docs/CLI.md | 921 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 921 insertions(+) create mode 100644 docs/CLI.md diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 00000000..cced368d --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,921 @@ +# CLI Mapping for MCP Tools + +This doc maps MCP tools that wrap CLIs (xcodebuild, xcrun simctl/devicectl/xcresulttool/xctrace, AXe) to equivalent terminal commands. It also calls out MCP-only pre/post-processing and stateful behavior. + +## Conventions + +- Placeholders use angle brackets (e.g., ``, ``). +- Commands are shown in the shape MCP executes; some tools orchestrate multiple commands. +- Use `-workspace` **or** `-project` depending on your project type. +- Tool names in MCP are kebab-case where defined (e.g., `session-set-defaults`). Older docs may show snake_case aliases. + +## Session defaults (stateful but optional) + +Many tools are session-aware and omit parameters that can be provided once via session defaults. See `docs/SESSION_DEFAULTS.md` for details and opt-out: + +```json +"env": { + "XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS": "true" +} +``` + +## Environment variables observed + +- `XCODEBUILDMCP_AXE_PATH`: override AXe binary path (preferred over PATH). +- `AXE_PATH`: alternate AXe path override. +- `XBMCP_LAUNCH_JSON_WAIT_MS`: device log launch JSON wait timeout (ms). + +## Project discovery + +### `discover_projs` + +**CLI equivalent (approximate):** + +```sh +cd "" +find "" -maxdepth \ + \( -name build -o -name DerivedData -o -name Pods -o -name .git -o -name node_modules \) -prune -false \ + -o -name "*.xcodeproj" -print \ + -o -name "*.xcworkspace" -print +``` + +**MCP notes:** enforces scan within ``, skips symlinks, returns absolute paths. + +### `list_schemes` + +```sh +xcodebuild -list -workspace "" +# or +xcodebuild -list -project "" +``` + +**MCP notes:** parses the `Schemes:` block and adds “Next Steps”. + +### `show_build_settings` + +```sh +xcodebuild -showBuildSettings -workspace "" -scheme "" +# or +xcodebuild -showBuildSettings -project "" -scheme "" +``` + +**MCP notes:** formats output and adds “Next Steps”. + +### `get_app_bundle_id` + +```sh +defaults read "/Info" CFBundleIdentifier +# fallback +/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Info.plist" +``` + +**MCP notes:** validates file existence before reading. + +### `get_mac_bundle_id` + +```sh +defaults read "/Contents/Info" CFBundleIdentifier +# fallback +/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Contents/Info.plist" +``` + +## Simulator + +### `list_sims` + +```sh +xcrun simctl list devices --json +# fallback when JSON parsing fails +xcrun simctl list devices +``` + +**MCP arguments:** + +- `enabled`: boolean filter (optional) + +**MCP notes:** merges JSON and text output to work around simctl JSON bugs and filters to available devices. + +### `boot_sim` + +```sh +xcrun simctl boot "" +``` + +### `open_sim` + +```sh +open -a Simulator +``` + +### `install_app_sim` + +```sh +xcrun simctl install "" "" +``` + +**MCP notes:** validates file existence before install. + +### `launch_app_sim` + +```sh +xcrun simctl get_app_container "" "" app +xcrun simctl launch "" "" [] +``` + +**MCP notes:** resolves simulator name to UDID when needed. + +### `stop_app_sim` + +```sh +xcrun simctl terminate "" "" +``` + +### `get_sim_app_path` + +```sh +xcodebuild -showBuildSettings \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=,id=' +# or use -project "" +``` + +**MCP notes:** parses `CODESIGNING_FOLDER_PATH` or `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. + +### `build_sim` + +```sh +xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=,id=' \ + -skipMacroValidation \ + build \ + [] +# or use -project "" +``` + +**MCP notes:** uses project directory as `cwd` and may use xcodemake/make for incremental builds (see “xcodemake branch”). + +### `test_sim` + +```sh +TEST_RUNNER_FOO=bar xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=,id=' \ + -skipMacroValidation \ + test +# or use -project "" +``` + +**MCP notes:** normalizes `testRunnerEnv` values, enforcing `TEST_RUNNER_` prefix. + +### `build_run_sim` (multi-step orchestration) + +1. Build (same as `build_sim`). +2. Resolve app path via `xcodebuild -showBuildSettings` and parse product paths. +3. Ensure simulator exists/booted (`simctl list`, then `simctl boot`). +4. Open Simulator app (best-effort). +5. Install app: + +```sh +xcrun simctl install "" "" +``` + +1. Extract bundle ID from Info.plist. +2. Launch: + +```sh +xcrun simctl launch "" "" +``` + +**MCP notes:** resolves simulator names to UDIDs and handles missing/booted state. Not a single CLI call. + +### `launch_app_logs_sim` + +**CLI equivalents (conceptual):** + +```sh +xcrun simctl launch --console-pty "" "" [] +xcrun simctl spawn "" log stream --level=debug --predicate 'subsystem == ""' +``` + +**MCP notes:** returns a `sessionId` and manages background processes; requires stop tool to collect logs. +**Stateful:** relies on MCP server process to track the active log capture session. + +### `screenshot` + +```sh +xcrun simctl io "" screenshot "" +# MCP post-processing +sips -Z 800 -s format jpeg -s formatOptions 75 "" --out "" +``` + +**MCP notes:** creates temp files, optimizes to JPEG, cleans up, and returns base64. + +### `record_sim_video` + +```sh +"" record-video --udid "" --fps +``` + +**MCP notes:** stateful start/stop session, collects stdout to parse MP4 path, moves file on stop, and enforces AXe >= 1.1.0. +**Stateful:** relies on MCP server process to track the active recording session. + +## Device + +### `list_devices` + +```sh +xcrun devicectl list devices --json-output "" +cat "" +# fallback +xcrun xctrace list devices +``` + +**MCP notes:** reads temp JSON, parses, cleans up; falls back to xctrace when devicectl is unavailable or empty. + +### `install_app_device` + +```sh +xcrun devicectl device install app --device "" "" +``` + +### `launch_app_device` + +```sh +xcrun devicectl device process launch \ + --device "" \ + --json-output "" \ + --terminate-existing \ + "" +``` + +**MCP notes:** parses JSON output for PID and adds “Next Steps”. + +### `stop_app_device` + +```sh +xcrun devicectl device process terminate --device "" --pid "" +``` + +### `build_device` + +```sh +xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'generic/platform=iOS' \ + -skipMacroValidation \ + build +# or use -project "" +``` + +**MCP notes:** when deviceId is provided, destination becomes `platform=iOS,id=`. + +### `get_device_app_path` + +```sh +xcodebuild -showBuildSettings \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'generic/platform=' +# or use -project "" +``` + +**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. + +### `test_device` + +```sh +xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=iOS,id=' \ + -skipMacroValidation \ + test \ + -resultBundlePath "/TestResults.xcresult" + +xcrun xcresulttool get test-results summary --path "/TestResults.xcresult" +``` + +**MCP notes:** creates temp bundle directory, parses summary JSON, formats output, and cleans up. + +## Logging + +### `start_sim_log_cap` + +```sh +xcrun simctl launch --console-pty --terminate-running-process "" "" [] +xcrun simctl spawn "" log stream --level=debug --predicate 'subsystem == ""' +``` + +**MCP arguments:** + +- `captureConsole`: boolean to capture console output +- `subsystemFilter`: filter logs by subsystem (app|all|swiftui|[custom subsystem]) + +**MCP notes:** writes to a temp log file, cleans old logs, manages multiple processes, returns `sessionId`. +**Stateful:** relies on MCP server process to track the active log capture session. + +### `stop_sim_log_cap` + +```sh +kill -TERM +cat "" +``` + +**MCP notes:** uses sessionId to find processes/log file, stops processes, reads and returns log content. +**Stateful:** relies on MCP server process to track the active log capture session. + +### `start_device_log_cap` + +```sh +xcrun devicectl device process launch \ + --console \ + --terminate-existing \ + --device "" \ + --json-output "" \ + "" +``` + +**MCP notes:** tails process output into a temp log file, polls JSON result for PID/errors, detects early failures, returns `sessionId`. +**Stateful:** relies on MCP server process to track the active log capture session. + +### `stop_device_log_cap` + +```sh +kill -TERM +cat "" +``` + +**MCP notes:** uses sessionId to find process/log file, waits for close, then reads log content. +**Stateful:** relies on MCP server process to track the active log capture session. + +## UI automation (AXe) + +All UI automation tools call AXe with `--udid `. If an AXe binary is bundled or configured, MCP sets up the correct environment and provides friendly errors. When a debugger is attached and stopped, MCP blocks UI automation and returns a warning. + +### `snapshot_ui` + +```sh +"" describe-ui --udid "" +``` + +**MCP notes:** records call timestamp and warns if subsequent coordinate-based tools run without a fresh `snapshot_ui`. + +### `tap` + +```sh +"" tap -x -y [--pre-delay ] [--post-delay ] --udid "" +# or +"" tap --id "" [--pre-delay ] [--post-delay ] --udid "" +# or +"" tap --label "" [--pre-delay ] [--post-delay ] --udid "" +``` + +**MCP arguments:** + +- `x`, `y`: tap coordinates (mutually exclusive with id/label) +- `id`: accessibility identifier (mutually exclusive with x/y and label) +- `label`: accessibility label (mutually exclusive with x/y and id) +- `preDelay`: seconds to wait before tap +- `postDelay`: seconds to wait after tap + +**MCP notes:** validates mutual exclusivity (id vs label), and warns on stale coordinates. + +### `swipe` + +```sh +"" swipe --start-x --start-y --end-x --end-y \ + [--duration ] [--delta ] [--pre-delay ] [--post-delay ] \ + --udid "" +``` + +**MCP notes:** warns if `describe_ui` was not called recently. + +### `gesture` + +```sh +"" gesture \ + [--screen-width ] [--screen-height ] [--duration ] [--delta ] \ + [--pre-delay ] [--post-delay ] \ + --udid "" +``` + +**MCP notes:** presets include scroll and edge swipes; MCP validates allowed values. + +### `touch` + +```sh +"" touch -x -y [--down] [--up] [--delay ] --udid "" +``` + +**MCP notes:** requires at least one of `--down` or `--up` and warns on stale coordinates. + +### `long_press` + +```sh +"" touch -x -y --down --up --delay --udid "" +``` + +**MCP notes:** converts `duration` (ms) to `--delay` (seconds). + +### `button` + +```sh +"" button [--duration ] --udid "" +``` + +**MCP notes:** valid button types are enforced (apple-pay, home, lock, side-button, siri). + +### `key_press` + +```sh +"" key [--duration ] --udid "" +``` + +### `key_sequence` + +```sh +"" key-sequence --keycodes ",," [--delay ] --udid "" +``` + +### `type_text` + +```sh +"" type "" --udid "" +``` + +## Utilities + +### `clean` + +```sh +xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination '' \ + clean +# or use -project "" +``` + +**MCP arguments:** + +- `extraArgs`: additional xcodebuild arguments +- `platform`: target platform (macOS, iOS, iOS Simulator, watchOS, watchOS Simulator, tvOS, tvOS Simulator, visionOS, visionOS Simulator) + +**MCP notes:** enforces project/workspace exclusivity and validates required scheme. + +## Doctor + +### `doctor` + +```sh +xcodebuild -version +xcode-select -p +xcrun --find xcodebuild +xcrun --version +xcrun --find lldb-dap + +which mise +mise --version + +"" --version +``` + +**MCP arguments:** + +- `enabled`: boolean filter + +**MCP notes:** reports internal workflow registry and server version in addition to CLI probes. + +## macOS + +### `build_macos` + +```sh +xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=macOS,arch=' \ + -skipMacroValidation \ + build \ + [] +# or use -project "" +``` + +**MCP notes:** uses project directory as `cwd` and may use xcodemake/make for incremental builds. + +### `build_run_macos` (multi-step orchestration) + +1. Build (same as `build_macos`). +2. Resolve app path with: + +```sh +xcodebuild -showBuildSettings \ + -workspace "" \ + -scheme "" \ + -configuration "" +# or use -project "" +``` + +3. Launch: + +```sh +open "" +``` + +**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME` to find the app path. + +### `get_mac_app_path` + +```sh +xcodebuild -showBuildSettings \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=macOS,arch=' +# or use -project "" +``` + +**MCP arguments:** + +- `derivedDataPath`: custom DerivedData path +- `extraArgs`: additional xcodebuild arguments + +**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. + +### `launch_mac_app` + +```sh +open "" --args +``` + +**MCP notes:** validates the .app bundle exists before launching. + +### `stop_mac_app` + +```sh +kill +# or +pkill -f "" || osascript -e 'tell application "" to quit' +``` + +### `test_macos` + +```sh +TEST_RUNNER_FOO=bar xcodebuild \ + -workspace "" \ + -scheme "" \ + -configuration "" \ + -destination 'platform=macOS,arch=' \ + -skipMacroValidation \ + test \ + -resultBundlePath "/TestResults.xcresult" + +xcrun xcresulttool get test-results summary --path "/TestResults.xcresult" +``` + +**MCP notes:** normalizes `testRunnerEnv` to `TEST_RUNNER_*`, parses xcresult summary, and cleans temp output. + +## Project scaffolding + +These tools are not direct CLI wrappers; they orchestrate template download plus file transformations. + +### `scaffold_ios_project` + +**CLI equivalent (approximate):** + +```sh +cp -R "" "" +# then replace placeholders in text files (project name, bundle id, versions) +``` + +**MCP arguments:** + +- `projectName`: project name +- `outputPath`: destination path +- `bundleIdentifier`: app bundle identifier +- `displayName`: app display name +- `marketingVersion`: marketing version string +- `currentProjectVersion`: build number +- `customizeNames`: customize names during scaffolding +- `deploymentTarget`: minimum iOS version +- `targetedDeviceFamily`: array of device types (iPhone, iPad) +- `supportedOrientations`: array of supported orientations for iPhone +- `supportedOrientationsIpad`: array of supported orientations for iPad + +**MCP notes:** downloads templates via TemplateManager and performs content/filename rewrites; no single CLI command maps 1:1. + +### `scaffold_macos_project` + +**CLI equivalent (approximate):** + +```sh +cp -R "" "" +# then replace placeholders in text files (project name, bundle id, versions) +``` + +**MCP arguments:** + +- `projectName`: project name +- `outputPath`: destination path +- `bundleIdentifier`: app bundle identifier +- `displayName`: app display name +- `marketingVersion`: marketing version string +- `currentProjectVersion`: build number +- `customizeNames`: customize names during scaffolding +- `deploymentTarget`: minimum macOS version + +**MCP notes:** downloads templates via TemplateManager and performs content/filename rewrites; no single CLI command maps 1:1. + +## Session management (stateful) + +These tools manage MCP server state and have no direct CLI equivalent. + +### `session-set-defaults` + +**CLI equivalent:** none. This is MCP server state. + +**MCP arguments:** + +- `projectPath`: xcodeproj path (mutually exclusive with workspacePath) +- `workspacePath`: xcworkspace path (mutually exclusive with projectPath) +- `scheme`: Xcode scheme name +- `configuration`: build configuration (e.g., Debug, Release) +- `simulatorName`: simulator name +- `simulatorId`: simulator UUID +- `deviceId`: physical device UUID +- `useLatestOS`: use latest OS version for simulator +- `arch`: architecture (arm64, x86_64) +- `suppressWarnings`: suppress build warnings +- `derivedDataPath`: custom DerivedData path +- `preferXcodebuild`: prefer xcodebuild over incremental builds +- `platform`: device platform (e.g., iOS, watchOS) +- `bundleId`: default bundle identifier +- `persist`: persist defaults to .xcodebuildmcp/config.yaml + +**Stateful:** relies on MCP server process to store defaults. + +### `session-show-defaults` + +**CLI equivalent:** none. This is MCP server state. + +**Stateful:** relies on MCP server process to store defaults. + +### `session-clear-defaults` + +**CLI equivalent:** none. This is MCP server state. + +**MCP arguments:** + +- `keys`: array of specific keys to clear +- `all`: boolean to clear all defaults + +**Stateful:** relies on MCP server process to store defaults. + +## Workflow management (stateful) + +### `manage-workflows` + +**CLI equivalent:** none. This is MCP server state. + +**MCP arguments:** + +- `workflowNames`: array of workflow directory names to enable/disable +- `enable`: boolean to enable (true) or disable (false) the specified workflows + +**MCP notes:** dynamically enables or disables workflow groups at runtime. Available workflows: debugging, device, doctor, logging, macos, project-discovery, project-scaffolding, session-management, simulator, simulator-management, swift-package, ui-automation, utilities, workflow-discovery. Some workflows (session-management) are mandatory and cannot be disabled. + +**Stateful:** relies on MCP server process to track enabled workflows. + +## Simulator management + +### `erase_sims` + +```sh +xcrun simctl erase "" +# optional pre-step +xcrun simctl shutdown "" +``` + +**MCP notes:** can auto-shutdown before erase when `shutdownFirst` is true. + +### `reset_sim_location` + +```sh +xcrun simctl location "" clear +``` + +### `set_sim_location` + +```sh +xcrun simctl location "" set "," +``` + +### `set_sim_appearance` + +```sh +xcrun simctl ui "" appearance +``` + +### `sim_statusbar` + +```sh +xcrun simctl status_bar "" clear +# or +xcrun simctl status_bar "" override --dataNetwork +``` + +## Debugging (LLDB / DAP) + +These tools are stateful: MCP maintains interactive debug sessions in server memory. + +### `debug_attach_sim` + +```sh +xcrun simctl spawn "" launchctl list | rg "" +xcrun lldb -p +``` + +**MCP arguments:** + +- `bundleId`: bundle identifier to attach to +- `pid`: process ID to attach to (alternative to bundleId) +- `waitFor`: wait for process to appear when attaching +- `continueOnAttach`: continue execution after attaching (default: true) +- `makeCurrent`: set debug session as current (default: true) + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_breakpoint_add` + +```sh +breakpoint set --file "" --line +# or +breakpoint set --name "" +``` + +**MCP arguments:** + +- `debugSessionId`: session ID (default: current session) +- `file`: source file path +- `line`: line number in file +- `function`: function name (alternative to file/line) +- `condition`: expression for conditional breakpoint + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_breakpoint_remove` + +```sh +breakpoint delete +``` + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_continue` + +```sh +process continue +``` + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_detach` + +```sh +process detach +``` + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_lldb_command` + +```sh + +``` + +**MCP arguments:** + +- `debugSessionId`: session ID (default: current session) +- `command`: LLDB command to execute +- `timeoutMs`: command timeout in milliseconds + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_stack` + +```sh +thread backtrace +``` + +**MCP arguments:** + +- `debugSessionId`: session ID (default: current session) +- `threadIndex`: thread index to inspect +- `maxFrames`: maximum number of frames to return + +**Stateful:** relies on MCP server process to track the active debug session. + +### `debug_variables` + +```sh +frame variable +``` + +**MCP arguments:** + +- `debugSessionId`: session ID (default: current session) +- `frameIndex`: frame index to inspect + +**Stateful:** relies on MCP server process to track the active debug session. + +## Swift Package Manager + +### `swift_package_build` + +```sh +swift build --package-path "" [-c release] [--target ""] [--arch ""] [-Xswiftc -parse-as-library] +``` + +### `swift_package_clean` + +```sh +swift package --package-path "" clean +``` + +### `swift_package_run` + +```sh +swift run --package-path "" [] [-- ] +# background (example) +swift run --package-path "" [] -- & +``` + +**MCP arguments:** + +- `packagePath`: path to Swift package +- `executableName`: name of executable to run +- `arguments`: arguments to pass to executable +- `timeout`: execution timeout in milliseconds +- `background`: run in background +- `parseAsLibrary`: add -Xswiftc -parse-as-library flag + +**Stateful:** relies on MCP server process to track background processes for list/stop. + +### `swift_package_test` + +```sh +swift test --package-path "" [-c release] [--test-product ""] [--filter ""] [--no-parallel] [--show-code-coverage] [-Xswiftc -parse-as-library] +``` + +### `swift_package_list` + +```sh +ps -axo pid,command | rg "" +``` + +**Stateful:** relies on MCP server process to track active processes started via `swift_package_run`. + +### `swift_package_stop` + +```sh +kill -TERM +``` + +**Stateful:** relies on MCP server process to track active processes started via `swift_package_run`. + +## Additional workflows + +These workflow-specific mappings are merged into this file. +## xcodemake branch (build tools) +When incremental builds are enabled and available, MCP may replace the xcodebuild call with: +```sh +xcodemake +make +``` +This branch is only used for `build` actions when xcodemake is enabled and `preferXcodebuild` is not set. +## Not reproducible statelessly (summary) +| Tool | Why a single CLI call is not enough | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| `build_run_sim` | Orchestrates multiple commands (build, resolve app path, boot, install, launch). | +| `launch_app_logs_sim` | Starts background log processes and returns a sessionId. | +| `start_sim_log_cap` / `stop_sim_log_cap` | Uses in-memory session tracking, multiple processes, log files. | +| `start_device_log_cap` / `stop_device_log_cap` | Uses in-memory session tracking, JSON polling, log files. | +| `record_sim_video` | Long-running AXe process with buffered output, parsed file path, and explicit stop. | +| UI automation tools (`snapshot_ui`, `tap`, `swipe`, etc.) | MCP guards against paused debuggers and warns on stale coordinate usage; CLI won't. | +| `session-*` tools | Store defaults in MCP server memory; no CLI equivalent. | +| `manage-workflows` | Dynamically enables/disables workflow groups in MCP server memory; no CLI equivalent.| +| `debug_*` tools | Maintain interactive LLDB/DAP sessions in MCP server memory. | +| `swift_package_run` / `swift_package_list` / `swift_package_stop` | Track processes started by MCP for list/stop. | From db8018cbc04a65ea030537e43a02bc13f22f0e90 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Feb 2026 15:52:50 +0000 Subject: [PATCH 2/3] Make CLI --- CHANGELOG.md | 8 + README.md | 49 +- docs/CLI.md | 994 ++----- docs/DAP_BACKEND_IMPLEMENTATION_PLAN.md | 2 +- docs/GETTING_STARTED.md | 41 +- docs/README.md | 1 + docs/TOOLS.md | 2 +- docs/dev/ARCHITECTURE.md | 2 +- docs/dev/CLI_CONVERSION_PLAN.md | 894 ++++++ docs/dev/CONTRIBUTING.md | 23 +- docs/dev/MANUAL_TESTING.md | 86 +- docs/dev/RELOADEROO.md | 86 +- docs/dev/RELOADEROO_FOR_XCODEBUILDMCP.md | 124 +- docs/dev/TESTING.md | 88 +- docs/dev/oracle-prompt-workspace-daemon.md | 2608 +++++++++++++++++ docs/dev/session_management_plan.md | 16 +- docs/investigations/daemon-log-missing.md | 34 + docs/investigations/launch-app-logs-sim.md | 39 + docs/investigations/spawn-sh-enoent.md | 59 + .../iOS/.xcodebuildmcp/config.yaml | 3 + .../iOS_Calculator/.xcodebuildmcp/config.yaml | 3 +- package-lock.json | 200 +- package.json | 4 +- src/cli.ts | 59 + src/cli/cli-tool-catalog.ts | 18 + src/cli/commands/daemon.ts | 344 +++ src/cli/commands/mcp.ts | 11 + src/cli/commands/tools.ts | 71 + src/cli/daemon-client.ts | 163 ++ src/cli/daemon-control.ts | 161 + src/cli/output.ts | 136 + src/cli/register-tool-commands.ts | 189 ++ src/cli/schema-to-yargs.ts | 245 ++ src/cli/yargs-app.ts | 95 + src/core/plugin-types.ts | 12 + src/daemon.ts | 251 ++ src/daemon/daemon-registry.ts | 137 + src/daemon/daemon-server.ts | 150 + src/daemon/framing.ts | 58 + src/daemon/protocol.ts | 59 + src/daemon/socket-path.ts | 147 + src/index.ts | 73 - .../resources/__tests__/simulators.test.ts | 11 +- src/mcp/tools/debugging/debug_attach_sim.ts | 48 +- .../tools/debugging/debug_breakpoint_add.ts | 3 + .../debugging/debug_breakpoint_remove.ts | 3 + src/mcp/tools/debugging/debug_continue.ts | 3 + src/mcp/tools/debugging/debug_detach.ts | 3 + src/mcp/tools/debugging/debug_lldb_command.ts | 3 + src/mcp/tools/debugging/debug_stack.ts | 3 + src/mcp/tools/debugging/debug_variables.ts | 3 + .../device/__tests__/build_device.test.ts | 6 +- .../__tests__/get_device_app_path.test.ts | 28 +- .../__tests__/install_app_device.test.ts | 2 +- .../__tests__/launch_app_device.test.ts | 15 +- .../device/__tests__/list_devices.test.ts | 26 +- .../device/__tests__/stop_app_device.test.ts | 2 +- src/mcp/tools/device/get_device_app_path.ts | 27 +- src/mcp/tools/device/install_app_device.ts | 2 +- src/mcp/tools/device/launch_app_device.ts | 23 +- src/mcp/tools/device/list_devices.ts | 38 +- src/mcp/tools/device/stop_app_device.ts | 2 +- .../__tests__/start_device_log_cap.test.ts | 5 +- .../__tests__/start_sim_log_cap.test.ts | 10 +- src/mcp/tools/logging/start_device_log_cap.ts | 13 +- src/mcp/tools/logging/start_sim_log_cap.ts | 13 +- src/mcp/tools/logging/stop_device_log_cap.ts | 3 + src/mcp/tools/logging/stop_sim_log_cap.ts | 3 + .../macos/__tests__/build_run_macos.test.ts | 10 +- .../macos/__tests__/get_mac_app_path.test.ts | 52 +- .../tools/macos/__tests__/test_macos.test.ts | 2 +- src/mcp/tools/macos/build_run_macos.ts | 4 +- src/mcp/tools/macos/get_mac_app_path.ts | 21 +- .../__tests__/get_app_bundle_id.test.ts | 56 +- .../__tests__/get_mac_bundle_id.test.ts | 32 +- .../__tests__/list_schemes.test.ts | 67 +- .../__tests__/show_build_settings.test.ts | 52 +- .../project-discovery/get_app_bundle_id.ts | 28 +- .../project-discovery/get_mac_bundle_id.ts | 16 +- .../tools/project-discovery/list_schemes.ts | 46 +- .../project-discovery/show_build_settings.ts | 44 +- .../__tests__/set_sim_appearance.test.ts | 2 +- .../__tests__/set_sim_location.test.ts | 2 +- .../__tests__/sim_statusbar.test.ts | 4 +- .../reset_sim_location.ts | 2 +- .../set_sim_appearance.ts | 2 +- .../simulator-management/set_sim_location.ts | 2 +- .../simulator-management/sim_statusbar.ts | 2 +- .../simulator/__tests__/boot_sim.test.ts | 24 +- .../__tests__/get_sim_app_path.test.ts | 2 +- .../__tests__/install_app_sim.test.ts | 40 +- .../__tests__/launch_app_logs_sim.test.ts | 10 +- .../__tests__/launch_app_sim.test.ts | 52 +- .../simulator/__tests__/list_sims.test.ts | 144 +- .../simulator/__tests__/open_sim.test.ts | 39 +- .../__tests__/record_sim_video.test.ts | 11 +- .../simulator/__tests__/stop_app_sim.test.ts | 2 +- src/mcp/tools/simulator/boot_sim.ts | 29 +- src/mcp/tools/simulator/build_run_sim.ts | 37 +- src/mcp/tools/simulator/get_sim_app_path.ts | 93 +- src/mcp/tools/simulator/install_app_sim.ts | 25 +- .../tools/simulator/launch_app_logs_sim.ts | 13 +- src/mcp/tools/simulator/launch_app_sim.ts | 31 +- src/mcp/tools/simulator/list_sims.ts | 41 +- src/mcp/tools/simulator/open_sim.ts | 39 +- src/mcp/tools/simulator/record_sim_video.ts | 21 +- src/mcp/tools/simulator/stop_app_sim.ts | 4 +- .../__tests__/swift_package_build.test.ts | 6 +- .../__tests__/swift_package_clean.test.ts | 2 +- .../__tests__/swift_package_run.test.ts | 12 +- .../__tests__/swift_package_test.test.ts | 4 +- .../tools/swift-package/active-processes.ts | 2 + .../swift-package/swift_package_build.ts | 2 +- .../swift-package/swift_package_clean.ts | 2 +- .../tools/swift-package/swift_package_list.ts | 38 +- .../tools/swift-package/swift_package_run.ts | 9 +- .../tools/swift-package/swift_package_stop.ts | 3 + .../tools/swift-package/swift_package_test.ts | 2 +- .../__tests__/screenshot.test.ts | 16 +- .../__tests__/snapshot_ui.test.ts | 26 +- src/mcp/tools/ui-automation/button.ts | 3 + src/mcp/tools/ui-automation/gesture.ts | 3 + src/mcp/tools/ui-automation/key_press.ts | 3 + src/mcp/tools/ui-automation/key_sequence.ts | 3 + src/mcp/tools/ui-automation/long_press.ts | 3 + src/mcp/tools/ui-automation/screenshot.ts | 70 +- src/mcp/tools/ui-automation/snapshot_ui.ts | 29 +- src/mcp/tools/ui-automation/swipe.ts | 3 + src/mcp/tools/ui-automation/tap.ts | 3 + src/mcp/tools/ui-automation/touch.ts | 3 + src/mcp/tools/ui-automation/type_text.ts | 3 + src/runtime/bootstrap-runtime.ts | 71 + src/runtime/naming.ts | 77 + src/runtime/tool-catalog.ts | 118 + src/runtime/tool-invoker.ts | 164 ++ src/runtime/types.ts | 90 + src/server/bootstrap.ts | 31 +- src/server/start-mcp-server.ts | 50 + src/types/common.ts | 27 + src/utils/CommandExecutor.ts | 4 + src/utils/__tests__/project-config.test.ts | 21 + src/utils/build-utils.ts | 2 +- src/utils/command.ts | 47 +- src/utils/logger.ts | 71 +- src/utils/project-config.ts | 35 +- .../__tests__/next-steps-renderer.test.ts | 299 ++ src/utils/responses/index.ts | 7 +- src/utils/responses/next-steps-renderer.ts | 119 + src/utils/tool-registry.ts | 7 +- src/utils/video_capture.ts | 2 +- src/utils/xcodemake.ts | 4 +- tsup.config.ts | 15 +- 152 files changed, 9085 insertions(+), 1572 deletions(-) create mode 100644 docs/dev/CLI_CONVERSION_PLAN.md create mode 100644 docs/dev/oracle-prompt-workspace-daemon.md create mode 100644 docs/investigations/daemon-log-missing.md create mode 100644 docs/investigations/launch-app-logs-sim.md create mode 100644 docs/investigations/spawn-sh-enoent.md create mode 100644 src/cli.ts create mode 100644 src/cli/cli-tool-catalog.ts create mode 100644 src/cli/commands/daemon.ts create mode 100644 src/cli/commands/mcp.ts create mode 100644 src/cli/commands/tools.ts create mode 100644 src/cli/daemon-client.ts create mode 100644 src/cli/daemon-control.ts create mode 100644 src/cli/output.ts create mode 100644 src/cli/register-tool-commands.ts create mode 100644 src/cli/schema-to-yargs.ts create mode 100644 src/cli/yargs-app.ts create mode 100644 src/daemon.ts create mode 100644 src/daemon/daemon-registry.ts create mode 100644 src/daemon/daemon-server.ts create mode 100644 src/daemon/framing.ts create mode 100644 src/daemon/protocol.ts create mode 100644 src/daemon/socket-path.ts delete mode 100644 src/index.ts create mode 100644 src/runtime/bootstrap-runtime.ts create mode 100644 src/runtime/naming.ts create mode 100644 src/runtime/tool-catalog.ts create mode 100644 src/runtime/tool-invoker.ts create mode 100644 src/runtime/types.ts create mode 100644 src/server/start-mcp-server.ts create mode 100644 src/utils/responses/__tests__/next-steps-renderer.test.ts create mode 100644 src/utils/responses/next-steps-renderer.ts 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: - [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/en/install-mcp?name=XcodeBuildMCP&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoibnB4IC15IHhjb2RlYnVpbGRtY3BAbGF0ZXN0IiwiZW52Ijp7IklOQ1JFTUVOVEFMX0JVSUxEU19FTkFCTEVEIjoiZmFsc2UiLCJYQ09ERUJVSUxETUNQX1NFTlRSWV9ESVNBQkxFRCI6ImZhbHNlIn19) + [![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](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: - [Install in VS Code](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) - [Install in VS Code 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%5D%7D&quality=insiders) + [Install in VS Code](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) + [Install in VS Code 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&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 index cced368d..2023b3c2 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -1,921 +1,267 @@ -# CLI Mapping for MCP Tools +# XcodeBuildMCP CLI -This doc maps MCP tools that wrap CLIs (xcodebuild, xcrun simctl/devicectl/xcresulttool/xctrace, AXe) to equivalent terminal commands. It also calls out MCP-only pre/post-processing and stateful behavior. +`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. -## Conventions +## Installation -- Placeholders use angle brackets (e.g., ``, ``). -- Commands are shown in the shape MCP executes; some tools orchestrate multiple commands. -- Use `-workspace` **or** `-project` depending on your project type. -- Tool names in MCP are kebab-case where defined (e.g., `session-set-defaults`). Older docs may show snake_case aliases. +```bash +# Install globally +npm install -g xcodebuildmcp -## Session defaults (stateful but optional) - -Many tools are session-aware and omit parameters that can be provided once via session defaults. See `docs/SESSION_DEFAULTS.md` for details and opt-out: - -```json -"env": { - "XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS": "true" -} -``` - -## Environment variables observed - -- `XCODEBUILDMCP_AXE_PATH`: override AXe binary path (preferred over PATH). -- `AXE_PATH`: alternate AXe path override. -- `XBMCP_LAUNCH_JSON_WAIT_MS`: device log launch JSON wait timeout (ms). - -## Project discovery - -### `discover_projs` - -**CLI equivalent (approximate):** - -```sh -cd "" -find "" -maxdepth \ - \( -name build -o -name DerivedData -o -name Pods -o -name .git -o -name node_modules \) -prune -false \ - -o -name "*.xcodeproj" -print \ - -o -name "*.xcworkspace" -print -``` - -**MCP notes:** enforces scan within ``, skips symlinks, returns absolute paths. - -### `list_schemes` - -```sh -xcodebuild -list -workspace "" -# or -xcodebuild -list -project "" -``` - -**MCP notes:** parses the `Schemes:` block and adds “Next Steps”. - -### `show_build_settings` - -```sh -xcodebuild -showBuildSettings -workspace "" -scheme "" -# or -xcodebuild -showBuildSettings -project "" -scheme "" -``` - -**MCP notes:** formats output and adds “Next Steps”. - -### `get_app_bundle_id` - -```sh -defaults read "/Info" CFBundleIdentifier -# fallback -/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Info.plist" -``` - -**MCP notes:** validates file existence before reading. - -### `get_mac_bundle_id` - -```sh -defaults read "/Contents/Info" CFBundleIdentifier -# fallback -/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "/Contents/Info.plist" -``` - -## Simulator - -### `list_sims` - -```sh -xcrun simctl list devices --json -# fallback when JSON parsing fails -xcrun simctl list devices -``` - -**MCP arguments:** - -- `enabled`: boolean filter (optional) - -**MCP notes:** merges JSON and text output to work around simctl JSON bugs and filters to available devices. - -### `boot_sim` - -```sh -xcrun simctl boot "" -``` - -### `open_sim` - -```sh -open -a Simulator -``` - -### `install_app_sim` - -```sh -xcrun simctl install "" "" -``` - -**MCP notes:** validates file existence before install. - -### `launch_app_sim` - -```sh -xcrun simctl get_app_container "" "" app -xcrun simctl launch "" "" [] -``` - -**MCP notes:** resolves simulator name to UDID when needed. - -### `stop_app_sim` - -```sh -xcrun simctl terminate "" "" -``` - -### `get_sim_app_path` - -```sh -xcodebuild -showBuildSettings \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=,id=' -# or use -project "" -``` - -**MCP notes:** parses `CODESIGNING_FOLDER_PATH` or `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. - -### `build_sim` - -```sh -xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=,id=' \ - -skipMacroValidation \ - build \ - [] -# or use -project "" -``` - -**MCP notes:** uses project directory as `cwd` and may use xcodemake/make for incremental builds (see “xcodemake branch”). - -### `test_sim` - -```sh -TEST_RUNNER_FOO=bar xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=,id=' \ - -skipMacroValidation \ - test -# or use -project "" -``` - -**MCP notes:** normalizes `testRunnerEnv` values, enforcing `TEST_RUNNER_` prefix. - -### `build_run_sim` (multi-step orchestration) - -1. Build (same as `build_sim`). -2. Resolve app path via `xcodebuild -showBuildSettings` and parse product paths. -3. Ensure simulator exists/booted (`simctl list`, then `simctl boot`). -4. Open Simulator app (best-effort). -5. Install app: - -```sh -xcrun simctl install "" "" -``` - -1. Extract bundle ID from Info.plist. -2. Launch: - -```sh -xcrun simctl launch "" "" -``` - -**MCP notes:** resolves simulator names to UDIDs and handles missing/booted state. Not a single CLI call. - -### `launch_app_logs_sim` - -**CLI equivalents (conceptual):** - -```sh -xcrun simctl launch --console-pty "" "" [] -xcrun simctl spawn "" log stream --level=debug --predicate 'subsystem == ""' -``` - -**MCP notes:** returns a `sessionId` and manages background processes; requires stop tool to collect logs. -**Stateful:** relies on MCP server process to track the active log capture session. - -### `screenshot` - -```sh -xcrun simctl io "" screenshot "" -# MCP post-processing -sips -Z 800 -s format jpeg -s formatOptions 75 "" --out "" -``` - -**MCP notes:** creates temp files, optimizes to JPEG, cleans up, and returns base64. - -### `record_sim_video` - -```sh -"" record-video --udid "" --fps -``` - -**MCP notes:** stateful start/stop session, collects stdout to parse MP4 path, moves file on stop, and enforces AXe >= 1.1.0. -**Stateful:** relies on MCP server process to track the active recording session. - -## Device - -### `list_devices` - -```sh -xcrun devicectl list devices --json-output "" -cat "" -# fallback -xcrun xctrace list devices -``` - -**MCP notes:** reads temp JSON, parses, cleans up; falls back to xctrace when devicectl is unavailable or empty. - -### `install_app_device` - -```sh -xcrun devicectl device install app --device "" "" -``` - -### `launch_app_device` - -```sh -xcrun devicectl device process launch \ - --device "" \ - --json-output "" \ - --terminate-existing \ - "" -``` - -**MCP notes:** parses JSON output for PID and adds “Next Steps”. - -### `stop_app_device` - -```sh -xcrun devicectl device process terminate --device "" --pid "" -``` - -### `build_device` - -```sh -xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'generic/platform=iOS' \ - -skipMacroValidation \ - build -# or use -project "" -``` - -**MCP notes:** when deviceId is provided, destination becomes `platform=iOS,id=`. - -### `get_device_app_path` - -```sh -xcodebuild -showBuildSettings \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'generic/platform=' -# or use -project "" -``` - -**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. - -### `test_device` - -```sh -xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=iOS,id=' \ - -skipMacroValidation \ - test \ - -resultBundlePath "/TestResults.xcresult" - -xcrun xcresulttool get test-results summary --path "/TestResults.xcresult" -``` - -**MCP notes:** creates temp bundle directory, parses summary JSON, formats output, and cleans up. - -## Logging - -### `start_sim_log_cap` - -```sh -xcrun simctl launch --console-pty --terminate-running-process "" "" [] -xcrun simctl spawn "" log stream --level=debug --predicate 'subsystem == ""' -``` - -**MCP arguments:** - -- `captureConsole`: boolean to capture console output -- `subsystemFilter`: filter logs by subsystem (app|all|swiftui|[custom subsystem]) - -**MCP notes:** writes to a temp log file, cleans old logs, manages multiple processes, returns `sessionId`. -**Stateful:** relies on MCP server process to track the active log capture session. - -### `stop_sim_log_cap` - -```sh -kill -TERM -cat "" -``` - -**MCP notes:** uses sessionId to find processes/log file, stops processes, reads and returns log content. -**Stateful:** relies on MCP server process to track the active log capture session. - -### `start_device_log_cap` - -```sh -xcrun devicectl device process launch \ - --console \ - --terminate-existing \ - --device "" \ - --json-output "" \ - "" -``` - -**MCP notes:** tails process output into a temp log file, polls JSON result for PID/errors, detects early failures, returns `sessionId`. -**Stateful:** relies on MCP server process to track the active log capture session. - -### `stop_device_log_cap` - -```sh -kill -TERM -cat "" -``` - -**MCP notes:** uses sessionId to find process/log file, waits for close, then reads log content. -**Stateful:** relies on MCP server process to track the active log capture session. - -## UI automation (AXe) - -All UI automation tools call AXe with `--udid `. If an AXe binary is bundled or configured, MCP sets up the correct environment and provides friendly errors. When a debugger is attached and stopped, MCP blocks UI automation and returns a warning. - -### `snapshot_ui` - -```sh -"" describe-ui --udid "" +# Or run via npx +npx xcodebuildmcp --help ``` -**MCP notes:** records call timestamp and warns if subsequent coordinate-based tools run without a fresh `snapshot_ui`. +## Quick Start -### `tap` +```bash +# Start MCP server (for MCP clients like Claude, Cursor, etc.) +xcodebuildmcp mcp -```sh -"" tap -x -y [--pre-delay ] [--post-delay ] --udid "" -# or -"" tap --id "" [--pre-delay ] [--post-delay ] --udid "" -# or -"" tap --label "" [--pre-delay ] [--post-delay ] --udid "" -``` - -**MCP arguments:** +# List available tools +xcodebuildmcp tools -- `x`, `y`: tap coordinates (mutually exclusive with id/label) -- `id`: accessibility identifier (mutually exclusive with x/y and label) -- `label`: accessibility label (mutually exclusive with x/y and id) -- `preDelay`: seconds to wait before tap -- `postDelay`: seconds to wait after tap +# Build for simulator +xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj -**MCP notes:** validates mutual exclusivity (id vs label), and warns on stale coordinates. +# List simulators +xcodebuildmcp list-sims -### `swipe` - -```sh -"" swipe --start-x --start-y --end-x --end-y \ - [--duration ] [--delta ] [--pre-delay ] [--post-delay ] \ - --udid "" +# Run tests +xcodebuildmcp test-sim --scheme MyApp --simulator-name "iPhone 16 Pro" ``` -**MCP notes:** warns if `describe_ui` was not called recently. - -### `gesture` +## Per-Workspace Daemon -```sh -"" gesture \ - [--screen-width ] [--screen-height ] [--duration ] [--delta ] \ - [--pre-delay ] [--post-delay ] \ - --udid "" -``` +The CLI uses a per-workspace daemon architecture for stateful operations (log capture, video recording, debugging). Each workspace gets its own daemon instance. -**MCP notes:** presets include scroll and edge swipes; MCP validates allowed values. +### How It Works -### `touch` +- **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. -```sh -"" touch -x -y [--down] [--up] [--delay ] --udid "" -``` +### Daemon Commands -**MCP notes:** requires at least one of `--down` or `--up` and warns on stale coordinates. +```bash +# Check daemon status for current workspace +xcodebuildmcp daemon status -### `long_press` +# Manually start the daemon +xcodebuildmcp daemon start -```sh -"" touch -x -y --down --up --delay --udid "" -``` +# Stop the daemon +xcodebuildmcp daemon stop -**MCP notes:** converts `duration` (ms) to `--delay` (seconds). +# Restart the daemon +xcodebuildmcp daemon restart -### `button` +# List all daemons across workspaces +xcodebuildmcp daemon list -```sh -"" button [--duration ] --udid "" +# List in JSON format +xcodebuildmcp daemon list --json ``` -**MCP notes:** valid button types are enforced (apple-pay, home, lock, side-button, siri). - -### `key_press` +### Daemon Status Output -```sh -"" key [--duration ] --udid "" ``` - -### `key_sequence` - -```sh -"" key-sequence --keycodes ",," [--delay ] --udid "" +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) ``` -### `type_text` +### Daemon List Output -```sh -"" type "" --udid "" ``` +Daemons: -## Utilities + [running] c5da0cbe19a7 + Workspace: /Users/you/Projects/MyApp + PID: 12345 + Started: 2024-01-15T10:30:00.000Z + Version: 1.15.0 -### `clean` + [stale] a1b2c3d4e5f6 + Workspace: /Users/you/Projects/OldProject + PID: 99999 + Started: 2024-01-14T08:00:00.000Z + Version: 1.14.0 -```sh -xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination '' \ - clean -# or use -project "" +Total: 2 (1 running, 1 stale) ``` -**MCP arguments:** - -- `extraArgs`: additional xcodebuild arguments -- `platform`: target platform (macOS, iOS, iOS Simulator, watchOS, watchOS Simulator, tvOS, tvOS Simulator, visionOS, visionOS Simulator) - -**MCP notes:** enforces project/workspace exclusivity and validates required scheme. +## Global Options -## Doctor +| 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 | -### `doctor` +## Tool Options -```sh -xcodebuild -version -xcode-select -p -xcrun --find xcodebuild -xcrun --version -xcrun --find lldb-dap +Each tool supports `--help` for detailed options: -which mise -mise --version - -"" --version +```bash +xcodebuildmcp build-sim --help ``` -**MCP arguments:** - -- `enabled`: boolean filter +Common patterns: -**MCP notes:** reports internal workflow registry and server version in addition to CLI probes. +```bash +# Pass options as flags +xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj -## macOS +# Pass complex options as JSON +xcodebuildmcp build-sim --json '{"scheme": "MyApp", "projectPath": "./MyApp.xcodeproj"}' -### `build_macos` - -```sh -xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=macOS,arch=' \ - -skipMacroValidation \ - build \ - [] -# or use -project "" +# Control output format +xcodebuildmcp list-sims --output json ``` -**MCP notes:** uses project directory as `cwd` and may use xcodemake/make for incremental builds. - -### `build_run_macos` (multi-step orchestration) +## Stateful vs Stateless Tools -1. Build (same as `build_macos`). -2. Resolve app path with: +### 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` -```sh -xcodebuild -showBuildSettings \ - -workspace "" \ - -scheme "" \ - -configuration "" -# or use -project "" -``` - -3. Launch: +### 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` -```sh -open "" -``` +When you invoke a stateful tool, the daemon auto-starts if needed. -**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME` to find the app path. +## Opting Out of Daemon -### `get_mac_app_path` +If you want to disable daemon auto-start (stateful tools will error): -```sh -xcodebuild -showBuildSettings \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=macOS,arch=' -# or use -project "" +```bash +xcodebuildmcp build-sim --no-daemon --scheme MyApp ``` -**MCP arguments:** +This is useful for CI environments or when you want explicit control. -- `derivedDataPath`: custom DerivedData path -- `extraArgs`: additional xcodebuild arguments +## Configuration -**MCP notes:** parses `BUILT_PRODUCTS_DIR` + `FULL_PRODUCT_NAME`. +The CLI respects the same configuration as the MCP server: -### `launch_mac_app` +```yaml +# .xcodebuildmcp/config.yaml +sessionDefaults: + scheme: MyApp + projectPath: ./MyApp.xcodeproj + simulatorName: iPhone 16 Pro -```sh -open "" --args +enabledWorkflows: + - simulator + - project-discovery ``` -**MCP notes:** validates the .app bundle exists before launching. - -### `stop_mac_app` +See [CONFIGURATION.md](CONFIGURATION.md) for the full schema. -```sh -kill -# or -pkill -f "" || osascript -e 'tell application "" to quit' -``` - -### `test_macos` +## Environment Variables -```sh -TEST_RUNNER_FOO=bar xcodebuild \ - -workspace "" \ - -scheme "" \ - -configuration "" \ - -destination 'platform=macOS,arch=' \ - -skipMacroValidation \ - test \ - -resultBundlePath "/TestResults.xcresult" +| Variable | Description | +|----------|-------------| +| `XCODEBUILDMCP_SOCKET` | Override socket path for all commands | +| `XCODEBUILDMCP_DISABLE_SESSION_DEFAULTS` | Disable session defaults | -xcrun xcresulttool get test-results summary --path "/TestResults.xcresult" -``` +## Examples -**MCP notes:** normalizes `testRunnerEnv` to `TEST_RUNNER_*`, parses xcresult summary, and cleans temp output. +### Build and Run Workflow -## Project scaffolding +```bash +# Discover projects +xcodebuildmcp discover-projs -These tools are not direct CLI wrappers; they orchestrate template download plus file transformations. +# List schemes +xcodebuildmcp list-schemes --project-path ./MyApp.xcodeproj -### `scaffold_ios_project` +# Build +xcodebuildmcp build-sim --scheme MyApp --project-path ./MyApp.xcodeproj -**CLI equivalent (approximate):** +# Boot simulator +xcodebuildmcp boot-sim --simulator-name "iPhone 16 Pro" -```sh -cp -R "" "" -# then replace placeholders in text files (project name, bundle id, versions) +# 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 ``` -**MCP arguments:** - -- `projectName`: project name -- `outputPath`: destination path -- `bundleIdentifier`: app bundle identifier -- `displayName`: app display name -- `marketingVersion`: marketing version string -- `currentProjectVersion`: build number -- `customizeNames`: customize names during scaffolding -- `deploymentTarget`: minimum iOS version -- `targetedDeviceFamily`: array of device types (iPhone, iPad) -- `supportedOrientations`: array of supported orientations for iPhone -- `supportedOrientationsIpad`: array of supported orientations for iPad +### Log Capture Workflow -**MCP notes:** downloads templates via TemplateManager and performs content/filename rewrites; no single CLI command maps 1:1. +```bash +# Start log capture (daemon auto-starts) +xcodebuildmcp start-sim-log-cap --simulator-id --bundle-id com.example.MyApp -### `scaffold_macos_project` +# ... use your app ... -**CLI equivalent (approximate):** - -```sh -cp -R "" "" -# then replace placeholders in text files (project name, bundle id, versions) +# Stop and retrieve logs +xcodebuildmcp stop-sim-log-cap --session-id ``` -**MCP arguments:** - -- `projectName`: project name -- `outputPath`: destination path -- `bundleIdentifier`: app bundle identifier -- `displayName`: app display name -- `marketingVersion`: marketing version string -- `currentProjectVersion`: build number -- `customizeNames`: customize names during scaffolding -- `deploymentTarget`: minimum macOS version - -**MCP notes:** downloads templates via TemplateManager and performs content/filename rewrites; no single CLI command maps 1:1. - -## Session management (stateful) - -These tools manage MCP server state and have no direct CLI equivalent. - -### `session-set-defaults` - -**CLI equivalent:** none. This is MCP server state. - -**MCP arguments:** - -- `projectPath`: xcodeproj path (mutually exclusive with workspacePath) -- `workspacePath`: xcworkspace path (mutually exclusive with projectPath) -- `scheme`: Xcode scheme name -- `configuration`: build configuration (e.g., Debug, Release) -- `simulatorName`: simulator name -- `simulatorId`: simulator UUID -- `deviceId`: physical device UUID -- `useLatestOS`: use latest OS version for simulator -- `arch`: architecture (arm64, x86_64) -- `suppressWarnings`: suppress build warnings -- `derivedDataPath`: custom DerivedData path -- `preferXcodebuild`: prefer xcodebuild over incremental builds -- `platform`: device platform (e.g., iOS, watchOS) -- `bundleId`: default bundle identifier -- `persist`: persist defaults to .xcodebuildmcp/config.yaml - -**Stateful:** relies on MCP server process to store defaults. - -### `session-show-defaults` - -**CLI equivalent:** none. This is MCP server state. - -**Stateful:** relies on MCP server process to store defaults. - -### `session-clear-defaults` - -**CLI equivalent:** none. This is MCP server state. - -**MCP arguments:** +### Testing -- `keys`: array of specific keys to clear -- `all`: boolean to clear all defaults +```bash +# Run all tests +xcodebuildmcp test-sim --scheme MyAppTests --project-path ./MyApp.xcodeproj -**Stateful:** relies on MCP server process to store defaults. - -## Workflow management (stateful) - -### `manage-workflows` - -**CLI equivalent:** none. This is MCP server state. - -**MCP arguments:** - -- `workflowNames`: array of workflow directory names to enable/disable -- `enable`: boolean to enable (true) or disable (false) the specified workflows - -**MCP notes:** dynamically enables or disables workflow groups at runtime. Available workflows: debugging, device, doctor, logging, macos, project-discovery, project-scaffolding, session-management, simulator, simulator-management, swift-package, ui-automation, utilities, workflow-discovery. Some workflows (session-management) are mandatory and cannot be disabled. - -**Stateful:** relies on MCP server process to track enabled workflows. - -## Simulator management - -### `erase_sims` - -```sh -xcrun simctl erase "" -# optional pre-step -xcrun simctl shutdown "" +# Run with specific simulator +xcodebuildmcp test-sim --scheme MyAppTests --simulator-name "iPhone 16 Pro" ``` -**MCP notes:** can auto-shutdown before erase when `shutdownFirst` is true. +## CLI vs MCP Mode -### `reset_sim_location` +| 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 | -```sh -xcrun simctl location "" clear -``` +Both share the same underlying tool implementations. -### `set_sim_location` +## Troubleshooting -```sh -xcrun simctl location "" set "," -``` - -### `set_sim_appearance` - -```sh -xcrun simctl ui "" appearance -``` +### Daemon won't start -### `sim_statusbar` - -```sh -xcrun simctl status_bar "" clear -# or -xcrun simctl status_bar "" override --dataNetwork -``` - -## Debugging (LLDB / DAP) - -These tools are stateful: MCP maintains interactive debug sessions in server memory. - -### `debug_attach_sim` - -```sh -xcrun simctl spawn "" launchctl list | rg "" -xcrun lldb -p -``` - -**MCP arguments:** - -- `bundleId`: bundle identifier to attach to -- `pid`: process ID to attach to (alternative to bundleId) -- `waitFor`: wait for process to appear when attaching -- `continueOnAttach`: continue execution after attaching (default: true) -- `makeCurrent`: set debug session as current (default: true) - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_breakpoint_add` - -```sh -breakpoint set --file "" --line -# or -breakpoint set --name "" -``` - -**MCP arguments:** - -- `debugSessionId`: session ID (default: current session) -- `file`: source file path -- `line`: line number in file -- `function`: function name (alternative to file/line) -- `condition`: expression for conditional breakpoint - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_breakpoint_remove` - -```sh -breakpoint delete -``` - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_continue` - -```sh -process continue -``` - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_detach` - -```sh -process detach -``` - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_lldb_command` - -```sh - -``` - -**MCP arguments:** - -- `debugSessionId`: session ID (default: current session) -- `command`: LLDB command to execute -- `timeoutMs`: command timeout in milliseconds - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_stack` - -```sh -thread backtrace -``` - -**MCP arguments:** - -- `debugSessionId`: session ID (default: current session) -- `threadIndex`: thread index to inspect -- `maxFrames`: maximum number of frames to return - -**Stateful:** relies on MCP server process to track the active debug session. - -### `debug_variables` - -```sh -frame variable -``` - -**MCP arguments:** - -- `debugSessionId`: session ID (default: current session) -- `frameIndex`: frame index to inspect - -**Stateful:** relies on MCP server process to track the active debug session. - -## Swift Package Manager - -### `swift_package_build` - -```sh -swift build --package-path "" [-c release] [--target ""] [--arch ""] [-Xswiftc -parse-as-library] -``` - -### `swift_package_clean` - -```sh -swift package --package-path "" clean -``` - -### `swift_package_run` - -```sh -swift run --package-path "" [] [-- ] -# background (example) -swift run --package-path "" [] -- & -``` - -**MCP arguments:** - -- `packagePath`: path to Swift package -- `executableName`: name of executable to run -- `arguments`: arguments to pass to executable -- `timeout`: execution timeout in milliseconds -- `background`: run in background -- `parseAsLibrary`: add -Xswiftc -parse-as-library flag - -**Stateful:** relies on MCP server process to track background processes for list/stop. - -### `swift_package_test` - -```sh -swift test --package-path "" [-c release] [--test-product ""] [--filter ""] [--no-parallel] [--show-code-coverage] [-Xswiftc -parse-as-library] -``` +```bash +# Check for stale sockets +xcodebuildmcp daemon list -### `swift_package_list` +# Force restart +xcodebuildmcp daemon restart -```sh -ps -axo pid,command | rg "" +# Run in foreground to see logs +xcodebuildmcp daemon start --foreground ``` -**Stateful:** relies on MCP server process to track active processes started via `swift_package_run`. +### Tool timeout -### `swift_package_stop` +Increase the daemon startup timeout: -```sh -kill -TERM +```bash +# Default is 5 seconds +export XCODEBUILDMCP_STARTUP_TIMEOUT_MS=10000 ``` -**Stateful:** relies on MCP server process to track active processes started via `swift_package_run`. +### Socket permission errors -## Additional workflows +The socket directory (`~/.xcodebuildmcp/daemons/`) should have mode 0700. If you encounter permission issues: -These workflow-specific mappings are merged into this file. -## xcodemake branch (build tools) -When incremental builds are enabled and available, MCP may replace the xcodebuild call with: -```sh -xcodemake -make +```bash +chmod 700 ~/.xcodebuildmcp +chmod -R 700 ~/.xcodebuildmcp/daemons ``` -This branch is only used for `build` actions when xcodemake is enabled and `preferXcodebuild` is not set. -## Not reproducible statelessly (summary) -| Tool | Why a single CLI call is not enough | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------ | -| `build_run_sim` | Orchestrates multiple commands (build, resolve app path, boot, install, launch). | -| `launch_app_logs_sim` | Starts background log processes and returns a sessionId. | -| `start_sim_log_cap` / `stop_sim_log_cap` | Uses in-memory session tracking, multiple processes, log files. | -| `start_device_log_cap` / `stop_device_log_cap` | Uses in-memory session tracking, JSON polling, log files. | -| `record_sim_video` | Long-running AXe process with buffered output, parsed file path, and explicit stop. | -| UI automation tools (`snapshot_ui`, `tap`, `swipe`, etc.) | MCP guards against paused debuggers and warns on stale coordinate usage; CLI won't. | -| `session-*` tools | Store defaults in MCP server memory; no CLI equivalent. | -| `manage-workflows` | Dynamically enables/disables workflow groups in MCP server memory; no CLI equivalent.| -| `debug_*` tools | Maintain interactive LLDB/DAP sessions in MCP server memory. | -| `swift_package_run` / `swift_package_list` / `swift_package_stop` | Track processes started by MCP for list/stop. | 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..7e01c6a5 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, @@ -53,6 +55,9 @@ export interface LogContext { // Client-requested log level (null means no filtering) let clientLogLevel: LogLevel | null = null; +let logFileStream: WriteStream | null = null; +let logFilePath: string | null = null; + function isTestEnv(): boolean { return ( process.env.VITEST === 'true' || @@ -99,6 +104,52 @@ 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 @@ -114,7 +165,7 @@ 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; } @@ -140,11 +191,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 +202,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'); + } } }, }); From b04c37a69b1e0964c6895884ba939aaa77f84c04 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Mon, 2 Feb 2026 15:51:26 +0000 Subject: [PATCH 3/3] Supress logs by default --- src/utils/logger.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 7e01c6a5..759ccf26 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -52,8 +52,8 @@ 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; @@ -152,9 +152,9 @@ export function setLogFile(path: string | null): void { /** * 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; } @@ -169,9 +169,8 @@ function shouldLog(level: string): boolean { 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