From 5d34c5a1858a4c2925fd59322b7a1a245bd82738 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 21 Feb 2026 20:06:59 +0000 Subject: [PATCH 1/4] fix(cli): replace skill installer script with init command --- CHANGELOG.md | 8 + README.md | 12 +- docs/MIGRATION_V2.md | 10 +- docs/SKILLS.md | 51 ++-- package.json | 3 +- scripts/check-docs-cli-commands.js | 2 +- scripts/install-skill.sh | 310 --------------------- scripts/package-macos-portable.sh | 2 + scripts/release.sh | 13 - scripts/verify-portable-install.sh | 5 + src/cli.ts | 44 ++- src/cli/commands/__tests__/init.test.ts | 347 ++++++++++++++++++++++++ src/cli/commands/init.ts | 295 ++++++++++++++++++++ src/cli/yargs-app.ts | 2 + 14 files changed, 739 insertions(+), 365 deletions(-) delete mode 100755 scripts/install-skill.sh create mode 100644 src/cli/commands/__tests__/init.test.ts create mode 100644 src/cli/commands/init.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b04cc542..7268bed0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `xcodebuildmcp init` CLI command to install agent skills, replacing the standalone `install-skill.sh` script. Supports auto-detection of AI clients (Claude Code, Cursor, Codex), `--print` for unsupported clients, and `--uninstall` for removal. + ### Changed - Changed MCP `xcode-ide` integration to expose manifest-managed gateway tools (`xcode_ide_list_tools`, `xcode_ide_call_tool`) while keeping CLI dynamic `xcode_tools_*` behavior unchanged ([#210](https://github.com/getsentry/XcodeBuildMCP/issues/210)) @@ -12,6 +16,10 @@ - Removed startup dependency on handshake-time Xcode bridge `tools/list` sync for MCP tool registration, preventing bridge list latency from delaying initial connect ([#210](https://github.com/getsentry/XcodeBuildMCP/issues/210)) - Fixed Sentry telemetry scope to capture only internal XcodeBuildMCP runtime failures, removing broad MCP wrapping, PII-heavy tags, and default per-error log capture ([#204](https://github.com/getsentry/XcodeBuildMCP/issues/204)) +### Removed + +- Removed `scripts/install-skill.sh` in favour of `xcodebuildmcp init`. + ## [2.0.7] ### Changed diff --git a/README.md b/README.md index 8412563f..98feef53 100644 --- a/README.md +++ b/README.md @@ -300,13 +300,19 @@ XcodeBuildMCP now includes two optional agent skills: - **CLI Skill**: Primes the agent with instructions on how to navigate the CLI (recommended when using the CLI). -To install, copy and paste the command below into a terminal and follow the on-screen instructions. +To install with a global binary: ```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh +xcodebuildmcp init ``` -For further information on how to install the skill, see: [docs/SKILLS.md](docs/SKILLS.md) +Or install directly via npx without a global install: + +```bash +npx -y xcodebuildmcp@latest init +``` + +For further information on installing skills, see: [docs/SKILLS.md](docs/SKILLS.md) ## Notes diff --git a/docs/MIGRATION_V2.md b/docs/MIGRATION_V2.md index 9994c923..cd870f5f 100644 --- a/docs/MIGRATION_V2.md +++ b/docs/MIGRATION_V2.md @@ -229,10 +229,16 @@ v2.0.0 introduces optional skill files that prime your coding agent with usage i - **CLI Skill** -- strongly recommended when using the CLI with a coding agent. - **MCP Skill** -- optional when using the MCP server; gives the agent better context on available tools. -Install via the interactive installer: +Install via the built-in CLI command: ```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.0/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh +xcodebuildmcp init +``` + +Or run it via npx without a global install: + +```bash +npx -y xcodebuildmcp@latest init ``` See [SKILLS.md](SKILLS.md) for more details. diff --git a/docs/SKILLS.md b/docs/SKILLS.md index fbf59bb4..1fcc7556 100644 --- a/docs/SKILLS.md +++ b/docs/SKILLS.md @@ -1,57 +1,40 @@ # XcodeBuildMCP Skill -XcodeBuildMCP now includes two optional agent skills: +XcodeBuildMCP includes two optional agent skills: - **MCP Skill**: Primes the agent with instructions on how to use the MCP server's tools (optional when using the MCP server). - **CLI Skill**: Primes the agent with instructions on how to navigate the CLI (recommended when using the CLI). -## Easiest way to install - -Install via the interactive installer and follow the on-screen instructions. +## Install ```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh +xcodebuildmcp init ``` -## Automated installation - -Useful for CI/CD pipelines or for agentic installation. `--skill` should be set to either `mcp` or `cli` to install the appropriate skill. +This auto-detects installed AI clients (Claude Code, Cursor, Codex) and installs the CLI skill. -### Install (Claude Code) +### Options ```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh --claude --remove-conflict --skill +xcodebuildmcp init --skill cli # Install CLI skill (default) +xcodebuildmcp init --skill mcp # Install MCP skill +xcodebuildmcp init --client claude # Install to Claude only +xcodebuildmcp init --dest /path/to/dir # Install to custom directory +xcodebuildmcp init --force # Overwrite existing +xcodebuildmcp init --remove-conflict # Auto-remove conflicting variant +xcodebuildmcp init --uninstall # Remove installed skill ``` -### Install (Cursor) +## Unsupported Clients -```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh --cursor --remove-conflict --skill -``` - -### Install (Codex CLI) +For clients without a skills directory, print the skill content and pipe it to a file or paste it into your client's instructions area: ```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh --codex --remove-conflict --skill +xcodebuildmcp init --print +xcodebuildmcp init --print --skill mcp > my-skill.md ``` -### Install (Other Clients) - -For other clients if you know the path to the skills directory you can pass the `--dest` flag. - -```bash -curl -fsSL https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v2.0.7/scripts/install-skill.sh -o install-skill.sh && bash install-skill.sh --dest /path/to/skills --remove-conflict --skill -``` - -## Unsupporting Clients - -Some MCP clients that do not yet support skills. Use the skill content as a concise, static instruction prompt: - -1. Open `skills/xcodebuildmcp[-cli]/SKILL.md`. -2. Copy the body (everything below the YAML frontmatter). -3. Paste it into the client’s global or project-level instructions/rules area. - ## Skills -To learn more about skills see: [https://agentskills.io/home](https://agentskills.io/home). \ No newline at end of file +To learn more about skills see: [https://agentskills.io/home](https://agentskills.io/home). diff --git a/package.json b/package.json index dc22c2d3..c68f2633 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ "build", "bundled", "plugins", - "manifests" + "manifests", + "skills" ], "keywords": [ "xcodebuild", diff --git a/scripts/check-docs-cli-commands.js b/scripts/check-docs-cli-commands.js index 3498c831..197001df 100755 --- a/scripts/check-docs-cli-commands.js +++ b/scripts/check-docs-cli-commands.js @@ -125,7 +125,7 @@ function extractCommandCandidates(content) { } function findInvalidCommands(files, validPairs, validWorkflows) { - const validTopLevel = new Set(['mcp', 'tools', 'daemon']); + const validTopLevel = new Set(['mcp', 'tools', 'daemon', 'init']); const validDaemonActions = new Set(['status', 'start', 'stop', 'restart', 'list']); const findings = []; diff --git a/scripts/install-skill.sh b/scripts/install-skill.sh deleted file mode 100755 index 1d9e1c18..00000000 --- a/scripts/install-skill.sh +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Colors and formatting -if [[ -t 1 ]] && [[ "${TERM:-}" != "dumb" ]]; then - BOLD='\033[1m' - DIM='\033[2m' - RESET='\033[0m' - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[0;33m' - BLUE='\033[0;34m' - CYAN='\033[0;36m' -else - BOLD='' - DIM='' - RESET='' - RED='' - GREEN='' - YELLOW='' - BLUE='' - CYAN='' -fi - -# Symbols -CHECK="${GREEN}✓${RESET}" -CROSS="${RED}✗${RESET}" -ARROW="${CYAN}→${RESET}" -WARN="${YELLOW}!${RESET}" - -print_header() { - printf "\n" - printf "${BOLD}${BLUE}╭──────────────────────────────────╮${RESET}\n" - printf "${BOLD}${BLUE}│${RESET} ${BOLD}XcodeBuildMCP Skill Installer${RESET} ${BOLD}${BLUE}│${RESET}\n" - printf "${BOLD}${BLUE}╰──────────────────────────────────╯${RESET}\n" -} - -print_success() { - printf " ${CHECK} ${GREEN}%s${RESET}\n" "$1" -} - -print_error() { - printf " ${CROSS} ${RED}%s${RESET}\n" "$1" >&2 -} - -print_warning() { - printf " ${WARN} ${YELLOW}%s${RESET}\n" "$1" -} - -print_info() { - printf " ${ARROW} %s\n" "$1" -} - -print_step() { - printf "\n${BOLD}%s${RESET}\n" "$1" -} - -usage() { - cat < [options] - -${BOLD}Options:${RESET} - --codex Install to Codex skills directory - --claude Install to Claude skills directory - --cursor Install to Cursor skills directory - --dest Install to custom directory - --skill Skill to install (prompted if omitted) - --ref Git ref to download from (default: main) - --remove-conflict Auto-remove conflicting skill - -h, --help Show this help message - -${BOLD}Description:${RESET} - Installs the XcodeBuildMCP skill for your AI coding assistant. - If run from a local checkout, installs the local skill file. - Otherwise downloads from GitHub. -EOF -} - -destination="" -skill_choice="" -skill_ref_override="" -remove_conflict="false" -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "${script_dir}/.." && pwd)" - -while [[ $# -gt 0 ]]; do - case "$1" in - --codex) - destination="${HOME}/.codex/skills/public" - shift - ;; - --claude) - destination="${HOME}/.claude/skills" - shift - ;; - --cursor) - destination="${HOME}/.cursor/skills" - shift - ;; - --dest) - if [[ $# -lt 2 ]]; then - echo "Missing value for --dest" >&2 - usage - exit 1 - fi - destination="$2" - shift 2 - ;; - --skill) - if [[ $# -lt 2 ]]; then - echo "Missing value for --skill" >&2 - usage - exit 1 - fi - skill_choice="$2" - shift 2 - ;; - --ref) - if [[ $# -lt 2 ]]; then - echo "Missing value for --ref" >&2 - usage - exit 1 - fi - skill_ref_override="$2" - shift 2 - ;; - --remove-conflict) - remove_conflict="true" - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage - exit 1 - ;; - esac -done - -prompt_for_destination() { - print_step "Select Target Client" - while true; do - printf "\n" - printf " ${CYAN}[1]${RESET} Codex\n" - printf " ${CYAN}[2]${RESET} Claude\n" - printf " ${CYAN}[3]${RESET} Cursor\n" - printf "\n" - printf " ${DIM}Enter your choice${RESET} ${BOLD}[1-3]:${RESET} " - read -r selection - case "${selection}" in - 1) - destination="${HOME}/.codex/skills/public" - print_success "Selected Codex" - return 0 - ;; - 2) - destination="${HOME}/.claude/skills" - print_success "Selected Claude" - return 0 - ;; - 3) - destination="${HOME}/.cursor/skills" - print_success "Selected Cursor" - return 0 - ;; - *) - print_error "Invalid selection. Please enter 1, 2, or 3." - ;; - esac - done -} - -prompt_for_skill() { - print_step "Select Skill Type" - while true; do - printf "\n" - printf " ${CYAN}[1]${RESET} XcodeBuildMCP ${DIM}(MCP server)${RESET}\n" - printf " ${DIM}Full MCP integration with all tools${RESET}\n" - printf "\n" - printf " ${CYAN}[2]${RESET} XcodeBuildMCP CLI\n" - printf " ${DIM}Lightweight CLI-based commands${RESET}\n" - printf "\n" - printf " ${DIM}Enter your choice${RESET} ${BOLD}[1-2]:${RESET} " - read -r selection - case "${selection}" in - 1) - skill_choice="mcp" - print_success "Selected MCP server skill" - return 0 - ;; - 2) - skill_choice="cli" - print_success "Selected CLI skill" - return 0 - ;; - *) - print_error "Invalid selection. Please enter 1 or 2." - ;; - esac - done -} - -print_header - -if [[ -z "${destination}" ]]; then - prompt_for_destination -fi - -if [[ -z "${skill_choice}" ]]; then - prompt_for_skill -fi - -case "${skill_choice}" in - mcp|server|xcodebuildmcp) - skill_dir_name="xcodebuildmcp" - skill_label="XcodeBuildMCP (MCP server)" - alt_dir_name="xcodebuildmcp-cli" - alt_label="XcodeBuildMCP CLI" - ;; - cli|xcodebuildmcp-cli) - skill_dir_name="xcodebuildmcp-cli" - skill_label="XcodeBuildMCP CLI" - alt_dir_name="xcodebuildmcp" - alt_label="XcodeBuildMCP (MCP server)" - ;; - *) - echo "Unknown skill: ${skill_choice}" >&2 - usage - exit 1 - ;; -esac - -skill_dir="${destination}/${skill_dir_name}" -alt_dir="${destination}/${alt_dir_name}" -skill_path="skills/${skill_dir_name}/SKILL.md" -skill_base_url="https://raw.githubusercontent.com/getsentry/XcodeBuildMCP" -skill_ref="main" - -if [[ -n "${skill_ref_override}" ]]; then - skill_ref="${skill_ref_override}" -fi - -print_step "Installing" - -if [[ -e "${alt_dir}" ]]; then - if [[ "${remove_conflict}" == "true" ]]; then - rm -r "${alt_dir}" - print_info "Removed conflicting skill: ${alt_label}" - else - printf "\n" - print_warning "Conflict detected!" - printf " ${DIM}Only one skill can be installed at a time.${RESET}\n" - printf " ${DIM}Found:${RESET} ${alt_label}\n" - printf " ${DIM}Path:${RESET} ${alt_dir}\n" - printf "\n" - printf " ${BOLD}Remove existing skill to continue?${RESET} ${DIM}[y/N]:${RESET} " - read -r confirm - case "${confirm}" in - y|Y|yes|YES) - rm -r "${alt_dir}" - print_success "Removed ${alt_label}" - ;; - *) - print_error "Installation cancelled" - exit 1 - ;; - esac - fi -fi - -if [[ -e "${skill_dir}" ]]; then - rm -r "${skill_dir}" - print_info "Replacing existing installation" -fi -mkdir -p "${skill_dir}" - -primary_url="${skill_base_url}/${skill_ref}/${skill_path}" -fallback_url="${skill_base_url}/main/${skill_path}" -local_skill_path="${repo_root}/${skill_path}" - -if [[ -f "${local_skill_path}" ]]; then - cp "${local_skill_path}" "${skill_dir}/SKILL.md" - print_info "Installed from local checkout" -else - print_info "Downloading from GitHub..." - if ! curl -fsSL "${primary_url}" -o "${skill_dir}/SKILL.md" 2>/dev/null; then - if [[ "${skill_ref}" != "main" ]]; then - print_warning "Tag ${skill_ref} not found, falling back to main" - if ! curl -fsSL "${fallback_url}" -o "${skill_dir}/SKILL.md" 2>/dev/null; then - print_error "Failed to download skill" - exit 1 - fi - else - print_error "Failed to download skill" - exit 1 - fi - fi -fi - -printf "\n" -printf "${BOLD}${GREEN}╭─────────────────────────────────────╮${RESET}\n" -printf "${BOLD}${GREEN}│${RESET} ${CHECK} ${BOLD}Installation Complete${RESET} ${BOLD}${GREEN}│${RESET}\n" -printf "${BOLD}${GREEN}╰─────────────────────────────────────╯${RESET}\n" -printf "\n" -printf " ${BOLD}Skill:${RESET} %s\n" "${skill_label}" -printf " ${BOLD}Location:${RESET} %s\n" "${skill_dir}" -printf "\n" diff --git a/scripts/package-macos-portable.sh b/scripts/package-macos-portable.sh index 6d3161ed..c56212be 100755 --- a/scripts/package-macos-portable.sh +++ b/scripts/package-macos-portable.sh @@ -317,6 +317,7 @@ if [[ "$UNIVERSAL" == "true" ]]; then cp -R "$ARM64_ROOT/libexec/build" "$UNIVERSAL_ROOT/libexec/" cp -R "$ARM64_ROOT/libexec/manifests" "$UNIVERSAL_ROOT/libexec/" cp -R "$ARM64_ROOT/libexec/bundled" "$UNIVERSAL_ROOT/libexec/" + cp -R "$ARM64_ROOT/libexec/skills" "$UNIVERSAL_ROOT/libexec/" cp -R "$ARM64_ROOT/libexec/node_modules" "$UNIVERSAL_ROOT/libexec/" cp "$ARM64_ROOT/libexec/package.json" "$UNIVERSAL_ROOT/libexec/package.json" @@ -364,6 +365,7 @@ install_node_runtime_for_arch "$ARCH" "$PORTABLE_ROOT/libexec/node-runtime" cp -R "$PROJECT_ROOT/build" "$PORTABLE_ROOT/libexec/" cp -R "$PROJECT_ROOT/manifests" "$PORTABLE_ROOT/libexec/" cp -R "$PROJECT_ROOT/bundled" "$PORTABLE_ROOT/libexec/" +cp -R "$PROJECT_ROOT/skills" "$PORTABLE_ROOT/libexec/" cp "$PROJECT_ROOT/package.json" "$PORTABLE_ROOT/libexec/package.json" cp "$PROJECT_ROOT/package-lock.json" "$PORTABLE_ROOT/libexec/package-lock.json" npm ci --omit=dev --ignore-scripts --prefix "$PORTABLE_ROOT/libexec" diff --git a/scripts/release.sh b/scripts/release.sh index f14a8e4b..a4832b92 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -437,19 +437,6 @@ if [[ "$SKIP_VERSION_UPDATE" == "false" ]]; then CURSOR_INSTALL_CONFIG=$(node -e "const tag='${NPM_TAG}';const config=JSON.stringify({command:\`npx -y xcodebuildmcp@\${tag} mcp\`});console.log(encodeURIComponent(Buffer.from(config).toString('base64')));") run node -e "const fs=require('fs');const path='README.md';const next='config=${CURSOR_INSTALL_CONFIG}';const contents=fs.readFileSync(path,'utf8');const updated=contents.replace(/config=[^)\\s]+/g,next);fs.writeFileSync(path,updated);" - # Update skill installer URL and versioned ref in README.md - echo "📝 Updating skill installer URL in README.md..." - README_SKILL_INSTALL_URL_REGEX='https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/[^/]+/scripts/install-skill.sh' - run sed_inplace "s#${README_SKILL_INSTALL_URL_REGEX}#https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v${VERSION}/scripts/install-skill.sh#g" README.md - - # Update skill installer URL in docs/SKILLS.md - if [[ -f docs/SKILLS.md ]]; then - echo "📝 Updating skill installer URL in docs/SKILLS.md..." - run sed_inplace "s#${README_SKILL_INSTALL_URL_REGEX}#https://raw.githubusercontent.com/getsentry/XcodeBuildMCP/v${VERSION}/scripts/install-skill.sh#g" docs/SKILLS.md - else - echo "⚠️ docs/SKILLS.md not found; skipping update" - fi - # server.json update echo "" if [[ -f server.json ]]; then diff --git a/scripts/verify-portable-install.sh b/scripts/verify-portable-install.sh index 3bb97c78..41d98da4 100755 --- a/scripts/verify-portable-install.sh +++ b/scripts/verify-portable-install.sh @@ -87,6 +87,10 @@ if [[ ! -d "$PORTABLE_ROOT/libexec/bundled/Frameworks" ]]; then echo "Missing bundled Frameworks under libexec" exit 1 fi +if [[ ! -d "$PORTABLE_ROOT/libexec/skills" ]]; then + echo "Missing skills directory under libexec" + exit 1 +fi HOST_ARCH="$(uname -m)" NODE_RUNTIME="$PORTABLE_ROOT/libexec/node-runtime" @@ -120,6 +124,7 @@ done if [[ "$CAN_EXECUTE" == "true" ]]; then "$PORTABLE_ROOT/bin/xcodebuildmcp" --help >/dev/null "$PORTABLE_ROOT/bin/xcodebuildmcp-doctor" --help >/dev/null + "$PORTABLE_ROOT/bin/xcodebuildmcp" init --print >/dev/null else echo "Skipping binary execution checks: host arch ($HOST_ARCH) not in runtime archs ($RUNTIME_ARCHS)" fi diff --git a/src/cli.ts b/src/cli.ts index 6e9f0e35..ea594bbe 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { getSocketPath, getWorkspaceKey, resolveWorkspaceRoot } from './daemon/s import { startMcpServer } from './server/start-mcp-server.ts'; import { listCliWorkflowIdsFromManifest } from './runtime/tool-catalog.ts'; import { flushAndCloseSentry, initSentry, recordBootstrapDurationMetric } from './utils/sentry.ts'; +import { setLogLevel, type LogLevel } from './utils/logger.ts'; function findTopLevelCommand(argv: string[]): string | undefined { const flagsWithValue = new Set(['--socket', '--log-level', '--style']); @@ -30,12 +31,53 @@ function findTopLevelCommand(argv: string[]): string | undefined { return undefined; } +async function runInitCommand(): Promise { + const yargs = (await import('yargs')).default; + const { hideBin } = await import('yargs/helpers'); + const { registerInitCommand } = await import('./cli/commands/init.ts'); + + const app = yargs(hideBin(process.argv)) + .scriptName('') + .strict() + .help() + .option('socket', { + type: 'string', + describe: 'Override daemon unix socket path', + hidden: true, + }) + .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); + } + }); + registerInitCommand(app); + await app.parseAsync(); +} + async function main(): Promise { const cliBootstrapStartedAt = Date.now(); - if (process.argv.includes('mcp')) { + const earlyCommand = findTopLevelCommand(process.argv.slice(2)); + if (earlyCommand === 'mcp') { await startMcpServer(); return; } + if (earlyCommand === 'init') { + await runInitCommand(); + return; + } initSentry({ mode: 'cli' }); // CLI mode uses disableSessionDefaults to show all tool parameters as flags diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts new file mode 100644 index 00000000..8d57305b --- /dev/null +++ b/src/cli/commands/__tests__/init.test.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +vi.mock('../../../core/resource-root.ts', () => ({ + getResourceRoot: vi.fn(), +})); + +vi.mock('node:os', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + homedir: vi.fn(original.homedir), + }; +}); + +import { getResourceRoot } from '../../../core/resource-root.ts'; +import { homedir } from 'node:os'; + +const mockedGetResourceRoot = vi.mocked(getResourceRoot); +const mockedHomedir = vi.mocked(homedir); + +function loadInitModule() { + return import('../init.ts'); +} + +describe('init command', () => { + let tempDir: string; + let fakeResourceRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'xbmcp-init-')); + fakeResourceRoot = join(tempDir, 'resource-root'); + mkdirSync(join(fakeResourceRoot, 'skills', 'xcodebuildmcp'), { recursive: true }); + mkdirSync(join(fakeResourceRoot, 'skills', 'xcodebuildmcp-cli'), { recursive: true }); + writeFileSync( + join(fakeResourceRoot, 'skills', 'xcodebuildmcp', 'SKILL.md'), + '# MCP Skill Content', + 'utf8', + ); + writeFileSync( + join(fakeResourceRoot, 'skills', 'xcodebuildmcp-cli', 'SKILL.md'), + '# CLI Skill Content', + 'utf8', + ); + mockedGetResourceRoot.mockReturnValue(fakeResourceRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + describe('registerInitCommand', () => { + it('exports registerInitCommand function', async () => { + const mod = await loadInitModule(); + expect(typeof mod.registerInitCommand).toBe('function'); + }); + }); + + describe('skill installation', () => { + it('installs CLI skill to a destination directory', async () => { + const dest = join(tempDir, 'skills'); + mkdirSync(dest, { recursive: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const installed = join(dest, 'xcodebuildmcp-cli', 'SKILL.md'); + expect(existsSync(installed)).toBe(true); + expect(readFileSync(installed, 'utf8')).toBe('# CLI Skill Content'); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('Installed xcodebuildmcp-cli skill'); + expect(output).toContain('Custom'); + expect(output).toContain(installed); + + stdoutSpy.mockRestore(); + }); + + it('installs MCP skill to a destination directory', async () => { + const dest = join(tempDir, 'skills'); + mkdirSync(dest, { recursive: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'mcp']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const installed = join(dest, 'xcodebuildmcp', 'SKILL.md'); + expect(existsSync(installed)).toBe(true); + expect(readFileSync(installed, 'utf8')).toBe('# MCP Skill Content'); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('Installed xcodebuildmcp skill'); + + stdoutSpy.mockRestore(); + }); + + it('defaults to CLI skill when --skill is omitted', async () => { + const dest = join(tempDir, 'skills'); + mkdirSync(dest, { recursive: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest]).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + expect(existsSync(join(dest, 'xcodebuildmcp-cli', 'SKILL.md'))).toBe(true); + expect(existsSync(join(dest, 'xcodebuildmcp', 'SKILL.md'))).toBe(false); + + stdoutSpy.mockRestore(); + }); + }); + + describe('conflict handling', () => { + it('removes conflicting skill with --remove-conflict', async () => { + const dest = join(tempDir, 'skills'); + const conflictDir = join(dest, 'xcodebuildmcp'); + mkdirSync(conflictDir, { recursive: true }); + writeFileSync(join(conflictDir, 'SKILL.md'), 'old mcp skill', 'utf8'); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli', '--remove-conflict']).scriptName( + '', + ); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + expect(existsSync(conflictDir)).toBe(false); + expect(existsSync(join(dest, 'xcodebuildmcp-cli', 'SKILL.md'))).toBe(true); + + stdoutSpy.mockRestore(); + }); + + it('errors on conflict in non-interactive mode without --remove-conflict', async () => { + const dest = join(tempDir, 'skills'); + const conflictDir = join(dest, 'xcodebuildmcp'); + mkdirSync(conflictDir, { recursive: true }); + writeFileSync(join(conflictDir, 'SKILL.md'), 'old mcp skill', 'utf8'); + + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('').fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow('Conflicting skill'); + + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + }); + + describe('--force', () => { + it('overwrites existing installation with --force', async () => { + const dest = join(tempDir, 'skills'); + const existingDir = join(dest, 'xcodebuildmcp-cli'); + mkdirSync(existingDir, { recursive: true }); + writeFileSync(join(existingDir, 'SKILL.md'), 'old content', 'utf8'); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli', '--force']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + expect(readFileSync(join(existingDir, 'SKILL.md'), 'utf8')).toBe('# CLI Skill Content'); + + stdoutSpy.mockRestore(); + }); + }); + + describe('--uninstall', () => { + it('removes all installed skill directories', async () => { + const dest = join(tempDir, 'skills'); + const cliSkillDir = join(dest, 'xcodebuildmcp-cli'); + const mcpSkillDir = join(dest, 'xcodebuildmcp'); + mkdirSync(cliSkillDir, { recursive: true }); + mkdirSync(mcpSkillDir, { recursive: true }); + writeFileSync(join(cliSkillDir, 'SKILL.md'), 'cli content', 'utf8'); + writeFileSync(join(mcpSkillDir, 'SKILL.md'), 'mcp content', 'utf8'); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--uninstall']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + expect(existsSync(cliSkillDir)).toBe(false); + expect(existsSync(mcpSkillDir)).toBe(false); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('Uninstalled skill directories'); + expect(output).toContain('Removed (xcodebuildmcp-cli):'); + expect(output).toContain('Removed (xcodebuildmcp):'); + + stdoutSpy.mockRestore(); + }); + + it('reports when no skill is installed', async () => { + const dest = join(tempDir, 'skills'); + mkdirSync(dest, { recursive: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--uninstall']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('No installed skill directories found'); + + stdoutSpy.mockRestore(); + }); + }); + + describe('--print', () => { + it('prints CLI skill content to stdout', async () => { + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--print', '--skill', 'cli']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toBe('# CLI Skill Content'); + + stdoutSpy.mockRestore(); + }); + + it('prints MCP skill content to stdout', async () => { + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--print', '--skill', 'mcp']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toBe('# MCP Skill Content'); + + stdoutSpy.mockRestore(); + }); + + it('does not create any skill directories when using --print', async () => { + const emptyHome = join(tempDir, 'print-home'); + mkdirSync(emptyHome, { recursive: true }); + mockedHomedir.mockReturnValue(emptyHome); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--print']).scriptName(''); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + expect(existsSync(join(emptyHome, '.claude', 'skills'))).toBe(false); + expect(existsSync(join(emptyHome, '.cursor', 'skills'))).toBe(false); + expect(existsSync(join(emptyHome, '.codex', 'skills', 'public'))).toBe(false); + + stdoutSpy.mockRestore(); + }); + }); + + describe('error cases', () => { + it('errors when --dest points to filesystem root', async () => { + const rootDest = '/'; + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', rootDest, '--skill', 'cli']).scriptName('').fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow( + 'Refusing to install skills into filesystem root', + ); + }); + + it('errors when skill source file is missing', async () => { + rmSync(join(fakeResourceRoot, 'skills', 'xcodebuildmcp-cli', 'SKILL.md')); + + const dest = join(tempDir, 'skills'); + mkdirSync(dest, { recursive: true }); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli']).scriptName('').fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow('Skill source not found'); + }); + + it('errors when no clients detected and no --dest or --print', async () => { + const emptyHome = join(tempDir, 'empty-home'); + mkdirSync(emptyHome, { recursive: true }); + mockedHomedir.mockReturnValue(emptyHome); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--skill', 'cli']).scriptName('').fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow('No supported AI clients detected'); + }); + }); +}); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts new file mode 100644 index 00000000..1c5c9d52 --- /dev/null +++ b/src/cli/commands/init.ts @@ -0,0 +1,295 @@ +import type { Argv } from 'yargs'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as readline from 'node:readline'; +import { getResourceRoot } from '../../core/resource-root.ts'; + +type SkillType = 'mcp' | 'cli'; + +interface ClientInfo { + name: string; + id: string; + skillsDir: string; +} + +const CLIENT_DEFINITIONS: { id: string; name: string; skillsSubdir: string }[] = [ + { id: 'claude', name: 'Claude Code', skillsSubdir: '.claude/skills' }, + { id: 'cursor', name: 'Cursor', skillsSubdir: '.cursor/skills' }, + { id: 'codex', name: 'Codex', skillsSubdir: '.codex/skills/public' }, +]; + +function writeLine(text: string): void { + process.stdout.write(`${text}\n`); +} + +function skillDirName(skillType: SkillType): string { + return skillType === 'mcp' ? 'xcodebuildmcp' : 'xcodebuildmcp-cli'; +} + +function altSkillDirName(skillType: SkillType): string { + return skillType === 'mcp' ? 'xcodebuildmcp-cli' : 'xcodebuildmcp'; +} + +function skillLabel(skillType: SkillType): string { + return skillType === 'mcp' ? 'xcodebuildmcp' : 'xcodebuildmcp-cli'; +} + +function detectClients(): ClientInfo[] { + const home = os.homedir(); + const detected: ClientInfo[] = []; + + for (const def of CLIENT_DEFINITIONS) { + const clientDir = path.join(home, def.skillsSubdir.split('/')[0]); + if (fs.existsSync(clientDir)) { + detected.push({ + name: def.name, + id: def.id, + skillsDir: path.join(home, def.skillsSubdir), + }); + } + } + + return detected; +} + +function getSkillSourcePath(skillType: SkillType): string { + const resourceRoot = getResourceRoot(); + return path.join(resourceRoot, 'skills', skillDirName(skillType), 'SKILL.md'); +} + +function readSkillContent(skillType: SkillType): string { + const sourcePath = getSkillSourcePath(skillType); + if (!fs.existsSync(sourcePath)) { + throw new Error(`Skill source not found: ${sourcePath}`); + } + return fs.readFileSync(sourcePath, 'utf8'); +} + +async function promptYesNo(question: string): Promise { + if (!process.stdin.isTTY) { + return false; + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stderr, + }); + + return new Promise((resolve) => { + rl.question(`${question} [y/N]: `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'); + }); + }); +} + +interface InstallResult { + client: string; + location: string; +} + +async function installSkill( + skillsDir: string, + clientName: string, + skillType: SkillType, + opts: { force: boolean; removeConflict: boolean }, +): Promise { + const targetDir = path.join(skillsDir, skillDirName(skillType)); + const altDir = path.join(skillsDir, altSkillDirName(skillType)); + const targetFile = path.join(targetDir, 'SKILL.md'); + + if (fs.existsSync(altDir)) { + if (opts.removeConflict) { + fs.rmSync(altDir, { recursive: true, force: true }); + } else { + const altType = skillType === 'mcp' ? 'cli' : 'mcp'; + if (!process.stdin.isTTY) { + throw new Error( + `Conflicting skill "${altSkillDirName(skillType)}" found in ${skillsDir}. ` + + `Use --remove-conflict to auto-remove it, or uninstall the ${altType} skill first.`, + ); + } + + const confirmed = await promptYesNo( + `Conflicting skill "${altSkillDirName(skillType)}" found in ${skillsDir}.\n Remove it?`, + ); + if (!confirmed) { + throw new Error('Installation cancelled due to conflicting skill.'); + } + fs.rmSync(altDir, { recursive: true, force: true }); + } + } + + if (fs.existsSync(targetFile) && !opts.force) { + if (!process.stdin.isTTY) { + throw new Error(`Skill already installed at ${targetFile}. Use --force to overwrite.`); + } + + const confirmed = await promptYesNo(`Skill already installed at ${targetFile}.\n Overwrite?`); + if (!confirmed) { + throw new Error('Installation cancelled.'); + } + } + + const content = readSkillContent(skillType); + fs.mkdirSync(targetDir, { recursive: true }); + fs.writeFileSync(targetFile, content, 'utf8'); + + return { client: clientName, location: targetFile }; +} + +function uninstallSkill( + skillsDir: string, + clientName: string, +): { client: string; removed: Array<{ variant: string; path: string }> } | null { + const removed: Array<{ variant: string; path: string }> = []; + for (const variant of ['xcodebuildmcp', 'xcodebuildmcp-cli']) { + const dir = path.join(skillsDir, variant); + if (fs.existsSync(dir)) { + fs.rmSync(dir, { recursive: true, force: true }); + removed.push({ variant, path: dir }); + } + } + + if (removed.length === 0) { + return null; + } + + return { client: clientName, removed }; +} + +function resolveTargets( + clientFlag: string | undefined, + destFlag: string | undefined, +): ClientInfo[] { + if (destFlag) { + const resolvedDest = path.resolve(destFlag); + if (resolvedDest === path.parse(resolvedDest).root) { + throw new Error( + 'Refusing to install skills into filesystem root. Use a dedicated directory.', + ); + } + return [{ name: 'Custom', id: 'custom', skillsDir: resolvedDest }]; + } + + if (clientFlag && clientFlag !== 'auto') { + const def = CLIENT_DEFINITIONS.find((d) => d.id === clientFlag); + if (!def) { + throw new Error(`Unknown client: ${clientFlag}. Valid clients: claude, cursor, codex`); + } + const home = os.homedir(); + return [{ name: def.name, id: def.id, skillsDir: path.join(home, def.skillsSubdir) }]; + } + + const detected = detectClients(); + if (detected.length === 0) { + throw new Error( + 'No supported AI clients detected.\n' + + 'Use --client to specify a client, --dest to specify a custom path, or --print to output the skill content.', + ); + } + return detected; +} + +export function registerInitCommand(app: Argv): void { + app.command( + 'init', + 'Install XcodeBuildMCP agent skill', + (yargs) => { + return yargs + .option('client', { + type: 'string', + describe: 'Target client: claude, cursor, codex (default: auto-detect)', + choices: ['auto', 'claude', 'cursor', 'codex'] as const, + default: 'auto', + }) + .option('skill', { + type: 'string', + describe: 'Skill variant: mcp or cli', + choices: ['mcp', 'cli'] as const, + default: 'cli', + }) + .option('dest', { + type: 'string', + describe: 'Custom destination directory (overrides --client)', + }) + .option('force', { + type: 'boolean', + default: false, + describe: 'Replace existing skill without prompting', + }) + .option('remove-conflict', { + type: 'boolean', + default: false, + describe: 'Auto-remove conflicting skill variant', + }) + .option('uninstall', { + type: 'boolean', + default: false, + describe: 'Remove the installed skill', + }) + .option('print', { + type: 'boolean', + default: false, + describe: 'Print the skill content to stdout instead of installing', + }); + }, + async (argv) => { + const skillType = argv.skill as SkillType; + + if (argv.print) { + const content = readSkillContent(skillType); + process.stdout.write(content); + return; + } + + if (argv.uninstall) { + const targets = resolveTargets( + argv.client as string | undefined, + argv.dest as string | undefined, + ); + let anyRemoved = false; + + for (const target of targets) { + const result = uninstallSkill(target.skillsDir, target.name); + if (result) { + if (!anyRemoved) { + writeLine('Uninstalled skill directories'); + } + writeLine(` Client: ${result.client}`); + for (const removed of result.removed) { + writeLine(` Removed (${removed.variant}): ${removed.path}`); + } + anyRemoved = true; + } + } + + if (!anyRemoved) { + writeLine('No installed skill directories found to remove.'); + } + return; + } + + const targets = resolveTargets( + argv.client as string | undefined, + argv.dest as string | undefined, + ); + + const results: InstallResult[] = []; + for (const target of targets) { + const result = await installSkill(target.skillsDir, target.name, skillType, { + force: argv.force as boolean, + removeConflict: argv['remove-conflict'] as boolean, + }); + results.push(result); + } + + writeLine(`Installed ${skillLabel(skillType)} skill`); + for (const result of results) { + writeLine(` Client: ${result.client}`); + writeLine(` Location: ${result.location}`); + } + }, + ); +} diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 47d60561..5420b3ad 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -3,6 +3,7 @@ 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 { registerInitCommand } from './commands/init.ts'; import { registerMcpCommand } from './commands/mcp.ts'; import { registerToolsCommand } from './commands/tools.ts'; import { registerToolCommands } from './register-tool-commands.ts'; @@ -70,6 +71,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { // Register command groups with workspace context registerMcpCommand(app); + registerInitCommand(app); registerToolsCommand(app); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, From 9121d7849d101d02ad8f98feb118717c3e169278 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 21 Feb 2026 20:36:51 +0000 Subject: [PATCH 2/4] fix(cli): use descriptive init skill labels --- src/cli/commands/__tests__/init.test.ts | 4 ++-- src/cli/commands/init.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 8d57305b..110b760c 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -78,7 +78,7 @@ describe('init command', () => { expect(readFileSync(installed, 'utf8')).toBe('# CLI Skill Content'); const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Installed xcodebuildmcp-cli skill'); + expect(output).toContain('Installed XcodeBuildMCP CLI skill'); expect(output).toContain('Custom'); expect(output).toContain(installed); @@ -103,7 +103,7 @@ describe('init command', () => { expect(readFileSync(installed, 'utf8')).toBe('# MCP Skill Content'); const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); - expect(output).toContain('Installed xcodebuildmcp skill'); + expect(output).toContain('Installed XcodeBuildMCP (MCP server) skill'); stdoutSpy.mockRestore(); }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 1c5c9d52..dd218402 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -31,8 +31,8 @@ function altSkillDirName(skillType: SkillType): string { return skillType === 'mcp' ? 'xcodebuildmcp-cli' : 'xcodebuildmcp'; } -function skillLabel(skillType: SkillType): string { - return skillType === 'mcp' ? 'xcodebuildmcp' : 'xcodebuildmcp-cli'; +function skillDisplayName(skillType: SkillType): string { + return skillType === 'mcp' ? 'XcodeBuildMCP (MCP server)' : 'XcodeBuildMCP CLI'; } function detectClients(): ClientInfo[] { @@ -285,7 +285,7 @@ export function registerInitCommand(app: Argv): void { results.push(result); } - writeLine(`Installed ${skillLabel(skillType)} skill`); + writeLine(`Installed ${skillDisplayName(skillType)} skill`); for (const result of results) { writeLine(` Client: ${result.client}`); writeLine(` Location: ${result.location}`); From 99828a110965a6e3d0b69c1b7279b062c04e443c Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 21 Feb 2026 20:54:14 +0000 Subject: [PATCH 3/4] fix(cli): make uninstall target resolution context-aware --- src/cli/commands/__tests__/init.test.ts | 22 +++++++++++++++++++++- src/cli/commands/init.ts | 9 ++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 110b760c..9d397ead 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -243,6 +243,26 @@ describe('init command', () => { stdoutSpy.mockRestore(); }); + + it('gracefully reports no installed skills when auto-detect finds no clients', async () => { + const emptyHome = join(tempDir, 'empty-home-uninstall'); + mkdirSync(emptyHome, { recursive: true }); + mockedHomedir.mockReturnValue(emptyHome); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--uninstall']).scriptName('').fail(false); + mod.registerInitCommand(app); + + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + await app.parseAsync(); + + const output = stdoutSpy.mock.calls.map((c) => String(c[0])).join(''); + expect(output).toContain('No installed skill directories found'); + + stdoutSpy.mockRestore(); + }); }); describe('--print', () => { @@ -311,7 +331,7 @@ describe('init command', () => { mod.registerInitCommand(app); await expect(app.parseAsync()).rejects.toThrow( - 'Refusing to install skills into filesystem root', + 'Refusing to use filesystem root as skills destination', ); }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index dd218402..4fac85c0 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -162,12 +162,13 @@ function uninstallSkill( function resolveTargets( clientFlag: string | undefined, destFlag: string | undefined, + operation: 'install' | 'uninstall', ): ClientInfo[] { if (destFlag) { const resolvedDest = path.resolve(destFlag); if (resolvedDest === path.parse(resolvedDest).root) { throw new Error( - 'Refusing to install skills into filesystem root. Use a dedicated directory.', + 'Refusing to use filesystem root as skills destination. Use a dedicated directory.', ); } return [{ name: 'Custom', id: 'custom', skillsDir: resolvedDest }]; @@ -184,6 +185,10 @@ function resolveTargets( const detected = detectClients(); if (detected.length === 0) { + if (operation === 'uninstall') { + return []; + } + throw new Error( 'No supported AI clients detected.\n' + 'Use --client to specify a client, --dest to specify a custom path, or --print to output the skill content.', @@ -248,6 +253,7 @@ export function registerInitCommand(app: Argv): void { const targets = resolveTargets( argv.client as string | undefined, argv.dest as string | undefined, + 'uninstall', ); let anyRemoved = false; @@ -274,6 +280,7 @@ export function registerInitCommand(app: Argv): void { const targets = resolveTargets( argv.client as string | undefined, argv.dest as string | undefined, + 'install', ); const results: InstallResult[] = []; From c6c83ac2f5aaf1d0ed338ea370c917ae189c0838 Mon Sep 17 00:00:00 2001 From: Cameron Cooke Date: Sat, 21 Feb 2026 21:18:39 +0000 Subject: [PATCH 4/4] fix(cli): fail before conflict deletion when skill source missing --- .cursor/BUGBOT.md | 7 ++++--- src/cli/commands/__tests__/init.test.ts | 21 +++++++++++++++++++++ src/cli/commands/init.ts | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md index 47596457..39662b5a 100644 --- a/.cursor/BUGBOT.md +++ b/.cursor/BUGBOT.md @@ -45,7 +45,8 @@ export const handler = (p: FooBarParams) => fooBarLogic(p); ## 3. Testing Checklist -* **Ban on Vitest mocking** (`vi.mock`, `vi.fn`, `vi.spyOn`, `.mock*`) ⇒ critical. Use `createMockExecutor` / `createMockFileSystemExecutor`. +* **External-boundary rule**: Use `createMockExecutor` / `createMockFileSystemExecutor` for command execution and filesystem side effects. +* **Internal mocking is allowed**: `vi.mock`, `vi.fn`, `vi.spyOn`, and `.mock*` are acceptable for internal modules/collaborators. * Each tool must have tests covering happy-path **and** at least one failure path. * Avoid the `any` type unless justified with an inline comment. @@ -72,8 +73,8 @@ export const handler = (p: FooBarParams) => fooBarLogic(p); ### How Bugbot Can Verify Rules -1. **Mocking violations**: search `*.test.ts` for `vi.` → critical. -2. **DI compliance**: search for direct `child_process` / `fs` imports outside executors. +1. **External-boundary violations**: confirm tests use injected executors/filesystem for external side effects. +2. **DI compliance**: search for direct `child_process` / `fs` imports outside approved patterns. 3. **Docs accuracy**: compare `docs/TOOLS.md` against `src/mcp/tools/**`. 4. **Style**: ensure ESLint and Prettier pass (`npm run lint`, `npm run format:check`). diff --git a/src/cli/commands/__tests__/init.test.ts b/src/cli/commands/__tests__/init.test.ts index 9d397ead..8c95203b 100644 --- a/src/cli/commands/__tests__/init.test.ts +++ b/src/cli/commands/__tests__/init.test.ts @@ -350,6 +350,27 @@ describe('init command', () => { await expect(app.parseAsync()).rejects.toThrow('Skill source not found'); }); + it('does not delete conflicting skill when source file is missing', async () => { + rmSync(join(fakeResourceRoot, 'skills', 'xcodebuildmcp-cli', 'SKILL.md')); + + const dest = join(tempDir, 'skills'); + const conflictDir = join(dest, 'xcodebuildmcp'); + mkdirSync(conflictDir, { recursive: true }); + writeFileSync(join(conflictDir, 'SKILL.md'), 'existing mcp skill', 'utf8'); + + const yargs = (await import('yargs')).default; + const mod = await loadInitModule(); + + const app = yargs(['init', '--dest', dest, '--skill', 'cli', '--remove-conflict']) + .scriptName('') + .fail(false); + mod.registerInitCommand(app); + + await expect(app.parseAsync()).rejects.toThrow('Skill source not found'); + expect(existsSync(conflictDir)).toBe(true); + expect(readFileSync(join(conflictDir, 'SKILL.md'), 'utf8')).toBe('existing mcp skill'); + }); + it('errors when no clients detected and no --dest or --print', async () => { const emptyHome = join(tempDir, 'empty-home'); mkdirSync(emptyHome, { recursive: true }); diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 4fac85c0..dcd3a27f 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -98,6 +98,7 @@ async function installSkill( const targetDir = path.join(skillsDir, skillDirName(skillType)); const altDir = path.join(skillsDir, altSkillDirName(skillType)); const targetFile = path.join(targetDir, 'SKILL.md'); + const content = readSkillContent(skillType); if (fs.existsSync(altDir)) { if (opts.removeConflict) { @@ -132,7 +133,6 @@ async function installSkill( } } - const content = readSkillContent(skillType); fs.mkdirSync(targetDir, { recursive: true }); fs.writeFileSync(targetFile, content, 'utf8');