diff --git a/.claude/commands/rp-build-cli.md b/.claude/commands/rp-build-cli.md new file mode 100644 index 00000000..35ceca05 --- /dev/null +++ b/.claude/commands/rp-build-cli.md @@ -0,0 +1,199 @@ +--- +description: Build with rp-cli context builder → chat → implement +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# MCP Builder Mode (CLI) + +Task: $ARGUMENTS + +You are an **MCP Builder** agent using rp-cli. Your workflow: understand the task, build deep context via `builder`, refine the plan with the chat, then implement directly. + +## Using rp-cli + +This workflow uses **rp-cli** (RepoPrompt CLI) instead of MCP tool calls. Run commands via: + +```bash +rp-cli -e '' +``` + +**Quick reference:** + +| MCP Tool | CLI Command | +|----------|-------------| +| `get_file_tree` | `rp-cli -e 'tree'` | +| `file_search` | `rp-cli -e 'search "pattern"'` | +| `get_code_structure` | `rp-cli -e 'structure path/'` | +| `read_file` | `rp-cli -e 'read path/file.swift'` | +| `manage_selection` | `rp-cli -e 'select add path/'` | +| `context_builder` | `rp-cli -e 'builder "instructions" --response-type plan'` | +| `chat_send` | `rp-cli -e 'chat "message" --mode plan'` | +| `apply_edits` | `rp-cli -e 'call apply_edits {"path":"...","search":"...","replace":"..."}'` | +| `file_actions` | `rp-cli -e 'call file_actions {"action":"create","path":"..."}'` | + +Chain commands with `&&`: +```bash +rp-cli -e 'select set src/ && context' +``` + +Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. + +--- +## The Workflow + +1. **Quick scan** – Understand how the task relates to the codebase +2. **Context builder** – Call `builder` with a clear prompt to get deep context + an architectural plan +3. **Refine with chat** – Use `chat` to clarify the plan if needed +4. **Implement directly** – Use editing tools to make changes + +--- + +## CRITICAL REQUIREMENT + +⚠️ **DO NOT START IMPLEMENTATION** until you have: +1. Completed Phase 1 (Quick Scan) +2. **Called `builder`** and received its plan + +Skipping `builder` results in shallow implementations that miss architectural patterns, related code, and edge cases. The quick scan alone is NOT sufficient for implementation. + +--- + +## Phase 1: Quick Scan (LIMITED - 2-3 tool calls max) + +⚠️ **This phase is intentionally brief.** Do NOT do extensive exploration here—that's what `builder` is for. + +Start by getting a lay of the land with the file tree: +```bash +rp-cli -e 'tree' +``` + +Then use targeted searches to understand how the task maps to the codebase: +```bash +rp-cli -e 'search ""' +rp-cli -e 'structure RootName/likely/relevant/area/' +``` + +Use what you learn to **reformulate the user's prompt** with added clarity—reference specific modules, patterns, or terminology from the codebase. + +**STOP exploring after 2-3 searches.** Your goal is orientation, not deep understanding. `builder` will do the heavy lifting. + +--- + +## Phase 2: Context Builder + +Call `builder` with your informed prompt. Use `response_type: "plan"` to get an actionable architectural plan. + +```bash +rp-cli -e 'builder "" --response-type plan' +``` + +**What you get back:** +- Smart file selection (automatically curated within token budget) +- Architectural plan grounded in actual code +- Chat session for follow-up conversation +- `tab_id` for targeting the same tab in subsequent CLI invocations + +**Tab routing:** Each `rp-cli` invocation is a fresh connection. To continue working in the same tab across separate invocations, pass `-t ` (the tab ID returned by builder). +**Trust `builder`** – it explores deeply and selects intelligently. You shouldn't need to add many files afterward. + +--- + +## Phase 3: Refine with Chat + +The chat is a **seer** – it sees selected files **completely** (full content, not summaries), but it **only sees what's in the selection**. Nothing else. + +Use the chat to: +- Review the plan and clarify ambiguities +- Ask about patterns across the selected files +- Validate your understanding before implementing + +```bash +rp-cli -t '' -e 'chat "How does X connect to Y in these files? Any edge cases I should watch for?" --mode plan' +``` + +> **Note:** Pass `-t ` to target the same tab across separate CLI invocations. + +**The chat excels at:** +- Revealing architectural patterns across files +- Spotting connections that piecemeal reading might miss +- Answering "how does this all fit together" questions + +**Don't expect:** +- Knowledge of files outside the selection +- Implementation—that's your job + +--- + +## Phase 4: Direct Implementation + +**STOP** - Before implementing, verify you have: +- [ ] An architectural plan from the builder +- [ ] An architectural plan grounded in actual code + +If anything is unclear, use `chat` to clarify before proceeding. + +Implement the plan directly. **Do not use `chat` with `mode:"edit"`** – you implement directly. + +**Primary tools:** +```bash +# Modify existing files (search/replace) - JSON format required +rp-cli -e 'call apply_edits {"path":"Root/File.swift","search":"old","replace":"new"}' + +# Multiline edits +rp-cli -e 'call apply_edits {"path":"Root/File.swift","search":"old\ntext","replace":"new\ntext"}' + +# Create new files +rp-cli -e 'file create Root/NewFile.swift "content..."' + +# Read specific sections during implementation +rp-cli -e 'read Root/File.swift --start-line 50 --limit 30' +``` + +**Ask the chat when stuck:** +```bash +rp-cli -t '' -e 'chat "I'\''m implementing X but unsure about Y. What pattern should I follow?" --mode chat' +``` + +--- + +## Key Guidelines + +**Token limit:** Stay under ~160k tokens. Check with `select get` if unsure. Context builder manages this, but be aware if you add files. + +**Selection management:** +- Add files as needed, but `builder` should have most of what you need +- Use slices for large files when you only need specific sections +- New files created are automatically selected + +```bash +# Check current selection and tokens +rp-cli -e 'select get' + +# Add a file if needed +rp-cli -e 'select add Root/path/to/file.swift' + +# Add a slice of a large file +rp-cli -e 'select add Root/large/file.swift:100-200' +``` + +**Chat sees only the selection:** If you need the chat's insight on a file, it must be selected first. + +--- + +## Anti-patterns to Avoid + +- 🚫 Using `chat` with `mode:"edit"` – implement directly with editing tools +- 🚫 Asking the chat about files not in the selection – it can't see them +- 🚫 Skipping `builder` and going straight to implementation – you'll miss context +- 🚫 Removing files from selection unnecessarily – prefer adding over removing +- 🚫 Using `manage_selection` with `op:"clear"` – this undoes `builder`'s work; only remove specific files when over token budget +- 🚫 Exceeding ~160k tokens – use slices if needed +- 🚫 **CRITICAL:** Doing extensive exploration (5+ tool calls) before calling `builder` – the quick scan should be 2-3 calls max +- 🚫 Reading full file contents during Phase 1 – save that for after `builder` builds context +- 🚫 Convincing yourself you understand enough to skip `builder` – you don't + +--- + +**Your job:** Build understanding through `builder`, refine the plan with the chat's holistic view, then execute the implementation directly and completely. \ No newline at end of file diff --git a/.claude/commands/rp-investigate-cli.md b/.claude/commands/rp-investigate-cli.md new file mode 100644 index 00000000..39c79a90 --- /dev/null +++ b/.claude/commands/rp-investigate-cli.md @@ -0,0 +1,167 @@ +--- +description: Deep codebase investigation and architecture research with rp-cli commands +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# Deep Investigation Mode (CLI) + +Investigate: $ARGUMENTS + +You are now in deep investigation mode for the issue described above. Follow this protocol rigorously. + +## Using rp-cli + +This workflow uses **rp-cli** (RepoPrompt CLI) instead of MCP tool calls. Run commands via: + +```bash +rp-cli -e '' +``` + +**Quick reference:** + +| MCP Tool | CLI Command | +|----------|-------------| +| `get_file_tree` | `rp-cli -e 'tree'` | +| `file_search` | `rp-cli -e 'search "pattern"'` | +| `get_code_structure` | `rp-cli -e 'structure path/'` | +| `read_file` | `rp-cli -e 'read path/file.swift'` | +| `manage_selection` | `rp-cli -e 'select add path/'` | +| `context_builder` | `rp-cli -e 'builder "instructions" --response-type plan'` | +| `chat_send` | `rp-cli -e 'chat "message" --mode plan'` | +| `apply_edits` | `rp-cli -e 'call apply_edits {"path":"...","search":"...","replace":"..."}'` | +| `file_actions` | `rp-cli -e 'call file_actions {"action":"create","path":"..."}'` | + +Chain commands with `&&`: +```bash +rp-cli -e 'select set src/ && context' +``` + +Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. + +--- +## Investigation Protocol + +### Core Principles +1. **Don't stop until confident** - pursue every lead until you have solid evidence +2. **Document findings as you go** - create/update a report file with observations +3. **Question everything** - if something seems off, investigate it +4. **Use `builder` aggressively** - it's designed for deep exploration + +### Phase 1: Initial Assessment + +1. Read any provided files/reports (traces, logs, error reports) +2. Summarize the symptoms and constraints +3. Form initial hypotheses + +### Phase 2: Systematic Exploration (via `builder` - REQUIRED) + +⚠️ **Do NOT skip this step.** You MUST call `builder` to get proper context before drawing conclusions. + +Use `builder` with detailed instructions: + +```bash +rp-cli -e 'builder "Investigate: + +Symptoms observed: +- +- + +Hypotheses to test: +- +- + +Areas to explore: +- +" --response-type plan' +``` + +### Phase 3: Follow-up Deep Dives + +After `builder` returns, continue with targeted questions: + +```bash +rp-cli -t '' -e 'chat "" --mode plan' +``` + +> Pass `-t ` to target the same tab across separate CLI invocations. + +### Phase 4: Evidence Gathering + +- Check git history for recent relevant changes +- Look for patterns across similar files +- Trace data/control flow through the codebase +- Identify any leaks, retained references, or improper cleanup + +### Phase 5: Conclusions + +Document: +- Root cause identification (with evidence) +- Eliminated hypotheses (and why) +- Recommended fixes +- Preventive measures for the future + +--- + +## Context Builder Tips + +The `builder` operates in two phases: +1. **Discovery**: Intelligently explores the codebase +2. **Analysis**: A capable model analyzes the captured context + +**Give it good guidance:** +- Be specific about what parts of the codebase to investigate +- Describe symptoms precisely +- List specific technical questions to answer +- Mention any relevant constraints or context + +--- + +## Report Template + +Create a findings report as you investigate: + +```markdown +# Investigation: [Title] + +## Summary +[1-2 sentence summary of findings] + +## Symptoms +- [Observed symptom 1] +- [Observed symptom 2] + +## Investigation Log + +### [Timestamp/Phase] - [Area Investigated] +**Hypothesis:** [What you were testing] +**Findings:** [What you found] +**Evidence:** [File:line references] +**Conclusion:** [Confirmed/Eliminated/Needs more investigation] + +## Root Cause +[Detailed explanation with evidence] + +## Recommendations +1. [Fix 1] +2. [Fix 2] + +## Preventive Measures +- [How to prevent this in future] +``` + +--- + +## Anti-patterns to Avoid + +- 🚫 **CRITICAL:** Skipping `builder` and attempting to investigate by reading files manually – you'll miss critical context +- 🚫 Doing extensive exploration (5+ tool calls) before calling `builder` – initial assessment should be brief +- 🚫 Drawing conclusions before `builder` has built proper context +- 🚫 Reading many full files during Phase 1 – save deep reading for after `builder` +- 🚫 Assuming you understand the issue without systematic exploration via `builder` +- 🚫 Using only chat follow-ups without an initial `builder` call + +--- + +Now begin the investigation. Read any provided context, then **immediately** use `builder` to start systematic exploration. Do not attempt manual exploration first. \ No newline at end of file diff --git a/.claude/commands/rp-oracle-export-cli.md b/.claude/commands/rp-oracle-export-cli.md new file mode 100644 index 00000000..ae36f670 --- /dev/null +++ b/.claude/commands/rp-oracle-export-cli.md @@ -0,0 +1,41 @@ +--- +description: Export context for oracle consultation using rp-cli +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# Oracle Export (CLI) + +Task: $ARGUMENTS + +Export a comprehensive prompt with full context for consultation with an external oracle. + +## How It Works + +Describe the task or question you need the oracle to solve. The context_builder agent will: +1. Analyze your request and explore the codebase +2. Select the most relevant files within a token budget +3. Write a detailed prompt explaining the task and context + +You don't need to specify which files to include—just describe what you need help with. + +## Workflow + +### 1. Build Context + +```bash +rp-cli -e 'builder "" --response-type clarify' +``` + +Wait for context_builder to complete. It will explore the codebase and build optimal context. + +### 2. Export Prompt + +Confirm the export path with the user (default: `~/Downloads/oracle-prompt.md`), then export: + +```bash +rp-cli -e 'prompt export ""' +``` + +Report the export path and token count to the user. \ No newline at end of file diff --git a/.claude/commands/rp-refactor-cli.md b/.claude/commands/rp-refactor-cli.md new file mode 100644 index 00000000..359e6994 --- /dev/null +++ b/.claude/commands/rp-refactor-cli.md @@ -0,0 +1,120 @@ +--- +description: Refactoring assistant using rp-cli to analyze and improve code organization +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# Refactoring Assistant (CLI) + +Refactor: $ARGUMENTS + +You are a **Refactoring Assistant** using rp-cli. Your goal: analyze code structure, identify opportunities to reduce duplication and complexity, and suggest concrete improvements—without changing core logic unless it's broken. + +## Using rp-cli + +This workflow uses **rp-cli** (RepoPrompt CLI) instead of MCP tool calls. Run commands via: + +```bash +rp-cli -e '' +``` + +**Quick reference:** + +| MCP Tool | CLI Command | +|----------|-------------| +| `get_file_tree` | `rp-cli -e 'tree'` | +| `file_search` | `rp-cli -e 'search "pattern"'` | +| `get_code_structure` | `rp-cli -e 'structure path/'` | +| `read_file` | `rp-cli -e 'read path/file.swift'` | +| `manage_selection` | `rp-cli -e 'select add path/'` | +| `context_builder` | `rp-cli -e 'builder "instructions" --response-type plan'` | +| `chat_send` | `rp-cli -e 'chat "message" --mode plan'` | +| `apply_edits` | `rp-cli -e 'call apply_edits {"path":"...","search":"...","replace":"..."}'` | +| `file_actions` | `rp-cli -e 'call file_actions {"action":"create","path":"..."}'` | + +Chain commands with `&&`: +```bash +rp-cli -e 'select set src/ && context' +``` + +Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. + +--- +## Goal + +Analyze code for redundancies and complexity, then implement improvements. **Preserve behavior** unless something is broken. + +--- + +## Protocol + +1. **Analyze** – Use `builder` with `response_type: "review"` to study recent changes and find refactor opportunities. +2. **Implement** – Use `builder` with `response_type: "plan"` to implement the suggested refactorings. + +--- + +## Step 1: Analyze for Refactoring Opportunities (via `builder` - REQUIRED) + +⚠️ **Do NOT skip this step.** You MUST call `builder` with `response_type: "review"` to properly analyze the code. + +Use XML tags to structure the instructions: +```bash +rp-cli -e 'builder "Analyze for refactoring opportunities. Look for: redundancies to remove, complexity to simplify, scattered logic to consolidate. + +Target: . +Goal: Preserve behavior while improving code organization. + +Focus on ." --response-type review' +``` + +Review the findings. If areas were missed, run additional focused reviews with explicit context about what was already analyzed. + +## Optional: Clarify Analysis + +After receiving analysis findings, you can ask clarifying questions in the same chat: +```bash +rp-cli -t '' -e 'chat "For the duplicate logic you identified, which location should be the canonical one?" --mode chat' +``` + +> Pass `-t ` to target the same tab from the builder response. + +## Step 2: Implement the Refactorings + +Once you have a clear list of refactoring opportunities, use `builder` with `response_type: "plan"` to implement: +```bash +rp-cli -e 'builder "Implement these refactorings: + +Refactorings to apply: +1. +2. + +Preserve existing behavior. Make incremental changes. + +Focus on files involved in the refactorings." --response-type plan' +``` + +--- + +## Output Format (be concise) + +**After analysis:** +- **Scope**: 1 line summary +- **Findings** (max 7): `[File]` what to change + why +- **Recommended order**: safest/highest-value first + +**After implementation:** +- Summary of changes made +- Any issues encountered + +--- + +## Anti-patterns to Avoid + +- 🚫 **CRITICAL:** This workflow requires TWO \(builderName) calls – one for analysis (Step 1), one for implementation (Step 2). Do not skip either. +- 🚫 Skipping Step 1's \(builderName) call with `response_type: "review"` and attempting to analyze manually +- 🚫 Skipping Step 2's \(builderName) call with `response_type: "plan"` and implementing without a plan +- 🚫 Doing extensive exploration (5+ tool calls) before the first \(builderName) call – let the builder do the heavy lifting +- 🚫 Proposing refactorings without the analysis phase via \(builderName) +- 🚫 Implementing refactorings after only the analysis phase – you need the second \(builderName) call for implementation planning +- 🚫 Assuming you understand the code structure without \(builderName)'s architectural analysis \ No newline at end of file diff --git a/.claude/commands/rp-reminder-cli.md b/.claude/commands/rp-reminder-cli.md new file mode 100644 index 00000000..30f0b472 --- /dev/null +++ b/.claude/commands/rp-reminder-cli.md @@ -0,0 +1,51 @@ +--- +description: Reminder to use rp-cli +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# RepoPrompt Tools Reminder (CLI) + +Continue your current workflow using rp-cli instead of built-in alternatives. + +## Primary Tools + +| Task | Use This | Not This | +|------|----------|----------| +| Find files/content | `search` | grep, find, Glob | +| Read files | `read` | cat, Read | +| Edit files | `edit` | sed, Edit | +| Create/delete/move | `file` | touch, rm, mv, Write | + +## Quick Reference + +```bash +# Search (path or content) +rp-cli -e 'search "keyword"' + +# Read file (or slice) +rp-cli -e 'read Root/file.swift' +rp-cli -e 'read Root/file.swift --start-line 50 --limit 30' + +# Edit (search/replace) - JSON format required +rp-cli -e 'call apply_edits {"path":"Root/file.swift","search":"old","replace":"new"}' +rp-cli -e 'call apply_edits {"path":"Root/file.swift","search":"a\nb","replace":"c\nd"}' + +# File operations +rp-cli -e 'file create Root/new.swift "content..."' +rp-cli -e 'file delete /absolute/path.swift' +rp-cli -e 'file move Root/old.swift Root/new.swift' +``` + +## Context Management + +```bash +# Check selection +rp-cli -e 'select get' + +# Add files for chat context +rp-cli -e 'select add Root/path/file.swift' +``` + +Continue with your task using these tools. \ No newline at end of file diff --git a/.claude/commands/rp-review-cli.md b/.claude/commands/rp-review-cli.md new file mode 100644 index 00000000..54f83536 --- /dev/null +++ b/.claude/commands/rp-review-cli.md @@ -0,0 +1,119 @@ +--- +description: Code review workflow using rp-cli git tool and context_builder +repoprompt_managed: true +repoprompt_commands_version: 5 +repoprompt_variant: cli +--- + +# Code Review Mode (CLI) + +Review: $ARGUMENTS + +You are a **Code Reviewer** using rp-cli. Your workflow: understand the scope of changes, gather context, and provide thorough, actionable code review feedback. + +## Using rp-cli + +This workflow uses **rp-cli** (RepoPrompt CLI) instead of MCP tool calls. Run commands via: + +```bash +rp-cli -e '' +``` + +**Quick reference:** + +| MCP Tool | CLI Command | +|----------|-------------| +| `get_file_tree` | `rp-cli -e 'tree'` | +| `file_search` | `rp-cli -e 'search "pattern"'` | +| `get_code_structure` | `rp-cli -e 'structure path/'` | +| `read_file` | `rp-cli -e 'read path/file.swift'` | +| `manage_selection` | `rp-cli -e 'select add path/'` | +| `context_builder` | `rp-cli -e 'builder "instructions" --response-type plan'` | +| `chat_send` | `rp-cli -e 'chat "message" --mode plan'` | +| `apply_edits` | `rp-cli -e 'call apply_edits {"path":"...","search":"...","replace":"..."}'` | +| `file_actions` | `rp-cli -e 'call file_actions {"action":"create","path":"..."}'` | + +Chain commands with `&&`: +```bash +rp-cli -e 'select set src/ && context' +``` + +Use `rp-cli -e 'describe '` for help on a specific tool, or `rp-cli --help` for CLI usage. + +--- +## Protocol + +1. **Survey changes** – Check git state and recent commits to understand what's changed. +2. **Confirm scope** – If user wasn't explicit, confirm what to review (uncommitted, staged, branch, etc.). +3. **Deep review** – Run `builder` with `response_type: "review"`. +4. **Fill gaps** – If the review missed areas, run focused follow-up reviews explicitly describing what was/wasn't covered. + +--- + +## Step 1: Survey Changes +```bash +rp-cli -e 'git status' +rp-cli -e 'git log --count 10' +rp-cli -e 'git diff --detail files' +``` + +## Step 2: Confirm Scope with User + +If the user didn't specify, ask them to confirm: +- `uncommitted` – All uncommitted changes (default) +- `staged` – Only staged changes +- `back:N` – Last N commits +- `main...HEAD` – Branch comparison + +## Step 3: Deep Review (via `builder` - REQUIRED) + +⚠️ **Do NOT skip this step.** You MUST call `builder` with `response_type: "review"` for proper code review context. + +Use XML tags to structure the instructions: +```bash +rp-cli -e 'builder "Review the changes. Focus on correctness, security, API changes, error handling. + +Changed files: + +Focus on directories containing changes." --response-type review' +``` + +## Optional: Clarify Findings + +After receiving review findings, you can ask clarifying questions in the same chat: +```bash +rp-cli -t '' -e 'chat "Can you explain the security concern in more detail? What'\''s the attack vector?" --mode chat' +``` + +> Pass `-t ` to target the same tab from the builder response. + +## Step 4: Fill Gaps + +If the review omitted significant areas, run a focused follow-up. **You must explicitly describe what was already covered and what needs review now** (`builder` has no memory of previous runs): +```bash +rp-cli -e 'builder "Review in depth. + +Previous review covered: . +Not yet reviewed: . + +Focus specifically on ." --response-type review' +``` + +--- + +## Anti-patterns to Avoid + +- 🚫 **CRITICAL:** Skipping `builder` and attempting to review by reading files manually – you'll miss architectural context +- 🚫 Doing extensive file reading before calling `builder` – git status/log/diff is sufficient for Step 1 +- 🚫 Providing review feedback without first calling `builder` with `response_type: "review"` +- 🚫 Assuming the git diff alone is sufficient context for a thorough review +- 🚫 Reading changed files manually instead of letting `builder` build proper review context + +--- + +## Output Format (be concise, max 15 bullets total) + +- **Summary**: 1-2 sentences +- **Must-fix** (max 5): `[File:line]` issue + suggested fix +- **Suggestions** (max 5): `[File:line]` improvement +- **Questions** (optional, max 3): clarifications needed \ No newline at end of file diff --git a/.gitignore b/.gitignore index 8697a1b8..ce712e26 100644 --- a/.gitignore +++ b/.gitignore @@ -108,3 +108,4 @@ bundled/ .mcpli .factory DerivedData +.derivedData diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 948310f6..d77c479a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -56,6 +56,51 @@ If you prefer the older, explicit style where each tool requires its own paramet Leave this unset for the streamlined session-aware experience; enable it to force explicit parameters on each tool call. +## Project config (config.yaml) + +You can provide deterministic session defaults for every AI coding session by creating a project config file at: + +``` +/.xcodebuildmcp/config.yaml +``` + +Notes: +- Put the file in your **workspace root** (where your Xcode project is located). +- Agents can persist changes by calling `session_set_defaults` with `"persist": true` (see below). + +### Schema + +```yaml +schemaVersion: 1 +sessionDefaults: + projectPath: "./MyApp.xcodeproj" # xor workspacePath + workspacePath: "./MyApp.xcworkspace" # xor projectPath + scheme: "MyApp" + configuration: "Debug" + simulatorName: "iPhone 16" # xor simulatorId + simulatorId: "" # xor simulatorName + deviceId: "" + useLatestOS: true + arch: "arm64" + suppressWarnings: false + derivedDataPath: "./.derivedData" + preferXcodebuild: false + platform: "iOS" + bundleId: "com.example.myapp" +``` + +Behavior: +- Relative paths in `projectPath`, `workspacePath`, and `derivedDataPath` resolve against the workspace root at load time. +- If both `projectPath` and `workspacePath` are set, **workspacePath wins**. +- If both `simulatorId` and `simulatorName` are set, **simulatorId wins**. + +### Persisting defaults from an agent + +By default, when the agent calls `session_set_defaults`, defaults are only stored in memory for that session. To persist them to the config file, ask the agent to set the `persist` flag to `true`. + +> [!IMPORTANT] +> The write is **patch-only**: only keys provided in that call are written (plus any removals needed for mutual exclusivity). + ## Sentry telemetry opt-out If you do not wish to send error logs to Sentry, set `XCODEBUILDMCP_SENTRY_DISABLED=true`. diff --git a/docs/SESSION_DEFAULTS.md b/docs/SESSION_DEFAULTS.md index 917b3e8c..5094ec6d 100644 --- a/docs/SESSION_DEFAULTS.md +++ b/docs/SESSION_DEFAULTS.md @@ -1,12 +1,13 @@ # Session Defaults -By default, XcodeBuildMCP uses a session-aware mode. The client sets shared defaults once (simulator, device, project/workspace, scheme, etc.) and all tools reuse them. This reduces schema size and repeated payloads. +By default, XcodeBuildMCP uses a session-aware mode. The client sets shared defaults once (simulator, device, project/workspace, scheme, etc.) and all tools reuse them. This reduces schema size and repeated payloads and ensures a more deterministic experience. ## How it works -- Call `session_set_defaults` once at the start of a workflow. +- Agent calls `session_set_defaults` once at the start of a workflow. - Tools reuse those defaults automatically. -- Use `session_show_defaults` to inspect current values. -- Use `session_clear_defaults` to clear values when switching contexts. +- Agent can call `session_show_defaults` to inspect current values. +- Agent can call `session_clear_defaults` to clear values when switching contexts. +- Defaults can also be seeded from `.xcodebuildmcp/config.yaml` at server startup. See the session-management tools in [TOOLS.md](TOOLS.md). @@ -19,7 +20,14 @@ If you prefer explicit parameters on every tool call, set: } ``` -This restores the legacy schemas with per-call parameters while still honoring any defaults you choose to set. +This restores the legacy schemas with per-call parameters while still honoring any defaults you choose to set. Though this is not recommended, it can be useful in certain scenarios where you are working on monorepos or multiple projects at once. + +## Persisting defaults +Session defaults can be persisted between sessions by asking your agent to set the defaults with the `persist` flag set to `true`. This will save the defaults into `.xcodebuildmcp/config.yaml` at the root of your project's workspace. + +The persisted config is patch-only (only provided keys are written). + +You can also manually create the config file to essentially seed the defaults at startup; see [CONFIGURATION.md](CONFIGURATION.md) for more information. ## Related docs - Configuration options: [CONFIGURATION.md](CONFIGURATION.md) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index b5e10f43..cb4a11e7 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -63,7 +63,7 @@ XcodeBuildMCP provides 72 tools organized into 14 workflow groups for comprehens - `clean` - Clean build products. ### session-management (`session-management`) -**Purpose**: Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values. (3 tools) +**Purpose**: Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup. (3 tools) - `session_clear_defaults` - Clear session defaults. - `session_set_defaults` - Set the session defaults, should be called at least once to set tool defaults. @@ -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-25* +*This documentation is automatically generated by `scripts/update-tools-docs.ts` using static analysis. Last updated: 2026-01-27* diff --git a/docs/dev/PROJECT_CONFIG_PLAN.md b/docs/dev/PROJECT_CONFIG_PLAN.md new file mode 100644 index 00000000..6cff5157 --- /dev/null +++ b/docs/dev/PROJECT_CONFIG_PLAN.md @@ -0,0 +1,135 @@ +# Project Config + Session Defaults Plan + +## Goal +Add a project-level config file at `.xcodebuildmcp/config.yaml` that: +1. Seeds session defaults at server startup (no client call required). +2. Allows `session-set-defaults` to persist provided defaults back to that config when a flag is set. + +Scope is limited to **cwd-only** resolution, **patch-only persistence** (provided keys only), and **warn+ignore** on invalid config. + +## Decisions (Confirmed) +- Config location: **only** `process.cwd()/.xcodebuildmcp/config.yaml` (no find-up). +- Persistence: **only** keys provided in the `session-set-defaults` call (plus necessary deletions for mutual exclusivity). +- Invalid config: **warn and ignore**, continue startup. + +## Config Format + +Proposed YAML: + +```yaml +schemaVersion: 1 +sessionDefaults: + projectPath: "./MyApp.xcodeproj" + workspacePath: "./MyApp.xcworkspace" + scheme: "MyApp" + configuration: "Debug" + simulatorName: "iPhone 16" + simulatorId: "" + deviceId: "" + useLatestOS: true + arch: "arm64" + suppressWarnings: false + derivedDataPath: "./.derivedData" + preferXcodebuild: false + platform: "iOS" + bundleId: "com.example.myapp" +``` + +Notes: +- `schemaVersion` supports future evolution. +- The config file is **not** exclusive to session defaults; future sections (e.g., `server`, `logging`, `discovery`) are expected. +- Relative paths resolve against the workspace root (cwd). + +## Precedence (Operational) +We seed the in-memory session defaults from config at startup, so after boot it behaves like normal session defaults. +Operationally, the only precedence that matters during tool calls is: +1. Tool call args (existing behavior in `createSessionAwareTool`). +2. In-memory session defaults (initially seeded from config; can be changed by `session-set-defaults`). + +## Implementation Plan + +### 1) New shared schema for session defaults +**File:** `src/utils/session-defaults-schema.ts` +- Define a Zod schema that mirrors `SessionDefaults`. +- Used by both config loading and `session-set-defaults` to avoid drift. + +### 2) New project config loader/writer +**File:** `src/utils/project-config.ts` +Responsibilities: +- Resolve config path: `path.join(cwd, '.xcodebuildmcp', 'config.yaml')`. +- Read YAML via `FileSystemExecutor`. +- Parse and validate with Zod. +- **Allow unknown top-level keys** (use `.passthrough()` in Zod) so non-session sections can exist without failing validation. +- Normalize mutual exclusivity: + - If both `projectPath` and `workspacePath` are set, keep `workspacePath`. + - If both `simulatorId` and `simulatorName` are set, keep `simulatorId`. +- Resolve relative paths for `projectPath`, `workspacePath`, and `derivedDataPath`. +- Persist changes when requested: + - Merge provided keys into `sessionDefaults`. + - Remove keys that were cleared due to exclusivity. + - Overwrite YAML file (comments not preserved). + +Suggested API: +```ts +export type LoadProjectConfigOptions = { + fs: FileSystemExecutor; + cwd: string; +}; + +export type LoadProjectConfigResult = + | { found: false } + | { found: true; path: string; config: ProjectConfig; notices: string[] }; + +export async function loadProjectConfig( + options: LoadProjectConfigOptions, +): Promise; + +export type PersistSessionDefaultsOptions = { + fs: FileSystemExecutor; + cwd: string; + patch: Partial; + deleteKeys?: (keyof SessionDefaults)[]; +}; + +export async function persistSessionDefaultsToProjectConfig( + options: PersistSessionDefaultsOptions, +): Promise<{ path: string }>; +``` + +### 3) Startup injection +**File:** `src/server/bootstrap.ts` +- Accept `fileSystemExecutor` and `cwd` in `BootstrapOptions` (default to `getDefaultFileSystemExecutor()` and `process.cwd()`). +- Load project config at the top of `bootstrapServer()`. +- On success: `sessionStore.setDefaults(normalizedDefaults)`. +- On parse/validation error: log warning and continue. + +### 4) Persist flag in `session-set-defaults` +**File:** `src/mcp/tools/session-management/session_set_defaults.ts` +- Extend schema with `persist?: boolean`. +- Use `createTypedToolWithContext` to access `{ fs, cwd }`. +- Apply defaults to `sessionStore` as usual. +- If `persist === true`, call `persistSessionDefaultsToProjectConfig()` with: + - `patch`: only keys provided in the tool call (excluding `persist`). + - `deleteKeys`: keys removed due to exclusivity rules. +- Add a notice in response: `Persisted defaults to `. + +### 5) Clear defaults key parity +**File:** `src/mcp/tools/session-management/session_clear_defaults.ts` +- Expand `keys` list to match full `SessionDefaults` surface. + +### 6) Documentation updates +- Update `docs/SESSION_DEFAULTS.md` to mention config auto-load + `persist` flag. +- Update tool description in `src/mcp/tools/session-management/index.ts`. + +### 7) Dependency +- Add `yaml` package for parsing/serializing. + +## Tests +- This change **must** be built TDD (red → green): write failing tests first, then implement code until tests pass. +- Add unit tests for `project-config` loader and persistence using `createMockFileSystemExecutor`. +- Update `session_set_defaults.test.ts` to cover `persist` path and mutual exclusivity deletions. + +## Risks / Notes +- Overwriting YAML drops comments and custom formatting. +- Explicitly **cwd-only** prevents automatic discovery from subdirectories. +- Warn+ignore avoids startup failures but can hide misconfigurations; add clear log messaging. diff --git a/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml new file mode 100644 index 00000000..a5b715dc --- /dev/null +++ b/example_projects/iOS_Calculator/.xcodebuildmcp/config.yaml @@ -0,0 +1,13 @@ +schemaVersion: 1 +sessionDefaults: + workspacePath: ./iOS_Calculator/CalculatorApp.xcworkspace + scheme: CalculatorApp + configuration: Debug + simulatorId: B38FE93D-578B-454B-BE9A-C6FA0CE5F096 + useLatestOS: true + arch: arm64 + suppressWarnings: false + derivedDataPath: ./iOS_Calculator/.derivedData + preferXcodebuild: true + platform: iOS + bundleId: com.example.calculatorapp diff --git a/package-lock.json b/package-lock.json index 31110756..506a8a97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@sentry/cli": "^2.43.1", "@sentry/node": "^10.5.0", "uuid": "^11.1.0", + "yaml": "^2.4.5", "zod": "^4.0.0" }, "bin": { @@ -10047,7 +10048,6 @@ "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 76f06a67..56bff0d8 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@sentry/cli": "^2.43.1", "@sentry/node": "^10.5.0", "uuid": "^11.1.0", + "yaml": "^2.4.5", "zod": "^4.0.0" }, "devDependencies": { diff --git a/scripts/analysis/tools-analysis.ts b/scripts/analysis/tools-analysis.ts index 7e6d5988..0ee43f53 100644 --- a/scripts/analysis/tools-analysis.ts +++ b/scripts/analysis/tools-analysis.ts @@ -297,6 +297,7 @@ export async function getStaticToolAnalysis(): Promise { '**/index.ts', '**/*.test.ts', '**/lib/**', + '**/shared/**', '**/*-processes.ts', // Process management utilities '**/*.deps.ts', // Dependency files '**/*-utils.ts', // Utility files diff --git a/skills/xcodebuildmcp/SKILL.md b/skills/xcodebuildmcp/SKILL.md index 946f94d6..a398e092 100644 --- a/skills/xcodebuildmcp/SKILL.md +++ b/skills/xcodebuildmcp/SKILL.md @@ -13,7 +13,7 @@ If a capability is missing, assume your tool list may be hiding tools (search/pr ### Session defaults -Most tools require session defaults to be set before they can be used, be sure to set all required defaults before tools. You may need to call one or more discovery/list tools to obtain the values needed for certain defaults. +Before you call any other tools, call `session_show_defaults` to show the current defaults, ensure you then fill in the appropriate missing defaults. You may need to call one or more discovery/list tools to obtain the values needed for certain defaults. - `session_set_defaults` - Set the session defaults, should be called at least once to set tool defaults. diff --git a/src/core/generated-plugins.ts b/src/core/generated-plugins.ts index f293cc73..86c36af8 100644 --- a/src/core/generated-plugins.ts +++ b/src/core/generated-plugins.ts @@ -415,7 +415,7 @@ export const WORKFLOW_METADATA = { 'session-management': { name: 'session-management', description: - 'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.', + 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', }, simulator: { name: 'iOS Simulator Development', diff --git a/src/mcp/tools/session-management/__tests__/index.test.ts b/src/mcp/tools/session-management/__tests__/index.test.ts index eaf33553..a5d76d1b 100644 --- a/src/mcp/tools/session-management/__tests__/index.test.ts +++ b/src/mcp/tools/session-management/__tests__/index.test.ts @@ -17,7 +17,7 @@ describe('session-management workflow metadata', () => { it('should have correct description', () => { expect(workflow.description).toBe( - 'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.', + 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', ); }); }); diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index d94d57c5..4506121f 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -12,6 +12,7 @@ describe('session-clear-defaults tool', () => { deviceId: 'DEVICE-123', useLatestOS: true, arch: 'arm64', + derivedDataPath: '/tmp/derived-data', }); }); @@ -40,13 +41,16 @@ describe('session-clear-defaults tool', () => { describe('Handler Behavior', () => { it('should clear specific keys when provided', async () => { - const result = await sessionClearDefaultsLogic({ keys: ['scheme', 'deviceId'] }); + const result = await sessionClearDefaultsLogic({ + keys: ['scheme', 'deviceId', 'derivedDataPath'], + }); expect(result.isError).toBe(false); expect(result.content[0].text).toContain('Session defaults cleared'); const current = sessionStore.getAll(); expect(current.scheme).toBeUndefined(); expect(current.deviceId).toBeUndefined(); + expect(current.derivedDataPath).toBeUndefined(); expect(current.projectPath).toBe('/path/to/proj.xcodeproj'); expect(current.simulatorName).toBe('iPhone 16'); expect(current.useLatestOS).toBe(true); diff --git a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts index 3e78fcda..a006c718 100644 --- a/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_set_defaults.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; import { sessionStore } from '../../../../utils/session-store.ts'; +import { createMockFileSystemExecutor } from '../../../../test-utils/mock-executors.ts'; import plugin, { sessionSetDefaultsLogic } from '../session_set_defaults.ts'; describe('session-set-defaults tool', () => { @@ -7,6 +10,13 @@ describe('session-set-defaults tool', () => { sessionStore.clear(); }); + const cwd = '/repo'; + const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); + + function createContext(overrides = {}) { + return { fs: createMockFileSystemExecutor(overrides), cwd }; + } + describe('Export Field Validation (Literal)', () => { it('should have correct name', () => { expect(plugin.name).toBe('session-set-defaults'); @@ -30,12 +40,15 @@ describe('session-set-defaults tool', () => { describe('Handler Behavior', () => { it('should set provided defaults and return updated state', async () => { - const result = await sessionSetDefaultsLogic({ - scheme: 'MyScheme', - simulatorName: 'iPhone 16', - useLatestOS: true, - arch: 'arm64', - }); + const result = await sessionSetDefaultsLogic( + { + scheme: 'MyScheme', + simulatorName: 'iPhone 16', + useLatestOS: true, + arch: 'arm64', + }, + createContext(), + ); expect(result.isError).toBe(false); expect(result.content[0].text).toContain('Defaults updated:'); @@ -59,7 +72,10 @@ describe('session-set-defaults tool', () => { it('should clear workspacePath when projectPath is set', async () => { sessionStore.setDefaults({ workspacePath: '/old/App.xcworkspace' }); - const result = await sessionSetDefaultsLogic({ projectPath: '/new/App.xcodeproj' }); + const result = await sessionSetDefaultsLogic( + { projectPath: '/new/App.xcodeproj' }, + createContext(), + ); const current = sessionStore.getAll(); expect(current.projectPath).toBe('/new/App.xcodeproj'); expect(current.workspacePath).toBeUndefined(); @@ -70,7 +86,10 @@ describe('session-set-defaults tool', () => { it('should clear projectPath when workspacePath is set', async () => { sessionStore.setDefaults({ projectPath: '/old/App.xcodeproj' }); - const result = await sessionSetDefaultsLogic({ workspacePath: '/new/App.xcworkspace' }); + const result = await sessionSetDefaultsLogic( + { workspacePath: '/new/App.xcworkspace' }, + createContext(), + ); const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/new/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); @@ -81,7 +100,7 @@ describe('session-set-defaults tool', () => { it('should clear simulatorName when simulatorId is set', async () => { sessionStore.setDefaults({ simulatorName: 'iPhone 16' }); - const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }); + const result = await sessionSetDefaultsLogic({ simulatorId: 'SIM-UUID' }, createContext()); const current = sessionStore.getAll(); expect(current.simulatorId).toBe('SIM-UUID'); expect(current.simulatorName).toBeUndefined(); @@ -92,7 +111,7 @@ describe('session-set-defaults tool', () => { it('should clear simulatorId when simulatorName is set', async () => { sessionStore.setDefaults({ simulatorId: 'SIM-UUID' }); - const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }); + const result = await sessionSetDefaultsLogic({ simulatorName: 'iPhone 16' }, createContext()); const current = sessionStore.getAll(); expect(current.simulatorName).toBe('iPhone 16'); expect(current.simulatorId).toBeUndefined(); @@ -102,10 +121,13 @@ describe('session-set-defaults tool', () => { }); it('should prefer workspacePath when both projectPath and workspacePath are provided', async () => { - const res = await sessionSetDefaultsLogic({ - projectPath: '/app/App.xcodeproj', - workspacePath: '/app/App.xcworkspace', - }); + const res = await sessionSetDefaultsLogic( + { + projectPath: '/app/App.xcodeproj', + workspacePath: '/app/App.xcworkspace', + }, + createContext(), + ); const current = sessionStore.getAll(); expect(current.workspacePath).toBe('/app/App.xcworkspace'); expect(current.projectPath).toBeUndefined(); @@ -115,10 +137,13 @@ describe('session-set-defaults tool', () => { }); it('should prefer simulatorId when both simulatorId and simulatorName are provided', async () => { - const res = await sessionSetDefaultsLogic({ - simulatorId: 'SIM-1', - simulatorName: 'iPhone 16', - }); + const res = await sessionSetDefaultsLogic( + { + simulatorId: 'SIM-1', + simulatorName: 'iPhone 16', + }, + createContext(), + ); const current = sessionStore.getAll(); expect(current.simulatorId).toBe('SIM-1'); expect(current.simulatorName).toBeUndefined(); @@ -126,5 +151,58 @@ describe('session-set-defaults tool', () => { 'Both simulatorId and simulatorName were provided; keeping simulatorId and ignoring simulatorName.', ); }); + + it('should persist defaults when persist is true', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' projectPath: "/old/App.xcodeproj"', + ' simulatorName: "OldSim"', + '', + ].join('\n'); + + const writes: { path: string; content: string }[] = []; + const context = createContext({ + existsSync: (targetPath: string) => targetPath === configPath, + readFile: async (targetPath: string) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + return yaml; + }, + writeFile: async (targetPath: string, content: string) => { + writes.push({ path: targetPath, content }); + }, + }); + + const result = await sessionSetDefaultsLogic( + { workspacePath: '/new/App.xcworkspace', simulatorId: 'SIM-1', persist: true }, + context, + ); + + expect(result.content[0].text).toContain('Persisted defaults to'); + expect(writes.length).toBe(1); + expect(writes[0].path).toBe(configPath); + + const parsed = parseYaml(writes[0].content) as { + sessionDefaults?: Record; + }; + expect(parsed.sessionDefaults?.workspacePath).toBe('/new/App.xcworkspace'); + expect(parsed.sessionDefaults?.projectPath).toBeUndefined(); + expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + }); + + it('should not persist when persist is true but no defaults were provided', async () => { + const context = createContext({ + writeFile: async () => { + throw new Error('writeFile should not be called'); + }, + }); + + const result = await sessionSetDefaultsLogic({ persist: true }, context); + + expect(result.content[0].text).toContain('No defaults provided to persist'); + }); }); }); diff --git a/src/mcp/tools/session-management/index.ts b/src/mcp/tools/session-management/index.ts index 64854c8c..1d0d807d 100644 --- a/src/mcp/tools/session-management/index.ts +++ b/src/mcp/tools/session-management/index.ts @@ -1,5 +1,5 @@ export const workflow = { name: 'session-management', description: - 'Manage session defaults for projectPath/workspacePath, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS and arch. These defaults are required by many tools and must be set before attempting to call tools that would depend on these values.', + 'Manage session defaults for project/workspace paths, scheme, configuration, simulatorName/simulatorId, deviceId, useLatestOS, arch, suppressWarnings, derivedDataPath, preferXcodebuild, platform, and bundleId. Defaults can be seeded from .xcodebuildmcp/config.yaml at startup.', }; diff --git a/src/mcp/tools/session-management/session_clear_defaults.ts b/src/mcp/tools/session-management/session_clear_defaults.ts index 94de3bb8..be3990e7 100644 --- a/src/mcp/tools/session-management/session_clear_defaults.ts +++ b/src/mcp/tools/session-management/session_clear_defaults.ts @@ -1,20 +1,11 @@ import * as z from 'zod'; import { sessionStore } from '../../../utils/session-store.ts'; +import { sessionDefaultKeys } from '../../../utils/session-defaults-schema.ts'; import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; import type { ToolResponse } from '../../../types/common.ts'; -const keys = [ - 'projectPath', - 'workspacePath', - 'scheme', - 'configuration', - 'simulatorName', - 'simulatorId', - 'deviceId', - 'useLatestOS', - 'arch', -] as const; +const keys = sessionDefaultKeys; const schemaObj = z.object({ keys: z.array(z.enum(keys)).optional(), diff --git a/src/mcp/tools/session-management/session_set_defaults.ts b/src/mcp/tools/session-management/session_set_defaults.ts index 8cd05f79..f629d712 100644 --- a/src/mcp/tools/session-management/session_set_defaults.ts +++ b/src/mcp/tools/session-management/session_set_defaults.ts @@ -1,60 +1,51 @@ import * as z from 'zod'; +import process from 'node:process'; +import type { FileSystemExecutor } from '../../../utils/FileSystemExecutor.ts'; import { sessionStore, type SessionDefaults } from '../../../utils/session-store.ts'; -import { createTypedTool } from '../../../utils/typed-tool-factory.ts'; -import { getDefaultCommandExecutor } from '../../../utils/execution/index.ts'; +import { getDefaultFileSystemExecutor } from '../../../utils/command.ts'; +import { persistSessionDefaultsToProjectConfig } from '../../../utils/project-config.ts'; +import { removeUndefined } from '../../../utils/remove-undefined.ts'; +import { sessionDefaultsSchema } from '../../../utils/session-defaults-schema.ts'; +import { createTypedToolWithContext } from '../../../utils/typed-tool-factory.ts'; import type { ToolResponse } from '../../../types/common.ts'; -const baseSchema = z.object({ - projectPath: z.string().optional().describe('xcodeproj path (xor workspacePath)'), - workspacePath: z.string().optional().describe('xcworkspace path (xor projectPath)'), - scheme: z.string().optional(), - configuration: z - .string() - .optional() - .describe("Build configuration for Xcode and SwiftPM tools (e.g. 'Debug' or 'Release')."), - simulatorName: z.string().optional(), - simulatorId: z.string().optional(), - deviceId: z.string().optional(), - useLatestOS: z.boolean().optional(), - arch: z.enum(['arm64', 'x86_64']).optional(), - suppressWarnings: z.boolean().optional(), - derivedDataPath: z - .string() - .optional() - .describe('Default DerivedData path for Xcode build/test/clean tools.'), - preferXcodebuild: z +const schemaObj = sessionDefaultsSchema.extend({ + persist: z .boolean() .optional() - .describe('Prefer xcodebuild over incremental builds for Xcode build/test/clean tools.'), - platform: z - .string() - .optional() - .describe('Default device platform for device tools (e.g. iOS, watchOS).'), - bundleId: z - .string() - .optional() - .describe('Default bundle ID for launch/stop/log tools when working on a single app.'), + .describe('Persist provided defaults to .xcodebuildmcp/config.yaml'), }); -const schemaObj = baseSchema; - type Params = z.infer; -export async function sessionSetDefaultsLogic(params: Params): Promise { +type SessionSetDefaultsContext = { + fs: FileSystemExecutor; + cwd: string; +}; + +export async function sessionSetDefaultsLogic( + params: Params, + context: SessionSetDefaultsContext, +): Promise { const notices: string[] = []; const current = sessionStore.getAll(); - const nextParams: Partial = { ...params }; + const { persist, ...rawParams } = params; + const nextParams = removeUndefined( + rawParams as Record, + ) as Partial; const hasProjectPath = - Object.prototype.hasOwnProperty.call(params, 'projectPath') && params.projectPath !== undefined; + Object.prototype.hasOwnProperty.call(nextParams, 'projectPath') && + nextParams.projectPath !== undefined; const hasWorkspacePath = - Object.prototype.hasOwnProperty.call(params, 'workspacePath') && - params.workspacePath !== undefined; + Object.prototype.hasOwnProperty.call(nextParams, 'workspacePath') && + nextParams.workspacePath !== undefined; const hasSimulatorId = - Object.prototype.hasOwnProperty.call(params, 'simulatorId') && params.simulatorId !== undefined; + Object.prototype.hasOwnProperty.call(nextParams, 'simulatorId') && + nextParams.simulatorId !== undefined; const hasSimulatorName = - Object.prototype.hasOwnProperty.call(params, 'simulatorName') && - params.simulatorName !== undefined; + Object.prototype.hasOwnProperty.call(nextParams, 'simulatorName') && + nextParams.simulatorName !== undefined; if (hasProjectPath && hasWorkspacePath) { delete nextParams.projectPath; @@ -113,7 +104,24 @@ export async function sessionSetDefaultsLogic(params: Params): Promise); + if (Object.keys(nextParams).length > 0) { + sessionStore.setDefaults(nextParams as Partial); + } + + if (persist) { + if (Object.keys(nextParams).length === 0 && toClear.size === 0) { + notices.push('No defaults provided to persist.'); + } else { + const { path } = await persistSessionDefaultsToProjectConfig({ + fs: context.fs, + cwd: context.cwd, + patch: nextParams, + deleteKeys: Array.from(toClear), + }); + notices.push(`Persisted defaults to ${path}`); + } + } + const updated = sessionStore.getAll(); const noticeText = notices.length > 0 ? `\nNotices:\n- ${notices.join('\n- ')}` : ''; return { @@ -130,10 +138,13 @@ export async function sessionSetDefaultsLogic(params: Params): Promise ({ + fs: getDefaultFileSystemExecutor(), + cwd: process.cwd(), + })), }; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 2b352776..71d50fff 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -2,11 +2,17 @@ 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 { loadProjectConfig } from '../utils/project-config.ts'; +import { sessionStore } from '../utils/session-store.ts'; import { registerWorkflows } from '../utils/tool-registry.ts'; export interface BootstrapOptions { enabledWorkflows?: string[]; + fileSystemExecutor?: FileSystemExecutor; + cwd?: string; } function parseEnabledWorkflows(value: string): string[] { @@ -27,6 +33,33 @@ export async function bootstrapServer( return {}; }); + const cwd = options.cwd ?? process.cwd(); + const fileSystemExecutor = options.fileSystemExecutor ?? getDefaultFileSystemExecutor(); + + try { + const configResult = await loadProjectConfig({ fs: fileSystemExecutor, cwd }); + if (configResult.found) { + const defaults = configResult.config.sessionDefaults ?? {}; + if (Object.keys(defaults).length > 0) { + sessionStore.setDefaults(defaults); + } + for (const notice of configResult.notices) { + log('info', `[ProjectConfig] ${notice}`); + } + } else if ('error' in configResult) { + const errorMessage = + configResult.error instanceof Error + ? configResult.error.message + : String(configResult.error); + log( + 'warning', + `Failed to read or parse project config at ${configResult.path}. ${errorMessage}`, + ); + } + } catch (error) { + log('warning', `Failed to load project config from ${cwd}. ${error}`); + } + const defaultEnabledWorkflows = ['simulator']; const enabledWorkflows = options.enabledWorkflows?.length diff --git a/src/utils/__tests__/project-config.test.ts b/src/utils/__tests__/project-config.test.ts new file mode 100644 index 00000000..a204a083 --- /dev/null +++ b/src/utils/__tests__/project-config.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts'; +import { loadProjectConfig, persistSessionDefaultsToProjectConfig } from '../project-config.ts'; + +const cwd = '/repo'; +const configPath = path.join(cwd, '.xcodebuildmcp', 'config.yaml'); +const configDir = path.join(cwd, '.xcodebuildmcp'); + +type MockWrite = { path: string; content: string }; + +type MockFsFixture = { + fs: ReturnType; + writes: MockWrite[]; + mkdirs: string[]; +}; + +function createFsFixture(options?: { exists?: boolean; readFile?: string }): MockFsFixture { + const writes: MockWrite[] = []; + const mkdirs: string[] = []; + const exists = options?.exists ?? false; + const readFileContent = options?.readFile; + + const fs = createMockFileSystemExecutor({ + existsSync: (targetPath) => (targetPath === configPath ? exists : false), + readFile: async (targetPath) => { + if (targetPath !== configPath) { + throw new Error(`Unexpected readFile path: ${targetPath}`); + } + if (readFileContent == null) { + throw new Error('readFile called but no readFile content was provided'); + } + return readFileContent; + }, + writeFile: async (targetPath, content) => { + writes.push({ path: targetPath, content }); + }, + mkdir: async (targetPath) => { + mkdirs.push(targetPath); + }, + }); + + return { fs, writes, mkdirs }; +} + +describe('project-config', () => { + describe('loadProjectConfig', () => { + it('should return found=false when config does not exist', async () => { + const { fs } = createFsFixture({ exists: false }); + const result = await loadProjectConfig({ fs, cwd }); + expect(result).toEqual({ found: false }); + }); + + it('should normalize mutual exclusivity and resolve relative paths', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' projectPath: "./App.xcodeproj"', + ' workspacePath: "./App.xcworkspace"', + ' simulatorName: "iPhone 16"', + ' simulatorId: "SIM-1"', + ' derivedDataPath: "./.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'); + + const defaults = result.config.sessionDefaults ?? {}; + expect(defaults.workspacePath).toBe(path.join(cwd, 'App.xcworkspace')); + expect(defaults.projectPath).toBeUndefined(); + expect(defaults.simulatorId).toBe('SIM-1'); + expect(defaults.simulatorName).toBeUndefined(); + expect(defaults.derivedDataPath).toBe(path.join(cwd, '.derivedData')); + expect(result.notices.length).toBeGreaterThan(0); + }); + + 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 }); + + const result = await loadProjectConfig({ fs, cwd }); + expect(result.found).toBe(false); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error).toBeInstanceOf(Error); + } + }); + + it('should return an error result when YAML does not parse to an object', async () => { + const { fs } = createFsFixture({ exists: true, readFile: '- item' }); + + const result = await loadProjectConfig({ fs, cwd }); + expect(result.found).toBe(false); + expect('error' in result).toBe(true); + if ('error' in result) { + expect(result.error.message).toBe('Project config must be an object'); + } + }); + }); + + describe('persistSessionDefaultsToProjectConfig', () => { + it('should merge patches, delete exclusive keys, and preserve unknown sections', async () => { + const yaml = [ + 'schemaVersion: 1', + 'sessionDefaults:', + ' scheme: "Old"', + ' simulatorName: "OldSim"', + 'server:', + ' enabledWorkflows:', + ' - simulator', + '', + ].join('\n'); + + const { fs, writes, mkdirs } = createFsFixture({ exists: true, readFile: yaml }); + + await persistSessionDefaultsToProjectConfig({ + fs, + cwd, + patch: { scheme: 'New', simulatorId: 'SIM-1' }, + deleteKeys: ['simulatorName'], + }); + + expect(mkdirs).toContain(configDir); + expect(writes.length).toBe(1); + expect(writes[0].path).toBe(configPath); + + const parsed = parseYaml(writes[0].content) as { + schemaVersion: number; + sessionDefaults?: Record; + server?: { enabledWorkflows?: string[] }; + }; + + expect(parsed.schemaVersion).toBe(1); + expect(parsed.sessionDefaults?.scheme).toBe('New'); + expect(parsed.sessionDefaults?.simulatorId).toBe('SIM-1'); + expect(parsed.sessionDefaults?.simulatorName).toBeUndefined(); + expect(parsed.server?.enabledWorkflows).toEqual(['simulator']); + }); + + it('should overwrite invalid existing config with a minimal valid config', async () => { + const { fs, writes } = createFsFixture({ exists: true, readFile: '- not-an-object' }); + + await persistSessionDefaultsToProjectConfig({ + fs, + cwd, + patch: { scheme: 'App' }, + }); + + expect(writes.length).toBe(1); + const parsed = parseYaml(writes[0].content) as { + schemaVersion: number; + sessionDefaults?: Record; + }; + + expect(parsed.schemaVersion).toBe(1); + expect(parsed.sessionDefaults?.scheme).toBe('App'); + }); + }); +}); diff --git a/src/utils/project-config.ts b/src/utils/project-config.ts new file mode 100644 index 00000000..7c3c0f76 --- /dev/null +++ b/src/utils/project-config.ts @@ -0,0 +1,186 @@ +import path from 'node:path'; +import * as z from 'zod'; +import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; +import type { FileSystemExecutor } from './FileSystemExecutor.ts'; +import type { SessionDefaults } from './session-store.ts'; +import { log } from './logger.ts'; +import { sessionDefaultsSchema } from './session-defaults-schema.ts'; +import { removeUndefined } from './remove-undefined.ts'; + +const CONFIG_DIR = '.xcodebuildmcp'; +const CONFIG_FILE = 'config.yaml'; + +const projectConfigSchema = z + .object({ + schemaVersion: z.literal(1).optional().default(1), + sessionDefaults: sessionDefaultsSchema.optional(), + }) + .passthrough(); + +type ProjectConfigSchema = z.infer; + +export type ProjectConfig = { + schemaVersion: 1; + sessionDefaults?: Partial; + [key: string]: unknown; +}; + +export type LoadProjectConfigOptions = { + fs: FileSystemExecutor; + cwd: string; +}; + +export type LoadProjectConfigResult = + | { found: false } + | { found: false; path: string; error: Error } + | { found: true; path: string; config: ProjectConfig; notices: string[] }; + +export type PersistSessionDefaultsOptions = { + fs: FileSystemExecutor; + cwd: string; + patch: Partial; + deleteKeys?: (keyof SessionDefaults)[]; +}; + +function getConfigDir(cwd: string): string { + return path.join(cwd, CONFIG_DIR); +} + +function getConfigPath(cwd: string): string { + return path.join(getConfigDir(cwd), CONFIG_FILE); +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function toError(value: unknown): Error { + return value instanceof Error ? value : new Error(String(value)); +} + +function hasValue>(defaults: T, key: keyof T): boolean { + return Object.prototype.hasOwnProperty.call(defaults, key) && defaults[key] !== undefined; +} + +function normalizeMutualExclusivity(defaults: Partial): { + normalized: Partial; + notices: string[]; +} { + const normalized: Partial = { ...defaults }; + const notices: string[] = []; + + if (hasValue(normalized, 'projectPath') && hasValue(normalized, 'workspacePath')) { + delete normalized.projectPath; + notices.push('Both projectPath and workspacePath were provided; keeping workspacePath.'); + } + + if (hasValue(normalized, 'simulatorId') && hasValue(normalized, 'simulatorName')) { + delete normalized.simulatorName; + notices.push('Both simulatorId and simulatorName were provided; keeping simulatorId.'); + } + + return { normalized, notices }; +} + +function resolveRelativeSessionPaths( + defaults: Partial, + cwd: string, +): Partial { + const resolved: Partial = { ...defaults }; + const pathKeys = ['projectPath', 'workspacePath', 'derivedDataPath'] as const; + + 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); + } + } + + return resolved; +} + +function parseProjectConfig(rawText: string): ProjectConfigSchema { + const parsed: unknown = parseYaml(rawText); + if (!isPlainObject(parsed)) { + throw new Error('Project config must be an object'); + } + return projectConfigSchema.parse(parsed); +} + +export async function loadProjectConfig( + options: LoadProjectConfigOptions, +): Promise { + const configPath = getConfigPath(options.cwd); + + if (!options.fs.existsSync(configPath)) { + return { found: false }; + } + + let parsed: ProjectConfigSchema; + try { + const rawText = await options.fs.readFile(configPath, 'utf8'); + parsed = parseProjectConfig(rawText); + + if (!parsed.sessionDefaults) { + return { found: true, path: configPath, config: parsed, notices: [] }; + } + + const { normalized, notices } = normalizeMutualExclusivity(parsed.sessionDefaults); + const resolved = resolveRelativeSessionPaths(normalized, options.cwd); + + const config: ProjectConfig = { + ...parsed, + sessionDefaults: resolved, + }; + + return { found: true, path: configPath, config, notices }; + } catch (error) { + return { found: false, path: configPath, error: toError(error) }; + } +} + +export async function persistSessionDefaultsToProjectConfig( + options: PersistSessionDefaultsOptions, +): Promise<{ path: string }> { + const configDir = getConfigDir(options.cwd); + const configPath = getConfigPath(options.cwd); + + await options.fs.mkdir(configDir, { recursive: true }); + + let baseConfig: ProjectConfig = { schemaVersion: 1 }; + + if (options.fs.existsSync(configPath)) { + try { + const rawText = await options.fs.readFile(configPath, 'utf8'); + const parsed = parseProjectConfig(rawText); + baseConfig = { ...parsed, schemaVersion: 1 }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log( + 'warning', + `Failed to read or parse project config at ${configPath}. Overwriting with new config. ${errorMessage}`, + ); + baseConfig = { schemaVersion: 1 }; + } + } + + const patch = removeUndefined(options.patch as Record); + const nextSessionDefaults: Partial = { + ...(baseConfig.sessionDefaults ?? {}), + ...patch, + }; + + for (const key of options.deleteKeys ?? []) { + delete nextSessionDefaults[key]; + } + + const nextConfig: ProjectConfig = { + ...baseConfig, + schemaVersion: 1, + sessionDefaults: nextSessionDefaults, + }; + + await options.fs.writeFile(configPath, stringifyYaml(nextConfig), 'utf8'); + + return { path: configPath }; +} diff --git a/src/utils/remove-undefined.ts b/src/utils/remove-undefined.ts new file mode 100644 index 00000000..9a63bed0 --- /dev/null +++ b/src/utils/remove-undefined.ts @@ -0,0 +1,9 @@ +export function removeUndefined>(input: T): Partial { + const result: Partial = {}; + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) { + result[key as keyof T] = value as T[keyof T]; + } + } + return result; +} diff --git a/src/utils/session-defaults-schema.ts b/src/utils/session-defaults-schema.ts new file mode 100644 index 00000000..e4819d9f --- /dev/null +++ b/src/utils/session-defaults-schema.ts @@ -0,0 +1,52 @@ +import * as z from 'zod'; + +export const sessionDefaultKeys = [ + 'projectPath', + 'workspacePath', + 'scheme', + 'configuration', + 'simulatorName', + 'simulatorId', + 'deviceId', + 'useLatestOS', + 'arch', + 'suppressWarnings', + 'derivedDataPath', + 'preferXcodebuild', + 'platform', + 'bundleId', +] as const; + +export type SessionDefaultKey = (typeof sessionDefaultKeys)[number]; + +export const sessionDefaultsSchema = z.object({ + projectPath: z.string().optional().describe('xcodeproj path (xor workspacePath)'), + workspacePath: z.string().optional().describe('xcworkspace path (xor projectPath)'), + scheme: z.string().optional(), + configuration: z + .string() + .optional() + .describe("Build configuration for Xcode and SwiftPM tools (e.g. 'Debug' or 'Release')."), + simulatorName: z.string().optional(), + simulatorId: z.string().optional(), + deviceId: z.string().optional(), + useLatestOS: z.boolean().optional(), + arch: z.enum(['arm64', 'x86_64']).optional(), + suppressWarnings: z.boolean().optional(), + derivedDataPath: z + .string() + .optional() + .describe('Default DerivedData path for Xcode build/test/clean tools.'), + preferXcodebuild: z + .boolean() + .optional() + .describe('Prefer xcodebuild over incremental builds for Xcode build/test/clean tools.'), + platform: z + .string() + .optional() + .describe('Default device platform for device tools (e.g. iOS, watchOS).'), + bundleId: z + .string() + .optional() + .describe('Default bundle ID for launch/stop/log tools when working on a single app.'), +});