diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000000..8bf45fb40571 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "link-args=/FORCE:MULTIPLE"] + +[target.aarch64-pc-windows-msvc] +rustflags = ["-C", "link-args=/FORCE:MULTIPLE"] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7aa823448a49..559274cb59bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ # CODEOWNERS file for block/goose repository # See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -# Documentation owned by DevRel team -/documentation/ @block/goose-devrel +# Documentation owned by DevRel +/documentation/ @blackgirlbytes diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3d975ebdc6c9..dfbaf7881824 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,8 +34,8 @@ - Async/await misuse or blocking operations in async contexts - Improper trait implementations -### No Prerelease Docs -- If the PR contains both code changes to features/functionality AND updates in `/documentation`: Documentation updates must be separated to keep public docs in sync with released versions. Either mark new topics with `unlisted: true` or remove/hide the documentation. +### No Doc Updates with Code Changes +- PRs with code changes shouldn't update `/documentation` - docs deploy on merge, code on release. Use `unlisted: true` or remove/hide docs. ## Project-Specific Context diff --git a/.github/workflows/goose-release-notes.yml b/.github/workflows/goose-release-notes.yml new file mode 100644 index 000000000000..4efc16daf360 --- /dev/null +++ b/.github/workflows/goose-release-notes.yml @@ -0,0 +1,350 @@ +# goose Release Notes Generator +# +# Automatically generates release notes using goose AI agent when a new release is published. +# Updates the GitHub release with AI-generated categorized notes and posts to Discord. +# +# Uses workflow_run instead of release:published because GitHub doesn't trigger +# workflows for events created by GITHUB_TOKEN (to prevent infinite loops). + +name: goose Release Notes Generator + +on: + # Trigger when Release workflow completes (works around GITHUB_TOKEN limitation) + workflow_run: + workflows: + - Release + types: + - completed + + # Allow manual trigger for testing + workflow_dispatch: + inputs: + tag: + description: 'Release tag to generate notes for (e.g., v1.25.0)' + required: true + type: string + +env: + GOOSE_RECIPE: | + version: "1.0.0" + title: "Release Notes Generator" + description: "Generate release notes for ${RELEASE_TAG}" + + extensions: + - type: builtin + name: developer + + instructions: | + Generate release notes for the goose release. + + ## Process + 1. You are already in the goose repository. Do NOT clone or checkout anything. + 2. Get the previous release tag by running: git describe --tags --abbrev=0 ${RELEASE_TAG}^ + 3. Get commits between tags: git log ..${RELEASE_TAG} --oneline --no-merges + 4. Analyze the commits and categorize changes + + ## Output Format + Categorize changes into these sections (skip empty sections): + - ✨ **Features** - New functionality + - πŸ› **Bug Fixes** - Bug fixes + - πŸ”§ **Improvements** - Enhancements to existing features + - πŸ“š **Documentation** - Documentation updates + + Format each item as: + - Concise description [#XXXX](https://github.com/block/goose/pull/XXXX) + + Rules: + - Extract PR numbers from commit messages (look for (#XXXX) pattern) + - Remove redundant words like "Added", "Fixed", "Documented" - the category headers make these clear + - Keep descriptions user-friendly and concise + - Order: Features β†’ Bug Fixes β†’ Improvements β†’ Documentation + + ## Final Step + Write ONLY the release notes content to /tmp/release_notes.md (no extra commentary) + + prompt: | + Generate release notes for ${RELEASE_TAG} in the goose repository. + +permissions: + contents: write + +concurrency: + group: release-notes-${{ github.event.workflow_run.head_branch || inputs.tag }} + cancel-in-progress: true + +jobs: + generate-release-notes: + name: Generate Release Notes + runs-on: ubuntu-latest + # For workflow_run: only run if Release succeeded and tag is not 'stable' + # For workflow_dispatch: only run if tag is not 'stable' + if: | + (github.event_name == 'workflow_dispatch' && inputs.tag != 'stable') || + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_branch != 'stable') + + container: + image: ghcr.io/block/goose:latest + options: --user root + env: + GOOSE_PROVIDER: ${{ vars.GOOSE_PROVIDER || 'anthropic' }} + GOOSE_MODEL: ${{ vars.GOOSE_MODEL || 'claude-opus-4-5' }} + ANTHROPIC_API_KEY: ${{ secrets.RELEASE_BOT_ANTHROPIC_KEY }} + HOME: /tmp/goose-home + + outputs: + release_notes: ${{ steps.read-notes.outputs.notes }} + notes_length: ${{ steps.read-notes.outputs.length }} + release_tag: ${{ steps.get-tag.outputs.tag }} + + steps: + - name: Get release tag + id: get-tag + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + WORKFLOW_RUN_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + echo "tag=$INPUT_TAG" >> $GITHUB_OUTPUT + else + echo "tag=$WORKFLOW_RUN_BRANCH" >> $GITHUB_OUTPUT + fi + + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Install tools + run: | + apt-get update + apt-get install -y gettext curl ripgrep git jq + + - name: Run goose to generate release notes + env: + RELEASE_TAG: ${{ steps.get-tag.outputs.tag }} + run: | + mkdir -p $HOME/.local/share/goose/sessions + mkdir -p $HOME/.config/goose + git config --global --add safe.directory "$GITHUB_WORKSPACE" + + # Checkout the release tag + git checkout "${RELEASE_TAG}" + + # Create recipe from env var with variable substitution + echo "$GOOSE_RECIPE" | envsubst '$RELEASE_TAG' > /tmp/recipe.yaml + + goose run --recipe /tmp/recipe.yaml + + - name: Read release notes + id: read-notes + run: | + if [ -f /tmp/release_notes.md ]; then + # Use random delimiter to prevent injection + DELIMITER="EOF_$(openssl rand -hex 8)" + echo "notes<<$DELIMITER" >> $GITHUB_OUTPUT + cat /tmp/release_notes.md >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "$DELIMITER" >> $GITHUB_OUTPUT + + LENGTH=$(wc -c < /tmp/release_notes.md) + echo "length=$LENGTH" >> $GITHUB_OUTPUT + + echo "::notice::Release notes generated successfully (${LENGTH} chars)" + else + echo "::error::Release notes file not found at /tmp/release_notes.md" + echo "notes=Release notes generation failed." >> $GITHUB_OUTPUT + echo "length=0" >> $GITHUB_OUTPUT + exit 1 + fi + + update-github-release: + name: Update GitHub Release + runs-on: ubuntu-latest + needs: generate-release-notes + permissions: + contents: write + + steps: + - name: Update release body + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 + with: + tag: ${{ needs.generate-release-notes.outputs.release_tag }} + token: ${{ secrets.GITHUB_TOKEN }} + body: ${{ needs.generate-release-notes.outputs.release_notes }} + allowUpdates: true + omitNameDuringUpdate: true + omitPrereleaseDuringUpdate: true + + post-to-discord: + name: Post to Discord + runs-on: ubuntu-latest + needs: generate-release-notes + + steps: + - name: Post release announcement + env: + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_RELEASE_BOT_TOKEN }} + DISCORD_CHANNEL_ID: ${{ secrets.DISCORD_RELEASE_BOT_CHANNEL_ID }} + RELEASE_TAG: ${{ needs.generate-release-notes.outputs.release_tag }} + RELEASE_URL: https://github.com/block/goose/releases/tag/${{ needs.generate-release-notes.outputs.release_tag }} + RELEASE_NOTES: ${{ needs.generate-release-notes.outputs.release_notes }} + NOTES_LENGTH: ${{ needs.generate-release-notes.outputs.notes_length }} + shell: bash + run: | + # Skip if Discord is not configured + if [ -z "$DISCORD_CHANNEL_ID" ] || [ -z "$DISCORD_BOT_TOKEN" ]; then + echo "::notice::Discord not configured, skipping" + exit 0 + fi + + # Discord message character limit is ~2000 for regular messages + SAFE_LIMIT=1800 + + # Function to send Discord message via bot API, returns message ID + send_discord() { + local content="$1" + local channel="$2" + + content=$(echo "$content" | jq -Rs .) + + response=$(curl -s -X POST "https://discord.com/api/v10/channels/${channel}/messages" \ + -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"content\": $content, \"flags\": 4}") + + # Debug: show response if there's an error + if echo "$response" | jq -e '.code' > /dev/null 2>&1; then + echo "::warning::Discord API error: $(echo "$response" | jq -c '.')" >&2 + fi + + echo "$response" | jq -r '.id // empty' + } + + # Function to create a thread from a message + create_thread() { + local channel="$1" + local message_id="$2" + local thread_name="$3" + + response=$(curl -s -X POST "https://discord.com/api/v10/channels/${channel}/messages/${message_id}/threads" \ + -H "Authorization: Bot ${DISCORD_BOT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"${thread_name}\"}") + + echo "$response" | jq -r '.id // empty' + } + + # Build the announcement header + HEADER="## πŸŽ‰ goose ${RELEASE_TAG} is here! + + πŸ“¦ **Release:** ${RELEASE_URL}" + + FOOTER=" + + @everyone + πŸ“ *More in thread...*" + HEADER_LEN=${#HEADER} + FOOTER_LEN=${#FOOTER} + AVAILABLE=$((SAFE_LIMIT - HEADER_LEN - FOOTER_LEN - 10)) + + # Check if everything fits in one message (no thread needed) + FULL_MSG="${HEADER} + + ${RELEASE_NOTES}" + if [ ${#FULL_MSG} -le "$SAFE_LIMIT" ]; then + send_discord "$FULL_MSG" "$DISCORD_CHANNEL_ID" + echo "::notice::Discord notification sent successfully" + exit 0 + fi + + # Need to split - use awk to fit complete lines in main vs thread + echo "$RELEASE_NOTES" > /tmp/release_notes_full.txt + + awk -v avail="$AVAILABLE" ' + BEGIN { main_len = 0; in_thread = 0 } + { + line_len = length($0) + 1 # +1 for newline + if (!in_thread && (main_len + line_len) <= avail) { + print > "/tmp/main_content.txt" + main_len += line_len + } else { + in_thread = 1 + print > "/tmp/thread_content.txt" + } + } + ' /tmp/release_notes_full.txt + + # Read the split content + MAIN_CONTENT="" + [ -f /tmp/main_content.txt ] && MAIN_CONTENT=$(cat /tmp/main_content.txt) + + THREAD_CONTENT="" + [ -f /tmp/thread_content.txt ] && THREAD_CONTENT=$(cat /tmp/thread_content.txt) + + # Build and send main message + MAIN_MESSAGE="${HEADER} + + ${MAIN_CONTENT} + ${FOOTER}" + + MESSAGE_ID=$(send_discord "$MAIN_MESSAGE" "$DISCORD_CHANNEL_ID") + + if [ -z "$MESSAGE_ID" ]; then + echo "::error::Failed to send Discord message" + exit 1 + fi + + echo "Created message $MESSAGE_ID, creating thread..." + sleep 1 + + # Create thread + THREAD_ID=$(create_thread "$DISCORD_CHANNEL_ID" "$MESSAGE_ID" "Release Notes ${RELEASE_TAG}") + if [ -z "$THREAD_ID" ]; then + echo "::warning::Failed to create thread" + THREAD_ID="$DISCORD_CHANNEL_ID" + fi + sleep 1 + + # Post thread content in chunks (by complete lines) + if [ -n "$THREAD_CONTENT" ]; then + # Split thread content into chunks using awk + awk -v limit="$SAFE_LIMIT" ' + BEGIN { chunk = ""; chunk_len = 0; chunk_num = 0 } + { + line_len = length($0) + 1 + if (chunk_len + line_len > limit && chunk_len > 0) { + # Save current chunk and start new one + print chunk > "/tmp/chunk_" chunk_num ".txt" + chunk_num++ + chunk = $0 "\n" + chunk_len = line_len + } else { + chunk = chunk $0 "\n" + chunk_len += line_len + } + } + END { + if (chunk_len > 0) { + print chunk > "/tmp/chunk_" chunk_num ".txt" + } + } + ' /tmp/thread_content.txt + + # Send each chunk + for chunk_file in /tmp/chunk_*.txt; do + [ -f "$chunk_file" ] || continue + CHUNK=$(cat "$chunk_file") + [ -n "$CHUNK" ] && send_discord "$CHUNK" "$THREAD_ID" + sleep 1 + rm -f "$chunk_file" + done + fi + + # Cleanup + rm -f /tmp/release_notes_full.txt /tmp/main_content.txt /tmp/thread_content.txt + + echo "::notice::Discord notification sent successfully" diff --git a/.goosehints b/.goosehints index d17fc8683f5e..82cddcf11ea7 100644 --- a/.goosehints +++ b/.goosehints @@ -14,5 +14,5 @@ that you can call the functionality from the server from the typescript. tips: - can look at unstaged changes for what is being worked on if starting -- always check rust compiles, cargo fmt etc and `./scripts/clippy-lint.sh` (as well as run tests in files you are working on) +- always check rust compiles, cargo fmt etc and `cargo clippy --all-targets -- -D warnings` (as well as run tests in files you are working on) - in ui/desktop, look at how you can run lint checks and if other tests can run diff --git a/AGENTS.md b/AGENTS.md index bcc2be8ed9cb..922b908813ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,18 +41,17 @@ cd ui/desktop && npm test # test UI ## Structure ``` crates/ -β”œβ”€β”€ goose # core logic -β”œβ”€β”€ goose-bench # benchmarking -β”œβ”€β”€ goose-cli # CLI entry -β”œβ”€β”€ goose-server # backend (binary: goosed) -β”œβ”€β”€ goose-mcp # MCP extensions -β”œβ”€β”€ goose-test # test utilities -β”œβ”€β”€ mcp-client # MCP client -β”œβ”€β”€ mcp-core # MCP shared -└── mcp-server # MCP server - -temporal-service/ # Go scheduler -ui/desktop/ # Electron app +β”œβ”€β”€ goose # core logic +β”œβ”€β”€ goose-acp # Agent Client Protocol +β”œβ”€β”€ goose-acp-macros # ACP proc macros +β”œβ”€β”€ goose-cli # CLI entry +β”œβ”€β”€ goose-server # backend (binary: goosed) +β”œβ”€β”€ goose-mcp # MCP extensions +β”œβ”€β”€ goose-test # test utilities +└── goose-test-support # test helpers + +evals/open-model-gym/ # benchmarking / evals +ui/desktop/ # Electron app ``` ## Development Loop diff --git a/CUSTOM_DISTROS.md b/CUSTOM_DISTROS.md index 1405a9119c0d..c9f08a2241ad 100644 --- a/CUSTOM_DISTROS.md +++ b/CUSTOM_DISTROS.md @@ -46,7 +46,7 @@ goose's architecture is designed for extensibility. Organizations can create "re |---------------|---------------|------------| | Preconfigure a model/provider | `config.yaml`, `init-config.yaml`, environment variables | Low | | Add custom AI providers | `crates/goose/src/providers/declarative/` | Low | -| Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json` | Medium | +| Bundle custom MCP extensions | `config.yaml` extensions section, `ui/desktop/src/built-in-extensions.json`, `ui/desktop/src/components/settings/extensions/bundled-extensions.json` | Medium | | Modify system prompts | `crates/goose/src/prompts/` | Low | | Customize desktop branding | `ui/desktop/` (icons, names, colors) | Medium | | Build a new UI (web, mobile) | Integrate with `goose-server` REST API | High | @@ -191,7 +191,11 @@ async def query_data_lake(query: str) -> str: return results ``` -2. **Bundle as a built-in extension** by adding to `ui/desktop/src/built-in-extensions.json`: +2. **Bundle as a built-in extension** by adding to either: + - `ui/desktop/src/built-in-extensions.json` (core built-ins surfaced in extension UI) + - `ui/desktop/src/components/settings/extensions/bundled-extensions.json` (bundled extension catalog in Settings) + +Example: ```json { @@ -268,6 +272,26 @@ You are an AI assistant called [YourName], created by [YourCompany]. - Component text and labels - Feature visibility +5. **Align packaging and updater names** when rebranding: + - Update static branding metadata in `ui/desktop/package.json` (`productName`, description) and Linux desktop templates (`ui/desktop/forge.deb.desktop`, `ui/desktop/forge.rpm.desktop`) + + - Set build/release environment variables consistently: + - `GITHUB_OWNER` and `GITHUB_REPO` for publisher + updater repository lookup + - `GOOSE_BUNDLE_NAME` for bundle/debug scripts and updater asset naming (defaults to `Goose`) + +Example: + +```bash +export GITHUB_OWNER="your-org" +export GITHUB_REPO="your-goose-fork" +export GOOSE_BUNDLE_NAME="InsightStream-goose" +``` + +6. **Use this branding consistency checklist** before release: + - Application metadata (`forge.config.ts`, `package.json`, `index.html`) uses your distro name + - Release artifact names and updater lookup names are consistent + - Desktop launchers (Linux `.desktop` templates) point to the same executable name produced by packaging + ### Technical Details - Electron config: `ui/desktop/forge.config.ts` diff --git a/Cargo.lock b/Cargo.lock index ef826b275421..9d36d96570fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,6 +178,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -339,9 +348,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -525,9 +534,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.12" +version = "1.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" dependencies = [ "futures-util", "pin-project-lite", @@ -694,9 +703,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.14" +version = "0.60.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" +checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" dependencies = [ "xmlparser", ] @@ -837,6 +846,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "rustls 0.23.36", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "az" version = "1.3.0" @@ -1711,9 +1742,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -3400,9 +3431,6 @@ name = "esaxx-rs" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" -dependencies = [ - "cc", -] [[package]] name = "etcetera" @@ -3725,6 +3753,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", + "tokio", ] [[package]] @@ -4239,7 +4268,7 @@ dependencies = [ [[package]] name = "goose" -version = "1.24.0" +version = "1.26.0" dependencies = [ "ahash", "anyhow", @@ -4267,7 +4296,6 @@ dependencies = [ "etcetera 0.11.0", "fs2", "futures", - "goose-mcp", "goose-test-support", "hf-hub", "ignore", @@ -4295,6 +4323,7 @@ dependencies = [ "posthog-rs", "pulldown-cmark", "rand 0.8.5", + "rayon", "regex", "reqwest 0.13.2", "rmcp 0.16.0", @@ -4322,6 +4351,16 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-java", + "tree-sitter-javascript", + "tree-sitter-kotlin-ng", + "tree-sitter-python", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-swift", + "tree-sitter-typescript", "unbinder", "unicode-normalization", "url", @@ -4338,7 +4377,7 @@ dependencies = [ [[package]] name = "goose-acp" -version = "1.24.0" +version = "1.26.0" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -4375,7 +4414,7 @@ dependencies = [ [[package]] name = "goose-acp-macros" -version = "1.24.0" +version = "1.26.0" dependencies = [ "proc-macro2", "quote", @@ -4384,7 +4423,7 @@ dependencies = [ [[package]] name = "goose-cli" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anstream", "anyhow", @@ -4437,7 +4476,7 @@ dependencies = [ [[package]] name = "goose-mcp" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -4487,10 +4526,12 @@ dependencies = [ [[package]] name = "goose-server" -version = "1.24.0" +version = "1.26.0" dependencies = [ "anyhow", + "aws-lc-rs", "axum 0.8.8", + "axum-server", "base64 0.22.1", "bytes", "chrono", @@ -4506,6 +4547,7 @@ dependencies = [ "http 1.4.0", "once_cell", "rand 0.9.2", + "rcgen", "reqwest 0.13.2", "rmcp 0.16.0", "rustls 0.23.36", @@ -4534,7 +4576,7 @@ dependencies = [ [[package]] name = "goose-test" -version = "1.24.0" +version = "1.26.0" dependencies = [ "clap", "serde_json", @@ -4542,7 +4584,7 @@ dependencies = [ [[package]] name = "goose-test-support" -version = "1.24.0" +version = "1.26.0" dependencies = [ "axum 0.7.9", "rmcp 0.16.0", @@ -5747,23 +5789,23 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llama-cpp-2" -version = "0.1.133" +version = "0.1.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "888c8805527f4c35ec16f26003d54a318cde1629e7439da8e9ef2d6d3883e106" +checksum = "aedc4f4ca22ad992bc43fe20734b3a0f37363b9621419727821bf6572b9c0395" dependencies = [ "encoding_rs", "enumflags2", "llama-cpp-sys-2", - "thiserror 1.0.69", + "thiserror 2.0.18", "tracing", "tracing-core", ] [[package]] name = "llama-cpp-sys-2" -version = "0.1.133" +version = "0.1.137" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a180dfa6d6f9d1df1e031bcdf0464bbad4f9b326395bfd28f2fa539d8cbc9c2b" +checksum = "da365e84fbe4d10e849fa3bfd5a0d70b3b4a59e8c5adc8b7be5c189327566bdb" dependencies = [ "bindgen", "cc", @@ -7831,6 +7873,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "realfft" version = "3.5.0" @@ -10562,7 +10617,6 @@ dependencies = [ "derive_builder", "esaxx-rs", "getrandom 0.3.4", - "indicatif 0.17.11", "itertools 0.14.0", "log", "macro_rules_attribute", @@ -11042,6 +11096,16 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.15" @@ -11421,8 +11485,15 @@ dependencies = [ [[package]] name = "v8" version = "145.0.0" +dependencies = [ + "v8-goose", +] + +[[package]] +name = "v8-goose" +version = "145.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61d9a107e16bae35a0be2bb0096ac1d2318aac352c82edd796ab2b9cac66d8f0" +checksum = "6f84468f393984251db025e944544590a8fb429d5088b78102bd72f09232f0da" dependencies = [ "bindgen", "bitflags 2.10.0", @@ -12561,6 +12632,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index d04d96e5a1a5..c571412c98e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "1.24.0" +version = "1.26.0" authors = ["Block "] license = "Apache-2.0" repository = "https://github.com/block/goose" @@ -68,3 +68,18 @@ opentelemetry-otlp = "0.31" opentelemetry-appender-tracing = "0.31" opentelemetry-stdout = { version = "0.31", features = ["trace", "metrics", "logs"] } tracing-opentelemetry = "0.32" + +rayon = "1.10" +tree-sitter = "0.26" +tree-sitter-go = "0.25" +tree-sitter-java = "0.23" +tree-sitter-javascript = "0.25" +tree-sitter-kotlin-ng = "1.1" +tree-sitter-python = "0.25" +tree-sitter-ruby = "0.23" +tree-sitter-rust = "0.24" +tree-sitter-swift = "0.7" +tree-sitter-typescript = "0.23" + +[patch.crates-io] +v8 = { path = "vendor/v8" } diff --git a/Dockerfile b/Dockerfile index 5500caf03954..c0f78860cc88 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,11 @@ FROM rust:1.82-bookworm AS builder RUN apt-get update && \ apt-get install -y --no-install-recommends \ build-essential \ + cmake \ pkg-config \ libssl-dev \ libdbus-1-dev \ + libclang-dev \ protobuf-compiler \ libprotobuf-dev \ ca-certificates \ @@ -40,6 +42,7 @@ RUN apt-get update && \ ca-certificates \ libssl3 \ libdbus-1-3 \ + libgomp1 \ libxcb1 \ curl \ git \ diff --git a/README.md b/README.md index e5199fc17290..f2a673bf8e1f 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Designed for maximum flexibility, goose works with any LLM and supports multi-mo - [Diagnostics & Reporting](https://block.github.io/goose/docs/troubleshooting/diagnostics-and-reporting) - [Known Issues](https://block.github.io/goose/docs/troubleshooting/known-issues) -# a little goose humor 🦒 +# a little goose humor πŸͺΏ > Why did the developer choose goose as their AI agent? > diff --git a/crates/goose-acp/Cargo.toml b/crates/goose-acp/Cargo.toml index b0829fc0f2ba..c3c388c0562c 100644 --- a/crates/goose-acp/Cargo.toml +++ b/crates/goose-acp/Cargo.toml @@ -15,15 +15,19 @@ path = "src/bin/server.rs" name = "generate-acp-schema" path = "src/bin/generate_acp_schema.rs" +[features] +default = ["code-mode"] +code-mode = ["goose/code-mode"] + [lints] workspace = true [dependencies] -goose = { path = "../goose" } +goose = { path = "../goose", default-features = false } goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } sacp = "10.1.0" -agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model"] } +agent-client-protocol-schema = { version = "0.10", features = ["unstable_session_model", "unstable_session_list"] } anyhow = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, features = ["compat", "rt"] } diff --git a/crates/goose-acp/src/custom_requests.rs b/crates/goose-acp/src/custom_requests.rs index 04025a4353fd..816d7bac9e86 100644 --- a/crates/goose-acp/src/custom_requests.rs +++ b/crates/goose-acp/src/custom_requests.rs @@ -82,12 +82,6 @@ pub struct GetSessionResponse { pub session: serde_json::Value, } -/// List all sessions. -#[derive(Debug, Serialize, JsonSchema)] -pub struct ListSessionsResponse { - pub sessions: Vec, -} - /// Delete a session. #[derive(Debug, Deserialize, JsonSchema)] pub struct DeleteSessionRequest { diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 3f953ab6adea..9ebd0aba71d8 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -24,10 +24,11 @@ use sacp::schema::{ AgentCapabilities, AuthMethod, AuthenticateRequest, AuthenticateResponse, BlobResourceContents, CancelNotification, Content, ContentBlock, ContentChunk, EmbeddedResource, EmbeddedResourceResource, ImageContent, InitializeRequest, InitializeResponse, - LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, ModelId, ModelInfo, - NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, - PromptCapabilities, PromptRequest, PromptResponse, RequestPermissionOutcome, - RequestPermissionRequest, ResourceLink, SessionId, SessionModelState, SessionNotification, + ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, McpCapabilities, McpServer, + ModelId, ModelInfo, NewSessionRequest, NewSessionResponse, PermissionOption, + PermissionOptionKind, PromptCapabilities, PromptRequest, PromptResponse, + RequestPermissionOutcome, RequestPermissionRequest, ResourceLink, SessionCapabilities, + SessionId, SessionInfo, SessionListCapabilities, SessionModelState, SessionNotification, SessionUpdate, SetSessionModelRequest, SetSessionModelResponse, StopReason, TextContent, TextResourceContents, ToolCall, ToolCallContent, ToolCallId, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, @@ -102,6 +103,10 @@ fn create_tool_location(path: &str, line: Option) -> ToolCallLocation { loc } +fn is_developer_file_tool(tool_name: &str) -> bool { + matches!(tool_name, "write" | "edit") +} + fn extract_tool_locations( tool_request: &goose::conversation::message::ToolRequest, tool_response: &goose::conversation::message::ToolResponse, @@ -109,10 +114,11 @@ fn extract_tool_locations( let mut locations = Vec::new(); if let Ok(tool_call) = &tool_request.tool_call { - if tool_call.name != "developer__text_editor" { + if !is_developer_file_tool(tool_call.name.as_ref()) { return locations; } + let tool_name = tool_call.name.as_ref(); let path_str = tool_call .arguments .as_ref() @@ -120,6 +126,11 @@ fn extract_tool_locations( .and_then(|p| p.as_str()); if let Some(path_str) = path_str { + if matches!(tool_name, "write" | "edit") { + locations.push(create_tool_location(path_str, Some(1))); + return locations; + } + let command = tool_call .arguments .as_ref() @@ -276,20 +287,21 @@ async fn add_extensions(agent: &Agent, extensions: Vec) { } } -async fn build_model_state( - provider: &dyn Provider, - current_model: &str, -) -> Result { - let models = provider.fetch_recommended_models().await.map_err(|e| { - sacp::Error::internal_error().data(format!("Failed to fetch models: {}", e)) - })?; - Ok(SessionModelState::new( +async fn build_model_state(provider: &dyn Provider, current_model: &str) -> SessionModelState { + let models = match provider.fetch_recommended_models().await { + Ok(models) => models, + Err(e) => { + warn!(error = %e, "failed to fetch models, model selection will be unavailable"); + vec![] + } + }; + SessionModelState::new( ModelId::new(current_model), models .iter() .map(|name| ModelInfo::new(ModelId::new(&**name), &**name)) .collect(), - )) + ) } impl GooseAcpAgent { @@ -653,6 +665,7 @@ impl GooseAcpAgent { let capabilities = AgentCapabilities::new() .load_session(true) + .session_capabilities(SessionCapabilities::new().list(SessionListCapabilities::new())) .prompt_capabilities( PromptCapabilities::new() .image(true) @@ -728,7 +741,7 @@ impl GooseAcpAgent { ); let model_state = - build_model_state(&*provider, &provider.get_model_config().model_name).await?; + build_model_state(&*provider, &provider.get_model_config().model_name).await; Ok(NewSessionResponse::new(SessionId::new(goose_session.id)).models(model_state)) } @@ -853,7 +866,7 @@ impl GooseAcpAgent { ); let model_state = - build_model_state(&*provider, &provider.get_model_config().model_name).await?; + build_model_state(&*provider, &provider.get_model_config().model_name).await; Ok(LoadSessionResponse::new().models(model_state)) } @@ -1086,14 +1099,15 @@ impl GooseAcpAgent { .list_sessions() .await .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - let sessions_json = sessions + let session_infos: Vec = sessions .into_iter() - .map(|s| serde_json::to_value(&s)) - .collect::, _>>() - .map_err(|e| sacp::Error::internal_error().data(e.to_string()))?; - Ok(ListSessionsResponse { - sessions: sessions_json, - }) + .map(|s| { + SessionInfo::new(SessionId::new(s.id), s.working_dir) + .title(s.name) + .updated_at(s.updated_at.to_rfc3339()) + }) + .collect(); + Ok(ListSessionsResponse::new(session_infos)) } #[custom_method("session/get")] @@ -1275,6 +1289,14 @@ impl JrMessageHandler for GooseAcpHandler { request_cx.respond(json)?; Ok(()) } + MessageCx::Request(req, request_cx) if req.method == "session/list" => { + let resp = agent.on_list_sessions().await?; + let json = serde_json::to_value(resp).map_err(|e| { + sacp::Error::internal_error().data(e.to_string()) + })?; + request_cx.respond(json)?; + Ok(()) + } MessageCx::Request(req, request_cx) if req.method.starts_with('_') => { match agent.handle_custom_request(&req.method, req.params).await { Ok(json) => request_cx.respond(json)?, @@ -1432,10 +1454,7 @@ print(\"hello, world\") #[test] fn test_format_tool_name_with_extension() { - assert_eq!( - format_tool_name("developer__text_editor"), - "Developer: Text Editor" - ); + assert_eq!(format_tool_name("developer__edit"), "Developer: Edit"); assert_eq!( format_tool_name("platform__manage_extensions"), "Platform: Manage Extensions" @@ -1521,37 +1540,37 @@ print(\"hello, world\") #[test_case( "model-a", Ok(vec!["model-a".into(), "model-b".into()]) - => Ok(SessionModelState::new( + => SessionModelState::new( ModelId::new("model-a"), vec![ModelInfo::new(ModelId::new("model-a"), "model-a"), ModelInfo::new(ModelId::new("model-b"), "model-b")], - )) + ) ; "returns current and available models" )] #[test_case( "model-a", Ok(vec![]) - => Ok(SessionModelState::new(ModelId::new("model-a"), vec![])) + => SessionModelState::new(ModelId::new("model-a"), vec![]) ; "empty model list" )] #[test_case( "model-a", Err(ProviderError::ExecutionError("fail".into())) - => matches Err(_) - ; "fetch error propagates" + => SessionModelState::new(ModelId::new("model-a"), vec![]) + ; "fetch error falls back to current model only" )] #[test_case( "switched-model", Ok(vec!["model-a".into(), "switched-model".into()]) - => Ok(SessionModelState::new( + => SessionModelState::new( ModelId::new("switched-model"), vec![ModelInfo::new(ModelId::new("model-a"), "model-a"), ModelInfo::new(ModelId::new("switched-model"), "switched-model")], - )) + ) ; "current model reflects switched model" )] #[tokio::test] async fn test_build_model_state( current_model: &str, models: Result, ProviderError>, - ) -> Result { + ) -> SessionModelState { let provider = MockModelProvider { models }; build_model_state(&provider, current_model).await } diff --git a/crates/goose-acp/tests/common_tests/mod.rs b/crates/goose-acp/tests/common_tests/mod.rs index 7305b7b8f647..69d78ce66f34 100644 --- a/crates/goose-acp/tests/common_tests/mod.rs +++ b/crates/goose-acp/tests/common_tests/mod.rs @@ -13,9 +13,7 @@ use goose::config::GooseMode; use goose::providers::provider_registry::ProviderConstructor; use goose_acp::server::GooseAcpAgent; use goose_test_support::{ExpectedSessionId, McpFixture, FAKE_CODE, TEST_MODEL}; -use sacp::schema::{ - McpServer, McpServerHttp, ModelId, ModelInfo, SessionModelState, ToolCallStatus, -}; +use sacp::schema::{McpServer, McpServerHttp, ModelId, ToolCallStatus}; use std::sync::Arc; pub async fn run_config_mcp() { @@ -122,65 +120,8 @@ pub async fn run_model_list() { expected_session_id.set(session.session_id().0.to_string()); let models = models.unwrap(); - let expected = SessionModelState::new( - ModelId::new(TEST_MODEL), - [ - "gpt-5.2", - "gpt-5.2-2025-12-11", - "gpt-5.2-chat-latest", - "gpt-5.2-codex", - "gpt-5.2-pro", - "gpt-5.2-pro-2025-12-11", - "gpt-5.1", - "gpt-5.1-2025-11-13", - "gpt-5.1-chat-latest", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5-pro", - "gpt-5-pro-2025-10-06", - "gpt-5-codex", - "gpt-5", - "gpt-5-2025-08-07", - "gpt-5-mini", - "gpt-5-mini-2025-08-07", - TEST_MODEL, - "gpt-5-nano-2025-08-07", - "codex-mini-latest", - "o3", - "o3-2025-04-16", - "o4-mini", - "o4-mini-2025-04-16", - "gpt-4.1", - "gpt-4.1-2025-04-14", - "gpt-4.1-mini", - "gpt-4.1-mini-2025-04-14", - "gpt-4.1-nano", - "gpt-4.1-nano-2025-04-14", - "o1-pro", - "o1-pro-2025-03-19", - "o3-mini", - "o3-mini-2025-01-31", - "o1", - "o1-2024-12-17", - "gpt-4o", - "gpt-4o-2024-05-13", - "gpt-4o-2024-08-06", - "gpt-4o-2024-11-20", - "gpt-4o-mini", - "gpt-4o-mini-2024-07-18", - "o4-mini-deep-research", - "o4-mini-deep-research-2025-06-26", - "gpt-4", - "gpt-4-0613", - "gpt-4-turbo", - "gpt-4-turbo-2024-04-09", - ] - .iter() - .map(|id| ModelInfo::new(ModelId::new(*id), *id)) - .collect(), - ); - assert_eq!(models, expected); + assert!(!models.available_models.is_empty()); + assert_eq!(models.current_model_id, ModelId::new(TEST_MODEL)); } pub async fn run_model_set() { @@ -313,7 +254,7 @@ pub async fn run_prompt_basic() { pub async fn run_prompt_codemode() { let expected_session_id = ExpectedSessionId::default(); let prompt = - "Search for getCode and textEditor tools. Use them to save the code to /tmp/result.txt."; + "Search for getCode and write tools. Use them to save the code to /tmp/result.txt."; let mcp = McpFixture::new(Some(expected_session_id.clone())).await; let openai = OpenAiFixture::new( vec![ @@ -326,7 +267,7 @@ pub async fn run_prompt_codemode() { include_str!("../test_data/openai_builtin_execute.txt"), ), ( - r#"Successfully wrote to /tmp/result.txt"#.into(), + r#"Created /tmp/result.txt"#.into(), include_str!("../test_data/openai_builtin_final.txt"), ), ], @@ -352,7 +293,7 @@ pub async fn run_prompt_codemode() { } let result = fs::read_to_string("/tmp/result.txt").unwrap_or_default(); - assert_eq!(result, format!("{FAKE_CODE}\n")); + assert_eq!(result, FAKE_CODE); expected_session_id.assert_matches(&session.session_id().0); } diff --git a/crates/goose-acp/tests/test_data/openai_builtin_execute.txt b/crates/goose-acp/tests/test_data/openai_builtin_execute.txt index 0aebbd29ea03..bf6d35bd7935 100644 --- a/crates/goose-acp/tests/test_data/openai_builtin_execute.txt +++ b/crates/goose-acp/tests/test_data/openai_builtin_execute.txt @@ -322,9 +322,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Developer"}}]},"finish_reason":null}],"usage":null,"obfuscation":"G6t"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"OOxdzNJq"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"."}}]},"finish_reason":null}],"usage":null,"obfuscation":"OOxdzNJq"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Editor"}}]},"finish_reason":null}],"usage":null,"obfuscation":"MiMZRWA"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"MiMZRWA"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"({"}}]},"finish_reason":null}],"usage":null,"obfuscation":"7sQdVn1KZH3"} @@ -354,7 +354,7 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"finish_reason":null}],"usage":null,"obfuscation":"XurvUHlgwc"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" command"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ZsYLy"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" // command"}}]},"finish_reason":null}],"usage":null,"obfuscation":"ZsYLy"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"PFlue8D49Rzx"} @@ -370,9 +370,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" "}}]},"finish_reason":null}],"usage":null,"obfuscation":"xVJI6wFQLA"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" file"}}]},"finish_reason":null}],"usage":null,"obfuscation":"aYkuCMJQ"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" content"}}]},"finish_reason":null}],"usage":null,"obfuscation":"aYkuCMJQ"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"_text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"DQ5IKXUC"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":""}}]},"finish_reason":null}],"usage":null,"obfuscation":"DQ5IKXUC"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":"}}]},"finish_reason":null}],"usage":null,"obfuscation":"YaxTVILdGh6I"} @@ -466,9 +466,9 @@ data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.c data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Developer"}}]},"finish_reason":null}],"usage":null,"obfuscation":"jzRU"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".text"}}]},"finish_reason":null}],"usage":null,"obfuscation":"zeeCDR1q"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"."}}]},"finish_reason":null}],"usage":null,"obfuscation":"zeeCDR1q"} -data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Editor"}}]},"finish_reason":null}],"usage":null,"obfuscation":"8YZ1VtI"} +data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"write"}}]},"finish_reason":null}],"usage":null,"obfuscation":"8YZ1VtI"} data: {"id":"chatcmpl-D64NZp69RkEyXdUoDaCBj7fSYll8J","object":"chat.completion.chunk","created":1770339173,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\",\""}}]},"finish_reason":null}],"usage":null,"obfuscation":"R15EQTSl"} diff --git a/crates/goose-acp/tests/test_data/openai_builtin_search.txt b/crates/goose-acp/tests/test_data/openai_builtin_search.txt index 4220c58ef8ab..7a47e4f4d2bc 100644 --- a/crates/goose-acp/tests/test_data/openai_builtin_search.txt +++ b/crates/goose-acp/tests/test_data/openai_builtin_search.txt @@ -24,11 +24,11 @@ data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.c data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"evelop"}}]},"finish_reason":null}],"obfuscation":"YdjzlvJ"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"er.t"}}]},"finish_reason":null}],"obfuscation":"Kv1vRc0to"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"er.w"}}]},"finish_reason":null}],"obfuscation":"Kv1vRc0to"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"extEd"}}]},"finish_reason":null}],"obfuscation":"4sRF9L7t"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"rite"}}]},"finish_reason":null}],"obfuscation":"4sRF9L7t"} -data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"itor\"]"}}]},"finish_reason":null}],"obfuscation":"SmXF9J"} +data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\"]"}}]},"finish_reason":null}],"obfuscation":"SmXF9J"} data: {"id":"chatcmpl-D64NHpAses8hYgIt8xQfDCmg3PoHQ","object":"chat.completion.chunk","created":1770339155,"model":"gpt-5-nano-2025-08-07","service_tier":"default","system_fingerprint":null,"usage":null,"choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"}"}}]},"finish_reason":null}],"obfuscation":"kO5yFNBeMAXW"} diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index dd7eabdca923..c919c662b683 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -20,8 +20,8 @@ path = "src/bin/generate_manpages.rs" [dependencies] clap_mangen = "0.2.31" -goose = { path = "../goose" } -goose-acp = { path = "../goose-acp" } +goose = { path = "../goose", default-features = false } +goose-acp = { path = "../goose-acp", default-features = false } goose-mcp = { path = "../goose-mcp" } rmcp = { workspace = true } clap = { workspace = true } @@ -70,6 +70,9 @@ comfy-table = "7.2.2" winapi = { version = "0.3", features = ["wincred"] } [features] +default = ["code-mode"] +code-mode = ["goose/code-mode", "goose-acp/code-mode"] +cuda = ["goose/cuda"] # disables the update command disable-update = [] diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index d1a9bf54d548..2f56562ac9d2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -6,9 +6,7 @@ use goose::config::Config; use goose::posthog::get_telemetry_choice; use goose::recipe::Recipe; use goose_mcp::mcp_server_runner::{serve, McpCommand}; -use goose_mcp::{ - AutoVisualiserRouter, ComputerControllerServer, DeveloperServer, MemoryServer, TutorialServer, -}; +use goose_mcp::{AutoVisualiserRouter, ComputerControllerServer, MemoryServer, TutorialServer}; use crate::commands::configure::{configure_telemetry_consent_dialog, handle_configure}; use crate::commands::info::handle_info; @@ -1060,7 +1058,6 @@ async fn handle_mcp_command(server: McpCommand) -> Result<()> { McpCommand::ComputerController => serve(ComputerControllerServer::new()).await?, McpCommand::Memory => serve(MemoryServer::new()).await?, McpCommand::Tutorial => serve(TutorialServer::new()).await?, - McpCommand::Developer => serve(DeveloperServer::new()).await?, } Ok(()) } diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index e4fa7d980f36..8b16cd46fa61 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -1,7 +1,7 @@ use crate::recipes::github_recipe::GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY; use cliclack::spinner; use console::style; -use goose::agents::extension::ToolInfo; +use goose::agents::extension::{ToolInfo, PLATFORM_EXTENSIONS}; use goose::agents::extension_manager::get_parameter_names; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; @@ -22,6 +22,7 @@ use goose::config::{ use goose::model::ModelConfig; use goose::posthog::{get_telemetry_choice, TELEMETRY_ENABLED_KEY}; use goose::providers::base::ConfigKey; +use goose::providers::formats::anthropic::supports_adaptive_thinking; use goose::providers::provider_test::test_provider_configuration; use goose::providers::{create, providers, retry_operation, RetryConfig}; use goose::session::SessionType; @@ -761,6 +762,53 @@ pub async fn configure_provider_dialog() -> anyhow::Result { config.set_gemini3_thinking_level(thinking_level)?; } + if model.to_lowercase().starts_with("claude-") { + let supports_adaptive = supports_adaptive_thinking(&model); + + let mut thinking_select = cliclack::select("Select extended thinking mode for Claude:"); + if supports_adaptive { + thinking_select = thinking_select.item( + "adaptive", + "Adaptive - Claude decides when and how much to think (recommended)", + "", + ); + } + thinking_select = thinking_select + .item("enabled", "Enabled - Fixed token budget for thinking", "") + .item("disabled", "Disabled - No extended thinking", ""); + if supports_adaptive { + thinking_select = thinking_select.initial_value("adaptive"); + } else { + thinking_select = thinking_select.initial_value("disabled"); + } + let thinking_type: &str = thinking_select.interact()?; + config.set_claude_thinking_type(thinking_type)?; + + if thinking_type == "adaptive" { + let effort: &str = cliclack::select("Select adaptive thinking effort level:") + .item("low", "Low - Minimal thinking, fastest responses", "") + .item("medium", "Medium - Moderate thinking", "") + .item("high", "High - Deep reasoning (default)", "") + .item( + "max", + "Max - No constraints on thinking depth (Opus 4.6 only)", + "", + ) + .initial_value("high") + .interact()?; + config.set_claude_thinking_effort(effort)?; + } else if thinking_type == "enabled" { + let budget: String = cliclack::input("Enter thinking budget (tokens):") + .default_input("16000") + .validate(|input: &String| match input.parse::() { + Ok(n) if n > 0 => Ok(()), + _ => Err("Please enter a valid positive number"), + }) + .interact()?; + config.set_claude_thinking_budget(budget.parse::()?)?; + } + } + // Test the configuration let spin = spinner(); spin.start("Checking your configuration..."); @@ -983,24 +1031,35 @@ fn configure_builtin_extension() -> anyhow::Result<()> { select = select.item(id, name, desc); } let extension = select.interact()?.to_string(); - let timeout = prompt_extension_timeout()?; - let (display_name, description) = extensions .iter() .find(|(id, _, _)| id == &extension) .map(|(_, name, desc)| (name.to_string(), desc.to_string())) .unwrap_or_else(|| (extension.clone(), extension.clone())); - set_extension(ExtensionEntry { - enabled: true, - config: ExtensionConfig::Builtin { + let config = if PLATFORM_EXTENSIONS.contains_key(extension.as_str()) { + ExtensionConfig::Platform { + name: extension.clone(), + description, + display_name: Some(display_name), + bundled: Some(true), + available_tools: Vec::new(), + } + } else { + let timeout = prompt_extension_timeout()?; + ExtensionConfig::Builtin { name: extension.clone(), display_name: Some(display_name), timeout: Some(timeout), bundled: Some(true), description, available_tools: Vec::new(), - }, + } + }; + + set_extension(ExtensionEntry { + enabled: true, + config, }); cliclack::outro(format!("Enabled {} extension", style(extension).green()))?; @@ -1741,12 +1800,11 @@ pub async fn handle_openrouter_auth() -> anyhow::Result<()> { if !has_developer { set_extension(ExtensionEntry { enabled: true, - config: ExtensionConfig::Builtin { + config: ExtensionConfig::Platform { name: "developer".to_string(), + description: "Developer extension".to_string(), display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), - timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), - description: "Developer extension".to_string(), available_tools: Vec::new(), }, }); @@ -1811,12 +1869,11 @@ pub async fn handle_tetrate_auth() -> anyhow::Result<()> { if !has_developer { set_extension(ExtensionEntry { enabled: true, - config: ExtensionConfig::Builtin { + config: ExtensionConfig::Platform { name: "developer".to_string(), + description: "Developer extension".to_string(), display_name: Some(goose::config::DEFAULT_DISPLAY_NAME.to_string()), - timeout: Some(goose::config::DEFAULT_EXTENSION_TIMEOUT), bundled: Some(true), - description: "Developer extension".to_string(), available_tools: Vec::new(), }, }); @@ -1970,6 +2027,7 @@ fn add_provider() -> anyhow::Result<()> { headers, requires_auth, catalog_provider_id: None, + base_path: None, })?; cliclack::outro(format!("Custom provider added: {}", display_name))?; diff --git a/crates/goose-cli/src/logging.rs b/crates/goose-cli/src/logging.rs index c9763408af12..c22db9ef6d3b 100644 --- a/crates/goose-cli/src/logging.rs +++ b/crates/goose-cli/src/logging.rs @@ -12,6 +12,15 @@ use goose::tracing::langfuse_layer; // Used to ensure we only set up tracing once static INIT: Once = Once::new(); +fn default_env_filter() -> EnvFilter { + EnvFilter::new("") + // Keep goose and MCP logs visible without verbose debug payloads. + .add_directive("mcp_client=info".parse().unwrap()) + .add_directive("goose=info".parse().unwrap()) + .add_directive("goose_cli=info".parse().unwrap()) + .add_directive(LevelFilter::WARN.into()) +} + /// Sets up the logging infrastructure for the application. /// This includes: /// - File-based logging with JSON formatting (DEBUG level) @@ -49,18 +58,8 @@ fn setup_logging_internal(name: Option<&str>, force: bool) -> Result<()> { .json(); // Base filter - let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { - // Set default levels for different modules - EnvFilter::new("") - // Set mcp-client to DEBUG - .add_directive("mcp_client=debug".parse().unwrap()) - // Set goose module to DEBUG - .add_directive("goose=debug".parse().unwrap()) - // Set goose-cli to INFO - .add_directive("goose_cli=info".parse().unwrap()) - // Set everything else to WARN - .add_directive(LevelFilter::WARN.into()) - }); + let env_filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| default_env_filter()); // Start building the subscriber let mut layers = vec![ @@ -188,4 +187,14 @@ mod tests { } } } + + #[test] + fn test_default_filter_avoids_debug_by_default() { + let filter = super::default_env_filter().to_string(); + assert!(!filter.contains("mcp_client=debug")); + assert!(!filter.contains("goose=debug")); + assert!(filter.contains("mcp_client=info")); + assert!(filter.contains("goose=info")); + assert!(filter.contains("goose_cli=info")); + } } diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose-cli/src/session/export.rs index e05daecc21f2..9b525a2730a0 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose-cli/src/session/export.rs @@ -112,6 +112,14 @@ fn value_to_markdown(value: &Value, depth: usize, export_full_strings: bool) -> md_string } +fn is_shell_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "shell") +} + +fn is_developer_file_tool_name(tool_name: &str) -> bool { + matches!(tool_name, "write" | "edit") +} + pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> String { let mut md = String::new(); match &req.tool_call { @@ -119,6 +127,10 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> let parts: Vec<_> = call.name.rsplitn(2, "__").collect(); let (namespace, tool_name_only) = if parts.len() == 2 { (parts[1], parts[0]) + } else if is_shell_tool_name(call.name.as_ref()) + || is_developer_file_tool_name(call.name.as_ref()) + { + ("developer", parts[0]) } else { ("Tool", parts[0]) }; @@ -130,7 +142,7 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> md.push_str("**Arguments:**\n"); match call.name.as_ref() { - "developer__shell" => { + name if is_shell_tool_name(name) => { if let Some(Value::String(command)) = call.arguments.as_ref().and_then(|args| args.get("command")) { @@ -157,39 +169,25 @@ pub fn tool_request_to_markdown(req: &ToolRequest, export_all_content: bool) -> )); } } - "developer__text_editor" => { + name if is_developer_file_tool_name(name) => { if let Some(Value::String(path)) = call.arguments.as_ref().and_then(|args| args.get("path")) { md.push_str(&format!("* **path**: `{}`\n", path)); } - if let Some(Value::String(code_edit)) = call - .arguments - .as_ref() - .and_then(|args| args.get("code_edit")) - { - md.push_str(&format!( - "* **code_edit**:\n ```\n{}\n ```\n", - code_edit - )); - } - let other_args: serde_json::Map = call - .arguments - .as_ref() - .map(|obj| { - obj.iter() - .filter(|(k, _)| k.as_str() != "path" && k.as_str() != "code_edit") - .map(|(k, v)| (k.clone(), v.clone())) - .collect() - }) - .unwrap_or_default(); - if !other_args.is_empty() { - md.push_str(&value_to_markdown( - &Value::Object(other_args), - 0, - export_all_content, - )); + if let Some(args) = &call.arguments { + let mut other_args = args.clone(); + other_args.remove("path"); + if !other_args.is_empty() { + md.push_str(&value_to_markdown( + &Value::Object(other_args), + 0, + export_all_content, + )); + } + } else { + md.push_str("*No arguments*\n"); } } _ => { @@ -529,7 +527,7 @@ mod tests { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "ls -la", "working_dir": "/home/user" @@ -552,14 +550,15 @@ mod tests { } #[test] - fn test_tool_request_to_markdown_text_editor() { + fn test_tool_request_to_markdown_edit() { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__text_editor".into(), + name: "edit".into(), arguments: Some(object!({ "path": "/path/to/file.txt", - "code_edit": "print('Hello World')" + "before": "Hello", + "after": "World" })), }; let tool_request = ToolRequest { @@ -570,10 +569,11 @@ mod tests { }; let result = tool_request_to_markdown(&tool_request, true); - assert!(result.contains("#### Tool Call: `text_editor`")); + assert!(result.contains("#### Tool Call: `edit`")); + assert!(result.contains("namespace: `developer`")); assert!(result.contains("**path**: `/path/to/file.txt`")); - assert!(result.contains("**code_edit**:")); - assert!(result.contains("print('Hello World')")); + assert!(result.contains("**before**")); + assert!(result.contains("**after**")); } #[test] @@ -702,7 +702,7 @@ mod tests { let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cat main.py" })), @@ -758,7 +758,7 @@ if __name__ == "__main__": let git_status_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "git status --porcelain" })), @@ -806,7 +806,7 @@ if __name__ == "__main__": let cargo_build_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cargo build" })), @@ -860,7 +860,7 @@ warning: unused variable `x` let curl_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "curl -s https://api.github.com/repos/microsoft/vscode/releases/latest" })), @@ -912,15 +912,14 @@ warning: unused variable `x` } #[test] - fn test_text_editor_tool_with_code_creation() { + fn test_write_tool_with_code_creation() { let editor_call = CallToolRequestParams { meta: None, task: None, - name: "developer__text_editor".into(), + name: "write".into(), arguments: Some(object!({ - "command": "write", "path": "/tmp/fibonacci.js", - "file_text": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" + "content": "function fibonacci(n) {\n if (n <= 1) return n;\n return fibonacci(n - 1) + fibonacci(n - 2);\n}\n\nconsole.log(fibonacci(10));" })), }; let tool_request = ToolRequest { @@ -951,10 +950,10 @@ warning: unused variable `x` let request_result = tool_request_to_markdown(&tool_request, true); let response_result = tool_response_to_markdown(&tool_response, true); - // Check request formatting - should format code in file_text properly - assert!(request_result.contains("#### Tool Call: `text_editor`")); + // Check request formatting - should format code in content properly + assert!(request_result.contains("#### Tool Call: `write`")); assert!(request_result.contains("**path**: `/tmp/fibonacci.js`")); - assert!(request_result.contains("**file_text**:")); + assert!(request_result.contains("**content**:")); assert!(request_result.contains("function fibonacci(n)")); assert!(request_result.contains("return fibonacci(n - 1)")); @@ -962,72 +961,12 @@ warning: unused variable `x` assert!(response_result.contains("File created successfully")); } - #[test] - fn test_text_editor_tool_view_code() { - let editor_call = CallToolRequestParams { - meta: None, - task: None, - name: "developer__text_editor".into(), - arguments: Some(object!({ - "command": "view", - "path": "/src/utils.py" - })), - }; - let _tool_request = ToolRequest { - id: "editor-view".to_string(), - tool_call: Ok(editor_call), - metadata: None, - tool_meta: None, - }; - - let python_code = r#"import os -import json -from typing import Dict, List, Optional - -def load_config(config_path: str) -> Dict: - """Load configuration from JSON file.""" - if not os.path.exists(config_path): - raise FileNotFoundError(f"Config file not found: {config_path}") - - with open(config_path, 'r') as f: - return json.load(f) - -def process_data(data: List[Dict]) -> List[Dict]: - """Process a list of data dictionaries.""" - return [item for item in data if item.get('active', False)]"#; - - let text_content = TextContent { - raw: RawTextContent { - text: python_code.to_string(), - meta: None, - }, - annotations: None, - }; - let tool_response = ToolResponse { - metadata: None, - id: "editor-view".to_string(), - tool_result: Ok(rmcp::model::CallToolResult { - content: vec![Content::text(text_content.raw.text)], - structured_content: None, - is_error: Some(false), - meta: None, - }), - }; - - let response_result = tool_response_to_markdown(&tool_response, true); - - // Text content is output as plain text - assert!(response_result.contains("import os")); - assert!(response_result.contains("def load_config")); - assert!(response_result.contains("typing import Dict")); - } - #[test] fn test_shell_tool_with_error_output() { let error_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "python nonexistent_script.py" })), @@ -1072,7 +1011,7 @@ Command failed with exit code 2"#; let script_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "python -c \"import sys; print(f'Python {sys.version}'); [print(f'{i}^2 = {i**2}') for i in range(1, 6)]\"" })), @@ -1128,7 +1067,7 @@ Command failed with exit code 2"#; let multi_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "cd /tmp && ls -la | head -5 && pwd" })), @@ -1182,7 +1121,7 @@ drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc let grep_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "rg 'async fn' --type rust -n" })), @@ -1235,7 +1174,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul let tool_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "echo '{\"test\": \"json\"}'" })), @@ -1279,7 +1218,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul let npm_call = CallToolRequestParams { meta: None, task: None, - name: "developer__shell".into(), + name: "shell".into(), arguments: Some(object!({ "command": "npm install express typescript @types/node --save-dev" })), diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index c458744b092f..c4801c4182ca 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -485,8 +485,8 @@ fn render_thinking_streaming( fn render_tool_request(req: &ToolRequest, theme: Theme, debug: bool) { match &req.tool_call { Ok(call) => match call.name.to_string().as_str() { - "developer__text_editor" => render_text_editor_request(call, debug), - "developer__shell" => render_shell_request(call, debug), + name if is_shell_tool_name(name) => render_shell_request(call, debug), + name if is_file_tool_name(name) => render_text_editor_request(call, debug), "execute" | "execute_code" => render_execute_code_request(call, debug), "delegate" => render_delegate_request(call, debug), "subagent" => render_delegate_request(call, debug), @@ -534,6 +534,14 @@ fn render_tool_response(resp: &ToolResponse, theme: Theme, debug: bool) { } } +fn is_shell_tool_name(name: &str) -> bool { + matches!(name, "shell") +} + +fn is_file_tool_name(name: &str) -> bool { + matches!(name, "write" | "edit") +} + pub fn render_error(message: &str) { println!("\n {} {}\n", style("error:").red().bold(), message); } diff --git a/crates/goose-cli/static/script.js b/crates/goose-cli/static/script.js index cab2afc4b2b2..ba0504ebdd21 100644 --- a/crates/goose-cli/static/script.js +++ b/crates/goose-cli/static/script.js @@ -269,11 +269,18 @@ function handleToolRequest(data) { const contentDiv = document.createElement('div'); contentDiv.className = 'tool-content'; + const isShellTool = data.tool_name === 'shell'; + const isDeveloperFileTool = [ + 'read', + 'write', + 'edit' + ].includes(data.tool_name); + // Format the arguments - if (data.tool_name === 'developer__shell' && data.arguments.command) { + if (isShellTool && data.arguments.command) { contentDiv.innerHTML = `
${escapeHtml(data.arguments.command)}
`; - } else if (data.tool_name === 'developer__text_editor') { - const action = data.arguments.command || 'unknown'; + } else if (isDeveloperFileTool) { + const action = data.arguments.command || data.tool_name; const path = data.arguments.path || 'unknown'; contentDiv.innerHTML = `
action: ${action}
`; contentDiv.innerHTML += `
path: ${escapeHtml(path)}
`; diff --git a/crates/goose-mcp/src/developer/analyze/cache.rs b/crates/goose-mcp/src/developer/analyze/cache.rs deleted file mode 100644 index 3c855ce4f825..000000000000 --- a/crates/goose-mcp/src/developer/analyze/cache.rs +++ /dev/null @@ -1,100 +0,0 @@ -use lru::LruCache; -use std::num::NonZeroUsize; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use std::time::SystemTime; - -use super::lock_or_recover; -use crate::developer::analyze::types::{AnalysisMode, AnalysisResult}; - -#[derive(Clone)] -pub struct AnalysisCache { - cache: Arc>>>, - #[allow(dead_code)] - max_size: usize, -} - -#[derive(Hash, Eq, PartialEq, Debug, Clone)] -struct CacheKey { - path: PathBuf, - modified: SystemTime, - mode: AnalysisMode, -} - -impl AnalysisCache { - pub fn new(max_size: usize) -> Self { - tracing::info!("Initializing analysis cache with size {}", max_size); - - let size = NonZeroUsize::new(max_size).unwrap_or_else(|| { - tracing::warn!("Invalid cache size {}, using default 100", max_size); - NonZeroUsize::new(100).unwrap() - }); - - Self { - cache: Arc::new(Mutex::new(LruCache::new(size))), - max_size, - } - } - - pub fn get( - &self, - path: &PathBuf, - modified: SystemTime, - mode: &AnalysisMode, - ) -> Option { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - let key = CacheKey { - path: path.clone(), - modified, - mode: *mode, - }; - - if let Some(result) = cache.get(&key) { - tracing::trace!("Cache hit for {:?} in {:?} mode", path, mode); - Some((**result).clone()) - } else { - tracing::trace!("Cache miss for {:?} in {:?} mode", path, mode); - None - } - } - - pub fn put( - &self, - path: PathBuf, - modified: SystemTime, - mode: &AnalysisMode, - result: AnalysisResult, - ) { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - let key = CacheKey { - path: path.clone(), - modified, - mode: *mode, - }; - - tracing::trace!("Caching result for {:?} in {:?} mode", path, mode); - cache.put(key, Arc::new(result)); - } - - pub fn clear(&self) { - let mut cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.clear(); - tracing::debug!("Cache cleared"); - } - - pub fn len(&self) -> usize { - let cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.len() - } - - pub fn is_empty(&self) -> bool { - let cache = lock_or_recover(&self.cache, |c| c.clear()); - cache.is_empty() - } -} - -impl Default for AnalysisCache { - fn default() -> Self { - Self::new(100) - } -} diff --git a/crates/goose-mcp/src/developer/analyze/formatter.rs b/crates/goose-mcp/src/developer/analyze/formatter.rs deleted file mode 100644 index 90529a06c702..000000000000 --- a/crates/goose-mcp/src/developer/analyze/formatter.rs +++ /dev/null @@ -1,753 +0,0 @@ -use crate::developer::analyze::types::{ - AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData, -}; -use crate::developer::lang; -use rmcp::model::{Content, Role}; -use std::collections::{HashMap, HashSet}; -use std::path::{Path, PathBuf}; - -fn safe_truncate(s: &str, max_chars: usize) -> String { - if s.chars().count() <= max_chars { - s.to_string() - } else { - let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect(); - format!("{}...", truncated) - } -} - -pub struct Formatter; - -impl Formatter { - pub fn format_results(output: String) -> Vec { - vec![ - Content::text(output.clone()).with_audience(vec![Role::Assistant]), - Content::text(output) - .with_audience(vec![Role::User]) - .with_priority(0.0), - ] - } - - /// Format analysis result based on mode - pub fn format_analysis_result( - path: &Path, - result: &AnalysisResult, - mode: &AnalysisMode, - ) -> String { - tracing::debug!("Formatting result for {:?} in {:?} mode", path, mode); - - match mode { - AnalysisMode::Structure => Self::format_structure_overview(path, result), - AnalysisMode::Semantic => Self::format_semantic_result(path, result), - AnalysisMode::Focused => { - // Focused mode is handled separately - tracing::warn!("format_analysis_result called with Focused mode"); - String::new() - } - } - } - - /// Format structure overview (compact format) - pub fn format_structure_overview(path: &Path, result: &AnalysisResult) -> String { - let mut output = String::new(); - - // Format as: path [LOC, FUNCTIONS, CLASSES] - output.push_str(&format!("{} [{}L", path.display(), result.line_count)); - - if result.function_count > 0 { - output.push_str(&format!(", {}F", result.function_count)); - } - - if result.class_count > 0 { - output.push_str(&format!(", {}C", result.class_count)); - } - - output.push(']'); - - // Add FLAGS if any - if let Some(main_line) = result.main_line { - output.push_str(&format!(" main:{}", main_line)); - } - - output.push('\n'); - output - } - - /// Format semantic analysis result (dense matrix format) - pub fn format_semantic_result(path: &Path, result: &AnalysisResult) -> String { - let mut output = format!( - "FILE: {} [{}L, {}F, {}C]\n\n", - path.display(), - result.line_count, - result.function_count, - result.class_count - ); - - // Classes on single/multiple lines with colon-separated line numbers - if !result.classes.is_empty() { - output.push_str("C: "); - let class_strs: Vec = result - .classes - .iter() - .map(|c| format!("{}:{}", c.name, c.line)) - .collect(); - output.push_str(&class_strs.join(" ")); - output.push_str("\n\n"); - } - - // Functions with call counts where significant - if !result.functions.is_empty() { - output.push_str("F: "); - - // Count how many times each function is called - let mut call_counts: HashMap = HashMap::new(); - for call in &result.calls { - *call_counts.entry(call.callee_name.clone()).or_insert(0) += 1; - } - - let func_strs: Vec = result - .functions - .iter() - .map(|f| { - let count = call_counts.get(&f.name).unwrap_or(&0); - if *count > 3 { - format!("{}:{}β€’{}", f.name, f.line, count) - } else { - format!("{}:{}", f.name, f.line) - } - }) - .collect(); - - // Format functions, wrapping at reasonable line length - let mut line_len = 3; // "F: " - for (i, func_str) in func_strs.iter().enumerate() { - if i > 0 && line_len + func_str.len() + 1 > 100 { - output.push_str("\n "); - line_len = 3; - } - if i > 0 { - output.push(' '); - line_len += 1; - } - output.push_str(func_str); - line_len += func_str.len(); - } - output.push_str("\n\n"); - } - - // Condensed imports - if !result.imports.is_empty() { - output.push_str("I: "); - - // Group imports by module/package - let mut grouped_imports: HashMap> = HashMap::new(); - for import in &result.imports { - // Simple heuristic: first word/module is the group - let group = if import.starts_with("use ") { - import.split("::").next().unwrap_or("use").to_string() - } else if import.starts_with("import ") { - import - .split_whitespace() - .nth(1) - .unwrap_or("import") - .to_string() - } else if import.starts_with("from ") { - import - .split_whitespace() - .nth(1) - .unwrap_or("from") - .to_string() - } else { - import.split_whitespace().next().unwrap_or("").to_string() - }; - grouped_imports - .entry(group) - .or_default() - .push(import.clone()); - } - - // Show condensed import summary - let import_summary: Vec = grouped_imports - .iter() - .map(|(group, imports)| { - if imports.len() > 1 { - format!("{}({})", group, imports.len()) - } else { - safe_truncate(&imports[0], 40) - } - }) - .collect(); - - output.push_str(&import_summary.join("; ")); - output.push('\n'); - } - - // References (type tracking) - only show if present - if !result.references.is_empty() { - Self::append_references(&mut output, result); - } - - output - } - - /// Append reference tracking information (method-to-type associations, type usage) - fn append_references(output: &mut String, result: &AnalysisResult) { - use crate::developer::analyze::types::ReferenceType; - - // Group references by type - let mut method_defs = Vec::new(); - let mut type_inst = Vec::new(); - let mut field_types = Vec::new(); - let mut var_types = Vec::new(); - let mut param_types = Vec::new(); - - for ref_info in &result.references { - match ref_info.ref_type { - ReferenceType::MethodDefinition => method_defs.push(ref_info), - ReferenceType::TypeInstantiation => type_inst.push(ref_info), - ReferenceType::FieldType => field_types.push(ref_info), - ReferenceType::VariableType => var_types.push(ref_info), - ReferenceType::ParameterType => param_types.push(ref_info), - ReferenceType::Call | ReferenceType::Definition | ReferenceType::Import => {} - } - } - - // Only show section if we have non-call references - if method_defs.is_empty() - && type_inst.is_empty() - && field_types.is_empty() - && var_types.is_empty() - && param_types.is_empty() - { - return; - } - - output.push_str("\nR: "); - - let mut sections = Vec::new(); - - // Method definitions (methods associated with types) - if !method_defs.is_empty() { - let mut method_strs: Vec = method_defs - .iter() - .map(|r| { - if let Some(type_name) = &r.associated_type { - format!("{}({})", r.symbol, type_name) - } else { - r.symbol.clone() - } - }) - .collect(); - method_strs.sort(); - method_strs.dedup(); - sections.push(format!("methods[{}]", method_strs.join(" "))); - } - - // Type instantiations (struct literals) - if !type_inst.is_empty() { - let mut type_names: Vec = type_inst.iter().map(|r| r.symbol.clone()).collect(); - type_names.sort(); - type_names.dedup(); - sections.push(format!("types[{}]", type_names.join(" "))); - } - - // Field types (only show unique types, not all occurrences) - if !field_types.is_empty() { - let mut field_type_names: Vec = - field_types.iter().map(|r| r.symbol.clone()).collect(); - field_type_names.sort(); - field_type_names.dedup(); - sections.push(format!("fields[{}]", field_type_names.join(" "))); - } - - // Variable types (only show unique types) - if !var_types.is_empty() { - let mut var_type_names: Vec = - var_types.iter().map(|r| r.symbol.clone()).collect(); - var_type_names.sort(); - var_type_names.dedup(); - sections.push(format!("vars[{}]", var_type_names.join(" "))); - } - - // Parameter types (only show unique types) - if !param_types.is_empty() { - let mut param_type_names: Vec = - param_types.iter().map(|r| r.symbol.clone()).collect(); - param_type_names.sort(); - param_type_names.dedup(); - sections.push(format!("params[{}]", param_type_names.join(" "))); - } - - output.push_str(§ions.join("; ")); - output.push('\n'); - } - - /// Format directory structure with summary - pub fn format_directory_structure( - base_path: &Path, - results: &[(PathBuf, EntryType)], - max_depth: u32, - ) -> String { - let mut output = String::new(); - - // Add summary section - Self::append_summary(&mut output, results, max_depth); - - output.push_str("\nPATH [LOC, FUNCTIONS, CLASSES] \n"); - - // Add tree structure - Self::append_tree_structure(&mut output, base_path, results); - - output - } - - /// Append summary section with statistics - fn append_summary(output: &mut String, results: &[(PathBuf, EntryType)], max_depth: u32) { - // Calculate totals (only from files) - let files: Vec<&AnalysisResult> = results - .iter() - .filter_map(|(_, entry)| match entry { - EntryType::File(result) => Some(result), - _ => None, - }) - .collect(); - - let total_files = files.len(); - let total_lines: usize = files.iter().map(|r| r.line_count).sum(); - let total_functions: usize = files.iter().map(|r| r.function_count).sum(); - let total_classes: usize = files.iter().map(|r| r.class_count).sum(); - - // Format summary with depth indicator - output.push_str("SUMMARY:\n"); - if max_depth == 0 { - output.push_str(&format!( - "Shown: {} files, {}L, {}F, {}C (unlimited depth)\n", - total_files, total_lines, total_functions, total_classes - )); - } else { - output.push_str(&format!( - "Shown: {} files, {}L, {}F, {}C (max_depth={})\n", - total_files, total_lines, total_functions, total_classes, max_depth - )); - } - - // Add language distribution - Self::append_language_stats(output, results, total_lines); - } - - /// Append language statistics - fn append_language_stats( - output: &mut String, - results: &[(PathBuf, EntryType)], - total_lines: usize, - ) { - // Calculate language distribution - let mut language_lines: HashMap = HashMap::new(); - for (path, entry) in results { - if let EntryType::File(result) = entry { - let lang = lang::get_language_identifier(path); - if !lang.is_empty() && result.line_count > 0 { - *language_lines.entry(lang.to_string()).or_insert(0) += result.line_count; - } - } - } - - // Format language percentages - if !language_lines.is_empty() && total_lines > 0 { - let mut languages: Vec<_> = language_lines.iter().collect(); - languages.sort_by(|a, b| b.1.cmp(a.1)); // Sort by lines descending - - let lang_str: Vec = languages - .iter() - .map(|(lang, lines)| { - let percentage = (**lines as f64 / total_lines as f64 * 100.0) as u32; - format!("{} ({}%)", lang, percentage) - }) - .collect(); - - output.push_str(&format!("Languages: {}\n", lang_str.join(", "))); - } - } - - /// Append tree structure for directory contents - fn append_tree_structure( - output: &mut String, - base_path: &Path, - results: &[(PathBuf, EntryType)], - ) { - // Sort results by path for consistent output - let mut sorted_results = results.to_vec(); - sorted_results.sort_by(|a, b| a.0.cmp(&b.0)); - - // Track which directories we've already printed to avoid duplicates - let mut printed_dirs = HashSet::new(); - - // Format each entry with tree-style indentation - for (path, entry) in sorted_results { - Self::format_tree_entry(output, base_path, &path, &entry, &mut printed_dirs); - } - } - - /// Format a single tree entry - fn format_tree_entry( - output: &mut String, - base_path: &Path, - path: &Path, - entry: &EntryType, - printed_dirs: &mut HashSet, - ) { - // Make path relative to base_path - let relative_path = path.strip_prefix(base_path).unwrap_or(path); - - // Get path components for determining structure - let components: Vec<_> = relative_path.components().collect(); - if components.is_empty() { - return; - } - - // Print parent directories if not already printed - for i in 0..components.len().saturating_sub(1) { - let parent_path: PathBuf = components[..=i].iter().collect(); - if !printed_dirs.contains(&parent_path) { - let indent = " ".repeat(i); - let dir_name = components[i].as_os_str().to_string_lossy(); - output.push_str(&format!("{}{}/\n", indent, dir_name)); - printed_dirs.insert(parent_path); - } - } - - // Determine indentation level for this entry - let indent_level = components.len().saturating_sub(1); - let indent = " ".repeat(indent_level); - - // Get the file/directory name (last component) - let name = components - .last() - .map(|c| c.as_os_str().to_string_lossy().to_string()) - .unwrap_or_else(|| relative_path.display().to_string()); - - // Format based on entry type - Self::format_entry_line( - output, - &indent, - &name, - entry, - base_path, - relative_path, - printed_dirs, - ); - } - - /// Format the line for a specific entry type - fn format_entry_line( - output: &mut String, - indent: &str, - name: &str, - entry: &EntryType, - base_path: &Path, - relative_path: &Path, - printed_dirs: &mut HashSet, - ) { - match entry { - EntryType::File(result) => { - output.push_str(&format!("{}{} [{}L", indent, name, result.line_count)); - if result.function_count > 0 { - output.push_str(&format!(", {}F", result.function_count)); - } - if result.class_count > 0 { - output.push_str(&format!(", {}C", result.class_count)); - } - output.push(']'); - if let Some(main_line) = result.main_line { - output.push_str(&format!(" main:{}", main_line)); - } - output.push('\n'); - } - EntryType::Directory => { - // Only print if not already printed as a parent - if !printed_dirs.contains(relative_path) { - output.push_str(&format!("{}{}/\n", indent, name)); - printed_dirs.insert(relative_path.to_path_buf()); - } - } - EntryType::SymlinkDir(target) | EntryType::SymlinkFile(target) => { - let is_dir = matches!(entry, EntryType::SymlinkDir(_)); - let target_display = if target.is_relative() { - target.display().to_string() - } else if let Ok(rel) = target.strip_prefix(base_path) { - rel.display().to_string() - } else { - target.display().to_string() - }; - let suffix = if is_dir { "/" } else { "" }; - output.push_str(&format!( - "{}{}{} -> {}\n", - indent, name, suffix, target_display - )); - } - } - } - - /// Format focused analysis output with call chains - pub fn format_focused_output(focus_data: &FocusedAnalysisData) -> String { - let mut output = format!("FOCUSED ANALYSIS: {}\n\n", focus_data.focus_symbol); - - // Build file alias mapping - let (file_map, sorted_files) = Self::build_file_aliases( - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - ); - - // Section 1: Definitions - Self::append_definitions( - &mut output, - focus_data.definitions, - &file_map, - focus_data.focus_symbol, - ); - - // Section 2: Incoming Call Chains - Self::append_call_chains( - &mut output, - focus_data.incoming_chains, - &file_map, - focus_data.follow_depth, - true, - ); - - // Section 3: Outgoing Call Chains - Self::append_call_chains( - &mut output, - focus_data.outgoing_chains, - &file_map, - focus_data.follow_depth, - false, - ); - - // Section 4: Summary Statistics - Self::append_statistics( - &mut output, - focus_data.files_analyzed, - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - focus_data.follow_depth, - ); - - // Section 5: File Legend - Self::append_file_legend( - &mut output, - &file_map, - &sorted_files, - focus_data.definitions, - focus_data.incoming_chains, - focus_data.outgoing_chains, - ); - - if focus_data.definitions.is_empty() - && focus_data.incoming_chains.is_empty() - && focus_data.outgoing_chains.is_empty() - { - output = format!( - "Symbol '{}' not found in any analyzed files.\n", - focus_data.focus_symbol - ); - } - - output - } - - /// Build file alias mapping for focused output - fn build_file_aliases( - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - ) -> (HashMap, Vec) { - let mut all_files = HashSet::new(); - - for (file, _) in definitions { - all_files.insert(file.clone()); - } - - for chain in incoming_chains.iter().chain(outgoing_chains.iter()) { - for (file, _, _, _) in &chain.path { - all_files.insert(file.clone()); - } - } - - let mut sorted_files: Vec<_> = all_files.into_iter().collect(); - sorted_files.sort(); - - let mut file_map = HashMap::new(); - for (index, file) in sorted_files.iter().enumerate() { - let alias = if sorted_files.len() == 1 { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - } else { - format!("F{}", index + 1) - }; - file_map.insert(file.clone(), alias); - } - - (file_map, sorted_files) - } - - /// Append definitions section to output - fn append_definitions( - output: &mut String, - definitions: &[(PathBuf, usize)], - file_map: &HashMap, - focus_symbol: &str, - ) { - if !definitions.is_empty() { - output.push_str("DEFINITIONS:\n"); - for (file, line) in definitions { - let alias = file_map.get(file).cloned().unwrap_or_else(|| { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - }); - output.push_str(&format!("{}:{} - {}\n", alias, line, focus_symbol)); - } - output.push('\n'); - } - } - - /// Append call chains section to output - fn append_call_chains( - output: &mut String, - chains: &[CallChain], - file_map: &HashMap, - follow_depth: u32, - is_incoming: bool, - ) { - if !chains.is_empty() { - let chain_type = if is_incoming { "INCOMING" } else { "OUTGOING" }; - output.push_str(&format!( - "{} CALL CHAINS (depth={}):\n", - chain_type, follow_depth - )); - - let mut unique_chains = HashSet::new(); - for chain in chains { - let chain_str = Self::format_chain_path(&chain.path, file_map); - unique_chains.insert(chain_str); - } - - let mut sorted_chains: Vec<_> = unique_chains.into_iter().collect(); - sorted_chains.sort(); - - for chain in sorted_chains { - output.push_str(&format!("{}\n", chain)); - } - output.push('\n'); - } - } - - /// Format a single chain path - fn format_chain_path( - path: &[(PathBuf, usize, String, String)], - file_map: &HashMap, - ) -> String { - path.iter() - .map(|(file, line, from, to)| { - let alias = file_map.get(file).cloned().unwrap_or_else(|| { - file.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string() - }); - format!("{}:{} ({} -> {})", alias, line, from, to) - }) - .collect::>() - .join(" -> ") - } - - /// Append statistics section to output - fn append_statistics( - output: &mut String, - files_analyzed: &[PathBuf], - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - follow_depth: u32, - ) { - output.push_str("STATISTICS:\n"); - output.push_str(&format!(" Files analyzed: {}\n", files_analyzed.len())); - output.push_str(&format!(" Definitions found: {}\n", definitions.len())); - output.push_str(&format!(" Incoming chains: {}\n", incoming_chains.len())); - output.push_str(&format!(" Outgoing chains: {}\n", outgoing_chains.len())); - output.push_str(&format!(" Follow depth: {}\n", follow_depth)); - } - - /// Append file legend section to output - fn append_file_legend( - output: &mut String, - file_map: &HashMap, - sorted_files: &[PathBuf], - definitions: &[(PathBuf, usize)], - incoming_chains: &[CallChain], - outgoing_chains: &[CallChain], - ) { - if !file_map.is_empty() - && (sorted_files.len() > 1 - || !incoming_chains.is_empty() - || !outgoing_chains.is_empty() - || !definitions.is_empty()) - { - output.push_str("\nFILES:\n"); - let mut legend_entries: Vec<_> = file_map.iter().collect(); - legend_entries.sort_by_key(|(_, alias)| alias.as_str()); - - for (file_path, alias) in legend_entries { - if sorted_files.len() == 1 - && alias == file_path.file_name().and_then(|n| n.to_str()).unwrap_or("") - { - continue; - } - output.push_str(&format!(" {}: {}\n", alias, file_path.display())); - } - } - } - - /// Filter output by focus symbol - pub fn filter_by_focus(output: &str, focus: &str) -> String { - let mut filtered = String::new(); - let mut include_section = false; - - for line in output.lines() { - if line.starts_with("##") { - include_section = false; - } - - if line.contains(focus) { - include_section = true; - // Include the file header - if let Some(header_line) = output - .lines() - .rev() - .find(|l| l.starts_with("##") && l.get(3..).is_some_and(|s| line.contains(s))) - { - if !filtered.contains(header_line) { - filtered.push_str(header_line); - filtered.push('\n'); - } - } - } - - if include_section || line.starts_with('#') { - filtered.push_str(line); - filtered.push('\n'); - } - } - - if filtered.is_empty() { - format!("No results found for symbol: {}", focus) - } else { - filtered - } - } -} diff --git a/crates/goose-mcp/src/developer/analyze/graph.rs b/crates/goose-mcp/src/developer/analyze/graph.rs deleted file mode 100644 index d87c72fd1d43..000000000000 --- a/crates/goose-mcp/src/developer/analyze/graph.rs +++ /dev/null @@ -1,245 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::PathBuf; - -use crate::developer::analyze::types::{AnalysisResult, CallChain}; - -/// Sentinel value used to represent type references (instantiation, field types, etc.) -/// as callers in the call graph, since they don't have an actual caller function. -const REFERENCE_CALLER: &str = ""; - -#[derive(Debug, Clone, Default)] -pub struct CallGraph { - callers: HashMap>, - callees: HashMap>, - pub definitions: HashMap>, -} - -impl CallGraph { - pub fn new() -> Self { - Self::default() - } - - pub fn build_from_results(results: &[(PathBuf, AnalysisResult)]) -> Self { - tracing::debug!("Building call graph from {} files", results.len()); - let mut graph = Self::new(); - - for (file_path, result) in results { - // Record definitions - for func in &result.functions { - graph - .definitions - .entry(func.name.clone()) - .or_default() - .push((file_path.clone(), func.line)); - } - - for class in &result.classes { - graph - .definitions - .entry(class.name.clone()) - .or_default() - .push((file_path.clone(), class.line)); - } - - // Record call relationships - for call in &result.calls { - let caller = call - .caller_name - .clone() - .unwrap_or_else(|| "".to_string()); - - // Add to callers map (who calls this function) - graph - .callers - .entry(call.callee_name.clone()) - .or_default() - .push((file_path.clone(), call.line, caller.clone())); - - // Add to callees map (what this function calls) - if caller != "" { - graph.callees.entry(caller).or_default().push(( - file_path.clone(), - call.line, - call.callee_name.clone(), - )); - } - } - - for reference in &result.references { - use crate::developer::analyze::types::ReferenceType; - - match &reference.ref_type { - ReferenceType::MethodDefinition => { - if let Some(type_name) = &reference.associated_type { - tracing::trace!( - "Linking method {} to type {}", - reference.symbol, - type_name - ); - graph.callees.entry(type_name.clone()).or_default().push(( - file_path.clone(), - reference.line, - reference.symbol.clone(), - )); - } - } - ReferenceType::TypeInstantiation - | ReferenceType::FieldType - | ReferenceType::VariableType - | ReferenceType::ParameterType => { - graph - .callers - .entry(reference.symbol.clone()) - .or_default() - .push(( - file_path.clone(), - reference.line, - REFERENCE_CALLER.to_string(), - )); - } - ReferenceType::Definition | ReferenceType::Call | ReferenceType::Import => { - // These are handled elsewhere or not relevant for type tracking - } - } - } - } - - tracing::trace!( - "Graph built: {} definitions, {} caller entries, {} callee entries", - graph.definitions.len(), - graph.callers.len(), - graph.callees.len() - ); - - graph - } - - pub fn find_incoming_chains(&self, symbol: &str, max_depth: u32) -> Vec { - tracing::trace!( - "Finding incoming chains for {} with depth {}", - symbol, - max_depth - ); - - if max_depth == 0 { - return vec![]; - } - - let mut chains = Vec::new(); - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - - // Start with direct callers - if let Some(direct_callers) = self.callers.get(symbol) { - for (file, line, caller) in direct_callers { - let initial_path = vec![(file.clone(), *line, caller.clone(), symbol.to_string())]; - - if max_depth == 1 { - chains.push(CallChain { path: initial_path }); - } else { - queue.push_back((caller.clone(), initial_path, 1)); - } - } - } - - // BFS to find deeper chains - while let Some((current_symbol, path, depth)) = queue.pop_front() { - if depth >= max_depth { - chains.push(CallChain { path }); - continue; - } - - // Avoid cycles - if visited.contains(¤t_symbol) { - chains.push(CallChain { path }); // Still record the path we found - continue; - } - visited.insert(current_symbol.clone()); - - // Find who calls the current symbol - if let Some(callers) = self.callers.get(¤t_symbol) { - for (file, line, caller) in callers { - let mut new_path = - vec![(file.clone(), *line, caller.clone(), current_symbol.clone())]; - new_path.extend(path.clone()); - - if depth + 1 >= max_depth { - chains.push(CallChain { path: new_path }); - } else { - queue.push_back((caller.clone(), new_path, depth + 1)); - } - } - } else { - // No more callers, this is a chain end - chains.push(CallChain { path }); - } - } - - tracing::trace!("Found {} incoming chains", chains.len()); - chains - } - - pub fn find_outgoing_chains(&self, symbol: &str, max_depth: u32) -> Vec { - tracing::trace!( - "Finding outgoing chains for {} with depth {}", - symbol, - max_depth - ); - - if max_depth == 0 { - return vec![]; - } - - let mut chains = Vec::new(); - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - - // Start with what this symbol calls - if let Some(direct_callees) = self.callees.get(symbol) { - for (file, line, callee) in direct_callees { - let initial_path = vec![(file.clone(), *line, symbol.to_string(), callee.clone())]; - - if max_depth == 1 { - chains.push(CallChain { path: initial_path }); - } else { - queue.push_back((callee.clone(), initial_path, 1)); - } - } - } - - // BFS to find deeper chains - while let Some((current_symbol, path, depth)) = queue.pop_front() { - if depth >= max_depth { - chains.push(CallChain { path }); - continue; - } - - // Avoid cycles - if visited.contains(¤t_symbol) { - chains.push(CallChain { path }); - continue; - } - visited.insert(current_symbol.clone()); - - // Find what the current symbol calls - if let Some(callees) = self.callees.get(¤t_symbol) { - for (file, line, callee) in callees { - let mut new_path = path.clone(); - new_path.push((file.clone(), *line, current_symbol.clone(), callee.clone())); - - if depth + 1 >= max_depth { - chains.push(CallChain { path: new_path }); - } else { - queue.push_back((callee.clone(), new_path, depth + 1)); - } - } - } else { - // No more callees, this is a chain end - chains.push(CallChain { path }); - } - } - - tracing::trace!("Found {} outgoing chains", chains.len()); - chains - } -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/go.rs b/crates/goose-mcp/src/developer/analyze/languages/go.rs deleted file mode 100644 index cc6b8c545bc2..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/go.rs +++ /dev/null @@ -1,98 +0,0 @@ -/// Tree-sitter query for extracting Go code elements -pub const ELEMENT_QUERY: &str = r#" - (function_declaration name: (identifier) @func) - (method_declaration name: (field_identifier) @func) - (type_declaration (type_spec name: (type_identifier) @struct)) - (const_declaration (const_spec name: (identifier) @const)) - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Go function calls and identifier references -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (selector_expression - field: (field_identifier) @method.call)) - - ; Identifier references in various expression contexts - ; This captures constants/variables used in arguments, comparisons, returns, assignments, etc. - (argument_list (identifier) @identifier.reference) - (binary_expression left: (identifier) @identifier.reference) - (binary_expression right: (identifier) @identifier.reference) - (unary_expression operand: (identifier) @identifier.reference) - (return_statement (expression_list (identifier) @identifier.reference)) - (assignment_statement right: (expression_list (identifier) @identifier.reference)) -"#; - -/// Tree-sitter query for extracting Go struct references and usage patterns -pub const REFERENCE_QUERY: &str = r#" - ; Method receivers - pointer type - (method_declaration - receiver: (parameter_list - (parameter_declaration - type: (pointer_type (type_identifier) @method.receiver)))) - - ; Method receivers - value type - (method_declaration - receiver: (parameter_list - (parameter_declaration - type: (type_identifier) @method.receiver))) - - ; Struct literals - simple - (composite_literal - type: (type_identifier) @struct.literal) - - ; Struct literals - qualified (package.Type) - (composite_literal - type: (qualified_type - name: (type_identifier) @struct.literal)) - - ; Field declarations in structs - simple type - (field_declaration - type: (type_identifier) @field.type) - - ; Field declarations - pointer type - (field_declaration - type: (pointer_type - (type_identifier) @field.type)) - - ; Field declarations - qualified type (package.Type) - (field_declaration - type: (qualified_type - name: (type_identifier) @field.type)) - - ; Field declarations - pointer to qualified type - (field_declaration - type: (pointer_type - (qualified_type - name: (type_identifier) @field.type))) -"#; - -/// Find the method name for a method receiver node in Go -/// -/// This walks up the tree to find the method_declaration parent and extracts -/// the method name, used for associating methods with their receiver types. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - _ast_recursion_limit: Option, -) -> Option { - let mut current = *receiver_node; - while let Some(parent) = current.parent() { - if parent.kind() == "method_declaration" { - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "field_identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/java.rs b/crates/goose-mcp/src/developer/analyze/languages/java.rs deleted file mode 100644 index 11e616dc2df1..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/java.rs +++ /dev/null @@ -1,17 +0,0 @@ -/// Tree-sitter query for extracting Java code elements -pub const ELEMENT_QUERY: &str = r#" - (method_declaration name: (identifier) @func) - (class_declaration name: (identifier) @class) - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Java function calls -pub const CALL_QUERY: &str = r#" - ; Method invocations - (method_invocation - name: (identifier) @method.call) - - ; Constructor calls - (object_creation_expression - type: (type_identifier) @constructor.call) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/javascript.rs b/crates/goose-mcp/src/developer/analyze/languages/javascript.rs deleted file mode 100644 index 48d923dca2ef..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/javascript.rs +++ /dev/null @@ -1,22 +0,0 @@ -/// Tree-sitter query for extracting JavaScript/TypeScript code elements -pub const ELEMENT_QUERY: &str = r#" - (function_declaration name: (identifier) @func) - (class_declaration name: (identifier) @class) - (import_statement) @import -"#; - -/// Tree-sitter query for extracting JavaScript/TypeScript function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (member_expression - property: (property_identifier) @method.call)) - - ; Constructor calls - (new_expression - constructor: (identifier) @constructor.call) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs b/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs deleted file mode 100644 index e031efdc1f9e..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/kotlin.rs +++ /dev/null @@ -1,26 +0,0 @@ -/// Tree-sitter query for extracting Kotlin code elements -pub const ELEMENT_QUERY: &str = r#" - ; Functions - (function_declaration name: (identifier) @func) - - ; Classes - (class_declaration name: (identifier) @class) - - ; Objects (singleton classes) - (object_declaration name: (identifier) @class) - - ; Imports - (import) @import -"#; - -/// Tree-sitter query for extracting Kotlin function calls -pub const CALL_QUERY: &str = r#" - ; Simple function calls - (call_expression - (identifier) @function.call) - - ; Method calls with navigation (obj.method()) - (call_expression - (navigation_expression - (identifier) @method.call)) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/mod.rs b/crates/goose-mcp/src/developer/analyze/languages/mod.rs deleted file mode 100644 index c9bae7bc5a78..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/mod.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Language-specific analysis implementations -//! -//! This module contains language-specific parsing logic and tree-sitter queries -//! for the analyze tool. Each language has its own submodule with query definitions -//! and optional helper functions. -//! -//! ## Adding a New Language -//! -//! To add support for a new language: -//! -//! 1. Create a new file `languages/yourlang.rs` -//! 2. Define `ELEMENT_QUERY` and `CALL_QUERY` constants -//! 3. Optionally define `REFERENCE_QUERY` for advanced type tracking -//! 4. Add `pub mod yourlang;` below -//! 5. Add language configuration to registry in `get_language_info()` -//! -//! ## Optional Features -//! -//! Languages can opt into additional features by implementing: -//! -//! - Reference tracking: Define `REFERENCE_QUERY` to track type instantiation, -//! field types, and method-to-type associations (see Go and Ruby) -//! - Custom function naming: Implement `extract_function_name_for_kind()` for -//! special cases like Swift's init/deinit or Rust's impl blocks -//! - Method receiver lookup: Implement `find_method_for_receiver()` to associate -//! methods with their containing types (see Go and Ruby) - -pub mod go; -pub mod java; -pub mod javascript; -pub mod kotlin; -pub mod python; -pub mod ruby; -pub mod rust; -pub mod swift; - -/// Handler for extracting function names from special node kinds -type ExtractFunctionNameHandler = fn(&tree_sitter::Node, &str, &str) -> Option; - -/// Handler for finding method names from receiver nodes -/// Takes: (receiver_node, source, ast_recursion_limit) -type FindMethodForReceiverHandler = fn(&tree_sitter::Node, &str, Option) -> Option; - -/// Handler for finding the receiver type from a receiver node -/// Takes: (receiver_node, source) -type FindReceiverTypeHandler = fn(&tree_sitter::Node, &str) -> Option; - -/// Language configuration containing all language-specific information -/// -/// This struct serves as a single source of truth for language support. -/// All language-specific queries and handlers are defined here. -#[derive(Copy, Clone)] -pub struct LanguageInfo { - /// Tree-sitter query for extracting code elements (functions, classes, imports) - pub element_query: &'static str, - /// Tree-sitter query for extracting function calls - pub call_query: &'static str, - /// Tree-sitter query for extracting type references (optional) - pub reference_query: &'static str, - /// Node kinds that represent function-like constructs - pub function_node_kinds: &'static [&'static str], - /// Node kinds that represent function name identifiers - pub function_name_kinds: &'static [&'static str], - /// Optional handler for language-specific function name extraction - pub extract_function_name_handler: Option, - /// Optional handler for finding method names from receiver nodes - pub find_method_for_receiver_handler: Option, - /// Optional handler for finding receiver type from receiver nodes - pub find_receiver_type_handler: Option, -} - -/// Get language configuration for a given language -/// -/// Returns `Some(LanguageInfo)` if the language is supported, `None` otherwise. -pub fn get_language_info(language: &str) -> Option { - match language { - "python" => Some(LanguageInfo { - element_query: python::ELEMENT_QUERY, - call_query: python::CALL_QUERY, - reference_query: "", - function_node_kinds: &["function_definition"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "rust" => Some(LanguageInfo { - element_query: rust::ELEMENT_QUERY, - call_query: rust::CALL_QUERY, - reference_query: rust::REFERENCE_QUERY, - function_node_kinds: &["function_item", "impl_item"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: Some(rust::extract_function_name_for_kind), - find_method_for_receiver_handler: Some(rust::find_method_for_receiver), - find_receiver_type_handler: Some(rust::find_receiver_type), - }), - "javascript" | "typescript" => Some(LanguageInfo { - element_query: javascript::ELEMENT_QUERY, - call_query: javascript::CALL_QUERY, - reference_query: "", - function_node_kinds: &[ - "function_declaration", - "method_definition", - "arrow_function", - ], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "go" => Some(LanguageInfo { - element_query: go::ELEMENT_QUERY, - call_query: go::CALL_QUERY, - reference_query: go::REFERENCE_QUERY, - function_node_kinds: &["function_declaration", "method_declaration"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: Some(go::find_method_for_receiver), - find_receiver_type_handler: None, - }), - "java" => Some(LanguageInfo { - element_query: java::ELEMENT_QUERY, - call_query: java::CALL_QUERY, - reference_query: "", - function_node_kinds: &["method_declaration", "constructor_declaration"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "kotlin" => Some(LanguageInfo { - element_query: kotlin::ELEMENT_QUERY, - call_query: kotlin::CALL_QUERY, - reference_query: "", - function_node_kinds: &["function_declaration", "class_body"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "swift" => Some(LanguageInfo { - element_query: swift::ELEMENT_QUERY, - call_query: swift::CALL_QUERY, - reference_query: "", - function_node_kinds: &[ - "function_declaration", - "init_declaration", - "deinit_declaration", - "subscript_declaration", - ], - function_name_kinds: &["simple_identifier"], - extract_function_name_handler: Some(swift::extract_function_name_for_kind), - find_method_for_receiver_handler: None, - find_receiver_type_handler: None, - }), - "ruby" => Some(LanguageInfo { - element_query: ruby::ELEMENT_QUERY, - call_query: ruby::CALL_QUERY, - reference_query: ruby::REFERENCE_QUERY, - function_node_kinds: &["method", "singleton_method"], - function_name_kinds: &["identifier", "field_identifier", "property_identifier"], - extract_function_name_handler: None, - find_method_for_receiver_handler: Some(ruby::find_method_for_receiver), - find_receiver_type_handler: None, - }), - _ => None, - } -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/python.rs b/crates/goose-mcp/src/developer/analyze/languages/python.rs deleted file mode 100644 index 3dd117d75dec..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/python.rs +++ /dev/null @@ -1,25 +0,0 @@ -/// Tree-sitter query for extracting Python code elements -pub const ELEMENT_QUERY: &str = r#" - (function_definition name: (identifier) @func) - (class_definition name: (identifier) @class) - (import_statement) @import - (import_from_statement) @import - (aliased_import) @import - (assignment left: (identifier) @class) -"#; - -/// Tree-sitter query for extracting Python function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call - function: (identifier) @function.call) - - ; Method calls - (call - function: (attribute - attribute: (identifier) @method.call)) - - ; Decorator applications - (decorator (identifier) @function.call) - (decorator (attribute attribute: (identifier) @method.call)) -"#; diff --git a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs deleted file mode 100644 index 04a0ab97ccd4..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs +++ /dev/null @@ -1,151 +0,0 @@ -/// Tree-sitter query for extracting Ruby code elements. -/// -/// This query captures: -/// - Method definitions (def) -/// - Class and module definitions -/// - Constants -/// - Common attr_* declarations (attr_accessor, attr_reader, attr_writer) -/// - Import statements (require, require_relative, load) -pub const ELEMENT_QUERY: &str = r#" - ; Method definitions - (method name: (identifier) @func) - - ; Class and module definitions - (class name: (constant) @class) - (module name: (constant) @class) - - ; Constant assignments - (assignment left: (constant) @const) - - ; Attr declarations as functions - (call method: (identifier) @func (#eq? @func "attr_accessor")) - (call method: (identifier) @func (#eq? @func "attr_reader")) - (call method: (identifier) @func (#eq? @func "attr_writer")) - - ; Require statements - (call method: (identifier) @import (#eq? @import "require")) - (call method: (identifier) @import (#eq? @import "require_relative")) - (call method: (identifier) @import (#eq? @import "load")) -"#; - -/// Tree-sitter query for extracting Ruby function calls. -/// -/// This query captures: -/// - Direct method calls -/// - Method calls with receivers (object.method) -/// - Calls to constants (typically constructors like ClassName.new) -/// - Identifier and constant references in various expression contexts -pub const CALL_QUERY: &str = r#" - ; Method calls - (call method: (identifier) @method.call) - - ; Method calls with receiver - (call receiver: (_) method: (identifier) @method.call) - - ; Calls to constants (typically constructors) - (call receiver: (constant) @function.call) - - ; Identifier and constant references in argument lists - (argument_list (identifier) @identifier.reference) - (argument_list (constant) @identifier.reference) - - ; Binary expressions - (binary left: (identifier) @identifier.reference) - (binary right: (identifier) @identifier.reference) - (binary left: (constant) @identifier.reference) - (binary right: (constant) @identifier.reference) - - ; Assignment expressions - (assignment right: (identifier) @identifier.reference) - (assignment right: (constant) @identifier.reference) -"#; - -/// Tree-sitter query for extracting Ruby type references and usage patterns. -/// -/// This query captures: -/// - Method-to-class associations (instance and class methods) -/// - Class instantiation (ClassName.new) -/// - Type references in various contexts -pub const REFERENCE_QUERY: &str = r#" - ; Instance methods within a class - capture class name, will find method via receiver lookup - (class - name: (constant) @method.receiver - (body_statement (method))) - - ; Class instantiation (ClassName.new) - (call - receiver: (constant) @struct.literal - method: (identifier) @method.name (#eq? @method.name "new")) - - ; Constant references as receivers (type usage) - (call - receiver: (constant) @field.type - method: (identifier)) -"#; - -/// Find the method name for a method receiver node in Ruby -/// -/// For Ruby, the receiver_node is the class constant. This finds methods -/// within that class node, used for associating methods with their classes. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - ast_recursion_limit: Option, -) -> Option { - let max_depth = ast_recursion_limit.unwrap_or(10); - - // For Ruby, receiver_node is the class constant - if receiver_node.kind() == "constant" { - let mut current = *receiver_node; - while let Some(parent) = current.parent() { - if parent.kind() == "class" { - return find_first_method_in_class(&parent, source, max_depth); - } - current = parent; - } - } - None -} - -/// Find the first method name within a Ruby class node -fn find_first_method_in_class( - class_node: &tree_sitter::Node, - source: &str, - max_depth: usize, -) -> Option { - for i in 0..class_node.child_count() as u32 { - if let Some(child) = class_node.child(i) { - if child.kind() == "body_statement" { - return find_method_in_body_with_depth(&child, source, 0, max_depth); - } - } - } - None -} - -/// Recursively find a method within a body_statement node with depth limit -fn find_method_in_body_with_depth( - node: &tree_sitter::Node, - source: &str, - depth: usize, - max_depth: usize, -) -> Option { - if depth >= max_depth { - return None; - } - - for i in 0..node.child_count() as u32 { - if let Some(child) = node.child(i) { - if child.kind() == "method" { - for j in 0..child.child_count() as u32 { - if let Some(name_node) = child.child(j) { - if name_node.kind() == "identifier" { - return source.get(name_node.byte_range()).map(|s| s.to_string()); - } - } - } - } - } - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/rust.rs b/crates/goose-mcp/src/developer/analyze/languages/rust.rs deleted file mode 100644 index 98bfabc6ed60..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/rust.rs +++ /dev/null @@ -1,146 +0,0 @@ -/// Tree-sitter query for extracting Rust code elements -pub const ELEMENT_QUERY: &str = r#" - (function_item name: (identifier) @func) - (impl_item type: (type_identifier) @class) - (struct_item name: (type_identifier) @struct) - (use_declaration) @import -"#; - -/// Tree-sitter query for extracting Rust function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - function: (identifier) @function.call) - - ; Method calls - (call_expression - function: (field_expression - field: (field_identifier) @method.call)) - - ; Associated function calls (e.g., Type::method()) - ; Now captures the full Type::method instead of just method - (call_expression - function: (scoped_identifier) @scoped.call) - - ; Macro calls (often contain function-like behavior) - (macro_invocation - macro: (identifier) @macro.call) -"#; - -/// Tree-sitter query for extracting Rust type references and usage patterns -pub const REFERENCE_QUERY: &str = r#" - ; Method receivers - capture self parameters to associate methods with impl types - (self_parameter) @method.receiver - - ; Struct instantiation - struct literals - (struct_expression - name: (type_identifier) @struct.literal) - - ; Field type declarations in structs - (field_declaration - type: (type_identifier) @field.type) - - ; Field with reference type - (field_declaration - type: (reference_type - (type_identifier) @field.type)) - - ; Field with generic type - (field_declaration - type: (generic_type - type: (type_identifier) @field.type)) - - ; Variable type annotations - (let_declaration - type: (type_identifier) @var.type) - - ; Variable with reference type - (let_declaration - type: (reference_type - (type_identifier) @var.type)) - - ; Function parameter types - (parameter - type: (type_identifier) @param.type) - - ; Parameter with reference type - (parameter - type: (reference_type - (type_identifier) @param.type)) -"#; - -/// Extract function name for Rust-specific node kinds -/// -/// Rust has special cases like impl_item blocks that should be -/// formatted as "impl TypeName" instead of extracting a simple name. -pub fn extract_function_name_for_kind( - node: &tree_sitter::Node, - source: &str, - kind: &str, -) -> Option { - if kind == "impl_item" { - // For impl blocks, find the type being implemented - for i in 0..node.child_count() as u32 { - if let Some(child) = node.child(i) { - if child.kind() == "type_identifier" { - return source - .get(child.byte_range()) - .map(|s| format!("impl {}", s)); - } - } - } - } - None -} - -/// Find the method name for a method receiver node in Rust -/// -/// The receiver_node is a self_parameter. This walks up to find the -/// containing function_item and returns the method name. -pub fn find_method_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - _ast_recursion_limit: Option, -) -> Option { - // Walk up to find the function_item that contains this self_parameter - let mut current = *receiver_node; - - while let Some(parent) = current.parent() { - if parent.kind() == "function_item" { - // Found the function, get its name - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} - -/// Find the receiver type for a self parameter in Rust -/// -/// In Rust, self parameters are special - they don't explicitly state their type. -/// This function walks up from a self_parameter node to find the impl block -/// and extracts the type being implemented. -pub fn find_receiver_type(node: &tree_sitter::Node, source: &str) -> Option { - // Walk up from self_parameter to find the impl_item - let mut current = *node; - while let Some(parent) = current.parent() { - if parent.kind() == "impl_item" { - // Find the type_identifier in the impl block - for i in 0..parent.child_count() as u32 { - if let Some(child) = parent.child(i) { - if child.kind() == "type_identifier" { - return source.get(child.byte_range()).map(|s| s.to_string()); - } - } - } - } - current = parent; - } - None -} diff --git a/crates/goose-mcp/src/developer/analyze/languages/swift.rs b/crates/goose-mcp/src/developer/analyze/languages/swift.rs deleted file mode 100644 index dd8ca8136a16..000000000000 --- a/crates/goose-mcp/src/developer/analyze/languages/swift.rs +++ /dev/null @@ -1,72 +0,0 @@ -/// Tree-sitter query for extracting Swift code elements -pub const ELEMENT_QUERY: &str = r#" - ; Functions - (function_declaration name: (simple_identifier) @func) - - ; Classes - (class_declaration name: (type_identifier) @class) - - ; Protocols (interfaces) - (protocol_declaration name: (type_identifier) @class) - - ; Imports - (import_declaration) @import -"#; - -/// Tree-sitter query for extracting Swift function calls -pub const CALL_QUERY: &str = r#" - ; Function calls - (call_expression - (simple_identifier) @function.call) - - ; Method calls with navigation - (call_expression - (navigation_expression - target: (_) - suffix: (navigation_suffix - suffix: (simple_identifier) @method.call))) - - ; Constructor calls - (constructor_expression - (user_type - (type_identifier) @constructor.call)) - - ; Async function calls - (await_expression - (call_expression - (simple_identifier) @function.call)) - - ; Async method calls - (await_expression - (call_expression - (navigation_expression - suffix: (navigation_suffix - suffix: (simple_identifier) @method.call)))) - - ; Static method calls (Type.method()) - (call_expression - (navigation_expression - target: (user_type) - suffix: (navigation_suffix - suffix: (simple_identifier) @scoped.call))) - - ; Closure calls - (call_expression - (navigation_expression) @function.call) -"#; - -/// Extract function name for Swift-specific node kinds -/// -/// Swift has special cases like init_declaration and deinit_declaration -/// that should return fixed names instead of extracting from children. -pub fn extract_function_name_for_kind( - _node: &tree_sitter::Node, - _source: &str, - kind: &str, -) -> Option { - match kind { - "init_declaration" => Some("init".to_string()), - "deinit_declaration" => Some("deinit".to_string()), - _ => None, - } -} diff --git a/crates/goose-mcp/src/developer/analyze/mod.rs b/crates/goose-mcp/src/developer/analyze/mod.rs deleted file mode 100644 index 9b7691b102fe..000000000000 --- a/crates/goose-mcp/src/developer/analyze/mod.rs +++ /dev/null @@ -1,332 +0,0 @@ -pub mod cache; -pub mod formatter; -pub mod graph; -pub mod languages; -pub mod parser; -pub mod traversal; -pub mod types; - -#[cfg(test)] -mod tests; - -use ignore::gitignore::Gitignore; -use rmcp::model::{CallToolResult, ErrorCode, ErrorData}; -use std::path::{Path, PathBuf}; - -use crate::developer::lang; - -use self::cache::AnalysisCache; -use self::formatter::Formatter; -use self::graph::CallGraph; -use self::parser::{ElementExtractor, ParserManager}; -use self::traversal::FileTraverser; -use self::types::{AnalysisMode, AnalysisResult, AnalyzeParams, FocusedAnalysisData}; - -/// Helper to safely lock a mutex with poison recovery -/// The recovery function is called on the mutex contents if the lock was poisoned -pub(crate) fn lock_or_recover( - mutex: &std::sync::Mutex, - recovery: F, -) -> std::sync::MutexGuard<'_, T> -where - F: FnOnce(&mut T), -{ - mutex.lock().unwrap_or_else(|poisoned| { - let mut guard = poisoned.into_inner(); - recovery(&mut guard); - tracing::warn!("Recovered from poisoned lock"); - guard - }) -} - -/// Code analyzer with caching and tree-sitter parsing -#[derive(Clone)] -pub struct CodeAnalyzer { - parser_manager: ParserManager, - cache: AnalysisCache, -} - -impl Default for CodeAnalyzer { - fn default() -> Self { - Self::new() - } -} - -impl CodeAnalyzer { - pub fn new() -> Self { - tracing::debug!("Initializing CodeAnalyzer"); - Self { - parser_manager: ParserManager::new(), - cache: AnalysisCache::new(100), - } - } - - pub fn analyze( - &self, - params: AnalyzeParams, - path: PathBuf, - ignore_patterns: &Gitignore, - ) -> Result { - tracing::info!("Starting analysis of {:?} with params {:?}", path, params); - - let traverser = FileTraverser::new(ignore_patterns); - - traverser.validate_path(&path)?; - - let mode = self.determine_mode(¶ms, &path); - - tracing::debug!("Using analysis mode: {:?}", mode); - - let mut output = match mode { - AnalysisMode::Focused => self.analyze_focused(&path, ¶ms, &traverser)?, - AnalysisMode::Semantic => { - if path.is_file() { - let result = self.analyze_file(&path, &mode, ¶ms)?; - Formatter::format_analysis_result(&path, &result, &mode) - } else { - self.analyze_directory(&path, ¶ms, &traverser, &mode)? - } - } - AnalysisMode::Structure => { - if path.is_file() { - let result = self.analyze_file(&path, &mode, ¶ms)?; - Formatter::format_analysis_result(&path, &result, &mode) - } else { - self.analyze_directory(&path, ¶ms, &traverser, &mode)? - } - } - }; - - // If focus is specified with non-focused mode, filter results - if let Some(focus) = ¶ms.focus { - if mode != AnalysisMode::Focused { - output = Formatter::filter_by_focus(&output, focus); - } - } - - const OUTPUT_LIMIT: usize = 1000; - if !params.force { - let line_count = output.lines().count(); - if line_count > OUTPUT_LIMIT { - let warning = format!( - "LARGE OUTPUT WARNING\n\n\ - The analysis would produce {} lines (~{} tokens).\n\ - This exceeds the {} line limit.\n\n\ - To proceed anyway, add 'force: true' to your parameters:\n\ - analyze path=\"{}\" force=true{}\n\n\ - Or narrow your scope by:\n\ - β€’ Analyzing a subdirectory instead\n\ - β€’ Using focus mode: focus=\"symbol_name\"\n\ - β€’ Reducing depth: max_depth=1", - line_count, - line_count * 10, // rough token estimate - OUTPUT_LIMIT, - path.display(), - if let Some(f) = ¶ms.focus { - format!(" focus=\"{}\"", f) - } else { - String::new() - } - ); - return Ok(CallToolResult::success(vec![rmcp::model::Content::text( - warning, - )])); - } - } - - tracing::info!("Analysis complete"); - Ok(CallToolResult::success(Formatter::format_results(output))) - } - - fn determine_mode(&self, params: &AnalyzeParams, path: &Path) -> AnalysisMode { - if params.focus.is_some() { - return AnalysisMode::Focused; - } - - if path.is_file() { - AnalysisMode::Semantic - } else { - AnalysisMode::Structure - } - } - - fn analyze_file( - &self, - path: &Path, - mode: &AnalysisMode, - params: &AnalyzeParams, - ) -> Result { - tracing::debug!("Analyzing file {:?} in {:?} mode", path, mode); - - let metadata = std::fs::metadata(path).map_err(|e| { - tracing::error!("Failed to get file metadata for {:?}: {}", path, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to get metadata for '{}': {}", path.display(), e), - None, - ) - })?; - - let modified = metadata.modified().map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!( - "Failed to get modification time for '{}': {}", - path.display(), - e - ), - None, - ) - })?; - - if let Some(cached) = self.cache.get(&path.to_path_buf(), modified, mode) { - tracing::trace!("Using cached result for {:?}", path); - return Ok(cached); - } - - let content = match std::fs::read_to_string(path) { - Ok(content) => content, - Err(e) => { - tracing::trace!("Skipping binary/non-UTF-8 file {:?}: {}", path, e); - return Ok(AnalysisResult::empty(0)); - } - }; - - let line_count = content.lines().count(); - - let language = lang::get_language_identifier(path); - if language.is_empty() { - tracing::trace!("Unsupported file type: {:?}", path); - return Ok(AnalysisResult::empty(line_count)); - } - - // Check if we support this language for parsing - // A language is supported if it has query definitions - let language_supported = languages::get_language_info(language) - .map(|info| !info.element_query.is_empty()) - .unwrap_or(false); - - if !language_supported { - tracing::trace!("Language {} not supported for parsing", language); - return Ok(AnalysisResult::empty(line_count)); - } - - let tree = self.parser_manager.parse(&content, language)?; - - let depth = mode.as_str(); - let mut result = ElementExtractor::extract_with_depth( - &tree, - &content, - language, - depth, - params.ast_recursion_limit, - )?; - - result.line_count = line_count; - - self.cache - .put(path.to_path_buf(), modified, mode, result.clone()); - - Ok(result) - } - - fn analyze_directory( - &self, - path: &Path, - params: &AnalyzeParams, - traverser: &FileTraverser<'_>, - mode: &AnalysisMode, - ) -> Result { - tracing::debug!("Analyzing directory {:?} in {:?} mode", path, mode); - - let mode = *mode; - - let results = traverser.collect_directory_results(path, params.max_depth, |file_path| { - self.analyze_file(file_path, &mode, params) - })?; - - Ok(Formatter::format_directory_structure( - path, - &results, - params.max_depth, - )) - } - - fn analyze_focused( - &self, - path: &Path, - params: &AnalyzeParams, - traverser: &FileTraverser<'_>, - ) -> Result { - let focus_symbol = params.focus.as_ref().ok_or_else(|| { - ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Focused mode requires 'focus' parameter to specify the symbol to track" - .to_string(), - None, - ) - })?; - - tracing::info!("Running focused analysis for symbol '{}'", focus_symbol); - - let files_to_analyze = if path.is_file() { - vec![path.to_path_buf()] - } else { - traverser.collect_files_for_focused(path, params.max_depth)? - }; - - tracing::debug!( - "Analyzing {} files for focused analysis", - files_to_analyze.len() - ); - - use rayon::prelude::*; - let all_results: Result, _> = files_to_analyze - .par_iter() - .map(|file_path| { - self.analyze_file(file_path, &AnalysisMode::Semantic, params) - .map(|result| (file_path.clone(), result)) - }) - .collect(); - let all_results = all_results?; - - let graph = CallGraph::build_from_results(&all_results); - - let incoming_chains = if params.follow_depth > 0 { - graph.find_incoming_chains(focus_symbol, params.follow_depth) - } else { - vec![] - }; - - let outgoing_chains = if params.follow_depth > 0 { - graph.find_outgoing_chains(focus_symbol, params.follow_depth) - } else { - vec![] - }; - - let definitions = graph - .definitions - .get(focus_symbol) - .cloned() - .unwrap_or_default(); - - let focus_data = FocusedAnalysisData { - focus_symbol, - follow_depth: params.follow_depth, - files_analyzed: &files_to_analyze, - definitions: &definitions, - incoming_chains: &incoming_chains, - outgoing_chains: &outgoing_chains, - }; - - let mut output = Formatter::format_focused_output(&focus_data); - - if path.is_file() { - let hint = "NOTE: Focus mode works best with directory paths. \ - Use a parent directory in the path for cross-file analysis.\n\n"; - output = format!("{}{}", hint, output); - } - - Ok(output) - } -} diff --git a/crates/goose-mcp/src/developer/analyze/parser.rs b/crates/goose-mcp/src/developer/analyze/parser.rs deleted file mode 100644 index 1caebacbc3e3..000000000000 --- a/crates/goose-mcp/src/developer/analyze/parser.rs +++ /dev/null @@ -1,525 +0,0 @@ -use rmcp::model::{ErrorCode, ErrorData}; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use tree_sitter::{Language, Parser, StreamingIterator, Tree}; - -use super::lock_or_recover; -use crate::developer::analyze::types::{ - AnalysisResult, CallInfo, ClassInfo, ElementQueryResult, FunctionInfo, ReferenceInfo, - ReferenceType, -}; - -#[derive(Clone)] -pub struct ParserManager { - parsers: Arc>>>>, -} - -impl ParserManager { - pub fn new() -> Self { - tracing::debug!("Initializing ParserManager"); - Self { - parsers: Arc::new(Mutex::new(HashMap::new())), - } - } - - pub fn get_or_create_parser(&self, language: &str) -> Result>, ErrorData> { - let mut cache = lock_or_recover(&self.parsers, |c| c.clear()); - - if let Some(parser) = cache.get(language) { - tracing::trace!("Reusing cached parser for {}", language); - return Ok(Arc::clone(parser)); - } - - tracing::debug!("Creating new parser for {}", language); - let mut parser = Parser::new(); - let language_config: Language = match language { - "python" => tree_sitter_python::LANGUAGE.into(), - "rust" => tree_sitter_rust::LANGUAGE.into(), - "javascript" | "typescript" => tree_sitter_javascript::LANGUAGE.into(), - "go" => tree_sitter_go::LANGUAGE.into(), - "java" => tree_sitter_java::LANGUAGE.into(), - "kotlin" => tree_sitter_kotlin_ng::LANGUAGE.into(), - "swift" => tree_sitter_swift::LANGUAGE.into(), - "ruby" => tree_sitter_ruby::LANGUAGE.into(), - _ => { - tracing::warn!("Unsupported language: {}", language); - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Unsupported language: {}", language), - None, - )); - } - }; - - parser.set_language(&language_config).map_err(|e| { - tracing::error!("Failed to set language for {}: {}", language, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to set language: {}", e), - None, - ) - })?; - - let parser_arc = Arc::new(Mutex::new(parser)); - cache.insert(language.to_string(), Arc::clone(&parser_arc)); - Ok(parser_arc) - } - - pub fn parse(&self, content: &str, language: &str) -> Result { - let parser_arc = self.get_or_create_parser(language)?; - let mut parser = lock_or_recover(&parser_arc, |_| {}); - - parser.parse(content, None).ok_or_else(|| { - tracing::error!("Failed to parse content as {}", language); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to parse file as {}", language), - None, - ) - }) - } -} - -impl Default for ParserManager { - fn default() -> Self { - Self::new() - } -} - -pub struct ElementExtractor; - -impl ElementExtractor { - fn find_child_by_kind<'a>( - node: &'a tree_sitter::Node, - kinds: &[&str], - ) -> Option> { - (0..node.child_count() as u32) - .filter_map(|i| node.child(i)) - .find(|child| kinds.contains(&child.kind())) - } - - fn extract_text_from_child( - node: &tree_sitter::Node, - source: &str, - kinds: &[&str], - ) -> Option { - Self::find_child_by_kind(node, kinds) - .and_then(|child| source.get(child.byte_range()).map(|s| s.to_string())) - } - - pub fn extract_with_depth( - tree: &Tree, - source: &str, - language: &str, - depth: &str, - ast_recursion_limit: Option, - ) -> Result { - use crate::developer::analyze::languages; - - tracing::trace!( - "Extracting elements from {} code with depth {}", - language, - depth - ); - - let mut result = Self::extract_elements(tree, source, language)?; - - if depth == "structure" { - result.functions.clear(); - result.classes.clear(); - result.imports.clear(); - } else if depth == "semantic" { - let calls = Self::extract_calls(tree, source, language)?; - result.calls = calls; - - for call in &result.calls { - result.references.push(ReferenceInfo { - symbol: call.callee_name.clone(), - ref_type: ReferenceType::Call, - line: call.line, - context: call.context.clone(), - associated_type: None, - }); - } - - // Languages can opt-in to advanced reference tracking by providing a REFERENCE_QUERY - // in their language definition. This enables tracking of: - // - Type instantiation (struct literals, object creation) - // - Field/variable/parameter type references - // - Method-to-type associations - if let Some(info) = languages::get_language_info(language) { - if !info.reference_query.is_empty() { - let references = - Self::extract_references(tree, source, language, ast_recursion_limit)?; - result.references.extend(references); - } - } - } - - Ok(result) - } - - pub fn extract_elements( - tree: &Tree, - source: &str, - language: &str, - ) -> Result { - use crate::developer::analyze::languages; - - let info = match languages::get_language_info(language) { - Some(info) if !info.element_query.is_empty() => info, - _ => return Ok(Self::empty_analysis_result()), - }; - - let query_str = info.element_query; - - let (functions, classes, imports) = Self::process_element_query(tree, source, query_str)?; - - let main_line = functions.iter().find(|f| f.name == "main").map(|f| f.line); - - Ok(AnalysisResult { - function_count: functions.len(), - class_count: classes.len(), - import_count: imports.len(), - functions, - classes, - imports, - calls: vec![], - references: vec![], - line_count: 0, - main_line, - }) - } - - fn process_element_query( - tree: &Tree, - source: &str, - query_str: &str, - ) -> Result { - use tree_sitter::{Query, QueryCursor}; - - let mut functions = Vec::new(); - let mut classes = Vec::new(); - let mut imports = Vec::new(); - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let line = source - .get(..node.start_byte()) - .map(|s: &str| s.lines().count() + 1) - .unwrap_or(1); - - match query.capture_names()[capture.index as usize] { - "func" | "const" => { - functions.push(FunctionInfo { - name: text.to_string(), - line, - params: vec![], // Simplified for now - }); - } - "class" | "struct" => { - classes.push(ClassInfo { - name: text.to_string(), - line, - methods: vec![], // Simplified for now - }); - } - "import" => { - imports.push(text.to_string()); - } - _ => {} - } - } - } - - tracing::trace!( - "Extracted {} functions, {} classes, {} imports", - functions.len(), - classes.len(), - imports.len() - ); - - Ok((functions, classes, imports)) - } - - fn extract_calls( - tree: &Tree, - source: &str, - language: &str, - ) -> Result, ErrorData> { - use crate::developer::analyze::languages; - use tree_sitter::{Query, QueryCursor}; - - let mut calls = Vec::new(); - - let info = match languages::get_language_info(language) { - Some(info) if !info.call_query.is_empty() => info, - _ => return Ok(calls), - }; - - let query_str = info.call_query; - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create call query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create call query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let start_pos = node.start_position(); - - let line_start = source - .get(..node.start_byte()) - .and_then(|s: &str| s.rfind('\n')) - .map(|i| i + 1) - .unwrap_or(0); - let line_end = source - .get(node.end_byte()..) - .and_then(|s: &str| s.find('\n')) - .map(|i| node.end_byte() + i) - .unwrap_or(source.len()); - let context = source - .get(line_start..line_end) - .map(|s: &str| s.trim().to_string()) - .unwrap_or_default(); - - let caller_name = Self::find_containing_function(&node, source, language); - - match query.capture_names()[capture.index as usize] { - "function.call" - | "method.call" - | "scoped.call" - | "macro.call" - | "constructor.call" - | "identifier.reference" => { - calls.push(CallInfo { - caller_name, - callee_name: text.to_string(), - line: start_pos.row + 1, - column: start_pos.column, - context, - }); - } - _ => {} - } - } - } - - tracing::trace!("Extracted {} calls", calls.len()); - Ok(calls) - } - - fn extract_references( - tree: &Tree, - source: &str, - language: &str, - ast_recursion_limit: Option, - ) -> Result, ErrorData> { - use crate::developer::analyze::languages; - use tree_sitter::{Query, QueryCursor}; - - let mut references = Vec::new(); - - let info = match languages::get_language_info(language) { - Some(info) if !info.reference_query.is_empty() => info, - _ => return Ok(references), - }; - - let query_str = info.reference_query; - - let query = Query::new(&tree.language(), query_str).map_err(|e| { - tracing::error!("Failed to create reference query: {}", e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to create reference query: {}", e), - None, - ) - })?; - - let mut cursor = QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(match_) = matches.next() { - for capture in match_.captures { - let node = capture.node; - let Some(text) = source.get(node.byte_range()) else { - continue; - }; - let start_pos = node.start_position(); - - let line_start = source - .get(..node.start_byte()) - .and_then(|s: &str| s.rfind('\n')) - .map(|i| i + 1) - .unwrap_or(0); - let line_end = source - .get(node.end_byte()..) - .and_then(|s: &str| s.find('\n')) - .map(|i| node.end_byte() + i) - .unwrap_or(source.len()); - let context = source - .get(line_start..line_end) - .map(|s: &str| s.trim().to_string()) - .unwrap_or_default(); - - let capture_name = query.capture_names()[capture.index as usize]; - - let (ref_type, symbol, associated_type) = match capture_name { - "method.receiver" => { - let method_name = Self::find_method_name_for_receiver( - &node, - source, - language, - ast_recursion_limit, - ); - if let Some(method_name) = method_name { - // Use language-specific handler to find receiver type, or fall back to text - let type_name = Self::find_receiver_type(&node, source, language) - .or_else(|| Some(text.to_string())); - - if let Some(type_name) = type_name { - ( - ReferenceType::MethodDefinition, - method_name, - Some(type_name), - ) - } else { - continue; - } - } else { - continue; - } - } - "struct.literal" => (ReferenceType::TypeInstantiation, text.to_string(), None), - "field.type" => (ReferenceType::FieldType, text.to_string(), None), - "param.type" => (ReferenceType::ParameterType, text.to_string(), None), - "var.type" | "shortvar.type" => { - (ReferenceType::VariableType, text.to_string(), None) - } - "type.assertion" | "type.conversion" => { - (ReferenceType::Call, text.to_string(), None) - } - _ => continue, - }; - - references.push(ReferenceInfo { - symbol, - ref_type, - line: start_pos.row + 1, - context, - associated_type, - }); - } - } - - tracing::trace!("Extracted {} struct references", references.len()); - Ok(references) - } - - fn find_method_name_for_receiver( - receiver_node: &tree_sitter::Node, - source: &str, - language: &str, - ast_recursion_limit: Option, - ) -> Option { - use crate::developer::analyze::languages; - - languages::get_language_info(language) - .and_then(|info| info.find_method_for_receiver_handler) - .and_then(|handler| handler(receiver_node, source, ast_recursion_limit)) - } - - fn find_receiver_type( - receiver_node: &tree_sitter::Node, - source: &str, - language: &str, - ) -> Option { - use crate::developer::analyze::languages; - - languages::get_language_info(language) - .and_then(|info| info.find_receiver_type_handler) - .and_then(|handler| handler(receiver_node, source)) - } - - fn find_containing_function( - node: &tree_sitter::Node, - source: &str, - language: &str, - ) -> Option { - use crate::developer::analyze::languages; - - let info = languages::get_language_info(language)?; - - let mut current = *node; - - while let Some(parent) = current.parent() { - let kind = parent.kind(); - - // Check if this is a function-like node - if info.function_node_kinds.contains(&kind) { - // Two-step extraction process: - // 1. Try language-specific extraction for special cases (e.g., Rust impl blocks, Swift init/deinit) - // 2. Fall back to generic extraction using standard identifier node kinds - // This pattern allows languages to override default behavior when needed - if let Some(handler) = info.extract_function_name_handler { - if let Some(name) = handler(&parent, source, kind) { - return Some(name); - } - } - - // Standard extraction: find first child matching expected identifier kinds - if let Some(name) = - Self::extract_text_from_child(&parent, source, info.function_name_kinds) - { - return Some(name); - } - } - - current = parent; - } - - None - } - - fn empty_analysis_result() -> AnalysisResult { - AnalysisResult { - functions: vec![], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 0, - class_count: 0, - line_count: 0, - import_count: 0, - main_line: None, - } - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs deleted file mode 100644 index 84eaefdde23b..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/cache_tests.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Tests for the cache module - -use crate::developer::analyze::cache::AnalysisCache; -use crate::developer::analyze::types::{AnalysisMode, AnalysisResult, FunctionInfo}; -use std::path::PathBuf; -use std::time::SystemTime; - -fn create_test_result() -> AnalysisResult { - AnalysisResult { - functions: vec![FunctionInfo { - name: "test_func".to_string(), - line: 1, - params: vec![], - }], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 1, - class_count: 0, - line_count: 10, - import_count: 0, - main_line: None, - } -} - -#[test] -fn test_cache_hit_miss() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - // Initial miss - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); - - // Store and hit - cache.put(path.clone(), time, &AnalysisMode::Semantic, result.clone()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); - - // Different time = miss - let later = time + std::time::Duration::from_secs(1); - assert!(cache.get(&path, later, &AnalysisMode::Semantic).is_none()); -} - -#[test] -fn test_cache_eviction() { - let cache = AnalysisCache::new(2); - let result = create_test_result(); - let time = SystemTime::now(); - - // Fill cache - cache.put( - PathBuf::from("file1.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - cache.put( - PathBuf::from("file2.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - assert_eq!(cache.len(), 2); - - // Add third item, should evict first - cache.put( - PathBuf::from("file3.rs"), - time, - &AnalysisMode::Semantic, - result.clone(), - ); - assert_eq!(cache.len(), 2); - - // First item should be evicted - assert!(cache - .get(&PathBuf::from("file1.rs"), time, &AnalysisMode::Semantic) - .is_none()); - assert!(cache - .get(&PathBuf::from("file2.rs"), time, &AnalysisMode::Semantic) - .is_some()); - assert!(cache - .get(&PathBuf::from("file3.rs"), time, &AnalysisMode::Semantic) - .is_some()); -} - -#[test] -fn test_cache_clear() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - cache.put(path.clone(), time, &AnalysisMode::Semantic, result); - assert!(!cache.is_empty()); - - cache.clear(); - assert!(cache.is_empty()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); -} - -#[test] -fn test_cache_default() { - let cache = AnalysisCache::default(); - assert!(cache.is_empty()); - - // Default cache should work normally - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - cache.put(path.clone(), time, &AnalysisMode::Semantic, result); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); -} - -#[test] -fn test_cache_mode_separation() { - let cache = AnalysisCache::new(10); - let path = PathBuf::from("test.rs"); - let time = SystemTime::now(); - let result = create_test_result(); - - // Store in structure mode - cache.put(path.clone(), time, &AnalysisMode::Structure, result.clone()); - assert!(cache.get(&path, time, &AnalysisMode::Structure).is_some()); - - // Different mode should be a miss - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_none()); - - // Store in semantic mode - cache.put(path.clone(), time, &AnalysisMode::Semantic, result.clone()); - - // Both modes should now have cached results - assert!(cache.get(&path, time, &AnalysisMode::Structure).is_some()); - assert!(cache.get(&path, time, &AnalysisMode::Semantic).is_some()); - - // Cache should contain 2 entries (one per mode) - assert_eq!(cache.len(), 2); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs b/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs deleted file mode 100644 index 3c8d7d26be1d..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/fixtures.rs +++ /dev/null @@ -1,87 +0,0 @@ -// Shared test fixtures and utilities - -use crate::developer::analyze::types::{AnalysisResult, CallInfo, ClassInfo, FunctionInfo}; -use ignore::gitignore::Gitignore; - -/// Create a test AnalysisResult with sample data -pub fn create_test_result() -> AnalysisResult { - AnalysisResult { - functions: vec![ - FunctionInfo { - name: "main".to_string(), - line: 10, - params: vec![], - }, - FunctionInfo { - name: "helper".to_string(), - line: 20, - params: vec![], - }, - ], - classes: vec![ClassInfo { - name: "TestClass".to_string(), - line: 5, - methods: vec![], - }], - imports: vec!["use std::fs".to_string()], - calls: vec![], - references: vec![], - function_count: 2, - class_count: 1, - line_count: 100, - import_count: 1, - main_line: Some(10), - } -} - -/// Create a test result with specific functions and call relationships -pub fn create_test_result_with_calls( - functions: Vec<&str>, - calls: Vec<(&str, &str)>, -) -> AnalysisResult { - AnalysisResult { - functions: functions - .into_iter() - .map(|name| FunctionInfo { - name: name.to_string(), - line: 1, - params: vec![], - }) - .collect(), - classes: vec![], - imports: vec![], - calls: calls - .into_iter() - .map(|(caller, callee)| CallInfo { - caller_name: Some(caller.to_string()), - callee_name: callee.to_string(), - line: 1, - column: 0, - context: String::new(), - }) - .collect(), - references: vec![], - function_count: 0, - class_count: 0, - line_count: 0, - import_count: 0, - main_line: None, - } -} - -/// Create a simple test gitignore -pub fn create_test_gitignore() -> Gitignore { - let mut builder = ignore::gitignore::GitignoreBuilder::new("."); - builder.add_line(None, "*.log").unwrap(); - builder.add_line(None, "node_modules/").unwrap(); - builder.build().unwrap() -} - -/// Create a test gitignore with custom base path -#[allow(dead_code)] -pub fn create_test_gitignore_at(base_path: &std::path::Path) -> Gitignore { - let mut builder = ignore::gitignore::GitignoreBuilder::new(base_path); - builder.add_line(None, "*.log").unwrap(); - builder.add_line(None, "node_modules/").unwrap(); - builder.build().unwrap() -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs deleted file mode 100644 index ee315f194e63..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/formatter_tests.rs +++ /dev/null @@ -1,151 +0,0 @@ -// Tests for the formatter module - -use crate::developer::analyze::formatter::Formatter; -use crate::developer::analyze::tests::fixtures::create_test_result; -use crate::developer::analyze::types::{AnalysisMode, CallChain, EntryType, FocusedAnalysisData}; -use std::path::{Path, PathBuf}; - -#[test] -fn test_format_structure_overview() { - let result = create_test_result(); - let output = Formatter::format_structure_overview(Path::new("test.rs"), &result); - - assert!(output.contains("[100L, 2F, 1C]")); - assert!(output.contains("main:10")); -} - -#[test] -fn test_format_semantic_result() { - let result = create_test_result(); - let output = Formatter::format_semantic_result(Path::new("test.rs"), &result); - - assert!(output.contains("FILE: test.rs")); - assert!(output.contains("C: TestClass:5")); - assert!(output.contains("F: main:10 helper:20")); - assert!(output.contains("I: use std::fs")); -} - -#[test] -fn test_filter_by_focus() { - // The filter_by_focus function includes the whole section when it finds a match - // This is the expected behavior - if a symbol is found in a file, show the whole file section - let output = "## test.rs\nfunction main at line 10\nfunction helper at line 20\n## other.rs\nfunction foo at line 5\n"; - let filtered = Formatter::filter_by_focus(output, "main"); - - assert!(filtered.contains("main")); - // When we find 'main' in test.rs, we include the whole test.rs section including 'helper' - assert!(filtered.contains("helper")); - assert!(!filtered.contains("foo")); // But we don't include other.rs -} - -#[test] -fn test_format_analysis_result_modes() { - let result = create_test_result(); - let path = Path::new("test.rs"); - - // Test structure mode - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Structure); - assert!(output.contains("[100L, 2F, 1C]")); - - // Test semantic mode - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Semantic); - assert!(output.contains("FILE: test.rs")); - assert!(output.contains("C: TestClass:5")); - - // Test focused mode (should return empty string with warning) - let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Focused); - assert_eq!(output, ""); -} - -#[test] -fn test_format_directory_structure() { - let base_path = Path::new("/test"); - let result1 = create_test_result(); - let mut result2 = create_test_result(); - result2.line_count = 200; - - let results = vec![ - (PathBuf::from("/test/file1.rs"), EntryType::File(result1)), - (PathBuf::from("/test/dir"), EntryType::Directory), - ( - PathBuf::from("/test/dir/file2.rs"), - EntryType::File(result2), - ), - ]; - - let output = Formatter::format_directory_structure(base_path, &results, 2); - - // Check summary - assert!(output.contains("SUMMARY:")); - assert!(output.contains("2 files, 300L, 4F, 2C")); - assert!(output.contains("Languages: rust (100%)")); - - // Check file entries - assert!(output.contains("file1.rs [100L, 2F, 1C]")); - assert!(output.contains("file2.rs [200L, 2F, 1C]")); -} - -#[test] -fn test_format_focused_output() { - let focus_data = FocusedAnalysisData { - focus_symbol: "test_func", - definitions: &[(PathBuf::from("test.rs"), 10)], - incoming_chains: &[CallChain { - path: vec![( - PathBuf::from("test.rs"), - 20, - "caller".to_string(), - "test_func".to_string(), - )], - }], - outgoing_chains: &[CallChain { - path: vec![( - PathBuf::from("test.rs"), - 30, - "test_func".to_string(), - "callee".to_string(), - )], - }], - files_analyzed: &[PathBuf::from("test.rs")], - follow_depth: 2, - }; - - let output = Formatter::format_focused_output(&focus_data); - - assert!(output.contains("FOCUSED ANALYSIS: test_func")); - assert!(output.contains("DEFINITIONS:")); - assert!(output.contains("INCOMING CALL CHAINS")); - assert!(output.contains("OUTGOING CALL CHAINS")); - assert!(output.contains("STATISTICS:")); -} - -#[test] -fn test_format_focused_output_empty() { - let focus_data = FocusedAnalysisData { - focus_symbol: "nonexistent", - definitions: &[], - incoming_chains: &[], - outgoing_chains: &[], - files_analyzed: &[PathBuf::from("test.rs")], - follow_depth: 2, - }; - - let output = Formatter::format_focused_output(&focus_data); - - assert!(output.contains("Symbol 'nonexistent' not found")); -} - -#[test] -fn test_format_results_wrapper() { - let text = "Test output"; - let contents = Formatter::format_results(text.to_string()); - - assert_eq!(contents.len(), 2); - - // Check that both assistant and user content are created - let assistant_content = contents[0].as_text().unwrap(); - assert_eq!(assistant_content.text, "Test output"); - - let user_content = contents[1].as_text().unwrap(); - assert_eq!(user_content.text, "Test output"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs deleted file mode 100644 index 2d7fc4881a60..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; -use std::collections::HashSet; -use std::path::PathBuf; - -fn parse_and_extract(code: &str) -> AnalysisResult { - let manager = ParserManager::new(); - let tree = manager.parse(code, "go").unwrap(); - ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap() -} - -fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { - let manager = ParserManager::new(); - let results: Vec<_> = files - .iter() - .map(|(path, code)| { - let tree = manager.parse(code, "go").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap(); - (PathBuf::from(*path), result) - }) - .collect(); - CallGraph::build_from_results(&results) -} - -#[test] -fn test_go_struct_and_method_tracking() { - let code = r#" -package main - -import "myapp/pkg/service" - -type Config struct { - Host string - Port int -} - -type Handler struct { - Cfg *Config - Svc *service.Widget -} - -func (h *Handler) Start() error { - return nil -} - -func (h *Handler) Stop() error { - return nil -} - -func main() { - cfg := Config{Host: "localhost", Port: 8080} - handler := Handler{Cfg: &cfg} - _ = handler.Start() -} -"#; - - let result = parse_and_extract(code); - let graph = build_test_graph(vec![("test.go", code)]); - - assert_eq!(result.class_count, 2); - let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(struct_names.contains("Config")); - assert!(struct_names.contains("Handler")); - - assert_eq!(result.function_count, 3); - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("Start")); - assert!(method_names.contains("Stop")); - assert!(method_names.contains("main")); - - let handler_methods: Vec<_> = result - .references - .iter() - .filter(|r| { - r.ref_type == ReferenceType::MethodDefinition - && r.associated_type.as_deref() == Some("Handler") - }) - .collect(); - assert!( - handler_methods.len() >= 2, - "Expected at least 2 methods on Handler, found {}", - handler_methods.len() - ); - - let field_type_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::FieldType) - .collect(); - assert!( - !field_type_refs.is_empty(), - "Expected to find field type references" - ); - - let config_literals: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - !config_literals.is_empty(), - "Expected to find Config struct literals" - ); - - let incoming = graph.find_incoming_chains("Handler", 1); - assert!( - !incoming.is_empty(), - "Expected to find incoming references to Handler" - ); - - let outgoing = graph.find_outgoing_chains("Handler", 1); - assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs deleted file mode 100644 index 47b1ed04f65a..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/graph_tests.rs +++ /dev/null @@ -1,116 +0,0 @@ -// Tests for the graph module - -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::tests::fixtures::create_test_result_with_calls; -use std::path::PathBuf; - -#[test] -fn test_simple_call_chain() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b", "c"], vec![("a", "b"), ("b", "c")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Test incoming chains for 'c' - let chains = graph.find_incoming_chains("c", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // b->c, a->b - - // Test outgoing chains for 'a' - let chains = graph.find_outgoing_chains("a", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // a->b, b->c -} - -#[test] -fn test_circular_dependency() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b"], vec![("a", "b"), ("b", "a")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Should handle cycles without infinite loop - let chains = graph.find_incoming_chains("a", 3); - assert!(!chains.is_empty()); -} - -#[test] -fn test_empty_graph() { - let graph = CallGraph::new(); - - // Should return empty results for nonexistent symbols - let chains = graph.find_incoming_chains("nonexistent", 2); - assert!(chains.is_empty()); - - let chains = graph.find_outgoing_chains("nonexistent", 2); - assert!(chains.is_empty()); -} - -#[test] -fn test_max_depth_zero() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls(vec!["a", "b"], vec![("a", "b")]), - )]; - - let graph = CallGraph::build_from_results(&results); - - // max_depth of 0 should return empty results - let chains = graph.find_incoming_chains("b", 0); - assert!(chains.is_empty()); - - let chains = graph.find_outgoing_chains("a", 0); - assert!(chains.is_empty()); -} - -#[test] -fn test_multiple_callers() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls( - vec!["a", "b", "c", "target"], - vec![("a", "target"), ("b", "target"), ("c", "target")], - ), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Should find all three callers - let chains = graph.find_incoming_chains("target", 1); - assert_eq!(chains.len(), 3); - - // Each chain should have exactly one call - for chain in chains { - assert_eq!(chain.path.len(), 1); - } -} - -#[test] -fn test_deep_chain() { - let results = vec![( - PathBuf::from("test.rs"), - create_test_result_with_calls( - vec!["a", "b", "c", "d", "e"], - vec![("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")], - ), - )]; - - let graph = CallGraph::build_from_results(&results); - - // Test various depths - let chains = graph.find_incoming_chains("e", 1); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 1); // Just d->e - - let chains = graph.find_incoming_chains("e", 2); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 2); // c->d, d->e - - let chains = graph.find_incoming_chains("e", 4); - assert_eq!(chains.len(), 1); - assert_eq!(chains[0].path.len(), 4); // Full chain a->b->c->d->e -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs deleted file mode 100644 index 4db4a23dc6b6..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs +++ /dev/null @@ -1,244 +0,0 @@ -// Integration tests for the analyze module - -use crate::developer::analyze::tests::fixtures::create_test_gitignore; -use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use std::fs; -use tempfile::TempDir; - -#[test] -fn test_analyze_python_file() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write(&file_path, "def main():\n pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that we got content back - assert!(!result.content.is_empty()); -} - -#[test] -fn test_analyze_directory() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create test files - fs::write(dir_path.join("test1.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("test2.py"), "def test(): pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: dir_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that we got content back - assert!(!result.content.is_empty()); - - // Extract text content and verify it contains expected information - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("SUMMARY:")); - assert!(text_content.text.contains("test1.rs")); - assert!(text_content.text.contains("test2.py")); - } -} - -#[test] -fn test_focused_analysis() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write( - &file_path, - "def main():\n helper()\n\ndef helper():\n pass", - ) - .unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: Some("helper".to_string()), - follow_depth: 1, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Check that focused analysis output is generated - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("FOCUSED ANALYSIS: helper")); - assert!(text_content.text.contains("DEFINITIONS:")); - } -} - -#[test] -fn test_analyze_with_cache() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.rs"); - fs::write(&file_path, "fn main() {\n println!(\"Hello\");\n}").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - - // First analysis - should cache - let result1 = analyzer.analyze(params.clone(), file_path.clone(), &ignore); - assert!(result1.is_ok()); - - // Second analysis - should use cache - let result2 = analyzer.analyze(params, file_path, &ignore); - assert!(result2.is_ok()); - - // Results should be identical - let content1 = result1.unwrap().content[0].as_text().unwrap().text.clone(); - let content2 = result2.unwrap().content[0].as_text().unwrap().text.clone(); - assert_eq!(content1, content2); -} - -#[test] -fn test_analyze_unsupported_file() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.txt"); - fs::write(&file_path, "This is not code").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - // Should succeed but return minimal information - assert!(result.is_ok()); -} - -#[test] -fn test_analyze_nonexistent_path() { - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: "/nonexistent/path".to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, "/nonexistent/path".into(), &ignore); - - // Should return an error - assert!(result.is_err()); -} - -#[test] -fn test_focused_without_symbol() { - let temp_dir = TempDir::new().unwrap(); - let file_path = temp_dir.path().join("test.py"); - fs::write(&file_path, "def main(): pass").unwrap(); - - let analyzer = CodeAnalyzer::new(); - - // This should trigger focused mode due to having focus parameter - let params = AnalyzeParams { - path: file_path.to_string_lossy().to_string(), - focus: Some("nonexistent_symbol".to_string()), - follow_depth: 1, - max_depth: 3, - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, file_path, &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - // Should indicate symbol not found - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content - .text - .contains("Symbol 'nonexistent_symbol' not found")); - } -} - -#[test] -fn test_nested_directory_analysis() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create nested structure - let src_dir = dir_path.join("src"); - fs::create_dir(&src_dir).unwrap(); - fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap(); - - let lib_dir = src_dir.join("lib"); - fs::create_dir(&lib_dir).unwrap(); - fs::write(lib_dir.join("utils.rs"), "pub fn util() {}").unwrap(); - - let analyzer = CodeAnalyzer::new(); - let params = AnalyzeParams { - path: dir_path.to_string_lossy().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, // Increase max_depth to ensure we reach nested files - ast_recursion_limit: None, - force: false, - }; - - let ignore = create_test_gitignore(); - let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore); - - assert!(result.is_ok()); - let result = result.unwrap(); - - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("main.rs")); - // The directory structure analysis should show both files - assert!(text_content.text.contains("src")); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs deleted file mode 100644 index 5df17ddb79b3..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs +++ /dev/null @@ -1,140 +0,0 @@ -use super::fixtures::create_test_gitignore; -use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use std::fs; -use tempfile::TempDir; - -#[test] -fn test_large_output_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with many files to trigger the warning - let temp_dir = TempDir::new().unwrap(); - - // Create many Python files with lots of functions to ensure we exceed 1000 lines - // Each file generates about 1 line in structure mode, so we need 1000+ files - for i in 0..1100 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - // Each file will have multiple functions to generate more output - let mut content = String::new(); - for j in 0..10 { - content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j)); - } - for j in 0..5 { - content.push_str(&format!( - "class Class_{}_{}:\n def method(self):\n pass\n\n", - i, j - )); - } - fs::write(&file_path, content).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, // Should trigger warning - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got a warning, not the actual analysis - assert_eq!(result.content.len(), 1); - if let Some(text_content) = result.content[0].as_text() { - assert!(text_content.text.contains("LARGE OUTPUT WARNING")); - assert!(text_content.text.contains("force=true")); - assert!(text_content.text.contains("exceed")); - } else { - panic!("Expected text content"); - } -} - -#[test] -fn test_force_flag_bypasses_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with many files - let temp_dir = TempDir::new().unwrap(); - - // Create many Python files with lots of functions to ensure we exceed 1000 lines - for i in 0..50 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - // Each file will have multiple functions to generate more output - let mut content = String::new(); - for j in 0..10 { - content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j)); - } - for j in 0..5 { - content.push_str(&format!( - "class Class_{}_{}:\n def method(self):\n pass\n\n", - i, j - )); - } - fs::write(&file_path, content).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: true, // Should bypass warning - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got the actual analysis, not a warning - if let Some(text_content) = result.content[0].as_text() { - assert!(!text_content.text.contains("LARGE OUTPUT WARNING")); - // Should contain actual file analysis - assert!(text_content.text.contains("file0.py")); - assert!(text_content.text.contains("file29.py")); - } else { - panic!("Expected text content"); - } -} - -#[test] -fn test_small_output_no_warning() { - let analyzer = CodeAnalyzer::new(); - let gitignore = create_test_gitignore(); - - // Create a temp directory with just a few files - let temp_dir = TempDir::new().unwrap(); - - // Create only 2 Python files - should not trigger warning - for i in 0..2 { - let file_path = temp_dir.path().join(format!("file{}.py", i)); - fs::write(&file_path, format!("def function_{}():\n pass\n", i)).unwrap(); - } - - let params = AnalyzeParams { - path: temp_dir.path().to_str().unwrap().to_string(), - focus: None, - follow_depth: 2, - max_depth: 3, - ast_recursion_limit: None, - force: false, // Shouldn't matter for small output - }; - - let result = analyzer - .analyze(params, temp_dir.path().to_path_buf(), &gitignore) - .unwrap(); - - // Check that we got the actual analysis, not a warning - if let Some(text_content) = result.content[0].as_text() { - assert!(!text_content.text.contains("LARGE OUTPUT WARNING")); - assert!(text_content.text.contains("file0.py")); - assert!(text_content.text.contains("file1.py")); - } else { - panic!("Expected text content"); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/mod.rs b/crates/goose-mcp/src/developer/analyze/tests/mod.rs deleted file mode 100644 index 6da0e66d1c26..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -// Test modules for the analyze tool - -pub mod cache_tests; -pub mod fixtures; -pub mod formatter_tests; -pub mod go_test; -pub mod graph_tests; -pub mod integration_tests; -pub mod large_output_tests; -pub mod parser_tests; -pub mod ruby_test; -pub mod rust_test; -pub mod traversal_tests; diff --git a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs deleted file mode 100644 index 0b93dec6aa0f..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs +++ /dev/null @@ -1,305 +0,0 @@ -// Tests for the parser module - -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use std::sync::Arc; - -#[test] -fn test_parser_initialization() { - let manager = ParserManager::new(); - assert!(manager.get_or_create_parser("python").is_ok()); - assert!(manager.get_or_create_parser("rust").is_ok()); - assert!(manager.get_or_create_parser("unknown").is_err()); -} - -#[test] -fn test_parser_caching() { - let manager = ParserManager::new(); - - // First call creates parser - let parser1 = manager.get_or_create_parser("python").unwrap(); - - // Second call should return cached parser - let parser2 = manager.get_or_create_parser("python").unwrap(); - - // They should be the same Arc - assert!(Arc::ptr_eq(&parser1, &parser2)); -} - -#[test] -fn test_parse_python() { - let manager = ParserManager::new(); - let content = "def hello():\n pass"; - - let tree = manager.parse(content, "python").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_parse_rust() { - let manager = ParserManager::new(); - let content = "fn main() {\n println!(\"Hello\");\n}"; - - let tree = manager.parse(content, "rust").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_parse_javascript() { - let manager = ParserManager::new(); - let content = "function hello() {\n console.log('Hello');\n}"; - - let tree = manager.parse(content, "javascript").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_extract_python_elements() { - let manager = ParserManager::new(); - let content = r#" -import os - -class MyClass: - def method(self): - pass - -def main(): - print("hello") -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "python").unwrap(); - - assert_eq!(result.function_count, 2); // main and method - assert_eq!(result.class_count, 1); // MyClass - assert_eq!(result.import_count, 1); // import os - assert!(result.main_line.is_some()); -} - -#[test] -fn test_extract_rust_elements() { - let manager = ParserManager::new(); - let content = r#" -use std::fs; - -struct MyStruct { - field: i32, -} - -impl MyStruct { - fn new() -> Self { - Self { field: 0 } - } -} - -fn main() { - let s = MyStruct::new(); -} -"#; - - let tree = manager.parse(content, "rust").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "rust").unwrap(); - - assert_eq!(result.function_count, 2); // main and new - assert_eq!(result.class_count, 2); // MyStruct (struct) and MyStruct (impl) - assert_eq!(result.import_count, 1); // use std::fs - assert!(result.main_line.is_some()); -} - -#[test] -fn test_extract_with_depth_structure() { - let manager = ParserManager::new(); - let content = r#" -def func1(): - pass - -def func2(): - func1() -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "structure", None).unwrap(); - - // In structure mode, detailed vectors should be empty but counts preserved - assert_eq!(result.function_count, 2); - assert!(result.functions.is_empty()); - assert!(result.calls.is_empty()); -} - -#[test] -fn test_extract_with_depth_semantic() { - let manager = ParserManager::new(); - let content = r#" -def func1(): - pass - -def func2(): - func1() -"#; - - let tree = manager.parse(content, "python").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "semantic", None).unwrap(); - - // In semantic mode, should have both elements and calls - assert_eq!(result.function_count, 2); - assert_eq!(result.functions.len(), 2); - assert!(!result.calls.is_empty()); - assert_eq!(result.calls[0].callee_name, "func1"); -} - -#[test] -fn test_parse_invalid_syntax() { - let manager = ParserManager::new(); - let content = "def invalid syntax here"; - - // Should still parse (tree-sitter is error-tolerant) - let tree = manager.parse(content, "python"); - assert!(tree.is_ok()); -} - -#[test] -fn test_multiple_languages() { - let manager = ParserManager::new(); - - // Test that we can handle multiple languages in the same manager - assert!(manager.get_or_create_parser("python").is_ok()); - assert!(manager.get_or_create_parser("rust").is_ok()); - assert!(manager.get_or_create_parser("javascript").is_ok()); - assert!(manager.get_or_create_parser("go").is_ok()); - assert!(manager.get_or_create_parser("java").is_ok()); - assert!(manager.get_or_create_parser("kotlin").is_ok()); -} - -#[test] -fn test_parse_kotlin() { - let manager = ParserManager::new(); - let content = r#" -package com.example - -import kotlin.math.* - -class Example(val name: String) { - fun greet() { - println("Hello, $name") - } -} - -fun main() { - val example = Example("World") - example.greet() -} -"#; - - let tree = manager.parse(content, "kotlin").unwrap(); - assert!(tree.root_node().child_count() > 0); -} - -#[test] -fn test_extract_kotlin_elements() { - let manager = ParserManager::new(); - let content = r#" -package com.example - -import kotlin.math.* - -class MyClass { - fun method() { - println("method") - } -} - -fun main() { - println("hello") -} - -fun helper() { - main() -} -"#; - - let tree = manager.parse(content, "kotlin").unwrap(); - let result = ElementExtractor::extract_elements(&tree, content, "kotlin").unwrap(); - - assert_eq!(result.function_count, 3); // main, helper, method - assert_eq!(result.class_count, 1); // MyClass - assert!(result.import_count > 0); // import statements - assert!(result.main_line.is_some()); -} - -#[test] -fn test_language_registry() { - use crate::developer::analyze::languages; - - let supported = vec![ - "python", - "rust", - "javascript", - "typescript", - "go", - "java", - "kotlin", - "swift", - "ruby", - ]; - - for lang in supported { - let info = languages::get_language_info(lang); - assert!(info.is_some(), "Language {} should be supported", lang); - - let info = info.unwrap(); - assert!( - !info.element_query.is_empty(), - "{} missing element_query", - lang - ); - assert!(!info.call_query.is_empty(), "{} missing call_query", lang); - assert!( - !info.function_node_kinds.is_empty(), - "{} missing function_node_kinds", - lang - ); - assert!( - !info.function_name_kinds.is_empty(), - "{} missing function_name_kinds", - lang - ); - } - - let js = languages::get_language_info("javascript").unwrap(); - let ts = languages::get_language_info("typescript").unwrap(); - assert_eq!( - js.element_query, ts.element_query, - "JS/TS should share config" - ); - - let go = languages::get_language_info("go").unwrap(); - assert!( - !go.reference_query.is_empty(), - "Go should have reference tracking" - ); - assert!(go.find_method_for_receiver_handler.is_some()); - - let ruby = languages::get_language_info("ruby").unwrap(); - assert!( - !ruby.reference_query.is_empty(), - "Ruby should have reference tracking" - ); - assert!(ruby.find_method_for_receiver_handler.is_some()); - - let rust = languages::get_language_info("rust").unwrap(); - assert!( - rust.extract_function_name_handler.is_some(), - "Rust should have custom handler" - ); - - let swift = languages::get_language_info("swift").unwrap(); - assert!( - swift.extract_function_name_handler.is_some(), - "Swift should have custom handler" - ); - - assert!(languages::get_language_info("unsupported").is_none()); - assert!(languages::get_language_info("").is_none()); - assert!(languages::get_language_info("C++").is_none()); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs deleted file mode 100644 index 5f6616604738..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs +++ /dev/null @@ -1,259 +0,0 @@ -#[cfg(test)] -mod ruby_tests { - use crate::developer::analyze::graph::CallGraph; - use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; - use crate::developer::analyze::types::ReferenceType; - use std::collections::HashSet; - use std::path::PathBuf; - - #[test] - fn test_ruby_basic_parsing() { - let parser = ParserManager::new(); - let source = r#" -require 'json' - -class MyClass - attr_accessor :name - - def initialize(name) - @name = name - end - - def greet - puts "Hello" - end -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert_eq!(result.class_count, 1); - assert!(result.classes.iter().any(|c| c.name == "MyClass")); - - assert!(result.function_count > 0); - assert!(result.functions.iter().any(|f| f.name == "initialize")); - assert!(result.functions.iter().any(|f| f.name == "greet")); - - assert!(result.import_count > 0); - } - - #[test] - fn test_ruby_attr_methods() { - let parser = ParserManager::new(); - let source = r#" -class Person - attr_reader :age - attr_writer :status - attr_accessor :name -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert!( - result.function_count >= 3, - "Expected at least 3 functions from attr_* declarations, got {}", - result.function_count - ); - } - - #[test] - fn test_ruby_require_patterns() { - let parser = ParserManager::new(); - let source = r#" -require 'json' -require_relative 'lib/helper' -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); - - assert_eq!( - result.import_count, 2, - "Should find both require and require_relative" - ); - } - - #[test] - fn test_ruby_method_calls() { - let parser = ParserManager::new(); - let source = r#" -class Example - def test_method - puts "Hello" - JSON.parse("{}") - object.method_call - end -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); - - assert!(!result.calls.is_empty(), "Should find method calls"); - assert!(result.calls.iter().any(|c| c.callee_name == "puts")); - } - - #[test] - fn test_ruby_reference_tracking() { - let parser = ParserManager::new(); - let source = r#" -class User - attr_accessor :name - - def initialize(name) - @name = name - end - - def greet - puts "Hello, #{@name}" - end -end - -class Post - STATUS_DRAFT = "draft" - STATUS_PUBLISHED = "published" - - def initialize(title) - @title = title - @status = STATUS_DRAFT - end - - def publish - @status = STATUS_PUBLISHED - notify_users(@status) - end -end - -def main - user = User.new("Alice") - post = Post.new("My Title") - post.publish -end -"#; - - let tree = parser.parse(source, "ruby").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); - - assert_eq!(result.class_count, 2); - let class_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(class_names.contains("User")); - assert!(class_names.contains("Post")); - - assert!(result.function_count > 0); - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("initialize")); - assert!(method_names.contains("greet")); - assert!(method_names.contains("publish")); - - let constant_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") - .collect(); - assert!( - !constant_refs.is_empty(), - "Expected to find constant references" - ); - - let instantiations: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - instantiations.len() >= 2, - "Expected at least 2 class instantiations (User.new, Post.new)" - ); - let instantiated_types: HashSet<_> = - instantiations.iter().map(|r| r.symbol.as_str()).collect(); - assert!(instantiated_types.contains("User")); - assert!(instantiated_types.contains("Post")); - - let constant_usages: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") - .collect(); - assert!( - !constant_usages.is_empty(), - "Expected to find STATUS_* constant usages" - ); - } - - #[test] - fn test_ruby_call_chains() { - let parser = ParserManager::new(); - - let file1 = r#" -class User - def initialize(name) - @name = name - end - - def display - format_output(@name) - end - - def format_output(text) - "User: #{text}" - end -end -"#; - - let file2 = r#" -require_relative 'user' - -def create_user(name) - User.new(name) -end - -def show_user(name) - user = create_user(name) - user.display -end -"#; - - let tree1 = parser.parse(file1, "ruby").unwrap(); - let result1 = - ElementExtractor::extract_with_depth(&tree1, file1, "ruby", "semantic", None).unwrap(); - - let tree2 = parser.parse(file2, "ruby").unwrap(); - let result2 = - ElementExtractor::extract_with_depth(&tree2, file2, "ruby", "semantic", None).unwrap(); - - let results = vec![ - (PathBuf::from("user.rb"), result1), - (PathBuf::from("main.rb"), result2), - ]; - let graph = CallGraph::build_from_results(&results); - - let incoming_user = graph.find_incoming_chains("User", 1); - assert!( - !incoming_user.is_empty(), - "Expected incoming references to User class" - ); - - let outgoing_display = graph.find_outgoing_chains("display", 1); - assert!( - !outgoing_display.is_empty(), - "Expected display to call format_output" - ); - - let outgoing_create = graph.find_outgoing_chains("create_user", 2); - assert!( - !outgoing_create.is_empty(), - "Expected create_user to have call chains" - ); - - let incoming_create = graph.find_incoming_chains("create_user", 1); - assert!( - !incoming_create.is_empty(), - "Expected show_user to call create_user" - ); - } -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs b/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs deleted file mode 100644 index d0bae3c91d4c..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/rust_test.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::developer::analyze::graph::CallGraph; -use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; -use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; -use std::collections::HashSet; -use std::path::PathBuf; - -fn parse_and_extract(code: &str) -> AnalysisResult { - let manager = ParserManager::new(); - let tree = manager.parse(code, "rust").unwrap(); - ElementExtractor::extract_with_depth(&tree, code, "rust", "semantic", None).unwrap() -} - -fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { - let manager = ParserManager::new(); - let results: Vec<_> = files - .iter() - .map(|(path, code)| { - let tree = manager.parse(code, "rust").unwrap(); - let result = - ElementExtractor::extract_with_depth(&tree, code, "rust", "semantic", None) - .unwrap(); - (PathBuf::from(*path), result) - }) - .collect(); - CallGraph::build_from_results(&results) -} - -#[test] -fn test_rust_self_parameter_type_resolution() { - // Test that self parameters correctly resolve to their impl type - let code = r#" -struct MyStruct { - value: i32, -} - -impl MyStruct { - fn method_with_self(&self) -> i32 { - self.value - } - - fn method_with_mut_self(&mut self) { - self.value += 1; - } - - fn associated_function() -> Self { - MyStruct { value: 0 } - } -} -"#; - - let result = parse_and_extract(code); - - // Find method references with self parameters - let self_methods: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::MethodDefinition) - .collect(); - - // Should find both methods with self parameters - assert_eq!( - self_methods.len(), - 2, - "Expected 2 methods with self parameters" - ); - - // Both should be associated with MyStruct - for method_ref in &self_methods { - assert_eq!( - method_ref.associated_type.as_deref(), - Some("MyStruct"), - "Method {} should be associated with MyStruct", - method_ref.symbol - ); - } - - // Verify the specific methods - let method_names: HashSet<_> = self_methods.iter().map(|r| r.symbol.as_str()).collect(); - assert!(method_names.contains("method_with_self")); - assert!(method_names.contains("method_with_mut_self")); -} - -#[test] -fn test_rust_struct_and_impl_tracking() { - let code = r#" -struct Config { - host: String, - port: u16, -} - -struct Handler { - cfg: Config, -} - -impl Handler { - fn new(cfg: Config) -> Self { - Handler { cfg } - } - - fn start(&self) -> Result<(), String> { - Ok(()) - } -} - -fn main() { - let cfg = Config { host: "localhost".to_string(), port: 8080 }; - let handler = Handler::new(cfg); - let _ = handler.start(); -} -"#; - - let result = parse_and_extract(code); - let graph = build_test_graph(vec![("test.rs", code)]); - - // Test struct extraction (includes impl blocks) - assert_eq!(result.class_count, 3); // Config, Handler, impl Handler - let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); - assert!(struct_names.contains("Config")); - assert!(struct_names.contains("Handler")); - - // Test method extraction - let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); - assert!(method_names.contains("new")); - assert!(method_names.contains("start")); - assert!(method_names.contains("main")); - - // Test method-to-type associations (only methods with self parameter) - let handler_methods: Vec<_> = result - .references - .iter() - .filter(|r| { - r.ref_type == ReferenceType::MethodDefinition - && r.associated_type.as_deref() == Some("Handler") - }) - .collect(); - assert!( - !handler_methods.is_empty(), - "Expected at least 1 method on Handler (start), found {}", - handler_methods.len() - ); - - // Verify the method is 'start' (new doesn't have self, so it's not tracked) - assert!( - handler_methods.iter().any(|r| r.symbol == "start"), - "Expected to find 'start' method on Handler" - ); - - // Test field type tracking - let field_type_refs: Vec<_> = result - .references - .iter() - .filter(|r| r.ref_type == ReferenceType::FieldType) - .collect(); - assert!( - !field_type_refs.is_empty(), - "Expected to find field type references" - ); - - // Test struct instantiation - let config_literals: Vec<_> = result - .references - .iter() - .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) - .collect(); - assert!( - !config_literals.is_empty(), - "Expected to find Config struct literals" - ); - - // Test call graph integration - let incoming = graph.find_incoming_chains("Handler", 1); - assert!( - !incoming.is_empty(), - "Expected to find incoming references to Handler" - ); - - let outgoing = graph.find_outgoing_chains("Handler", 1); - assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); -} diff --git a/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs deleted file mode 100644 index 8e99dc0a531d..000000000000 --- a/crates/goose-mcp/src/developer/analyze/tests/traversal_tests.rs +++ /dev/null @@ -1,190 +0,0 @@ -// Tests for the traversal module - -use crate::developer::analyze::tests::fixtures::create_test_gitignore; -use crate::developer::analyze::traversal::FileTraverser; -use ignore::gitignore::Gitignore; -use std::fs; -use std::path::Path; -use tempfile::TempDir; - -#[test] -fn test_is_ignored() { - // Create a temporary directory for testing - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create actual files and directories to test - fs::write(dir_path.join("test.log"), "log content").unwrap(); - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - - // Create gitignore that ignores .log files - let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path); - builder.add_line(None, "*.log").unwrap(); - let ignore = builder.build().unwrap(); - - let traverser = FileTraverser::new(&ignore); - - // Test that .log files are ignored and .rs files are not - assert!(traverser.is_ignored(&dir_path.join("test.log"))); - assert!(!traverser.is_ignored(&dir_path.join("test.rs"))); -} - -#[test] -fn test_validate_path() { - let ignore = create_test_gitignore(); - let traverser = FileTraverser::new(&ignore); - - // Test nonexistent path - assert!(traverser - .validate_path(Path::new("/nonexistent/path")) - .is_err()); - - // Test ignored path - assert!(traverser.validate_path(Path::new("test.log")).is_err()); -} - -#[test] -fn test_collect_files() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create test files - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("test.py"), "def main(): pass").unwrap(); - fs::write(dir_path.join("test.txt"), "not code").unwrap(); - - // Create subdirectory with file - let sub_dir = dir_path.join("src"); - fs::create_dir(&sub_dir).unwrap(); - fs::write(sub_dir.join("lib.rs"), "pub fn test() {}").unwrap(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find .rs and .py files but not .txt - assert_eq!(files.len(), 3); - assert!(files.iter().any(|p| p.ends_with("test.rs"))); - assert!(files.iter().any(|p| p.ends_with("test.py"))); - assert!(files.iter().any(|p| p.ends_with("lib.rs"))); -} - -#[test] -fn test_max_depth() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create nested structure - fs::write(dir_path.join("root.rs"), "").unwrap(); - - let level1 = dir_path.join("level1"); - fs::create_dir(&level1).unwrap(); - fs::write(level1.join("file1.rs"), "").unwrap(); - - let level2 = level1.join("level2"); - fs::create_dir(&level2).unwrap(); - fs::write(level2.join("file2.rs"), "").unwrap(); - - let level3 = level2.join("level3"); - fs::create_dir(&level3).unwrap(); - fs::write(level3.join("file3.rs"), "").unwrap(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - // Test that limiting depth works - exact counts may vary based on implementation - // The important thing is that deeper files are excluded with lower max_depth - - // With a small max_depth, we should find fewer files - let files_limited = traverser.collect_files_for_focused(dir_path, 2).unwrap(); - - // With unlimited depth, we should find all files - let files_unlimited = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // The unlimited search should find more files than the limited one - assert!( - files_unlimited.len() > files_limited.len(), - "Unlimited depth should find more files than limited depth" - ); - - // Should always find the root file - assert!(files_unlimited.iter().any(|p| p.ends_with("root.rs"))); - - // With unlimited, should find all 4 files - assert_eq!( - files_unlimited.len(), - 4, - "Should find all 4 files with unlimited depth" - ); -} - -#[test] -fn test_symlink_handling() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create a file and directory - fs::write(dir_path.join("target.rs"), "fn main() {}").unwrap(); - let target_dir = dir_path.join("target_dir"); - fs::create_dir(&target_dir).unwrap(); - fs::write(target_dir.join("inner.rs"), "fn test() {}").unwrap(); - - // Create symlinks (if supported by the OS) - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - let _ = symlink(dir_path.join("target.rs"), dir_path.join("link.rs")); - let _ = symlink(&target_dir, dir_path.join("link_dir")); - } - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - // Collect files - symlinks should be handled appropriately - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find the actual files - assert!(files.iter().any(|p| p.ends_with("target.rs"))); - assert!(files.iter().any(|p| p.ends_with("inner.rs"))); -} - -#[test] -fn test_empty_directory() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - let ignore = Gitignore::empty(); - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - assert_eq!(files.len(), 0); -} - -#[test] -fn test_gitignore_patterns() { - let temp_dir = TempDir::new().unwrap(); - let dir_path = temp_dir.path(); - - // Create files - fs::write(dir_path.join("test.log"), "log").unwrap(); - fs::write(dir_path.join("debug.log"), "debug").unwrap(); - fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap(); - fs::write(dir_path.join("main.py"), "def main(): pass").unwrap(); - - // Create gitignore that only ignores .log files - let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path); - builder.add_line(None, "*.log").unwrap(); - let ignore = builder.build().unwrap(); - - let traverser = FileTraverser::new(&ignore); - - let files = traverser.collect_files_for_focused(dir_path, 0).unwrap(); - - // Should find .rs and .py files, but not .log files - assert_eq!(files.len(), 2, "Should find 2 non-log files"); - assert!(files.iter().any(|p| p.ends_with("test.rs"))); - assert!(files.iter().any(|p| p.ends_with("main.py"))); - assert!(!files.iter().any(|p| p.ends_with(".log"))); -} diff --git a/crates/goose-mcp/src/developer/analyze/traversal.rs b/crates/goose-mcp/src/developer/analyze/traversal.rs deleted file mode 100644 index bc0b3701be75..000000000000 --- a/crates/goose-mcp/src/developer/analyze/traversal.rs +++ /dev/null @@ -1,171 +0,0 @@ -use ignore::gitignore::Gitignore; -use rayon::prelude::*; -use rmcp::model::{ErrorCode, ErrorData}; -use std::path::{Path, PathBuf}; - -use crate::developer::analyze::types::{AnalysisResult, EntryType}; -use crate::developer::lang; - -/// Handles file system traversal with ignore patterns -pub struct FileTraverser<'a> { - ignore_patterns: &'a Gitignore, -} - -impl<'a> FileTraverser<'a> { - /// Create a new file traverser with the given ignore patterns - pub fn new(ignore_patterns: &'a Gitignore) -> Self { - Self { ignore_patterns } - } - - /// Check if a path should be ignored - pub fn is_ignored(&self, path: &Path) -> bool { - let ignored = self.ignore_patterns.matched(path, false).is_ignore(); - if ignored { - tracing::trace!("Path {:?} is ignored", path); - } - ignored - } - - /// Validate that a path exists and is not ignored - pub fn validate_path(&self, path: &Path) -> Result<(), ErrorData> { - // Check if path is ignored - if self.is_ignored(path) { - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!( - "Access to '{}' is restricted by .gooseignore", - path.display() - ), - None, - )); - } - - // Check if path exists - if !path.exists() { - return Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - format!("Path '{}' does not exist", path.display()), - None, - )); - } - - Ok(()) - } - - /// Collect all files for focused analysis - pub fn collect_files_for_focused( - &self, - path: &Path, - max_depth: u32, - ) -> Result, ErrorData> { - tracing::debug!( - "Collecting files from {:?} with max_depth {}", - path, - max_depth - ); - - if max_depth == 0 { - tracing::warn!("Unlimited depth traversal requested for {:?}", path); - } - - let files = self.collect_files_recursive(path, 0, max_depth)?; - - tracing::info!("Collected {} files from {:?}", files.len(), path); - Ok(files) - } - - /// Recursively collect files - fn collect_files_recursive( - &self, - path: &Path, - current_depth: u32, - max_depth: u32, - ) -> Result, ErrorData> { - let mut files = Vec::new(); - - // Check if we're at a file (base case) - if path.is_file() { - let lang = lang::get_language_identifier(path); - if !lang.is_empty() { - tracing::trace!("Including file {:?} (language: {})", path, lang); - files.push(path.to_path_buf()); - } - return Ok(files); - } - - // max_depth of 0 means unlimited depth - // current_depth starts at 0, max_depth is the number of directory levels to traverse - if max_depth > 0 && current_depth >= max_depth { - tracing::trace!("Reached max depth {} at {:?}", max_depth, path); - return Ok(files); - } - - let entries = std::fs::read_dir(path).map_err(|e| { - tracing::error!("Failed to read directory {:?}: {}", path, e); - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to read directory: {}", e), - None, - ) - })?; - - for entry in entries { - let entry = entry.map_err(|e| { - ErrorData::new( - ErrorCode::INTERNAL_ERROR, - format!("Failed to read directory entry: {}", e), - None, - ) - })?; - - let entry_path = entry.path(); - - // Skip ignored paths - if self.is_ignored(&entry_path) { - continue; - } - - if entry_path.is_file() { - // Only include supported file types - let lang = lang::get_language_identifier(&entry_path); - if !lang.is_empty() { - tracing::trace!("Including file {:?} (language: {})", entry_path, lang); - files.push(entry_path); - } - } else if entry_path.is_dir() { - // Recurse into subdirectory - let mut sub_files = - self.collect_files_recursive(&entry_path, current_depth + 1, max_depth)?; - files.append(&mut sub_files); - } - } - - Ok(files) - } - - /// Collect directory results for analysis with parallel processing - pub fn collect_directory_results( - &self, - path: &Path, - max_depth: u32, - analyze_file: F, - ) -> Result, ErrorData> - where - F: Fn(&Path) -> Result + Sync, - { - tracing::debug!("Collecting directory results from {:?}", path); - - // First collect all files to analyze - let files_to_analyze = self.collect_files_recursive(path, 0, max_depth)?; - - // Then analyze them in parallel using Rayon - let results: Result, ErrorData> = files_to_analyze - .par_iter() - .map(|file_path| { - analyze_file(file_path).map(|result| (file_path.clone(), EntryType::File(result))) - }) - .collect(); - - results - } -} diff --git a/crates/goose-mcp/src/developer/analyze/types.rs b/crates/goose-mcp/src/developer/analyze/types.rs deleted file mode 100644 index ec39af645dbd..000000000000 --- a/crates/goose-mcp/src/developer/analyze/types.rs +++ /dev/null @@ -1,176 +0,0 @@ -use rmcp::schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AnalyzeParams { - pub path: String, - - pub focus: Option, - - /// Call graph depth. 0=where defined, 1=direct callers/callees, 2+=transitive chains - #[serde(default = "default_follow_depth")] - pub follow_depth: u32, - - /// Directory recursion limit. 0=unlimited (warning: fails on binary files) - #[serde(default = "default_max_depth")] - pub max_depth: u32, - - /// Maximum depth for recursive AST traversal (prevents stack overflow in deeply nested code) - #[serde(default)] - pub ast_recursion_limit: Option, - - /// Allow large outputs without warning (default: false) - #[serde(default)] - pub force: bool, -} - -fn default_follow_depth() -> u32 { - 2 -} - -fn default_max_depth() -> u32 { - 3 -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AnalysisResult { - pub functions: Vec, - pub classes: Vec, - pub imports: Vec, - // Semantic analysis fields - pub calls: Vec, - pub references: Vec, - // Structure mode fields (for compact overview) - pub function_count: usize, - pub class_count: usize, - pub line_count: usize, - pub import_count: usize, - pub main_line: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct FunctionInfo { - pub name: String, - pub line: usize, - pub params: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ClassInfo { - pub name: String, - pub line: usize, - pub methods: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CallInfo { - pub caller_name: Option, - pub callee_name: String, - pub line: usize, - pub column: usize, - pub context: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ReferenceInfo { - pub symbol: String, - pub ref_type: ReferenceType, - pub line: usize, - pub context: String, - /// For method definitions, this stores the type to which the method belongs - /// For type usage, this is None - pub associated_type: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum ReferenceType { - /// Type/class/struct definition - Definition, - /// Method or function definition on a type (use associated_type to link to type) - MethodDefinition, - /// Function call or method call - Call, - /// Type instantiation (e.g., struct literal, class constructor) - TypeInstantiation, - /// Type used in field declaration - FieldType, - /// Type used in variable declaration - VariableType, - /// Type used in function/method parameter - ParameterType, - /// Import statement - Import, -} - -// Entry type for directory results - cleaner than overloading AnalysisResult -#[derive(Debug, Clone)] -pub enum EntryType { - File(AnalysisResult), - Directory, - SymlinkDir(PathBuf), - SymlinkFile(PathBuf), -} - -// Type alias for complex query results -pub type ElementQueryResult = (Vec, Vec, Vec); - -#[derive(Debug, Clone)] -pub struct CallChain { - pub path: Vec<(PathBuf, usize, String, String)>, // (file, line, from, to) -} - -// Data structure to pass to format_focused_output_with_chains -pub struct FocusedAnalysisData<'a> { - pub focus_symbol: &'a str, - pub follow_depth: u32, - pub files_analyzed: &'a [PathBuf], - pub definitions: &'a [(PathBuf, usize)], - pub incoming_chains: &'a [CallChain], - pub outgoing_chains: &'a [CallChain], -} - -/// Analysis modes -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum AnalysisMode { - Structure, // Directory overview - Semantic, // File details - Focused, // Symbol tracking -} - -impl AnalysisMode { - pub fn as_str(&self) -> &str { - match self { - AnalysisMode::Structure => "structure", - AnalysisMode::Semantic => "semantic", - AnalysisMode::Focused => "focused", - } - } - - pub fn parse(s: &str) -> Self { - match s { - "structure" => AnalysisMode::Structure, - "semantic" => AnalysisMode::Semantic, - "focused" => AnalysisMode::Focused, - _ => AnalysisMode::Structure, - } - } -} - -impl AnalysisResult { - /// Create an empty analysis result with only line count - pub fn empty(line_count: usize) -> Self { - Self { - functions: vec![], - classes: vec![], - imports: vec![], - calls: vec![], - references: vec![], - function_count: 0, - class_count: 0, - line_count, - import_count: 0, - main_line: None, - } - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md b/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md deleted file mode 100644 index 4dd395eb8d58..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/EDITOR_API_EXAMPLE.md +++ /dev/null @@ -1,84 +0,0 @@ -# Enhanced Code Editing with AI Models - -The developer extension now supports using AI models for enhanced code editing through the `str_replace` command. When configured, it will use an AI model to intelligently apply code changes instead of simple string replacement. - -## Configuration - -Set these environment variables to enable AI-powered code editing: - -```bash -export GOOSE_EDITOR_API_KEY="your-api-key-here" -export GOOSE_EDITOR_HOST="https://api.openai.com/v1" -export GOOSE_EDITOR_MODEL="gpt-4o" -``` - -**All three environment variables must be set and non-empty for the feature to activate.** - -### Supported Providers - -Any OpenAI-compatible API endpoint should work. Examples: - -**OpenAI:** -```bash -export GOOSE_EDITOR_API_KEY="sk-..." -export GOOSE_EDITOR_HOST="https://api.openai.com/v1" -export GOOSE_EDITOR_MODEL="gpt-4o" -``` - -**Anthropic (via OpenAI-compatible proxy):** -```bash -export GOOSE_EDITOR_API_KEY="sk-ant-..." -export GOOSE_EDITOR_HOST="https://api.anthropic.com/v1" -export GOOSE_EDITOR_MODEL="claude-sonnet-4-20250514" -``` - -**Morph:** -```bash -export GOOSE_EDITOR_API_KEY="sk-..." -export GOOSE_EDITOR_HOST="https://api.morphllm.com/v1" -export GOOSE_EDITOR_MODEL="morph-v3-large" -``` - -**Relace** -```bash -export GOOSE_EDITOR_API_KEY="rlc-..." -export GOOSE_EDITOR_HOST="https://instantapply.endpoint.relace.run/v1/apply" -export GOOSE_EDITOR_MODEL="auto" -``` - -**Local/Custom endpoints:** -```bash -export GOOSE_EDITOR_API_KEY="your-key" -export GOOSE_EDITOR_HOST="http://localhost:8000/v1" -export GOOSE_EDITOR_MODEL="your-model" -``` - -## How it works - -When you use the `str_replace` command in the text editor: - -1. **Configuration check**: The system first checks if all three environment variables are properly set and non-empty. - -2. **With AI enabled**: If configured, the system sends the original code and your requested change to the configured AI model, which intelligently applies the change while maintaining code structure, formatting, and context. - -3. **Fallback**: If the AI API is not configured or the API call fails, it falls back to simple string replacement as before. - -4. **User feedback**: The first time you use `str_replace` without AI configuration, you'll see a helpful message explaining how to enable the feature. - -## Benefits - -- **Context-aware editing**: The AI understands code structure and can make more intelligent changes -- **Better formatting**: Maintains consistent code style and formatting -- **Error prevention**: Can catch and fix potential issues during the edit -- **Flexible**: Works with any OpenAI-compatible API -- **Clean implementation**: Uses proper control flow instead of exception handling for configuration checks - -## Implementation Details - -The implementation follows idiomatic Rust patterns: -- Environment variables are checked upfront before attempting API calls -- No exceptions are used for normal control flow -- Clear separation between configured and unconfigured states -- Graceful fallback behavior in all cases - -The feature is completely optional and backwards compatible - if not configured, the system works exactly as before with simple string replacement. \ No newline at end of file diff --git a/crates/goose-mcp/src/developer/editor_models/mod.rs b/crates/goose-mcp/src/developer/editor_models/mod.rs deleted file mode 100644 index 5b8fd1adc26a..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/mod.rs +++ /dev/null @@ -1,98 +0,0 @@ -mod morphllm_editor; -mod openai_compatible_editor; -mod relace_editor; - -use anyhow::Result; - -pub use morphllm_editor::MorphLLMEditor; -pub use openai_compatible_editor::OpenAICompatibleEditor; -pub use relace_editor::RelaceEditor; - -/// Enum for different editor models that can perform intelligent code editing -#[derive(Debug, Clone)] -pub enum EditorModel { - MorphLLM(MorphLLMEditor), - OpenAICompatible(OpenAICompatibleEditor), - Relace(RelaceEditor), -} - -impl EditorModel { - /// Call the editor API to perform intelligent code replacement - pub async fn edit_code( - &self, - original_code: &str, - old_str: &str, - update_snippet: &str, - ) -> Result { - match self { - EditorModel::MorphLLM(editor) => { - editor - .edit_code(original_code, old_str, update_snippet) - .await - } - EditorModel::OpenAICompatible(editor) => { - editor - .edit_code(original_code, old_str, update_snippet) - .await - } - EditorModel::Relace(editor) => { - editor - .edit_code(original_code, old_str, update_snippet) - .await - } - } - } - - /// Get the description for the str_replace command when this editor is active - pub fn get_str_replace_description(&self) -> &'static str { - match self { - EditorModel::MorphLLM(editor) => editor.get_str_replace_description(), - EditorModel::OpenAICompatible(editor) => editor.get_str_replace_description(), - EditorModel::Relace(editor) => editor.get_str_replace_description(), - } - } -} - -/// Trait for individual editor implementations -pub trait EditorModelImpl { - /// Call the editor API to perform intelligent code replacement - async fn edit_code( - &self, - original_code: &str, - old_str: &str, - update_snippet: &str, - ) -> Result; - - /// Get the description for the str_replace command when this editor is active - fn get_str_replace_description(&self) -> &'static str; -} - -/// Factory function to create the appropriate editor model based on environment variables -pub fn create_editor_model() -> Option { - // Don't use Editor API during tests - if cfg!(test) { - return None; - } - - // Check if basic editor API variables are set - let api_key = std::env::var("GOOSE_EDITOR_API_KEY").ok()?; - let host = std::env::var("GOOSE_EDITOR_HOST").ok()?; - let model = std::env::var("GOOSE_EDITOR_MODEL").ok()?; - - if api_key.is_empty() || host.is_empty() || model.is_empty() { - return None; - } - - // Determine which editor to use based on the host - if host.contains("relace.run") { - Some(EditorModel::Relace(RelaceEditor::new(api_key, host, model))) - } else if host.contains("api.morphllm") || model.contains("morph") { - Some(EditorModel::MorphLLM(MorphLLMEditor::new( - api_key, host, model, - ))) - } else { - Some(EditorModel::OpenAICompatible(OpenAICompatibleEditor::new( - api_key, host, model, - ))) - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs b/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs deleted file mode 100644 index 44170a48cf81..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/morphllm_editor.rs +++ /dev/null @@ -1,310 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// MorphLLM editor that uses the standard chat completions format -#[derive(Debug, Clone)] -pub struct MorphLLMEditor { - api_key: String, - host: String, - model: String, -} - -impl MorphLLMEditor { - pub fn new(api_key: String, host: String, model: String) -> Self { - Self { - api_key, - host, - model, - } - } - - /// Extract content between XML tags - fn extract_tag_content(text: &str, tag_name: &str) -> Option { - let start_tag = format!("<{}>", tag_name); - let end_tag = format!("", tag_name); - - if let (Some(start_pos), Some(end_pos)) = (text.find(&start_tag), text.find(&end_tag)) { - if start_pos < end_pos { - let content_start = start_pos + start_tag.len(); - if let Some(content) = text.get(content_start..end_pos) { - return Some(content.trim().to_string()); - } - } - } - None - } - - fn format_user_prompt(original_code: &str, update_snippet: &str) -> String { - if let Some(code_content) = Self::extract_tag_content(update_snippet, "code") { - // Look for instruction tags which help provide hints - if let Some(instruction_content) = - Self::extract_tag_content(update_snippet, "instruction") - { - // Both code and instruction tags found - return format!( - "{}\n{}\n{}", - instruction_content, original_code, code_content - ); - } - // Only code tags found, no instruction - return format!( - "{}\n{}", - original_code, code_content - ); - } - format!( - "{}\n{}", - original_code, update_snippet - ) - } -} - -impl EditorModelImpl for MorphLLMEditor { - async fn edit_code( - &self, - original_code: &str, - _old_str: &str, - update_snippet: &str, - ) -> Result { - // Construct the full URL - let provider_url = if self.host.ends_with("/chat/completions") { - self.host.clone() - } else if self.host.ends_with('/') { - format!("{}chat/completions", self.host) - } else { - format!("{}/chat/completions", self.host) - }; - - // Create the client - let client = Client::new(); - - // Parse update_snippet for and tags - let user_prompt = Self::format_user_prompt(original_code, update_snippet); - - // Prepare the request body for OpenAI-compatible API - let body = json!({ - "model": self.model, - "messages": [ - { - "role": "user", - "content": user_prompt - } - ] - }); - - // Send the request - let response = match client - .post(&provider_url) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(&body) - .send() - .await - { - Ok(resp) => resp, - Err(e) => return Err(format!("Request error: {}", e)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - let content = response_json - .get("choices") - .and_then(|choices| choices.get(0)) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .and_then(|content| content.as_str()) - .ok_or_else(|| "Invalid response format".to_string())?; - - Ok(content.to_string()) - } - - fn get_str_replace_description(&self) -> &'static str { - "Use the edit_file to propose an edit to an existing file. - This will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write. - - **IMPORTANT**: in the new_str parameter, you must also provide an `instruction` - a single sentence written in the first person describing what you are going to do for the sketched edit. - This instruction helps the less intelligent model understand and apply your edit correctly. - - Examples of good instructions: - - I am adding error handling to the user authentication function and removing the old authentication method - - The instruction should be specific enough to disambiguate any uncertainty in your edit. - - - The format for new_str should be like this example: - - - new code here you want to add - - - adding new code with error handling - - - provide this to new_str as a single string. - - When writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines. - - For example: - // ... existing code ... - FIRST_EDIT - // ... existing code ... - SECOND_EDIT - // ... existing code ... - THIRD_EDIT - // ... existing code ... - - You should bias towards repeating as few lines of the original file as possible to convey the change. - Each edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity. - If you plan on deleting a section, you must provide surrounding context to indicate the deletion. - DO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence. - " - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_tag_content_valid() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, Some("fn main() {}".to_string())); - } - - #[test] - fn test_extract_tag_content_with_whitespace() { - let text = " I am adding a print statement "; - let result = MorphLLMEditor::extract_tag_content(text, "instruction"); - assert_eq!(result, Some("I am adding a print statement".to_string())); - } - - #[test] - fn test_extract_tag_content_invalid_order() { - let text = "Invalid"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_missing_end_tag() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_missing_start_tag() { - let text = "fn main() {}"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, None); - } - - #[test] - fn test_extract_tag_content_nested_tags() { - let text = "fn main() { nested }"; - let result = MorphLLMEditor::extract_tag_content(text, "code"); - assert_eq!(result, Some("fn main() { nested".to_string())); - } - - #[test] - fn test_format_user_prompt_no_tags() { - let original_code = "fn main() {}"; - let update_snippet = "Add error handling"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nAdd error handling" - ); - } - - #[test] - fn test_format_user_prompt_with_code_tags_only() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { println!(\"Hello\"); }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_with_both_tags() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { println!(\"Hello\"); }I am adding a print statement"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_with_whitespace() { - let original_code = "fn main() {}"; - let update_snippet = " fn main() { println!(\"Hello\"); } I am adding a print statement "; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_invalid_code_tags() { - let original_code = "fn main() {}"; - let update_snippet = "Invalid"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nInvalid" - ); - } - - #[test] - fn test_format_user_prompt_invalid_instruction_tags() { - let original_code = "fn main() {}"; - let update_snippet = - "fn main() { println!(\"Hello\"); }Invalid"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "fn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } - - #[test] - fn test_format_user_prompt_nested_tags() { - let original_code = "fn main() {}"; - let update_snippet = "fn main() { nested }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - // Should use the first occurrence of and find its matching - assert_eq!( - result, - "fn main() {}\nfn main() { nested" - ); - } - - #[test] - fn test_format_user_prompt_tags_in_different_order() { - let original_code = "fn main() {}"; - let update_snippet = "I am adding a print statementfn main() { println!(\"Hello\"); }"; - let result = MorphLLMEditor::format_user_prompt(original_code, update_snippet); - assert_eq!( - result, - "I am adding a print statement\nfn main() {}\nfn main() { println!(\"Hello\"); }" - ); - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs b/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs deleted file mode 100644 index 52778d559930..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/openai_compatible_editor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// OpenAI-compatible editor that uses the standard chat completions format -#[derive(Debug, Clone)] -pub struct OpenAICompatibleEditor { - api_key: String, - host: String, - model: String, -} - -impl OpenAICompatibleEditor { - pub fn new(api_key: String, host: String, model: String) -> Self { - Self { - api_key, - host, - model, - } - } -} - -impl EditorModelImpl for OpenAICompatibleEditor { - async fn edit_code( - &self, - original_code: &str, - _old_str: &str, - update_snippet: &str, - ) -> Result { - eprintln!("Calling OpenAI-compatible Editor API"); - - // Construct the full URL - let provider_url = if self.host.ends_with("/chat/completions") { - self.host.clone() - } else if self.host.ends_with('/') { - format!("{}chat/completions", self.host) - } else { - format!("{}/chat/completions", self.host) - }; - - // Create the client - let client = Client::new(); - - // Format the prompt as specified in the Python example - let user_prompt = format!( - "{}\n{}", - original_code, update_snippet - ); - - // Prepare the request body for OpenAI-compatible API - let body = json!({ - "model": self.model, - "messages": [ - { - "role": "user", - "content": user_prompt - } - ] - }); - - // Send the request - let response = match client - .post(&provider_url) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(&body) - .send() - .await - { - Ok(resp) => resp, - Err(e) => return Err(format!("Request error: {}", e)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - let content = response_json - .get("choices") - .and_then(|choices| choices.get(0)) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .and_then(|content| content.as_str()) - .ok_or_else(|| "Invalid response format".to_string())?; - - eprintln!("OpenAI-compatible Editor API worked"); - Ok(content.to_string()) - } - - fn get_str_replace_description(&self) -> &'static str { - "Edit the file with the new content." - } -} diff --git a/crates/goose-mcp/src/developer/editor_models/relace_editor.rs b/crates/goose-mcp/src/developer/editor_models/relace_editor.rs deleted file mode 100644 index cc7a6d0b25cb..000000000000 --- a/crates/goose-mcp/src/developer/editor_models/relace_editor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use super::EditorModelImpl; -use anyhow::Result; -use reqwest::Client; -use serde_json::{json, Value}; - -/// Relace-specific editor that uses the predicted outputs convention -#[derive(Debug, Clone)] -pub struct RelaceEditor { - api_key: String, - host: String, - model: String, -} - -impl RelaceEditor { - pub fn new(api_key: String, host: String, model: String) -> Self { - Self { - api_key, - host, - model, - } - } -} - -impl EditorModelImpl for RelaceEditor { - async fn edit_code( - &self, - original_code: &str, - _old_str: &str, - update_snippet: &str, - ) -> Result { - eprintln!("Calling Relace Editor API"); - - // Construct the full URL - let provider_url = if self.host.ends_with("/chat/completions") { - self.host.clone() - } else if self.host.ends_with('/') { - format!("{}chat/completions", self.host) - } else { - format!("{}/chat/completions", self.host) - }; - - // Create the client - let client = Client::new(); - - // Prepare the request body for Relace API - // The Relace endpoint expects the OpenAI predicted outputs convention - // where the original code is supplied under `prediction` and the - // update snippet is the sole user message. - let body = json!({ - "model": self.model, - "prediction": { - "content": original_code - }, - "messages": [ - { - "role": "user", - "content": update_snippet - } - ] - }); - - // Send the request - let response = match client - .post(&provider_url) - .header("Content-Type", "application/json") - .header("Authorization", format!("Bearer {}", self.api_key)) - .json(&body) - .send() - .await - { - Ok(resp) => resp, - Err(e) => return Err(format!("Request error: {}", e)), - }; - - // Process the response - if !response.status().is_success() { - return Err(format!("API error: HTTP {}", response.status())); - } - - // Parse the JSON response - let response_json: Value = match response.json().await { - Ok(json) => json, - Err(e) => return Err(format!("Failed to parse response: {}", e)), - }; - - // Extract the content from the response - let content = response_json - .get("choices") - .and_then(|choices| choices.get(0)) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .and_then(|content| content.as_str()) - .ok_or_else(|| "Invalid response format".to_string())?; - - eprintln!("Relace Editor API worked"); - Ok(content.to_string()) - } - - fn get_str_replace_description(&self) -> &'static str { - "edit_file will take the new_str and work out how to place old_str with it intelligently." - } -} diff --git a/crates/goose-mcp/src/developer/lang.rs b/crates/goose-mcp/src/developer/lang.rs deleted file mode 100644 index 590f065d7c49..000000000000 --- a/crates/goose-mcp/src/developer/lang.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::path::Path; - -/// Get the markdown language identifier for a file extension -pub fn get_language_identifier(path: &Path) -> &'static str { - match path.extension().and_then(|ext| ext.to_str()) { - Some("rs") => "rust", - Some("hs") => "haskell", - Some("rkt") | Some("scm") => "scheme", - Some("py") => "python", - Some("js") => "javascript", - Some("ts") => "typescript", - Some("json") => "json", - Some("toml") => "toml", - Some("yaml") | Some("yml") => "yaml", - Some("sh") => "bash", - Some("ps1") => "powershell", - Some("bat") | Some("cmd") => "batch", - Some("vbs") => "vbscript", - Some("go") => "go", - Some("md") => "markdown", - Some("html") => "html", - Some("css") => "css", - Some("sql") => "sql", - Some("java") => "java", - Some("cpp") | Some("cc") | Some("cxx") => "cpp", - Some("c") => "c", - Some("h") | Some("hpp") => "cpp", - Some("rb") => "ruby", - Some("php") => "php", - Some("swift") => "swift", - Some("kt") | Some("kts") => "kotlin", - Some("scala") => "scala", - Some("r") => "r", - Some("m") => "matlab", - Some("pl") => "perl", - Some("dockerfile") => "dockerfile", - _ => "", - } -} diff --git a/crates/goose-mcp/src/developer/mod.rs b/crates/goose-mcp/src/developer/mod.rs deleted file mode 100644 index 282e638c554d..000000000000 --- a/crates/goose-mcp/src/developer/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -pub mod analyze; -mod editor_models; -mod lang; -pub mod paths; -mod shell; -mod text_editor; - -pub mod rmcp_developer; - -#[cfg(test)] -mod tests; diff --git a/crates/goose-mcp/src/developer/paths.rs b/crates/goose-mcp/src/developer/paths.rs deleted file mode 100644 index 9eb500d77a8d..000000000000 --- a/crates/goose-mcp/src/developer/paths.rs +++ /dev/null @@ -1,115 +0,0 @@ -use crate::subprocess::SubprocessExt; -use anyhow::Result; -use std::env; -use std::path::PathBuf; -use tokio::process::Command; -use tokio::sync::OnceCell; - -static SHELL_PATH_DIRS: OnceCell, anyhow::Error>> = OnceCell::const_new(); - -pub async fn get_shell_path_dirs() -> Result<&'static Vec> { - let result = SHELL_PATH_DIRS - .get_or_init(|| async { - get_shell_path_async() - .await - .map(|path| env::split_paths(&path).collect()) - }) - .await; - - match result { - Ok(dirs) => Ok(dirs), - Err(e) => Err(anyhow::anyhow!( - "Failed to get shell PATH directories: {}", - e - )), - } -} - -async fn get_shell_path_async() -> Result { - let shell = env::var("SHELL").unwrap_or_else(|_| { - if cfg!(windows) { - "cmd".to_string() - } else { - "/bin/bash".to_string() - } - }); - - if cfg!(windows) { - get_windows_path_async(&shell).await - } else { - get_unix_path_async(&shell).await - } - .or_else(|e| { - tracing::warn!( - "Failed to get PATH from shell ({}), falling back to current PATH", - e - ); - env::var("PATH").map_err(|_| anyhow::anyhow!("No PATH variable available")) - }) -} - -async fn get_unix_path_async(shell: &str) -> Result { - let output = Command::new(shell) - .args(["-l", "-i", "-c", "echo $PATH"]) - .output() - .await - .map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); - } - - let path = String::from_utf8(output.stdout) - .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? - .trim() - .to_string(); - - if path.is_empty() { - return Err(anyhow::anyhow!("Shell returned empty PATH")); - } - - Ok(path) -} - -async fn get_windows_path_async(shell: &str) -> Result { - let shell_name = std::path::Path::new(shell) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("cmd"); - - let output = match shell_name { - "pwsh" | "powershell" => { - Command::new(shell) - .args(["-NoLogo", "-Command", "$env:PATH"]) - .set_no_window() - .output() - .await - } - _ => { - Command::new(shell) - .args(["/c", "echo %PATH%"]) - .set_no_window() - .output() - .await - } - }; - - let output = output.map_err(|e| anyhow::anyhow!("Failed to execute shell command: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Shell command failed: {}", stderr)); - } - - let path = String::from_utf8(output.stdout) - .map_err(|e| anyhow::anyhow!("Invalid UTF-8 in shell output: {}", e))? - .trim() - .to_string(); - - if path.is_empty() { - return Err(anyhow::anyhow!("Shell returned empty PATH")); - } - - Ok(path) -} diff --git a/crates/goose-mcp/src/developer/prompts/unit_test.json b/crates/goose-mcp/src/developer/prompts/unit_test.json deleted file mode 100644 index abef9414f405..000000000000 --- a/crates/goose-mcp/src/developer/prompts/unit_test.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "unit_test", - "template": "Generate or update unit tests for a given source code file.\n\nThe source code file is provided in {source_code}.\nPlease update the existing tests, ensure they are passing, and add any new tests as needed.\n\nThe test suite should:\n- Follow language-specific test naming conventions for {language}\n- Include all necessary imports and annotations\n- Thoroughly test the specified functionality\n- Ensure tests are passing before completion\n- Handle edge cases and error conditions\n- Use clear test names that reflect what is being tested", - "arguments": [ - { - "name": "source_code", - "description": "The source code file content to be tested", - "required": true - }, - { - "name": "language", - "description": "The programming language of the source code", - "required": true - } - ] - } \ No newline at end of file diff --git a/crates/goose-mcp/src/developer/rmcp_developer.rs b/crates/goose-mcp/src/developer/rmcp_developer.rs deleted file mode 100644 index 0a1b3ce147a6..000000000000 --- a/crates/goose-mcp/src/developer/rmcp_developer.rs +++ /dev/null @@ -1,3671 +0,0 @@ -use anyhow::anyhow; -use base64::Engine; -use etcetera::AppStrategy; -use ignore::gitignore::{Gitignore, GitignoreBuilder}; -use include_dir::{include_dir, Dir}; -use indoc::{formatdoc, indoc}; -use once_cell::sync::Lazy; -use rmcp::{ - handler::server::{router::tool::ToolRouter, wrapper::Parameters}, - model::{ - CallToolResult, CancelledNotificationParam, Content, ErrorCode, ErrorData, - GetPromptRequestParams, GetPromptResult, Implementation, ListPromptsResult, LoggingLevel, - LoggingMessageNotificationParam, Meta, PaginatedRequestParams, Prompt, PromptArgument, - PromptMessage, PromptMessageRole, Role, ServerCapabilities, ServerInfo, - }, - schemars::JsonSchema, - service::{NotificationContext, RequestContext}, - tool, tool_handler, tool_router, RoleServer, ServerHandler, -}; - -const WORKING_DIR_HEADER: &str = "agent-working-dir"; -const SESSION_ID_HEADER: &str = "agent-session-id"; - -pub const WORKING_DIR_PLACEHOLDER: &str = "{{WORKING_DIR}}"; - -fn extract_working_dir_from_meta(meta: &Meta) -> Option { - meta.0 - .get(WORKING_DIR_HEADER) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .filter(|s| !s.contains('\0')) - .map(PathBuf::from) -} - -fn extract_session_id_from_meta(meta: &Meta) -> Option { - meta.0 - .get(SESSION_ID_HEADER) - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .filter(|s| !s.contains('\0')) - .map(String::from) -} - -use serde::{Deserialize, Serialize}; -use std::{ - collections::HashMap, - env::join_paths, - ffi::OsString, - future::Future, - io::Cursor, - path::{Path, PathBuf}, - sync::{Arc, Mutex}, -}; -use xcap::{Monitor, Window}; - -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - sync::RwLock, -}; -use tokio_stream::{wrappers::SplitStream, StreamExt as _}; -use tokio_util::sync::CancellationToken; - -use crate::developer::{paths::get_shell_path_dirs, shell::ShellConfig}; - -use super::analyze::{types::AnalyzeParams, CodeAnalyzer}; -use super::editor_models::{create_editor_model, EditorModel}; -use super::shell::{configure_shell_command, expand_path, is_absolute_path, kill_process_group}; -use super::text_editor::{ - text_editor_insert, text_editor_replace, text_editor_undo, text_editor_view, text_editor_write, -}; - -/// Parameters for the screen_capture tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ScreenCaptureParams { - /// The display number to capture (0 is main display) - #[serde(default)] - pub display: Option, - - /// Optional: the exact title of the window to capture. - /// Use the list_windows tool to find the available windows. - pub window_title: Option, -} - -/// Parameters for the text_editor tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct TextEditorParams { - /// Absolute path to file or directory, e.g. `/repo/file.py` or `/repo`. - pub path: String, - - /// The operation to perform. Allowed options are: `view`, `write`, `str_replace`, `insert`, `undo_edit`. - pub command: String, - - /// Unified diff to apply. Supports editing multiple files simultaneously. Cannot create or delete files - /// Example: "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n context" - /// Preferred edit method. - pub diff: Option, - - /// Optional array of two integers specifying the start and end line numbers to view. - /// Line numbers are 1-indexed, and -1 for the end line means read to the end of the file. - /// This parameter only applies when viewing files, not directories. - pub view_range: Option>, - - /// The content to write to the file. Required for `write` command. - pub file_text: Option, - - /// The old string to replace. - pub old_str: Option, - - /// The new string to replace with. Required for `insert` command. - pub new_str: Option, - - /// The line number after which to insert text (0 for beginning). Required for `insert` command. - pub insert_line: Option, -} - -/// Parameters for the shell tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ShellParams { - /// The command string to execute in the shell - pub command: String, -} - -/// Parameters for the image_processor tool -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ImageProcessorParams { - /// Absolute path to the image file to process - pub path: String, -} - -/// Template structure for prompt definitions -#[derive(Debug, Serialize, Deserialize)] -pub struct PromptTemplate { - pub id: String, - pub template: String, - pub arguments: Vec, -} - -/// Template structure for prompt arguments -#[derive(Debug, Serialize, Deserialize)] -pub struct PromptArgumentTemplate { - pub name: String, - pub description: Option, - pub required: Option, -} - -// Embeds the prompts directory to the build -static PROMPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/developer/prompts"); - -static MACOS_SCREENSHOT_FILENAME_RE: Lazy = Lazy::new(|| { - regex::Regex::new( - r"^Screenshot \d{4}-\d{2}-\d{2} at \d{1,2}\.\d{2}\.\d{2} (AM|PM|am|pm)(?: \(\d+\))?\.png$", - ) - .expect("macOS screenshot filename regex should be valid") -}); - -const DEFAULT_GOOSEIGNORE_CONTENT: &str = concat!( - "# This file is created automatically if no .gooseignore exists.\n", - "# Customize or uncomment the patterns below instead of deleting the file.\n", - "# Removing it will simply cause goose to recreate it on the next start.\n", - "#\n", - "# Suggested patterns you can uncomment if desired:\n", - "# **/.ssh/** # block SSH keys and configs\n", - "# **/*.key # block loose private keys\n", - "# **/*.pem # block certificates/private keys\n", - "# **/.git/** # block git metadata entirely\n", - "# **/target/** # block Rust build artifacts\n", - "# **/node_modules/** # block JS/TS dependencies\n", - "# **/*.db # block local database files\n", - "# **/*.sqlite # block SQLite databases\n", - "#\n", - "\n", - "**/.env\n", - "**/.env.*\n", - "**/secrets.*\n", -); - -/// Loads prompt files from the embedded PROMPTS_DIR and returns a HashMap of prompts. -/// Ensures that each prompt name is unique. -fn load_prompt_files() -> HashMap { - let mut prompts = HashMap::new(); - - for entry in PROMPTS_DIR.files() { - // Only process JSON files - if entry.path().extension().is_none_or(|ext| ext != "json") { - continue; - } - - let prompt_str = String::from_utf8_lossy(entry.contents()).into_owned(); - - let template: PromptTemplate = match serde_json::from_str(&prompt_str) { - Ok(t) => t, - Err(e) => { - eprintln!( - "Failed to parse prompt template in {}: {}", - entry.path().display(), - e - ); - continue; // Skip invalid prompt file - } - }; - - let arguments = template - .arguments - .into_iter() - .map(|arg| PromptArgument { - name: arg.name, - description: arg.description, - required: arg.required, - title: None, - }) - .collect::>(); - - let prompt = Prompt::new(&template.id, Some(&template.template), Some(arguments)); - - if prompts.contains_key(&prompt.name) { - eprintln!("Duplicate prompt name '{}' found. Skipping.", prompt.name); - continue; // Skip duplicate prompt name - } - - prompts.insert(prompt.name.clone(), prompt); - } - - prompts -} - -/// Developer MCP Server using official RMCP SDK -#[derive(Clone)] -pub struct DeveloperServer { - tool_router: ToolRouter, - file_history: Arc>>>, - ignore_patterns: Gitignore, - editor_model: Option, - prompts: HashMap, - code_analyzer: CodeAnalyzer, - #[cfg(test)] - pub running_processes: Arc>>, - #[cfg(not(test))] - running_processes: Arc>>, - bash_env_file: Option, - extend_path_with_shell: bool, -} - -#[tool_handler(router = self.tool_router)] -impl ServerHandler for DeveloperServer { - #[allow(clippy::too_many_lines)] - fn get_info(&self) -> ServerInfo { - let os = std::env::consts::OS; - let in_container = Self::is_definitely_container(); - - let base_instructions = match os { - "windows" => formatdoc! {r#" - The developer extension gives you the capabilities to edit code files and run shell commands, - and can be used to solve a wide range of problems. - - You can use the shell tool to run Windows commands (PowerShell or CMD). - When using paths, you can use either backslashes or forward slashes. - - Use the shell tool as needed to locate files or interact with the project. - - Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context - - delegate analysis, retain summaries - - Your windows/screen tools can be used for visual debugging. You should not use these tools unless - prompted to, but you can mention they are available if they are relevant. - - operating system: {os} - current directory: {cwd} - {container_info} - "#, - os=os, - cwd=WORKING_DIR_PLACEHOLDER, - container_info=if in_container { "container: true" } else { "" }, - }, - _ => { - let shell_info = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - formatdoc! {r#" - The developer extension gives you the capabilities to edit code files and run shell commands, - and can be used to solve a wide range of problems. - - You can use the shell tool to run any command that would work on the relevant operating system. - Use the shell tool as needed to locate files or interact with the project. - - Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context - - delegate analysis, retain summaries - - Your windows/screen tools can be used for visual debugging. You should not use these tools unless - prompted to, but you can mention they are available if they are relevant. - - Always prefer ripgrep (rg -C 3) to grep. - - operating system: {os} - current directory: {cwd} - shell: {shell} - {container_info} - "#, - os=os, - cwd=WORKING_DIR_PLACEHOLDER, - shell=shell_info, - container_info=if in_container { "container: true" } else { "" }, - } - } - }; - - // Check if editor model exists and augment with custom llm editor tool description - let editor_description = if let Some(ref editor) = self.editor_model { - formatdoc! {r#" - - Additional Text Editor Tool Instructions: - - Perform text editing operations on files. - The `command` parameter specifies the operation to perform. Allowed options are: - - `view`: View the content of a file. - - `write`: Create or overwrite a file with the given content - - `str_replace`: Replace text in one or more files. - - `insert`: Insert text at a specific line location in the file. - - `undo_edit`: Undo the last edit made to a file. - - To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with - existing files! This is a full overwrite, so you must include everything - not just sections you are modifying. - - To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end) - and `new_str` (the text to insert). - - To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff. - To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one - unique section of the original file, including any whitespace. Make sure to include enough context that the match is not - ambiguous. The entire original string will be replaced with `new_str` - - When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call. - - {} - - "#, editor.get_str_replace_description()} - } else { - formatdoc! {r#" - - Additional Text Editor Tool Instructions: - - Perform text editing operations on files. - - The `command` parameter specifies the operation to perform. Allowed options are: - - `view`: View the content of a file. - - `write`: Create or overwrite a file with the given content - - `str_replace`: Replace text in one or more files. - - `insert`: Insert text at a specific line location in the file. - - `undo_edit`: Undo the last edit made to a file. - - To use the write command, you must specify `file_text` which will become the new content of the file. Be careful with - existing files! This is a full overwrite, so you must include everything - not just sections you are modifying. - - To use the str_replace command to edit multiple files, use the `diff` parameter with a unified diff. - To use the str_replace command to edit one file, you must specify both `old_str` and `new_str` - the `old_str` needs to exactly match one - unique section of the original file, including any whitespace. Make sure to include enough context that the match is not - ambiguous. The entire original string will be replaced with `new_str` - - When possible, batch file edits together by using a multi-file unified `diff` within a single str_replace tool call. - - To use the insert command, you must specify both `insert_line` (the line number after which to insert, 0 for beginning, -1 for end) - and `new_str` (the text to insert). - - - "#} - }; - - // Create comprehensive shell tool instructions - let common_shell_instructions = indoc! {r#" - Additional Shell Tool Instructions: - Execute a command in the shell. - - This will return the output and error concatenated into a single string, as - you would see from running on the command line. There will also be an indication - of if the command succeeded or failed. - - Avoid commands that produce a large amount of output, and consider piping those outputs to files. - - **Important**: Each shell command runs in its own process. Things like directory changes or - sourcing files do not persist between tool calls. So you may need to repeat them each time by - stringing together commands. - - If fetching web content, consider adding Accept: text/markdown header - "#}; - - let windows_specific = indoc! {r#" - **Important**: For searching files and code: - - Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast: - - To locate a file by name: `rg --files | rg example.py` - - To locate content inside files: `rg 'class Example'` - - Alternative Windows commands (if ripgrep is not installed): - - To locate a file by name: `dir /s /b example.py` - - To locate content inside files: `findstr /s /i "class Example" *.py` - - Note: Alternative commands may show ignored/hidden files that should be excluded. - - - Multiple commands: Use && to chain commands, avoid newlines - - Example: `cd example && dir` or `activate.bat && pip install numpy` - - **Important**: Use forward slashes in paths (e.g., `C:/Users/name`) to avoid - escape character issues with backslashes, i.e. \n in a path could be - mistaken for a newline. - "#}; - - let unix_specific = indoc! {r#" - If you need to run a long lived command, background it - e.g. `uvicorn main:app &` so that - this tool does not run indefinitely. - - **Important**: Use ripgrep - `rg` - exclusively when you need to locate a file or a code reference, - other solutions may produce too large output because of hidden files! For example *do not* use `find` or `ls -r` - - List files by name: `rg --files | rg ` - - List files that contain a regex: `rg '' -l` - - - Multiple commands: Use && to chain commands, avoid newlines - - Example: `cd example && ls` or `source env/bin/activate && pip install numpy` - "#}; - - let shell_tool_desc = match os { - "windows" => format!("{}{}", common_shell_instructions, windows_specific), - _ => format!("{}{}", common_shell_instructions, unix_specific), - }; - - let instructions = format!("{base_instructions}{editor_description}\n{shell_tool_desc}"); - - ServerInfo { - server_info: Implementation { - name: "goose-developer".to_string(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: None, - description: None, - icons: None, - website_url: None, - }, - capabilities: ServerCapabilities::builder() - .enable_tools() - .enable_prompts() - .build(), - instructions: Some(instructions), - ..Default::default() - } - } - - // TODO: use the rmcp prompt macros instead when SDK is updated - // Current rmcp version 0.6.0 doesn't support prompt macros yet. - // When upgrading to a newer version that supports it, replace this manual - // implementation with the macro-based approach for better maintainability. - fn list_prompts( - &self, - _request: Option, - _context: RequestContext, - ) -> impl Future> + Send + '_ { - let prompts: Vec = self.prompts.values().cloned().collect(); - std::future::ready(Ok(ListPromptsResult { - prompts, - next_cursor: None, - meta: None, - })) - } - - fn get_prompt( - &self, - request: GetPromptRequestParams, - _context: RequestContext, - ) -> impl Future> + Send + '_ { - let prompt_name = request.name; - let arguments = request.arguments.unwrap_or_default(); - - match self.prompts.get(&prompt_name) { - Some(prompt) => { - // Get the template from the prompt description - let template = prompt.description.clone().unwrap_or_default(); - - // Validate template length - if template.len() > 10000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INTERNAL_ERROR, - "Prompt template exceeds maximum allowed length".to_string(), - None, - ))); - } - - // Validate arguments for security (same checks as router) - for (key, value) in &arguments { - // Check for empty or overly long keys/values - if key.is_empty() || key.len() > 1000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Argument keys must be between 1-1000 characters".to_string(), - None, - ))); - } - - let value_str = value.as_str().unwrap_or_default(); - if value_str.len() > 1000 { - return std::future::ready(Err(ErrorData::new( - ErrorCode::INVALID_PARAMS, - "Argument values must not exceed 1000 characters".to_string(), - None, - ))); - } - - // Check for potentially dangerous patterns - let dangerous_patterns = ["../", "//", "\\\\", "