diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42877e..a26c677 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - macos-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: clippy,rustfmt - uses: Swatinem/rust-cache@v2 @@ -32,7 +32,7 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: components: clippy,rustfmt - uses: Swatinem/rust-cache@v2 @@ -55,6 +55,14 @@ jobs: } $env:AGENTMESH_LATEST_VERSION = "v$($versionLine.Matches[0].Groups[1].Value)" powershell -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -PrintUrl + $devUrl = powershell -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -Channel dev -PrintUrl + if ($devUrl -notmatch "/dev/agentmesh-dev-x86_64-pc-windows-msvc\.tar\.gz$") { + throw "unexpected dev installer URL: $devUrl" + } + $devWrapperUrl = powershell -NoProfile -ExecutionPolicy Bypass -File installers/install-dev.ps1 -PrintUrl + if ($devWrapperUrl -notmatch "/dev/agentmesh-dev-x86_64-pc-windows-msvc\.tar\.gz$") { + throw "unexpected install-dev.ps1 URL: $devWrapperUrl" + } - name: Verify Windows installer banner rendering shell: powershell run: | @@ -124,7 +132,10 @@ jobs: && args[3] == "--bundle" && Path::new(&args[4]).is_file() && args[5] == "--certificate-identity-regexp" - && args[6] == r"^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" + && ( + args[6] == r"^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" + || args[6] == r"^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$" + ) && args[7] == "--certificate-oidc-issuer" && args[8] == "https://token.actions.githubusercontent.com" && Path::new(&args[9]).is_file(); @@ -146,6 +157,23 @@ jobs: & (Join-Path $install "agentmesh.exe") --version Assert-LastExitCode "installed agentmesh.exe did not run" + $devRelease = Join-Path $tmp "releases\dev" + $devStage = Join-Path $tmp "dev-stage\agentmesh" + $devInstall = Join-Path $tmp "dev-install" + New-Item -ItemType Directory -Path $devRelease, $devStage, $devInstall -Force | Out-Null + Copy-Item target\release\agentmesh.exe (Join-Path $devStage "agentmesh.exe") + $devArtifact = "agentmesh-dev-x86_64-pc-windows-msvc.tar.gz" + tar -czf (Join-Path $devRelease $devArtifact) -C (Join-Path $tmp "dev-stage") agentmesh + Assert-LastExitCode "failed to create dev test release archive" + $devHash = (Get-FileHash -Algorithm SHA256 -Path (Join-Path $devRelease $devArtifact)).Hash.ToLowerInvariant() + Set-Content -NoNewline -Path (Join-Path $devRelease "SHA256SUMS") -Value "$devHash $devArtifact`n" + New-Item -ItemType File -Path (Join-Path $devRelease "SHA256SUMS.sig"), (Join-Path $devRelease "SHA256SUMS.bundle") -Force | Out-Null + + powershell -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -Channel dev -InstallDir $devInstall + Assert-LastExitCode "install.ps1 failed for valid fake dev release" + & (Join-Path $devInstall "agentmesh.exe") --version + Assert-LastExitCode "installed dev agentmesh.exe did not run" + Set-Content -NoNewline -Path (Join-Path $release "SHA256SUMS") -Value "0000 $artifact`n" powershell -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 -Channel stable -InstallDir (Join-Path $tmp "bad-install") $badExitCode = $LASTEXITCODE @@ -159,7 +187,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: x86_64-unknown-linux-musl - uses: Swatinem/rust-cache@v2 @@ -172,21 +200,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 - uses: taiki-e/install-action@v2 with: tool: cargo-deny,cargo-audit - run: make ci-supply-chain - - run: cargo deny --manifest-path fuzz/Cargo.toml check - - run: cargo audit --file fuzz/Cargo.lock installers: name: installer smoke runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + - uses: dtolnay/rust-toolchain@1.88.0 - uses: Swatinem/rust-cache@v2 - run: cargo build --locked --release -p agentmesh - run: make ci-installers @@ -240,13 +266,17 @@ jobs: case "$stable_tag" in v[0-9]*.[0-9]*.[0-9]*) ;; *) echo "failed to parse stable installer URL: $stable_artifact_url" >&2; exit 1 ;; esac case "$stable_version" in [0-9]*.[0-9]*.[0-9]*) ;; *) echo "failed to parse stable installer URL: $stable_artifact_url" >&2; exit 1 ;; esac nightly="$(sh installers/install.sh --print-url --channel=nightly)" + dev="$(sh installers/install.sh --print-url --channel=dev)" + dev_wrapper="$(sh installers/install-dev.sh --print-url)" case "$stable" in */"$stable_tag"/agentmesh-v"$stable_version"-*) ;; *) exit 1 ;; esac case "$nightly" in */nightly/*) ;; *) exit 1 ;; esac + case "$dev" in */dev/agentmesh-dev-*) ;; *) exit 1 ;; esac + case "$dev_wrapper" in */dev/agentmesh-dev-*) ;; *) exit 1 ;; esac tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT platform="$(sh installers/install.sh --print-platform)" - mkdir -p "$tmp/releases/$stable_tag" "$tmp/releases/nightly" "$tmp/cosign-base" + mkdir -p "$tmp/releases/$stable_tag" "$tmp/releases/nightly" "$tmp/releases/dev" "$tmp/cosign-base" cosign_artifact="$tmp/cosign-base/cosign-linux-amd64" cat >"$cosign_artifact" <<'EOF' @@ -259,7 +289,11 @@ jobs: test "$4" = "--bundle" test -f "$5" test "$6" = "--certificate-identity-regexp" - test "$7" = "^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" + case "$7" in + "^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*") ;; + "^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$") ;; + *) exit 1 ;; + esac test "$8" = "--certificate-oidc-issuer" test "$9" = "https://token.actions.githubusercontent.com" test -f "${10}" @@ -284,8 +318,10 @@ jobs: stable_artifact="agentmesh-v$stable_version-$platform.tar.gz" nightly_artifact="agentmesh-nightly-$platform.tar.gz" + dev_artifact="agentmesh-dev-$platform.tar.gz" create_release "$stable_tag" "$stable_artifact" create_release "nightly" "$nightly_artifact" + create_release "dev" "$dev_artifact" export AGENTMESH_BASE_URL="file://$tmp/releases" export AGENTMESH_COSIGN_BASE_URL="file://$tmp/cosign-base" @@ -295,6 +331,10 @@ jobs: "$tmp/shell-stable/agentmesh" --version PATH="/usr/bin:/bin" AGENTMESH_COSIGN_DIR="$tmp/cosign-shell-nightly" sh installers/install.sh --channel=nightly --install-dir="$tmp/shell-nightly" "$tmp/shell-nightly/agentmesh" --version + PATH="/usr/bin:/bin" AGENTMESH_COSIGN_DIR="$tmp/cosign-shell-dev" sh installers/install.sh --channel=dev --install-dir="$tmp/shell-dev" + "$tmp/shell-dev/agentmesh" --version + PATH="/usr/bin:/bin" AGENTMESH_COSIGN_DIR="$tmp/cosign-shell-dev-wrapper" sh installers/install-dev.sh --install-dir="$tmp/shell-dev-wrapper" + "$tmp/shell-dev-wrapper/agentmesh" --version e2e_repo="$tmp/e2e-repo" mkdir -p "$e2e_repo/.claude/skills/demo" diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml new file mode 100644 index 0000000..dcec5ea --- /dev/null +++ b/.github/workflows/dev-release.yml @@ -0,0 +1,134 @@ +name: dev release + +on: + push: + branches: + - dev + +concurrency: + group: dev-release + cancel-in-progress: true + +permissions: + contents: read + +jobs: + gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy,rustfmt + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-deny,cargo-audit + - run: make release-gate PERF_FULL_SYNC_BUDGET_SECONDS=5 PERF_HOOK_P99_BUDGET_MS=100 + - run: make ci-installers + - run: sh installers/install-dev.sh --smoke + + build-artifacts: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + - os: macos-latest + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.88.0 + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + - name: Install Linux cross linker + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu + echo 'CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc' >> "$GITHUB_ENV" + - name: Install musl tools + if: matrix.target == 'x86_64-unknown-linux-musl' + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + - name: Build + run: cargo build --locked --release --target ${{ matrix.target }} -p agentmesh + - name: Stage artifact + shell: bash + run: | + set -euo pipefail + name="agentmesh-dev-${{ matrix.target }}" + mkdir -p "dist/${name}" + binary="target/${{ matrix.target }}/release/agentmesh" + suffix="" + if [[ "${{ matrix.target }}" == *windows* ]]; then + binary="${binary}.exe" + suffix=".exe" + fi + cp "$binary" "dist/${name}/agentmesh${suffix}" + tar -C dist -czf "dist/${name}.tar.gz" "${name}" + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: agentmesh-dev-${{ matrix.target }} + path: dist/agentmesh-dev-${{ matrix.target }}.tar.gz + if-no-files-found: error + + publish: + runs-on: ubuntu-latest + needs: + - gate + - build-artifacts + permissions: + contents: write + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Generate checksum manifest + run: | + cd dist + sha256sum agentmesh-dev-* > SHA256SUMS + - uses: sigstore/cosign-installer@v3 + - name: Sign checksum manifest + run: cosign sign-blob --yes --output-signature dist/SHA256SUMS.sig --bundle dist/SHA256SUMS.bundle dist/SHA256SUMS + - name: Publish dev release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + cat >release-notes.md </dev/null 2>&1 || true + git tag -f dev "$GITHUB_SHA" + git push --force origin refs/tags/dev + gh release create dev dist/* \ + --title "AgentMesh dev" \ + --notes-file release-notes.md \ + --prerelease \ + --target "$GITHUB_SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d3c388..23639c3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,22 +7,47 @@ on: - "v*" permissions: - contents: write - id-token: write - pull-requests: write + contents: read jobs: release-please: if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - uses: googleapis/release-please-action@v4 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + tag-validation: + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Validate release tag + shell: bash + run: | + set -euo pipefail + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error title=Invalid release tag::Release tags must match vX.Y.Z." + exit 1 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error title=Invalid release source::Release tag must point to a commit reachable from origin/main." + exit 1 + fi + build-artifacts: if: startsWith(github.ref, 'refs/tags/') + needs: + - tag-validation name: build ${{ matrix.target }} runs-on: ${{ matrix.os }} strategy: @@ -49,7 +74,9 @@ jobs: archive: tar.gz steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.88.0 with: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 @@ -90,10 +117,14 @@ jobs: fuzz-smoke: if: startsWith(github.ref, 'refs/tags/') + needs: + - tag-validation runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@1.85.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.88.0 - uses: dtolnay/rust-toolchain@nightly - uses: Swatinem/rust-cache@v2 - run: cargo install cargo-fuzz --locked @@ -104,12 +135,35 @@ jobs: - run: cargo +nightly fuzz run frontmatter_parse -- -runs=1000 working-directory: fuzz + release-gate: + if: startsWith(github.ref, 'refs/tags/') + needs: + - tag-validation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@1.88.0 + with: + components: clippy,rustfmt + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-deny,cargo-audit + - run: make release-gate PERF_FULL_SYNC_BUDGET_SECONDS=5 PERF_HOOK_P99_BUDGET_MS=100 + publish: if: startsWith(github.ref, 'refs/tags/') needs: - build-artifacts - fuzz-smoke + - release-gate + - tag-validation runs-on: ubuntu-latest + permissions: + contents: write + id-token: write steps: - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/sync-install-scripts.yml b/.github/workflows/sync-install-scripts.yml index 54181fe..a136130 100644 --- a/.github/workflows/sync-install-scripts.yml +++ b/.github/workflows/sync-install-scripts.yml @@ -4,9 +4,12 @@ on: push: branches: - main + - dev paths: - installers/install.sh - installers/install.ps1 + - installers/install-dev.sh + - installers/install-dev.ps1 workflow_dispatch: jobs: @@ -25,21 +28,26 @@ jobs: SOURCE_REF: ${{ github.sha }} run: | set -euo pipefail - gh api "repos/aranticlabs/agentmesh/contents/installers/install.sh?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install.sh - gh api "repos/aranticlabs/agentmesh/contents/installers/install.ps1?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install.ps1 - chmod +x static/install.sh + if [ "${GITHUB_REF_NAME}" = "main" ]; then + gh api "repos/aranticlabs/agentmesh/contents/installers/install.sh?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install.sh + gh api "repos/aranticlabs/agentmesh/contents/installers/install.ps1?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install.ps1 + chmod +x static/install.sh + fi + gh api "repos/aranticlabs/agentmesh/contents/installers/install-dev.sh?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install-dev.sh + gh api "repos/aranticlabs/agentmesh/contents/installers/install-dev.ps1?ref=${SOURCE_REF}" --jq '.content' | base64 -d > static/install-dev.ps1 + chmod +x static/install-dev.sh - name: Commit and push env: GH_TOKEN: ${{ secrets.AGENTMESH_DOCS_SYNC_TOKEN }} run: | set -euo pipefail - if git diff --quiet -- static/install.sh static/install.ps1; then + if git diff --quiet -- static/install.sh static/install.ps1 static/install-dev.sh static/install-dev.ps1; then echo "No changes to sync." exit 0 fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add static/install.sh static/install.ps1 + git add static/install.sh static/install.ps1 static/install-dev.sh static/install-dev.ps1 git commit -m "chore: sync install scripts from aranticlabs/agentmesh@${GITHUB_SHA:0:7}" git push diff --git a/.gitignore b/.gitignore index 187aed5..1ad84b4 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,16 @@ coverage/ .claude/ .codex/ .cursor/ +!adapters/claude/fixtures/**/.claude/ +!adapters/claude/fixtures/**/.claude/** +!adapters/claude/fixtures/**/CLAUDE.md +!adapters/codex/fixtures/**/.codex/ +!adapters/codex/fixtures/**/.codex/** +!adapters/codex/fixtures/**/AGENTS.md +!adapters/cursor/fixtures/**/.cursor/ +!adapters/cursor/fixtures/**/.cursor/** CLAUDE.md AGENTS.md -agentmesh.lock \ No newline at end of file +!adapters/claude/fixtures/**/CLAUDE.md +!adapters/codex/fixtures/**/AGENTS.md +agentmesh.lock diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a915e8c..001eb2c 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.1.1" + ".": "0.1.2" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1c1ded..e04bbe2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,6 +10,9 @@ agentmesh -> agentmesh-adapter-sdk-rust -> adapters/claude -> adapters/codex + -> adapters/copilot + -> adapters/cursor + -> adapters/gemini agentmesh-core <-> agentmesh-protocol adapters/* <-> agentmesh-protocol @@ -38,17 +41,45 @@ service registration writes platform launch definitions without silently install `adapters/claude` imports and emits Claude-native files: - `CLAUDE.md` +- `.claude/rules/**/*.md` - `.claude/skills//SKILL.md` - `.claude/agents/.md` +- `.claude/commands/**/*.md` +- `.claude/settings.json` project hook and permission sections +- `.mcp.json` - `.claude/settings.local.json` for hook installation `adapters/codex` imports and emits Codex-native files: - `AGENTS.md` +- nested `AGENTS.md` - `.codex/skills//SKILL.md` +- `.agents/skills//SKILL.md` - `.codex/agents/.toml` +- `.codex/config.toml` project MCP and permission sections - `.codex/hooks.json` for hook installation +`adapters/copilot` imports and emits GitHub Copilot-native files: + +- `.github/copilot-instructions.md` +- `.github/instructions/**/*.instructions.md` +- `.github/prompts/*.prompt.md` +- `.github/skills//SKILL.md` +- `.agents/skills//SKILL.md` +- `.github/agents/*.agent.md` and `.github/agents/*.md` + +`adapters/cursor` imports and emits Cursor-native files: + +- `.cursor/rules/**/*.mdc` + +`adapters/gemini` imports and emits Gemini CLI-native files: + +- `GEMINI.md` +- nested `GEMINI.md` +- `.gemini/skills//SKILL.md` +- `.agents/skills//SKILL.md` +- `.gemini/commands/**/*.toml` + ## Runtime Boundaries Adapters declare their readable and writable workspace-relative paths during initialization. Runtime diff --git a/Cargo.lock b/Cargo.lock index 4a91b5e..566dcb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,9 @@ version = "0.1.2" dependencies = [ "agentmesh-adapter-claude", "agentmesh-adapter-codex", + "agentmesh-adapter-copilot", + "agentmesh-adapter-cursor", + "agentmesh-adapter-gemini", "agentmesh-adapter-sdk-rust", "agentmesh-core", "agentmesh-protocol", @@ -60,6 +63,40 @@ dependencies = [ "toml", ] +[[package]] +name = "agentmesh-adapter-copilot" +version = "0.1.2" +dependencies = [ + "agentmesh-adapter-sdk-rust", + "agentmesh-protocol", + "serde_json", + "serde_norway", + "tempfile", +] + +[[package]] +name = "agentmesh-adapter-cursor" +version = "0.1.2" +dependencies = [ + "agentmesh-adapter-sdk-rust", + "agentmesh-protocol", + "serde_json", + "serde_norway", + "tempfile", +] + +[[package]] +name = "agentmesh-adapter-gemini" +version = "0.1.2" +dependencies = [ + "agentmesh-adapter-sdk-rust", + "agentmesh-protocol", + "serde_json", + "serde_norway", + "tempfile", + "toml", +] + [[package]] name = "agentmesh-adapter-sdk-rust" version = "0.1.2" @@ -79,6 +116,9 @@ version = "0.1.2" dependencies = [ "agentmesh-adapter-claude", "agentmesh-adapter-codex", + "agentmesh-adapter-copilot", + "agentmesh-adapter-cursor", + "agentmesh-adapter-gemini", "agentmesh-adapter-sdk-rust", "agentmesh-protocol", "blake3", @@ -210,9 +250,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arrayref" diff --git a/Cargo.toml b/Cargo.toml index 8e31375..54fa62d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,9 @@ resolver = "3" members = [ "adapters/claude", "adapters/codex", + "adapters/copilot", + "adapters/cursor", + "adapters/gemini", "crates/agentmesh-adapter-sdk-rust", "crates/agentmesh", "crates/agentmesh-core", @@ -13,7 +16,7 @@ members = [ [workspace.package] version = "0.1.2" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT" authors = ["Arantic"] repository = "https://github.com/aranticlabs/agentmesh" @@ -22,6 +25,9 @@ homepage = "https://agentmesh.sh" [workspace.dependencies] agentmesh-adapter-claude = { path = "adapters/claude", version = "0.1.2" } agentmesh-adapter-codex = { path = "adapters/codex", version = "0.1.2" } +agentmesh-adapter-copilot = { path = "adapters/copilot", version = "0.1.2" } +agentmesh-adapter-cursor = { path = "adapters/cursor", version = "0.1.2" } +agentmesh-adapter-gemini = { path = "adapters/gemini", version = "0.1.2" } agentmesh-adapter-sdk-rust = { path = "crates/agentmesh-adapter-sdk-rust", version = "0.1.2" } agentmesh-core = { path = "crates/agentmesh-core", version = "0.1.2" } agentmesh-protocol = { path = "crates/agentmesh-protocol", version = "0.1.2" } diff --git a/Makefile b/Makefile index 9bcb247..8ffd8f4 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,14 @@ -.PHONY: help fmt fmt-check check clippy test build bench-check deny audit fuzz-check \ - installer-smoke ci-rust ci-supply-chain ci-installers ci release retag clean +.PHONY: help fmt fmt-check check clippy test build bench-check perf-budget deny audit \ + fuzz-check fuzz-deny fuzz-audit installer-smoke ci-rust ci-supply-chain \ + ci-installers ci release-gate release retag clean GREEN := \033[0;32m BLUE := \033[0;34m YELLOW := \033[0;33m RED := \033[0;31m NC := \033[0m +PERF_FULL_SYNC_BUDGET_SECONDS ?= 5 +PERF_HOOK_P99_BUDGET_MS ?= 500 help: @echo "$(BLUE)AgentMesh targets$(NC)" @@ -13,6 +16,7 @@ help: @echo " make ci-rust Format, typecheck, lint, test, build, and bench-compile" @echo " make ci-supply-chain Run dependency policy and advisory checks" @echo " make ci-installers Run installer smoke checks" + @echo " make release-gate Run release-blocking Rust, performance, and supply-chain checks" @echo " make build Build the release binary" @echo " make release v=X.Y.Z Bump Cargo versions, tag, and push a GitHub release" @echo " make retag v=X.Y.Z Bump Cargo versions and overwrite an existing GitHub release tag" @@ -38,6 +42,9 @@ build: bench-check: @cargo bench --workspace --all-features --no-run +perf-budget: + @AGENTMESH_FULL_SYNC_BUDGET_SECONDS=$(PERF_FULL_SYNC_BUDGET_SECONDS) AGENTMESH_HOOK_P99_BUDGET_MS=$(PERF_HOOK_P99_BUDGET_MS) cargo test --release -p agentmesh-core --test performance_budgets --all-features -- --ignored + deny: @cargo deny check @@ -47,6 +54,12 @@ audit: fuzz-check: @cargo check --manifest-path fuzz/Cargo.toml --bins +fuzz-deny: + @cargo deny --manifest-path fuzz/Cargo.toml check + +fuzz-audit: + @cargo audit --file fuzz/Cargo.lock + installer-smoke: @sh installers/install.sh --smoke @sh installers/install.sh --upgrade-help @@ -55,7 +68,7 @@ installer-smoke: ci-rust: fmt-check check clippy test build bench-check fuzz-check @echo "$(GREEN)[SUCCESS]$(NC) Rust CI checks passed" -ci-supply-chain: deny audit +ci-supply-chain: deny audit fuzz-deny fuzz-audit @echo "$(GREEN)[SUCCESS]$(NC) Supply-chain checks passed" ci-installers: installer-smoke @@ -64,6 +77,9 @@ ci-installers: installer-smoke ci: ci-rust ci-supply-chain ci-installers @echo "$(GREEN)[SUCCESS]$(NC) Full CI suite passed" +release-gate: ci-rust perf-budget ci-supply-chain + @echo "$(GREEN)[SUCCESS]$(NC) Release gate checks passed" + release: @if [ -z "$(v)" ]; then \ echo "$(RED)[ERROR]$(NC) Version required. Usage: make release v=X.Y.Z"; \ @@ -106,6 +122,8 @@ release: echo "$(RED)[ABORT]$(NC) Release cancelled."; \ exit 1; \ fi; \ + echo "$(BLUE)[INFO]$(NC) Running release gate checks before tagging..."; \ + $(MAKE) release-gate; \ echo "$(BLUE)[INFO]$(NC) Bumping Cargo workspace version to $(v)..."; \ scripts/bump-release-version.sh "$(v)"; \ echo "$(BLUE)[INFO]$(NC) Validating Cargo metadata..."; \ @@ -161,6 +179,8 @@ retag: echo "$(RED)[ABORT]$(NC) Retag cancelled."; \ exit 1; \ fi; \ + echo "$(BLUE)[INFO]$(NC) Running release gate checks before retagging..."; \ + $(MAKE) release-gate; \ echo "$(BLUE)[INFO]$(NC) Bumping Cargo workspace version to $(v)..."; \ scripts/bump-release-version.sh "$(v)"; \ echo "$(BLUE)[INFO]$(NC) Validating Cargo metadata..."; \ diff --git a/README.md b/README.md index 6466ec6..4c9f788 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ AgentMesh synchronizes project-level AI runtime context across coding tools. -The v0.1 binary is a local-first Rust CLI with bundled Claude Code and Codex adapters. It -normalizes project instructions, skills, and subagents into a shared repository model, then renders -those entities back into each runtime's native file layout. +The v0.2 binary is a local-first Rust CLI with bundled adapters for Claude Code, Codex, +GitHub Copilot, Cursor, and Gemini CLI. It normalizes project instructions, rules, prompts, +skills, subagents, commands, hooks, MCP bindings, and permission policies into a shared repository +model, then renders supported entities back into each runtime's native file layout. Documentation: [agentmesh.sh](https://agentmesh.sh) @@ -35,8 +36,14 @@ cargo build --workspace ## Quickstart **Prerequisites:** a git repository at your project root, and at least one supported runtime -present or planned (Claude Code with `.claude/` and/or `CLAUDE.md`, or Codex with `.codex/` and/or -`AGENTS.md`). +present or planned: + +- Claude Code: `.claude/` and/or `CLAUDE.md` +- Codex: `.codex/` and/or `AGENTS.md` +- GitHub Copilot: `.github/copilot-instructions.md`, `.github/instructions/`, + `.github/prompts/`, `.github/skills/`, or `.github/agents/` +- Cursor: `.cursor/rules/` +- Gemini CLI: `GEMINI.md`, nested `GEMINI.md`, `.gemini/skills/`, or `.gemini/commands/` Preview detection without writing: @@ -45,7 +52,8 @@ agentmesh scan ``` Initialize AgentMesh from your project root. This detects runtimes, imports entities into the -canonical `.ai/` model, propagates to other runtimes, installs hooks, and writes `agentmesh.lock`: +canonical `.ai/` model, propagates to other runtimes, installs hooks for hook-capable runtimes, +starts watcher coverage, and writes `agentmesh.lock`: ```bash cd /path/to/your/repo @@ -73,6 +81,10 @@ git add AGENTS.md .ai/ agentmesh.lock git commit -m "chore: initialize AgentMesh sync" ``` +Commit native runtime files that are part of your team workflow, such as `CLAUDE.md`, +`.claude/rules/`, `.codex/config.toml`, `.github/`, `.cursor/rules/`, `GEMINI.md`, `.gemini/`, +and shared `.agents/skills/`. + Do not commit machine-local hook files (`.claude/settings.local.json`, `.codex/hooks.json`). Each teammate runs `agentmesh init` on their machine. Add `.codex/hooks.json` to `.gitignore`. @@ -81,12 +93,18 @@ open Codex in the repository and run any tool-backed action; when Codex asks whe AgentMesh hook command, approve it once. Sync still works via the watcher daemon, Claude hooks, and manual `agentmesh sync` until then. -| Situation | Command | -| -------------------------- | ------------------------------------ | -| Added a runtime after init | `agentmesh install --runtime ` | -| Upgraded the binary | `agentmesh upgrade` | -| Commit-time drift check | `agentmesh install --git-pre-commit` | -| CI pipeline | `agentmesh sync --check` | +Cursor, GitHub Copilot, and Gemini CLI are watcher/manual-sync runtimes in v0.2. AgentMesh imports +and emits their write-enabled project files, but does not install native runtime hooks for them. +`agentmesh doctor` reports read-only and deferred surfaces instead of silently writing unsupported +files. + +| Situation | Command | +| ------------------------------- | ------------------------------------ | +| Added Claude/Codex after init | `agentmesh install --runtime ` | +| Added hookless runtime files | `agentmesh sync --await-drain` | +| Upgraded the binary | `agentmesh upgrade` | +| Commit-time drift check | `agentmesh install --git-pre-commit` | +| CI pipeline | `agentmesh sync --check` | Full walkthrough: [agentmesh.sh/quickstart](https://agentmesh.sh/quickstart) @@ -98,9 +116,9 @@ To start AgentMesh again for an initialized repository: agentmesh start -y ``` -This refreshes machine-local AgentMesh state, installs AgentMesh-owned hooks for detected runtimes, -and starts the watcher so direct edits to `AGENTS.md`, `CLAUDE.md`, and `.ai/` files sync -immediately. It keeps `agentmesh.lock`, `.ai/`, and runtime files such as `AGENTS.md` intact. +This refreshes machine-local AgentMesh state, installs AgentMesh-owned hooks for detected +hook-capable runtimes, and starts the watcher so direct edits to supported native files sync +promptly. It keeps `agentmesh.lock`, `.ai/`, and runtime files such as `AGENTS.md` intact. To stop AgentMesh for the current repository while keeping all repository state and AgentMesh installed on this computer: diff --git a/adapters/claude/fixtures/commands/invalid/.claude/commands/broken.md b/adapters/claude/fixtures/commands/invalid/.claude/commands/broken.md new file mode 100644 index 0000000..45e5514 --- /dev/null +++ b/adapters/claude/fixtures/commands/invalid/.claude/commands/broken.md @@ -0,0 +1,4 @@ +--- +description: "unterminated +--- +This malformed command must be diagnosed and left untouched. diff --git a/adapters/claude/fixtures/commands/manifest.toml b/adapters/claude/fixtures/commands/manifest.toml new file mode 100644 index 0000000..2cefaa8 --- /dev/null +++ b/adapters/claude/fixtures/commands/manifest.toml @@ -0,0 +1,37 @@ +[[fixture]] +name = "review-command" +surface = "claude.project-command" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "review/.claude/commands/review.md" +expected_canonical_id = "command:review" +expected_emitted_path = ".claude/commands/review.md" +expected_diagnostics = [] + +[[fixture]] +name = "namespaced-command" +surface = "claude.project-command" +cases = ["minimum_valid", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "namespaced/.claude/commands/git/commit.md" +expected_canonical_id = "command:git:commit" +expected_emitted_path = ".claude/commands/git/commit.md" +expected_diagnostics = [] + +[[fixture]] +name = "command-invalid-frontmatter" +surface = "claude.project-command" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "invalid/.claude/commands/broken.md" +expected_canonical_id = "command:broken" +expected_diagnostics = ["failed to parse frontmatter"] diff --git a/adapters/claude/fixtures/commands/namespaced/.claude/commands/git/commit.md b/adapters/claude/fixtures/commands/namespaced/.claude/commands/git/commit.md new file mode 100644 index 0000000..8ee18ae --- /dev/null +++ b/adapters/claude/fixtures/commands/namespaced/.claude/commands/git/commit.md @@ -0,0 +1,4 @@ +--- +description: Draft a commit message +--- +Inspect the staged diff and draft a concise commit message. diff --git a/adapters/claude/fixtures/commands/review/.claude/commands/review.md b/adapters/claude/fixtures/commands/review/.claude/commands/review.md new file mode 100644 index 0000000..7552652 --- /dev/null +++ b/adapters/claude/fixtures/commands/review/.claude/commands/review.md @@ -0,0 +1,6 @@ +--- +description: Review the current changes +argument-hint: path +claudeMetadata: preserve +--- +Review the requested path and summarize correctness, risk, and missing tests. diff --git a/adapters/claude/fixtures/mcp/invalid/.mcp.json b/adapters/claude/fixtures/mcp/invalid/.mcp.json new file mode 100644 index 0000000..472f61a --- /dev/null +++ b/adapters/claude/fixtures/mcp/invalid/.mcp.json @@ -0,0 +1 @@ +{"mcpServers": diff --git a/adapters/claude/fixtures/mcp/manifest.toml b/adapters/claude/fixtures/mcp/manifest.toml new file mode 100644 index 0000000..28732b2 --- /dev/null +++ b/adapters/claude/fixtures/mcp/manifest.toml @@ -0,0 +1,24 @@ +[[fixture]] +name = "project-mcp" +surface = "claude.project-mcp" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/mcp" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "mcp_binding" +fixture_file_path = "project/.mcp.json" +expected_canonical_id = "mcp-binding:project" +expected_emitted_path = ".mcp.json" +expected_diagnostics = [] + +[[fixture]] +name = "invalid-project-mcp" +surface = "claude.project-mcp" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/mcp" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "mcp_binding" +fixture_file_path = "invalid/.mcp.json" +expected_canonical_id = "mcp-binding:project" +expected_diagnostics = ["failed to parse hook overlay JSON"] diff --git a/adapters/claude/fixtures/mcp/project/.mcp.json b/adapters/claude/fixtures/mcp/project/.mcp.json new file mode 100644 index 0000000..245e286 --- /dev/null +++ b/adapters/claude/fixtures/mcp/project/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "filesystem": { + "command": "node", + "args": ["server.js"], + "env": { + "PROJECT_ROOT": "." + } + } + }, + "metadata": { + "owner": "platform" + } +} diff --git a/adapters/claude/fixtures/rules/always/.claude/rules/security.md b/adapters/claude/fixtures/rules/always/.claude/rules/security.md new file mode 100644 index 0000000..c596495 --- /dev/null +++ b/adapters/claude/fixtures/rules/always/.claude/rules/security.md @@ -0,0 +1,6 @@ +--- +description: Security review behavior +mode: always +owner: platform +--- +Check security-sensitive changes for input validation, path handling, and permission boundaries. diff --git a/adapters/claude/fixtures/rules/invalid/.claude/rules/broken.md b/adapters/claude/fixtures/rules/invalid/.claude/rules/broken.md new file mode 100644 index 0000000..ad8bf2d --- /dev/null +++ b/adapters/claude/fixtures/rules/invalid/.claude/rules/broken.md @@ -0,0 +1,4 @@ +--- +description: "unterminated +--- +This malformed rule must be diagnosed and left untouched. diff --git a/adapters/claude/fixtures/rules/manifest.toml b/adapters/claude/fixtures/rules/manifest.toml new file mode 100644 index 0000000..d444eef --- /dev/null +++ b/adapters/claude/fixtures/rules/manifest.toml @@ -0,0 +1,50 @@ +[[fixture]] +name = "always-on-rule" +surface = "claude.project-rule" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/memory" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "always/.claude/rules/security.md" +expected_canonical_id = "rule:security" +expected_emitted_path = ".claude/rules/security.md" +expected_diagnostics = [] + +[[fixture]] +name = "path-scoped-rule" +surface = "claude.project-rule" +cases = ["minimum_valid", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/memory" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "scoped/.claude/rules/api.md" +expected_canonical_id = "instructions:scoped:packages-api" +expected_emitted_path = ".claude/rules/api.md" +expected_diagnostics = [] + +[[fixture]] +name = "unknown-frontmatter-preservation" +surface = "claude.project-rule" +cases = ["unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/memory" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "unknown/.claude/rules/preserve-metadata.md" +expected_canonical_id = "rule:preserve-metadata" +expected_emitted_path = ".claude/rules/preserve-metadata.md" +expected_diagnostics = [] + +[[fixture]] +name = "invalid-frontmatter" +surface = "claude.project-rule" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/memory" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "invalid/.claude/rules/broken.md" +expected_canonical_id = "rule:broken" +expected_diagnostics = ["failed to parse frontmatter"] diff --git a/adapters/claude/fixtures/rules/scoped/.claude/rules/api.md b/adapters/claude/fixtures/rules/scoped/.claude/rules/api.md new file mode 100644 index 0000000..239b50f --- /dev/null +++ b/adapters/claude/fixtures/rules/scoped/.claude/rules/api.md @@ -0,0 +1,6 @@ +--- +paths: + - packages/api/** +description: API package instructions +--- +Prefer typed request and response boundaries for API changes. diff --git a/adapters/claude/fixtures/rules/unknown/.claude/rules/preserve-metadata.md b/adapters/claude/fixtures/rules/unknown/.claude/rules/preserve-metadata.md new file mode 100644 index 0000000..bda3c16 --- /dev/null +++ b/adapters/claude/fixtures/rules/unknown/.claude/rules/preserve-metadata.md @@ -0,0 +1,5 @@ +--- +description: Preserve runtime metadata +claudePriority: high +--- +Keep runtime-specific metadata intact during same-runtime round trips. diff --git a/adapters/claude/fixtures/settings/combined/.claude/settings.json b/adapters/claude/fixtures/settings/combined/.claude/settings.json new file mode 100644 index 0000000..4e57142 --- /dev/null +++ b/adapters/claude/fixtures/settings/combined/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "cargo test" + } + ] + } + ] + }, + "permissions": { + "allow": ["Bash(cargo test:*)"] + }, + "cleanupPeriodDays": 30 +} diff --git a/adapters/claude/fixtures/settings/hooks/.claude/settings.json b/adapters/claude/fixtures/settings/hooks/.claude/settings.json new file mode 100644 index 0000000..2e09dd1 --- /dev/null +++ b/adapters/claude/fixtures/settings/hooks/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "npm test" + } + ] + } + ] + }, + "editor": "vim" +} diff --git a/adapters/claude/fixtures/settings/invalid/.claude/settings.json b/adapters/claude/fixtures/settings/invalid/.claude/settings.json new file mode 100644 index 0000000..e9aa6e8 --- /dev/null +++ b/adapters/claude/fixtures/settings/invalid/.claude/settings.json @@ -0,0 +1 @@ +{"hooks": diff --git a/adapters/claude/fixtures/settings/manifest.toml b/adapters/claude/fixtures/settings/manifest.toml new file mode 100644 index 0000000..c624d8f --- /dev/null +++ b/adapters/claude/fixtures/settings/manifest.toml @@ -0,0 +1,63 @@ +[[fixture]] +name = "project-hooks" +surface = "claude.project-hooks" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/hooks" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "hooks/.claude/settings.json" +expected_canonical_id = "hook:claude-project" +expected_emitted_path = ".claude/settings.json" +expected_diagnostics = [] + +[[fixture]] +name = "project-permissions" +surface = "claude.project-permissions" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/settings" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "permission_policy" +fixture_file_path = "permissions/.claude/settings.json" +expected_canonical_id = "permission-policy:claude-project" +expected_emitted_path = ".claude/settings.json" +expected_diagnostics = [] + +[[fixture]] +name = "combined-settings" +surface = "claude.project-hooks" +cases = ["round_trip", "sync_check", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/settings" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "combined/.claude/settings.json" +expected_canonical_id = "hook:claude-project" +expected_emitted_path = ".claude/settings.json" +expected_diagnostics = [] +expected_behaviors = ["hook and permission sections share settings.json without overwriting each other"] + +[[fixture]] +name = "invalid-hooks-json" +surface = "claude.project-hooks" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/hooks" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "invalid/.claude/settings.json" +expected_canonical_id = "hook:claude-project" +expected_diagnostics = ["failed to parse hook overlay JSON"] + +[[fixture]] +name = "invalid-permissions-json" +surface = "claude.project-permissions" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.anthropic.com/en/docs/claude-code/settings" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "permission_policy" +fixture_file_path = "invalid/.claude/settings.json" +expected_canonical_id = "permission-policy:claude-project" +expected_diagnostics = ["failed to parse hook overlay JSON"] diff --git a/adapters/claude/fixtures/settings/permissions/.claude/settings.json b/adapters/claude/fixtures/settings/permissions/.claude/settings.json new file mode 100644 index 0000000..0a08ba9 --- /dev/null +++ b/adapters/claude/fixtures/settings/permissions/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": ["Bash(git status:*)"], + "deny": ["Bash(rm -rf:*)"] + }, + "theme": "dark" +} diff --git a/adapters/claude/src/lib.rs b/adapters/claude/src/lib.rs index f2569b5..6beee98 100644 --- a/adapters/claude/src/lib.rs +++ b/adapters/claude/src/lib.rs @@ -20,22 +20,50 @@ use agentmesh_protocol::{ InstallHooksResponse, InstalledHook, RemoveHooksRequest, RemoveHooksResponse, RuntimeMode, SkippedPath, }; -use serde_json::{Value as JsonValue, json}; +use serde_json::{Map as JsonMap, Value as JsonValue, json}; use serde_norway::{Mapping as YamlMapping, Value as YamlValue}; const SUPPORTED_ENTITIES: &[EntityType] = &[ EntityType::Instructions, + EntityType::Rule, + EntityType::Command, + EntityType::Hook, + EntityType::McpBinding, + EntityType::PermissionPolicy, EntityType::Skill, EntityType::Subagent, ]; -const ALLOWED_READ_PATHS: &[&str] = &[".claude/**", "CLAUDE.md"]; -const ALLOWED_WRITE_PATHS: &[&str] = &[".claude/**", "CLAUDE.md"]; +const ALLOWED_READ_PATHS: &[&str] = &[".claude/**", ".mcp.json", "CLAUDE.md"]; +const ALLOWED_WRITE_PATHS: &[&str] = &[".claude/**", ".mcp.json", "CLAUDE.md"]; const MARKDOWN_FORMATS: &[&str] = &["markdown"]; -const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[FormatTranslation { - entity_type: EntityType::Subagent, - formats: MARKDOWN_FORMATS, -}]; +const JSON_FORMATS: &[&str] = &["json"]; +const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[ + FormatTranslation { + entity_type: EntityType::Rule, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Command, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Hook, + formats: JSON_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::McpBinding, + formats: JSON_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::PermissionPolicy, + formats: JSON_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Subagent, + formats: MARKDOWN_FORMATS, + }, +]; /// Claude adapter handle. #[derive(Debug, Clone, Copy, Default)] @@ -51,6 +79,10 @@ impl Adapter for ClaudeAdapter { workspace_root.join(".claude"), workspace_root.join(".claude/skills"), workspace_root.join(".claude/agents"), + workspace_root.join(".claude/rules"), + workspace_root.join(".claude/commands"), + workspace_root.join(".claude/settings.json"), + workspace_root.join(".mcp.json"), workspace_root.join("CLAUDE.md"), ]; let files = evidence @@ -73,6 +105,7 @@ impl Adapter for ClaudeAdapter { let mut skipped = Vec::new(); let instructions_path = workspace_root.join("CLAUDE.md"); + let mut imported_root_instructions = false; if selected(filter, &[PathBuf::from("CLAUDE.md")]) && is_regular_file(&workspace_root, &instructions_path)? { @@ -85,8 +118,51 @@ impl Adapter for ClaudeAdapter { PathBuf::from("AGENTS.md"), PathBuf::from("CLAUDE.md"), )?); + imported_root_instructions = true; } + let dot_claude_instructions_path = request.runtime_dir.join("CLAUDE.md"); + let dot_claude_instructions_relative = PathBuf::from(".claude/CLAUDE.md"); + if !imported_root_instructions + && selected( + filter, + std::slice::from_ref(&dot_claude_instructions_relative), + ) + && is_regular_file(&workspace_root, &dot_claude_instructions_path)? + { + entities.push(import_markdown_entity( + &workspace_root, + &dot_claude_instructions_path, + EntityType::Instructions, + "instructions:root".to_string(), + Some("root".to_string()), + PathBuf::from("AGENTS.md"), + dot_claude_instructions_relative, + )?); + } + + import_rules( + &workspace_root, + &request.runtime_dir.join("rules"), + filter, + &mut entities, + &mut skipped, + )?; + import_commands( + &workspace_root, + &request.runtime_dir.join("commands"), + filter, + &mut entities, + &mut skipped, + )?; + import_settings_json( + &workspace_root, + &request.runtime_dir.join("settings.json"), + filter, + &mut entities, + &mut skipped, + )?; + import_mcp_json(&workspace_root, filter, &mut entities, &mut skipped)?; import_skills( &workspace_root, &request.runtime_dir.join("skills"), @@ -132,14 +208,85 @@ impl Adapter for ClaudeAdapter { )); continue; }; + let is_root = is_root_instruction(&entity.id, entity.scope.as_deref()); + let frontmatter = claude_frontmatter_for_emit(&entity, !is_root); let rendered = render_markdown_with_overrides( &content, - &entity.frontmatter, + &frontmatter, &entity.overrides, + if is_root { &[] } else { &["globs"] }, )?; - let path = workspace_root.join("CLAUDE.md"); + let path = if is_root { + workspace_root.join("CLAUDE.md") + } else if let Some(source_path) = + native_source_path(&entity, ".claude/rules", "md") + { + workspace_root.join(source_path) + } else { + request + .runtime_dir + .join("rules") + .join(format!("{}.md", scoped_instruction_slug(&entity.id))) + }; write_atomic(&path, rendered.as_bytes())?; - files_written.push(PathBuf::from("CLAUDE.md")); + files_written.push(workspace_relative(&workspace_root, &path)?); + } + EntityType::Rule => { + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity(entity.id, "rule entity has no files")); + continue; + }; + let rendered = render_markdown_with_overrides( + &content, + &entity.frontmatter, + &entity.overrides, + &[], + )?; + let target = native_source_path(&entity, ".claude/rules", "md") + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| { + request.runtime_dir.join("rules").join(format!("{slug}.md")) + }); + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Command => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity(entity.id, "command entity has no files")); + continue; + }; + let rendered = render_markdown_with_overrides( + &content, + &entity.frontmatter, + &entity.overrides, + &[], + )?; + let target = native_source_path(&entity, ".claude/commands", "md") + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| { + request + .runtime_dir + .join("commands") + .join(command_runtime_file(&entity.id, "md")) + }); + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Hook => { + let target = request.runtime_dir.join("settings.json"); + merge_json_section(&target, "hooks", &entity)?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::McpBinding => { + let target = workspace_root.join(".mcp.json"); + merge_json_section(&target, "mcpServers", &entity)?; + files_written.push(PathBuf::from(".mcp.json")); + } + EntityType::PermissionPolicy => { + let target = request.runtime_dir.join("settings.json"); + merge_json_section(&target, "permissions", &entity)?; + files_written.push(workspace_relative(&workspace_root, &target)?); } EntityType::Skill => { let slug = slug_for_entity(&entity.id, &entity.frontmatter); @@ -164,6 +311,7 @@ impl Adapter for ClaudeAdapter { &content, &entity.frontmatter, &entity.overrides, + &[], )?; bytes = rendered.into_bytes(); } @@ -182,6 +330,7 @@ impl Adapter for ClaudeAdapter { &content, &entity.frontmatter, &entity.overrides, + &[], )?; let target = request .runtime_dir @@ -190,6 +339,12 @@ impl Adapter for ClaudeAdapter { write_atomic(&target, rendered.as_bytes())?; files_written.push(workspace_relative(&workspace_root, &target)?); } + unsupported => { + skipped.push(skipped_entity( + entity.id, + format!("{} entity is not supported", unsupported.as_str()), + )); + } } } @@ -228,6 +383,309 @@ pub const fn metadata() -> AdapterMetadata { } } +fn import_rules( + workspace_root: &Path, + rules_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, rules_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, rules_root), + reason: error.to_string(), + }); + return Ok(()); + } + } + + import_rules_in_dir( + workspace_root, + rules_root, + rules_root, + filter, + entities, + skipped, + ) +} + +fn import_rules_in_dir( + workspace_root: &Path, + rules_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked rule path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_rules_in_dir(workspace_root, rules_root, &path, filter, entities, skipped)?; + continue; + } + if !file_type.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + + let slug = path_slug(rules_root, &path); + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let (entity_type, id, scope, canonical_path) = if frontmatter.contains_key("paths") { + let scope = match scope_from_paths(&frontmatter, &slug) { + Ok(scope) => scope, + Err(reason) => { + skipped.push(SkippedPath { + path: source_relative, + reason, + }); + continue; + } + }; + let slug = scope + .as_deref() + .and_then(scope_directory) + .map(|path| slugify(&path.to_string_lossy())) + .unwrap_or(slug); + ( + EntityType::Instructions, + format!("instructions:scoped:{slug}"), + scope, + PathBuf::from("instructions").join(format!("{slug}.md")), + ) + } else { + ( + EntityType::Rule, + format!("rule:{slug}"), + None, + PathBuf::from("rules").join(format!("{slug}.md")), + ) + }; + + entities.push(import_markdown_entity( + workspace_root, + &path, + entity_type, + id, + scope, + canonical_path, + source_relative, + )?); + } + + Ok(()) +} + +fn import_commands( + workspace_root: &Path, + commands_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, commands_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, commands_root), + reason: error.to_string(), + }); + return Ok(()); + } + } + + import_commands_in_dir( + workspace_root, + commands_root, + commands_root, + filter, + entities, + skipped, + ) +} + +fn import_commands_in_dir( + workspace_root: &Path, + commands_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked command path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_commands_in_dir( + workspace_root, + commands_root, + &path, + filter, + entities, + skipped, + )?; + continue; + } + if !file_type.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("md") + { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = command_slug(commands_root, &path); + let canonical_path = + PathBuf::from("commands").join(command_runtime_file(&format!("command:{slug}"), "md")); + let entity = match import_markdown_entity( + workspace_root, + &path, + EntityType::Command, + format!("command:{slug}"), + None, + canonical_path, + source_relative, + ) { + Ok(entity) => entity, + Err(error) => { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: error.to_string(), + }); + continue; + } + }; + entities.push(entity); + } + + Ok(()) +} + +fn import_settings_json( + workspace_root: &Path, + settings_path: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let source_relative = PathBuf::from(".claude/settings.json"); + if !selected(filter, std::slice::from_ref(&source_relative)) + || !is_regular_file(workspace_root, settings_path)? + { + return Ok(()); + } + let value = match read_json_object(settings_path) { + Ok(value) => value, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + if let Some(hooks) = value.get("hooks") { + entities.push(import_json_section_entity( + settings_path, + source_relative.clone(), + EntityType::Hook, + "hook:claude-project", + PathBuf::from("hooks/claude-project.json"), + "hooks", + hooks.clone(), + )?); + } + if let Some(permissions) = value.get("permissions") { + entities.push(import_json_section_entity( + settings_path, + source_relative, + EntityType::PermissionPolicy, + "permission-policy:claude-project", + PathBuf::from("permission-policies/claude-project.json"), + "permissions", + permissions.clone(), + )?); + } + Ok(()) +} + +fn import_mcp_json( + workspace_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let source_path = workspace_root.join(".mcp.json"); + let source_relative = PathBuf::from(".mcp.json"); + if !selected(filter, std::slice::from_ref(&source_relative)) + || !is_regular_file(workspace_root, &source_path)? + { + return Ok(()); + } + let value = match read_json_object(&source_path) { + Ok(value) => value, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + entities.push(import_json_entity( + &source_path, + source_relative, + EntityType::McpBinding, + "mcp-binding:project", + PathBuf::from("mcp-bindings/project.json"), + value, + )?); + Ok(()) +} + fn import_skills( workspace_root: &Path, skills_root: &Path, @@ -442,6 +900,71 @@ fn import_markdown_entity( }) } +fn import_json_section_entity( + path: &Path, + source_path: PathBuf, + entity_type: EntityType, + id: &str, + canonical_path: PathBuf, + section_key: &str, + section_value: JsonValue, +) -> agentmesh_adapter_sdk_rust::Result { + let mut object = JsonMap::new(); + object.insert(section_key.to_string(), section_value); + import_json_entity( + path, + source_path, + entity_type, + id, + canonical_path, + JsonValue::Object(object), + ) +} + +fn import_json_entity( + path: &Path, + source_path: PathBuf, + entity_type: EntityType, + id: &str, + canonical_path: PathBuf, + value: JsonValue, +) -> agentmesh_adapter_sdk_rust::Result { + let content = render_json(&value)?; + let file_key = canonical_path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| canonical_path.clone()); + let files = BTreeMap::from([(file_key, EntityFile::utf8(content.clone()))]); + + Ok(ImportedEntity { + id: id.to_string(), + entity_type, + scope: None, + canonical_path, + files, + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path, + source_mtime: mtime_string(path)?, + }) +} + +fn render_json(value: &JsonValue) -> agentmesh_adapter_sdk_rust::Result { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to serialize JSON entity: {source}"), + ) + })?; + bytes.push(b'\n'); + String::from_utf8(bytes).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to encode JSON entity: {source}"), + ) + }) +} + fn first_file_content(files: &BTreeMap) -> Option { for key in [ Path::new("SKILL.md"), @@ -455,15 +978,231 @@ fn first_file_content(files: &BTreeMap) -> Option { files.values().find_map(file_text) } +fn is_root_instruction(id: &str, scope: Option<&str>) -> bool { + id == "instructions:root" || scope == Some("root") +} + +fn scoped_instruction_slug(id: &str) -> String { + id.strip_prefix("instructions:scoped:") + .map(ToString::to_string) + .unwrap_or_else(|| slugify(id)) +} + +fn command_runtime_file(id: &str, extension: &str) -> PathBuf { + let slug = id.strip_prefix("command:").unwrap_or(id); + path_from_colon_slug(slug, extension) +} + +fn path_from_colon_slug(slug: &str, extension: &str) -> PathBuf { + let mut parts = slug.split(':').peekable(); + let mut path = PathBuf::new(); + while let Some(part) = parts.next() { + if parts.peek().is_some() { + path.push(part); + } else { + path.push(format!("{part}.{extension}")); + } + } + path +} + +fn path_slug(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + let mut parts = relative + .iter() + .filter_map(|part| part.to_str()) + .map(ToString::to_string) + .collect::>(); + if let Some(last) = parts.last_mut() + && let Some(stem) = Path::new(last) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToString::to_string) + { + *last = stem; + } + slugify(&parts.join("-")) +} + +fn command_slug(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + let mut parts = relative + .iter() + .filter_map(|part| part.to_str()) + .map(ToString::to_string) + .collect::>(); + if let Some(last) = parts.last_mut() + && let Some(stem) = Path::new(last) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToString::to_string) + { + *last = stem; + } + parts + .into_iter() + .map(|part| slugify(&part)) + .collect::>() + .join(":") +} + +fn scope_from_paths( + frontmatter: &BTreeMap, + fallback_slug: &str, +) -> Result, String> { + match frontmatter.get("paths") { + Some(JsonValue::String(value)) if !value.trim().is_empty() => Ok(Some(value.clone())), + Some(JsonValue::Array(values)) => { + let scopes = values + .iter() + .filter_map(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .collect::>(); + if scopes.len() > 1 { + return Err( + "Claude rule paths with multiple scopes cannot be represented losslessly" + .to_string(), + ); + } + Ok(scopes + .first() + .map(|scope| (*scope).to_string()) + .or_else(|| Some(fallback_slug.to_string()))) + } + _ => Ok(Some(fallback_slug.to_string())), + } +} + +fn scope_directory(scope: &str) -> Option { + let trimmed = scope.trim().trim_matches('/'); + let trimmed = trimmed + .strip_suffix("/**") + .or_else(|| trimmed.strip_suffix("/*")) + .unwrap_or(trimmed); + if trimmed.contains(['*', '?', '[', ']']) { + return None; + } + if trimmed.is_empty() || trimmed == "root" { + return None; + } + let path = PathBuf::from(trimmed); + if is_safe_relative(&path) { + Some(path) + } else { + None + } +} + +fn native_source_path( + entity: &agentmesh_protocol::EmitEntity, + required_prefix: &str, + extension: &str, +) -> Option { + let path = entity.source_path.as_ref()?; + if !is_safe_relative(path) || !path.starts_with(required_prefix) { + return None; + } + if path.extension().and_then(|value| value.to_str()) != Some(extension) { + return None; + } + Some(path.clone()) +} + +fn merge_json_section( + target: &Path, + section_key: &str, + entity: &agentmesh_protocol::EmitEntity, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let mut existing = read_json_object(target)?; + let payload = json_object_from_entity(entity, section_key)?; + let replacement = payload + .get(section_key) + .cloned() + .unwrap_or(JsonValue::Object(payload)); + let Some(existing_object) = existing.as_object_mut() else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "settings JSON root must be an object", + )); + }; + let merged = merge_json_section_value(existing_object.remove(section_key), replacement); + existing_object.insert(section_key.to_string(), merged); + write_json_pretty(target, &existing) +} + +fn merge_json_section_value(existing: Option, replacement: JsonValue) -> JsonValue { + match (existing, replacement) { + (Some(JsonValue::Object(mut existing)), JsonValue::Object(replacement)) => { + existing.extend(replacement); + JsonValue::Object(existing) + } + (_, replacement) => replacement, + } +} + +fn json_object_from_entity( + entity: &agentmesh_protocol::EmitEntity, + label: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + let Some(content) = first_file_content(&entity.files) else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("{label} entity has no files"), + )); + }; + let value = serde_json::from_str::(&content).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to parse {label} JSON: {source}"), + ) + })?; + match value { + JsonValue::Object(object) => Ok(object), + _ => Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("{label} JSON root must be an object"), + )), + } +} + +fn claude_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, + scoped_instruction: bool, +) -> BTreeMap { + let mut frontmatter = entity.frontmatter.clone(); + if scoped_instruction { + frontmatter.remove("globs"); + if !frontmatter.contains_key("paths") + && let Some(scope) = entity.scope.as_deref().filter(|scope| *scope != "root") + { + frontmatter.insert( + "paths".to_string(), + JsonValue::Array(vec![JsonValue::String(scope.to_string())]), + ); + } + } + frontmatter +} + fn render_markdown_with_overrides( content: &str, frontmatter: &BTreeMap, overrides: &BTreeMap, + excluded_frontmatter: &[&str], ) -> agentmesh_adapter_sdk_rust::Result { - if frontmatter.is_empty() && overrides.is_empty() && !content.starts_with("---\n") { + if frontmatter.is_empty() + && overrides.is_empty() + && excluded_frontmatter.is_empty() + && !content.starts_with("---\n") + { return Ok(content.to_string()); } let mut document = parse_frontmatter(content)?; + for key in excluded_frontmatter { + document + .frontmatter + .remove(YamlValue::String((*key).to_string())); + } for (key, value) in frontmatter { document .frontmatter @@ -474,6 +1213,11 @@ fn render_markdown_with_overrides( .frontmatter .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); } + for key in excluded_frontmatter { + document + .frontmatter + .remove(YamlValue::String((*key).to_string())); + } compose_frontmatter(&document) } @@ -743,6 +1487,7 @@ mod tests { id: "skill:security-review".to_string(), entity_type: agentmesh_protocol::EntityType::Skill, scope: None, + source_path: None, files, frontmatter: BTreeMap::new(), overrides: BTreeMap::from([("model".to_string(), json!("opus"))]), @@ -831,6 +1576,7 @@ mod tests { id: "skill:security-review".to_string(), entity_type: agentmesh_protocol::EntityType::Skill, scope: None, + source_path: None, files, frontmatter: BTreeMap::new(), overrides: BTreeMap::new(), @@ -885,6 +1631,218 @@ mod tests { assert_eq!(hook_count, 1); } + #[test] + fn imports_claude_v02_project_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write(root.join("CLAUDE.md"), "# Instructions\n"); + write( + root.join(".claude/rules/security.md"), + "---\ndescription: Security\n---\nCheck input boundaries.\n", + ); + write( + root.join(".claude/rules/api.md"), + "---\npaths:\n - packages/api/**\n---\nUse API conventions.\n", + ); + write( + root.join(".claude/commands/git/commit.md"), + "---\ndescription: Commit message\n---\nDraft a commit message.\n", + ); + write( + root.join(".claude/settings.json"), + r#"{"hooks":{"PostToolUse":[]},"permissions":{"allow":["Bash(git status:*)"]},"theme":"dark"}"#, + ); + write( + root.join(".mcp.json"), + r#"{"mcpServers":{"filesystem":{"command":"node","args":["server.js"]}}}"#, + ); + + let adapter = ClaudeAdapter; + let imported = adapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".claude"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"rule:security")); + assert!(ids.contains(&"instructions:scoped:packages-api")); + assert!(ids.contains(&"command:git:commit")); + assert!(ids.contains(&"hook:claude-project")); + assert!(ids.contains(&"permission-policy:claude-project")); + assert!(ids.contains(&"mcp-binding:project")); + let scoped = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:scoped:packages-api") + .unwrap_or_else(|| panic!("scoped rule should be imported")); + assert_eq!(scoped.scope.as_deref(), Some("packages/api/**")); + } + + #[test] + fn skips_claude_rule_with_multiple_path_scopes() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".claude/rules/multi.md"), + "---\npaths:\n - packages/api/**\n - packages/web/**\n---\nUse package conventions.\n", + ); + + let imported = ClaudeAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".claude"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + + assert!(imported.entities.is_empty()); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".claude/rules/multi.md") + && skipped.reason.contains("multiple scopes") + })); + } + + #[test] + fn emits_claude_v02_surfaces_without_clobbering_shared_settings() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write(root.join(".claude/settings.json"), r#"{"theme":"dark"}"#); + let adapter = ClaudeAdapter; + + let response = adapter + .emit(EmitRequest { + runtime_dir: root.join(".claude"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "instructions:scoped:packages-api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("packages/api/**".to_string()), + source_path: None, + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + file("Use API conventions.\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "command:git:commit".to_string(), + entity_type: agentmesh_protocol::EntityType::Command, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("commit.md"), + file("Draft a commit message.\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "hook:claude-project".to_string(), + entity_type: agentmesh_protocol::EntityType::Hook, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("claude-project.json"), + file(r#"{"hooks":{"PostToolUse":[]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "permission-policy:claude-project".to_string(), + entity_type: agentmesh_protocol::EntityType::PermissionPolicy, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("claude-project.json"), + file(r#"{"permissions":{"allow":["Bash(git status:*)"]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert!( + response + .files_written + .contains(&PathBuf::from(".claude/rules/packages-api.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".claude/commands/git/commit.md")) + ); + let scoped = read(root.join(".claude/rules/packages-api.md")); + assert!(scoped.contains("paths:")); + assert!(!scoped.contains("globs:")); + let settings = read(root.join(".claude/settings.json")); + assert!(settings.contains("\"theme\": \"dark\"")); + assert!(settings.contains("\"hooks\"")); + assert!(settings.contains("\"permissions\"")); + } + + #[test] + fn emits_claude_mcp_binding_without_clobbering_other_servers() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".mcp.json"), + r#"{"mcpServers":{"filesystem":{"command":"old-node"},"browser":{"command":"browser-server"}},"metadata":{"owner":"user"}}"#, + ); + let adapter = ClaudeAdapter; + + adapter + .emit(EmitRequest { + runtime_dir: root.join(".claude"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "mcp-binding:project".to_string(), + entity_type: agentmesh_protocol::EntityType::McpBinding, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("project.json"), + file(r#"{"mcpServers":{"filesystem":{"command":"node","args":["server.js"]}}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + let mcp = read(root.join(".mcp.json")); + assert!(mcp.contains("\"filesystem\"")); + assert!(mcp.contains("\"command\": \"node\"")); + assert!(mcp.contains("\"browser\"")); + assert!(mcp.contains("\"browser-server\"")); + assert!(mcp.contains("\"owner\": \"user\"")); + assert!(!mcp.contains("old-node")); + } + proptest! { #[test] fn skill_import_emit_import_roundtrip_preserves_entity_shape( @@ -947,6 +1905,7 @@ mod tests { id: entity.id, entity_type: entity.entity_type, scope: entity.scope, + source_path: Some(entity.source_path), files: entity.files, frontmatter: entity.frontmatter, overrides: BTreeMap::new(), @@ -988,10 +1947,10 @@ mod tests { fn write(path: impl AsRef, content: &str) { let path = path.as_ref(); - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - panic!("parent directory should be created: {error}"); - } + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("parent directory should be created: {error}"); } if let Err(error) = fs::write(path, content) { panic!("file should be written: {error}"); @@ -1000,10 +1959,10 @@ mod tests { fn write_bytes(path: impl AsRef, content: &[u8]) { let path = path.as_ref(); - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - panic!("parent directory should be created: {error}"); - } + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("parent directory should be created: {error}"); } if let Err(error) = fs::write(path, content) { panic!("file should be written: {error}"); diff --git a/adapters/codex/fixtures/agents/invalid/.codex/agents/broken.toml b/adapters/codex/fixtures/agents/invalid/.codex/agents/broken.toml new file mode 100644 index 0000000..ca2e58f --- /dev/null +++ b/adapters/codex/fixtures/agents/invalid/.codex/agents/broken.toml @@ -0,0 +1 @@ +name = "broken diff --git a/adapters/codex/fixtures/agents/manifest.toml b/adapters/codex/fixtures/agents/manifest.toml new file mode 100644 index 0000000..9912186 --- /dev/null +++ b/adapters/codex/fixtures/agents/manifest.toml @@ -0,0 +1,24 @@ +[[fixture]] +name = "codex-subagent" +surface = "codex.subagent" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "subagent" +fixture_file_path = "native/.codex/agents/security-reviewer.toml" +expected_canonical_id = "subagent:security-reviewer" +expected_emitted_path = ".codex/agents/security-reviewer.toml" +expected_diagnostics = [] + +[[fixture]] +name = "codex-subagent-invalid-toml" +surface = "codex.subagent" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "subagent" +fixture_file_path = "invalid/.codex/agents/broken.toml" +expected_canonical_id = "subagent:broken" +expected_diagnostics = ["failed to parse Codex subagent TOML"] diff --git a/adapters/codex/fixtures/agents/native/.codex/agents/security-reviewer.toml b/adapters/codex/fixtures/agents/native/.codex/agents/security-reviewer.toml new file mode 100644 index 0000000..dc47b99 --- /dev/null +++ b/adapters/codex/fixtures/agents/native/.codex/agents/security-reviewer.toml @@ -0,0 +1,7 @@ +name = "security-reviewer" +model = "gpt-5" +instructions = "Review security-sensitive changes." +unknown_metadata = "preserve" + +[skills] +bundled = ["repo-map"] diff --git a/adapters/codex/fixtures/config/combined/.codex/config.toml b/adapters/codex/fixtures/config/combined/.codex/config.toml new file mode 100644 index 0000000..3e80774 --- /dev/null +++ b/adapters/codex/fixtures/config/combined/.codex/config.toml @@ -0,0 +1,11 @@ +model = "gpt-5" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[mcp_servers.filesystem] +command = "node" +args = ["server.js"] + +[profiles.locked-down] +approval_policy = "never" +sandbox_mode = "read-only" diff --git a/adapters/codex/fixtures/config/invalid/.codex/config.toml b/adapters/codex/fixtures/config/invalid/.codex/config.toml new file mode 100644 index 0000000..f61400f --- /dev/null +++ b/adapters/codex/fixtures/config/invalid/.codex/config.toml @@ -0,0 +1 @@ +[mcp_servers.filesystem diff --git a/adapters/codex/fixtures/config/manifest.toml b/adapters/codex/fixtures/config/manifest.toml new file mode 100644 index 0000000..1c0eb4a --- /dev/null +++ b/adapters/codex/fixtures/config/manifest.toml @@ -0,0 +1,63 @@ +[[fixture]] +name = "project-mcp" +surface = "codex.project-mcp" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/mcp" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "mcp_binding" +fixture_file_path = "mcp/.codex/config.toml" +expected_canonical_id = "mcp-binding:codex-project" +expected_emitted_path = ".codex/config.toml" +expected_diagnostics = [] + +[[fixture]] +name = "permission-policy" +surface = "codex.permission-policy" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/config-reference" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "permission_policy" +fixture_file_path = "permissions/.codex/config.toml" +expected_canonical_id = "permission-policy:codex-project" +expected_emitted_path = ".codex/config.toml" +expected_diagnostics = [] + +[[fixture]] +name = "combined-config" +surface = "codex.project-mcp" +cases = ["round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/config-reference" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "mcp_binding" +fixture_file_path = "combined/.codex/config.toml" +expected_canonical_id = "mcp-binding:codex-project" +expected_emitted_path = ".codex/config.toml" +expected_diagnostics = [] +expected_behaviors = ["MCP and permission sections share config.toml without overwriting each other"] + +[[fixture]] +name = "invalid-project-mcp" +surface = "codex.project-mcp" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/mcp" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "mcp_binding" +fixture_file_path = "invalid/.codex/config.toml" +expected_canonical_id = "mcp-binding:codex-project" +expected_diagnostics = ["failed to parse TOML"] + +[[fixture]] +name = "invalid-permission-policy" +surface = "codex.permission-policy" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/config-reference" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "permission_policy" +fixture_file_path = "invalid/.codex/config.toml" +expected_canonical_id = "permission-policy:codex-project" +expected_diagnostics = ["failed to parse TOML"] diff --git a/adapters/codex/fixtures/config/mcp/.codex/config.toml b/adapters/codex/fixtures/config/mcp/.codex/config.toml new file mode 100644 index 0000000..467ad9b --- /dev/null +++ b/adapters/codex/fixtures/config/mcp/.codex/config.toml @@ -0,0 +1,8 @@ +model = "gpt-5" + +[mcp_servers.filesystem] +command = "node" +args = ["server.js"] + +[mcp_servers.filesystem.env] +PROJECT_ROOT = "." diff --git a/adapters/codex/fixtures/config/permissions/.codex/config.toml b/adapters/codex/fixtures/config/permissions/.codex/config.toml new file mode 100644 index 0000000..e1e4f47 --- /dev/null +++ b/adapters/codex/fixtures/config/permissions/.codex/config.toml @@ -0,0 +1,6 @@ +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[profiles.locked-down] +approval_policy = "never" +sandbox_mode = "read-only" diff --git a/adapters/codex/fixtures/deferred/commands/.codex/commands/review.md b/adapters/codex/fixtures/deferred/commands/.codex/commands/review.md new file mode 100644 index 0000000..bef6c3b --- /dev/null +++ b/adapters/codex/fixtures/deferred/commands/.codex/commands/review.md @@ -0,0 +1,3 @@ +# Review Command + +Review the current change set. diff --git a/adapters/codex/fixtures/deferred/manifest.toml b/adapters/codex/fixtures/deferred/manifest.toml new file mode 100644 index 0000000..d955186 --- /dev/null +++ b/adapters/codex/fixtures/deferred/manifest.toml @@ -0,0 +1,23 @@ +[[fixture]] +name = "custom-prompt" +surface = "codex.custom-prompts" +cases = ["deferred_detection", "doctor"] +source_url = "https://developers.openai.com/codex/custom-prompts" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "prompt" +fixture_file_path = "prompts/.codex/prompts/release.md" +expected_canonical_id = "prompt:codex-release" +expected_diagnostics = ["Codex custom prompts are deferred for project sync"] + +[[fixture]] +name = "project-command" +surface = "codex.project-commands" +cases = ["deferred_detection", "doctor"] +source_url = "https://developers.openai.com/codex/custom-prompts" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "command" +fixture_file_path = "commands/.codex/commands/review.md" +expected_canonical_id = "command:codex-review" +expected_diagnostics = ["Codex project commands are deferred for project sync"] diff --git a/adapters/codex/fixtures/deferred/prompts/.codex/prompts/release.md b/adapters/codex/fixtures/deferred/prompts/.codex/prompts/release.md new file mode 100644 index 0000000..6745b75 --- /dev/null +++ b/adapters/codex/fixtures/deferred/prompts/.codex/prompts/release.md @@ -0,0 +1,3 @@ +# Release Prompt + +Draft release notes from the current diff. diff --git a/adapters/codex/fixtures/diagnostics/inline-hooks/.codex/config.toml b/adapters/codex/fixtures/diagnostics/inline-hooks/.codex/config.toml new file mode 100644 index 0000000..aec1fd9 --- /dev/null +++ b/adapters/codex/fixtures/diagnostics/inline-hooks/.codex/config.toml @@ -0,0 +1,3 @@ +[hooks.PostToolUse] +command = "npm test" +matcher = "Edit" diff --git a/adapters/codex/fixtures/diagnostics/manifest.toml b/adapters/codex/fixtures/diagnostics/manifest.toml new file mode 100644 index 0000000..2afde54 --- /dev/null +++ b/adapters/codex/fixtures/diagnostics/manifest.toml @@ -0,0 +1,23 @@ +[[fixture]] +name = "inline-config-hooks" +surface = "codex.inline-config-hooks" +cases = ["read_only_diagnostic", "doctor"] +source_url = "https://developers.openai.com/codex/hooks" +source_checked_on = "2026-07-05" +support_level = "read_only" +canonical_entity_type = "hook" +fixture_file_path = "inline-hooks/.codex/config.toml" +expected_canonical_id = "hook:codex-inline-config" +expected_diagnostics = ["Codex inline config hooks are read-only diagnostics"] + +[[fixture]] +name = "experimental-rules" +surface = "codex.experimental-rules" +cases = ["read_only_diagnostic", "doctor"] +source_url = "https://developers.openai.com/codex/config-reference" +source_checked_on = "2026-07-05" +support_level = "read_only" +canonical_entity_type = "permission_policy" +fixture_file_path = "rules/.codex/rules/strict.rules" +expected_canonical_id = "permission-policy:strict" +expected_diagnostics = ["Codex experimental rules are read-only diagnostics"] diff --git a/adapters/codex/fixtures/diagnostics/rules/.codex/rules/strict.rules b/adapters/codex/fixtures/diagnostics/rules/.codex/rules/strict.rules new file mode 100644 index 0000000..0221646 --- /dev/null +++ b/adapters/codex/fixtures/diagnostics/rules/.codex/rules/strict.rules @@ -0,0 +1,2 @@ +deny = ["network"] +allow = ["read"] diff --git a/adapters/codex/fixtures/hooks/invalid/.codex/hooks.json b/adapters/codex/fixtures/hooks/invalid/.codex/hooks.json new file mode 100644 index 0000000..e9aa6e8 --- /dev/null +++ b/adapters/codex/fixtures/hooks/invalid/.codex/hooks.json @@ -0,0 +1 @@ +{"hooks": diff --git a/adapters/codex/fixtures/hooks/manifest.toml b/adapters/codex/fixtures/hooks/manifest.toml new file mode 100644 index 0000000..e8e4e90 --- /dev/null +++ b/adapters/codex/fixtures/hooks/manifest.toml @@ -0,0 +1,38 @@ +[[fixture]] +name = "project-hook" +surface = "codex.project-hook" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/hooks" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "owned/.codex/hooks.json" +expected_canonical_id = "hook:codex-project" +expected_emitted_path = ".codex/hooks.json" +expected_diagnostics = [] + +[[fixture]] +name = "third-party-project-hook" +surface = "codex.project-hook" +cases = ["unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/hooks" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "third-party/.codex/hooks.json" +expected_canonical_id = "hook:codex-project" +expected_emitted_path = ".codex/hooks.json" +expected_diagnostics = [] +expected_behaviors = ["third-party hook config is synced as data only"] + +[[fixture]] +name = "invalid-project-hook" +surface = "codex.project-hook" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/hooks" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "hook" +fixture_file_path = "invalid/.codex/hooks.json" +expected_canonical_id = "hook:codex-project" +expected_diagnostics = ["failed to parse hook overlay JSON"] diff --git a/adapters/codex/fixtures/hooks/owned/.codex/hooks.json b/adapters/codex/fixtures/hooks/owned/.codex/hooks.json new file mode 100644 index 0000000..f08c782 --- /dev/null +++ b/adapters/codex/fixtures/hooks/owned/.codex/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^Bash$", + "hooks": [ + { + "type": "command", + "command": "cargo test", + "timeout": 5, + "statusMessage": "Running checks" + } + ] + } + ] + } +} diff --git a/adapters/codex/fixtures/hooks/third-party/.codex/hooks.json b/adapters/codex/fixtures/hooks/third-party/.codex/hooks.json new file mode 100644 index 0000000..1755d86 --- /dev/null +++ b/adapters/codex/fixtures/hooks/third-party/.codex/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "^Edit$", + "hooks": [ + { + "type": "command", + "command": "npm test", + "unknownField": "preserve" + } + ] + } + ] + } +} diff --git a/adapters/codex/fixtures/instructions/manifest.toml b/adapters/codex/fixtures/instructions/manifest.toml new file mode 100644 index 0000000..b799f6a --- /dev/null +++ b/adapters/codex/fixtures/instructions/manifest.toml @@ -0,0 +1,27 @@ +[[fixture]] +name = "root-instructions" +surface = "codex.root-instructions" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/guides/agents-md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:root" +fixture_file_path = "root/AGENTS.md" +expected_canonical_id = "instructions:root" +expected_emitted_path = "AGENTS.md" +expected_diagnostics = [] +expected_behaviors = ["plain Markdown AGENTS.md files have no native metadata syntax to reject"] + +[[fixture]] +name = "scoped-instructions" +surface = "codex.scoped-instructions" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/guides/agents-md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "nested/packages/api/AGENTS.md" +expected_canonical_id = "instructions:scoped:packages-api" +expected_emitted_path = "packages/api/AGENTS.md" +expected_diagnostics = [] +expected_behaviors = ["nested AGENTS.md scope is derived from the containing directory"] diff --git a/adapters/codex/fixtures/instructions/nested/packages/api/AGENTS.md b/adapters/codex/fixtures/instructions/nested/packages/api/AGENTS.md new file mode 100644 index 0000000..91a220e --- /dev/null +++ b/adapters/codex/fixtures/instructions/nested/packages/api/AGENTS.md @@ -0,0 +1,3 @@ +# API Package Instructions + +Prefer typed boundaries and explicit error handling inside this package. diff --git a/adapters/codex/fixtures/instructions/root/AGENTS.md b/adapters/codex/fixtures/instructions/root/AGENTS.md new file mode 100644 index 0000000..d28d5ce --- /dev/null +++ b/adapters/codex/fixtures/instructions/root/AGENTS.md @@ -0,0 +1,3 @@ +# Repository Instructions + +Use repository-local conventions and keep generated state out of user-visible files. diff --git a/adapters/codex/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md b/adapters/codex/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md new file mode 100644 index 0000000..5ff2ea6 --- /dev/null +++ b/adapters/codex/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md @@ -0,0 +1,4 @@ +--- +description: "unterminated +--- +Malformed shared skill metadata. diff --git a/adapters/codex/fixtures/skills/invalid/.codex/skills/broken/SKILL.md b/adapters/codex/fixtures/skills/invalid/.codex/skills/broken/SKILL.md new file mode 100644 index 0000000..22b5515 --- /dev/null +++ b/adapters/codex/fixtures/skills/invalid/.codex/skills/broken/SKILL.md @@ -0,0 +1,4 @@ +--- +description: "unterminated +--- +Malformed skill metadata. diff --git a/adapters/codex/fixtures/skills/manifest.toml b/adapters/codex/fixtures/skills/manifest.toml new file mode 100644 index 0000000..c1d6996 --- /dev/null +++ b/adapters/codex/fixtures/skills/manifest.toml @@ -0,0 +1,58 @@ +[[fixture]] +name = "codex-skill-directory" +surface = "codex.skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "native/.codex/skills/repo-map/SKILL.md" +fixture_root_path = "native/.codex/skills/repo-map" +expected_canonical_id = "skill:repo-map" +expected_emitted_path = ".codex/skills/repo-map/SKILL.md" +expected_files = ["SKILL.md", "references/checklist.md"] +expected_diagnostics = [] + +[[fixture]] +name = "shared-skill-directory" +surface = "codex.shared-skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "shared/.agents/skills/shared-workflow/SKILL.md" +fixture_root_path = "shared/.agents/skills/shared-workflow" +expected_canonical_id = "skill:shared-workflow" +expected_emitted_path = ".agents/skills/shared-workflow/SKILL.md" +expected_files = ["SKILL.md", "references/gates.md"] +expected_diagnostics = [] +expected_behaviors = ["shared skill path must import once when more than one runtime can read it"] + +[[fixture]] +name = "codex-skill-invalid-frontmatter" +surface = "codex.skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid/.codex/skills/broken/SKILL.md" +fixture_root_path = "invalid/.codex/skills/broken" +expected_canonical_id = "skill:broken" +expected_files = ["SKILL.md"] +expected_diagnostics = ["failed to parse frontmatter"] + +[[fixture]] +name = "shared-skill-invalid-frontmatter" +surface = "codex.shared-skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://developers.openai.com/codex/skills" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid-shared/.agents/skills/broken-shared/SKILL.md" +fixture_root_path = "invalid-shared/.agents/skills/broken-shared" +expected_canonical_id = "skill:broken-shared" +expected_files = ["SKILL.md"] +expected_diagnostics = ["failed to parse frontmatter"] diff --git a/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/SKILL.md b/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/SKILL.md new file mode 100644 index 0000000..6ffce74 --- /dev/null +++ b/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/SKILL.md @@ -0,0 +1,6 @@ +--- +name: repo-map +description: Map repository structure +codexMetadata: preserve +--- +Inspect repository layout and identify the main crates and adapters. diff --git a/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/references/checklist.md b/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/references/checklist.md new file mode 100644 index 0000000..bde1176 --- /dev/null +++ b/adapters/codex/fixtures/skills/native/.codex/skills/repo-map/references/checklist.md @@ -0,0 +1,4 @@ +# Checklist + +- Locate workspace manifests. +- Identify adapter-specific crates. diff --git a/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md b/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md new file mode 100644 index 0000000..90324cb --- /dev/null +++ b/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md @@ -0,0 +1,5 @@ +--- +name: shared-workflow +description: Shared workflow gates +--- +Apply shared implementation and review gates. diff --git a/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md b/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md new file mode 100644 index 0000000..60bec30 --- /dev/null +++ b/adapters/codex/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md @@ -0,0 +1,5 @@ +# Gates + +- Format. +- Typecheck. +- Test. diff --git a/adapters/codex/src/hooks.rs b/adapters/codex/src/hooks.rs index e7912a2..0d11c4b 100644 --- a/adapters/codex/src/hooks.rs +++ b/adapters/codex/src/hooks.rs @@ -18,13 +18,18 @@ pub(crate) fn install_hooks( request.agentmesh_binary_path.display() ); let mut value = read_json_object(&overlay)?; - let post_tool_use = ensure_hook_array(&mut value, &["PostToolUse"])?; + let legacy_removed = + remove_hook_entries_at(&mut value, &["PostToolUse"], &[], "$.PostToolUse", true); + let post_tool_use = ensure_hook_array(&mut value, &["hooks", "PostToolUse"])?; if let Some(index) = find_hook_group(post_tool_use, &command) { + if legacy_removed > 0 { + write_json_pretty(&overlay, &value)?; + } return Ok(InstallHooksResponse { hooks_installed: vec![InstalledHook { overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.PostToolUse[{index}]"), + entry_path: format!("$.hooks.PostToolUse[{index}]"), command, matcher, }], @@ -33,6 +38,7 @@ pub(crate) fn install_hooks( }); } + remove_matching_entries(post_tool_use, "codex-hook"); post_tool_use.push(json!({ "matcher": matcher, "hooks": [{ @@ -48,7 +54,7 @@ pub(crate) fn install_hooks( Ok(InstallHooksResponse { hooks_installed: vec![InstalledHook { overlay_file: workspace_relative(&workspace_root, &overlay)?, - entry_path: format!("$.PostToolUse[{index}]"), + entry_path: format!("$.hooks.PostToolUse[{index}]"), command, matcher, }], @@ -70,26 +76,31 @@ pub(crate) fn remove_hooks( } let mut value = read_json_object(&overlay)?; - let removed = { - let Some(post_tool_use) = find_hook_array_mut(&mut value, &["PostToolUse"]) else { - return Ok(RemoveHooksResponse { - ok: false, - removed_count: 0, - error: Some("Codex PostToolUse hook array not found".to_string()), - }); - }; + let mut removed = remove_hook_entries_at( + &mut value, + &["hooks", "PostToolUse"], + &request.entry_paths, + "$.hooks.PostToolUse", + false, + ); + removed += remove_hook_entries_at( + &mut value, + &["PostToolUse"], + &request.entry_paths, + "$.PostToolUse", + false, + ); - let mut removed = remove_recorded_entries( - post_tool_use, - &request.entry_paths, - "$.PostToolUse", - "codex-hook", + if removed == 0 { + removed += remove_hook_entries_at( + &mut value, + &["hooks", "PostToolUse"], + &[], + "$.hooks.PostToolUse", + true, ); - if removed == 0 { - removed = remove_matching_entries(post_tool_use, "codex-hook"); - } - removed - }; + removed += remove_hook_entries_at(&mut value, &["PostToolUse"], &[], "$.PostToolUse", true); + } if removed == 0 { return Ok(RemoveHooksResponse { @@ -116,6 +127,23 @@ pub(crate) fn remove_hooks( }) } +fn remove_hook_entries_at( + value: &mut JsonValue, + path: &[&str], + entry_paths: &[String], + prefix: &str, + remove_all_matching: bool, +) -> u32 { + let Some(entries) = find_hook_array_mut(value, path) else { + return 0; + }; + if remove_all_matching { + remove_matching_entries(entries, "codex-hook") + } else { + remove_recorded_entries(entries, entry_paths, prefix, "codex-hook") + } +} + fn codex_matcher(extra: Option<&str>) -> String { let mut tools = vec!["Edit", "Write", "MultiEdit"]; if let Some(extra) = extra.map(str::trim).filter(|extra| !extra.is_empty()) { @@ -128,7 +156,18 @@ fn codex_hooks_are_empty(value: &JsonValue) -> bool { let Some(object) = value.as_object() else { return false; }; - object + object.iter().all(|(key, value)| match key.as_str() { + "hooks" => codex_hook_object_is_empty(value), + "PostToolUse" => value.as_array().is_some_and(Vec::is_empty), + _ => false, + }) +} + +fn codex_hook_object_is_empty(value: &JsonValue) -> bool { + let Some(hooks) = value.as_object() else { + return false; + }; + hooks .iter() .all(|(key, value)| key == "PostToolUse" && value.as_array().is_some_and(Vec::is_empty)) } diff --git a/adapters/codex/src/lib.rs b/adapters/codex/src/lib.rs index 6df8e05..356914e 100644 --- a/adapters/codex/src/lib.rs +++ b/adapters/codex/src/lib.rs @@ -26,17 +26,52 @@ use serde_norway::{Mapping as YamlMapping, Value as YamlValue}; const SUPPORTED_ENTITIES: &[EntityType] = &[ EntityType::Instructions, + EntityType::Hook, + EntityType::McpBinding, + EntityType::PermissionPolicy, EntityType::Skill, EntityType::Subagent, ]; -const ALLOWED_READ_PATHS: &[&str] = &[".codex/**", "AGENTS.md"]; -const ALLOWED_WRITE_PATHS: &[&str] = &[".codex/**", "AGENTS.md"]; +const ALLOWED_READ_PATHS: &[&str] = &[ + ".codex/**", + ".agents/skills/**", + "AGENTS.md", + "**/AGENTS.md", +]; +const ALLOWED_WRITE_PATHS: &[&str] = &[ + ".codex/**", + ".agents/skills/**", + "AGENTS.md", + "**/AGENTS.md", +]; const SUBAGENT_FORMATS: &[&str] = &["markdown", "toml"]; -const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[FormatTranslation { - entity_type: EntityType::Subagent, - formats: SUBAGENT_FORMATS, -}]; +const JSON_FORMATS: &[&str] = &["json"]; +const TOML_FORMATS: &[&str] = &["toml"]; +const CODEX_PERMISSION_POLICY_KEYS: &[&str] = &[ + "profiles", + "approval_policy", + "sandbox_mode", + "sandbox_workspace_write", +]; +const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[ + FormatTranslation { + entity_type: EntityType::Hook, + formats: JSON_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::McpBinding, + formats: TOML_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::PermissionPolicy, + formats: TOML_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Subagent, + formats: SUBAGENT_FORMATS, + }, +]; /// Codex adapter handle. #[derive(Debug, Clone, Copy, Default)] @@ -52,6 +87,9 @@ impl Adapter for CodexAdapter { workspace_root.join(".codex"), workspace_root.join(".codex/skills"), workspace_root.join(".codex/agents"), + workspace_root.join(".codex/hooks.json"), + workspace_root.join(".codex/config.toml"), + workspace_root.join(".agents/skills"), workspace_root.join("AGENTS.md"), ]; let files = evidence @@ -87,6 +125,7 @@ impl Adapter for CodexAdapter { )?); } + import_nested_instructions(&workspace_root, filter, &mut entities, &mut skipped)?; import_skills( &workspace_root, &request.runtime_dir.join("skills"), @@ -94,6 +133,13 @@ impl Adapter for CodexAdapter { &mut entities, &mut skipped, )?; + import_skills( + &workspace_root, + &workspace_root.join(".agents/skills"), + filter, + &mut entities, + &mut skipped, + )?; import_subagents( &workspace_root, &request.runtime_dir.join("agents"), @@ -101,6 +147,21 @@ impl Adapter for CodexAdapter { &mut entities, &mut skipped, )?; + import_hooks_json( + &workspace_root, + &request.runtime_dir.join("hooks.json"), + filter, + &mut entities, + &mut skipped, + )?; + import_config_toml( + &workspace_root, + &request.runtime_dir.join("config.toml"), + filter, + &mut entities, + &mut skipped, + )?; + import_read_only_and_deferred_diagnostics(&workspace_root, filter, &mut skipped)?; Ok(ImportResponse { entities, skipped }) } @@ -137,13 +198,43 @@ impl Adapter for CodexAdapter { &entity.frontmatter, &entity.overrides, )?; - let path = workspace_root.join("AGENTS.md"); + let path = if is_root_instruction(&entity.id, entity.scope.as_deref()) { + workspace_root.join("AGENTS.md") + } else if let Some(source_path) = native_agents_path(&entity) { + workspace_root.join(source_path) + } else { + let Some(path) = scoped_agents_path(&entity.id, entity.scope.as_deref()) + else { + skipped.push(skipped_entity( + entity.id, + "scoped instructions cannot be represented as a nested AGENTS.md", + )); + continue; + }; + workspace_root.join(path) + }; write_atomic(&path, rendered.as_bytes())?; - files_written.push(PathBuf::from("AGENTS.md")); + files_written.push(workspace_relative(&workspace_root, &path)?); + } + EntityType::Hook => { + let target = request.runtime_dir.join("hooks.json"); + merge_json_section(&target, "hooks", &entity)?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::McpBinding => { + let target = request.runtime_dir.join("config.toml"); + merge_toml_sections(&target, &entity, &["mcp_servers"])?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::PermissionPolicy => { + let target = request.runtime_dir.join("config.toml"); + merge_toml_sections(&target, &entity, CODEX_PERMISSION_POLICY_KEYS)?; + files_written.push(workspace_relative(&workspace_root, &target)?); } EntityType::Skill => { let slug = slug_for_entity(&entity.id, &entity.frontmatter); - let target_root = request.runtime_dir.join("skills").join(&slug); + let target_root = + skill_target_root(&workspace_root, &request.runtime_dir, &slug, &entity); if entity.files.is_empty() { skipped.push(skipped_entity(entity.id, "skill entity has no files")); continue; @@ -187,6 +278,12 @@ impl Adapter for CodexAdapter { write_atomic(&target, rendered.as_bytes())?; files_written.push(workspace_relative(&workspace_root, &target)?); } + unsupported => { + skipped.push(skipped_entity( + entity.id, + format!("{} entity is not supported", unsupported.as_str()), + )); + } } } @@ -225,6 +322,262 @@ pub const fn metadata() -> AdapterMetadata { } } +fn import_nested_instructions( + workspace_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_nested_instructions_in_dir(workspace_root, workspace_root, filter, entities, skipped) +} + +fn import_nested_instructions_in_dir( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked instruction path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + if should_skip_nested_instruction_dir(workspace_root, &path) { + continue; + } + import_nested_instructions_in_dir(workspace_root, &path, filter, entities, skipped)?; + continue; + } + if !file_type.is_file() + || path.file_name().and_then(|name| name.to_str()) != Some("AGENTS.md") + { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if source_relative == Path::new("AGENTS.md") + || !selected(filter, std::slice::from_ref(&source_relative)) + { + continue; + } + let Some(scope_dir) = source_relative.parent() else { + continue; + }; + let slug = slugify(&scope_dir.to_string_lossy()); + let scope = format!("{}/**", scope_dir.to_string_lossy().replace('\\', "/")); + entities.push(import_markdown_entity( + &path, + EntityType::Instructions, + format!("instructions:scoped:{slug}"), + Some(scope), + PathBuf::from("instructions").join(format!("{slug}.md")), + source_relative, + )?); + } + + Ok(()) +} + +fn should_skip_nested_instruction_dir(workspace_root: &Path, path: &Path) -> bool { + let relative = path.strip_prefix(workspace_root).unwrap_or(path); + let Some(first) = relative.iter().next().and_then(|part| part.to_str()) else { + return true; + }; + matches!( + first, + ".git" + | ".ai" + | ".agents" + | ".claude" + | ".codex" + | ".cursor" + | ".gemini" + | ".github" + | "target" + ) +} + +fn import_hooks_json( + workspace_root: &Path, + hooks_path: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let source_relative = PathBuf::from(".codex/hooks.json"); + if !selected(filter, std::slice::from_ref(&source_relative)) + || !is_regular_file(workspace_root, hooks_path)? + { + return Ok(()); + } + let value = match read_json_object(hooks_path) { + Ok(value) => value, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + entities.push(import_json_entity( + hooks_path, + source_relative, + EntityType::Hook, + "hook:codex-project", + PathBuf::from("hooks/codex-project.json"), + value, + )?); + Ok(()) +} + +fn import_config_toml( + workspace_root: &Path, + config_path: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let source_relative = PathBuf::from(".codex/config.toml"); + if !selected(filter, std::slice::from_ref(&source_relative)) + || !is_regular_file(workspace_root, config_path)? + { + return Ok(()); + } + let table = match read_toml_table(config_path) { + Ok(table) => table, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + if table.contains_key("hooks") { + skipped.push(SkippedPath { + path: source_relative.clone(), + reason: "Codex inline config hooks are read-only diagnostics".to_string(), + }); + } + if table.contains_key("mcp_servers") { + entities.push(import_toml_section_entity( + config_path, + source_relative.clone(), + EntityType::McpBinding, + "mcp-binding:codex-project", + PathBuf::from("mcp-bindings/codex-project.toml"), + &table, + &["mcp_servers"], + )?); + } + if CODEX_PERMISSION_POLICY_KEYS + .iter() + .any(|key| table.contains_key(*key)) + { + entities.push(import_toml_section_entity( + config_path, + source_relative, + EntityType::PermissionPolicy, + "permission-policy:codex-project", + PathBuf::from("permission-policies/codex-project.toml"), + &table, + CODEX_PERMISSION_POLICY_KEYS, + )?); + } + Ok(()) +} + +fn import_read_only_and_deferred_diagnostics( + workspace_root: &Path, + filter: Option<&ImportFilter>, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".codex/rules"), + filter, + "Codex experimental rules are read-only diagnostics", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".codex/prompts"), + filter, + "Codex custom prompts are deferred for project sync", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".codex/commands"), + filter, + "Codex project commands are deferred for project sync", + skipped, + ) +} + +fn import_diagnostic_file_tree( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_diagnostic_file_tree_inner(workspace_root, root, filter, reason, skipped) +} + +fn import_diagnostic_file_tree_inner( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked diagnostic path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_diagnostic_file_tree_inner(workspace_root, &path, filter, reason, skipped)?; + continue; + } + if !file_type.is_file() { + continue; + } + let relative = workspace_relative(workspace_root, &path)?; + if selected(filter, std::slice::from_ref(&relative)) { + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + } + } + Ok(()) +} + fn import_skills( workspace_root: &Path, skills_root: &Path, @@ -302,7 +655,16 @@ fn import_skills( continue; } let content = read_to_string(&source_path)?; - let frontmatter = frontmatter_json(&content)?; + let frontmatter = match frontmatter_json(&content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; entities.push(ImportedEntity { id: format!("skill:{slug}"), @@ -371,12 +733,22 @@ fn import_subagents( continue; }; let slug = slugify(stem); - entities.push(import_toml_subagent( + let entity = match import_toml_subagent( &path, format!("subagent:{slug}"), PathBuf::from("agents").join(format!("{slug}.md")), - source_relative, - )?); + source_relative.clone(), + ) { + Ok(entity) => entity, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + entities.push(entity); } Ok(()) @@ -436,6 +808,7 @@ fn import_toml_subagent( .iter() .map(|(key, value)| (key.clone(), toml_to_json(value))) .collect::>(); + normalize_imported_codex_skills(&mut frontmatter); let body = frontmatter .get("instructions") .or_else(|| frontmatter.get("prompt")) @@ -469,6 +842,85 @@ fn import_toml_subagent( }) } +fn import_json_entity( + path: &Path, + source_path: PathBuf, + entity_type: EntityType, + id: &str, + canonical_path: PathBuf, + value: JsonValue, +) -> agentmesh_adapter_sdk_rust::Result { + let content = render_json(&value)?; + let file_key = canonical_path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| canonical_path.clone()); + let files = BTreeMap::from([(file_key, EntityFile::utf8(content.clone()))]); + + Ok(ImportedEntity { + id: id.to_string(), + entity_type, + scope: None, + canonical_path, + files, + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path, + source_mtime: mtime_string(path)?, + }) +} + +fn import_toml_section_entity( + path: &Path, + source_path: PathBuf, + entity_type: EntityType, + id: &str, + canonical_path: PathBuf, + source_table: &toml::map::Map, + section_keys: &[&str], +) -> agentmesh_adapter_sdk_rust::Result { + let mut table = toml::map::Map::new(); + for key in section_keys { + if let Some(value) = source_table.get(*key) { + table.insert((*key).to_string(), value.clone()); + } + } + let content = serialize_toml_table(&table); + let file_key = canonical_path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| canonical_path.clone()); + let files = BTreeMap::from([(file_key, EntityFile::utf8(content.clone()))]); + + Ok(ImportedEntity { + id: id.to_string(), + entity_type, + scope: None, + canonical_path, + files, + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path, + source_mtime: mtime_string(path)?, + }) +} + +fn render_json(value: &JsonValue) -> agentmesh_adapter_sdk_rust::Result { + let mut bytes = serde_json::to_vec_pretty(value).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to serialize JSON entity: {source}"), + ) + })?; + bytes.push(b'\n'); + String::from_utf8(bytes).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to encode JSON entity: {source}"), + ) + }) +} + fn first_file_content(files: &BTreeMap) -> Option { for key in [Path::new("SKILL.md"), Path::new("AGENTS.md")] { if let Some(content) = files.get(key).and_then(file_text) { @@ -478,6 +930,279 @@ fn first_file_content(files: &BTreeMap) -> Option { files.values().find_map(file_text) } +fn is_root_instruction(id: &str, scope: Option<&str>) -> bool { + id == "instructions:root" || scope == Some("root") +} + +fn scoped_agents_path(_id: &str, scope: Option<&str>) -> Option { + if let Some(scope) = scope.and_then(scope_directory) { + return Some(scope.join("AGENTS.md")); + } + None +} + +fn scope_directory(scope: &str) -> Option { + let trimmed = scope.trim().trim_matches('/'); + let trimmed = trimmed + .strip_suffix("/**") + .or_else(|| trimmed.strip_suffix("/*")) + .unwrap_or(trimmed); + if trimmed.contains(['*', '?', '[', ']']) { + return None; + } + if trimmed.is_empty() || trimmed == "root" { + return None; + } + let path = PathBuf::from(trimmed); + if is_safe_relative(&path) { + Some(path) + } else { + None + } +} + +fn skill_target_root( + workspace_root: &Path, + runtime_dir: &Path, + slug: &str, + entity: &agentmesh_protocol::EmitEntity, +) -> PathBuf { + if let Some(source_root) = shared_skill_source_root(entity) { + return workspace_root.join(source_root); + } + runtime_dir.join("skills").join(slug) +} + +fn shared_skill_source_root(entity: &agentmesh_protocol::EmitEntity) -> Option<&Path> { + let path = entity.source_path.as_deref()?; + if !is_safe_relative(path) + || !path.starts_with(".agents/skills") + || path.file_name().and_then(|value| value.to_str()) != Some("SKILL.md") + { + return None; + } + let parent = path.parent()?; + if parent.parent() == Some(Path::new(".agents/skills")) { + Some(parent) + } else { + None + } +} + +fn native_agents_path(entity: &agentmesh_protocol::EmitEntity) -> Option { + let path = entity.source_path.as_ref()?; + if !is_safe_relative(path) { + return None; + } + if path.components().next().is_some_and(|component| { + matches!(component, std::path::Component::Normal(part) if part.to_string_lossy().starts_with('.')) + }) { + return None; + } + if path.file_name().and_then(|value| value.to_str()) != Some("AGENTS.md") { + return None; + } + Some(path.clone()) +} + +fn json_object_from_entity( + entity: &agentmesh_protocol::EmitEntity, + label: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + let Some(content) = first_file_content(&entity.files) else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("{label} entity has no files"), + )); + }; + let value = serde_json::from_str::(&content).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to parse {label} JSON: {source}"), + ) + })?; + match value { + JsonValue::Object(object) => Ok(object), + _ => Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("{label} JSON root must be an object"), + )), + } +} + +fn merge_json_section( + target: &Path, + section_key: &str, + entity: &agentmesh_protocol::EmitEntity, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let mut existing = read_json_object(target)?; + let payload = json_object_from_entity(entity, section_key)?; + let replacement = payload + .get(section_key) + .cloned() + .unwrap_or(JsonValue::Object(payload)); + let Some(existing_object) = existing.as_object_mut() else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "JSON root must be an object", + )); + }; + if section_key == "hooks" + && replacement + .as_object() + .is_some_and(|object| object.contains_key("PostToolUse")) + { + remove_codex_hook_entries_at_key(existing_object, "PostToolUse"); + } + let merged = if section_key == "hooks" { + merge_codex_hooks_value(existing_object.remove(section_key), replacement) + } else { + merge_json_section_value(existing_object.remove(section_key), replacement) + }; + existing_object.insert(section_key.to_string(), merged); + write_json_pretty(target, &existing) +} + +fn merge_codex_hooks_value(existing: Option, replacement: JsonValue) -> JsonValue { + match (existing, replacement) { + (Some(JsonValue::Object(mut existing)), JsonValue::Object(replacement)) => { + for (key, value) in replacement { + let merged = if key == "PostToolUse" { + merge_codex_hook_entries(existing.remove(&key), value) + } else { + merge_json_section_value(existing.remove(&key), value) + }; + existing.insert(key, merged); + } + JsonValue::Object(existing) + } + (_, replacement) => replacement, + } +} + +fn merge_codex_hook_entries(existing: Option, replacement: JsonValue) -> JsonValue { + match (existing, replacement) { + (Some(JsonValue::Array(mut existing)), JsonValue::Array(replacement)) => { + existing.retain(|value| !json_contains_agentmesh_codex_hook(value)); + JsonValue::Array(merge_json_arrays(existing, replacement)) + } + (_, replacement) => replacement, + } +} + +fn remove_codex_hook_entries_at_key(object: &mut serde_json::Map, key: &str) { + if let Some(JsonValue::Array(entries)) = object.get_mut(key) { + entries.retain(|value| !json_contains_agentmesh_codex_hook(value)); + } + if object + .get(key) + .is_some_and(|value| value.as_array().is_some_and(Vec::is_empty)) + { + object.remove(key); + } +} + +fn json_contains_agentmesh_codex_hook(value: &JsonValue) -> bool { + let Some(object) = value.as_object() else { + return false; + }; + let Some(hooks) = object.get("hooks").and_then(JsonValue::as_array) else { + return false; + }; + hooks.iter().any(|hook| { + hook.get("command") + .and_then(JsonValue::as_str) + .is_some_and(|command| command.contains("agentmesh") && command.contains("codex-hook")) + }) +} + +fn merge_json_section_value(existing: Option, replacement: JsonValue) -> JsonValue { + match (existing, replacement) { + (Some(JsonValue::Object(existing)), JsonValue::Object(replacement)) => { + JsonValue::Object(merge_json_objects(existing, replacement)) + } + (_, replacement) => replacement, + } +} + +fn merge_json_objects( + mut existing: serde_json::Map, + replacement: serde_json::Map, +) -> serde_json::Map { + for (key, value) in replacement { + let merged = match (existing.remove(&key), value) { + (Some(JsonValue::Object(existing)), JsonValue::Object(replacement)) => { + JsonValue::Object(merge_json_objects(existing, replacement)) + } + (Some(JsonValue::Array(existing)), JsonValue::Array(replacement)) => { + JsonValue::Array(merge_json_arrays(existing, replacement)) + } + (_, replacement) => replacement, + }; + existing.insert(key, merged); + } + existing +} + +fn merge_json_arrays(mut existing: Vec, replacement: Vec) -> Vec { + for value in replacement { + if !existing.contains(&value) { + existing.push(value); + } + } + existing +} + +fn read_toml_table( + path: &Path, +) -> agentmesh_adapter_sdk_rust::Result> { + let content = read_to_string(path)?; + toml_table_from_str(&content, path) +} + +fn toml_table_from_str( + content: &str, + path: &Path, +) -> agentmesh_adapter_sdk_rust::Result> { + let value = content.parse::().map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to parse TOML at {}: {source}", path.display()), + ) + })?; + value.as_table().cloned().ok_or_else(|| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("TOML root at {} must be a table", path.display()), + ) + }) +} + +fn merge_toml_sections( + target: &Path, + entity: &agentmesh_protocol::EmitEntity, + section_keys: &[&str], +) -> agentmesh_adapter_sdk_rust::Result<()> { + let mut existing = if target.exists() { + read_toml_table(target)? + } else { + toml::map::Map::new() + }; + let Some(content) = first_file_content(&entity.files) else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "TOML entity has no files", + )); + }; + let payload = toml_table_from_str(&content, target)?; + for key in section_keys { + if let Some(value) = payload.get(*key) { + existing.insert((*key).to_string(), value.clone()); + } + } + write_atomic(target, serialize_toml_table(&existing).as_bytes()) +} + fn render_markdown_with_overrides( content: &str, frontmatter: &BTreeMap, @@ -517,6 +1242,7 @@ fn render_toml_subagent( .iter() .map(|(key, value)| (key.clone(), value.clone())), ); + normalize_codex_skills_for_emit(&mut merged); let body = toml_instructions_body(&document.body); if !document.body.is_empty() @@ -529,13 +1255,65 @@ fn render_toml_subagent( ); } - let mut table = toml::map::Map::new(); - for (key, value) in merged { - if let Some(value) = json_to_toml(&value) { - table.insert(key, value); + let mut table = toml::map::Map::new(); + for (key, value) in merged { + if let Some(value) = json_to_toml(&value) { + table.insert(key, value); + } + } + Ok(serialize_toml_table(&table)) +} + +fn normalize_imported_codex_skills(frontmatter: &mut BTreeMap) { + let Some(skills) = frontmatter.get("skills").cloned() else { + return; + }; + let Some(bundled) = extract_current_codex_bundled_skills(&skills) else { + return; + }; + frontmatter.insert("skills".to_string(), JsonValue::Array(bundled)); +} + +fn normalize_codex_skills_for_emit(frontmatter: &mut BTreeMap) { + let Some(skills) = frontmatter.get("skills").cloned() else { + return; + }; + let Some(bundled) = extract_canonical_skills(&skills) else { + return; + }; + frontmatter.insert( + "skills".to_string(), + JsonValue::Object( + [("bundled".to_string(), JsonValue::Array(bundled))] + .into_iter() + .collect(), + ), + ); +} + +fn extract_current_codex_bundled_skills(value: &JsonValue) -> Option> { + let JsonValue::Object(object) = value else { + return None; + }; + object.get("bundled").and_then(extract_canonical_skills) +} + +fn extract_canonical_skills(value: &JsonValue) -> Option> { + match value { + JsonValue::Array(values) => { + let skills = values + .iter() + .filter_map(JsonValue::as_str) + .map(|skill| JsonValue::String(skill.to_string())) + .collect::>(); + if skills.is_empty() { + None + } else { + Some(skills) + } } + _ => None, } - Ok(serialize_toml_table(&table)) } fn toml_instructions_body(body: &str) -> String { @@ -819,7 +1597,7 @@ mod tests { ); write( root.join(".codex/agents/code-reviewer.toml"), - "name = \"code-reviewer\"\nmodel = \"gpt-5\"\ninstructions = \"Review code.\"\n", + "name = \"code-reviewer\"\nmodel = \"gpt-5\"\ninstructions = \"Review code.\"\n\n[skills]\nbundled = [\"security-review\"]\n", ); let adapter = CodexAdapter; @@ -841,6 +1619,10 @@ mod tests { }; assert_eq!(subagent.frontmatter.get("model"), Some(&json!("gpt-5"))); assert_eq!(subagent.frontmatter.get("instructions"), None); + assert_eq!( + subagent.frontmatter.get("skills"), + Some(&json!(["security-review"])) + ); assert!( subagent .files @@ -865,6 +1647,55 @@ mod tests { ); } + #[test] + fn import_skips_malformed_codex_entities_without_aborting() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write(root.join("AGENTS.md"), "# Instructions\n"); + write( + root.join(".codex/skills/good/SKILL.md"), + "---\nname: good\n---\nBody\n", + ); + write( + root.join(".codex/skills/bad/SKILL.md"), + "---\ndescription: \"unterminated\n---\nBody\n", + ); + write(root.join(".codex/agents/broken.toml"), "name = \"broken\n"); + + let adapter = CodexAdapter; + let imported = adapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".codex"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should skip malformed entities: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"instructions:root")); + assert!(ids.contains(&"skill:good")); + assert!(!ids.contains(&"skill:bad")); + assert!(!ids.contains(&"subagent:broken")); + assert_eq!(imported.skipped.len(), 2); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/skills/bad/SKILL.md") + && skipped.reason.contains("failed to parse frontmatter") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/agents/broken.toml") + && skipped + .reason + .contains("failed to parse Codex subagent TOML") + })); + } + #[test] fn emits_codex_subagent_toml_from_markdown() { let temp = match tempfile::tempdir() { @@ -885,6 +1716,7 @@ mod tests { id: "subagent:code-reviewer".to_string(), entity_type: agentmesh_protocol::EntityType::Subagent, scope: None, + source_path: None, files, frontmatter: BTreeMap::new(), overrides: BTreeMap::new(), @@ -903,6 +1735,48 @@ mod tests { assert!(content.contains("instructions = \"Review code.\"")); } + #[test] + fn emits_codex_subagent_skills_as_structured_table() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + let adapter = CodexAdapter; + let files = BTreeMap::from([( + PathBuf::from("code-reviewer.md"), + file( + "---\nname: code-reviewer\nmodel: gpt-5\nskills:\n - add-endpoint\n - explore-architecture\n---\nReview code.\n", + ), + )]); + + let response = match adapter.emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "subagent:code-reviewer".to_string(), + entity_type: agentmesh_protocol::EntityType::Subagent, + scope: None, + source_path: None, + files, + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) { + Ok(response) => response, + Err(error) => panic!("emit should succeed: {error}"), + }; + + assert_eq!( + response.files_written, + vec![PathBuf::from(".codex/agents/code-reviewer.toml")] + ); + let content = read(root.join(".codex/agents/code-reviewer.toml")); + assert!(content.contains("[skills]")); + assert!(content.contains("bundled = [\"add-endpoint\", \"explore-architecture\"]")); + assert!(!content.contains("skills = \"")); + } + #[test] fn emits_codex_skill_assets() { let temp = match tempfile::tempdir() { @@ -929,6 +1803,7 @@ mod tests { id: "skill:security-review".to_string(), entity_type: agentmesh_protocol::EntityType::Skill, scope: None, + source_path: None, files, frontmatter: BTreeMap::new(), overrides: BTreeMap::new(), @@ -951,6 +1826,120 @@ mod tests { ); } + #[test] + fn emits_shared_codex_skill_to_agents_surface() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + let adapter = CodexAdapter; + let files = BTreeMap::from([( + PathBuf::from("SKILL.md"), + file("---\nname: Shared Workflow v2\n---\nBody\n"), + )]); + + let response = adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "skill:shared-workflow".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: Some(PathBuf::from(".agents/skills/shared-workflow/SKILL.md")), + files, + frontmatter: BTreeMap::from([( + "name".to_string(), + json!("Shared Workflow v2"), + )]), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert_eq!( + response.files_written, + vec![PathBuf::from(".agents/skills/shared-workflow/SKILL.md")] + ); + assert!( + root.join(".agents/skills/shared-workflow/SKILL.md") + .is_file() + ); + assert!(!root.join(".codex/skills/shared-workflow/SKILL.md").exists()); + assert!( + !root + .join(".agents/skills/shared-workflow-v2/SKILL.md") + .exists() + ); + } + + #[test] + fn skips_codex_scoped_instruction_with_unrepresentable_glob_scope() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + let adapter = CodexAdapter; + + let response = adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "instructions:scoped:api-rs".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("packages/api/**/*.rs".to_string()), + source_path: None, + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + file("Use API conventions.\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed with skip: {error}")); + + assert!(response.files_written.is_empty()); + assert_eq!(response.skipped.len(), 1); + assert!(!root.join("packages/api/**/*.rs/AGENTS.md").exists()); + } + + #[test] + fn skips_codex_scoped_instruction_without_scope_or_source_path() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + let adapter = CodexAdapter; + + let response = adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "instructions:scoped:packages-api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + file("Use API conventions.\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed with skip: {error}")); + + assert!(response.files_written.is_empty()); + assert_eq!(response.skipped.len(), 1); + assert!(!root.join("packages-api/AGENTS.md").exists()); + } + #[test] fn installs_and_removes_codex_hook_file() { let temp = match tempfile::tempdir() { @@ -969,10 +1958,14 @@ mod tests { Err(error) => panic!("install should succeed: {error}"), }; - assert_eq!(installed.hooks_installed[0].entry_path, "$.PostToolUse[0]"); + assert_eq!( + installed.hooks_installed[0].entry_path, + "$.hooks.PostToolUse[0]" + ); let overlay = read(root.join(".codex/hooks.json")); assert!(overlay.contains("codex-hook")); assert!(overlay.contains("AgentMesh sync")); + assert!(overlay.contains("\"hooks\"")); let removed = match adapter.remove_hooks(RemoveHooksRequest { runtime_dir: root.join(".codex"), @@ -994,7 +1987,7 @@ mod tests { let root = temp.path(); write( root.join(".codex/hooks.json"), - r#"{"PostToolUse":[{"matcher":"^Bash$","hooks":[{"type":"command","command":"echo user"}]}]}"#, + r#"{"hooks":{"PostToolUse":[{"matcher":"^Bash$","hooks":[{"type":"command","command":"echo user"}]}]}}"#, ); let adapter = CodexAdapter; @@ -1006,7 +1999,10 @@ mod tests { Ok(installed) => installed, Err(error) => panic!("install should succeed: {error}"), }; - assert_eq!(installed.hooks_installed[0].entry_path, "$.PostToolUse[1]"); + assert_eq!( + installed.hooks_installed[0].entry_path, + "$.hooks.PostToolUse[1]" + ); let removed = match adapter.remove_hooks(RemoveHooksRequest { runtime_dir: root.join(".codex"), @@ -1065,6 +2061,7 @@ severity = ["high", "medium"] id: entity.id, entity_type: entity.entity_type, scope: entity.scope, + source_path: Some(entity.source_path), files: entity.files, frontmatter: entity.frontmatter, overrides: BTreeMap::new(), @@ -1164,11 +2161,399 @@ severity = ["high", "medium"] let overlay = read(root.join(".codex/hooks.json")); let hook_count = overlay.matches("codex-hook").count(); - assert_eq!(first.hooks_installed[0].entry_path, "$.PostToolUse[0]"); - assert_eq!(second.hooks_installed[0].entry_path, "$.PostToolUse[0]"); + assert_eq!( + first.hooks_installed[0].entry_path, + "$.hooks.PostToolUse[0]" + ); + assert_eq!( + second.hooks_installed[0].entry_path, + "$.hooks.PostToolUse[0]" + ); assert_eq!(hook_count, 1); } + #[test] + fn install_migrates_legacy_codex_hook_without_duplication() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/hooks.json"), + r#"{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/old/agentmesh sync --trigger=codex-hook --silent"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"echo user"}]}]}"#, + ); + let adapter = CodexAdapter; + + let installed = adapter + .install_hooks(InstallHooksRequest { + runtime_dir: root.join(".codex"), + agentmesh_binary_path: absolute_agentmesh_binary_path(), + matcher_extra: None, + }) + .unwrap_or_else(|error| panic!("install should succeed: {error}")); + + assert_eq!( + installed.hooks_installed[0].entry_path, + "$.hooks.PostToolUse[0]" + ); + let overlay = read(root.join(".codex/hooks.json")); + assert_eq!(overlay.matches("codex-hook").count(), 1); + assert!(!overlay.contains("/old/agentmesh")); + assert!(overlay.contains("echo user")); + } + + #[test] + fn remove_codex_hook_handles_legacy_entries_without_deleting_user_hooks() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/hooks.json"), + r#"{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/old/agentmesh sync --trigger=codex-hook --silent"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"echo user"}]}]}"#, + ); + let adapter = CodexAdapter; + + let removed = adapter + .remove_hooks(RemoveHooksRequest { + runtime_dir: root.join(".codex"), + entry_paths: vec!["$.PostToolUse[0]".to_string()], + }) + .unwrap_or_else(|error| panic!("remove should succeed: {error}")); + + assert!(removed.ok); + assert_eq!(removed.removed_count, 1); + let overlay = read(root.join(".codex/hooks.json")); + assert!(overlay.contains("echo user")); + assert!(!overlay.contains("codex-hook")); + } + + #[test] + fn imports_codex_v02_project_surfaces_and_diagnostics() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write(root.join("AGENTS.md"), "# Root\n"); + write(root.join("packages/api/AGENTS.md"), "# API\n"); + write( + root.join(".agents/skills/shared-workflow/SKILL.md"), + "---\nname: shared-workflow\n---\nShared workflow.\n", + ); + write( + root.join(".codex/hooks.json"), + r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"npm test"}]}]}}"#, + ); + write( + root.join(".codex/config.toml"), + r#"model = "gpt-5" +approval_policy = "on-request" +sandbox_mode = "workspace-write" + +[hooks.PostToolUse] +command = "npm test" +matcher = "Edit" + +[mcp_servers.filesystem] +command = "node" +args = ["server.js"] + +[profiles.locked-down] +approval_policy = "never" +sandbox_mode = "read-only" +"#, + ); + write( + root.join(".codex/rules/strict.rules"), + "deny = [\"network\"]\n", + ); + write(root.join(".codex/prompts/release.md"), "# Release\n"); + write(root.join(".codex/commands/review.md"), "# Review\n"); + + let adapter = CodexAdapter; + let imported = adapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".codex"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"instructions:root")); + assert!(ids.contains(&"instructions:scoped:packages-api")); + assert!(ids.contains(&"skill:shared-workflow")); + assert!(ids.contains(&"hook:codex-project")); + assert!(ids.contains(&"mcp-binding:codex-project")); + assert!(ids.contains(&"permission-policy:codex-project")); + let nested = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:scoped:packages-api") + .unwrap_or_else(|| panic!("nested instructions should be imported")); + assert_eq!(nested.scope.as_deref(), Some("packages/api/**")); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/config.toml") + && skipped.reason.contains("inline config hooks") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/rules/strict.rules") + && skipped.reason.contains("experimental rules") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/prompts/release.md") + && skipped.reason.contains("custom prompts") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".codex/commands/review.md") + && skipped.reason.contains("project commands") + })); + } + + #[test] + fn emits_codex_v02_surfaces_without_clobbering_shared_config() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/config.toml"), + "model = \"gpt-5\"\n\n[hooks.PostToolUse]\ncommand = \"npm test\"\n", + ); + let adapter = CodexAdapter; + + let response = adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "instructions:scoped:packages-api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("packages/api/**".to_string()), + source_path: None, + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + file("Use API conventions.\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "hook:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::Hook, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.json"), + file(r#"{"hooks":{"PostToolUse":[]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "mcp-binding:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::McpBinding, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.toml"), + file( + "[mcp_servers.filesystem]\ncommand = \"node\"\nargs = [\"server.js\"]\n", + ), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "permission-policy:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::PermissionPolicy, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.toml"), + file( + "approval_policy = \"never\"\nsandbox_mode = \"read-only\"\n\n[profiles.locked]\napproval_policy = \"never\"\n", + ), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert!( + response + .files_written + .contains(&PathBuf::from("packages/api/AGENTS.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".codex/hooks.json")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".codex/config.toml")) + ); + let config = read(root.join(".codex/config.toml")); + assert!(config.contains("model = \"gpt-5\"")); + assert!(config.contains("[hooks.PostToolUse]")); + assert!(config.contains("[mcp_servers.filesystem]")); + assert!(config.contains("[profiles.locked]")); + assert!(config.contains("sandbox_mode = \"read-only\"")); + } + + #[test] + fn emits_codex_hook_and_permission_policy_additively() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/hooks.json"), + r#"{"metadata":{"owner":"user"},"hooks":{"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ); + write( + root.join(".codex/config.toml"), + "approval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\nmodel = \"gpt-5\"\n", + ); + let adapter = CodexAdapter; + + adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "hook:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::Hook, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.json"), + file(r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"agentmesh sync --trigger=codex-hook --silent"}]}]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "permission-policy:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::PermissionPolicy, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.toml"), + file("approval_policy = \"never\"\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + let hooks = read(root.join(".codex/hooks.json")); + assert!(hooks.contains("\"owner\": \"user\"")); + assert!(hooks.contains("echo user")); + assert!(hooks.contains("codex-hook")); + + let config = read(root.join(".codex/config.toml")); + assert!(config.contains("approval_policy = \"never\"")); + assert!(config.contains("sandbox_mode = \"workspace-write\"")); + assert!(config.contains("model = \"gpt-5\"")); + } + + #[test] + fn emits_codex_hook_replaces_stale_managed_entries() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/hooks.json"), + r#"{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/old/agentmesh sync --trigger=codex-hook --silent"}]}],"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"/old/agentmesh sync --trigger=codex-hook --silent"}]},{"matcher":"Bash","hooks":[{"type":"command","command":"echo user"}]}]}}"#, + ); + let adapter = CodexAdapter; + + adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "hook:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::Hook, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.json"), + file(r#"{"hooks":{"PostToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"/new/agentmesh sync --trigger=codex-hook --silent"}]}]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + let hooks = read(root.join(".codex/hooks.json")); + assert!(!hooks.contains("/old/agentmesh")); + assert!(hooks.contains("/new/agentmesh")); + assert!(hooks.contains("echo user")); + assert_eq!(hooks.matches("codex-hook").count(), 1); + } + + #[test] + fn emits_codex_hook_preserves_third_party_codex_hook_mentions() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let root = temp.path(); + write( + root.join(".codex/hooks.json"), + r#"{"hooks":{"PostToolUse":[{"matcher":"codex-hook","hooks":[{"type":"command","command":"echo user","statusMessage":"mentions codex-hook"}]},{"matcher":"Edit","hooks":[{"type":"command","command":"/old/agentmesh sync --trigger=codex-hook --silent"}]}]}}"#, + ); + let adapter = CodexAdapter; + + adapter + .emit(EmitRequest { + runtime_dir: root.join(".codex"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "hook:codex-project".to_string(), + entity_type: agentmesh_protocol::EntityType::Hook, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("codex-project.json"), + file(r#"{"hooks":{"PostToolUse":[{"matcher":"Write","hooks":[{"type":"command","command":"/new/agentmesh sync --trigger=codex-hook --silent"}]}]}}"#), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + let hooks = read(root.join(".codex/hooks.json")); + assert!(hooks.contains("echo user")); + assert!(hooks.contains("mentions codex-hook")); + assert!(!hooks.contains("/old/agentmesh")); + assert!(hooks.contains("/new/agentmesh")); + } + proptest! { #[test] fn import_emit_import_roundtrip_preserves_entity_shape( @@ -1235,6 +2620,7 @@ severity = ["high", "medium"] id: entity.id, entity_type: entity.entity_type, scope: entity.scope, + source_path: Some(entity.source_path), files: entity.files, frontmatter: entity.frontmatter, overrides: BTreeMap::new(), @@ -1276,10 +2662,10 @@ severity = ["high", "medium"] fn write(path: impl AsRef, content: &str) { let path = path.as_ref(); - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - panic!("parent directory should be created: {error}"); - } + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("parent directory should be created: {error}"); } if let Err(error) = fs::write(path, content) { panic!("file should be written: {error}"); @@ -1288,10 +2674,10 @@ severity = ["high", "medium"] fn write_bytes(path: impl AsRef, content: &[u8]) { let path = path.as_ref(); - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - panic!("parent directory should be created: {error}"); - } + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("parent directory should be created: {error}"); } if let Err(error) = fs::write(path, content) { panic!("file should be written: {error}"); diff --git a/adapters/copilot/Cargo.toml b/adapters/copilot/Cargo.toml new file mode 100644 index 0000000..c3fba6e --- /dev/null +++ b/adapters/copilot/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "agentmesh-adapter-copilot" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agentmesh-adapter-sdk-rust.workspace = true +agentmesh-protocol.workspace = true +serde_json.workspace = true +serde_norway.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/adapters/copilot/fixtures/agents/agent-profile/.github/agents/security-reviewer.agent.md b/adapters/copilot/fixtures/agents/agent-profile/.github/agents/security-reviewer.agent.md new file mode 100644 index 0000000..4fd13e1 --- /dev/null +++ b/adapters/copilot/fixtures/agents/agent-profile/.github/agents/security-reviewer.agent.md @@ -0,0 +1,15 @@ +--- +name: security-reviewer +description: Reviews repository changes for security-sensitive regressions. +tools: + - codebase + - search +mcpServers: + audit: + command: audit-server +--- + +# Security Reviewer + +Review only the changed files and their immediate call paths. Report exploitable +behavior first and keep speculative concerns separate. diff --git a/adapters/copilot/fixtures/agents/invalid-profile/.github/agents/broken.agent.md b/adapters/copilot/fixtures/agents/invalid-profile/.github/agents/broken.agent.md new file mode 100644 index 0000000..fb98899 --- /dev/null +++ b/adapters/copilot/fixtures/agents/invalid-profile/.github/agents/broken.agent.md @@ -0,0 +1,8 @@ +--- +name: broken +description: "unterminated +--- + +# Broken Agent + +This custom agent is intentionally malformed. diff --git a/adapters/copilot/fixtures/agents/legacy-profile/.github/agents/performance-reviewer.md b/adapters/copilot/fixtures/agents/legacy-profile/.github/agents/performance-reviewer.md new file mode 100644 index 0000000..dcebc27 --- /dev/null +++ b/adapters/copilot/fixtures/agents/legacy-profile/.github/agents/performance-reviewer.md @@ -0,0 +1,10 @@ +--- +name: performance-reviewer +description: Reviews hot paths and memory growth risks. +unknownField: preserve-me +--- + +# Performance Reviewer + +Look for repeated filesystem scans, unbounded allocations, and unnecessary +serialization on hot paths. diff --git a/adapters/copilot/fixtures/agents/manifest.toml b/adapters/copilot/fixtures/agents/manifest.toml new file mode 100644 index 0000000..fe4a069 --- /dev/null +++ b/adapters/copilot/fixtures/agents/manifest.toml @@ -0,0 +1,38 @@ +[[fixture]] +name = "custom-agent-profile" +surface = "copilot.custom-agent" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-custom-agents" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "subagent" +fixture_file_path = "agent-profile/.github/agents/security-reviewer.agent.md" +expected_canonical_id = "subagent:security-reviewer" +expected_emitted_path = ".github/agents/security-reviewer.agent.md" +expected_diagnostics = [] + +[[fixture]] +name = "custom-agent-legacy-extension" +surface = "copilot.custom-agent" +cases = ["round_trip", "doctor"] +source_url = "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-custom-agents" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "subagent" +fixture_file_path = "legacy-profile/.github/agents/performance-reviewer.md" +expected_canonical_id = "subagent:performance-reviewer" +expected_emitted_path = ".github/agents/performance-reviewer.agent.md" +expected_diagnostics = [] +expected_behaviors = ["new emits normalize to .agent.md"] + +[[fixture]] +name = "custom-agent-invalid-frontmatter" +surface = "copilot.custom-agent" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.github.com/en/copilot/concepts/agents/cloud-agent/about-custom-agents" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "subagent" +fixture_file_path = "invalid-profile/.github/agents/broken.agent.md" +expected_canonical_id = "subagent:broken" +expected_diagnostics = ["invalid custom-agent frontmatter must not be overwritten"] diff --git a/adapters/copilot/fixtures/deferred/environment/agent-environment.json b/adapters/copilot/fixtures/deferred/environment/agent-environment.json new file mode 100644 index 0000000..6b4e994 --- /dev/null +++ b/adapters/copilot/fixtures/deferred/environment/agent-environment.json @@ -0,0 +1,8 @@ +{ + "variables": { + "RUST_LOG": "info" + }, + "secrets": { + "CARGO_REGISTRY_TOKEN": "configured-in-github" + } +} diff --git a/adapters/copilot/fixtures/deferred/hooks/.github/hooks/pre-tool.json b/adapters/copilot/fixtures/deferred/hooks/.github/hooks/pre-tool.json new file mode 100644 index 0000000..f1667c1 --- /dev/null +++ b/adapters/copilot/fixtures/deferred/hooks/.github/hooks/pre-tool.json @@ -0,0 +1,4 @@ +{ + "event": "preToolUse", + "command": "echo copilot-hook" +} diff --git a/adapters/copilot/fixtures/deferred/manifest.toml b/adapters/copilot/fixtures/deferred/manifest.toml new file mode 100644 index 0000000..7557495 --- /dev/null +++ b/adapters/copilot/fixtures/deferred/manifest.toml @@ -0,0 +1,47 @@ +[[fixture]] +name = "copilot-hook-detection" +surface = "copilot.hooks" +cases = ["deferred_detection", "doctor"] +source_url = "https://docs.github.com/en/copilot" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "hook" +fixture_file_path = "hooks/.github/hooks/pre-tool.json" +expected_canonical_id = "hook:copilot-pre-tool" +expected_diagnostics = ["Copilot hook surfaces are deferred and must never be emitted"] + +[[fixture]] +name = "copilot-repository-mcp-settings" +surface = "copilot.repository-mcp" +cases = ["deferred_detection", "doctor"] +source_url = "https://docs.github.com/copilot/how-tos/copilot-on-github/customize-copilot/configure-mcp-servers" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "mcp_binding" +fixture_file_path = "mcp/repository-mcp-settings.json" +expected_canonical_id = "mcp-binding:copilot-repository" +expected_diagnostics = ["repository MCP settings are deferred and must never be emitted"] + +[[fixture]] +name = "copilot-setup-steps" +surface = "copilot.setup-steps" +cases = ["deferred_detection", "doctor"] +source_url = "https://docs.github.com/copilot/how-tos/use-copilot-agents/coding-agent/customize-the-agent-environment" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "command" +fixture_file_path = "setup/.github/workflows/copilot-setup-steps.yml" +expected_canonical_id = "command:copilot-setup-steps" +expected_diagnostics = ["Copilot setup steps configure execution environment and must never be emitted"] + +[[fixture]] +name = "copilot-agent-environment" +surface = "copilot.agent-environment" +cases = ["deferred_detection", "doctor"] +source_url = "https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/configure-secrets-and-variables" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "permission_policy" +fixture_file_path = "environment/agent-environment.json" +expected_canonical_id = "permission-policy:copilot-agent-environment" +expected_diagnostics = ["Copilot agent environment variables and secrets are deferred and must never be emitted"] diff --git a/adapters/copilot/fixtures/deferred/mcp/repository-mcp-settings.json b/adapters/copilot/fixtures/deferred/mcp/repository-mcp-settings.json new file mode 100644 index 0000000..6af3d42 --- /dev/null +++ b/adapters/copilot/fixtures/deferred/mcp/repository-mcp-settings.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "repo-tools": { + "type": "local", + "command": "repo-tools", + "args": ["serve"] + } + } +} diff --git a/adapters/copilot/fixtures/deferred/setup/.github/workflows/copilot-setup-steps.yml b/adapters/copilot/fixtures/deferred/setup/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 0000000..6a125dc --- /dev/null +++ b/adapters/copilot/fixtures/deferred/setup/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,13 @@ +name: Copilot Setup Steps + +on: + workflow_dispatch: + +jobs: + copilot-setup-steps: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - run: cargo fetch diff --git a/adapters/copilot/fixtures/instructions/invalid/.github/instructions/missing-applyto.instructions.md b/adapters/copilot/fixtures/instructions/invalid/.github/instructions/missing-applyto.instructions.md new file mode 100644 index 0000000..af2ff58 --- /dev/null +++ b/adapters/copilot/fixtures/instructions/invalid/.github/instructions/missing-applyto.instructions.md @@ -0,0 +1,8 @@ +--- +description: Missing applyTo should be diagnostic-only +--- + +# Missing Scope + +This file can be imported for diagnostics, but it cannot be emitted until a +deterministic target scope is supplied. diff --git a/adapters/copilot/fixtures/instructions/manifest.toml b/adapters/copilot/fixtures/instructions/manifest.toml new file mode 100644 index 0000000..07f8b4e --- /dev/null +++ b/adapters/copilot/fixtures/instructions/manifest.toml @@ -0,0 +1,38 @@ +[[fixture]] +name = "repository-instructions" +surface = "copilot.repository-instructions" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/add-custom-instructions/add-repository-instructions" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:root" +fixture_file_path = "root/.github/copilot-instructions.md" +expected_canonical_id = "instructions:root" +expected_emitted_path = ".github/copilot-instructions.md" +expected_diagnostics = [] +expected_behaviors = ["plain Markdown repository instructions have no native metadata syntax to reject"] + +[[fixture]] +name = "path-specific-instructions" +surface = "copilot.path-specific-instructions" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "scoped/.github/instructions/api-review.instructions.md" +expected_canonical_id = "instructions:scoped:api-review" +expected_emitted_path = ".github/instructions/api-review.instructions.md" +expected_diagnostics = [] + +[[fixture]] +name = "path-specific-instructions-missing-applyto" +surface = "copilot.path-specific-instructions" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "invalid/.github/instructions/missing-applyto.instructions.md" +expected_canonical_id = "instructions:scoped:missing-applyto" +expected_diagnostics = ["missing applyTo frontmatter"] diff --git a/adapters/copilot/fixtures/instructions/root/.github/copilot-instructions.md b/adapters/copilot/fixtures/instructions/root/.github/copilot-instructions.md new file mode 100644 index 0000000..8f6538f --- /dev/null +++ b/adapters/copilot/fixtures/instructions/root/.github/copilot-instructions.md @@ -0,0 +1,8 @@ +# Repository Coding Instructions + +Prefer small, reviewable changes that keep generated code consistent with the +existing module boundaries. When changing APIs, update call sites and tests in +the same change. + +Use precise error messages that name the failing input or file path when that +information is safe to expose. diff --git a/adapters/copilot/fixtures/instructions/scoped/.github/instructions/api-review.instructions.md b/adapters/copilot/fixtures/instructions/scoped/.github/instructions/api-review.instructions.md new file mode 100644 index 0000000..f25b8da --- /dev/null +++ b/adapters/copilot/fixtures/instructions/scoped/.github/instructions/api-review.instructions.md @@ -0,0 +1,12 @@ +--- +description: API handler review guidance +applyTo: + - "crates/*/src/**/*.rs" +excludeAgent: false +customRouting: reviewer +--- + +# API Review + +When reviewing API handlers, check that domain behavior remains outside command +parsing code and that filesystem paths stay as structured path values. diff --git a/adapters/copilot/fixtures/prompts/invalid/.github/prompts/broken.prompt.md b/adapters/copilot/fixtures/prompts/invalid/.github/prompts/broken.prompt.md new file mode 100644 index 0000000..41ef909 --- /dev/null +++ b/adapters/copilot/fixtures/prompts/invalid/.github/prompts/broken.prompt.md @@ -0,0 +1,6 @@ +--- +description: "unterminated +mode: ask +--- + +This prompt is intentionally malformed. diff --git a/adapters/copilot/fixtures/prompts/manifest.toml b/adapters/copilot/fixtures/prompts/manifest.toml new file mode 100644 index 0000000..59ea341 --- /dev/null +++ b/adapters/copilot/fixtures/prompts/manifest.toml @@ -0,0 +1,24 @@ +[[fixture]] +name = "release-notes-prompt" +surface = "copilot.prompt-file" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "prompt" +fixture_file_path = "release/.github/prompts/release-notes.prompt.md" +expected_canonical_id = "prompt:release-notes" +expected_emitted_path = ".github/prompts/release-notes.prompt.md" +expected_diagnostics = [] + +[[fixture]] +name = "prompt-invalid-frontmatter" +surface = "copilot.prompt-file" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "prompt" +fixture_file_path = "invalid/.github/prompts/broken.prompt.md" +expected_canonical_id = "prompt:broken" +expected_diagnostics = ["invalid prompt frontmatter must not be overwritten"] diff --git a/adapters/copilot/fixtures/prompts/release/.github/prompts/release-notes.prompt.md b/adapters/copilot/fixtures/prompts/release/.github/prompts/release-notes.prompt.md new file mode 100644 index 0000000..40ebdae --- /dev/null +++ b/adapters/copilot/fixtures/prompts/release/.github/prompts/release-notes.prompt.md @@ -0,0 +1,12 @@ +--- +description: Draft release notes from the current branch +mode: ask +tools: + - codebase + - changes +--- + +Summarize the user-visible changes in {{branchName}}. + +Group the result into Added, Changed, Fixed, and Security sections. Include +only changes that can be verified from the repository. diff --git a/adapters/copilot/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md b/adapters/copilot/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md new file mode 100644 index 0000000..ccce84a --- /dev/null +++ b/adapters/copilot/fixtures/skills/invalid-shared/.agents/skills/broken-shared/SKILL.md @@ -0,0 +1,8 @@ +--- +name: broken-shared +description: "unterminated +--- + +# Broken Shared Skill + +This shared skill is intentionally malformed. diff --git a/adapters/copilot/fixtures/skills/invalid/.github/skills/broken/SKILL.md b/adapters/copilot/fixtures/skills/invalid/.github/skills/broken/SKILL.md new file mode 100644 index 0000000..7ef94b1 --- /dev/null +++ b/adapters/copilot/fixtures/skills/invalid/.github/skills/broken/SKILL.md @@ -0,0 +1,8 @@ +--- +name: broken +description: "unterminated +--- + +# Broken Skill + +This skill is intentionally malformed. diff --git a/adapters/copilot/fixtures/skills/manifest.toml b/adapters/copilot/fixtures/skills/manifest.toml new file mode 100644 index 0000000..f73184f --- /dev/null +++ b/adapters/copilot/fixtures/skills/manifest.toml @@ -0,0 +1,58 @@ +[[fixture]] +name = "copilot-skill-directory" +surface = "copilot.skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "native/.github/skills/repo-map/SKILL.md" +fixture_root_path = "native/.github/skills/repo-map" +expected_canonical_id = "skill:repo-map" +expected_emitted_path = ".github/skills/repo-map/SKILL.md" +expected_files = ["SKILL.md", "references/checklist.md"] +expected_diagnostics = [] + +[[fixture]] +name = "shared-skill-directory" +surface = "copilot.shared-skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "shared/.agents/skills/shared-workflow/SKILL.md" +fixture_root_path = "shared/.agents/skills/shared-workflow" +expected_canonical_id = "skill:shared-workflow" +expected_emitted_path = ".agents/skills/shared-workflow/SKILL.md" +expected_files = ["SKILL.md", "references/gates.md"] +expected_diagnostics = [] +expected_behaviors = ["shared skill path must import once when multiple runtimes can read it"] + +[[fixture]] +name = "copilot-skill-invalid-frontmatter" +surface = "copilot.skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid/.github/skills/broken/SKILL.md" +fixture_root_path = "invalid/.github/skills/broken" +expected_canonical_id = "skill:broken" +expected_files = ["SKILL.md"] +expected_diagnostics = ["invalid skill frontmatter must not be overwritten"] + +[[fixture]] +name = "shared-skill-invalid-frontmatter" +surface = "copilot.shared-skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://docs.github.com/en/copilot/reference/customization-cheat-sheet" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid-shared/.agents/skills/broken-shared/SKILL.md" +fixture_root_path = "invalid-shared/.agents/skills/broken-shared" +expected_canonical_id = "skill:broken-shared" +expected_files = ["SKILL.md"] +expected_diagnostics = ["invalid shared skill frontmatter must not be overwritten"] diff --git a/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/SKILL.md b/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/SKILL.md new file mode 100644 index 0000000..f591146 --- /dev/null +++ b/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/SKILL.md @@ -0,0 +1,10 @@ +--- +name: repo-map +description: Build a concise map of repository ownership and module boundaries. +category: discovery +--- + +# Repository Map + +Inspect the workspace manifests, public crate boundaries, and adapter crates. +Return the smallest map that explains where a new feature should live. diff --git a/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/references/checklist.md b/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/references/checklist.md new file mode 100644 index 0000000..3e72a09 --- /dev/null +++ b/adapters/copilot/fixtures/skills/native/.github/skills/repo-map/references/checklist.md @@ -0,0 +1,5 @@ +# Repository Map Checklist + +- Identify workspace members. +- Identify public crate APIs. +- Identify adapter-owned path boundaries. diff --git a/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md b/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md new file mode 100644 index 0000000..1ce5ad9 --- /dev/null +++ b/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/SKILL.md @@ -0,0 +1,10 @@ +--- +name: shared-workflow +description: Apply the repository's common verification workflow. +owner: platform +--- + +# Shared Workflow + +Run the narrow checks that cover the edited files first, then run the full +workspace gate before handoff. diff --git a/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md b/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md new file mode 100644 index 0000000..7fae480 --- /dev/null +++ b/adapters/copilot/fixtures/skills/shared/.agents/skills/shared-workflow/references/gates.md @@ -0,0 +1,6 @@ +# Verification Gates + +- Format +- Typecheck +- Tests +- Review diff --git a/adapters/copilot/src/lib.rs b/adapters/copilot/src/lib.rs new file mode 100644 index 0000000..050f65c --- /dev/null +++ b/adapters/copilot/src/lib.rs @@ -0,0 +1,1785 @@ +//! Bundled GitHub Copilot adapter entry points. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::{ + Adapter, AdapterError, AdapterMetadata, FormatTranslation, collect_entity_files, + compose_frontmatter, dir_entry_file_type, hash_files, is_regular_dir, is_regular_file, + is_safe_relative, max_mtime_string, mtime_string, parse_frontmatter, read_dir_sorted, + read_to_string, selected, sha256_bytes, skipped_entity, slug_for_entity, slugify, + workspace_relative, workspace_root_for, write_atomic, +}; +use agentmesh_protocol::{ + AdapterErrorCode, DetectResponse, EmitRequest, EmitResponse, EntityFile, EntityFileEncoding, + EntityType, ImportFilter, ImportRequest, ImportResponse, ImportedEntity, InstallHooksRequest, + InstallHooksResponse, RemoveHooksRequest, RemoveHooksResponse, RuntimeMode, SkippedPath, +}; +use serde_json::Value as JsonValue; +use serde_norway::{Mapping as YamlMapping, Value as YamlValue}; + +const SUPPORTED_ENTITIES: &[EntityType] = &[ + EntityType::Instructions, + EntityType::Prompt, + EntityType::Skill, + EntityType::Subagent, +]; +const ALLOWED_READ_PATHS: &[&str] = &[ + ".github/copilot-instructions.md", + ".github/instructions/**", + ".github/prompts/**", + ".github/skills/**", + ".github/agents/**", + ".agents/skills/**", + ".github/hooks/**", + "mcp/repository-mcp-settings.json", + ".github/workflows/copilot-setup-steps.yml", + "environment/agent-environment.json", +]; +const ALLOWED_WRITE_PATHS: &[&str] = &[ + ".github/copilot-instructions.md", + ".github/instructions/**", + ".github/prompts/**", + ".github/skills/**", + ".agents/skills/**", + ".github/agents/**", +]; +const MARKDOWN_FORMATS: &[&str] = &["markdown"]; +const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[ + FormatTranslation { + entity_type: EntityType::Instructions, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Prompt, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Skill, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Subagent, + formats: MARKDOWN_FORMATS, + }, +]; + +/// GitHub Copilot adapter handle. +#[derive(Debug, Clone, Copy, Default)] +pub struct CopilotAdapter; + +impl Adapter for CopilotAdapter { + fn metadata(&self) -> AdapterMetadata { + metadata() + } + + fn detect(&self, workspace_root: &Path) -> agentmesh_adapter_sdk_rust::Result { + let evidence = [ + workspace_root.join(".github/copilot-instructions.md"), + workspace_root.join(".github/instructions"), + workspace_root.join(".github/prompts"), + workspace_root.join(".github/skills"), + workspace_root.join(".agents/skills"), + workspace_root.join(".github/agents"), + ]; + let mut files = Vec::new(); + for path in evidence { + let present = if path.is_file() { + is_regular_file(workspace_root, &path)? + } else { + is_regular_dir(workspace_root, &path)? + }; + if present { + files.push(workspace_relative(workspace_root, &path)?); + } + } + + Ok(DetectResponse { + present: !files.is_empty(), + version: None, + files, + }) + } + + fn import(&self, request: ImportRequest) -> agentmesh_adapter_sdk_rust::Result { + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let filter = request.filter.as_ref(); + let mut entities = Vec::new(); + let mut skipped = Vec::new(); + + import_root_instructions(&workspace_root, filter, &mut entities, &mut skipped)?; + import_scoped_instructions( + &workspace_root, + &workspace_root.join(".github/instructions"), + filter, + &mut entities, + &mut skipped, + )?; + import_prompts( + &workspace_root, + &workspace_root.join(".github/prompts"), + filter, + &mut entities, + &mut skipped, + )?; + import_skills( + &workspace_root, + &workspace_root.join(".github/skills"), + false, + filter, + &mut entities, + &mut skipped, + )?; + import_skills( + &workspace_root, + &workspace_root.join(".agents/skills"), + true, + filter, + &mut entities, + &mut skipped, + )?; + import_custom_agents( + &workspace_root, + &workspace_root.join(".github/agents"), + filter, + &mut entities, + &mut skipped, + )?; + import_deferred_diagnostics(&workspace_root, filter, &mut skipped)?; + + Ok(ImportResponse { entities, skipped }) + } + + fn emit(&self, request: EmitRequest) -> agentmesh_adapter_sdk_rust::Result { + if request.mode == RuntimeMode::ReadOnly { + return Ok(EmitResponse { + files_written: Vec::new(), + skipped: request + .entities + .into_iter() + .map(|entity| skipped_entity(entity.id, "runtime is read-only")) + .collect(), + partial_fidelity: Vec::new(), + }); + } + + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let mut files_written = Vec::new(); + let mut skipped = Vec::new(); + + for entity in request.entities { + match entity.entity_type { + EntityType::Instructions => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity( + entity.id, + "instructions entity has no files", + )); + continue; + }; + if is_root_instruction(&entity.id, entity.scope.as_deref()) { + let target = workspace_root.join(".github/copilot-instructions.md"); + validate_copilot_write_path( + &workspace_root, + &target, + &[workspace_root.join(".github")], + Some("md"), + "Copilot root instructions", + )?; + write_atomic(&target, content.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + continue; + } + + let mut frontmatter = scoped_instruction_frontmatter_for_emit(&entity); + if !frontmatter.contains_key("applyTo") { + let Some(scope) = entity.scope.as_deref().filter(|scope| *scope != "root") + else { + skipped.push(skipped_entity( + entity.id, + "scoped Copilot instructions require applyTo or scope", + )); + continue; + }; + frontmatter.insert( + "applyTo".to_string(), + JsonValue::Array(vec![JsonValue::String(scope.to_string())]), + ); + } + if let Err(reason) = apply_to_scope(&frontmatter) { + skipped.push(skipped_entity(entity.id, reason)); + continue; + } + let rendered = render_markdown_with_frontmatter( + &content, + &frontmatter, + &entity.overrides, + )?; + let target = workspace_root.join(".github/instructions").join(format!( + "{}.instructions.md", + scoped_instruction_slug(&entity.id) + )); + validate_copilot_write_path( + &workspace_root, + &target, + &[workspace_root.join(".github/instructions")], + Some("md"), + "Copilot path-specific instructions", + )?; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Prompt => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity(entity.id, "prompt entity has no files")); + continue; + }; + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let frontmatter = + frontmatter_for_emit(&entity, ".github/prompts", &["description"]); + let rendered = render_markdown_with_frontmatter( + &content, + &frontmatter, + &entity.overrides, + )?; + let target = workspace_root + .join(".github/prompts") + .join(format!("{slug}.prompt.md")); + validate_copilot_write_path( + &workspace_root, + &target, + &[workspace_root.join(".github/prompts")], + Some("md"), + "Copilot prompt", + )?; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Skill => { + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let skill_root = copilot_skill_target_root(&workspace_root, &entity, &slug); + validate_copilot_write_path( + &workspace_root, + &skill_root.join("SKILL.md"), + &[ + workspace_root.join(".github/skills"), + workspace_root.join(".agents/skills"), + ], + Some("md"), + "Copilot skill", + )?; + let frontmatter = skill_frontmatter_for_emit(&entity); + for (file_path, file) in &entity.files { + if !is_safe_relative(file_path) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Copilot skill file path {}", file_path.display()), + )); + } + let target = skill_root.join(file_path); + validate_copilot_write_path( + &workspace_root, + &target, + &[ + workspace_root.join(".github/skills"), + workspace_root.join(".agents/skills"), + ], + None, + "Copilot skill", + )?; + let bytes = if file_path == Path::new("SKILL.md") + && file.encoding == EntityFileEncoding::Utf8 + { + render_markdown_with_frontmatter( + &file.content, + &frontmatter, + &entity.overrides, + )? + .into_bytes() + } else { + entity_file_bytes(file_path, file)? + }; + write_atomic(&target, &bytes)?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + } + EntityType::Subagent => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity(entity.id, "subagent entity has no files")); + continue; + }; + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let frontmatter = + frontmatter_for_emit(&entity, ".github/agents", &["name", "description"]); + let rendered = render_markdown_with_frontmatter( + &content, + &frontmatter, + &entity.overrides, + )?; + let target = workspace_root + .join(".github/agents") + .join(format!("{slug}.agent.md")); + validate_copilot_write_path( + &workspace_root, + &target, + &[workspace_root.join(".github/agents")], + Some("md"), + "Copilot custom agent", + )?; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + unsupported => skipped.push(skipped_entity( + entity.id, + format!("{} entity is not supported", unsupported.as_str()), + )), + } + } + + Ok(EmitResponse { + files_written, + skipped, + partial_fidelity: Vec::new(), + }) + } + + fn install_hooks( + &self, + _request: InstallHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(InstallHooksResponse { + hooks_installed: Vec::new(), + fallback_needed: false, + fallback_reason: None, + }) + } + + fn remove_hooks( + &self, + _request: RemoveHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(RemoveHooksResponse { + ok: true, + removed_count: 0, + error: None, + }) + } +} + +/// Returns static metadata for the GitHub Copilot adapter. +#[must_use] +pub const fn metadata() -> AdapterMetadata { + AdapterMetadata { + name: "copilot", + runtime_dir: ".github", + supported_entities: SUPPORTED_ENTITIES, + allowed_read_paths: ALLOWED_READ_PATHS, + allowed_write_paths: ALLOWED_WRITE_PATHS, + format_translations: FORMAT_TRANSLATIONS, + } +} + +fn import_root_instructions( + workspace_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let path = workspace_root.join(".github/copilot-instructions.md"); + let source_relative = PathBuf::from(".github/copilot-instructions.md"); + if !selected(filter, std::slice::from_ref(&source_relative)) { + return Ok(()); + } + match is_regular_file(workspace_root, &path) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + } + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + entities.push(ImportedEntity { + id: "instructions:root".to_string(), + entity_type: EntityType::Instructions, + scope: Some("root".to_string()), + canonical_path: PathBuf::from("AGENTS.md"), + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + EntityFile::utf8(content.clone()), + )]), + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path: source_relative, + source_mtime: mtime_string(&path)?, + }); + Ok(()) +} + +fn import_scoped_instructions( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_scoped_instructions_inner(workspace_root, root, root, filter, entities, skipped) +} + +fn import_scoped_instructions_inner( + workspace_root: &Path, + root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Copilot instruction path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_scoped_instructions_inner( + workspace_root, + root, + &path, + filter, + entities, + skipped, + )?; + continue; + } + if !file_type.is_file() || !is_copilot_instruction_file(&path) { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = path_slug(root, &path); + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: format!( + "invalid path-specific instruction frontmatter must not be overwritten: {error}" + ), + }); + continue; + } + }; + let scope = match apply_to_scope(&frontmatter) { + Ok(scope) => scope, + Err(reason) => { + skipped.push(SkippedPath { + path: source_relative, + reason, + }); + continue; + } + }; + let canonical_content = render_canonical_markdown( + &content, + &portable_frontmatter(&frontmatter, &["description"]), + )?; + entities.push(import_markdown_entity(MarkdownImport { + path: &path, + id: format!("instructions:scoped:{slug}"), + entity_type: EntityType::Instructions, + scope: Some(scope), + canonical_path: PathBuf::from("instructions").join(format!("{slug}.md")), + source_path: source_relative, + content: canonical_content, + frontmatter, + })?); + } + Ok(()) +} + +fn import_prompts( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + for entry in read_dir_sorted(root)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Copilot prompt path is not supported".to_string(), + }); + continue; + } + if !file_type.is_file() || !is_copilot_prompt_file(&path) { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = prompt_slug(&path); + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: format!("invalid prompt frontmatter must not be overwritten: {error}"), + }); + continue; + } + }; + let canonical_content = render_canonical_markdown( + &content, + &portable_frontmatter(&frontmatter, &["description"]), + )?; + entities.push(import_markdown_entity(MarkdownImport { + path: &path, + id: format!("prompt:{slug}"), + entity_type: EntityType::Prompt, + scope: None, + canonical_path: PathBuf::from("prompts").join(format!("{slug}.md")), + source_path: source_relative, + content: canonical_content, + frontmatter, + })?); + } + Ok(()) +} + +fn import_skills( + workspace_root: &Path, + root: &Path, + shared: bool, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + + for entry in read_dir_sorted(root)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Copilot skill path is not supported".to_string(), + }); + continue; + } + if !file_type.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: "skill directory name is not UTF-8".to_string(), + }); + continue; + }; + if name.starts_with('.') { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: "hidden skill directory is treated as a draft".to_string(), + }); + continue; + } + + let source_path = path.join("SKILL.md"); + let source_relative = workspace_relative(workspace_root, &source_path)?; + let skill_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, &[source_relative.clone(), skill_relative]) { + continue; + } + let source_is_file = match is_regular_file(workspace_root, &source_path) { + Ok(source_is_file) => source_is_file, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + if !source_is_file { + continue; + } + + let slug = slugify(name); + let mut files = BTreeMap::new(); + if let Err(error) = collect_entity_files(&path, &path, &mut files) { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: error.to_string(), + }); + continue; + } + let content = match read_to_string(&source_path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + let prefix = if shared { + "invalid shared skill frontmatter must not be overwritten" + } else { + "invalid skill frontmatter must not be overwritten" + }; + skipped.push(SkippedPath { + path: source_relative, + reason: format!("{prefix}: {error}"), + }); + continue; + } + }; + if !shared { + let canonical = render_canonical_markdown( + &content, + &portable_frontmatter(&frontmatter, &["name", "description"]), + )?; + files.insert(PathBuf::from("SKILL.md"), EntityFile::utf8(canonical)); + } + + entities.push(ImportedEntity { + id: format!("skill:{slug}"), + entity_type: EntityType::Skill, + scope: None, + canonical_path: PathBuf::from("skills").join(&slug).join("SKILL.md"), + canonical_sha256: hash_files(&files), + files, + frontmatter, + source_path: source_relative, + source_mtime: max_mtime_string(&path)?, + }); + } + Ok(()) +} + +fn import_custom_agents( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + for entry in read_dir_sorted(root)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Copilot custom agent path is not supported".to_string(), + }); + continue; + } + if !file_type.is_file() || !is_copilot_agent_file(&path) { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = agent_slug(&path); + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: format!( + "invalid custom-agent frontmatter must not be overwritten: {error}" + ), + }); + continue; + } + }; + let canonical_content = render_canonical_markdown( + &content, + &portable_frontmatter(&frontmatter, &["name", "description"]), + )?; + entities.push(import_markdown_entity(MarkdownImport { + path: &path, + id: format!("subagent:{slug}"), + entity_type: EntityType::Subagent, + scope: None, + canonical_path: PathBuf::from("subagents").join(format!("{slug}.md")), + source_path: source_relative, + content: canonical_content, + frontmatter, + })?); + } + Ok(()) +} + +fn import_deferred_diagnostics( + workspace_root: &Path, + filter: Option<&ImportFilter>, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".github/hooks"), + filter, + "Copilot hook surfaces are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join("mcp/repository-mcp-settings.json"), + filter, + "repository MCP settings are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join(".github/workflows/copilot-setup-steps.yml"), + filter, + "Copilot setup steps configure execution environment and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join("environment/agent-environment.json"), + filter, + "Copilot agent environment variables and secrets are deferred and must never be emitted", + skipped, + ) +} + +fn import_diagnostic_file( + workspace_root: &Path, + path: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = relative_or_path(workspace_root, path); + if !selected(filter, std::slice::from_ref(&relative)) || !is_regular_file(workspace_root, path)? + { + return Ok(()); + } + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + Ok(()) +} + +fn import_diagnostic_file_tree( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_diagnostic_file_tree_inner(workspace_root, root, filter, reason, skipped) +} + +fn import_diagnostic_file_tree_inner( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked diagnostic path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_diagnostic_file_tree_inner(workspace_root, &path, filter, reason, skipped)?; + continue; + } + if file_type.is_file() { + let relative = workspace_relative(workspace_root, &path)?; + if selected(filter, std::slice::from_ref(&relative)) { + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + } + } + } + Ok(()) +} + +struct MarkdownImport<'a> { + path: &'a Path, + id: String, + entity_type: EntityType, + scope: Option, + canonical_path: PathBuf, + source_path: PathBuf, + content: String, + frontmatter: BTreeMap, +} + +fn import_markdown_entity( + input: MarkdownImport<'_>, +) -> agentmesh_adapter_sdk_rust::Result { + let file_key = input + .canonical_path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| input.canonical_path.clone()); + Ok(ImportedEntity { + id: input.id, + entity_type: input.entity_type, + scope: input.scope, + canonical_path: input.canonical_path, + files: BTreeMap::from([(file_key, EntityFile::utf8(input.content.clone()))]), + frontmatter: input.frontmatter, + canonical_sha256: sha256_bytes(input.content.as_bytes()), + source_path: input.source_path, + source_mtime: mtime_string(input.path)?, + }) +} + +fn scoped_instruction_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, +) -> BTreeMap { + let copilot_origin = entity + .source_path + .as_ref() + .is_some_and(|path| path.starts_with(".github/instructions")); + if copilot_origin { + return entity.frontmatter.clone(); + } + let mut frontmatter = portable_frontmatter(&entity.frontmatter, &["description"]); + if let Some(value) = entity.frontmatter.get("applyTo") { + frontmatter.insert("applyTo".to_string(), value.clone()); + } + frontmatter +} + +fn frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, + native_root: &str, + portable_keys: &[&str], +) -> BTreeMap { + let native_origin = entity + .source_path + .as_ref() + .is_some_and(|path| path.starts_with(native_root)); + if native_origin { + entity.frontmatter.clone() + } else { + portable_frontmatter(&entity.frontmatter, portable_keys) + } +} + +fn skill_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, +) -> BTreeMap { + let native_or_shared_origin = entity.source_path.as_ref().is_some_and(|path| { + direct_skill_source_root(path, ".github/skills").is_some() + || direct_skill_source_root(path, ".agents/skills").is_some() + }); + if native_or_shared_origin { + entity.frontmatter.clone() + } else { + portable_frontmatter(&entity.frontmatter, &["name", "description"]) + } +} + +fn copilot_skill_target_root( + workspace_root: &Path, + entity: &agentmesh_protocol::EmitEntity, + slug: &str, +) -> PathBuf { + entity + .source_path + .as_ref() + .and_then(|path| { + direct_skill_source_root(path, ".github/skills") + .or_else(|| direct_skill_source_root(path, ".agents/skills")) + }) + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| workspace_root.join(".github/skills").join(slug)) +} + +fn direct_skill_source_root(path: &Path, root: &str) -> Option { + if !is_safe_relative(path) + || path.file_name().and_then(|value| value.to_str()) != Some("SKILL.md") + || !path.starts_with(root) + { + return None; + } + let parent = path.parent()?; + let relative = parent.strip_prefix(root).ok()?; + if relative.components().count() == 1 { + Some(parent.to_path_buf()) + } else { + None + } +} + +fn validate_copilot_write_path( + workspace_root: &Path, + target: &Path, + roots: &[PathBuf], + required_extension: Option<&str>, + label: &str, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, target)?; + if !is_safe_relative(&relative) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe {label} path {}", target.display()), + )); + } + if !roots.iter().any(|root| target.starts_with(root)) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{} is outside declared {label} roots", target.display()), + )); + } + if let Some(extension) = required_extension + && target.extension().and_then(|value| value.to_str()) != Some(extension) + { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{label} target must use .{extension}: {}", target.display()), + )); + } + let parent = target.parent().ok_or_else(|| { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{label} target has no parent: {}", target.display()), + ) + })?; + validate_existing_components(workspace_root, parent, true)?; + validate_existing_components(workspace_root, target, false) +} + +fn validate_existing_components( + workspace_root: &Path, + path: &Path, + require_directory: bool, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, path)?; + let mut current = workspace_root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Copilot path component in {}", path.display()), + )); + }; + current.push(part); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(AdapterError::Io { + action: "read metadata", + path: current, + source, + }); + } + }; + if metadata.file_type().is_symlink() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "symlinked Copilot path {} is not supported", + current.display() + ), + )); + } + if require_directory && !metadata.is_dir() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "Copilot path component is not a directory: {}", + current.display() + ), + )); + } + } + Ok(()) +} + +fn apply_to_scope(frontmatter: &BTreeMap) -> Result { + match frontmatter.get("applyTo") { + Some(JsonValue::String(value)) if !value.trim().is_empty() => Ok(value.clone()), + Some(JsonValue::String(_)) | None => Err("missing applyTo frontmatter".to_string()), + Some(JsonValue::Array(values)) => { + let mut scopes = Vec::new(); + for value in values { + let Some(scope) = value.as_str() else { + return Err( + "applyTo frontmatter must be a string or list of strings".to_string() + ); + }; + if scope.trim().is_empty() { + return Err("applyTo frontmatter entries must be non-empty strings".to_string()); + } + scopes.push(scope); + } + match scopes.as_slice() { + [] => Err("missing applyTo frontmatter".to_string()), + [scope] => Ok((*scope).to_string()), + _ => Err( + "Copilot applyTo with multiple scopes cannot be represented losslessly" + .to_string(), + ), + } + } + Some(_) => Err("applyTo frontmatter must be a string or list of strings".to_string()), + } +} + +fn render_canonical_markdown( + content: &str, + frontmatter: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + if frontmatter.is_empty() { + return Ok(document.body); + } + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn render_markdown_with_frontmatter( + content: &str, + frontmatter: &BTreeMap, + overrides: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + if frontmatter.is_empty() && overrides.is_empty() && !content.starts_with("---\n") { + return Ok(content.to_string()); + } + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + for (key, value) in overrides { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn portable_frontmatter( + frontmatter: &BTreeMap, + keys: &[&str], +) -> BTreeMap { + frontmatter + .iter() + .filter(|(key, _)| keys.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn frontmatter_json_for_path( + source_path: &Path, + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + frontmatter_json(content).map_err(|error| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!( + "failed to parse frontmatter in {}: {error}", + source_path.display() + ), + ) + }) +} + +fn frontmatter_json( + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + let document = parse_frontmatter(content)?; + yaml_mapping_to_json(&document.frontmatter) +} + +fn yaml_mapping_to_json( + mapping: &YamlMapping, +) -> agentmesh_adapter_sdk_rust::Result> { + let json_value = + serde_json::to_value(YamlValue::Mapping(mapping.clone())).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert YAML frontmatter to JSON: {source}"), + ) + })?; + let Some(object) = json_value.as_object() else { + return Ok(BTreeMap::new()); + }; + Ok(object + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) +} + +fn json_to_yaml(value: &JsonValue) -> agentmesh_adapter_sdk_rust::Result { + serde_norway::to_value(value).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert JSON value to YAML: {source}"), + ) + }) +} + +fn entity_file_bytes( + path: &Path, + file: &EntityFile, +) -> agentmesh_adapter_sdk_rust::Result> { + file.decode_bytes().map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!( + "failed to decode Copilot entity file {}: {source}", + path.display() + ), + ) + }) +} + +fn first_file_content(files: &BTreeMap) -> Option { + files.values().find_map(file_text) +} + +fn file_text(file: &EntityFile) -> Option { + match file.encoding { + EntityFileEncoding::Utf8 => Some(file.content.clone()), + EntityFileEncoding::Base64 => None, + } +} + +fn is_root_instruction(id: &str, scope: Option<&str>) -> bool { + id == "instructions:root" || scope == Some("root") +} + +fn scoped_instruction_slug(id: &str) -> String { + id.strip_prefix("instructions:scoped:") + .unwrap_or("scoped") + .to_string() +} + +fn is_copilot_instruction_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".instructions.md")) +} + +fn is_copilot_prompt_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".prompt.md")) +} + +fn is_copilot_agent_file(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".agent.md") || name.ends_with(".md")) +} + +fn path_slug(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + let mut parts = relative + .components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => part.to_str().map(ToString::to_string), + _ => None, + }) + .collect::>(); + if let Some(last) = parts.last_mut() { + let stem = last + .strip_suffix(".instructions.md") + .or_else(|| last.strip_suffix(".md")) + .unwrap_or(last) + .to_string(); + *last = stem; + } + parts + .into_iter() + .map(|part| slugify(&part)) + .collect::>() + .join("-") +} + +fn prompt_slug(path: &Path) -> String { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + slugify(name.strip_suffix(".prompt.md").unwrap_or(name)) +} + +fn agent_slug(path: &Path) -> String { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + let stem = name + .strip_suffix(".agent.md") + .or_else(|| name.strip_suffix(".md")) + .unwrap_or(name); + slugify(stem) +} + +fn relative_or_path(workspace_root: &Path, path: &Path) -> PathBuf { + workspace_relative(workspace_root, path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + use agentmesh_adapter_sdk_rust::Adapter; + use agentmesh_protocol::{EmitEntity, EmitRequest, EntityFile, ImportRequest, RuntimeMode}; + use serde_json::json; + + use super::CopilotAdapter; + + fn write(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("parent dirs should be created: {error}")); + } + std::fs::write(path, contents) + .unwrap_or_else(|error| panic!("fixture should be written: {error}")); + } + + fn read(path: impl AsRef) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("fixture should be readable: {error}")) + } + + fn file(contents: &str) -> EntityFile { + EntityFile::utf8(contents.to_string()) + } + + #[test] + fn imports_copilot_write_enabled_surfaces_and_deferred_diagnostics() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write(root.join(".github/copilot-instructions.md"), "# Root\n"); + write( + root.join(".github/instructions/api.instructions.md"), + "---\ndescription: API\napplyTo:\n - crates/api/**\nexcludeAgent: false\n---\n# API\n", + ); + write( + root.join(".github/prompts/release-notes.prompt.md"), + "---\ndescription: Release\nmode: ask\n---\nRelease body.\n", + ); + write( + root.join(".github/skills/repo-map/SKILL.md"), + "---\nname: repo-map\ndescription: Map repo\ncategory: discovery\n---\n# Repo Map\n", + ); + write( + root.join(".github/skills/repo-map/references/checklist.md"), + "# Checklist\n", + ); + write( + root.join(".agents/skills/shared-workflow/SKILL.md"), + "---\nname: shared-workflow\ndescription: Shared\nowner: platform\n---\n# Shared\n", + ); + write( + root.join(".github/agents/security-reviewer.agent.md"), + "---\nname: security-reviewer\ndescription: Security\ntools:\n - codebase\n---\n# Security\n", + ); + write(root.join(".github/hooks/pre-tool.json"), "{}\n"); + + let imported = CopilotAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".github"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"instructions:root")); + assert!(ids.contains(&"instructions:scoped:api")); + assert!(ids.contains(&"prompt:release-notes")); + assert!(ids.contains(&"skill:repo-map")); + assert!(ids.contains(&"skill:shared-workflow")); + assert!(ids.contains(&"subagent:security-reviewer")); + let scoped = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:scoped:api") + .unwrap_or_else(|| panic!("scoped instructions should import")); + assert_eq!(scoped.scope.as_deref(), Some("crates/api/**")); + assert!(scoped.frontmatter.contains_key("excludeAgent")); + assert!( + !scoped.files[Path::new("api.md")] + .content + .contains("applyTo") + ); + let native_skill = imported + .entities + .iter() + .find(|entity| entity.id == "skill:repo-map") + .unwrap_or_else(|| panic!("native skill should import")); + assert!(native_skill.frontmatter.contains_key("category")); + assert!( + !native_skill.files[Path::new("SKILL.md")] + .content + .contains("category") + ); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".github/hooks/pre-tool.json") + && skipped + .reason + .contains("Copilot hook surfaces are deferred") + })); + } + + #[test] + fn detects_only_write_enabled_copilot_surfaces() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write(root.join(".github/hooks/pre-tool.json"), "{}\n"); + + let deferred_only = CopilotAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + assert!(!deferred_only.present); + + write( + root.join(".github/prompts/release.prompt.md"), + "# Release\n", + ); + let with_prompt = CopilotAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + assert!(with_prompt.present); + } + + #[test] + fn emits_copilot_surfaces_and_normalizes_legacy_agents() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + let response = CopilotAdapter + .emit(EmitRequest { + runtime_dir: root.join(".github"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "instructions:root".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("root".to_string()), + source_path: None, + files: BTreeMap::from([(PathBuf::from("AGENTS.md"), file("# Root\n"))]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "instructions:scoped:api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("crates/api/**".to_string()), + source_path: None, + files: BTreeMap::from([(PathBuf::from("api.md"), file("# API\n"))]), + frontmatter: BTreeMap::from([("description".to_string(), json!("API"))]), + overrides: BTreeMap::from([("excludeAgent".to_string(), json!(false))]), + }, + EmitEntity { + id: "prompt:release-notes".to_string(), + entity_type: agentmesh_protocol::EntityType::Prompt, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("release-notes.md"), + file("Release body.\n"), + )]), + frontmatter: BTreeMap::from([( + "description".to_string(), + json!("Release"), + )]), + overrides: BTreeMap::from([("mode".to_string(), json!("ask"))]), + }, + EmitEntity { + id: "skill:repo-map".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: Some(PathBuf::from(".agents/skills/repo-map/SKILL.md")), + files: BTreeMap::from([ + (PathBuf::from("SKILL.md"), file("# Repo Map\n")), + ( + PathBuf::from("references/checklist.md"), + file("# Checklist\n"), + ), + ]), + frontmatter: BTreeMap::from([ + ("name".to_string(), json!("repo-map")), + ("owner".to_string(), json!("platform")), + ]), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "subagent:performance-reviewer".to_string(), + entity_type: agentmesh_protocol::EntityType::Subagent, + scope: None, + source_path: Some(PathBuf::from(".github/agents/performance-reviewer.md")), + files: BTreeMap::from([( + PathBuf::from("performance-reviewer.md"), + file("# Performance\n"), + )]), + frontmatter: BTreeMap::from([ + ("name".to_string(), json!("performance-reviewer")), + ("unknownField".to_string(), json!("preserve-me")), + ]), + overrides: BTreeMap::new(), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert!( + response + .files_written + .contains(&PathBuf::from(".github/copilot-instructions.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".github/instructions/api.instructions.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".github/prompts/release-notes.prompt.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".agents/skills/repo-map/SKILL.md")) + ); + assert!(response.files_written.contains(&PathBuf::from( + ".github/agents/performance-reviewer.agent.md" + ))); + assert!(read(root.join(".github/instructions/api.instructions.md")).contains("applyTo:")); + assert!(read(root.join(".github/prompts/release-notes.prompt.md")).contains("mode: ask")); + assert!( + read(root.join(".github/agents/performance-reviewer.agent.md")) + .contains("unknownField: preserve-me") + ); + assert!(read(root.join(".agents/skills/repo-map/SKILL.md")).contains("owner: platform")); + } + + #[test] + fn invalid_write_enabled_files_report_diagnostics() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".github/instructions/missing.instructions.md"), + "---\ndescription: Missing\n---\nBody\n", + ); + write( + root.join(".github/instructions/mixed.instructions.md"), + "---\napplyTo:\n - crates/api/**\n - 42\n---\nBody\n", + ); + write( + root.join(".github/prompts/broken.prompt.md"), + "---\ndescription: \"unterminated\n---\nBroken\n", + ); + write( + root.join(".github/skills/broken/SKILL.md"), + "---\nname: broken\ndescription: \"unterminated\n---\nBroken\n", + ); + write( + root.join(".github/agents/broken.agent.md"), + "---\nname: broken\ndescription: \"unterminated\n---\nBroken\n", + ); + + let imported = CopilotAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".github"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + + assert!(imported.entities.is_empty()); + for expected in [ + "missing applyTo frontmatter", + "applyTo frontmatter must be a string or list of strings", + "invalid prompt frontmatter must not be overwritten", + "invalid skill frontmatter must not be overwritten", + "invalid custom-agent frontmatter must not be overwritten", + ] { + assert!( + imported + .skipped + .iter() + .any(|skipped| skipped.reason.contains(expected)), + "missing diagnostic {expected}; skipped: {:?}", + imported.skipped + ); + } + + write( + root.join(".github/instructions/multi.instructions.md"), + "---\napplyTo:\n - crates/api/**\n - adapters/api/**\n---\nBody\n", + ); + let imported = CopilotAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".github"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + assert!(imported.skipped.iter().any(|skipped| { + skipped + .reason + .contains("Copilot applyTo with multiple scopes cannot be represented losslessly") + })); + } + + #[test] + fn scoped_emit_rejects_multi_scope_apply_to_before_writing() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + let response = CopilotAdapter + .emit(EmitRequest { + runtime_dir: root.join(".github"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "instructions:scoped:multi".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("crates/api/**".to_string()), + source_path: None, + files: BTreeMap::from([(PathBuf::from("multi.md"), file("# Multi\n"))]), + frontmatter: BTreeMap::from([( + "applyTo".to_string(), + json!(["crates/api/**", "adapters/api/**"]), + )]), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed with skipped entity: {error}")); + + assert!(response.files_written.is_empty()); + assert!(response.skipped.iter().any(|skipped| { + skipped.entity_id == "instructions:scoped:multi" + && skipped.reason.contains( + "Copilot applyTo with multiple scopes cannot be represented losslessly", + ) + })); + assert!( + !root + .join(".github/instructions/multi.instructions.md") + .exists() + ); + } + + #[test] + fn nested_skill_source_path_does_not_preserve_unscanned_root() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + let response = CopilotAdapter + .emit(EmitRequest { + runtime_dir: root.join(".github"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "skill:archived".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: Some(PathBuf::from(".agents/skills/archived/archive/SKILL.md")), + files: BTreeMap::from([(PathBuf::from("SKILL.md"), file("# Archived\n"))]), + frontmatter: BTreeMap::from([ + ("name".to_string(), json!("archived")), + ("owner".to_string(), json!("platform")), + ]), + overrides: BTreeMap::new(), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert_eq!( + response.files_written, + vec![PathBuf::from(".github/skills/archived/SKILL.md")] + ); + assert!(root.join(".github/skills/archived/SKILL.md").exists()); + assert!( + !root + .join(".agents/skills/archived/archive/SKILL.md") + .exists() + ); + assert!(!read(root.join(".github/skills/archived/SKILL.md")).contains("owner")); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_copilot_skill_write_root() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside) + .unwrap_or_else(|error| panic!("outside dir should be created: {error}")); + std::fs::create_dir_all(root.join(".github")) + .unwrap_or_else(|error| panic!("github dir should be created: {error}")); + symlink(&outside, root.join(".github/skills")) + .unwrap_or_else(|error| panic!("symlink should be created: {error}")); + + let error = CopilotAdapter + .emit(EmitRequest { + runtime_dir: root.join(".github"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "skill:escape".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: None, + files: BTreeMap::from([(PathBuf::from("SKILL.md"), file("# Escape\n"))]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .expect_err("symlinked Copilot skill root should be rejected"); + + assert!(error.to_string().contains("symlinked Copilot path")); + assert!(!outside.join("escape/SKILL.md").exists()); + } +} diff --git a/adapters/cursor/Cargo.toml b/adapters/cursor/Cargo.toml new file mode 100644 index 0000000..53872b7 --- /dev/null +++ b/adapters/cursor/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "agentmesh-adapter-cursor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agentmesh-adapter-sdk-rust.workspace = true +agentmesh-protocol.workspace = true +serde_json.workspace = true +serde_norway.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/adapters/cursor/fixtures/deferred/commands/.cursor/commands/review.md b/adapters/cursor/fixtures/deferred/commands/.cursor/commands/review.md new file mode 100644 index 0000000..5aaa47d --- /dev/null +++ b/adapters/cursor/fixtures/deferred/commands/.cursor/commands/review.md @@ -0,0 +1,3 @@ +# Review + +Review the current change for correctness and test coverage. diff --git a/adapters/cursor/fixtures/deferred/hooks/.cursor/hooks.json b/adapters/cursor/fixtures/deferred/hooks/.cursor/hooks.json new file mode 100644 index 0000000..b759085 --- /dev/null +++ b/adapters/cursor/fixtures/deferred/hooks/.cursor/hooks.json @@ -0,0 +1,7 @@ +{ + "AfterAgent": [ + { + "command": "cursor-agent-review" + } + ] +} diff --git a/adapters/cursor/fixtures/deferred/manifest.toml b/adapters/cursor/fixtures/deferred/manifest.toml new file mode 100644 index 0000000..54301f0 --- /dev/null +++ b/adapters/cursor/fixtures/deferred/manifest.toml @@ -0,0 +1,61 @@ +[[fixture]] +name = "cursor-skill-detection" +surface = "cursor.skills" +cases = ["deferred_detection", "doctor"] +source_url = "https://cursor.com/docs/skills" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "skill" +fixture_file_path = "skills/.cursor/skills/repo-audit/SKILL.md" +fixture_root_path = "skills/.cursor/skills/repo-audit" +expected_canonical_id = "skill:cursor-repo-audit" +expected_files = ["SKILL.md"] +expected_diagnostics = ["Cursor skills are deferred and must never be emitted"] + +[[fixture]] +name = "cursor-hook-detection" +surface = "cursor.hooks" +cases = ["deferred_detection", "doctor"] +source_url = "https://cursor.com/docs/hooks" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "hook" +fixture_file_path = "hooks/.cursor/hooks.json" +expected_canonical_id = "hook:cursor-project" +expected_diagnostics = ["Cursor hooks are deferred and must never be emitted"] + +[[fixture]] +name = "cursor-command-detection" +surface = "cursor.commands" +cases = ["deferred_detection", "doctor"] +source_url = "https://cursor.com/docs/cli/using" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "command" +fixture_file_path = "commands/.cursor/commands/review.md" +expected_canonical_id = "command:cursor-review" +expected_diagnostics = ["Cursor commands are deferred and must never be emitted"] + +[[fixture]] +name = "cursor-subagent-detection" +surface = "cursor.subagents" +cases = ["deferred_detection", "doctor"] +source_url = "https://cursor.com/docs/subagents" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "subagent" +fixture_file_path = "subagents/.cursor/agents/investigator.md" +expected_canonical_id = "subagent:cursor-investigator" +expected_diagnostics = ["Cursor subagents are deferred and must never be emitted"] + +[[fixture]] +name = "cursor-mcp-detection" +surface = "cursor.mcp" +cases = ["deferred_detection", "doctor"] +source_url = "https://cursor.com/docs/mcp" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "mcp_binding" +fixture_file_path = "mcp/.cursor/mcp.json" +expected_canonical_id = "mcp-binding:cursor-project" +expected_diagnostics = ["Cursor MCP configuration is deferred and must never be emitted"] diff --git a/adapters/cursor/fixtures/deferred/mcp/.cursor/mcp.json b/adapters/cursor/fixtures/deferred/mcp/.cursor/mcp.json new file mode 100644 index 0000000..cc166f1 --- /dev/null +++ b/adapters/cursor/fixtures/deferred/mcp/.cursor/mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "repo-tools": { + "command": "repo-tools", + "args": ["serve"] + } + } +} diff --git a/adapters/cursor/fixtures/deferred/skills/.cursor/skills/repo-audit/SKILL.md b/adapters/cursor/fixtures/deferred/skills/.cursor/skills/repo-audit/SKILL.md new file mode 100644 index 0000000..9b78e08 --- /dev/null +++ b/adapters/cursor/fixtures/deferred/skills/.cursor/skills/repo-audit/SKILL.md @@ -0,0 +1,8 @@ +--- +name: repo-audit +description: Audit repository structure and verification gates. +--- + +# Repo Audit + +Inspect manifests, runtime boundaries, and tests without modifying files. diff --git a/adapters/cursor/fixtures/deferred/subagents/.cursor/agents/investigator.md b/adapters/cursor/fixtures/deferred/subagents/.cursor/agents/investigator.md new file mode 100644 index 0000000..92dcc50 --- /dev/null +++ b/adapters/cursor/fixtures/deferred/subagents/.cursor/agents/investigator.md @@ -0,0 +1,8 @@ +--- +name: investigator +description: Inspects code paths without editing files. +--- + +# Investigator + +Trace root cause and report evidence. diff --git a/adapters/cursor/fixtures/rules/always/.cursor/rules/security.mdc b/adapters/cursor/fixtures/rules/always/.cursor/rules/security.mdc new file mode 100644 index 0000000..dcfc813 --- /dev/null +++ b/adapters/cursor/fixtures/rules/always/.cursor/rules/security.mdc @@ -0,0 +1,9 @@ +--- +description: Security invariants for all changes +alwaysApply: true +--- + +# Security Invariants + +Preserve explicit user consent around hook installation and command execution. +Never add a generated command that executes repository-controlled strings. diff --git a/adapters/cursor/fixtures/rules/invalid/.cursor/rules/broken.mdc b/adapters/cursor/fixtures/rules/invalid/.cursor/rules/broken.mdc new file mode 100644 index 0000000..274d37f --- /dev/null +++ b/adapters/cursor/fixtures/rules/invalid/.cursor/rules/broken.mdc @@ -0,0 +1,9 @@ +--- +description: "unterminated +alwaysApply: true +--- + +# Broken Rule + +This file is intentionally invalid and should produce diagnostics without being +overwritten. diff --git a/adapters/cursor/fixtures/rules/manifest.toml b/adapters/cursor/fixtures/rules/manifest.toml new file mode 100644 index 0000000..5921d96 --- /dev/null +++ b/adapters/cursor/fixtures/rules/manifest.toml @@ -0,0 +1,78 @@ +[[fixture]] +name = "always-on-rule" +surface = "cursor.project-rule" +cases = ["minimum_valid", "all_supported_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "always/.cursor/rules/security.mdc" +expected_canonical_id = "rule:security" +expected_emitted_path = ".cursor/rules/security.mdc" +expected_diagnostics = [] + +[[fixture]] +name = "path-scoped-rule" +surface = "cursor.project-rule" +cases = ["minimum_valid", "all_supported_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "scoped/.cursor/rules/api.mdc" +expected_canonical_id = "instructions:scoped:api" +expected_emitted_path = ".cursor/rules/api.mdc" +expected_diagnostics = [] + +[[fixture]] +name = "manual-rule" +surface = "cursor.project-rule" +cases = ["minimum_valid", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "manual/.cursor/rules/manual-review.mdc" +expected_canonical_id = "rule:manual-review" +expected_emitted_path = ".cursor/rules/manual-review.mdc" +expected_diagnostics = [] +expected_behaviors = ["plain MDC rule bodies have no native metadata syntax to reject when no frontmatter is present"] + +[[fixture]] +name = "nested-rule" +surface = "cursor.project-rule" +cases = ["minimum_valid", "round_trip", "sync_check", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "nested/.cursor/rules/team/security.mdc" +expected_canonical_id = "rule:team-security" +expected_emitted_path = ".cursor/rules/team/security.mdc" +expected_diagnostics = [] + +[[fixture]] +name = "unknown-frontmatter-preservation" +surface = "cursor.project-rule" +cases = ["unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "unknown/.cursor/rules/preserve-metadata.mdc" +expected_canonical_id = "rule:preserve-metadata" +expected_emitted_path = ".cursor/rules/preserve-metadata.mdc" +expected_diagnostics = [] +expected_behaviors = ["unknown frontmatter is emitted only for Cursor-originated entities"] + +[[fixture]] +name = "invalid-frontmatter" +surface = "cursor.project-rule" +cases = ["invalid_syntax", "doctor"] +source_url = "https://cursor.com/docs/rules" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "rule" +fixture_file_path = "invalid/.cursor/rules/broken.mdc" +expected_canonical_id = "rule:broken" +expected_diagnostics = ["failed to parse frontmatter"] diff --git a/adapters/cursor/fixtures/rules/manual/.cursor/rules/manual-review.mdc b/adapters/cursor/fixtures/rules/manual/.cursor/rules/manual-review.mdc new file mode 100644 index 0000000..f54cec5 --- /dev/null +++ b/adapters/cursor/fixtures/rules/manual/.cursor/rules/manual-review.mdc @@ -0,0 +1,4 @@ +# Manual Review + +Use this rule when a change needs a focused review of recovery behavior and +user-visible diagnostics. diff --git a/adapters/cursor/fixtures/rules/nested/.cursor/rules/team/security.mdc b/adapters/cursor/fixtures/rules/nested/.cursor/rules/team/security.mdc new file mode 100644 index 0000000..c1b6c99 --- /dev/null +++ b/adapters/cursor/fixtures/rules/nested/.cursor/rules/team/security.mdc @@ -0,0 +1,8 @@ +--- +description: Team security guidance +alwaysApply: true +--- + +# Team Security + +Review authentication and file-write paths for explicit trust boundaries. diff --git a/adapters/cursor/fixtures/rules/scoped/.cursor/rules/api.mdc b/adapters/cursor/fixtures/rules/scoped/.cursor/rules/api.mdc new file mode 100644 index 0000000..083061f --- /dev/null +++ b/adapters/cursor/fixtures/rules/scoped/.cursor/rules/api.mdc @@ -0,0 +1,9 @@ +--- +description: API implementation guidance +globs: + - "crates/**/src/**/*.rs" +--- + +# API Guidance + +Keep command parsing thin and move domain behavior into library crates. diff --git a/adapters/cursor/fixtures/rules/unknown/.cursor/rules/preserve-metadata.mdc b/adapters/cursor/fixtures/rules/unknown/.cursor/rules/preserve-metadata.mdc new file mode 100644 index 0000000..651e574 --- /dev/null +++ b/adapters/cursor/fixtures/rules/unknown/.cursor/rules/preserve-metadata.mdc @@ -0,0 +1,11 @@ +--- +description: Preserve Cursor metadata +alwaysApply: false +cursorPriority: high +owner: platform +--- + +# Preserve Metadata + +When this file is imported and emitted back to Cursor, unknown frontmatter stays +attached to the Cursor-originated entity. diff --git a/adapters/cursor/src/lib.rs b/adapters/cursor/src/lib.rs new file mode 100644 index 0000000..4588e06 --- /dev/null +++ b/adapters/cursor/src/lib.rs @@ -0,0 +1,1087 @@ +//! Bundled Cursor adapter entry points. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::{ + Adapter, AdapterError, AdapterMetadata, FormatTranslation, compose_frontmatter, + dir_entry_file_type, is_regular_dir, is_regular_file, is_safe_relative, mtime_string, + parse_frontmatter, read_dir_sorted, read_to_string, selected, sha256_bytes, skipped_entity, + slug_for_entity, slugify, workspace_relative, workspace_root_for, write_atomic, +}; +use agentmesh_protocol::{ + AdapterErrorCode, DetectResponse, EmitRequest, EmitResponse, EntityFile, EntityFileEncoding, + EntityType, ImportFilter, ImportRequest, ImportResponse, ImportedEntity, InstallHooksRequest, + InstallHooksResponse, RemoveHooksRequest, RemoveHooksResponse, RuntimeMode, SkippedPath, +}; +use serde_json::Value as JsonValue; +use serde_norway::{Mapping as YamlMapping, Value as YamlValue}; + +const SUPPORTED_ENTITIES: &[EntityType] = &[EntityType::Instructions, EntityType::Rule]; +const ALLOWED_READ_PATHS: &[&str] = &[ + ".cursor/rules/**", + ".cursor/skills/**", + ".cursor/hooks.json", + ".cursor/commands/**", + ".cursor/agents/**", + ".cursor/mcp.json", +]; +const ALLOWED_WRITE_PATHS: &[&str] = &[".cursor/rules/**"]; +const MARKDOWN_FORMATS: &[&str] = &["markdown", "mdc"]; +const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[ + FormatTranslation { + entity_type: EntityType::Instructions, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Rule, + formats: MARKDOWN_FORMATS, + }, +]; + +/// Cursor adapter handle. +#[derive(Debug, Clone, Copy, Default)] +pub struct CursorAdapter; + +impl Adapter for CursorAdapter { + fn metadata(&self) -> AdapterMetadata { + metadata() + } + + fn detect(&self, workspace_root: &Path) -> agentmesh_adapter_sdk_rust::Result { + let rules_dir = workspace_root.join(".cursor/rules"); + let files = if is_regular_dir(workspace_root, &rules_dir)? { + vec![workspace_relative(workspace_root, &rules_dir)?] + } else { + Vec::new() + }; + + Ok(DetectResponse { + present: !files.is_empty(), + version: None, + files, + }) + } + + fn import(&self, request: ImportRequest) -> agentmesh_adapter_sdk_rust::Result { + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let filter = request.filter.as_ref(); + let mut entities = Vec::new(); + let mut skipped = Vec::new(); + + import_rules( + &workspace_root, + &request.runtime_dir.join("rules"), + filter, + &mut entities, + &mut skipped, + )?; + import_deferred_diagnostics(&workspace_root, filter, &mut skipped)?; + + Ok(ImportResponse { entities, skipped }) + } + + fn emit(&self, request: EmitRequest) -> agentmesh_adapter_sdk_rust::Result { + if request.mode == RuntimeMode::ReadOnly { + return Ok(EmitResponse { + files_written: Vec::new(), + skipped: request + .entities + .into_iter() + .map(|entity| skipped_entity(entity.id, "runtime is read-only")) + .collect(), + partial_fidelity: Vec::new(), + }); + } + + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let rules_root = request.runtime_dir.join("rules"); + let mut files_written = Vec::new(); + let mut skipped = Vec::new(); + + for entity in request.entities { + match entity.entity_type { + EntityType::Instructions => { + if is_root_instruction(&entity.id, entity.scope.as_deref()) { + skipped.push(skipped_entity( + entity.id, + "Cursor uses root AGENTS.md and does not emit duplicate root instructions", + )); + continue; + } + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity( + entity.id, + "instructions entity has no files", + )); + continue; + }; + let frontmatter = cursor_frontmatter_for_emit(&entity, true); + let rendered = render_markdown_with_frontmatter( + &content, + &frontmatter, + &entity.overrides, + )?; + let target = cursor_source_path(&entity) + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| { + request + .runtime_dir + .join("rules") + .join(format!("{}.mdc", scoped_instruction_slug(&entity.id))) + }); + validate_cursor_write_path(&workspace_root, &rules_root, &target)?; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Rule => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity(entity.id, "rule entity has no files")); + continue; + }; + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let frontmatter = cursor_frontmatter_for_emit(&entity, false); + let rendered = render_markdown_with_frontmatter( + &content, + &frontmatter, + &entity.overrides, + )?; + let target = cursor_source_path(&entity) + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| { + request + .runtime_dir + .join("rules") + .join(format!("{slug}.mdc")) + }); + validate_cursor_write_path(&workspace_root, &rules_root, &target)?; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + unsupported => skipped.push(skipped_entity( + entity.id, + format!("{} entity is not supported", unsupported.as_str()), + )), + } + } + + Ok(EmitResponse { + files_written, + skipped, + partial_fidelity: Vec::new(), + }) + } + + fn install_hooks( + &self, + _request: InstallHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(InstallHooksResponse { + hooks_installed: Vec::new(), + fallback_needed: false, + fallback_reason: None, + }) + } + + fn remove_hooks( + &self, + _request: RemoveHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(RemoveHooksResponse { + ok: true, + removed_count: 0, + error: None, + }) + } +} + +/// Returns static metadata for the Cursor adapter. +#[must_use] +pub const fn metadata() -> AdapterMetadata { + AdapterMetadata { + name: "cursor", + runtime_dir: ".cursor", + supported_entities: SUPPORTED_ENTITIES, + allowed_read_paths: ALLOWED_READ_PATHS, + allowed_write_paths: ALLOWED_WRITE_PATHS, + format_translations: FORMAT_TRANSLATIONS, + } +} + +fn import_rules( + workspace_root: &Path, + rules_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, rules_root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, rules_root), + reason: error.to_string(), + }); + return Ok(()); + } + } + + import_rules_inner( + workspace_root, + rules_root, + rules_root, + filter, + entities, + skipped, + ) +} + +fn import_rules_inner( + workspace_root: &Path, + rules_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let file_type = match dir_entry_file_type(&entry) { + Ok(file_type) => file_type, + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &entry.path()), + reason: error.to_string(), + }); + continue; + } + }; + let path = entry.path(); + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Cursor rule path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_rules_inner(workspace_root, rules_root, &path, filter, entities, skipped)?; + continue; + } + if !file_type.is_file() + || path.extension().and_then(|extension| extension.to_str()) != Some("mdc") + { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = path_slug(rules_root, &path); + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let (entity_type, id, scope, canonical_path) = if frontmatter.contains_key("globs") { + let scope = match scope_from_globs(&frontmatter) { + Ok(scope) => scope, + Err(reason) => { + skipped.push(SkippedPath { + path: source_relative, + reason, + }); + continue; + } + }; + ( + EntityType::Instructions, + format!("instructions:scoped:{slug}"), + scope, + PathBuf::from("instructions").join(format!("{slug}.md")), + ) + } else { + ( + EntityType::Rule, + format!("rule:{slug}"), + None, + PathBuf::from("rules").join(format!("{slug}.mdc")), + ) + }; + + let canonical_content = + render_canonical_cursor_content(&content, &portable_cursor_frontmatter(&frontmatter))?; + + entities.push(import_markdown_entity(MarkdownImport { + path: &path, + entity_type, + id, + scope, + canonical_path, + source_path: source_relative, + content: canonical_content, + frontmatter, + })?); + } + Ok(()) +} + +fn import_deferred_diagnostics( + workspace_root: &Path, + filter: Option<&ImportFilter>, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".cursor/skills"), + filter, + "Cursor skills are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join(".cursor/hooks.json"), + filter, + "Cursor hooks are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".cursor/commands"), + filter, + "Cursor commands are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".cursor/agents"), + filter, + "Cursor subagents are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join(".cursor/mcp.json"), + filter, + "Cursor MCP configuration is deferred and must never be emitted", + skipped, + ) +} + +fn import_diagnostic_file( + workspace_root: &Path, + path: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = relative_or_path(workspace_root, path); + if !selected(filter, std::slice::from_ref(&relative)) || !is_regular_file(workspace_root, path)? + { + return Ok(()); + } + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + Ok(()) +} + +fn import_diagnostic_file_tree( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_diagnostic_file_tree_inner(workspace_root, root, filter, reason, skipped) +} + +fn import_diagnostic_file_tree_inner( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked diagnostic path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_diagnostic_file_tree_inner(workspace_root, &path, filter, reason, skipped)?; + continue; + } + if file_type.is_file() { + let relative = workspace_relative(workspace_root, &path)?; + if selected(filter, std::slice::from_ref(&relative)) { + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + } + } + } + Ok(()) +} + +struct MarkdownImport<'a> { + path: &'a Path, + entity_type: EntityType, + id: String, + scope: Option, + canonical_path: PathBuf, + source_path: PathBuf, + content: String, + frontmatter: BTreeMap, +} + +fn import_markdown_entity( + input: MarkdownImport<'_>, +) -> agentmesh_adapter_sdk_rust::Result { + let files = BTreeMap::from([( + input + .canonical_path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| input.canonical_path.clone()), + EntityFile::utf8(input.content.clone()), + )]); + Ok(ImportedEntity { + id: input.id, + entity_type: input.entity_type, + scope: input.scope, + canonical_path: input.canonical_path, + files, + frontmatter: input.frontmatter, + canonical_sha256: sha256_bytes(input.content.as_bytes()), + source_path: input.source_path, + source_mtime: mtime_string(input.path)?, + }) +} + +fn cursor_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, + scoped_instruction: bool, +) -> BTreeMap { + let cursor_origin = entity + .source_path + .as_ref() + .is_some_and(|path| path.starts_with(".cursor/rules")); + let mut frontmatter = if cursor_origin { + entity.frontmatter.clone() + } else { + entity + .frontmatter + .iter() + .filter(|(key, _)| matches!(key.as_str(), "description" | "globs" | "alwaysApply")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }; + if scoped_instruction + && !frontmatter.contains_key("globs") + && let Some(scope) = entity.scope.as_deref().filter(|scope| *scope != "root") + { + frontmatter.insert( + "globs".to_string(), + JsonValue::Array(vec![JsonValue::String(scope.to_string())]), + ); + } + frontmatter +} + +fn portable_cursor_frontmatter( + frontmatter: &BTreeMap, +) -> BTreeMap { + frontmatter + .iter() + .filter(|(key, _)| key.as_str() == "description") + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn render_canonical_cursor_content( + content: &str, + frontmatter: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + if frontmatter.is_empty() { + return Ok(document.body); + } + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn scope_from_globs(frontmatter: &BTreeMap) -> Result, String> { + match frontmatter.get("globs") { + Some(JsonValue::String(value)) => { + if value.trim().is_empty() { + return Err("Cursor rule globs must contain one non-empty string scope".to_string()); + } + Ok(Some(value.clone())) + } + Some(JsonValue::Array(values)) => { + let scopes = values + .iter() + .filter_map(JsonValue::as_str) + .filter(|value| !value.trim().is_empty()) + .collect::>(); + if scopes.is_empty() { + return Err("Cursor rule globs must contain one non-empty string scope".to_string()); + } + if scopes.len() > 1 { + return Err( + "Cursor rule globs with multiple scopes cannot be represented losslessly" + .to_string(), + ); + } + Ok(scopes.first().map(|scope| (*scope).to_string())) + } + Some(_) => Err("Cursor rule globs must be a string or list of strings".to_string()), + _ => Ok(None), + } +} + +fn cursor_source_path(entity: &agentmesh_protocol::EmitEntity) -> Option { + let path = entity.source_path.as_ref()?; + if !is_safe_relative(path) || !path.starts_with(".cursor/rules") { + return None; + } + if path.extension().and_then(|value| value.to_str()) != Some("mdc") { + return None; + } + Some(path.clone()) +} + +fn validate_cursor_write_path( + workspace_root: &Path, + rules_root: &Path, + target: &Path, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, target)?; + if !is_safe_relative(&relative) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Cursor rule path {}", target.display()), + )); + } + if !target.starts_with(rules_root) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "{} is outside declared Cursor rules root {}", + target.display(), + rules_root.display() + ), + )); + } + if target.extension().and_then(|value| value.to_str()) != Some("mdc") { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("Cursor rule target must use .mdc: {}", target.display()), + )); + } + + let parent = target.parent().ok_or_else(|| { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("Cursor rule target has no parent: {}", target.display()), + ) + })?; + validate_existing_components(workspace_root, parent, true)?; + validate_existing_components(workspace_root, target, false) +} + +fn validate_existing_components( + workspace_root: &Path, + path: &Path, + require_directory: bool, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, path)?; + let mut current = workspace_root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Cursor path component in {}", path.display()), + )); + }; + current.push(part); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(AdapterError::Io { + action: "read metadata", + path: current, + source, + }); + } + }; + if metadata.file_type().is_symlink() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "symlinked Cursor path {} is not supported", + current.display() + ), + )); + } + if require_directory && !metadata.is_dir() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "Cursor path component is not a directory: {}", + current.display() + ), + )); + } + } + Ok(()) +} + +fn is_root_instruction(id: &str, scope: Option<&str>) -> bool { + id == "instructions:root" || scope == Some("root") +} + +fn scoped_instruction_slug(id: &str) -> String { + id.strip_prefix("instructions:scoped:") + .unwrap_or("scoped") + .to_string() +} + +fn path_slug(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + let mut parts = relative + .components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => part.to_str().map(ToString::to_string), + _ => None, + }) + .collect::>(); + if let Some(last) = parts.last_mut() + && let Some(stem) = Path::new(last) + .file_stem() + .and_then(|stem| stem.to_str()) + .map(ToString::to_string) + { + *last = stem; + } + parts + .into_iter() + .map(|part| slugify(&part)) + .collect::>() + .join("-") +} + +fn render_markdown_with_frontmatter( + content: &str, + frontmatter: &BTreeMap, + overrides: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + if frontmatter.is_empty() && overrides.is_empty() && !content.starts_with("---\n") { + return Ok(content.to_string()); + } + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + for (key, value) in overrides { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn frontmatter_json( + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + let document = parse_frontmatter(content)?; + yaml_mapping_to_json(&document.frontmatter) +} + +fn frontmatter_json_for_path( + source_path: &Path, + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + frontmatter_json(content).map_err(|error| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!( + "failed to parse frontmatter in {}: {error}", + source_path.display() + ), + ) + }) +} + +fn yaml_mapping_to_json( + mapping: &YamlMapping, +) -> agentmesh_adapter_sdk_rust::Result> { + let json_value = + serde_json::to_value(YamlValue::Mapping(mapping.clone())).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert YAML frontmatter to JSON: {source}"), + ) + })?; + let Some(object) = json_value.as_object() else { + return Ok(BTreeMap::new()); + }; + Ok(object + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) +} + +fn json_to_yaml(value: &JsonValue) -> agentmesh_adapter_sdk_rust::Result { + serde_norway::to_value(value).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert JSON value to YAML: {source}"), + ) + }) +} + +fn first_file_content(files: &BTreeMap) -> Option { + files.values().find_map(file_text) +} + +fn file_text(file: &EntityFile) -> Option { + match file.encoding { + EntityFileEncoding::Utf8 => Some(file.content.clone()), + EntityFileEncoding::Base64 => None, + } +} + +fn relative_or_path(workspace_root: &Path, path: &Path) -> PathBuf { + workspace_relative(workspace_root, path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + use agentmesh_adapter_sdk_rust::Adapter; + use agentmesh_protocol::{EmitEntity, EmitRequest, EntityFile, ImportRequest, RuntimeMode}; + use serde_json::json; + + use super::CursorAdapter; + + fn write(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("parent dirs should be created: {error}")); + } + std::fs::write(path, contents) + .unwrap_or_else(|error| panic!("fixture should be written: {error}")); + } + + fn read(path: impl AsRef) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("fixture should be readable: {error}")) + } + + fn file(contents: &str) -> EntityFile { + EntityFile::utf8(contents.to_string()) + } + + #[test] + fn imports_cursor_rules_and_deferred_diagnostics() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\n---\nSecurity body.\n", + ); + write( + root.join(".cursor/rules/api.mdc"), + "---\ndescription: API\nglobs:\n - crates/**/src/**/*.rs\n---\nAPI body.\n", + ); + write( + root.join(".cursor/rules/team/security.mdc"), + "---\ndescription: Team security\nalwaysApply: true\n---\nSecurity body.\n", + ); + write(root.join(".cursor/commands/review.md"), "# Review\n"); + + let imported = CursorAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".cursor"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"rule:security")); + assert!(ids.contains(&"instructions:scoped:api")); + assert!(ids.contains(&"rule:team-security")); + let scoped = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:scoped:api") + .unwrap_or_else(|| panic!("scoped rule should import")); + assert_eq!(scoped.scope.as_deref(), Some("crates/**/src/**/*.rs")); + assert!(!scoped.files[Path::new("api.md")].content.contains("globs:")); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".cursor/commands/review.md") + && skipped.reason.contains("Cursor commands are deferred") + })); + } + + #[test] + fn detects_only_cursor_rules_presence() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write(root.join(".cursor/mcp.json"), "{}\n"); + + let deferred_only = CursorAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + assert!(!deferred_only.present); + + write(root.join(".cursor/rules/security.mdc"), "# Security\n"); + let with_rules = CursorAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + assert!(with_rules.present); + } + + #[test] + fn emits_cursor_rules_and_preserves_cursor_origin_metadata() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + let response = CursorAdapter + .emit(EmitRequest { + runtime_dir: root.join(".cursor"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "rule:preserve-metadata".to_string(), + entity_type: agentmesh_protocol::EntityType::Rule, + scope: None, + source_path: Some(PathBuf::from(".cursor/rules/preserve-metadata.mdc")), + files: BTreeMap::from([( + PathBuf::from("preserve-metadata.mdc"), + file("# Preserve\n"), + )]), + frontmatter: BTreeMap::from([ + ("description".to_string(), json!("Preserve")), + ("cursorPriority".to_string(), json!("high")), + ]), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "instructions:scoped:api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("crates/**/src/**/*.rs".to_string()), + source_path: None, + files: BTreeMap::from([(PathBuf::from("api.md"), file("# API\n"))]), + frontmatter: BTreeMap::from([("description".to_string(), json!("API"))]), + overrides: BTreeMap::new(), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert!( + response + .files_written + .contains(&PathBuf::from(".cursor/rules/preserve-metadata.mdc")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".cursor/rules/api.mdc")) + ); + let preserved = read(root.join(".cursor/rules/preserve-metadata.mdc")); + assert!(preserved.contains("cursorPriority: high")); + let scoped = read(root.join(".cursor/rules/api.mdc")); + assert!(scoped.contains("globs:")); + } + + #[test] + fn skips_invalid_cursor_frontmatter() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".cursor/rules/broken.mdc"), + "---\ndescription: \"unterminated\n---\nBroken\n", + ); + + let imported = CursorAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".cursor"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + + assert!(imported.entities.is_empty()); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".cursor/rules/broken.mdc") + && skipped.reason.contains("failed to parse frontmatter") + })); + } + + #[test] + fn skips_lossy_multi_glob_cursor_rule() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".cursor/rules/multi.mdc"), + "---\nglobs:\n - crates/api/**\n - crates/web/**\n---\nMulti\n", + ); + write( + root.join(".cursor/rules/empty.mdc"), + "---\nglobs: []\n---\nEmpty\n", + ); + write( + root.join(".cursor/rules/object.mdc"), + "---\nglobs:\n include: crates/**\n---\nObject\n", + ); + + let imported = CursorAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".cursor"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + + assert!(imported.entities.is_empty()); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".cursor/rules/multi.mdc") + && skipped.reason.contains("multiple scopes") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".cursor/rules/empty.mdc") + && skipped.reason.contains("one non-empty string scope") + })); + assert!(imported.skipped.iter().any(|skipped| { + skipped.path == Path::new(".cursor/rules/object.mdc") + && skipped.reason.contains("string or list of strings") + })); + } + + #[test] + fn keeps_cursor_only_frontmatter_out_of_canonical_content() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".cursor/rules/preserve.mdc"), + "---\ndescription: Preserve\nalwaysApply: true\ncursorPriority: high\n---\nBody\n", + ); + + let imported = CursorAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".cursor"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let entity = imported + .entities + .iter() + .find(|entity| entity.id == "rule:preserve") + .unwrap_or_else(|| panic!("rule should import")); + let content = &entity.files[Path::new("preserve.mdc")].content; + + assert!(entity.frontmatter.contains_key("cursorPriority")); + assert!(!content.contains("cursorPriority")); + assert!(!content.contains("alwaysApply")); + assert!(content.contains("description: Preserve")); + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_cursor_rule_write_root() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside) + .unwrap_or_else(|error| panic!("outside dir should be created: {error}")); + std::fs::create_dir_all(root.join(".cursor")) + .unwrap_or_else(|error| panic!("cursor dir should be created: {error}")); + symlink(&outside, root.join(".cursor/rules")) + .unwrap_or_else(|error| panic!("symlink should be created: {error}")); + + let error = CursorAdapter + .emit(EmitRequest { + runtime_dir: root.join(".cursor"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "rule:escape".to_string(), + entity_type: agentmesh_protocol::EntityType::Rule, + scope: None, + source_path: None, + files: BTreeMap::from([(PathBuf::from("escape.md"), file("# Escape\n"))]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .expect_err("symlinked Cursor rules root should be rejected"); + + assert!(error.to_string().contains("symlinked Cursor path")); + assert!(!outside.join("escape.mdc").exists()); + } +} diff --git a/adapters/gemini/Cargo.toml b/adapters/gemini/Cargo.toml new file mode 100644 index 0000000..f0ca158 --- /dev/null +++ b/adapters/gemini/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "agentmesh-adapter-gemini" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true + +[dependencies] +agentmesh-adapter-sdk-rust.workspace = true +agentmesh-protocol.workspace = true +serde_json.workspace = true +serde_norway.workspace = true +toml.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/adapters/gemini/fixtures/commands/invalid/.gemini/commands/missing-prompt.toml b/adapters/gemini/fixtures/commands/invalid/.gemini/commands/missing-prompt.toml new file mode 100644 index 0000000..99c2014 --- /dev/null +++ b/adapters/gemini/fixtures/commands/invalid/.gemini/commands/missing-prompt.toml @@ -0,0 +1 @@ +description = "This command is missing its required prompt field." diff --git a/adapters/gemini/fixtures/commands/manifest.toml b/adapters/gemini/fixtures/commands/manifest.toml new file mode 100644 index 0000000..eab4804 --- /dev/null +++ b/adapters/gemini/fixtures/commands/manifest.toml @@ -0,0 +1,37 @@ +[[fixture]] +name = "project-command" +surface = "gemini.project-command" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "review/.gemini/commands/review.toml" +expected_canonical_id = "command:review" +expected_emitted_path = ".gemini/commands/review.toml" +expected_diagnostics = [] + +[[fixture]] +name = "namespaced-project-command" +surface = "gemini.project-command" +cases = ["minimum_valid", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "namespaced/.gemini/commands/git/commit.toml" +expected_canonical_id = "command:git:commit" +expected_emitted_path = ".gemini/commands/git/commit.toml" +expected_diagnostics = [] + +[[fixture]] +name = "invalid-command" +surface = "gemini.project-command" +cases = ["invalid_syntax", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/custom-commands.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "command" +fixture_file_path = "invalid/.gemini/commands/missing-prompt.toml" +expected_canonical_id = "command:missing-prompt" +expected_diagnostics = ["missing required prompt field"] diff --git a/adapters/gemini/fixtures/commands/namespaced/.gemini/commands/git/commit.toml b/adapters/gemini/fixtures/commands/namespaced/.gemini/commands/git/commit.toml new file mode 100644 index 0000000..cb76a51 --- /dev/null +++ b/adapters/gemini/fixtures/commands/namespaced/.gemini/commands/git/commit.toml @@ -0,0 +1,6 @@ +description = "Draft a commit message from the staged changes." +prompt = """ +Inspect the staged diff and draft a concise commit message. + +Do not modify files. Arguments: {{args}} +""" diff --git a/adapters/gemini/fixtures/commands/review/.gemini/commands/review.toml b/adapters/gemini/fixtures/commands/review/.gemini/commands/review.toml new file mode 100644 index 0000000..b3e8806 --- /dev/null +++ b/adapters/gemini/fixtures/commands/review/.gemini/commands/review.toml @@ -0,0 +1,8 @@ +description = "Review the current branch with repository checks." +prompt = """ +Review the current branch for correctness and test coverage. + +Arguments: {{args}} +Shell snippet stays literal: !{git status --short} +File reference stays literal: @{README.md} +""" diff --git a/adapters/gemini/fixtures/context/manifest.toml b/adapters/gemini/fixtures/context/manifest.toml new file mode 100644 index 0000000..366f30b --- /dev/null +++ b/adapters/gemini/fixtures/context/manifest.toml @@ -0,0 +1,27 @@ +[[fixture]] +name = "root-context" +surface = "gemini.root-context" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:root" +fixture_file_path = "root/GEMINI.md" +expected_canonical_id = "instructions:root" +expected_emitted_path = "GEMINI.md" +expected_diagnostics = [] +expected_behaviors = ["plain Markdown context files have no native metadata syntax to reject"] + +[[fixture]] +name = "nested-context" +surface = "gemini.nested-context" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "invalid_syntax_not_applicable", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "instructions:scoped" +fixture_file_path = "nested/packages/api/GEMINI.md" +expected_canonical_id = "instructions:scoped:packages-api" +expected_emitted_path = "packages/api/GEMINI.md" +expected_diagnostics = [] +expected_behaviors = ["nested plain Markdown context files derive scope from path and have no native metadata syntax to reject"] diff --git a/adapters/gemini/fixtures/context/nested/packages/api/GEMINI.md b/adapters/gemini/fixtures/context/nested/packages/api/GEMINI.md new file mode 100644 index 0000000..a0ae23d --- /dev/null +++ b/adapters/gemini/fixtures/context/nested/packages/api/GEMINI.md @@ -0,0 +1,4 @@ +# API Package Context + +For this subtree, preserve JSON wire compatibility and add tests for error +cases that cross process or filesystem boundaries. diff --git a/adapters/gemini/fixtures/context/root/GEMINI.md b/adapters/gemini/fixtures/context/root/GEMINI.md new file mode 100644 index 0000000..78e9cb6 --- /dev/null +++ b/adapters/gemini/fixtures/context/root/GEMINI.md @@ -0,0 +1,6 @@ +# Project Context + +Treat this repository as a Rust workspace. Keep CLI entrypoints thin and avoid +duplicating adapter-specific behavior in generic sync code. + +@./docs/shared-rust-style.md diff --git a/adapters/gemini/fixtures/diagnostics/deferred/extensions/gemini-extension.json b/adapters/gemini/fixtures/diagnostics/deferred/extensions/gemini-extension.json new file mode 100644 index 0000000..1135735 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/deferred/extensions/gemini-extension.json @@ -0,0 +1,11 @@ +{ + "name": "repo-extension", + "version": "1.0.0", + "contextFileName": "GEMINI.md", + "mcpServers": { + "repoTools": { + "command": "repo-tools", + "args": ["serve"] + } + } +} diff --git a/adapters/gemini/fixtures/diagnostics/deferred/hooks/.gemini/settings.json b/adapters/gemini/fixtures/diagnostics/deferred/hooks/.gemini/settings.json new file mode 100644 index 0000000..a013c5e --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/deferred/hooks/.gemini/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "write_file", + "hooks": [ + { + "name": "guard", + "type": "command", + "command": "node .gemini/hooks/guard.js" + } + ] + } + ] + } +} diff --git a/adapters/gemini/fixtures/diagnostics/deferred/subagents/.gemini/agents/investigator.md b/adapters/gemini/fixtures/diagnostics/deferred/subagents/.gemini/agents/investigator.md new file mode 100644 index 0000000..040eea4 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/deferred/subagents/.gemini/agents/investigator.md @@ -0,0 +1,8 @@ +--- +name: investigator +description: Investigates code paths in a separate context. +--- + +# Investigator + +Inspect repository structure and report evidence without editing files. diff --git a/adapters/gemini/fixtures/diagnostics/manifest.toml b/adapters/gemini/fixtures/diagnostics/manifest.toml new file mode 100644 index 0000000..91ab0c9 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/manifest.toml @@ -0,0 +1,71 @@ +[[fixture]] +name = "project-mcp-settings" +surface = "gemini.project-mcp-settings" +cases = ["read_only_diagnostic", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md" +source_checked_on = "2026-07-05" +support_level = "read_only" +canonical_entity_type = "mcp_binding" +fixture_file_path = "settings/mcp/.gemini/settings.json" +expected_canonical_id = "mcp-binding:gemini-project" +expected_diagnostics = ["project MCP settings are diagnostics-only"] + +[[fixture]] +name = "project-policy-settings" +surface = "gemini.policy-settings" +cases = ["read_only_diagnostic", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/reference/policy-engine.md" +source_checked_on = "2026-07-05" +support_level = "read_only" +canonical_entity_type = "permission_policy" +fixture_file_path = "settings/policy/.gemini/settings.json" +expected_canonical_id = "permission-policy:gemini-project" +expected_diagnostics = ["project policy settings are diagnostics-only"] + +[[fixture]] +name = "custom-context-filename" +surface = "gemini.custom-context-filenames" +cases = ["deferred_detection", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/gemini-md.md" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "instructions:root" +fixture_file_path = "settings/context/.gemini/settings.json" +expected_canonical_id = "instructions:root" +expected_diagnostics = ["custom context filenames are detected but only GEMINI.md is emitted"] + +[[fixture]] +name = "gemini-subagent-detection" +surface = "gemini.subagents" +cases = ["deferred_detection", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/core/subagents.md" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "subagent" +fixture_file_path = "deferred/subagents/.gemini/agents/investigator.md" +expected_canonical_id = "subagent:gemini-investigator" +expected_diagnostics = ["Gemini subagents are deferred and must never be emitted"] + +[[fixture]] +name = "gemini-hook-detection" +surface = "gemini.hooks" +cases = ["deferred_detection", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/writing-hooks.md" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "hook" +fixture_file_path = "deferred/hooks/.gemini/settings.json" +expected_canonical_id = "hook:gemini-project" +expected_diagnostics = ["Gemini hooks are deferred and must never be emitted"] + +[[fixture]] +name = "gemini-extension-detection" +surface = "gemini.extensions" +cases = ["deferred_detection", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/extensions/writing-extensions.md" +source_checked_on = "2026-07-05" +support_level = "deferred" +canonical_entity_type = "command" +fixture_file_path = "deferred/extensions/gemini-extension.json" +expected_canonical_id = "command:gemini-extension" +expected_diagnostics = ["Gemini extensions are deferred and must never be emitted"] diff --git a/adapters/gemini/fixtures/diagnostics/settings/context/.gemini/settings.json b/adapters/gemini/fixtures/diagnostics/settings/context/.gemini/settings.json new file mode 100644 index 0000000..15d7325 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/settings/context/.gemini/settings.json @@ -0,0 +1,5 @@ +{ + "context": { + "fileName": ["AGENTS.md", "CONTEXT.md", "GEMINI.md"] + } +} diff --git a/adapters/gemini/fixtures/diagnostics/settings/mcp/.gemini/settings.json b/adapters/gemini/fixtures/diagnostics/settings/mcp/.gemini/settings.json new file mode 100644 index 0000000..361a1b2 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/settings/mcp/.gemini/settings.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "repo-tools": { + "command": "repo-tools", + "args": ["serve"], + "trust": false + } + }, + "mcp": { + "allowed": ["repo-tools"], + "excluded": ["experimental"] + } +} diff --git a/adapters/gemini/fixtures/diagnostics/settings/policy/.gemini/settings.json b/adapters/gemini/fixtures/diagnostics/settings/policy/.gemini/settings.json new file mode 100644 index 0000000..f9811b2 --- /dev/null +++ b/adapters/gemini/fixtures/diagnostics/settings/policy/.gemini/settings.json @@ -0,0 +1,4 @@ +{ + "policyPaths": [".gemini/policies"], + "adminPolicyPaths": [] +} diff --git a/adapters/gemini/fixtures/skills/invalid-shared/.agents/skills/gemini-broken-shared/SKILL.md b/adapters/gemini/fixtures/skills/invalid-shared/.agents/skills/gemini-broken-shared/SKILL.md new file mode 100644 index 0000000..e652fc2 --- /dev/null +++ b/adapters/gemini/fixtures/skills/invalid-shared/.agents/skills/gemini-broken-shared/SKILL.md @@ -0,0 +1,8 @@ +--- +name: gemini-broken-shared +description: "unterminated +--- + +# Broken Shared Gemini Skill + +This shared skill is intentionally malformed. diff --git a/adapters/gemini/fixtures/skills/invalid/.gemini/skills/broken/SKILL.md b/adapters/gemini/fixtures/skills/invalid/.gemini/skills/broken/SKILL.md new file mode 100644 index 0000000..4046d00 --- /dev/null +++ b/adapters/gemini/fixtures/skills/invalid/.gemini/skills/broken/SKILL.md @@ -0,0 +1,8 @@ +--- +name: gemini-broken +description: "unterminated +--- + +# Broken Gemini Skill + +This skill is intentionally malformed. diff --git a/adapters/gemini/fixtures/skills/manifest.toml b/adapters/gemini/fixtures/skills/manifest.toml new file mode 100644 index 0000000..d9654bb --- /dev/null +++ b/adapters/gemini/fixtures/skills/manifest.toml @@ -0,0 +1,58 @@ +[[fixture]] +name = "workspace-skill" +surface = "gemini.skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/using-agent-skills.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "native/.gemini/skills/release-check/SKILL.md" +fixture_root_path = "native/.gemini/skills/release-check" +expected_canonical_id = "skill:release-check" +expected_emitted_path = ".gemini/skills/release-check/SKILL.md" +expected_files = ["SKILL.md", "references/checks.md"] +expected_diagnostics = [] + +[[fixture]] +name = "shared-agent-skill" +surface = "gemini.shared-skill" +cases = ["minimum_valid", "all_supported_metadata", "unknown_metadata", "round_trip", "sync_check", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/using-agent-skills.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "shared/.agents/skills/shared-analysis/SKILL.md" +fixture_root_path = "shared/.agents/skills/shared-analysis" +expected_canonical_id = "skill:shared-analysis" +expected_emitted_path = ".gemini/skills/shared-analysis/SKILL.md" +expected_files = ["SKILL.md", "references/notes.md"] +expected_diagnostics = [] +expected_behaviors = ["shared skill path imports only when Gemini has write-enabled runtime evidence"] + +[[fixture]] +name = "workspace-skill-invalid-frontmatter" +surface = "gemini.skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/using-agent-skills.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid/.gemini/skills/broken/SKILL.md" +fixture_root_path = "invalid/.gemini/skills/broken" +expected_canonical_id = "skill:broken" +expected_files = ["SKILL.md"] +expected_diagnostics = ["invalid Gemini skill frontmatter must not be overwritten"] + +[[fixture]] +name = "shared-agent-skill-invalid-frontmatter" +surface = "gemini.shared-skill" +cases = ["invalid_syntax", "doctor"] +source_url = "https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/using-agent-skills.md" +source_checked_on = "2026-07-05" +support_level = "write_enabled" +canonical_entity_type = "skill" +fixture_file_path = "invalid-shared/.agents/skills/gemini-broken-shared/SKILL.md" +fixture_root_path = "invalid-shared/.agents/skills/gemini-broken-shared" +expected_canonical_id = "skill:gemini-broken-shared" +expected_files = ["SKILL.md"] +expected_diagnostics = ["invalid shared Gemini skill frontmatter must not be overwritten"] diff --git a/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/SKILL.md b/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/SKILL.md new file mode 100644 index 0000000..dde7279 --- /dev/null +++ b/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/SKILL.md @@ -0,0 +1,9 @@ +--- +name: release-check +description: Verify release readiness for a Rust workspace. +--- + +# Release Check + +Check formatting, clippy warnings, tests, and release notes. Report blockers +before polish items. diff --git a/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/references/checks.md b/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/references/checks.md new file mode 100644 index 0000000..8328c70 --- /dev/null +++ b/adapters/gemini/fixtures/skills/native/.gemini/skills/release-check/references/checks.md @@ -0,0 +1,5 @@ +# Release Checks + +- cargo fmt --all +- cargo clippy --workspace --all-targets --all-features -- -D warnings +- cargo test --workspace --all-features diff --git a/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/SKILL.md b/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/SKILL.md new file mode 100644 index 0000000..6124979 --- /dev/null +++ b/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/SKILL.md @@ -0,0 +1,9 @@ +--- +name: shared-analysis +description: Analyze cross-runtime behavior without changing files. +--- + +# Shared Analysis + +Trace the producer, canonical state, and consumer separately before assigning +root cause. diff --git a/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/references/notes.md b/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/references/notes.md new file mode 100644 index 0000000..b96d037 --- /dev/null +++ b/adapters/gemini/fixtures/skills/shared/.agents/skills/shared-analysis/references/notes.md @@ -0,0 +1,4 @@ +# Shared Analysis Notes + +- Keep producer and consumer runtime behavior separate. +- Report verified facts before inferred risks. diff --git a/adapters/gemini/src/lib.rs b/adapters/gemini/src/lib.rs new file mode 100644 index 0000000..aec8d47 --- /dev/null +++ b/adapters/gemini/src/lib.rs @@ -0,0 +1,1859 @@ +//! Bundled Gemini CLI adapter entry points. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::{ + Adapter, AdapterError, AdapterMetadata, FormatTranslation, collect_entity_files, + compose_frontmatter, dir_entry_file_type, hash_files, is_regular_dir, is_regular_file, + is_safe_relative, max_mtime_string, mtime_string, parse_frontmatter, read_dir_sorted, + read_json_object, read_to_string, selected, sha256_bytes, skipped_entity, slug_for_entity, + slugify, workspace_relative, workspace_root_for, write_atomic, +}; +use agentmesh_protocol::{ + AdapterErrorCode, DetectResponse, EmitRequest, EmitResponse, EntityFile, EntityFileEncoding, + EntityType, ImportFilter, ImportRequest, ImportResponse, ImportedEntity, InstallHooksRequest, + InstallHooksResponse, RemoveHooksRequest, RemoveHooksResponse, RuntimeMode, SkippedPath, +}; +use serde_json::{Number as JsonNumber, Value as JsonValue}; +use serde_norway::{Mapping as YamlMapping, Value as YamlValue}; + +const SUPPORTED_ENTITIES: &[EntityType] = &[ + EntityType::Instructions, + EntityType::Command, + EntityType::Skill, +]; +const ALLOWED_READ_PATHS: &[&str] = &[ + "GEMINI.md", + "**/GEMINI.md", + ".gemini/skills/**", + ".agents/skills/**", + ".gemini/commands/**", + ".gemini/settings.json", + ".gemini/agents/**", + ".gemini/hooks/**", + ".gemini/extensions/**", + "gemini-extension.json", +]; +const ALLOWED_WRITE_PATHS: &[&str] = &[ + "GEMINI.md", + "**/GEMINI.md", + ".gemini/skills/**", + ".gemini/commands/**", +]; +const MARKDOWN_FORMATS: &[&str] = &["markdown"]; +const TOML_FORMATS: &[&str] = &["toml"]; +const FORMAT_TRANSLATIONS: &[FormatTranslation] = &[ + FormatTranslation { + entity_type: EntityType::Instructions, + formats: MARKDOWN_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Command, + formats: TOML_FORMATS, + }, + FormatTranslation { + entity_type: EntityType::Skill, + formats: MARKDOWN_FORMATS, + }, +]; + +/// Gemini CLI adapter handle. +#[derive(Debug, Clone, Copy, Default)] +pub struct GeminiAdapter; + +impl Adapter for GeminiAdapter { + fn metadata(&self) -> AdapterMetadata { + metadata() + } + + fn detect(&self, workspace_root: &Path) -> agentmesh_adapter_sdk_rust::Result { + let mut files = Vec::new(); + collect_context_evidence(workspace_root, workspace_root, &mut files)?; + for path in [ + workspace_root.join(".gemini/skills"), + workspace_root.join(".gemini/commands"), + ] { + let present = if path.is_file() { + is_regular_file(workspace_root, &path)? + } else { + is_regular_dir(workspace_root, &path)? + }; + if present { + files.push(workspace_relative(workspace_root, &path)?); + } + } + + Ok(DetectResponse { + present: !files.is_empty(), + version: None, + files, + }) + } + + fn import(&self, request: ImportRequest) -> agentmesh_adapter_sdk_rust::Result { + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let filter = request.filter.as_ref(); + let mut entities = Vec::new(); + let mut skipped = Vec::new(); + + import_context_files(&workspace_root, filter, &mut entities, &mut skipped)?; + import_skills( + &workspace_root, + &workspace_root.join(".gemini/skills"), + false, + filter, + &mut entities, + &mut skipped, + )?; + if has_write_enabled_gemini_evidence(&workspace_root)? { + import_skills( + &workspace_root, + &workspace_root.join(".agents/skills"), + true, + filter, + &mut entities, + &mut skipped, + )?; + } + import_commands( + &workspace_root, + &workspace_root.join(".gemini/commands"), + filter, + &mut entities, + &mut skipped, + )?; + import_settings_diagnostics(&workspace_root, filter, &mut skipped)?; + import_deferred_diagnostics(&workspace_root, filter, &mut skipped)?; + + Ok(ImportResponse { entities, skipped }) + } + + fn emit(&self, request: EmitRequest) -> agentmesh_adapter_sdk_rust::Result { + if request.mode == RuntimeMode::ReadOnly { + return Ok(EmitResponse { + files_written: Vec::new(), + skipped: request + .entities + .into_iter() + .map(|entity| skipped_entity(entity.id, "runtime is read-only")) + .collect(), + partial_fidelity: Vec::new(), + }); + } + + let workspace_root = workspace_root_for(&request.runtime_dir)?; + let mut files_written = Vec::new(); + let mut skipped = Vec::new(); + + for entity in request.entities { + match entity.entity_type { + EntityType::Instructions => { + let Some(content) = first_file_content(&entity.files) else { + skipped.push(skipped_entity( + entity.id, + "instructions entity has no files", + )); + continue; + }; + let target = match gemini_context_target(&workspace_root, &entity) { + Ok(target) => target, + Err(reason) => { + skipped.push(skipped_entity(entity.id, reason)); + continue; + } + }; + validate_gemini_write_path( + &workspace_root, + &target, + WriteSurface::Context, + "Gemini context", + )?; + write_atomic(&target, content.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + EntityType::Skill => { + let slug = slug_for_entity(&entity.id, &entity.frontmatter); + let skill_root = gemini_skill_target_root(&workspace_root, &entity, &slug); + validate_gemini_write_path( + &workspace_root, + &skill_root.join("SKILL.md"), + WriteSurface::Skill, + "Gemini skill", + )?; + let frontmatter = skill_frontmatter_for_emit(&entity); + for (file_path, file) in &entity.files { + if !is_safe_relative(file_path) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Gemini skill file path {}", file_path.display()), + )); + } + let target = skill_root.join(file_path); + validate_gemini_write_path( + &workspace_root, + &target, + WriteSurface::Skill, + "Gemini skill", + )?; + let bytes = if file_path == Path::new("SKILL.md") + && file.encoding == EntityFileEncoding::Utf8 + { + render_markdown_with_frontmatter( + &file.content, + &frontmatter, + &entity.overrides, + )? + .into_bytes() + } else { + entity_file_bytes(file_path, file)? + }; + write_atomic(&target, &bytes)?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + } + EntityType::Command => { + let Some((file_path, content)) = first_text_file(&entity.files) else { + skipped.push(skipped_entity(entity.id, "command entity has no files")); + continue; + }; + let slug = entity.id.strip_prefix("command:").unwrap_or(&entity.id); + let target = native_source_path(&entity, ".gemini/commands", "toml") + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| { + workspace_root + .join(".gemini/commands") + .join(command_file_name(slug, "toml")) + }); + validate_gemini_write_path( + &workspace_root, + &target, + WriteSurface::Command, + "Gemini command", + )?; + let frontmatter = command_frontmatter_for_emit(&entity); + let rendered = + if file_path.extension().and_then(|value| value.to_str()) == Some("toml") { + render_existing_toml_command( + &content, + &frontmatter, + &entity.overrides, + &file_path, + )? + } else { + render_toml_command(&content, &frontmatter, &entity.overrides)? + }; + write_atomic(&target, rendered.as_bytes())?; + files_written.push(workspace_relative(&workspace_root, &target)?); + } + unsupported => skipped.push(skipped_entity( + entity.id, + format!("{} entity is not supported", unsupported.as_str()), + )), + } + } + + Ok(EmitResponse { + files_written, + skipped, + partial_fidelity: Vec::new(), + }) + } + + fn install_hooks( + &self, + _request: InstallHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(InstallHooksResponse { + hooks_installed: Vec::new(), + fallback_needed: false, + fallback_reason: None, + }) + } + + fn remove_hooks( + &self, + _request: RemoveHooksRequest, + ) -> agentmesh_adapter_sdk_rust::Result { + Ok(RemoveHooksResponse { + ok: true, + removed_count: 0, + error: None, + }) + } +} + +/// Returns static metadata for the Gemini CLI adapter. +#[must_use] +pub const fn metadata() -> AdapterMetadata { + AdapterMetadata { + name: "gemini", + runtime_dir: ".gemini", + supported_entities: SUPPORTED_ENTITIES, + allowed_read_paths: ALLOWED_READ_PATHS, + allowed_write_paths: ALLOWED_WRITE_PATHS, + format_translations: FORMAT_TRANSLATIONS, + } +} + +fn collect_context_evidence( + workspace_root: &Path, + dir: &Path, + files: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + if should_skip_context_dir(workspace_root, &path) { + continue; + } + collect_context_evidence(workspace_root, &path, files)?; + continue; + } + if file_type.is_file() && is_gemini_context_file(&path) { + files.push(workspace_relative(workspace_root, &path)?); + } + } + Ok(()) +} + +fn import_context_files( + workspace_root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_context_files_in_dir(workspace_root, workspace_root, filter, entities, skipped) +} + +fn import_context_files_in_dir( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Gemini context path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + if should_skip_context_dir(workspace_root, &path) { + continue; + } + import_context_files_in_dir(workspace_root, &path, filter, entities, skipped)?; + continue; + } + if !file_type.is_file() || !is_gemini_context_file(&path) { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let content = match read_to_string(&path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + if source_relative == Path::new("GEMINI.md") { + entities.push(ImportedEntity { + id: "instructions:root".to_string(), + entity_type: EntityType::Instructions, + scope: Some("root".to_string()), + canonical_path: PathBuf::from("AGENTS.md"), + files: BTreeMap::from([( + PathBuf::from("AGENTS.md"), + EntityFile::utf8(content.clone()), + )]), + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path: source_relative, + source_mtime: mtime_string(&path)?, + }); + continue; + } + let Some(scope_dir) = source_relative.parent() else { + continue; + }; + let slug = path_slug(scope_dir); + let scope = format!("{}/**", scope_dir.to_string_lossy().replace('\\', "/")); + entities.push(ImportedEntity { + id: format!("instructions:scoped:{slug}"), + entity_type: EntityType::Instructions, + scope: Some(scope), + canonical_path: PathBuf::from("instructions").join(format!("{slug}.md")), + files: BTreeMap::from([( + PathBuf::from(format!("{slug}.md")), + EntityFile::utf8(content.clone()), + )]), + frontmatter: BTreeMap::new(), + canonical_sha256: sha256_bytes(content.as_bytes()), + source_path: source_relative, + source_mtime: mtime_string(&path)?, + }); + } + Ok(()) +} + +fn has_write_enabled_gemini_evidence( + workspace_root: &Path, +) -> agentmesh_adapter_sdk_rust::Result { + let mut files = Vec::new(); + collect_context_evidence(workspace_root, workspace_root, &mut files)?; + if !files.is_empty() { + return Ok(true); + } + for path in [ + workspace_root.join(".gemini/skills"), + workspace_root.join(".gemini/commands"), + ] { + let present = if path.is_file() { + is_regular_file(workspace_root, &path)? + } else { + is_regular_dir(workspace_root, &path)? + }; + if present { + return Ok(true); + } + } + Ok(false) +} + +fn should_skip_context_dir(workspace_root: &Path, path: &Path) -> bool { + let relative = path.strip_prefix(workspace_root).unwrap_or(path); + !gemini_context_parent_allowed(relative) +} + +fn import_skills( + workspace_root: &Path, + root: &Path, + shared: bool, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + + for entry in read_dir_sorted(root)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Gemini skill path is not supported".to_string(), + }); + continue; + } + if !file_type.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: "skill directory name is not UTF-8".to_string(), + }); + continue; + }; + if name.starts_with('.') { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: "hidden skill directory is treated as a draft".to_string(), + }); + continue; + } + + let source_path = path.join("SKILL.md"); + let source_relative = workspace_relative(workspace_root, &source_path)?; + let skill_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, &[source_relative.clone(), skill_relative]) { + continue; + } + let source_is_file = match is_regular_file(workspace_root, &source_path) { + Ok(source_is_file) => source_is_file, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + if !source_is_file { + continue; + } + + let slug = slugify(name); + let mut files = BTreeMap::new(); + if let Err(error) = collect_entity_files(&path, &path, &mut files) { + skipped.push(SkippedPath { + path: workspace_relative(workspace_root, &path)?, + reason: error.to_string(), + }); + continue; + } + let content = match read_to_string(&source_path) { + Ok(content) => content, + Err(error) => { + skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }); + continue; + } + }; + let frontmatter = match frontmatter_json_for_path(&source_relative, &content) { + Ok(frontmatter) => frontmatter, + Err(error) => { + let prefix = if shared { + "invalid shared Gemini skill frontmatter must not be overwritten" + } else { + "invalid Gemini skill frontmatter must not be overwritten" + }; + skipped.push(SkippedPath { + path: source_relative, + reason: format!("{prefix}: {error}"), + }); + continue; + } + }; + if !shared { + let canonical = render_canonical_markdown( + &content, + &portable_frontmatter(&frontmatter, &["name", "description"]), + )?; + files.insert(PathBuf::from("SKILL.md"), EntityFile::utf8(canonical)); + } + + entities.push(ImportedEntity { + id: format!("skill:{slug}"), + entity_type: EntityType::Skill, + scope: None, + canonical_path: PathBuf::from("skills").join(&slug).join("SKILL.md"), + canonical_sha256: hash_files(&files), + files, + frontmatter, + source_path: source_relative, + source_mtime: max_mtime_string(&path)?, + }); + } + Ok(()) +} + +fn import_commands( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_commands_inner(workspace_root, root, root, filter, entities, skipped) +} + +fn import_commands_inner( + workspace_root: &Path, + root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + entities: &mut Vec, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked Gemini command path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_commands_inner(workspace_root, root, &path, filter, entities, skipped)?; + continue; + } + if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("toml") + { + continue; + } + let source_relative = workspace_relative(workspace_root, &path)?; + if !selected(filter, std::slice::from_ref(&source_relative)) { + continue; + } + let slug = command_slug(root, &path); + match import_toml_command(&path, source_relative.clone(), &slug) { + Ok(entity) => entities.push(entity), + Err(error) => skipped.push(SkippedPath { + path: source_relative, + reason: error.to_string(), + }), + } + } + Ok(()) +} + +fn import_toml_command( + path: &Path, + source_path: PathBuf, + slug: &str, +) -> agentmesh_adapter_sdk_rust::Result { + let content = read_to_string(path)?; + let table = toml_table_from_str(&content, path)?; + let Some(prompt) = table.get("prompt").and_then(toml::Value::as_str) else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "missing required prompt field", + )); + }; + if prompt.trim().is_empty() { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "missing required prompt field", + )); + } + let frontmatter = table + .iter() + .filter(|(key, _)| key.as_str() != "prompt") + .map(|(key, value)| (key.clone(), toml_to_json(value))) + .collect::>(); + let canonical = render_markdown_with_frontmatter( + prompt, + &portable_frontmatter(&frontmatter, &["description"]), + &BTreeMap::new(), + )?; + let file_key = command_file_name(slug, "md"); + + Ok(ImportedEntity { + id: format!("command:{slug}"), + entity_type: EntityType::Command, + scope: None, + canonical_path: PathBuf::from("commands").join(&file_key), + files: BTreeMap::from([(file_key, EntityFile::utf8(canonical.clone()))]), + frontmatter, + canonical_sha256: sha256_bytes(canonical.as_bytes()), + source_path, + source_mtime: mtime_string(path)?, + }) +} + +fn import_settings_diagnostics( + workspace_root: &Path, + filter: Option<&ImportFilter>, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let path = workspace_root.join(".gemini/settings.json"); + let relative = PathBuf::from(".gemini/settings.json"); + if !selected(filter, std::slice::from_ref(&relative)) + || !is_regular_file(workspace_root, &path)? + { + return Ok(()); + } + let value = match read_json_object(&path) { + Ok(value) => value, + Err(error) => { + skipped.push(SkippedPath { + path: relative, + reason: error.to_string(), + }); + return Ok(()); + } + }; + if value.get("mcpServers").is_some() || value.get("mcp").is_some() { + skipped.push(SkippedPath { + path: relative.clone(), + reason: "project MCP settings are diagnostics-only and must never be emitted" + .to_string(), + }); + } + if value.get("policyPaths").is_some() || value.get("adminPolicyPaths").is_some() { + skipped.push(SkippedPath { + path: relative.clone(), + reason: "project policy settings are diagnostics-only and must never be emitted" + .to_string(), + }); + } + if value + .get("context") + .and_then(JsonValue::as_object) + .is_some_and(|context| context.contains_key("fileName")) + { + skipped.push(SkippedPath { + path: relative.clone(), + reason: "custom context filenames are detected but only GEMINI.md is emitted" + .to_string(), + }); + } + if value.get("hooks").is_some() { + skipped.push(SkippedPath { + path: relative, + reason: "Gemini hooks are deferred and must never be emitted".to_string(), + }); + } + Ok(()) +} + +fn import_deferred_diagnostics( + workspace_root: &Path, + filter: Option<&ImportFilter>, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".gemini/agents"), + filter, + "Gemini subagents are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".gemini/hooks"), + filter, + "Gemini hooks are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file_tree( + workspace_root, + &workspace_root.join(".gemini/extensions"), + filter, + "Gemini extensions are deferred and must never be emitted", + skipped, + )?; + import_diagnostic_file( + workspace_root, + &workspace_root.join("gemini-extension.json"), + filter, + "Gemini extensions are deferred and must never be emitted", + skipped, + ) +} + +fn import_diagnostic_file( + workspace_root: &Path, + path: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = relative_or_path(workspace_root, path); + if !selected(filter, std::slice::from_ref(&relative)) || !is_regular_file(workspace_root, path)? + { + return Ok(()); + } + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + Ok(()) +} + +fn import_diagnostic_file_tree( + workspace_root: &Path, + root: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + match is_regular_dir(workspace_root, root) { + Ok(true) => {} + Ok(false) => return Ok(()), + Err(error) => { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, root), + reason: error.to_string(), + }); + return Ok(()); + } + } + import_diagnostic_file_tree_inner(workspace_root, root, filter, reason, skipped) +} + +fn import_diagnostic_file_tree_inner( + workspace_root: &Path, + dir: &Path, + filter: Option<&ImportFilter>, + reason: &str, + skipped: &mut Vec, +) -> agentmesh_adapter_sdk_rust::Result<()> { + for entry in read_dir_sorted(dir)? { + let path = entry.path(); + let file_type = dir_entry_file_type(&entry)?; + if file_type.is_symlink() { + skipped.push(SkippedPath { + path: relative_or_path(workspace_root, &path), + reason: "symlinked diagnostic path is not supported".to_string(), + }); + continue; + } + if file_type.is_dir() { + import_diagnostic_file_tree_inner(workspace_root, &path, filter, reason, skipped)?; + continue; + } + if file_type.is_file() { + let relative = workspace_relative(workspace_root, &path)?; + if selected(filter, std::slice::from_ref(&relative)) { + skipped.push(SkippedPath { + path: relative, + reason: reason.to_string(), + }); + } + } + } + Ok(()) +} + +fn gemini_context_target( + workspace_root: &Path, + entity: &agentmesh_protocol::EmitEntity, +) -> Result { + if is_root_instruction(&entity.id, entity.scope.as_deref()) { + return Ok(workspace_root.join("GEMINI.md")); + } + if let Some(path) = entity + .source_path + .as_deref() + .filter(|path| is_native_gemini_context_path(path)) + { + return Ok(workspace_root.join(path)); + } + let Some(scope) = entity.scope.as_deref().filter(|scope| *scope != "root") else { + return Err("scoped Gemini context requires a scope".to_string()); + }; + let Some(scope_dir) = scope_dir_from_scope(scope) else { + return Err("scoped Gemini context scope must end in /**".to_string()); + }; + Ok(workspace_root.join(scope_dir).join("GEMINI.md")) +} + +fn scope_dir_from_scope(scope: &str) -> Option { + let value = scope.strip_suffix("/**")?.trim_matches('/'); + if value.is_empty() { + return None; + } + let path = PathBuf::from(value); + if !is_safe_relative(&path) || !gemini_context_path_allowed(&path.join("GEMINI.md")) { + return None; + } + Some(path) +} + +fn is_native_gemini_context_path(path: &Path) -> bool { + is_safe_relative(path) + && path.file_name().and_then(|value| value.to_str()) == Some("GEMINI.md") + && gemini_context_path_allowed(path) +} + +fn gemini_context_path_allowed(path: &Path) -> bool { + if path == Path::new("GEMINI.md") { + return true; + } + if !is_safe_relative(path) + || path.file_name().and_then(|name| name.to_str()) != Some("GEMINI.md") + { + return false; + } + path.parent().is_some_and(gemini_context_parent_allowed) +} + +fn gemini_context_parent_allowed(path: &Path) -> bool { + path.components().all(|component| { + let std::path::Component::Normal(part) = component else { + return false; + }; + part.to_str() + .is_some_and(|part| !part.starts_with('.') && part != "target") + }) +} + +fn gemini_skill_target_root( + workspace_root: &Path, + entity: &agentmesh_protocol::EmitEntity, + slug: &str, +) -> PathBuf { + entity + .source_path + .as_ref() + .and_then(|path| direct_skill_source_root(path, ".gemini/skills")) + .map(|path| workspace_root.join(path)) + .unwrap_or_else(|| workspace_root.join(".gemini/skills").join(slug)) +} + +fn direct_skill_source_root(path: &Path, root: &str) -> Option { + if !is_safe_relative(path) + || path.file_name().and_then(|value| value.to_str()) != Some("SKILL.md") + || !path.starts_with(root) + { + return None; + } + let parent = path.parent()?; + let relative = parent.strip_prefix(root).ok()?; + if relative.components().count() == 1 { + Some(parent.to_path_buf()) + } else { + None + } +} + +fn skill_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, +) -> BTreeMap { + if entity + .source_path + .as_ref() + .is_some_and(|path| direct_skill_source_root(path, ".gemini/skills").is_some()) + { + entity.frontmatter.clone() + } else { + portable_frontmatter(&entity.frontmatter, &["name", "description"]) + } +} + +fn command_frontmatter_for_emit( + entity: &agentmesh_protocol::EmitEntity, +) -> BTreeMap { + if entity + .source_path + .as_ref() + .is_some_and(|_| native_source_path(entity, ".gemini/commands", "toml").is_some()) + { + entity.frontmatter.clone() + } else { + portable_frontmatter(&entity.frontmatter, &["description"]) + } +} + +fn native_source_path( + entity: &agentmesh_protocol::EmitEntity, + native_root: &str, + extension: &str, +) -> Option { + let path = entity.source_path.as_ref()?; + if is_safe_relative(path) + && path.starts_with(native_root) + && path.extension().and_then(|value| value.to_str()) == Some(extension) + { + Some(path.clone()) + } else { + None + } +} + +#[derive(Debug, Clone, Copy)] +enum WriteSurface { + Context, + Skill, + Command, +} + +fn validate_gemini_write_path( + workspace_root: &Path, + target: &Path, + surface: WriteSurface, + label: &str, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, target)?; + if !is_safe_relative(&relative) { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe {label} path {}", target.display()), + )); + } + let allowed = match surface { + WriteSurface::Context => is_native_gemini_context_path(&relative), + WriteSurface::Skill => relative.starts_with(".gemini/skills"), + WriteSurface::Command => { + relative.starts_with(".gemini/commands") + && relative.extension().and_then(|value| value.to_str()) == Some("toml") + } + }; + if !allowed { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{} is outside declared {label} roots", target.display()), + )); + } + let parent = target.parent().ok_or_else(|| { + AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("{label} target has no parent: {}", target.display()), + ) + })?; + validate_existing_components(workspace_root, parent, true)?; + validate_existing_components(workspace_root, target, false) +} + +fn validate_existing_components( + workspace_root: &Path, + path: &Path, + require_directory: bool, +) -> agentmesh_adapter_sdk_rust::Result<()> { + let relative = workspace_relative(workspace_root, path)?; + let mut current = workspace_root.to_path_buf(); + for component in relative.components() { + let std::path::Component::Normal(part) = component else { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!("unsafe Gemini path component in {}", path.display()), + )); + }; + current.push(part); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(AdapterError::Io { + action: "read metadata", + path: current, + source, + }); + } + }; + if metadata.file_type().is_symlink() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "symlinked Gemini path {} is not supported", + current.display() + ), + )); + } + if require_directory && !metadata.is_dir() { + return Err(AdapterError::rpc( + AdapterErrorCode::WorkspaceOutsideBound, + format!( + "Gemini path component is not a directory: {}", + current.display() + ), + )); + } + } + Ok(()) +} + +fn render_canonical_markdown( + content: &str, + frontmatter: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + if frontmatter.is_empty() { + return Ok(document.body); + } + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn render_markdown_with_frontmatter( + content: &str, + frontmatter: &BTreeMap, + overrides: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + if frontmatter.is_empty() && overrides.is_empty() && !content.starts_with("---\n") { + return Ok(content.to_string()); + } + let mut document = parse_frontmatter(content)?; + document.frontmatter.clear(); + for (key, value) in frontmatter { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + for (key, value) in overrides { + document + .frontmatter + .insert(YamlValue::String(key.clone()), json_to_yaml(value)?); + } + compose_frontmatter(&document) +} + +fn render_toml_command( + content: &str, + frontmatter: &BTreeMap, + overrides: &BTreeMap, +) -> agentmesh_adapter_sdk_rust::Result { + let document = parse_frontmatter(content)?; + let body = document.body; + if body.trim().is_empty() { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "Gemini command prompt cannot be empty", + )); + } + let mut merged = BTreeMap::new(); + for (key, value) in frontmatter { + merged.insert(key.clone(), value.clone()); + } + for (key, value) in overrides { + merged.insert(key.clone(), value.clone()); + } + let mut table = toml::map::Map::new(); + for (key, value) in merged { + if key == "prompt" { + continue; + } + if let Some(value) = json_to_toml(&value) { + table.insert(key, value); + } + } + table.insert("prompt".to_string(), toml::Value::String(body)); + Ok(serialize_toml_table(&table)) +} + +fn render_existing_toml_command( + content: &str, + frontmatter: &BTreeMap, + overrides: &BTreeMap, + path: &Path, +) -> agentmesh_adapter_sdk_rust::Result { + let mut table = toml_table_from_str(content, path)?; + insert_missing_toml_metadata(&mut table, frontmatter); + insert_missing_toml_metadata(&mut table, overrides); + let Some(prompt) = table.get("prompt").and_then(toml::Value::as_str) else { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "missing required prompt field", + )); + }; + if prompt.trim().is_empty() { + return Err(AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + "missing required prompt field", + )); + } + Ok(serialize_toml_table(&table)) +} + +fn insert_missing_toml_metadata( + table: &mut toml::map::Map, + metadata: &BTreeMap, +) { + for (key, value) in metadata { + if key == "prompt" || table.contains_key(key) { + continue; + } + if let Some(value) = json_to_toml(value) { + table.insert(key.clone(), value); + } + } +} + +fn frontmatter_json_for_path( + source_path: &Path, + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + frontmatter_json(content).map_err(|error| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!( + "failed to parse frontmatter in {}: {error}", + source_path.display() + ), + ) + }) +} + +fn frontmatter_json( + content: &str, +) -> agentmesh_adapter_sdk_rust::Result> { + let document = parse_frontmatter(content)?; + yaml_mapping_to_json(&document.frontmatter) +} + +fn yaml_mapping_to_json( + mapping: &YamlMapping, +) -> agentmesh_adapter_sdk_rust::Result> { + let json_value = + serde_json::to_value(YamlValue::Mapping(mapping.clone())).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert YAML frontmatter to JSON: {source}"), + ) + })?; + let Some(object) = json_value.as_object() else { + return Ok(BTreeMap::new()); + }; + Ok(object + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect()) +} + +fn json_to_yaml(value: &JsonValue) -> agentmesh_adapter_sdk_rust::Result { + serde_norway::to_value(value).map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to convert JSON value to YAML: {source}"), + ) + }) +} + +fn toml_table_from_str( + content: &str, + path: &Path, +) -> agentmesh_adapter_sdk_rust::Result> { + let value = content.parse::().map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("failed to parse TOML at {}: {source}", path.display()), + ) + })?; + value.as_table().cloned().ok_or_else(|| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!("TOML root at {} must be a table", path.display()), + ) + }) +} + +fn serialize_toml_table(table: &toml::map::Map) -> String { + let mut output = String::new(); + write_toml_table(&mut output, None, table); + output +} + +fn write_toml_table( + output: &mut String, + prefix: Option<&str>, + table: &toml::map::Map, +) { + for (key, value) in table { + if !matches!(value, toml::Value::Table(_)) { + output.push_str("e_toml_key(key)); + output.push_str(" = "); + output.push_str(&inline_toml_value(value)); + output.push('\n'); + } + } + for (key, value) in table { + let toml::Value::Table(child) = value else { + continue; + }; + if !output.is_empty() { + output.push('\n'); + } + let section = match prefix { + Some(prefix) => format!("{prefix}.{}", quote_toml_key(key)), + None => quote_toml_key(key), + }; + output.push('['); + output.push_str(§ion); + output.push_str("]\n"); + write_toml_table(output, Some(§ion), child); + } +} + +fn inline_toml_value(value: &toml::Value) -> String { + match value { + toml::Value::String(value) => quote_toml_string(value), + toml::Value::Integer(value) => value.to_string(), + toml::Value::Float(value) => value.to_string(), + toml::Value::Boolean(value) => value.to_string(), + toml::Value::Datetime(value) => quote_toml_string(&value.to_string()), + toml::Value::Array(values) => { + let values = values.iter().map(inline_toml_value).collect::>(); + format!("[{}]", values.join(", ")) + } + toml::Value::Table(table) => inline_toml_table(table), + } +} + +fn inline_toml_table(table: &toml::map::Map) -> String { + let values = table + .iter() + .map(|(key, value)| format!("{} = {}", quote_toml_key(key), inline_toml_value(value))) + .collect::>(); + format!("{{ {} }}", values.join(", ")) +} + +fn quote_toml_key(key: &str) -> String { + if key + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-') + { + key.to_string() + } else { + quote_toml_string(key) + } +} + +fn quote_toml_string(value: &str) -> String { + let mut output = String::from("\""); + for character in value.chars() { + match character { + '\\' => output.push_str("\\\\"), + '"' => output.push_str("\\\""), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character => output.push(character), + } + } + output.push('"'); + output +} + +fn toml_to_json(value: &toml::Value) -> JsonValue { + match value { + toml::Value::String(value) => JsonValue::String(value.clone()), + toml::Value::Integer(value) => JsonValue::Number(JsonNumber::from(*value)), + toml::Value::Float(value) => JsonNumber::from_f64(*value) + .map(JsonValue::Number) + .unwrap_or(JsonValue::Null), + toml::Value::Boolean(value) => JsonValue::Bool(*value), + toml::Value::Datetime(value) => JsonValue::String(value.to_string()), + toml::Value::Array(values) => JsonValue::Array(values.iter().map(toml_to_json).collect()), + toml::Value::Table(table) => JsonValue::Object( + table + .iter() + .map(|(key, value)| (key.clone(), toml_to_json(value))) + .collect(), + ), + } +} + +fn json_to_toml(value: &JsonValue) -> Option { + match value { + JsonValue::Null => None, + JsonValue::Bool(value) => Some(toml::Value::Boolean(*value)), + JsonValue::Number(value) => value + .as_i64() + .map(toml::Value::Integer) + .or_else(|| value.as_f64().map(toml::Value::Float)), + JsonValue::String(value) => Some(toml::Value::String(value.clone())), + JsonValue::Array(values) => Some(toml::Value::Array( + values.iter().filter_map(json_to_toml).collect(), + )), + JsonValue::Object(values) => { + let mut table = toml::map::Map::new(); + for (key, value) in values { + if let Some(value) = json_to_toml(value) { + table.insert(key.clone(), value); + } + } + Some(toml::Value::Table(table)) + } + } +} + +fn portable_frontmatter( + frontmatter: &BTreeMap, + keys: &[&str], +) -> BTreeMap { + frontmatter + .iter() + .filter(|(key, _)| keys.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +fn entity_file_bytes( + path: &Path, + file: &EntityFile, +) -> agentmesh_adapter_sdk_rust::Result> { + file.decode_bytes().map_err(|source| { + AdapterError::rpc( + AdapterErrorCode::FormatTranslationFailed, + format!( + "failed to decode Gemini entity file {}: {source}", + path.display() + ), + ) + }) +} + +fn first_file_content(files: &BTreeMap) -> Option { + files.values().find_map(file_text) +} + +fn first_text_file(files: &BTreeMap) -> Option<(PathBuf, String)> { + files + .iter() + .find_map(|(path, file)| file_text(file).map(|content| (path.clone(), content))) +} + +fn file_text(file: &EntityFile) -> Option { + match file.encoding { + EntityFileEncoding::Utf8 => Some(file.content.clone()), + EntityFileEncoding::Base64 => None, + } +} + +fn is_root_instruction(id: &str, scope: Option<&str>) -> bool { + id == "instructions:root" || scope == Some("root") +} + +fn is_gemini_context_file(path: &Path) -> bool { + path.file_name().and_then(|name| name.to_str()) == Some("GEMINI.md") +} + +fn path_slug(path: &Path) -> String { + path.components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => part.to_str().map(slugify), + _ => None, + }) + .collect::>() + .join("-") +} + +fn command_slug(root: &Path, path: &Path) -> String { + let relative = path.strip_prefix(root).unwrap_or(path); + let mut parts = relative + .components() + .filter_map(|component| match component { + std::path::Component::Normal(part) => part.to_str().map(ToString::to_string), + _ => None, + }) + .collect::>(); + if let Some(last) = parts.last_mut() { + let stem = last.strip_suffix(".toml").unwrap_or(last).to_string(); + *last = stem; + } + parts + .into_iter() + .map(|part| slugify(&part)) + .collect::>() + .join(":") +} + +fn command_file_name(slug: &str, extension: &str) -> PathBuf { + let mut parts = slug.split(':').peekable(); + let mut path = PathBuf::new(); + while let Some(part) = parts.next() { + if parts.peek().is_some() { + path.push(part); + } else { + path.push(format!("{part}.{extension}")); + } + } + path +} + +fn relative_or_path(workspace_root: &Path, path: &Path) -> PathBuf { + workspace_relative(workspace_root, path).unwrap_or_else(|_| path.to_path_buf()) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::path::{Path, PathBuf}; + + use agentmesh_adapter_sdk_rust::Adapter; + use agentmesh_protocol::{EmitEntity, EmitRequest, EntityFile, ImportRequest, RuntimeMode}; + use serde_json::json; + + use super::GeminiAdapter; + + fn write(path: impl AsRef, contents: impl AsRef<[u8]>) { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|error| panic!("parent dirs should be created: {error}")); + } + std::fs::write(path, contents) + .unwrap_or_else(|error| panic!("fixture should be written: {error}")); + } + + fn read(path: impl AsRef) -> String { + std::fs::read_to_string(path) + .unwrap_or_else(|error| panic!("fixture should be readable: {error}")) + } + + fn file(contents: &str) -> EntityFile { + EntityFile::utf8(contents.to_string()) + } + + #[test] + fn detect_ignores_shared_and_diagnostic_only_surfaces() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".agents/skills/shared-analysis/SKILL.md"), + "---\nname: shared-analysis\n---\n# Shared\n", + ); + write( + root.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo-tools"}}}"#, + ); + + let detected = GeminiAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + + assert!(!detected.present); + assert!(detected.files.is_empty()); + } + + #[test] + fn ignores_hidden_nested_context_files() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write(root.join(".secrets/GEMINI.md"), "# Root secret\n"); + write(root.join("packages/.git/GEMINI.md"), "# Git\n"); + write(root.join("packages/.secrets/GEMINI.md"), "# Secret\n"); + write( + root.join("packages/.gemini/GEMINI.md"), + "# Nested runtime\n", + ); + write(root.join("packages/api/GEMINI.md"), "# API\n"); + + let detected = GeminiAdapter + .detect(root) + .unwrap_or_else(|error| panic!("detect should succeed: {error}")); + let imported = GeminiAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".gemini"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!( + detected + .files + .contains(&PathBuf::from("packages/api/GEMINI.md")) + ); + assert_eq!(ids, vec!["instructions:scoped:packages-api"]); + } + + #[test] + fn imports_gemini_write_enabled_surfaces_and_diagnostics() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write(root.join("GEMINI.md"), "# Root\n@./docs/style.md\n"); + write(root.join("packages/api/GEMINI.md"), "# API\n"); + write( + root.join(".gemini/skills/release-check/SKILL.md"), + "---\nname: release-check\ndescription: Release\nowner: platform\n---\n# Release\n", + ); + write( + root.join(".gemini/skills/release-check/references/checks.md"), + "# Checks\n", + ); + write( + root.join(".agents/skills/shared-analysis/SKILL.md"), + "---\nname: shared-analysis\ndescription: Shared\n---\n# Shared\n", + ); + write( + root.join(".gemini/commands/git/commit.toml"), + "description = \"Commit\"\nmode = \"builtin\"\nprompt = \"Draft commit for {{args}}.\"\n", + ); + write( + root.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo-tools"}},"policyPaths":[".gemini/policies"],"context":{"fileName":["CONTEXT.md","GEMINI.md"]}}"#, + ); + write(root.join(".gemini/agents/investigator.md"), "# Agent\n"); + write(root.join("gemini-extension.json"), "{}\n"); + + let imported = GeminiAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".gemini"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + let ids = imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(); + + assert!(ids.contains(&"instructions:root")); + assert!(ids.contains(&"instructions:scoped:packages-api")); + assert!(ids.contains(&"skill:release-check")); + assert!(ids.contains(&"skill:shared-analysis")); + assert!(ids.contains(&"command:git:commit")); + let root_context = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:root") + .unwrap_or_else(|| panic!("root context should import")); + assert!( + root_context.files[Path::new("AGENTS.md")] + .content + .contains("@./docs/style.md") + ); + let scoped = imported + .entities + .iter() + .find(|entity| entity.id == "instructions:scoped:packages-api") + .unwrap_or_else(|| panic!("scoped context should import")); + assert_eq!(scoped.scope.as_deref(), Some("packages/api/**")); + let command = imported + .entities + .iter() + .find(|entity| entity.id == "command:git:commit") + .unwrap_or_else(|| panic!("command should import")); + assert_eq!(command.frontmatter.get("mode"), Some(&json!("builtin"))); + assert!( + command.files[Path::new("git/commit.md")] + .content + .contains("Draft commit for {{args}}.") + ); + for expected in [ + "project MCP settings are diagnostics-only", + "project policy settings are diagnostics-only", + "custom context filenames are detected", + "Gemini subagents are deferred", + "Gemini extensions are deferred", + ] { + assert!( + imported + .skipped + .iter() + .any(|skipped| skipped.reason.contains(expected)), + "missing diagnostic {expected}; skipped: {:?}", + imported.skipped + ); + } + } + + #[test] + fn emits_gemini_context_skills_and_commands() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + let response = GeminiAdapter + .emit(EmitRequest { + runtime_dir: root.join(".gemini"), + mode: RuntimeMode::Managed, + entities: vec![ + EmitEntity { + id: "instructions:root".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("root".to_string()), + source_path: None, + files: BTreeMap::from([(PathBuf::from("AGENTS.md"), file("# Root\n"))]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "instructions:scoped:packages-api".to_string(), + entity_type: agentmesh_protocol::EntityType::Instructions, + scope: Some("packages/api/**".to_string()), + source_path: None, + files: BTreeMap::from([( + PathBuf::from("packages-api.md"), + file("# API\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "skill:shared-analysis".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: Some(PathBuf::from(".agents/skills/shared-analysis/SKILL.md")), + files: BTreeMap::from([(PathBuf::from("SKILL.md"), file("# Shared\n"))]), + frontmatter: BTreeMap::from([ + ("name".to_string(), json!("shared-analysis")), + ("owner".to_string(), json!("platform")), + ]), + overrides: BTreeMap::new(), + }, + EmitEntity { + id: "command:git:commit".to_string(), + entity_type: agentmesh_protocol::EntityType::Command, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("commit.md"), + file("---\ndescription: Commit\n---\nDraft commit for {{args}}.\n"), + )]), + frontmatter: BTreeMap::from([("description".to_string(), json!("Commit"))]), + overrides: BTreeMap::from([("mode".to_string(), json!("builtin"))]), + }, + ], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + assert!(response.skipped.is_empty()); + assert!(response.files_written.contains(&PathBuf::from("GEMINI.md"))); + assert!( + response + .files_written + .contains(&PathBuf::from("packages/api/GEMINI.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".gemini/skills/shared-analysis/SKILL.md")) + ); + assert!( + response + .files_written + .contains(&PathBuf::from(".gemini/commands/git/commit.toml")) + ); + assert!(read(root.join("GEMINI.md")).contains("# Root")); + assert!(read(root.join("packages/api/GEMINI.md")).contains("# API")); + assert!( + !root + .join(".agents/skills/shared-analysis/SKILL.md") + .exists() + ); + let command = read(root.join(".gemini/commands/git/commit.toml")); + assert!(command.contains("description = \"Commit\"")); + assert!(command.contains("mode = \"builtin\"")); + assert!(command.contains("prompt = \"Draft commit for {{args}}.\\n\"")); + } + + #[test] + fn emits_canonical_toml_command_as_gemini_toml() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + + GeminiAdapter + .emit(EmitRequest { + runtime_dir: root.join(".gemini"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "command:deploy".to_string(), + entity_type: agentmesh_protocol::EntityType::Command, + scope: None, + source_path: None, + files: BTreeMap::from([( + PathBuf::from("deploy.toml"), + file("description = \"Deploy\"\nprompt = \"Deploy {{args}}\"\n"), + )]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::from([("mode".to_string(), json!("builtin"))]), + }], + }) + .unwrap_or_else(|error| panic!("emit should succeed: {error}")); + + let command = read(root.join(".gemini/commands/deploy.toml")); + assert!(command.contains("description = \"Deploy\"")); + assert!(command.contains("mode = \"builtin\"")); + assert!(command.contains("prompt = \"Deploy {{args}}\"")); + assert!(!command.contains("prompt = \"prompt =")); + } + + #[test] + fn invalid_gemini_files_report_diagnostics() { + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + write( + root.join(".gemini/skills/broken/SKILL.md"), + "---\nname: broken\ndescription: \"unterminated\n---\nBroken\n", + ); + write( + root.join(".agents/skills/broken-shared/SKILL.md"), + "---\nname: broken-shared\ndescription: \"unterminated\n---\nBroken\n", + ); + write( + root.join(".gemini/commands/missing-prompt.toml"), + "description = \"Missing\"\n", + ); + + let imported = GeminiAdapter + .import(ImportRequest { + canonical_dir: root.join(".ai"), + runtime_dir: root.join(".gemini"), + filter: None, + }) + .unwrap_or_else(|error| panic!("import should succeed: {error}")); + + assert!(imported.entities.is_empty()); + for expected in [ + "invalid Gemini skill frontmatter must not be overwritten", + "invalid shared Gemini skill frontmatter must not be overwritten", + "missing required prompt field", + ] { + assert!( + imported + .skipped + .iter() + .any(|skipped| skipped.reason.contains(expected)), + "missing diagnostic {expected}; skipped: {:?}", + imported.skipped + ); + } + } + + #[cfg(unix)] + #[test] + fn rejects_symlinked_gemini_skill_write_root() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir() + .unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let root = temp.path(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside) + .unwrap_or_else(|error| panic!("outside dir should be created: {error}")); + std::fs::create_dir_all(root.join(".gemini")) + .unwrap_or_else(|error| panic!("gemini dir should be created: {error}")); + symlink(&outside, root.join(".gemini/skills")) + .unwrap_or_else(|error| panic!("symlink should be created: {error}")); + + let error = GeminiAdapter + .emit(EmitRequest { + runtime_dir: root.join(".gemini"), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: "skill:escape".to_string(), + entity_type: agentmesh_protocol::EntityType::Skill, + scope: None, + source_path: None, + files: BTreeMap::from([(PathBuf::from("SKILL.md"), file("# Escape\n"))]), + frontmatter: BTreeMap::new(), + overrides: BTreeMap::new(), + }], + }) + .expect_err("symlinked Gemini skill root should be rejected"); + + assert!(error.to_string().contains("symlinked Gemini path")); + assert!(!outside.join("escape/SKILL.md").exists()); + } +} diff --git a/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs b/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs index fa278a6..f55fca8 100644 --- a/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs +++ b/crates/agentmesh-adapter-sdk-rust/src/frontmatter.rs @@ -17,27 +17,39 @@ pub struct FrontmatterDocument { /// Splits Markdown into YAML frontmatter and body content. pub fn parse_frontmatter(markdown: &str) -> Result { - let Some(rest) = markdown.strip_prefix("---\n") else { + let Some((frontmatter, body)) = split_frontmatter(markdown) else { return Ok(FrontmatterDocument { frontmatter: Mapping::new(), body: markdown.to_string(), }); }; - let Some(end) = rest.find("\n---\n") else { - return Ok(FrontmatterDocument { - frontmatter: Mapping::new(), - body: markdown.to_string(), - }); - }; - - let frontmatter = &rest[..end]; - let body = &rest[end + "\n---\n".len()..]; Ok(FrontmatterDocument { frontmatter: parse_frontmatter_mapping(frontmatter)?, body: body.to_string(), }) } +fn split_frontmatter(markdown: &str) -> Option<(&str, &str)> { + let rest = markdown + .strip_prefix("---\n") + .or_else(|| markdown.strip_prefix("---\r\n"))?; + let mut offset = 0; + + while offset < rest.len() { + let remaining = &rest[offset..]; + let line_len = remaining + .find('\n') + .map_or(remaining.len(), |index| index + 1); + let line = &remaining[..line_len]; + if matches!(line, "---\n" | "---\r\n" | "---") { + return Some((&rest[..offset], &rest[offset + line_len..])); + } + offset += line_len; + } + + None +} + /// Serializes Markdown with stable frontmatter key ordering. pub fn compose_frontmatter(document: &FrontmatterDocument) -> Result { let ordered = ordered_frontmatter(&document.frontmatter); diff --git a/crates/agentmesh-adapter-sdk-rust/src/lib.rs b/crates/agentmesh-adapter-sdk-rust/src/lib.rs index 304cfa1..8a60cd9 100644 --- a/crates/agentmesh-adapter-sdk-rust/src/lib.rs +++ b/crates/agentmesh-adapter-sdk-rust/src/lib.rs @@ -1375,6 +1375,26 @@ mod tests { assert_eq!(document.body, "Body\n"); } + #[test] + fn parses_crlf_frontmatter_delimiters() { + let input = + "---\r\nname: implementation-auditor\r\ndescription: Strict audit\r\n---\r\nBody\r\n"; + let document = match parse_frontmatter(input) { + Ok(document) => document, + Err(error) => panic!("frontmatter should parse: {error}"), + }; + + assert_eq!( + document.frontmatter.get("name"), + Some(&YamlValue::String("implementation-auditor".to_string())) + ); + assert_eq!( + document.frontmatter.get("description"), + Some(&YamlValue::String("Strict audit".to_string())) + ); + assert_eq!(document.body, "Body\r\n"); + } + #[test] fn malformed_structured_frontmatter_still_fails() { let input = "---\nname: demo\nmetadata: {unterminated\n---\nBody\n"; diff --git a/crates/agentmesh-core/Cargo.toml b/crates/agentmesh-core/Cargo.toml index 8f222ef..d1aa59e 100644 --- a/crates/agentmesh-core/Cargo.toml +++ b/crates/agentmesh-core/Cargo.toml @@ -26,6 +26,9 @@ ulid = "1.2.1" [dev-dependencies] agentmesh-adapter-claude.workspace = true agentmesh-adapter-codex.workspace = true +agentmesh-adapter-copilot.workspace = true +agentmesh-adapter-cursor.workspace = true +agentmesh-adapter-gemini.workspace = true agentmesh-adapter-sdk-rust.workspace = true criterion.workspace = true insta.workspace = true diff --git a/crates/agentmesh-core/benches/full_sync_bench.rs b/crates/agentmesh-core/benches/full_sync_bench.rs index c0227f4..1acda4b 100644 --- a/crates/agentmesh-core/benches/full_sync_bench.rs +++ b/crates/agentmesh-core/benches/full_sync_bench.rs @@ -59,6 +59,27 @@ fn with_runtime_adapter( message: source.to_string(), }) } + "copilot" => { + let adapter = agentmesh_adapter_copilot::CopilotAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } + "cursor" => { + let adapter = agentmesh_adapter_cursor::CursorAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } + "gemini" => { + let adapter = agentmesh_adapter_gemini::GeminiAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } _ => Err(PipelineError::Adapter { runtime: runtime.clone(), message: "unknown runtime adapter".to_string(), @@ -66,6 +87,25 @@ fn with_runtime_adapter( } } +fn write_seed_file( + path: &Path, + contents: impl AsRef<[u8]>, + action: &'static str, +) -> Result<(), PipelineError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| PipelineError::Io { + action: "create benchmark fixture directory", + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, contents).map_err(|source| PipelineError::Io { + action, + path: path.to_path_buf(), + source, + }) +} + fn seed_repo() -> Result<(TempDir, PathBuf), PipelineError> { let temp = TempDir::new().map_err(|source| PipelineError::Io { action: "create benchmark temp directory", @@ -83,6 +123,31 @@ fn seed_repo() -> Result<(TempDir, PathBuf), PipelineError> { path: repo.join(".codex"), source, })?; + write_seed_file( + &repo.join(".github/copilot-instructions.md"), + "Benchmark instructions.\n", + "write benchmark Copilot instructions", + )?; + write_seed_file( + &repo.join(".github/prompts/performance.prompt.md"), + "---\ndescription: Performance prompt\nmode: ask\n---\nReview performance.\n", + "write benchmark Copilot prompt", + )?; + write_seed_file( + &repo.join(".cursor/rules/performance.mdc"), + "---\ndescription: Performance rule\nalwaysApply: true\n---\nKeep hot paths efficient.\n", + "write benchmark Cursor rule", + )?; + write_seed_file( + &repo.join("GEMINI.md"), + "Benchmark instructions.\n", + "write benchmark Gemini context", + )?; + write_seed_file( + &repo.join(".gemini/commands/performance.toml"), + "description = \"Performance command\"\nprompt = \"Review performance for {{args}}.\"\n", + "write benchmark Gemini command", + )?; for index in 0..1000 { let slug = format!("skill-{index}"); let skill_dir = repo.join(".claude/skills").join(&slug); diff --git a/crates/agentmesh-core/benches/lockfile_bench.rs b/crates/agentmesh-core/benches/lockfile_bench.rs index eefb43c..552ef4d 100644 --- a/crates/agentmesh-core/benches/lockfile_bench.rs +++ b/crates/agentmesh-core/benches/lockfile_bench.rs @@ -50,6 +50,7 @@ fn sample_lockfile(entity_count: usize) -> Lockfile { id, LockfileEntity { entity_type: EntityType::Skill, + entity_schema: agentmesh_core::lockfile::entity_schema_for_type(EntityType::Skill), scope: None, locations: BTreeMap::from([ ( diff --git a/crates/agentmesh-core/src/config.rs b/crates/agentmesh-core/src/config.rs index 39d4fb4..abf9b6e 100644 --- a/crates/agentmesh-core/src/config.rs +++ b/crates/agentmesh-core/src/config.rs @@ -116,15 +116,13 @@ impl AgentmeshConfig { }); } - if let Some(sync) = &self.sync { - if let Some(threshold) = sync.rename_similarity_threshold { - if !(0.0..=1.0).contains(&threshold) { - return Err(ConfigError::InvalidValue { - message: "sync.rename_similarity_threshold must be between 0.0 and 1.0" - .to_string(), - }); - } - } + if let Some(sync) = &self.sync + && let Some(threshold) = sync.rename_similarity_threshold + && !(0.0..=1.0).contains(&threshold) + { + return Err(ConfigError::InvalidValue { + message: "sync.rename_similarity_threshold must be between 0.0 and 1.0".to_string(), + }); } Ok(()) diff --git a/crates/agentmesh-core/src/identity.rs b/crates/agentmesh-core/src/identity.rs index e33056b..c689566 100644 --- a/crates/agentmesh-core/src/identity.rs +++ b/crates/agentmesh-core/src/identity.rs @@ -64,21 +64,278 @@ pub struct DetectedRename { /// Derives the deterministic entity ID for a first-seen path. pub fn derive_entity_id(path: &Path) -> Result { + derive_entity_id_for_type(None, path) +} + +/// Derives the deterministic entity ID for a first-seen path and entity type. +pub fn derive_entity_id_as(entity_type: EntityType, path: &Path) -> Result { + derive_entity_id_for_type(Some(entity_type), path) +} + +fn derive_entity_id_for_type(entity_type: Option, path: &Path) -> Result { let parts = utf8_components(path)?; match parts.as_slice() { - ["AGENTS.md"] | ["CLAUDE.md"] => EntityId::new("instructions:root").map_err(Into::into), - [".ai", "skills", skill_name, ..] | [".claude" | ".codex", "skills", skill_name, ..] => { + ["AGENTS.md"] | ["CLAUDE.md"] | ["GEMINI.md"] | [".github", "copilot-instructions.md"] + if entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + EntityId::new("instructions:root").map_err(Into::into) + } + [scope @ .., "AGENTS.md" | "GEMINI.md"] + if !scope.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + let slug = slugify_segments(scope)?; + EntityId::new(format!("instructions:scoped:{slug}")).map_err(Into::into) + } + [".github", "instructions", instruction_file] + if entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + let slug = slugify(strip_suffix(instruction_file, ".instructions.md"))?; + EntityId::new(format!("instructions:scoped:{slug}")).map_err(Into::into) + } + [".github", "instructions", nested @ .., instruction_file] + if !nested.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + let slug = + slugify_path_with_stem(nested, strip_suffix(instruction_file, ".instructions.md"))?; + EntityId::new(format!("instructions:scoped:{slug}")).map_err(Into::into) + } + [".ai", "instructions", instruction_file] + if entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + let slug = slugify(file_stem(instruction_file))?; + EntityId::new(format!("instructions:scoped:{slug}")).map_err(Into::into) + } + [".ai", "instructions", nested @ .., instruction_file] + if !nested.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::Instructions) => + { + let slug = slugify_path_with_stem(nested, file_stem(instruction_file))?; + EntityId::new(format!("instructions:scoped:{slug}")).map_err(Into::into) + } + [ + ".ai" | ".agents" | ".github" | ".gemini", + "skills", + skill_name, + .., + ] + | [".claude" | ".codex", "skills", skill_name, ..] + if entity_type.is_none_or(|kind| kind == EntityType::Skill) => + { EntityId::from_parts(EntityType::Skill, &slugify(skill_name)?).map_err(Into::into) } - [".ai", "subagents", file_name] => { + [".ai", "subagents", file_name] + if entity_type.is_none_or(|kind| kind == EntityType::Subagent) => + { EntityId::from_parts(EntityType::Subagent, &slugify(file_stem(file_name))?) .map_err(Into::into) } - [".claude" | ".codex", "agents", file_name] => { + [".claude" | ".codex", "agents", file_name] + if entity_type.is_none_or(|kind| kind == EntityType::Subagent) => + { EntityId::from_parts(EntityType::Subagent, &slugify(file_stem(file_name))?) .map_err(Into::into) } + [".github", "agents", file_name] + if entity_type.is_none_or(|kind| kind == EntityType::Subagent) => + { + let stem = file_name + .strip_suffix(".agent.md") + .or_else(|| file_name.strip_suffix(".md")) + .unwrap_or(file_name); + EntityId::from_parts(EntityType::Subagent, &slugify(stem)?).map_err(Into::into) + } + [".cursor", "rules", rule_file] + if entity_type.is_none_or(|kind| kind == EntityType::Rule) => + { + EntityId::from_parts(EntityType::Rule, &slugify(file_stem(rule_file))?) + .map_err(Into::into) + } + [".ai", "rules", rule_file] if entity_type.is_none_or(|kind| kind == EntityType::Rule) => { + EntityId::from_parts(EntityType::Rule, &slugify(file_stem(rule_file))?) + .map_err(Into::into) + } + [".ai", "rules", nested @ .., rule_file] + if !nested.is_empty() && entity_type.is_none_or(|kind| kind == EntityType::Rule) => + { + EntityId::from_parts( + EntityType::Rule, + &slugify_path_with_stem(nested, file_stem(rule_file))?, + ) + .map_err(Into::into) + } + [".cursor", "rules", nested @ .., rule_file] + if !nested.is_empty() && entity_type.is_none_or(|kind| kind == EntityType::Rule) => + { + EntityId::from_parts( + EntityType::Rule, + &slugify_path_with_stem(nested, file_stem(rule_file))?, + ) + .map_err(Into::into) + } + [".claude", "rules", rule_file] + if entity_type.is_some_and(|kind| kind == EntityType::Instructions) => + { + EntityId::from_parts(EntityType::Instructions, &slugify(file_stem(rule_file))?) + .map_err(Into::into) + } + [".claude", "rules", nested @ .., rule_file] + if !nested.is_empty() + && entity_type.is_some_and(|kind| kind == EntityType::Instructions) => + { + EntityId::from_parts( + EntityType::Instructions, + &slugify_path_with_stem(nested, file_stem(rule_file))?, + ) + .map_err(Into::into) + } + [".claude", "rules", rule_file] + if entity_type.is_none_or(|kind| kind == EntityType::Rule) => + { + EntityId::from_parts(EntityType::Rule, &slugify(file_stem(rule_file))?) + .map_err(Into::into) + } + [".claude", "rules", nested @ .., rule_file] + if !nested.is_empty() && entity_type.is_none_or(|kind| kind == EntityType::Rule) => + { + EntityId::from_parts( + EntityType::Rule, + &slugify_path_with_stem(nested, file_stem(rule_file))?, + ) + .map_err(Into::into) + } + [".github", "prompts", prompt_file] + if entity_type.is_none_or(|kind| kind == EntityType::Prompt) => + { + EntityId::from_parts( + EntityType::Prompt, + &slugify(strip_suffix(prompt_file, ".prompt.md"))?, + ) + .map_err(Into::into) + } + [".ai", "prompts", prompt_file] + if entity_type.is_none_or(|kind| kind == EntityType::Prompt) => + { + EntityId::from_parts(EntityType::Prompt, &slugify(file_stem(prompt_file))?) + .map_err(Into::into) + } + [".ai", "prompts", nested @ .., prompt_file] + if !nested.is_empty() && entity_type.is_none_or(|kind| kind == EntityType::Prompt) => + { + EntityId::from_parts( + EntityType::Prompt, + &slugify_path_with_stem(nested, file_stem(prompt_file))?, + ) + .map_err(Into::into) + } + [".ai", "commands", command @ ..] + if !command.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::Command) => + { + EntityId::from_parts(EntityType::Command, &command_slug(command)?).map_err(Into::into) + } + [".claude" | ".gemini", "commands", command @ ..] + if !command.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::Command) => + { + EntityId::from_parts(EntityType::Command, &command_slug(command)?).map_err(Into::into) + } + [".ai", "hooks", file_name] if entity_type.is_none_or(|kind| kind == EntityType::Hook) => { + EntityId::from_parts(EntityType::Hook, &slugify(file_stem(file_name))?) + .map_err(Into::into) + } + [".ai", "hooks", nested @ .., file_name] + if !nested.is_empty() && entity_type.is_none_or(|kind| kind == EntityType::Hook) => + { + EntityId::from_parts( + EntityType::Hook, + &slugify_path_with_stem(nested, file_stem(file_name))?, + ) + .map_err(Into::into) + } + [".ai", "mcp-bindings", file_name] + if entity_type.is_none_or(|kind| kind == EntityType::McpBinding) => + { + EntityId::from_parts(EntityType::McpBinding, &slugify(file_stem(file_name))?) + .map_err(Into::into) + } + [".ai", "mcp-bindings", nested @ .., file_name] + if !nested.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::McpBinding) => + { + EntityId::from_parts( + EntityType::McpBinding, + &slugify_path_with_stem(nested, file_stem(file_name))?, + ) + .map_err(Into::into) + } + [".ai", "permission-policies", file_name] + if entity_type.is_none_or(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts( + EntityType::PermissionPolicy, + &slugify(file_stem(file_name))?, + ) + .map_err(Into::into) + } + [".ai", "permission-policies", nested @ .., file_name] + if !nested.is_empty() + && entity_type.is_none_or(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts( + EntityType::PermissionPolicy, + &slugify_path_with_stem(nested, file_stem(file_name))?, + ) + .map_err(Into::into) + } + [".claude", "settings.json"] if entity_type.is_none_or(|kind| kind == EntityType::Hook) => { + EntityId::from_parts(EntityType::Hook, "claude-project").map_err(Into::into) + } + [".gemini", "settings.json"] if entity_type.is_none_or(|kind| kind == EntityType::Hook) => { + EntityId::from_parts(EntityType::Hook, "gemini-project").map_err(Into::into) + } + [".codex", "hooks.json"] if entity_type.is_none_or(|kind| kind == EntityType::Hook) => { + EntityId::from_parts(EntityType::Hook, "codex-project").map_err(Into::into) + } + [".mcp.json"] if entity_type.is_none_or(|kind| kind == EntityType::McpBinding) => { + EntityId::from_parts(EntityType::McpBinding, "project").map_err(Into::into) + } + [".gemini", "settings.json"] + if entity_type.is_some_and(|kind| kind == EntityType::McpBinding) => + { + EntityId::from_parts(EntityType::McpBinding, "gemini-project").map_err(Into::into) + } + [".codex", "config.toml"] + if entity_type.is_some_and(|kind| kind == EntityType::McpBinding) => + { + EntityId::from_parts(EntityType::McpBinding, "codex-project").map_err(Into::into) + } + [".claude", "settings.json"] + if entity_type.is_some_and(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts(EntityType::PermissionPolicy, "claude-project").map_err(Into::into) + } + [".gemini", "settings.json"] + if entity_type.is_some_and(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts(EntityType::PermissionPolicy, "gemini-project").map_err(Into::into) + } + [".codex", "config.toml"] + if entity_type.is_some_and(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts(EntityType::PermissionPolicy, "codex-project").map_err(Into::into) + } + [".codex", "rules", file_name] + if entity_type.is_some_and(|kind| kind == EntityType::PermissionPolicy) => + { + EntityId::from_parts( + EntityType::PermissionPolicy, + &slugify(file_stem(file_name))?, + ) + .map_err(Into::into) + } _ => Err(IdentityError::UnsupportedPath { path: path.to_path_buf(), }), @@ -191,6 +448,34 @@ fn file_stem(file_name: &str) -> &str { .unwrap_or(file_name) } +fn strip_suffix<'a>(value: &'a str, suffix: &str) -> &'a str { + value.strip_suffix(suffix).unwrap_or(value) +} + +fn slugify_segments(segments: &[&str]) -> Result { + slugify(&segments.join("-")) +} + +fn slugify_path_with_stem(segments: &[&str], stem: &str) -> Result { + let mut parts = segments.to_vec(); + parts.push(stem); + slugify_segments(&parts) +} + +fn command_slug(command: &[&str]) -> Result { + let Some((file_name, namespace)) = command.split_last() else { + return Err(IdentityError::InvalidSlug { + value: String::new(), + }); + }; + let command_name = slugify(file_stem(file_name))?; + if namespace.is_empty() { + return Ok(command_name); + } + let namespace = slugify_segments(namespace)?; + Ok(format!("{namespace}:{command_name}")) +} + fn slugify(value: &str) -> Result { let mut slug = String::new(); let mut previous_dash = false; @@ -221,8 +506,10 @@ mod tests { use std::path::Path; use super::{ - RenameCandidate, derive_entity_id, detect_rename, parse_pin_marker, resolve_collision, + RenameCandidate, derive_entity_id, derive_entity_id_as, detect_rename, parse_pin_marker, + resolve_collision, }; + use crate::EntityType; use crate::types::{EntityId, Hash}; fn hash(value: &str) -> Hash { @@ -244,6 +531,13 @@ mod tests { let cases = [ ("AGENTS.md", "instructions:root"), ("CLAUDE.md", "instructions:root"), + ("GEMINI.md", "instructions:root"), + ("packages/api/AGENTS.md", "instructions:scoped:packages-api"), + ("packages/api/GEMINI.md", "instructions:scoped:packages-api"), + ( + ".github/instructions/api-review.instructions.md", + "instructions:scoped:api-review", + ), ( ".claude/skills/Security Review/SKILL.md", "skill:security-review", @@ -253,9 +547,33 @@ mod tests { "skill:security-review", ), (".ai/skills/api-design/SKILL.md", "skill:api-design"), + ( + ".agents/skills/shared-analysis/SKILL.md", + "skill:shared-analysis", + ), (".claude/agents/code-reviewer.md", "subagent:code-reviewer"), (".codex/agents/code-reviewer.toml", "subagent:code-reviewer"), + ( + ".github/agents/code-reviewer.agent.md", + "subagent:code-reviewer", + ), + ( + ".github/agents/performance-reviewer.md", + "subagent:performance-reviewer", + ), (".ai/subagents/code-reviewer.md", "subagent:code-reviewer"), + (".cursor/rules/security.mdc", "rule:security"), + (".claude/rules/security.md", "rule:security"), + ( + ".github/prompts/release-notes.prompt.md", + "prompt:release-notes", + ), + (".claude/commands/git/commit.md", "command:git:commit"), + (".gemini/commands/review.toml", "command:review"), + (".claude/settings.json", "hook:claude-project"), + (".gemini/settings.json", "hook:gemini-project"), + (".codex/hooks.json", "hook:codex-project"), + (".mcp.json", "mcp-binding:project"), ]; for (path, expected) in cases { @@ -267,6 +585,55 @@ mod tests { } } + #[test] + fn derives_typed_ids_for_multi_entity_config_paths() { + let cases = [ + ( + EntityType::Instructions, + ".claude/rules/api.md", + "instructions:scoped:api", + ), + ( + EntityType::McpBinding, + ".gemini/settings.json", + "mcp-binding:gemini-project", + ), + ( + EntityType::McpBinding, + ".codex/config.toml", + "mcp-binding:codex-project", + ), + ( + EntityType::PermissionPolicy, + ".claude/settings.json", + "permission-policy:claude-project", + ), + ( + EntityType::PermissionPolicy, + ".gemini/settings.json", + "permission-policy:gemini-project", + ), + ( + EntityType::PermissionPolicy, + ".codex/config.toml", + "permission-policy:codex-project", + ), + ( + EntityType::PermissionPolicy, + ".codex/rules/strict.rules", + "permission-policy:strict", + ), + ]; + + for (entity_type, path, expected) in cases { + let actual = match derive_entity_id_as(entity_type, Path::new(path)) { + Ok(actual) => actual, + Err(error) => panic!("path should derive a typed id: {error}"), + }; + assert_eq!(actual.as_str(), expected); + } + } + #[test] fn resolves_collisions_with_numeric_suffixes() { let existing = BTreeSet::from([ @@ -281,6 +648,20 @@ mod tests { assert_eq!(resolved.as_str(), "skill:security-review-3"); } + #[test] + fn resolves_namespaced_command_collisions_on_command_segment() { + let existing = BTreeSet::from([ + entity_id("command:git:commit"), + entity_id("command:git:commit-2"), + ]); + let resolved = match resolve_collision(&entity_id("command:git:commit"), &existing) { + Ok(resolved) => resolved, + Err(error) => panic!("collision should resolve: {error}"), + }; + + assert_eq!(resolved.as_str(), "command:git:commit-3"); + } + #[test] fn parses_optional_pin_marker() { let marker = match parse_pin_marker( diff --git a/crates/agentmesh-core/src/lib.rs b/crates/agentmesh-core/src/lib.rs index 59a6ac4..e5c1eee 100644 --- a/crates/agentmesh-core/src/lib.rs +++ b/crates/agentmesh-core/src/lib.rs @@ -1,5 +1,6 @@ //! Core domain APIs and persisted state shapes for AgentMesh. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use thiserror::Error; @@ -94,6 +95,24 @@ pub struct SyncSummary { pub pending_conflicts: usize, /// Number of entities skipped because a runtime lacks support for them. pub capability_skipped: usize, + /// Detailed unsupported capability skips. + pub capability_skips: Vec, +} + +/// Details for one entity skipped because a runtime lacks native support. +#[derive(Debug, Clone, PartialEq, Eq)] +#[must_use] +pub struct CapabilitySkipFinding { + /// Runtime that cannot represent the entity. + pub runtime: RuntimeName, + /// Entity ID that could not be emitted. + pub entity_id: EntityId, + /// Entity type that could not be emitted. + pub entity_type: EntityType, + /// Configured fallback behavior. + pub fallback: config::CapabilityFallback, + /// Existing lockfile locations for the skipped entity. + pub locations: BTreeMap, } /// Health report for a repository. diff --git a/crates/agentmesh-core/src/lockfile.rs b/crates/agentmesh-core/src/lockfile.rs index 284e3e3..0bfca39 100644 --- a/crates/agentmesh-core/src/lockfile.rs +++ b/crates/agentmesh-core/src/lockfile.rs @@ -71,7 +71,7 @@ pub enum LockfileError { pub const LOCKFILE_CONTENT_VERSION: u32 = 1; /// Current lockfile schema version. -pub const LOCKFILE_SCHEMA_VERSION: u32 = 1; +pub const LOCKFILE_SCHEMA_VERSION: u32 = 2; /// Highest lockfile schema version this build can read. pub const MAX_SUPPORTED_LOCKFILE_SCHEMA: u32 = LOCKFILE_SCHEMA_VERSION; @@ -132,6 +132,9 @@ pub struct LockfileEntity { /// Canonical entity type. #[serde(rename = "type")] pub entity_type: EntityType, + /// Schema version for the canonical entity representation. + #[serde(default = "default_entity_schema")] + pub entity_schema: u32, /// Scope for instruction entities. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, @@ -229,7 +232,9 @@ impl MigrationRegistry { /// Creates a registry that migrates to the current schema. #[must_use] pub fn current() -> Self { - Self::new(LOCKFILE_SCHEMA_VERSION) + let mut registry = Self::new(LOCKFILE_SCHEMA_VERSION); + registry.register(1, migrate_schema_1_to_2); + registry } /// Creates a registry that migrates to a specific schema. @@ -262,6 +267,34 @@ impl MigrationRegistry { } } +/// Returns the current canonical schema for a new entity. +#[must_use] +pub const fn entity_schema_for_type(entity_type: EntityType) -> u32 { + match entity_type { + EntityType::Instructions | EntityType::Skill | EntityType::Subagent => 1, + EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy => 2, + } +} + +fn default_entity_schema() -> u32 { + 1 +} + +fn migrate_schema_1_to_2(mut lockfile: Lockfile) -> Result { + lockfile.schema = 2; + for entity in lockfile.entities.values_mut() { + if entity.entity_schema == 0 { + entity.entity_schema = default_entity_schema(); + } + } + Ok(lockfile) +} + /// Parses a lockfile from YAML and checks schema compatibility. pub fn parse_lockfile(contents: &str) -> Result { let lockfile = @@ -313,7 +346,8 @@ mod tests { use super::{ AdapterDeclaration, AdapterMode, HookKind, LOCKFILE_SCHEMA_VERSION, Lockfile, - LockfileEntity, LockfileError, MigrationRegistry, parse_lockfile, serialize_lockfile, + LockfileEntity, LockfileError, MigrationRegistry, entity_schema_for_type, parse_lockfile, + serialize_lockfile, }; use crate::EntityType; use crate::types::{EntityId, Hash, LocationKey, RuntimeName}; @@ -363,6 +397,7 @@ mod tests { entity_id("skill:security-review"), LockfileEntity { entity_type: EntityType::Skill, + entity_schema: entity_schema_for_type(EntityType::Skill), scope: None, locations, canonical_sha256: valid_hash(), @@ -386,6 +421,39 @@ mod tests { hooks: vec![HookKind::PostToolUse], }, ); + for (id, entity_type) in [ + ("instructions:scoped:api-review", EntityType::Instructions), + ("rule:security", EntityType::Rule), + ("prompt:release-notes", EntityType::Prompt), + ("command:git:commit", EntityType::Command), + ("hook:gemini-project", EntityType::Hook), + ("mcp-binding:gemini-project", EntityType::McpBinding), + ( + "permission-policy:gemini-project", + EntityType::PermissionPolicy, + ), + ("subagent:code-reviewer", EntityType::Subagent), + ] { + lockfile.entities.insert( + entity_id(id), + LockfileEntity { + entity_type, + entity_schema: entity_schema_for_type(entity_type), + scope: if id.starts_with("instructions:scoped:") { + Some("api-review".to_string()) + } else { + None + }, + locations: BTreeMap::new(), + canonical_sha256: valid_hash(), + emitted_native_sha256: BTreeMap::new(), + lineage: Vec::new(), + pending_conflict_resolution: None, + rename_history: Vec::new(), + id_pin: None, + }, + ); + } let serialized = match serialize_lockfile(&lockfile) { Ok(serialized) => serialized, @@ -446,6 +514,7 @@ mod tests { let entity = LockfileEntity { entity_type: EntityType::Subagent, scope: None, + entity_schema: entity_schema_for_type(EntityType::Subagent), locations: BTreeMap::from([ ( location_key(".ai"), @@ -488,4 +557,39 @@ mod tests { assert_eq!(migrated.schema, 2); } + + #[test] + fn migrates_schema_one_lockfiles_without_rewriting_entities() { + let contents = r#" +version: 1 +schema: 1 +entities: + instructions:root: + type: instructions + scope: root + canonical_sha256: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + skill:security-review: + type: skill + canonical_sha256: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +"#; + + let migrated = match parse_lockfile(contents) { + Ok(lockfile) => lockfile, + Err(error) => panic!("schema one lockfile should migrate: {error}"), + }; + + assert_eq!(migrated.schema, LOCKFILE_SCHEMA_VERSION); + assert_eq!( + migrated.entities[&entity_id("instructions:root")].entity_schema, + 1 + ); + assert_eq!( + migrated.entities[&entity_id("skill:security-review")].entity_schema, + 1 + ); + assert_eq!( + migrated.entities[&entity_id("instructions:root")].scope, + Some("root".to_string()) + ); + } } diff --git a/crates/agentmesh-core/src/merge.rs b/crates/agentmesh-core/src/merge.rs index 7cea6ea..56be99c 100644 --- a/crates/agentmesh-core/src/merge.rs +++ b/crates/agentmesh-core/src/merge.rs @@ -142,22 +142,13 @@ pub fn preserve_losing_version( Ok(path) } -fn split_markdown(input: &str) -> Result { - let Some(rest) = input.strip_prefix("---\n") else { +pub(crate) fn split_markdown(input: &str) -> Result { + let Some((frontmatter, body)) = split_frontmatter(input) else { return Ok(MarkdownParts { frontmatter: Mapping::new(), body: input.to_string(), }); }; - let Some(end) = rest.find("\n---\n") else { - return Ok(MarkdownParts { - frontmatter: Mapping::new(), - body: input.to_string(), - }); - }; - - let frontmatter = &rest[..end]; - let body = &rest[end + "\n---\n".len()..]; Ok(MarkdownParts { frontmatter: parse_frontmatter_mapping(frontmatter)?, @@ -165,6 +156,27 @@ fn split_markdown(input: &str) -> Result { }) } +fn split_frontmatter(markdown: &str) -> Option<(&str, &str)> { + let rest = markdown + .strip_prefix("---\n") + .or_else(|| markdown.strip_prefix("---\r\n"))?; + let mut offset = 0; + + while offset < rest.len() { + let remaining = &rest[offset..]; + let line_len = remaining + .find('\n') + .map_or(remaining.len(), |index| index + 1); + let line = &remaining[..line_len]; + if matches!(line, "---\n" | "---\r\n" | "---") { + return Some((&rest[..offset], &rest[offset + line_len..])); + } + offset += line_len; + } + + None +} + pub(crate) fn parse_frontmatter_mapping(frontmatter: &str) -> Result { if frontmatter.trim().is_empty() { return Ok(Mapping::new()); @@ -321,15 +333,14 @@ fn merge_value( if incoming == ancestor { return ValueMerge::Value(current.cloned()); } - if SET_LIKE_KEYS.contains(&key) { - if let ( + if SET_LIKE_KEYS.contains(&key) + && let ( Some(Value::Sequence(ancestor)), Some(Value::Sequence(current)), Some(Value::Sequence(incoming)), ) = (ancestor, current, incoming) - { - return ValueMerge::Value(Some(merge_set_like_sequence(ancestor, current, incoming))); - } + { + return ValueMerge::Value(Some(merge_set_like_sequence(ancestor, current, incoming))); } ValueMerge::Conflict @@ -505,6 +516,22 @@ Body assert!(canonical.ends_with("Body\n")); } + #[test] + fn canonicalizes_crlf_frontmatter_delimiters() { + let canonical = match canonicalize_markdown( + "---\r\nname: implementation-auditor\r\ndescription: Strict audit\r\n---\r\nBody\r\n", + ) { + Ok(canonical) => canonical, + Err(error) => panic!("canonicalization should succeed: {error}"), + }; + + assert!( + canonical + .starts_with("---\nname: implementation-auditor\ndescription: Strict audit\n---\n") + ); + assert!(canonical.ends_with("Body\r\n")); + } + #[test] fn malformed_structured_frontmatter_still_fails() { let input = "---\nname: demo\nmetadata: {unterminated\n---\nBody\n"; diff --git a/crates/agentmesh-core/src/pipeline.rs b/crates/agentmesh-core/src/pipeline.rs index c544f19..5663cc9 100644 --- a/crates/agentmesh-core/src/pipeline.rs +++ b/crates/agentmesh-core/src/pipeline.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{BufRead, BufReader, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -32,7 +32,7 @@ use crate::identity::{ }; use crate::lockfile::{ AdapterDeclaration, AdapterMode, HookKind, Lockfile, LockfileEntity, LockfileError, - RenameRecord, read_lockfile, write_lockfile, + OverrideEntry, RenameRecord, entity_schema_for_type, read_lockfile, write_lockfile, }; use crate::merge::{MergeError, MergeSide, MergeStatus, merge_markdown, preserve_losing_version}; use crate::mutex::{AgentmeshMutex, MutexError}; @@ -44,9 +44,9 @@ use crate::state::{ }; use crate::types::{EntityId, Hash, LocationKey, RuntimeName, TypeError}; use crate::{ - CanonicalInstructions, DoctorHealth, DoctorReport, EntityType, InitOptions, InitSummary, - ReconcileSummary, RestoreOptions, RestoreSummary, SyncOptions, SyncSummary, UninstallOptions, - UninstallSummary, UpgradeSummary, VERSION, + CanonicalInstructions, CapabilitySkipFinding, DoctorHealth, DoctorReport, EntityType, + InitOptions, InitSummary, ReconcileSummary, RestoreOptions, RestoreSummary, SyncOptions, + SyncSummary, UninstallOptions, UninstallSummary, UpgradeSummary, VERSION, }; mod doctor; @@ -170,6 +170,18 @@ pub enum PipelineError { /// Entity type being emitted. entity_type: EntityType, }, + /// The configured document fallback has no safe target for this entity. + #[error( + "runtime `{runtime}` cannot render `{entity_type}` for entity `{entity_id}` as a supported document" + )] + RenderAsDocUnsupported { + /// Runtime that lacks a safe document target. + runtime: RuntimeName, + /// Entity ID being emitted. + entity_id: EntityId, + /// Entity type being emitted. + entity_type: EntityType, + }, /// Entity content could not be translated into canonical form. #[error("failed to translate entity at {}: {message}", path.display())] EntityFormat { @@ -221,11 +233,13 @@ pub enum PipelineError { #[derive(Debug, Clone)] struct EntityView { entity_type: EntityType, + scope: Option, location: LocationKey, relative_path: PathBuf, lockfile_path: PathBuf, canonical_contents: Vec, files: BTreeMap>, + runtime_overrides: BTreeMap, native_hash: Hash, mtime: SystemTime, id_pin: Option, @@ -279,6 +293,9 @@ struct ScannedEntities { struct RuntimeMarkers { claude: bool, codex: bool, + copilot: bool, + cursor: bool, + gemini: bool, } #[derive(Debug, Clone)] @@ -435,7 +452,7 @@ fn entity_location_hash( location: &LocationKey, lockfile_path: &Path, ) -> Result> { - let absolute_path = path_from_lockfile(repo_root, location, lockfile_path); + let absolute_path = path_from_lockfile(repo_root, location, lockfile_path)?; if !is_regular_file_path(&absolute_path)? { return Ok(None); } @@ -655,6 +672,7 @@ fn run_sync_with_plan_options( pending_drained: drain.processed, pending_conflicts: count_pending_conflicts(¤t_lockfile), capability_skipped: 0, + capability_skips: Vec::new(), }); } @@ -675,7 +693,12 @@ fn run_sync_with_plan_options( let mut changed_entities = file_changes.clone(); changed_entities.extend(lockfile_entity_changes); changed_entities.extend(pending_emit_changes); - let capability_skipped = capability_skip_count_for_lockfile(&plan.lockfile, &config)?; + let capability_report = capability_skip_report_for_lockfile( + &plan.lockfile, + &config, + CapabilityReportMode::Enforce, + )?; + let capability_skipped = capability_report.skipped; let pending_ready_count = queue.read_ready()?.len(); let changed = lockfile_changed || !file_changes.is_empty() @@ -690,6 +713,7 @@ fn run_sync_with_plan_options( pending_drained: 0, pending_conflicts: count_pending_conflicts(&plan.lockfile), capability_skipped, + capability_skips: capability_report.findings, }); } @@ -730,6 +754,7 @@ fn run_sync_with_plan_options( pending_drained: drain.processed, pending_conflicts: count_pending_conflicts(&plan.lockfile), capability_skipped, + capability_skips: capability_report.findings, }) } @@ -848,20 +873,70 @@ fn pending_emit_entities( pending } +#[cfg(test)] fn capability_skip_count_for_lockfile( lockfile: &Lockfile, config: &AgentmeshConfig, ) -> Result { - let mut skipped = 0; + Ok( + capability_skip_report_for_lockfile(lockfile, config, CapabilityReportMode::Enforce)? + .skipped, + ) +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +struct CapabilitySkipReport { + skipped: usize, + findings: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CapabilityReportMode { + Enforce, + Diagnostic, +} + +fn capability_skip_report_for_lockfile( + lockfile: &Lockfile, + config: &AgentmeshConfig, + mode: CapabilityReportMode, +) -> Result { + let mut report = CapabilitySkipReport::default(); for (entity_id, entity) in &lockfile.entities { for (runtime, adapter) in &lockfile.adapters { if adapter.entities.contains(&entity.entity_type) { continue; } - match capability_fallback(config, runtime, entity.entity_type) { + let fallback = capability_fallback(config, runtime, entity.entity_type); + match fallback { CapabilityFallback::Skip => {} - CapabilityFallback::Warn | CapabilityFallback::RenderAsDoc => skipped += 1, + CapabilityFallback::Warn => { + report.skipped += 1; + report.findings.push(capability_skip_finding( + runtime, entity_id, entity, fallback, + )); + } + CapabilityFallback::RenderAsDoc => { + if mode == CapabilityReportMode::Enforce { + return Err(PipelineError::RenderAsDocUnsupported { + runtime: runtime.clone(), + entity_id: entity_id.clone(), + entity_type: entity.entity_type, + }); + } + report.skipped += 1; + report.findings.push(capability_skip_finding( + runtime, entity_id, entity, fallback, + )); + } CapabilityFallback::Fail => { + if mode == CapabilityReportMode::Diagnostic { + report.skipped += 1; + report.findings.push(capability_skip_finding( + runtime, entity_id, entity, fallback, + )); + continue; + } return Err(PipelineError::CapabilityMismatch { runtime: runtime.clone(), entity_id: entity_id.clone(), @@ -871,7 +946,22 @@ fn capability_skip_count_for_lockfile( } } } - Ok(skipped) + Ok(report) +} + +fn capability_skip_finding( + runtime: &RuntimeName, + entity_id: &EntityId, + entity: &LockfileEntity, + fallback: CapabilityFallback, +) -> CapabilitySkipFinding { + CapabilitySkipFinding { + runtime: runtime.clone(), + entity_id: entity_id.clone(), + entity_type: entity.entity_type, + fallback, + locations: entity.locations.clone(), + } } fn capability_fallback( @@ -887,6 +977,36 @@ fn capability_fallback( .unwrap_or(CapabilityFallback::Warn) } +fn capability_fallback_name(fallback: CapabilityFallback) -> &'static str { + match fallback { + CapabilityFallback::Skip => "skip", + CapabilityFallback::Warn => "warn", + CapabilityFallback::RenderAsDoc => "render-as-doc", + CapabilityFallback::Fail => "fail", + } +} + +fn capability_skip_finding_message(finding: &CapabilitySkipFinding) -> String { + let locations = if finding.locations.is_empty() { + "none".to_string() + } else { + finding + .locations + .iter() + .map(|(location, path)| format!("{}:{}", location.as_str(), path.display())) + .collect::>() + .join(",") + }; + format!( + "capability_skip: runtime={} entity={} type={} fallback={} locations={}", + finding.runtime.as_str(), + finding.entity_id.as_str(), + finding.entity_type.as_str(), + capability_fallback_name(finding.fallback), + locations + ) +} + fn build_sync_plan( repo_root: &Path, previous: Lockfile, @@ -966,11 +1086,12 @@ fn build_sync_plan( .or_else(|| previous_entry.and_then(|entry| entry.id_pin.clone())); let lockfile_entity = LockfileEntity { entity_type, - scope: if entity_type == EntityType::Instructions { - Some("root".to_string()) - } else { - None - }, + entity_schema: entity_schema_for_type(entity_type), + scope: views + .values() + .find_map(|view| view.scope.clone()) + .or_else(|| previous_entry.and_then(|entry| entry.scope.clone())) + .or_else(|| instruction_scope(&entity_id, entity_type)), locations, canonical_sha256: hash_entity_payload( entity_type, @@ -994,6 +1115,33 @@ fn build_sync_plan( id_pin, }; + let mut entity_overrides = previous + .overrides + .get(&entity_id) + .cloned() + .unwrap_or_default(); + let mut current_override_runtimes = BTreeSet::new(); + for view in views.values() { + if let Some(runtime) = runtime_override_location(&view.location)? { + current_override_runtimes.insert(runtime); + } + } + entity_overrides.retain(|runtime, _| current_override_runtimes.contains(runtime)); + for view in views.values() { + if let Some(runtime) = runtime_override_location(&view.location)? { + if view.runtime_overrides.is_empty() { + entity_overrides.remove(&runtime); + } else { + entity_overrides.insert(runtime, OverrideEntry(view.runtime_overrides.clone())); + } + } + } + if !entity_overrides.is_empty() { + lockfile + .overrides + .insert(entity_id.clone(), entity_overrides); + } + lockfile .entities .insert(entity_id.clone(), lockfile_entity.clone()); @@ -1032,11 +1180,13 @@ fn scan_entities( })?; let view = EntityView { entity_type: candidate.entity_type, + scope: None, location: location.clone(), relative_path: candidate.relative_path, lockfile_path: candidate.lockfile_path, canonical_contents, files, + runtime_overrides: BTreeMap::new(), native_hash, mtime, id_pin, @@ -1044,7 +1194,9 @@ fn scan_entities( raw_views.push((base_entity_id, view)); } for runtime in detected_adapter_runtimes(repo_root, adapters)? { - for imported in import_runtime_entities_hot(repo_root, &runtime, adapters)? { + let response = import_runtime_entities_hot(repo_root, &runtime, adapters)?; + validate_import_skips(&runtime, &response.skipped)?; + for imported in response.entities { let (base_entity_id, view) = entity_view_from_import(repo_root, &runtime, imported)?; raw_views.push((base_entity_id, view)); } @@ -1073,6 +1225,9 @@ fn scan_entities( }) .unwrap_or(base_entity_id); + if is_duplicate_shared_view(&entities, &entity_id, &view) { + continue; + } if entity_requires_collision_resolution(&entities, &entity_id, &view) { entity_id = resolve_collision(&entity_id, &occupied)?; } @@ -1089,12 +1244,38 @@ fn scan_entities( }) } +fn is_duplicate_shared_view( + entities: &BTreeMap>, + entity_id: &EntityId, + view: &EntityView, +) -> bool { + if view.location.as_str() != ".agents" { + return false; + } + entities + .get(entity_id) + .and_then(|views| views.get(&view.location)) + .is_some_and(|existing| { + existing.entity_type == view.entity_type + && existing.relative_path == view.relative_path + && existing.lockfile_path == view.lockfile_path + && existing.canonical_contents == view.canonical_contents + && existing.files == view.files + }) +} + fn detected_adapter_runtimes( repo_root: &Path, adapters: &dyn AdapterRegistry, ) -> Result> { let mut runtimes = Vec::new(); - for runtime in [runtime_name("claude")?, runtime_name("codex")?] { + for runtime in [ + runtime_name("claude")?, + runtime_name("codex")?, + runtime_name("copilot")?, + runtime_name("cursor")?, + runtime_name("gemini")?, + ] { let present = adapters.detect(&runtime, repo_root)?.present; if present { runtimes.push(runtime); @@ -1107,8 +1288,8 @@ fn import_runtime_entities_hot( repo_root: &Path, runtime: &RuntimeName, adapters: &dyn AdapterRegistry, -) -> Result> { - let response = adapters.import( +) -> Result { + adapters.import( runtime, repo_root, ImportRequest { @@ -1116,8 +1297,52 @@ fn import_runtime_entities_hot( runtime_dir: runtime_dir(repo_root, runtime), filter: None, }, - )?; - Ok(response.entities) + ) +} + +fn validate_import_skips( + runtime: &RuntimeName, + skipped: &[agentmesh_protocol::SkippedPath], +) -> Result<()> { + for skipped in skipped { + match runtime.as_str() { + "cursor" if skipped.path.starts_with(".cursor/rules") => { + return Err(PipelineError::EntityFormat { + path: skipped.path.clone(), + message: skipped.reason.clone(), + }); + } + "copilot" + if skipped.path == Path::new(".github/copilot-instructions.md") + || skipped.path.starts_with(".github/instructions") + || skipped.path.starts_with(".github/prompts") + || skipped.path.starts_with(".github/skills") + || skipped.path.starts_with(".github/agents") + || skipped.path.starts_with(".agents/skills") => + { + return Err(PipelineError::EntityFormat { + path: skipped.path.clone(), + message: skipped.reason.clone(), + }); + } + "gemini" + if skipped.path == Path::new("GEMINI.md") + || (skipped.path.file_name().and_then(|name| name.to_str()) + == Some("GEMINI.md") + && gemini_repo_relative_context_path(&skipped.path)) + || skipped.path.starts_with(".gemini/skills") + || skipped.path.starts_with(".gemini/commands") + || skipped.path.starts_with(".agents/skills") => + { + return Err(PipelineError::EntityFormat { + path: skipped.path.clone(), + message: skipped.reason.clone(), + }); + } + _ => {} + } + } + Ok(()) } fn entity_view_from_import( @@ -1131,9 +1356,23 @@ fn entity_view_from_import( let mut canonical_text = imported_primary_content(&imported)?; canonical_text = match imported.entity_type { EntityType::Instructions => strip_empty_frontmatter(&canonical_text).to_string(), - EntityType::Skill | EntityType::Subagent => { + EntityType::Skill + | EntityType::Subagent + | EntityType::Rule + | EntityType::Prompt + | EntityType::Command + if is_markdown_path(&imported.source_path) => + { crate::merge::canonicalize_markdown(&canonical_text)? } + EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Skill + | EntityType::Subagent => canonical_text, }; let mut files = files_from_imported_entity(&imported)?; if let Some(primary_key) = primary_file_key(imported.entity_type, &files) { @@ -1150,13 +1389,16 @@ fn entity_view_from_import( source, })?; let lockfile_path = lockfile_path_for_import(&location, &imported.source_path); + let runtime_overrides = runtime_overrides_for_import(runtime, &imported); let view = EntityView { entity_type: imported.entity_type, + scope: imported.scope, location, relative_path: imported.source_path, lockfile_path, canonical_contents, files, + runtime_overrides, native_hash, mtime, id_pin, @@ -1164,6 +1406,57 @@ fn entity_view_from_import( Ok((base_entity_id, view)) } +fn runtime_overrides_for_import( + runtime: &RuntimeName, + imported: &ImportedEntity, +) -> BTreeMap { + match runtime.as_str() { + "cursor" if imported.source_path.starts_with(".cursor/rules") => imported + .frontmatter + .iter() + .filter(|(key, _)| key.as_str() != "description") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "copilot" if imported.source_path.starts_with(".github/instructions") => imported + .frontmatter + .iter() + .filter(|(key, _)| key.as_str() != "description") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "copilot" if imported.source_path.starts_with(".github/prompts") => imported + .frontmatter + .iter() + .filter(|(key, _)| key.as_str() != "description") + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "copilot" if imported.source_path.starts_with(".github/skills") => imported + .frontmatter + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "name" | "description")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "copilot" if imported.source_path.starts_with(".github/agents") => imported + .frontmatter + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "name" | "description")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "gemini" if imported.source_path.starts_with(".gemini/skills") => imported + .frontmatter + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "name" | "description")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + "gemini" if imported.source_path.starts_with(".gemini/commands") => imported + .frontmatter + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "description" | "prompt")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + _ => BTreeMap::new(), + } +} + fn strip_empty_frontmatter(contents: &str) -> &str { contents .strip_prefix("---\n{}\n---\n") @@ -1171,10 +1464,24 @@ fn strip_empty_frontmatter(contents: &str) -> &str { .unwrap_or(contents) } -fn location_for_import(runtime: &RuntimeName, _imported: &ImportedEntity) -> Result { +fn location_for_import(runtime: &RuntimeName, imported: &ImportedEntity) -> Result { + if imported.source_path.starts_with(".agents") { + return location_key(".agents").map_err(Into::into); + } location_key(&format!(".{}", runtime.as_str())).map_err(Into::into) } +fn runtime_override_location(location: &LocationKey) -> Result> { + match location.as_str() { + ".ai" | ".agents" => Ok(None), + value => value + .strip_prefix('.') + .map(RuntimeName::new) + .transpose() + .map_err(Into::into), + } +} + fn lockfile_path_for_import(location: &LocationKey, source_path: &Path) -> PathBuf { if !source_path.starts_with(location.as_str()) { return PathBuf::from("..").join(source_path); @@ -1247,11 +1554,19 @@ fn primary_file_key( EntityType::Instructions => files .get_key_value(Path::new("AGENTS.md")) .or_else(|| files.get_key_value(Path::new("CLAUDE.md"))) - .map(|(path, _)| path.clone()), + .or_else(|| files.get_key_value(Path::new("GEMINI.md"))) + .map(|(path, _)| path.clone()) + .or_else(|| files.keys().next().cloned()), EntityType::Skill => files .get_key_value(Path::new("SKILL.md")) .map(|(path, _)| path.clone()), - EntityType::Subagent => files.keys().next().cloned(), + EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Subagent => files.keys().next().cloned(), } } @@ -1321,7 +1636,10 @@ fn detect_previous_rename( if previous_path == &view.lockfile_path { continue; } - let previous_absolute = path_from_lockfile(repo_root, &view.location, previous_path); + let Ok(previous_absolute) = path_from_lockfile(repo_root, &view.location, previous_path) + else { + continue; + }; if previous_absolute.exists() { continue; } @@ -1372,14 +1690,15 @@ fn resolve_canonical_contents( canonical_instructions: Option, preserve_conflicts: bool, ) -> Result { - if entity_type == EntityType::Instructions && previous_entry.is_none() { - if let Some(preferred) = preferred_instruction_view(views, canonical_instructions) { - return Ok(CanonicalDecision { - contents: preferred.canonical_contents.clone(), - files: preferred.files.clone(), - pending_conflict_resolution: false, - }); - } + if entity_type == EntityType::Instructions + && previous_entry.is_none() + && let Some(preferred) = preferred_instruction_view(views, canonical_instructions) + { + return Ok(CanonicalDecision { + contents: preferred.canonical_contents.clone(), + files: preferred.files.clone(), + pending_conflict_resolution: false, + }); } if all_canonical_payloads_equal(views) { @@ -1420,11 +1739,12 @@ fn resolve_canonical_contents( }); } - if entity_type != EntityType::Instructions && changed.iter().all(|view| is_markdown_view(view)) + if (!entity_type.is_configuration_only() || entity_type == EntityType::Command) + && entity_type != EntityType::Instructions + && changed.iter().all(|view| is_markdown_view(view)) + && let Some(ancestor) = unchanged_ancestor_view(views, previous_entry) { - if let Some(ancestor) = unchanged_ancestor_view(views, previous_entry) { - return merge_changed_views(cache, entity_id, ancestor, &changed, preserve_conflicts); - } + return merge_changed_views(cache, entity_id, ancestor, &changed, preserve_conflicts); } tiebreak_changed_views(cache, entity_id, &changed, preserve_conflicts) @@ -1600,15 +1920,14 @@ fn canonical_text(view: &EntityView) -> Result { } fn is_markdown_view(view: &EntityView) -> bool { - view.relative_path - .extension() - .and_then(|extension| extension.to_str()) - == Some("md") - || view - .relative_path - .file_name() - .and_then(|file_name| file_name.to_str()) - == Some("SKILL.md") + is_markdown_path(&view.relative_path) +} + +fn is_markdown_path(path: &Path) -> bool { + matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("md" | "mdc") + ) || path.file_name().and_then(|file_name| file_name.to_str()) == Some("SKILL.md") } fn runtime_for_location(location: &LocationKey) -> Result { @@ -1617,8 +1936,17 @@ fn runtime_for_location(location: &LocationKey) -> Result { fn slug_from_lockfile_path(entity_type: EntityType, lockfile_path: &Path) -> Option { let relative_path = match entity_type { + EntityType::Instructions if lockfile_path.starts_with("instructions") => { + PathBuf::from(".ai").join(lockfile_path) + } EntityType::Instructions => PathBuf::from("AGENTS.md"), EntityType::Skill => PathBuf::from(".ai").join(lockfile_path), + EntityType::Rule => PathBuf::from(".ai").join(lockfile_path), + EntityType::Prompt => PathBuf::from(".ai").join(lockfile_path), + EntityType::Command => PathBuf::from(".ai").join(lockfile_path), + EntityType::Hook => PathBuf::from(".ai").join(lockfile_path), + EntityType::McpBinding => PathBuf::from(".ai").join(lockfile_path), + EntityType::PermissionPolicy => PathBuf::from(".ai").join(lockfile_path), EntityType::Subagent if lockfile_path.starts_with("subagents") => { PathBuf::from(".ai").join(lockfile_path) } @@ -1636,6 +1964,15 @@ struct EntityCandidate { lockfile_path: PathBuf, } +struct EntityFileTreeScan<'a> { + root: &'a Path, + location_key: LocationKey, + relative_root: &'a Path, + lockfile_root: &'a Path, + entity_type: EntityType, + extensions: &'a [&'a str], +} + fn entity_candidates(repo_root: &Path) -> Result> { let mut candidates = Vec::new(); if is_regular_file_path(&repo_root.join("AGENTS.md"))? { @@ -1647,7 +1984,77 @@ fn entity_candidates(repo_root: &Path) -> Result> { }); } + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/instructions", + "instructions", + EntityType::Instructions, + &["md"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/rules", + "rules", + EntityType::Rule, + &["md", "mdc"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/prompts", + "prompts", + EntityType::Prompt, + &["md"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/commands", + "commands", + EntityType::Command, + &["md", "json", "toml"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/hooks", + "hooks", + EntityType::Hook, + &["json", "toml", "yaml", "yml"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/mcp-bindings", + "mcp-bindings", + EntityType::McpBinding, + &["json", "toml", "yaml", "yml"], + &mut candidates, + )?; + scan_entity_file_tree( + repo_root, + ".ai", + ".ai/permission-policies", + "permission-policies", + EntityType::PermissionPolicy, + &["json", "toml", "yaml", "yml"], + &mut candidates, + )?; scan_skill_dir(repo_root, ".ai", ".ai/skills", "skills", &mut candidates)?; + scan_skill_dir( + repo_root, + ".agents", + ".agents/skills", + "skills", + &mut candidates, + )?; scan_subagent_dir( repo_root, ".ai", @@ -1660,6 +2067,67 @@ fn entity_candidates(repo_root: &Path) -> Result> { Ok(candidates) } +fn scan_entity_file_tree( + repo_root: &Path, + location: &str, + relative_dir: &str, + lockfile_root: &str, + entity_type: EntityType, + extensions: &[&str], + candidates: &mut Vec, +) -> Result<()> { + let root = repo_root.join(relative_dir); + if !is_regular_dir_path(&root)? { + return Ok(()); + } + let scan = EntityFileTreeScan { + root: &root, + location_key: location_key(location)?, + relative_root: Path::new(relative_dir), + lockfile_root: Path::new(lockfile_root), + entity_type, + extensions, + }; + scan_entity_file_tree_inner(&scan, &root, candidates) +} + +fn scan_entity_file_tree_inner( + scan: &EntityFileTreeScan<'_>, + dir: &Path, + candidates: &mut Vec, +) -> Result<()> { + for entry in read_dir_sorted(dir)? { + if is_regular_dir_path(&entry)? { + scan_entity_file_tree_inner(scan, &entry, candidates)?; + continue; + } + if !is_regular_file_path(&entry)? { + continue; + } + let Some(extension) = entry.extension().and_then(|value| value.to_str()) else { + continue; + }; + if !scan.extensions.contains(&extension) { + continue; + } + let relative_file = + entry + .strip_prefix(scan.root) + .map_err(|source| PipelineError::EntityFormat { + path: entry.clone(), + message: source.to_string(), + })?; + candidates.push(EntityCandidate { + entity_type: scan.entity_type, + location_key: scan.location_key.clone(), + relative_path: scan.relative_root.join(relative_file), + lockfile_path: scan.lockfile_root.join(relative_file), + }); + } + + Ok(()) +} + fn scan_skill_dir( repo_root: &Path, location: &str, @@ -1791,7 +2259,14 @@ fn entity_files_for_candidate( files.insert(PathBuf::from("SKILL.md"), primary_contents); Ok(files) } - EntityType::Instructions | EntityType::Subagent => { + EntityType::Instructions + | EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Subagent => { let key = candidate .relative_path .file_name() @@ -1907,7 +2382,16 @@ fn canonicalize_for_candidate( ) -> Result> { match entity_type { EntityType::Instructions => Ok(contents.as_bytes().to_vec()), - EntityType::Skill => Ok(crate::merge::canonicalize_markdown(contents)?.into_bytes()), + EntityType::Skill | EntityType::Rule | EntityType::Prompt => { + Ok(crate::merge::canonicalize_markdown(contents)?.into_bytes()) + } + EntityType::Command if is_markdown_path(relative_path) => { + Ok(crate::merge::canonicalize_markdown(contents)?.into_bytes()) + } + EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy => Ok(contents.as_bytes().to_vec()), EntityType::Subagent if relative_path.extension().and_then(|value| value.to_str()) == Some("toml") => { @@ -1964,25 +2448,16 @@ fn canonicalize_codex_toml_subagent(contents: &str, relative_path: &Path) -> Res } fn split_canonical_markdown(contents: &str) -> Result<(serde_norway::Mapping, String)> { - let Some(rest) = contents.strip_prefix("---\n") else { - return Ok((serde_norway::Mapping::new(), contents.to_string())); - }; - let Some(end) = rest.find("\n---\n") else { - return Ok((serde_norway::Mapping::new(), contents.to_string())); - }; - let frontmatter = &rest[..end]; - let body = rest[end + "\n---\n".len()..].to_string(); - let mapping = crate::merge::parse_frontmatter_mapping(frontmatter).map_err(|source| { - PipelineError::EntityFormat { + let parts = + crate::merge::split_markdown(contents).map_err(|source| PipelineError::EntityFormat { path: PathBuf::from(""), message: source.to_string(), - } - })?; - Ok((mapping, body)) + })?; + Ok((parts.frontmatter, parts.body)) } fn choose_canonical_view(views: &BTreeMap) -> Option<&EntityView> { - for key in [".ai", ".claude", ".codex"] { + for key in [".ai", ".agents", ".claude", ".codex", ".copilot", ".cursor"] { let key = match location_key(key) { Ok(key) => key, Err(_) => continue, @@ -1991,7 +2466,7 @@ fn choose_canonical_view(views: &BTreeMap) -> Option<&E return Some(view); } } - None + views.values().next() } fn planned_writes( @@ -2003,14 +2478,27 @@ fn planned_writes( ) -> Result> { let mut writes = Vec::new(); match entity_type { - EntityType::Instructions | EntityType::Subagent => { - let contents = canonical_files.values().next().cloned().unwrap_or_default(); + EntityType::Instructions + | EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Subagent => { + let (entity_file_path, contents) = + canonical_files.into_iter().next().unwrap_or_else(|| { + ( + primary_entity_file_path_for_slug(entity_type, slug), + Vec::new(), + ) + }); writes.push(planned_write( repo_root, ".ai", - canonical_lockfile_path(entity_type, slug), - canonical_repo_path(entity_type, slug), - primary_entity_file_path(entity_type), + canonical_lockfile_path_for_file(entity_type, slug, &entity_file_path), + canonical_repo_path_for_file(entity_type, slug, &entity_file_path), + entity_file_path, contents, true, )?); @@ -2059,11 +2547,30 @@ fn planned_write( fn primary_entity_file_path(entity_type: EntityType) -> PathBuf { match entity_type { EntityType::Instructions => PathBuf::from("AGENTS.md"), + EntityType::Rule => PathBuf::from("rule.md"), + EntityType::Prompt => PathBuf::from("prompt.md"), + EntityType::Command => PathBuf::from("command.md"), + EntityType::Hook => PathBuf::from("hook.json"), + EntityType::McpBinding => PathBuf::from("mcp-binding.json"), + EntityType::PermissionPolicy => PathBuf::from("permission-policy.json"), EntityType::Skill => PathBuf::from("SKILL.md"), EntityType::Subagent => PathBuf::from("subagent.md"), } } +fn primary_entity_file_path_for_slug(entity_type: EntityType, slug: &str) -> PathBuf { + match entity_type { + EntityType::Instructions if slug != "root" => scoped_instruction_file_name(slug), + EntityType::Rule => PathBuf::from(format!("{slug}.md")), + EntityType::Prompt => PathBuf::from(format!("{slug}.md")), + EntityType::Command => command_file_name(slug, "md"), + EntityType::Hook => PathBuf::from(format!("{slug}.json")), + EntityType::McpBinding => PathBuf::from(format!("{slug}.json")), + EntityType::PermissionPolicy => PathBuf::from(format!("{slug}.json")), + _ => primary_entity_file_path(entity_type), + } +} + fn planned_file_changes(plan: &SyncPlan) -> Result> { let mut changed = BTreeSet::new(); for entity in &plan.entities { @@ -2279,16 +2786,17 @@ fn affected_entity_ids( .entities .iter() .filter(|(_, entity)| { - if let Some(record_type) = record.entity_type { - if entity.entity_type != record_type { - return false; - } + if let Some(record_type) = record.entity_type + && entity.entity_type != record_type + { + return false; } entity.locations.iter().any(|(location, lockfile_path)| { - let relative = relative_to( - repo_root, - &path_from_lockfile(repo_root, location, lockfile_path), - ); + let Ok(absolute_path) = path_from_lockfile(repo_root, location, lockfile_path) + else { + return false; + }; + let relative = relative_to(repo_root, &absolute_path); let root = entity_root_from_location(entity.entity_type, &relative); record.changed_paths.iter().any(|changed| { changed == &relative @@ -2353,10 +2861,15 @@ fn emit_entity_to_runtime( if !adapter.entities.contains(&entity.entity_type) { return match capability_fallback(config, runtime, entity.entity_type) { CapabilityFallback::Skip => Ok(EmitOutcome::default()), - CapabilityFallback::Warn | CapabilityFallback::RenderAsDoc => Ok(EmitOutcome { + CapabilityFallback::Warn => Ok(EmitOutcome { files_written: 0, capability_skipped: 1, }), + CapabilityFallback::RenderAsDoc => Err(PipelineError::RenderAsDocUnsupported { + runtime: runtime.clone(), + entity_id: entity_id.clone(), + entity_type: entity.entity_type, + }), CapabilityFallback::Fail => Err(PipelineError::CapabilityMismatch { runtime: runtime.clone(), entity_id: entity_id.clone(), @@ -2423,7 +2936,7 @@ fn build_emit_entity( entity_id: entity_id.clone(), }); }; - let absolute_path = path_from_lockfile(repo_root, &canonical_location, canonical_path); + let absolute_path = path_from_lockfile(repo_root, &canonical_location, canonical_path)?; let files = canonical_entity_files_for_emit(repo_root, entity.entity_type, &absolute_path)?; let primary_key = primary_file_key(entity.entity_type, &files) .unwrap_or_else(|| emit_file_key(entity.entity_type, canonical_path)); @@ -2449,11 +2962,25 @@ fn build_emit_entity( .and_then(|runtime_overrides| runtime_overrides.get(runtime)) .map(|entry| entry.0.clone()) .unwrap_or_default(); + let runtime_location = location_key(&format!(".{}", runtime.as_str()))?; + let source_path = entity + .locations + .get(&runtime_location) + .map(|path| workspace_path_from_lockfile(&runtime_location, path)) + .transpose()? + .or_else(|| { + entity + .locations + .get(&location_key(".agents").ok()?) + .filter(|_| entity.entity_type == EntityType::Skill) + .map(|path| PathBuf::from(".agents").join(path)) + }); Ok(EmitEntity { id: entity_id.as_str().to_string(), entity_type: entity.entity_type, scope: entity.scope.clone(), + source_path, files: emit_files, frontmatter: yaml_frontmatter_to_json_map(frontmatter)?, overrides, @@ -2491,10 +3018,16 @@ fn emit_file_key(entity_type: EntityType, canonical_path: &Path) -> PathBuf { match entity_type { EntityType::Instructions => PathBuf::from("AGENTS.md"), EntityType::Skill => PathBuf::from("SKILL.md"), - EntityType::Subagent => canonical_path + EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Subagent => canonical_path .file_name() .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from("subagent.md")), + .unwrap_or_else(|| primary_entity_file_path(entity_type)), } } @@ -2527,7 +3060,6 @@ fn update_emitted_native_hashes( if files_written.is_empty() { return Ok(()); } - let location = location_key(&format!(".{}", runtime.as_str()))?; let entity_type = lockfile .entities .get(entity_id) @@ -2538,16 +3070,42 @@ fn update_emitted_native_hashes( let primary_path = primary_written_path(entity_type, files_written) .cloned() .unwrap_or_else(|| files_written[0].clone()); + let location = if primary_path.starts_with(".agents") { + location_key(".agents")? + } else { + location_key(&format!(".{}", runtime.as_str()))? + }; let hash = emitted_runtime_hash(repo_root, entity_type, &primary_path)?; + if !lockfile.entities.contains_key(entity_id) { + return Err(PipelineError::EntityNotFound { + entity_id: entity_id.clone(), + }); + } + let updated_path = if location.as_str() == ".agents" { + primary_path + .strip_prefix(".agents") + .map(Path::to_path_buf) + .unwrap_or_else(|_| primary_path.clone()) + } else { + lockfile_path_from_workspace(runtime, &primary_path) + }; + for sibling in lockfile.entities.values_mut() { + if sibling + .locations + .get(&location) + .is_some_and(|path| path == &updated_path) + { + sibling + .emitted_native_sha256 + .insert(location.clone(), hash.clone()); + } + } let Some(entity) = lockfile.entities.get_mut(entity_id) else { return Err(PipelineError::EntityNotFound { entity_id: entity_id.clone(), }); }; - entity.locations.insert( - location.clone(), - lockfile_path_from_workspace(runtime, &primary_path), - ); + entity.locations.insert(location.clone(), updated_path); entity.emitted_native_sha256.insert(location, hash); Ok(()) } @@ -2561,7 +3119,13 @@ fn primary_written_path(entity_type: EntityType, files_written: &[PathBuf]) -> O EntityType::Skill => files_written .iter() .find(|path| path.file_name().and_then(|name| name.to_str()) == Some("SKILL.md")), - EntityType::Subagent => files_written.first(), + EntityType::Rule + | EntityType::Prompt + | EntityType::Command + | EntityType::Hook + | EntityType::McpBinding + | EntityType::PermissionPolicy + | EntityType::Subagent => files_written.first(), } } @@ -2592,6 +3156,13 @@ fn lockfile_path_from_workspace(runtime: &RuntimeName, relative_path: &Path) -> PathBuf::from("..").join(relative_path) } +fn workspace_path_from_lockfile(location: &LocationKey, lockfile_path: &Path) -> Result { + if let Some(root_relative) = repo_relative_lockfile_path(location, lockfile_path)? { + return Ok(root_relative); + } + Ok(PathBuf::from(location.as_str()).join(lockfile_path)) +} + fn call_adapter_subprocess( repo_root: &Path, runtime: &RuntimeName, @@ -2753,6 +3324,18 @@ fn with_bundled_adapter( let mut adapter = agentmesh_adapter_codex::CodexAdapter; call(&mut adapter).map_err(|source| adapter_error(runtime, source)) } + "copilot" => { + let mut adapter = agentmesh_adapter_copilot::CopilotAdapter; + call(&mut adapter).map_err(|source| adapter_error(runtime, source)) + } + "cursor" => { + let mut adapter = agentmesh_adapter_cursor::CursorAdapter; + call(&mut adapter).map_err(|source| adapter_error(runtime, source)) + } + "gemini" => { + let mut adapter = agentmesh_adapter_gemini::GeminiAdapter; + call(&mut adapter).map_err(|source| adapter_error(runtime, source)) + } _ => Err(PipelineError::Adapter { runtime: runtime.clone(), message: "unknown bundled adapter".to_string(), @@ -2821,7 +3404,7 @@ fn restore_with_cache( path: preserved.clone(), source, })?; - let absolute_path = path_from_lockfile(repo_root, &location_key(".ai")?, &canonical_path); + let absolute_path = path_from_lockfile(repo_root, &location_key(".ai")?, &canonical_path)?; if dry_run { return Ok(RestoreSummary { changed: false, @@ -2999,6 +3582,15 @@ fn detect_runtime_markers( .detect(&runtime_name("claude")?, repo_root)? .present, codex: adapters.detect(&runtime_name("codex")?, repo_root)?.present, + copilot: adapters + .detect(&runtime_name("copilot")?, repo_root)? + .present, + cursor: adapters + .detect(&runtime_name("cursor")?, repo_root)? + .present, + gemini: adapters + .detect(&runtime_name("gemini")?, repo_root)? + .present, }) } @@ -3007,20 +3599,34 @@ fn adapter_declarations( ) -> Result> { let mut adapters = BTreeMap::new(); if markers.claude { - adapters.insert(RuntimeName::new("claude")?, bundled_adapter_declaration()); + adapters.insert(RuntimeName::new("claude")?, claude_adapter_declaration()); } if markers.codex { - adapters.insert(RuntimeName::new("codex")?, bundled_adapter_declaration()); + adapters.insert(RuntimeName::new("codex")?, codex_adapter_declaration()); + } + if markers.copilot { + adapters.insert(RuntimeName::new("copilot")?, copilot_adapter_declaration()); + } + if markers.cursor { + adapters.insert(RuntimeName::new("cursor")?, cursor_adapter_declaration()); + } + if markers.gemini { + adapters.insert(RuntimeName::new("gemini")?, gemini_adapter_declaration()); } Ok(adapters) } -fn bundled_adapter_declaration() -> AdapterDeclaration { +fn claude_adapter_declaration() -> AdapterDeclaration { AdapterDeclaration { mode: AdapterMode::Bundled, - protocol_version: 1, + protocol_version: PROTOCOL_VERSION, entities: vec![ EntityType::Instructions, + EntityType::Rule, + EntityType::Command, + EntityType::Hook, + EntityType::McpBinding, + EntityType::PermissionPolicy, EntityType::Skill, EntityType::Subagent, ], @@ -3028,25 +3634,154 @@ fn bundled_adapter_declaration() -> AdapterDeclaration { } } -fn entity_slug(entity_id: &EntityId) -> &str { - entity_id - .as_str() - .split_once(':') - .map(|(_, slug)| slug) - .unwrap_or("root") -} - -fn canonical_lockfile_path(entity_type: EntityType, slug: &str) -> PathBuf { - match entity_type { - EntityType::Instructions => PathBuf::from("../AGENTS.md"), - EntityType::Skill => PathBuf::from("skills").join(slug).join("SKILL.md"), - EntityType::Subagent => PathBuf::from("subagents").join(format!("{slug}.md")), - } +fn codex_adapter_declaration() -> AdapterDeclaration { + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: PROTOCOL_VERSION, + entities: vec![ + EntityType::Instructions, + EntityType::Hook, + EntityType::McpBinding, + EntityType::PermissionPolicy, + EntityType::Skill, + EntityType::Subagent, + ], + hooks: vec![HookKind::PostToolUse], + } +} + +fn cursor_adapter_declaration() -> AdapterDeclaration { + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: PROTOCOL_VERSION, + entities: vec![EntityType::Instructions, EntityType::Rule], + hooks: Vec::new(), + } +} + +fn copilot_adapter_declaration() -> AdapterDeclaration { + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: PROTOCOL_VERSION, + entities: vec![ + EntityType::Instructions, + EntityType::Prompt, + EntityType::Skill, + EntityType::Subagent, + ], + hooks: Vec::new(), + } +} + +fn gemini_adapter_declaration() -> AdapterDeclaration { + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: PROTOCOL_VERSION, + entities: vec![ + EntityType::Instructions, + EntityType::Command, + EntityType::Skill, + ], + hooks: Vec::new(), + } +} + +fn entity_slug(entity_id: &EntityId) -> &str { + entity_id + .as_str() + .split_once(':') + .map(|(_, slug)| slug) + .unwrap_or("root") +} + +fn instruction_scope(entity_id: &EntityId, entity_type: EntityType) -> Option { + if entity_type != EntityType::Instructions { + return None; + } + match entity_id.as_str() { + "instructions:root" => Some("root".to_string()), + value => value + .strip_prefix("instructions:scoped:") + .map(std::string::ToString::to_string), + } +} + +fn canonical_lockfile_path_for_file( + entity_type: EntityType, + slug: &str, + entity_file_path: &Path, +) -> PathBuf { + match entity_type { + EntityType::Instructions if slug == "root" => PathBuf::from("../AGENTS.md"), + EntityType::Instructions => PathBuf::from("instructions").join(file_name_for_slug( + scoped_slug(slug), + entity_file_path, + "md", + )), + EntityType::Rule => { + PathBuf::from("rules").join(file_name_for_slug(slug, entity_file_path, "md")) + } + EntityType::Prompt => { + PathBuf::from("prompts").join(file_name_for_slug(slug, entity_file_path, "md")) + } + EntityType::Command => PathBuf::from("commands").join(command_file_name( + slug, + &extension_for_file(entity_file_path, "md"), + )), + EntityType::Hook => { + PathBuf::from("hooks").join(file_name_for_slug(slug, entity_file_path, "json")) + } + EntityType::McpBinding => { + PathBuf::from("mcp-bindings").join(file_name_for_slug(slug, entity_file_path, "json")) + } + EntityType::PermissionPolicy => PathBuf::from("permission-policies") + .join(file_name_for_slug(slug, entity_file_path, "json")), + EntityType::Skill => PathBuf::from("skills").join(slug).join("SKILL.md"), + EntityType::Subagent => PathBuf::from("subagents").join(format!("{slug}.md")), + } } -fn canonical_repo_path(entity_type: EntityType, slug: &str) -> PathBuf { +fn canonical_repo_path_for_file( + entity_type: EntityType, + slug: &str, + entity_file_path: &Path, +) -> PathBuf { match entity_type { - EntityType::Instructions => PathBuf::from("AGENTS.md"), + EntityType::Instructions if slug == "root" => PathBuf::from("AGENTS.md"), + EntityType::Instructions => { + PathBuf::from(".ai") + .join("instructions") + .join(file_name_for_slug( + scoped_slug(slug), + entity_file_path, + "md", + )) + } + EntityType::Rule => PathBuf::from(".ai").join("rules").join(file_name_for_slug( + slug, + entity_file_path, + "md", + )), + EntityType::Prompt => PathBuf::from(".ai") + .join("prompts") + .join(file_name_for_slug(slug, entity_file_path, "md")), + EntityType::Command => PathBuf::from(".ai") + .join("commands") + .join(command_file_name( + slug, + &extension_for_file(entity_file_path, "md"), + )), + EntityType::Hook => PathBuf::from(".ai").join("hooks").join(file_name_for_slug( + slug, + entity_file_path, + "json", + )), + EntityType::McpBinding => PathBuf::from(".ai") + .join("mcp-bindings") + .join(file_name_for_slug(slug, entity_file_path, "json")), + EntityType::PermissionPolicy => PathBuf::from(".ai") + .join("permission-policies") + .join(file_name_for_slug(slug, entity_file_path, "json")), EntityType::Skill => PathBuf::from(".ai") .join("skills") .join(slug) @@ -3057,12 +3792,167 @@ fn canonical_repo_path(entity_type: EntityType, slug: &str) -> PathBuf { } } -fn path_from_lockfile(repo_root: &Path, location: &LocationKey, lockfile_path: &Path) -> PathBuf { - if let Ok(root_relative) = lockfile_path.strip_prefix("..") { - return repo_root.join(root_relative); +fn scoped_instruction_file_name(slug: &str) -> PathBuf { + PathBuf::from(format!("{}.md", scoped_slug(slug))) +} + +fn scoped_slug(slug: &str) -> &str { + slug.strip_prefix("scoped:").unwrap_or(slug) +} + +fn file_name_for_slug(slug: &str, entity_file_path: &Path, default_extension: &str) -> PathBuf { + PathBuf::from(format!( + "{}.{}", + slug, + extension_for_file(entity_file_path, default_extension) + )) +} + +fn extension_for_file(entity_file_path: &Path, default_extension: &str) -> String { + entity_file_path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or(default_extension) + .to_string() +} + +fn command_file_name(slug: &str, extension: &str) -> PathBuf { + let mut parts = slug.split(':').peekable(); + let mut path = PathBuf::new(); + while let Some(part) = parts.next() { + if parts.peek().is_some() { + path.push(part); + } else { + path.push(format!("{part}.{extension}")); + } + } + path +} + +fn path_from_lockfile( + repo_root: &Path, + location: &LocationKey, + lockfile_path: &Path, +) -> Result { + if let Some(root_relative) = repo_relative_lockfile_path(location, lockfile_path)? { + return Ok(repo_root.join(root_relative)); + } + + Ok(repo_root.join(location.as_str()).join(lockfile_path)) +} + +fn repo_relative_lockfile_path( + location: &LocationKey, + lockfile_path: &Path, +) -> Result> { + if lockfile_path.as_os_str().is_empty() || lockfile_path.is_absolute() { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe lockfile path".to_string(), + }); + } + + let mut components = lockfile_path.components(); + let Some(first) = components.next() else { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe lockfile path".to_string(), + }); + }; + + if first == Component::ParentDir { + let mut root_relative = PathBuf::new(); + for component in components { + let Component::Normal(part) = component else { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe repo-relative lockfile path".to_string(), + }); + }; + root_relative.push(part); + } + if root_relative.as_os_str().is_empty() { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe repo-relative lockfile path".to_string(), + }); + } + if !repo_relative_path_allowed(location, &root_relative) { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "repo-relative lockfile path is not allowed for this location".to_string(), + }); + } + return Ok(Some(root_relative)); + } + + if !matches!(first, Component::Normal(_)) { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe lockfile path".to_string(), + }); + } + for component in components { + if !matches!(component, Component::Normal(_)) { + return Err(PipelineError::EntityFormat { + path: lockfile_path.to_path_buf(), + message: "unsafe lockfile path".to_string(), + }); + } + } + Ok(None) +} + +fn repo_relative_path_allowed(location: &LocationKey, root_relative: &Path) -> bool { + match location.as_str() { + ".ai" => root_relative == Path::new("AGENTS.md"), + ".claude" => { + root_relative == Path::new("CLAUDE.md") || root_relative == Path::new(".mcp.json") + } + ".codex" => codex_repo_relative_instruction_path(root_relative), + ".gemini" => gemini_repo_relative_context_path(root_relative), + ".copilot" => { + root_relative == Path::new(".github/copilot-instructions.md") + || root_relative.starts_with(".github/instructions") + || root_relative.starts_with(".github/prompts") + || root_relative.starts_with(".github/skills") + || root_relative.starts_with(".github/agents") + } + _ => false, + } +} + +fn gemini_repo_relative_context_path(root_relative: &Path) -> bool { + if root_relative == Path::new("GEMINI.md") { + return true; + } + if root_relative.file_name().and_then(|name| name.to_str()) != Some("GEMINI.md") { + return false; + } + safe_nested_repo_root(root_relative) +} + +fn codex_repo_relative_instruction_path(root_relative: &Path) -> bool { + if root_relative == Path::new("AGENTS.md") { + return true; + } + if root_relative.file_name().and_then(|name| name.to_str()) != Some("AGENTS.md") { + return false; } + safe_nested_repo_root(root_relative) +} - repo_root.join(location.as_str()).join(lockfile_path) +fn safe_nested_repo_root(root_relative: &Path) -> bool { + let Some(parent) = root_relative.parent() else { + return false; + }; + parent.components().all(|component| { + let Component::Normal(part) = component else { + return false; + }; + part.to_str() + .is_some_and(|part| !part.starts_with('.') && part != "target") + }) } fn read_text_file(path: &Path) -> Result { @@ -3172,23 +4062,38 @@ fn runtime_name(value: &str) -> Result { } fn runtime_dir(repo_root: &Path, runtime: &RuntimeName) -> PathBuf { + if runtime.as_str() == "copilot" { + return repo_root.join(".github"); + } repo_root.join(format!(".{}", runtime.as_str())) } #[cfg(test)] mod tests { + use std::cell::RefCell; + use std::collections::BTreeMap; use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{Duration, SystemTime}; - use super::{PlanOptions, capability_skip_count_for_lockfile}; + use super::{ + CapabilityReportMode, PipelineError, PlanOptions, capability_skip_count_for_lockfile, + capability_skip_report_for_lockfile, path_from_lockfile, + }; + use crate::config::{AgentmeshConfig, CapabilityFallback}; use crate::lockfile::{ AdapterDeclaration, AdapterMode, HookKind, Lockfile, LockfileEntity, OverrideEntry, - read_lockfile, write_lockfile, + entity_schema_for_type, read_lockfile, write_lockfile, }; use crate::merge::preserve_losing_version; use crate::pending_queue::PendingQueue; use crate::state::{CacheLayout, PendingAction, PendingSyncRecord}; use crate::types::{EntityId, Hash, LocationKey, RuntimeName}; use crate::{EntityType, SyncOptions, ack}; + use agentmesh_protocol::{ + DetectResponse, EmitRequest, EmitResponse, EntityFile, ImportRequest, ImportResponse, + ImportedEntity, + }; use proptest::prelude::*; fn run_sync( @@ -3291,13 +4196,14 @@ mod tests { }; insta::assert_snapshot!(yaml, @r" version: 1 -schema: 1 +schema: 2 "); } fn entity_entry(entity_type: EntityType, canonical_hash: Hash) -> LockfileEntity { LockfileEntity { entity_type, + entity_schema: entity_schema_for_type(entity_type), scope: if entity_type == EntityType::Instructions { Some("root".to_string()) } else { @@ -3320,6 +4226,104 @@ schema: 1 } } + #[derive(Default)] + struct TestAdapterRegistry { + imports: Vec, + emitted: RefCell>, + present: bool, + fail_emit: bool, + } + + impl super::AdapterRegistry for TestAdapterRegistry { + fn detect( + &self, + _runtime: &RuntimeName, + _repo_root: &std::path::Path, + ) -> super::Result { + Ok(DetectResponse { + present: self.present, + version: None, + files: Vec::new(), + }) + } + + fn import( + &self, + _runtime: &RuntimeName, + _repo_root: &std::path::Path, + _request: ImportRequest, + ) -> super::Result { + Ok(ImportResponse { + entities: self.imports.clone(), + skipped: Vec::new(), + }) + } + + fn emit( + &self, + runtime: &RuntimeName, + _repo_root: &std::path::Path, + request: EmitRequest, + ) -> super::Result { + self.emitted.borrow_mut().push(request); + if self.fail_emit { + return Err(PipelineError::Adapter { + runtime: runtime.clone(), + message: "forced emit failure".to_string(), + }); + } + Ok(EmitResponse { + files_written: Vec::new(), + skipped: Vec::new(), + partial_fidelity: Vec::new(), + }) + } + } + + fn test_view( + entity_type: EntityType, + location: &str, + relative_path: &str, + contents: &str, + native_hash: &str, + mtime_seconds: u64, + ) -> super::EntityView { + let path = PathBuf::from(relative_path); + let file_key = path + .file_name() + .map(PathBuf::from) + .unwrap_or_else(|| super::primary_entity_file_path(entity_type)); + super::EntityView { + entity_type, + scope: None, + location: location_key(location), + relative_path: path.clone(), + lockfile_path: path, + canonical_contents: contents.as_bytes().to_vec(), + files: BTreeMap::from([(file_key, contents.as_bytes().to_vec())]), + runtime_overrides: BTreeMap::new(), + native_hash: hash(native_hash), + mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(mtime_seconds), + id_pin: None, + } + } + + fn entity_file_text(file: &EntityFile) -> String { + let bytes = match file.decode_bytes() { + Ok(bytes) => bytes, + Err(error) => panic!("entity file should decode: {error}"), + }; + match String::from_utf8(bytes) { + Ok(text) => text, + Err(error) => panic!("entity file should be UTF-8: {error}"), + } + } + + #[cfg(not(target_os = "windows"))] + fn contains_display_path(value: &str, relative_path: &str) -> bool { + value.contains(relative_path) || value.contains(&relative_path.replace('/', "\\")) + } + #[test] fn init_style_sync_imports_and_emits_skills() { let temp = match tempfile::tempdir() { @@ -3653,31 +4657,27 @@ schema: 1 } #[test] - fn no_drift_after_apply() { + fn sync_check_reports_capability_skips_for_mixed_runtime_support() { let temp = match tempfile::tempdir() { Ok(temp) => temp, Err(error) => panic!("tempdir should be available: {error}"), }; let repo = temp.path().join("repo"); - if let Err(error) = fs::create_dir_all(repo.join(".claude")) { - panic!("repo should be created: {error}"); + if let Err(error) = fs::create_dir_all(repo.join(".claude/commands")) { + panic!("claude command dir should be created: {error}"); } - if let Err(error) = fs::write(repo.join("AGENTS.md"), "Instructions\n") { + if let Err(error) = fs::create_dir_all(repo.join(".codex")) { + panic!("codex marker should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".claude/commands/review.md"), + "---\ndescription: Review changes\n---\nReview the current diff.\n", + ) { panic!("fixture should be written: {error}"); } let cache = cache_for(&temp, &repo); - if let Err(error) = run_sync( - &repo, - SyncOptions { - await_drain: true, - ..SyncOptions::default() - }, - &cache, - ) { - panic!("sync should apply: {error}"); - } - let check = match run_sync( + let summary = match run_sync( &repo, SyncOptions { check: true, @@ -3686,91 +4686,1078 @@ schema: 1 &cache, ) { Ok(summary) => summary, - Err(error) => panic!("check should succeed: {error}"), + Err(error) => panic!("sync check should succeed: {error}"), }; - assert!(!check.changed); + assert!(summary.changed); + assert_eq!(summary.capability_skipped, 1); + assert_eq!(summary.capability_skips.len(), 1); + let finding = &summary.capability_skips[0]; + assert_eq!(finding.runtime, runtime_name("codex")); + assert_eq!(finding.entity_id, entity_id("command:review")); + assert_eq!(finding.entity_type, EntityType::Command); + assert_eq!(finding.fallback, CapabilityFallback::Warn); + assert_eq!( + finding.locations[&location_key(".claude")], + PathBuf::from("commands/review.md") + ); + assert!(!repo.join("agentmesh.lock").exists()); } #[test] - fn pending_records_are_retained_when_drain_is_not_requested() { + fn cursor_scoped_rule_syncs_to_claude_rule_paths() { let temp = match tempfile::tempdir() { Ok(temp) => temp, Err(error) => panic!("tempdir should be available: {error}"), }; let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules")) { + panic!("cursor rules dir should be created: {error}"); + } if let Err(error) = fs::create_dir_all(repo.join(".claude")) { - panic!("repo should be created: {error}"); + panic!("claude marker should be created: {error}"); } - if let Err(error) = fs::write(repo.join("AGENTS.md"), "Instructions\n") { - panic!("fixture should be written: {error}"); + if let Err(error) = fs::write( + repo.join(".cursor/rules/api.mdc"), + "---\ndescription: API guidance\nglobs:\n - crates/api/**\n---\nUse API conventions.\n", + ) { + panic!("cursor rule should be written: {error}"); } let cache = cache_for(&temp, &repo); - let summary = match run_sync(&repo, SyncOptions::default(), &cache) { + let summary = match run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { Ok(summary) => summary, - Err(error) => panic!("sync should apply: {error}"), + Err(error) => panic!("sync should succeed: {error}"), }; - assert!(summary.pending_enqueued > 0); - assert_eq!(summary.pending_drained, 0); - let queue = PendingQueue::new(&cache.pending_syncs_dir); - let pending = match queue.read_ready() { - Ok(pending) => pending, - Err(error) => panic!("pending queue should read: {error}"), + assert!(summary.changed); + assert_eq!(summary.pending_enqueued, 1, "summary: {summary:?}"); + if summary.pending_drained != 1 { + let queue = PendingQueue::new(&cache.pending_syncs_dir); + let remaining = queue + .read_ready() + .unwrap_or_else(|error| panic!("pending queue should read: {error}")); + panic!("summary: {summary:?}; remaining: {remaining:?}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), }; - assert!(!pending.is_empty()); + assert!( + lockfile.adapters.contains_key(&runtime_name("claude")), + "adapters: {:?}", + lockfile.adapters.keys().collect::>() + ); + let id = entity_id("instructions:scoped:api"); + let entity = lockfile + .entities + .get(&id) + .unwrap_or_else(|| panic!("scoped Cursor rule should be tracked")); + assert_eq!(entity.entity_type, EntityType::Instructions); + assert_eq!( + entity.locations[&location_key(".cursor")], + PathBuf::from("rules/api.mdc") + ); + let claude_rule = match fs::read_to_string(repo.join(".claude/rules/api.md")) { + Ok(contents) => contents, + Err(error) => panic!("claude rule should be emitted: {error}"), + }; + assert!(claude_rule.contains("paths:")); + assert!(claude_rule.contains("crates/api/**")); + assert!(!claude_rule.contains("globs:")); + assert_eq!( + entity.locations.get(&location_key(".claude")), + Some(&PathBuf::from("rules/api.md")), + "locations: {:?}", + entity.locations + ); } #[test] - fn multi_file_skill_enqueues_one_directory_record() { + fn nested_cursor_rule_uses_valid_flattened_id() { let temp = match tempfile::tempdir() { Ok(temp) => temp, Err(error) => panic!("tempdir should be available: {error}"), }; let repo = temp.path().join("repo"); - if let Err(error) = fs::create_dir_all(repo.join(".claude/skills/security-review/assets")) { - panic!("fixture dirs should be created: {error}"); - } - if let Err(error) = fs::create_dir_all(repo.join(".codex")) { - panic!("fixture dirs should be created: {error}"); - } - if let Err(error) = fs::write( - repo.join(".claude/skills/security-review/SKILL.md"), - "---\nname: security-review\n---\nBody\n", - ) { - panic!("fixture should be written: {error}"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules/team")) { + panic!("cursor rules dir should be created: {error}"); } if let Err(error) = fs::write( - repo.join(".claude/skills/security-review/assets/prompt.md"), - "Prompt\n", + repo.join(".cursor/rules/team/security.mdc"), + "---\ndescription: Security\n---\nSecurity body.\n", ) { - panic!("fixture should be written: {error}"); + panic!("cursor rule should be written: {error}"); } let cache = cache_for(&temp, &repo); let summary = match run_sync( &repo, SyncOptions { - trigger: Some("claude-hook".to_string()), + check: true, ..SyncOptions::default() }, &cache, ) { Ok(summary) => summary, - Err(error) => panic!("sync should succeed: {error}"), + Err(error) => panic!("sync check should succeed: {error}"), }; - - assert_eq!(summary.pending_enqueued, 1); - let queue = PendingQueue::new(&cache.pending_syncs_dir); - let pending = match queue.read_ready() { - Ok(pending) => pending, - Err(error) => panic!("pending queue should read: {error}"), + let plan = match build_sync_plan(&repo, Lockfile::empty(), &cache, PlanOptions::default()) { + Ok(plan) => plan, + Err(error) => panic!("plan should build: {error}"), }; - assert_eq!(pending.len(), 1); - let record = &pending[0].record; - assert_eq!( - record.entity_root, + + assert!(summary.changed); + assert!( + plan.lockfile + .entities + .contains_key(&entity_id("rule:team-security")) + ); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn invalid_cursor_rule_fails_sync_check_and_reports_in_doctor() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules")) { + panic!("cursor rules dir should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".cursor/rules/broken.mdc"), + "---\ndescription: \"unterminated\n---\nBroken\n", + ) { + panic!("cursor rule should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".cursor/rules/empty-globs.mdc"), + "---\nglobs: []\n---\nEmpty\n", + ) { + panic!("cursor rule should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let error = run_sync( + &repo, + SyncOptions { + check: true, + ..SyncOptions::default() + }, + &cache, + ) + .expect_err("invalid Cursor rule should fail sync check"); + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("cursor_rule_invalid: ") + && contains_display_path(finding, ".cursor/rules/broken.mdc") + })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("cursor_rule_invalid: ") + && contains_display_path(finding, ".cursor/rules/empty-globs.mdc") + && finding.contains("Cursor rule globs must contain one non-empty string scope") + })); + } + + #[cfg(unix)] + #[test] + fn doctor_reports_symlinked_cursor_rules_without_aborting() { + use std::os::unix::fs::symlink; + + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let outside = temp.path().join("outside"); + if let Err(error) = fs::create_dir_all(&outside) { + panic!("outside dir should be created: {error}"); + } + if let Err(error) = fs::create_dir_all(repo.join(".cursor")) { + panic!("cursor dir should be created: {error}"); + } + if let Err(error) = symlink(&outside, repo.join(".cursor/rules")) { + panic!("cursor rules symlink should be created: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should continue through adapter detect errors: {error}"), + }; + + assert!( + report + .findings + .iter() + .any(|finding| { finding.starts_with("adapter_cursor_detect_error:") }) + ); + assert!(report.findings.iter().any(|finding| { + finding == "cursor_rule_invalid: .cursor/rules: symlinked path is not supported" + })); + } + + #[test] + fn cursor_frontmatter_stays_cursor_only_across_runtime_emit() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules")) { + panic!("cursor rules dir should be created: {error}"); + } + if let Err(error) = fs::create_dir_all(repo.join(".claude")) { + panic!("claude marker should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\ncursorPriority: high\n---\nSecurity body.\n", + ) { + panic!("cursor rule should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("initial sync should succeed: {error}"); + } + let claude_rule = match fs::read_to_string(repo.join(".claude/rules/security.md")) { + Ok(contents) => contents, + Err(error) => panic!("claude rule should be emitted: {error}"), + }; + assert!(claude_rule.contains("description: Security")); + assert!(!claude_rule.contains("alwaysApply")); + assert!(!claude_rule.contains("cursorPriority")); + + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + let overrides = &lockfile.overrides[&entity_id("rule:security")][&runtime_name("cursor")].0; + assert_eq!( + overrides.get("cursorPriority"), + Some(&serde_json::json!("high")) + ); + + if let Err(error) = fs::write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\n---\nSecurity body.\n", + ) { + panic!("cursor rule metadata should be removed: {error}"); + } + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("metadata removal sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + assert!( + !lockfile + .overrides + .get(&entity_id("rule:security")) + .is_some_and(|overrides| overrides.contains_key(&runtime_name("cursor"))) + ); + + if let Err(error) = fs::write(repo.join(".ai/rules/security.mdc"), "# Updated\n") { + panic!("canonical rule should be updated: {error}"); + } + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("second sync should succeed: {error}"); + } + let cursor_rule = match fs::read_to_string(repo.join(".cursor/rules/security.mdc")) { + Ok(contents) => contents, + Err(error) => panic!("cursor rule should be emitted: {error}"), + }; + assert!(!cursor_rule.contains("cursorPriority")); + assert!(!cursor_rule.contains("alwaysApply")); + assert!(cursor_rule.contains("# Updated")); + } + + #[test] + fn cursor_overrides_clear_when_cursor_rule_view_disappears() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules")) { + panic!("cursor rules dir should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\ncursorPriority: high\n---\nSecurity body.\n", + ) { + panic!("cursor rule should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("initial sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + assert!( + lockfile + .overrides + .get(&entity_id("rule:security")) + .is_some_and(|overrides| overrides.contains_key(&runtime_name("cursor"))) + ); + + if let Err(error) = fs::remove_file(repo.join(".cursor/rules/security.mdc")) { + panic!("cursor rule should be deleted: {error}"); + } + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("delete sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + assert!( + !lockfile + .overrides + .get(&entity_id("rule:security")) + .is_some_and(|overrides| overrides.contains_key(&runtime_name("cursor"))) + ); + + if let Err(error) = fs::write(repo.join(".ai/rules/security.mdc"), "# Updated\n") { + panic!("canonical rule should be updated: {error}"); + } + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("canonical edit sync should succeed: {error}"); + } + let cursor_rule = match fs::read_to_string(repo.join(".cursor/rules/security.mdc")) { + Ok(contents) => contents, + Err(error) => panic!("cursor rule should be emitted: {error}"), + }; + assert!(!cursor_rule.contains("cursorPriority")); + assert!(!cursor_rule.contains("alwaysApply")); + assert!(cursor_rule.contains("# Updated")); + } + + #[test] + fn cursor_runtime_does_not_emit_duplicate_root_instructions() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".cursor/rules")) { + panic!("cursor rules dir should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("AGENTS.md"), "Root instructions.\n") { + panic!("root instructions should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let summary = match run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + Ok(summary) => summary, + Err(error) => panic!("sync should succeed: {error}"), + }; + + assert!(summary.changed); + assert!(!repo.join(".cursor/rules/root.mdc").exists()); + assert!(!repo.join(".cursor/rules/instructions-root.mdc").exists()); + } + + #[test] + fn copilot_scoped_instruction_preserves_native_metadata_in_overrides() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".github/instructions")) { + panic!("copilot instructions dir should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".github/instructions/api.instructions.md"), + "---\ndescription: API guidance\napplyTo:\n - crates/api/**\nexcludeAgent: false\ncustomRouting: reviewer\n---\n# API\n", + ) { + panic!("copilot instruction should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("initial sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + let id = entity_id("instructions:scoped:api"); + let entity = lockfile + .entities + .get(&id) + .unwrap_or_else(|| panic!("Copilot scoped instructions should be tracked")); + assert_eq!(entity.scope.as_deref(), Some("crates/api/**")); + assert_eq!( + entity.locations[&location_key(".copilot")], + PathBuf::from("../.github/instructions/api.instructions.md") + ); + let canonical = match fs::read_to_string(repo.join(".ai/instructions/api.md")) { + Ok(contents) => contents, + Err(error) => panic!("canonical instruction should read: {error}"), + }; + assert!(!canonical.contains("applyTo")); + assert!(!canonical.contains("customRouting")); + let overrides = &lockfile.overrides[&id][&runtime_name("copilot")].0; + assert!(overrides.contains_key("applyTo")); + assert_eq!( + overrides.get("customRouting"), + Some(&serde_json::json!("reviewer")) + ); + + if let Err(error) = fs::write(repo.join(".ai/instructions/api.md"), "# Updated API\n") { + panic!("canonical instruction should be updated: {error}"); + } + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("canonical edit sync should succeed: {error}"); + } + let copilot = + match fs::read_to_string(repo.join(".github/instructions/api.instructions.md")) { + Ok(contents) => contents, + Err(error) => panic!("copilot instruction should read: {error}"), + }; + assert!(copilot.contains("applyTo:")); + assert!(copilot.contains("crates/api/**")); + assert!(copilot.contains("customRouting: reviewer")); + assert!(copilot.contains("# Updated API")); + assert!(!repo.join(".copilot").exists()); + } + + #[test] + fn gemini_context_commands_and_shared_skills_sync_through_core() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [ + "packages/api", + ".gemini/commands/git", + ".agents/skills/shared-analysis", + ] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write(repo.join("GEMINI.md"), "# Root\n@./docs/style.md\n") { + panic!("Gemini root context should be written: {error}"); + } + if let Err(error) = fs::write(repo.join("packages/api/GEMINI.md"), "# API\n") { + panic!("Gemini scoped context should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".gemini/commands/git/commit.toml"), + "description = \"Commit\"\nmode = \"builtin\"\nprompt = \"Draft commit for {{args}}.\"\n", + ) { + panic!("Gemini command should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".agents/skills/shared-analysis/SKILL.md"), + "---\nname: shared-analysis\ndescription: Shared\nowner: platform\n---\n# Shared\n", + ) { + panic!("shared skill should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("Gemini sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + + let scoped_id = entity_id("instructions:scoped:packages-api"); + let scoped = lockfile + .entities + .get(&scoped_id) + .unwrap_or_else(|| panic!("Gemini scoped context should be tracked")); + assert_eq!(scoped.scope.as_deref(), Some("packages/api/**")); + assert_eq!( + scoped.locations[&location_key(".gemini")], + PathBuf::from("../packages/api/GEMINI.md") + ); + let command_id = entity_id("command:git:commit"); + let command = lockfile + .entities + .get(&command_id) + .unwrap_or_else(|| panic!("Gemini command should be tracked")); + assert_eq!( + command.locations[&location_key(".gemini")], + PathBuf::from("commands/git/commit.toml") + ); + let overrides = &lockfile.overrides[&command_id][&runtime_name("gemini")].0; + assert_eq!(overrides.get("mode"), Some(&serde_json::json!("builtin"))); + let canonical_command = match fs::read_to_string(repo.join(".ai/commands/git/commit.md")) { + Ok(contents) => contents, + Err(error) => panic!("canonical command should read: {error}"), + }; + assert!(canonical_command.contains("Draft commit for {{args}}.")); + assert!(!canonical_command.contains("mode")); + assert!( + lockfile + .entities + .contains_key(&entity_id("skill:shared-analysis")) + ); + assert!( + !lockfile + .entities + .contains_key(&entity_id("skill:shared-analysis-2")) + ); + assert!( + repo.join(".gemini/skills/shared-analysis/SKILL.md") + .is_file() + ); + + if let Err(error) = fs::write( + repo.join(".ai/instructions/packages-api.md"), + "# Updated API\n", + ) { + panic!("canonical scoped context should be updated: {error}"); + } + if let Err(error) = fs::write( + repo.join(".ai/commands/git/commit.md"), + "---\ndescription: Commit\n---\nUpdated commit prompt.\n", + ) { + panic!("canonical command should be updated: {error}"); + } + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("canonical edit sync should succeed: {error}"); + } + let scoped_context = match fs::read_to_string(repo.join("packages/api/GEMINI.md")) { + Ok(contents) => contents, + Err(error) => panic!("Gemini scoped context should read: {error}"), + }; + assert!(scoped_context.contains("# Updated API")); + let command = match fs::read_to_string(repo.join(".gemini/commands/git/commit.toml")) { + Ok(contents) => contents, + Err(error) => panic!("Gemini command should read: {error}"), + }; + assert!(command.contains("mode = \"builtin\"")); + assert!(command.contains("prompt = \"Updated commit prompt.\\n\"")); + } + + #[test] + fn gemini_settings_do_not_enable_runtime_emit() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".gemini")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("AGENTS.md"), "# Root\n") { + panic!("root instructions should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo-tools"}}}"#, + ) { + panic!("Gemini settings should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("sync should succeed: {error}"); + } + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should read: {error}"), + }; + + assert!(!lockfile.adapters.contains_key(&runtime_name("gemini"))); + assert!(!repo.join("GEMINI.md").exists()); + assert!(!repo.join(".gemini/commands").exists()); + assert!(!repo.join(".gemini/skills").exists()); + } + + #[test] + fn gemini_emits_canonical_toml_commands_without_wrapping() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".ai/commands")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("GEMINI.md"), "# Root\n") { + panic!("Gemini context should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".ai/commands/deploy.toml"), + "description = \"Deploy\"\nprompt = \"Deploy {{args}}\"\n", + ) { + panic!("canonical command should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("sync should succeed: {error}"); + } + let command = match fs::read_to_string(repo.join(".gemini/commands/deploy.toml")) { + Ok(contents) => contents, + Err(error) => panic!("Gemini command should read: {error}"), + }; + + assert!(command.contains("description = \"Deploy\"")); + assert!(command.contains("prompt = \"Deploy {{args}}\"")); + assert!(!command.contains("prompt = \"prompt =")); + } + + #[test] + fn deferred_gemini_context_named_files_do_not_fail_sync_check() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".gemini/extensions/sample")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("GEMINI.md"), "# Root\n") { + panic!("Gemini context should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".gemini/extensions/sample/GEMINI.md"), + "# Deferred\n", + ) { + panic!("deferred context-named file should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let summary = match run_sync( + &repo, + SyncOptions { + check: true, + ..SyncOptions::default() + }, + &cache, + ) { + Ok(summary) => summary, + Err(error) => panic!("sync check should not fail on deferred diagnostics: {error}"), + }; + + assert!(summary.changed); + assert!(!repo.join("agentmesh.lock").exists()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn invalid_gemini_write_enabled_files_fail_sync_check_and_report_in_doctor() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [".gemini/skills/broken", ".gemini/commands"] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write( + repo.join(".gemini/skills/broken/SKILL.md"), + "---\nname: broken\ndescription: \"unterminated\n---\nBroken\n", + ) { + panic!("invalid skill should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".gemini/commands/missing-prompt.toml"), + "description = \"Missing\"\n", + ) { + panic!("invalid command should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let error = run_sync( + &repo, + SyncOptions { + check: true, + ..SyncOptions::default() + }, + &cache, + ) + .expect_err("invalid Gemini files should fail sync check"); + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("gemini_skill_invalid: ") + && contains_display_path(finding, ".gemini/skills/broken/SKILL.md") + })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("gemini_command_invalid: ") + && contains_display_path(finding, ".gemini/commands/missing-prompt.toml") + && finding.contains("missing required prompt field") + })); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn invalid_copilot_write_enabled_files_fail_sync_check_and_report_in_doctor() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".github/instructions")) { + panic!("copilot instructions dir should be created: {error}"); + } + if let Err(error) = fs::create_dir_all(repo.join(".github/prompts")) { + panic!("copilot prompts dir should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".github/instructions/missing.instructions.md"), + "---\ndescription: Missing\n---\n# Missing\n", + ) { + panic!("invalid instruction should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".github/prompts/broken.prompt.md"), + "---\ndescription: \"unterminated\n---\nBroken\n", + ) { + panic!("invalid prompt should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".github/instructions/multi.instructions.md"), + "---\napplyTo:\n - crates/api/**\n - adapters/api/**\n---\n# Multi\n", + ) { + panic!("invalid multi-scope instruction should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let error = run_sync( + &repo, + SyncOptions { + check: true, + ..SyncOptions::default() + }, + &cache, + ) + .expect_err("invalid Copilot files should fail sync check"); + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("copilot_instruction_invalid: ") + && contains_display_path(finding, ".github/instructions/missing.instructions.md") + && finding.contains("missing applyTo frontmatter") + })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("copilot_prompt_invalid: ") + && contains_display_path(finding, ".github/prompts/broken.prompt.md") + })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("copilot_instruction_invalid: ") + && contains_display_path(finding, ".github/instructions/multi.instructions.md") + && finding.contains( + "Copilot applyTo with multiple scopes cannot be represented losslessly", + ) + })); + } + + #[test] + fn doctor_reports_unsupported_capability_skip_details() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(&repo) { + panic!("repo should be created: {error}"); + } + + let mut lockfile = Lockfile::empty(); + lockfile.entities.insert( + entity_id("permission-policy:project"), + entity_entry( + EntityType::PermissionPolicy, + hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ), + ); + lockfile.adapters.insert( + runtime_name("cursor"), + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: 2, + entities: vec![EntityType::Instructions, EntityType::Rule], + hooks: Vec::new(), + }, + ); + if let Err(error) = write_lockfile(&repo, &lockfile) { + panic!("lockfile should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + assert_eq!(report.health.capability_skips, 1); + assert!(report.findings.iter().any(|finding| { + finding + == "capability_skip: runtime=cursor entity=permission-policy:project type=permission-policy fallback=warn locations=none" + })); + } + + #[test] + fn doctor_reports_fail_fallback_without_enforcing_it() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(&repo) { + panic!("repo should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join("agentmesh.config.yaml"), + "fallbacks:\n cursor:\n permission-policy: fail\n", + ) { + panic!("config should be written: {error}"); + } + + let mut lockfile = Lockfile::empty(); + lockfile.entities.insert( + entity_id("permission-policy:project"), + entity_entry( + EntityType::PermissionPolicy, + hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ), + ); + lockfile.adapters.insert( + runtime_name("cursor"), + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: 2, + entities: vec![EntityType::Instructions, EntityType::Rule], + hooks: Vec::new(), + }, + ); + if let Err(error) = write_lockfile(&repo, &lockfile) { + panic!("lockfile should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should report strict fallback without failing: {error}"), + }; + + assert_eq!(report.health.capability_skips, 1); + assert!(report.findings.iter().any(|finding| { + finding + == "capability_skip: runtime=cursor entity=permission-policy:project type=permission-policy fallback=fail locations=none" + })); + } + + #[test] + fn no_drift_after_apply() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".claude")) { + panic!("repo should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("AGENTS.md"), "Instructions\n") { + panic!("fixture should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync( + &repo, + SyncOptions { + await_drain: true, + ..SyncOptions::default() + }, + &cache, + ) { + panic!("sync should apply: {error}"); + } + let check = match run_sync( + &repo, + SyncOptions { + check: true, + ..SyncOptions::default() + }, + &cache, + ) { + Ok(summary) => summary, + Err(error) => panic!("check should succeed: {error}"), + }; + + assert!(!check.changed); + } + + #[test] + fn pending_records_are_retained_when_drain_is_not_requested() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".claude")) { + panic!("repo should be created: {error}"); + } + if let Err(error) = fs::write(repo.join("AGENTS.md"), "Instructions\n") { + panic!("fixture should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let summary = match run_sync(&repo, SyncOptions::default(), &cache) { + Ok(summary) => summary, + Err(error) => panic!("sync should apply: {error}"), + }; + + assert!(summary.pending_enqueued > 0); + assert_eq!(summary.pending_drained, 0); + let queue = PendingQueue::new(&cache.pending_syncs_dir); + let pending = match queue.read_ready() { + Ok(pending) => pending, + Err(error) => panic!("pending queue should read: {error}"), + }; + assert!(!pending.is_empty()); + } + + #[test] + fn multi_file_skill_enqueues_one_directory_record() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".claude/skills/security-review/assets")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::create_dir_all(repo.join(".codex")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".claude/skills/security-review/SKILL.md"), + "---\nname: security-review\n---\nBody\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".claude/skills/security-review/assets/prompt.md"), + "Prompt\n", + ) { + panic!("fixture should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + let summary = match run_sync( + &repo, + SyncOptions { + trigger: Some("claude-hook".to_string()), + ..SyncOptions::default() + }, + &cache, + ) { + Ok(summary) => summary, + Err(error) => panic!("sync should succeed: {error}"), + }; + + assert_eq!(summary.pending_enqueued, 1); + let queue = PendingQueue::new(&cache.pending_syncs_dir); + let pending = match queue.read_ready() { + Ok(pending) => pending, + Err(error) => panic!("pending queue should read: {error}"), + }; + assert_eq!(pending.len(), 1); + let record = &pending[0].record; + assert_eq!( + record.entity_root, std::path::PathBuf::from(".ai/skills/security-review") ); assert!(record.changed_paths.contains(&std::path::PathBuf::from( @@ -3906,7 +5893,7 @@ schema: 1 .insert(location_key(".ai"), canonical_hash.clone()); lockfile.entities.insert(id.clone(), entity); lockfile.adapters.insert( - runtime_name("gemini"), + runtime_name("failing"), AdapterDeclaration { mode: AdapterMode::Bundled, protocol_version: 1, @@ -3940,14 +5927,19 @@ schema: 1 if let Err(error) = queue.enqueue(&record) { panic!("pending record should enqueue: {error}"); } + let registry = TestAdapterRegistry { + fail_emit: true, + ..TestAdapterRegistry::default() + }; - let summary = match run_sync( + let summary = match super::run_sync( &repo, SyncOptions { drain_pending: true, ..SyncOptions::default() }, &cache, + ®istry, ) { Ok(summary) => summary, Err(error) => panic!("drain should complete: {error}"), @@ -4068,27 +6060,620 @@ schema: 1 panic!("fixture should be written: {error}"); } - if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { - panic!("conflict sync should succeed: {error}"); + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("conflict sync should succeed: {error}"); + } + + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should be readable: {error}"), + }; + assert_eq!( + lockfile.entities[&entity_id("skill:conflict-demo")].pending_conflict_resolution, + Some(true) + ); + let conflict_dir = crate::state::conflict_entity_dir( + &cache.conflicts_dir, + &entity_id("skill:conflict-demo"), + ); + let preserved = match fs::read_dir(&conflict_dir) { + Ok(entries) => entries.count(), + Err(error) => panic!("conflict dir should be readable: {error}"), + }; + assert!(preserved > 0); + } + + #[test] + fn markdown_backed_expanded_entities_merge_against_unchanged_view() { + let cases = [ + (EntityType::Rule, "rule:security", ".ai/rules/security.md"), + ( + EntityType::Prompt, + "prompt:release-notes", + ".ai/prompts/release-notes.md", + ), + ( + EntityType::Command, + "command:git:commit", + ".ai/commands/git/commit.md", + ), + ]; + + for (entity_type, id, path) in cases { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = cache_for(&temp, &repo); + let old_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let left_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let right_hash = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let ancestor = "---\ndescription: Base\n---\nLine one\nLine two\n"; + let mut previous = entity_entry(entity_type, hash(old_hash)); + previous.emitted_native_sha256 = BTreeMap::from([ + (location_key(".ai"), hash(old_hash)), + (location_key(".claude"), hash(old_hash)), + (location_key(".codex"), hash(old_hash)), + ]); + let views = BTreeMap::from([ + ( + location_key(".ai"), + test_view( + entity_type, + ".ai", + path, + "---\ndescription: Updated\n---\nLine one\nLine two\n", + left_hash, + 2, + ), + ), + ( + location_key(".claude"), + test_view( + entity_type, + ".claude", + path, + "---\ndescription: Base\n---\nLine one\nLine two\nLine three\n", + right_hash, + 3, + ), + ), + ( + location_key(".codex"), + test_view(entity_type, ".codex", path, ancestor, old_hash, 1), + ), + ]); + + let decision = match super::resolve_canonical_contents( + &cache, + &entity_id(id), + entity_type, + &views, + Some(&previous), + None, + true, + ) { + Ok(decision) => decision, + Err(error) => panic!("expanded markdown entity should merge: {error}"), + }; + let merged = match String::from_utf8(decision.contents) { + Ok(merged) => merged, + Err(error) => panic!("merged contents should be UTF-8: {error}"), + }; + + assert!(merged.contains("description: Updated")); + assert!(merged.contains("Line three")); + assert!(!decision.pending_conflict_resolution); + } + } + + #[test] + fn config_backed_entities_tiebreak_and_preserve_losing_versions() { + let cases = [ + ( + EntityType::Command, + "command:deploy", + ".ai/commands/deploy.toml", + ), + ( + EntityType::Hook, + "hook:gemini-project", + ".ai/hooks/gemini-project.json", + ), + ( + EntityType::McpBinding, + "mcp-binding:gemini-project", + ".ai/mcp-bindings/gemini-project.json", + ), + ( + EntityType::PermissionPolicy, + "permission-policy:gemini-project", + ".ai/permission-policies/gemini-project.json", + ), + ]; + + for (entity_type, id, path) in cases { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = cache_for(&temp, &repo); + let old_hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let left_hash = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let right_hash = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let mut previous = entity_entry(entity_type, hash(old_hash)); + previous.emitted_native_sha256 = BTreeMap::from([ + (location_key(".ai"), hash(old_hash)), + (location_key(".claude"), hash(old_hash)), + (location_key(".codex"), hash(old_hash)), + ]); + let views = BTreeMap::from([ + ( + location_key(".ai"), + test_view( + entity_type, + ".ai", + path, + "{\"value\":\"left\"}\n", + left_hash, + 2, + ), + ), + ( + location_key(".claude"), + test_view( + entity_type, + ".claude", + path, + "{\"value\":\"right\"}\n", + right_hash, + 4, + ), + ), + ( + location_key(".codex"), + test_view( + entity_type, + ".codex", + path, + "{\"value\":\"old\"}\n", + old_hash, + 1, + ), + ), + ]); + + let decision = match super::resolve_canonical_contents( + &cache, + &entity_id(id), + entity_type, + &views, + Some(&previous), + None, + true, + ) { + Ok(decision) => decision, + Err(error) => panic!("config entity should tiebreak: {error}"), + }; + let chosen = match String::from_utf8(decision.contents) { + Ok(chosen) => chosen, + Err(error) => panic!("chosen contents should be UTF-8: {error}"), + }; + let conflict_dir = + crate::state::conflict_entity_dir(&cache.conflicts_dir, &entity_id(id)); + let preserved = match fs::read_dir(&conflict_dir) { + Ok(entries) => entries.count(), + Err(error) => panic!("conflict dir should be readable: {error}"), + }; + + assert!(chosen.contains("right")); + assert!(decision.pending_conflict_resolution); + assert!(preserved > 0); + } + } + + #[test] + fn config_backed_command_keeps_canonical_extension() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(repo.join(".ai/commands")) { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".ai/commands/deploy.toml"), + "command = \"deploy\"\n", + ) { + panic!("fixture should be written: {error}"); + } + let cache = cache_for(&temp, &repo); + + if let Err(error) = run_sync(&repo, SyncOptions::default(), &cache) { + panic!("sync should preserve command extension: {error}"); + } + + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should be readable: {error}"), + }; + assert_eq!( + lockfile.entities[&entity_id("command:deploy")].locations[&location_key(".ai")], + PathBuf::from("commands/deploy.toml") + ); + assert!(!repo.join(".ai/commands/deploy.md").exists()); + } + + #[test] + fn chooses_available_view_for_unlisted_runtime_locations() { + let view = test_view( + EntityType::Command, + ".gemini", + ".gemini/commands/review.toml", + "prompt = \"review\"\n", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 1, + ); + let views = BTreeMap::from([(location_key(".gemini"), view)]); + + let chosen = match super::choose_canonical_view(&views) { + Some(chosen) => chosen, + None => panic!("available view should be chosen"), + }; + + assert_eq!(chosen.location, location_key(".gemini")); + assert_eq!(chosen.canonical_contents, b"prompt = \"review\"\n"); + } + + #[test] + fn deduplicates_shared_skill_imports() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = + fs::create_dir_all(repo.join(".agents/skills/shared-analysis/references")) + { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write( + repo.join(".agents/skills/shared-analysis/SKILL.md"), + "---\nname: shared-analysis\n---\nShared body\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".agents/skills/shared-analysis/references/notes.md"), + "Notes\n", + ) { + panic!("support file should be written: {error}"); + } + let imported = ImportedEntity { + id: "skill:shared-analysis".to_string(), + entity_type: EntityType::Skill, + scope: None, + canonical_path: PathBuf::from(".ai/skills/shared-analysis/SKILL.md"), + files: BTreeMap::from([ + ( + PathBuf::from("SKILL.md"), + EntityFile::utf8("---\nname: shared-analysis\n---\nShared body\n"), + ), + ( + PathBuf::from("references/notes.md"), + EntityFile::utf8("Notes\n"), + ), + ]), + frontmatter: BTreeMap::new(), + canonical_sha256: "unused".to_string(), + source_path: PathBuf::from(".agents/skills/shared-analysis/SKILL.md"), + source_mtime: "unix-1".to_string(), + }; + let registry = TestAdapterRegistry { + imports: vec![imported], + emitted: RefCell::default(), + present: true, + fail_emit: false, + }; + let cache = cache_for(&temp, &repo); + + let summary = match super::run_sync( + &repo, + SyncOptions { + trigger: Some("test".to_string()), + ..SyncOptions::default() + }, + &cache, + ®istry, + ) { + Ok(summary) => summary, + Err(error) => panic!("sync should deduplicate shared skill: {error}"), + }; + + let lockfile = match read_lockfile(&repo) { + Ok(lockfile) => lockfile, + Err(error) => panic!("lockfile should be readable: {error}"), + }; + let id = entity_id("skill:shared-analysis"); + assert!(lockfile.entities.contains_key(&id)); + assert!( + !lockfile + .entities + .contains_key(&entity_id("skill:shared-analysis-2")) + ); + assert_eq!(lockfile.entities.len(), 1); + assert_eq!( + lockfile.entities[&id].locations[&location_key(".agents")], + PathBuf::from("skills/shared-analysis/SKILL.md") + ); + assert!(repo.join(".ai/skills/shared-analysis/SKILL.md").exists()); + assert!( + repo.join(".ai/skills/shared-analysis/references/notes.md") + .exists() + ); + assert_eq!(summary.pending_enqueued, 1); + let pending = match PendingQueue::new(&cache.pending_syncs_dir).read_ready() { + Ok(pending) => pending, + Err(error) => panic!("pending queue should read: {error}"), + }; + assert_eq!(pending.len(), 1); + } + + #[test] + fn configuration_entities_emit_payloads_as_data() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = cache_for(&temp, &repo); + if let Err(error) = cache.ensure_dirs() { + panic!("cache dirs should be created: {error}"); + } + let runtime = runtime_name("gemini"); + let cases = [ + ( + EntityType::Command, + "command:deploy", + ".ai/commands/deploy.toml", + "commands/deploy.toml", + "command = \"sh -c 'rm -rf target'\"\n", + ), + ( + EntityType::Hook, + "hook:gemini-project", + ".ai/hooks/gemini-project.json", + "hooks/gemini-project.json", + "{\"command\":\"sh -c 'echo hook'\"}\n", + ), + ( + EntityType::McpBinding, + "mcp-binding:gemini-project", + ".ai/mcp-bindings/gemini-project.json", + "mcp-bindings/gemini-project.json", + "{\"server\":{\"command\":\"node server.js\"}}\n", + ), + ( + EntityType::PermissionPolicy, + "permission-policy:gemini-project", + ".ai/permission-policies/gemini-project.json", + "permission-policies/gemini-project.json", + "{\"allow\":[\"shell:*\"]}\n", + ), + ]; + + for (_, _, repo_path, _, contents) in cases { + let path = repo.join(repo_path); + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("fixture dirs should be created: {error}"); + } + if let Err(error) = fs::write(path, contents) { + panic!("fixture should be written: {error}"); + } + } + + let registry = TestAdapterRegistry { + imports: Vec::new(), + emitted: RefCell::default(), + present: false, + fail_emit: false, + }; + for (entity_type, id, _, lockfile_path, contents) in cases { + let mut lockfile = Lockfile::empty(); + let mut entity = entity_entry( + entity_type, + hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ); + entity + .locations + .insert(location_key(".ai"), PathBuf::from(lockfile_path)); + lockfile.entities.insert(entity_id(id), entity); + lockfile.adapters.insert( + runtime.clone(), + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: 2, + entities: vec![entity_type], + hooks: Vec::new(), + }, + ); + + let outcome = match super::emit_entity_to_runtime( + &repo, + &mut lockfile, + &AgentmeshConfig::default(), + &entity_id(id), + &runtime, + ®istry, + ) { + Ok(outcome) => outcome, + Err(error) => panic!("emit should preserve payload as data: {error}"), + }; + + assert_eq!(outcome.files_written, 0); + let emitted = registry.emitted.borrow(); + let Some(request) = emitted.last() else { + panic!("emit request should be recorded"); + }; + let Some(entity) = request.entities.first() else { + panic!("emit request should contain an entity"); + }; + let Some(file) = entity.files.values().next() else { + panic!("emit entity should contain a file"); + }; + assert_eq!(entity.entity_type, entity_type); + assert_eq!(entity_file_text(file), contents); + assert!(!cache.hook_ownership_json.exists()); + } + } + + #[test] + fn fallback_behaviors_cover_configuration_entities() { + let config_entities = [ + (EntityType::Command, "command:deploy"), + (EntityType::Hook, "hook:gemini-project"), + (EntityType::McpBinding, "mcp-binding:gemini-project"), + ( + EntityType::PermissionPolicy, + "permission-policy:gemini-project", + ), + ]; + + for (fallback, expected_diagnostic_skips) in [ + (CapabilityFallback::Skip, 0, false), + (CapabilityFallback::Warn, config_entities.len(), false), + (CapabilityFallback::RenderAsDoc, config_entities.len(), true), + (CapabilityFallback::Fail, config_entities.len(), true), + ] + .into_iter() + .map(|(fallback, expected, _)| (fallback, expected)) + { + let mut lockfile = Lockfile::empty(); + for (entity_type, id) in config_entities { + lockfile.entities.insert( + entity_id(id), + entity_entry( + entity_type, + hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ), + ); + } + lockfile.adapters.insert( + runtime_name("codex"), + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: 2, + entities: Vec::new(), + hooks: Vec::new(), + }, + ); + let mut config = AgentmeshConfig::default(); + config.fallbacks.insert( + runtime_name("codex"), + config_entities + .iter() + .map(|(entity_type, _)| (entity_type.as_str().to_string(), fallback)) + .collect(), + ); + + let enforce = capability_skip_report_for_lockfile( + &lockfile, + &config, + CapabilityReportMode::Enforce, + ); + match fallback { + CapabilityFallback::Skip | CapabilityFallback::Warn => { + let report = + enforce.unwrap_or_else(|error| panic!("fallback should not fail: {error}")); + assert_eq!(report.skipped, expected_diagnostic_skips); + assert_eq!(report.findings.len(), expected_diagnostic_skips); + let skipped = capability_skip_count_for_lockfile(&lockfile, &config) + .unwrap_or_else(|error| { + panic!("count helper should match report: {error}") + }); + assert_eq!(skipped, expected_diagnostic_skips); + } + CapabilityFallback::RenderAsDoc => assert!(matches!( + enforce, + Err(super::PipelineError::RenderAsDocUnsupported { .. }) + )), + CapabilityFallback::Fail => assert!(matches!( + enforce, + Err(super::PipelineError::CapabilityMismatch { .. }) + )), + } + + let diagnostic = capability_skip_report_for_lockfile( + &lockfile, + &config, + CapabilityReportMode::Diagnostic, + ) + .unwrap_or_else(|error| panic!("diagnostic report should not fail: {error}")); + assert_eq!(diagnostic.skipped, expected_diagnostic_skips); + assert_eq!(diagnostic.findings.len(), expected_diagnostic_skips); + for finding in &diagnostic.findings { + assert_eq!(finding.fallback, fallback); + } } + } - let lockfile = match read_lockfile(&repo) { - Ok(lockfile) => lockfile, - Err(error) => panic!("lockfile should be readable: {error}"), + #[test] + fn render_as_doc_fallback_rejects_unsupported_emit_without_writing() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), }; - assert_eq!( - lockfile.entities[&entity_id("skill:conflict-demo")].pending_conflict_resolution, - Some(true) + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(&repo) { + panic!("repo should be created: {error}"); + } + let runtime = runtime_name("codex"); + let id = entity_id("command:deploy"); + let mut lockfile = Lockfile::empty(); + lockfile.entities.insert( + id.clone(), + entity_entry( + EntityType::Command, + hash("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + ), ); - let conflict_dir = crate::state::conflict_entity_dir( - &cache.conflicts_dir, - &entity_id("skill:conflict-demo"), + lockfile.adapters.insert( + runtime.clone(), + AdapterDeclaration { + mode: AdapterMode::Bundled, + protocol_version: 2, + entities: vec![EntityType::Instructions], + hooks: Vec::new(), + }, ); - let preserved = match fs::read_dir(&conflict_dir) { - Ok(entries) => entries.count(), - Err(error) => panic!("conflict dir should be readable: {error}"), + let mut config = AgentmeshConfig::default(); + config.fallbacks.insert( + runtime.clone(), + BTreeMap::from([( + EntityType::Command.as_str().to_string(), + CapabilityFallback::RenderAsDoc, + )]), + ); + let registry = TestAdapterRegistry { + imports: Vec::new(), + emitted: RefCell::default(), + present: false, + fail_emit: false, }; - assert!(preserved > 0); + + let result = + super::emit_entity_to_runtime(&repo, &mut lockfile, &config, &id, &runtime, ®istry); + + assert!(matches!( + result, + Err(super::PipelineError::RenderAsDocUnsupported { .. }) + )); + assert!(registry.emitted.borrow().is_empty()); } #[test] @@ -4282,6 +6867,148 @@ schema: 1 assert!(summary.files_written >= 2); } + #[test] + fn lockfile_repo_relative_paths_reject_nested_parent_components() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + if let Err(error) = fs::create_dir_all(&repo) { + panic!("repo should be created: {error}"); + } + + let valid = match path_from_lockfile( + &repo, + &location_key(".copilot"), + Path::new("../.github/instructions/api.instructions.md"), + ) { + Ok(path) => path, + Err(error) => panic!("valid repo-relative lockfile path should resolve: {error}"), + }; + assert_eq!(valid, repo.join(".github/instructions/api.instructions.md")); + + let valid = match path_from_lockfile(&repo, &location_key(".ai"), Path::new("../AGENTS.md")) + { + Ok(path) => path, + Err(error) => panic!("canonical root instruction path should resolve: {error}"), + }; + assert_eq!(valid, repo.join("AGENTS.md")); + + let valid = match path_from_lockfile( + &repo, + &location_key(".codex"), + Path::new("../packages/api/AGENTS.md"), + ) { + Ok(path) => path, + Err(error) => panic!("Codex nested instruction path should resolve: {error}"), + }; + assert_eq!(valid, repo.join("packages/api/AGENTS.md")); + + let valid = match path_from_lockfile( + &repo, + &location_key(".gemini"), + Path::new("../packages/api/GEMINI.md"), + ) { + Ok(path) => path, + Err(error) => panic!("Gemini nested context path should resolve: {error}"), + }; + assert_eq!(valid, repo.join("packages/api/GEMINI.md")); + + let error = path_from_lockfile( + &repo, + &location_key(".copilot"), + Path::new("../../outside.md"), + ) + .expect_err("nested parent components should be rejected"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("unsafe repo-relative lockfile path") + ); + + let error = path_from_lockfile( + &repo, + &location_key(".ai"), + Path::new("../.git/hooks/pre-commit"), + ) + .expect_err("canonical root location should reject arbitrary repo-relative paths"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + + let error = path_from_lockfile( + &repo, + &location_key(".codex"), + Path::new("../.git/hooks/AGENTS.md"), + ) + .expect_err("hidden repository roots should be rejected"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + + let error = path_from_lockfile( + &repo, + &location_key(".codex"), + Path::new("../packages/.codex/AGENTS.md"), + ) + .expect_err("nested runtime directories should be rejected for Codex"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + + let error = path_from_lockfile( + &repo, + &location_key(".gemini"), + Path::new("../.git/hooks/GEMINI.md"), + ) + .expect_err("hidden repository roots should be rejected for Gemini"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + + for path in [ + "../packages/.git/hooks/GEMINI.md", + "../packages/.gemini/GEMINI.md", + "../packages/.secrets/GEMINI.md", + ] { + let error = path_from_lockfile(&repo, &location_key(".gemini"), Path::new(path)) + .expect_err("nested hidden directories should be rejected for Gemini"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + } + + let error = path_from_lockfile( + &repo, + &location_key(".copilot"), + Path::new("../.git/hooks/pre-commit"), + ) + .expect_err("arbitrary repo-relative paths should be rejected"); + assert!(matches!(error, PipelineError::EntityFormat { .. })); + assert!( + error + .to_string() + .contains("repo-relative lockfile path is not allowed") + ); + } + #[test] fn doctor_reports_core_counts() { let temp = match tempfile::tempdir() { @@ -4343,6 +7070,219 @@ schema: 1 ); } + #[test] + fn doctor_reports_codex_read_only_and_deferred_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [".codex/rules", ".codex/prompts", ".codex/commands"] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write( + repo.join(".codex/config.toml"), + "[hooks.PostToolUse]\ncommand = \"npm test\"\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".codex/rules/strict.rules"), + "deny = [\"network\"]\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".codex/prompts/release.md"), "# Release\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".codex/commands/review.md"), "# Review\n") { + panic!("fixture should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + for expected in [ + "codex_inline_config_hooks: read-only diagnostic", + "codex_experimental_rules: read-only diagnostic", + "codex_custom_prompts: deferred", + "codex_project_commands: deferred", + ] { + assert!( + report + .findings + .iter() + .any(|finding| finding.starts_with(expected)), + "missing doctor finding starting with {expected}; findings: {:?}", + report.findings + ); + } + } + + #[test] + fn doctor_reports_cursor_deferred_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [ + ".cursor/skills/repo-audit", + ".cursor/commands", + ".cursor/agents", + ] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write( + repo.join(".cursor/skills/repo-audit/SKILL.md"), + "# Repo audit\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".cursor/hooks.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".cursor/commands/review.md"), "# Review\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".cursor/agents/investigator.md"), "# Agent\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".cursor/mcp.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + for expected in [ + "cursor_skills: deferred", + "cursor_hooks: deferred", + "cursor_commands: deferred", + "cursor_subagents: deferred", + "cursor_mcp: deferred", + ] { + assert!( + report + .findings + .iter() + .any(|finding| finding.starts_with(expected)), + "missing doctor finding starting with {expected}; findings: {:?}", + report.findings + ); + } + } + + #[test] + fn doctor_reports_copilot_deferred_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [".github/hooks", "mcp", ".github/workflows", "environment"] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write(repo.join(".github/hooks/pre-tool.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join("mcp/repository-mcp-settings.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write( + repo.join(".github/workflows/copilot-setup-steps.yml"), + "name: Copilot Setup Steps\n", + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join("environment/agent-environment.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + for expected in [ + "copilot_hooks: deferred", + "copilot_repository_mcp: deferred", + "copilot_setup_steps: deferred", + "copilot_agent_environment: deferred", + ] { + assert!( + report + .findings + .iter() + .any(|finding| finding.starts_with(expected)), + "missing doctor finding starting with {expected}; findings: {:?}", + report.findings + ); + } + } + + #[test] + fn doctor_reports_gemini_read_only_and_deferred_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + for dir in [".gemini", ".gemini/agents", ".gemini/hooks"] { + if let Err(error) = fs::create_dir_all(repo.join(dir)) { + panic!("fixture dirs should be created: {error}"); + } + } + if let Err(error) = fs::write( + repo.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo-tools"}},"policyPaths":[".gemini/policies"],"context":{"fileName":["CONTEXT.md","GEMINI.md"]},"hooks":{"BeforeTool":[]}}"#, + ) { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".gemini/agents/investigator.md"), "# Agent\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join(".gemini/hooks/guard.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + if let Err(error) = fs::write(repo.join("gemini-extension.json"), "{}\n") { + panic!("fixture should be written: {error}"); + } + + let report = match doctor(&repo) { + Ok(report) => report, + Err(error) => panic!("doctor should succeed: {error}"), + }; + + for expected in [ + "gemini_project_mcp: read-only diagnostic", + "gemini_policy_settings: read-only diagnostic", + "gemini_custom_context_filenames: deferred", + "gemini_hooks: deferred", + "gemini_subagents: deferred", + "gemini_extensions: deferred", + ] { + assert!( + report + .findings + .iter() + .any(|finding| finding.starts_with(expected)), + "missing doctor finding starting with {expected}; findings: {:?}", + report.findings + ); + } + } + #[test] fn doctor_warns_about_sensitive_lockfile_metadata() { let temp = match tempfile::tempdir() { @@ -4368,10 +7308,13 @@ schema: 1 entity_id, std::collections::BTreeMap::from([( runtime_name("codex"), - OverrideEntry(std::collections::BTreeMap::from([( - "api_token".to_string(), - serde_json::json!("redacted"), - )])), + OverrideEntry(std::collections::BTreeMap::from([ + ("api_token".to_string(), serde_json::json!("redacted")), + ( + "header".to_string(), + serde_json::json!("ghp_0123456789abcdefghijklmnopqrstuv"), + ), + ])), )]), ); if let Err(error) = write_lockfile(&repo, &lockfile) { @@ -4392,6 +7335,16 @@ schema: 1 finding.starts_with("lockfile_privacy_warning_") && finding.contains("override key `api_token`") })); + assert!(report.findings.iter().any(|finding| { + finding.starts_with("lockfile_privacy_warning_") + && finding.contains("override value `header`") + })); + assert!( + !report + .findings + .iter() + .any(|finding| finding.contains("ghp_0123456789")) + ); } #[test] diff --git a/crates/agentmesh-core/src/pipeline/doctor.rs b/crates/agentmesh-core/src/pipeline/doctor.rs index 32c51bb..d2ce79d 100644 --- a/crates/agentmesh-core/src/pipeline/doctor.rs +++ b/crates/agentmesh-core/src/pipeline/doctor.rs @@ -22,7 +22,9 @@ pub fn doctor_with_adapter_registry( .filter(|entry| entry.pending_conflict_resolution == Some(true)) .count(); let config = load_config(repo_root)?.config; - let capability_skipped = capability_skip_count_for_lockfile(&lockfile, &config)?; + let capability_report = + capability_skip_report_for_lockfile(&lockfile, &config, CapabilityReportMode::Diagnostic)?; + let capability_skipped = capability_report.skipped; let sync_state = entity_sync_state(repo_root, &lockfile)?; let privacy_findings = doctor_lockfile_privacy_findings(&lockfile); @@ -35,9 +37,20 @@ pub fn doctor_with_adapter_registry( findings.push(format!("failed_pending_syncs: {failed_pending_count}")); findings.extend(doctor_pending_failure_findings(&cache.pending_syncs_dir)?); findings.push(format!("capability_skips: {capability_skipped}")); + findings.extend( + capability_report + .findings + .iter() + .map(capability_skip_finding_message), + ); findings.push(format!("cache_root: {}", cache.root.display())); findings.extend(doctor_integrity_findings(repo_root, &cache)?); findings.extend(doctor_adapter_findings(repo_root, &lockfile, adapters)?); + findings.extend(doctor_codex_surface_findings(repo_root)?); + findings.extend(doctor_copilot_surface_findings(repo_root)?); + findings.extend(doctor_cursor_rule_findings(repo_root)?); + findings.extend(doctor_cursor_surface_findings(repo_root)?); + findings.extend(doctor_gemini_surface_findings(repo_root)?); findings.extend(doctor_hook_findings(repo_root, &cache)?); findings.extend(doctor_conflict_findings(&cache, &lockfile)?); findings.extend(privacy_findings.findings); @@ -198,18 +211,18 @@ fn collect_sensitive_json_keys( warnings: &mut Vec, warning_count: &mut usize, ) { - if let Some(key) = key { - if contains_sensitive_term(key) { - push_privacy_warning( - warnings, - warning_count, - format!( - "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", - entity_id.as_str(), - runtime.as_str() - ), - ); - } + if let Some(key) = key + && contains_sensitive_term(key) + { + push_privacy_warning( + warnings, + warning_count, + format!( + "override key `{key}` for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", + entity_id.as_str(), + runtime.as_str() + ), + ); } match value { @@ -237,7 +250,23 @@ fn collect_sensitive_json_keys( ); } } - Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + Value::String(value) => { + if looks_like_sensitive_value(value) { + let field = key + .map(|key| format!(" `{key}`")) + .unwrap_or_else(|| " string".to_string()); + push_privacy_warning( + warnings, + warning_count, + format!( + "override value{field} for `{}` at `{}` looks sensitive; keep secrets in machine-local config or environment variables", + entity_id.as_str(), + runtime.as_str() + ), + ); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} } } @@ -275,6 +304,36 @@ fn contains_sensitive_term(value: &str) -> bool { .any(|term| normalized.contains(term)) } +fn looks_like_sensitive_value(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.contains("-----BEGIN ") && trimmed.contains("PRIVATE KEY-----") { + return true; + } + if trimmed.starts_with("github_pat_") + || trimmed.starts_with("ghp_") + || trimmed.starts_with("gho_") + || trimmed.starts_with("ghu_") + || trimmed.starts_with("ghs_") + || trimmed.starts_with("ghr_") + || trimmed.starts_with("sk-") + || trimmed.starts_with("xoxb-") + || trimmed.starts_with("xoxp-") + { + return true; + } + trimmed.len() >= 40 + && trimmed + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '-')) + && trimmed + .chars() + .any(|character| character.is_ascii_lowercase()) + && trimmed + .chars() + .any(|character| character.is_ascii_uppercase()) + && trimmed.chars().any(|character| character.is_ascii_digit()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] struct EntitySyncState { in_sync: usize, @@ -441,12 +500,28 @@ fn doctor_adapter_findings( lockfile: &Lockfile, adapters: &dyn AdapterRegistry, ) -> Result> { - let markers = detect_runtime_markers(repo_root, adapters)?; + let mut findings = Vec::new(); + let claude_detected = + doctor_detect_runtime(repo_root, adapters, &runtime_name("claude")?, &mut findings); + let codex_detected = + doctor_detect_runtime(repo_root, adapters, &runtime_name("codex")?, &mut findings); + let copilot_detected = doctor_detect_runtime( + repo_root, + adapters, + &runtime_name("copilot")?, + &mut findings, + ); + let cursor_detected = + doctor_detect_runtime(repo_root, adapters, &runtime_name("cursor")?, &mut findings); + let gemini_detected = + doctor_detect_runtime(repo_root, adapters, &runtime_name("gemini")?, &mut findings); let known = [ - (runtime_name("claude")?, markers.claude), - (runtime_name("codex")?, markers.codex), + (runtime_name("claude")?, claude_detected), + (runtime_name("codex")?, codex_detected), + (runtime_name("copilot")?, copilot_detected), + (runtime_name("cursor")?, cursor_detected), + (runtime_name("gemini")?, gemini_detected), ]; - let mut findings = Vec::new(); let mut known_runtimes = BTreeSet::new(); for (runtime, detected) in &known { known_runtimes.insert(runtime.clone()); @@ -486,6 +561,12 @@ fn doctor_adapter_findings( ); for entity_type in [ EntityType::Instructions, + EntityType::Rule, + EntityType::Prompt, + EntityType::Command, + EntityType::Hook, + EntityType::McpBinding, + EntityType::PermissionPolicy, EntityType::Skill, EntityType::Subagent, ] { @@ -511,12 +592,903 @@ fn doctor_adapter_findings( Ok(findings) } +fn doctor_detect_runtime( + repo_root: &Path, + adapters: &dyn AdapterRegistry, + runtime: &RuntimeName, + findings: &mut Vec, +) -> bool { + match adapters.detect(runtime, repo_root) { + Ok(response) => response.present, + Err(error) => { + findings.push(format!( + "adapter_{}_detect_error: {error}", + runtime.as_str() + )); + false + } + } +} + fn adapter_mode_name(mode: AdapterMode) -> &'static str { match mode { AdapterMode::Bundled => "bundled", } } +fn doctor_codex_surface_findings(repo_root: &Path) -> Result> { + let mut findings = Vec::new(); + let config = repo_root.join(".codex/config.toml"); + let config_metadata = match fs::symlink_metadata(&config) { + Ok(metadata) => Some(metadata), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => None, + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: config.clone(), + source, + }); + } + }; + if config_metadata.is_some_and(|metadata| metadata.is_file()) { + let content = fs::read_to_string(&config).map_err(|source| PipelineError::Io { + action: "read file", + path: config.clone(), + source, + })?; + match content.parse::() { + Ok(value) + if value + .as_table() + .is_some_and(|table| table.contains_key("hooks")) => + { + findings.push( + "codex_inline_config_hooks: read-only diagnostic; .codex/config.toml hooks are not emitted".to_string(), + ); + } + Ok(_) => {} + Err(source) => findings.push(format!( + "codex_config_toml: invalid TOML at {}: {source}", + config.display() + )), + } + } + + let rules = count_files_with_extension(&repo_root.join(".codex/rules"), "rules")?; + if rules > 0 { + findings.push(format!( + "codex_experimental_rules: read-only diagnostic; {rules} file(s) are not emitted" + )); + } + let prompts = count_files(&repo_root.join(".codex/prompts"))?; + if prompts > 0 { + findings.push(format!( + "codex_custom_prompts: deferred; {prompts} file(s) are not emitted" + )); + } + let commands = count_files(&repo_root.join(".codex/commands"))?; + if commands > 0 { + findings.push(format!( + "codex_project_commands: deferred; {commands} file(s) are not emitted" + )); + } + Ok(findings) +} + +fn doctor_cursor_surface_findings(repo_root: &Path) -> Result> { + let mut findings = Vec::new(); + let skills = count_files(&repo_root.join(".cursor/skills"))?; + if skills > 0 { + findings.push(format!( + "cursor_skills: deferred; {skills} file(s) are not emitted" + )); + } + if regular_file_exists(&repo_root.join(".cursor/hooks.json"))? { + findings.push("cursor_hooks: deferred; .cursor/hooks.json is not emitted".to_string()); + } + let commands = count_files(&repo_root.join(".cursor/commands"))?; + if commands > 0 { + findings.push(format!( + "cursor_commands: deferred; {commands} file(s) are not emitted" + )); + } + let subagents = count_files(&repo_root.join(".cursor/agents"))?; + if subagents > 0 { + findings.push(format!( + "cursor_subagents: deferred; {subagents} file(s) are not emitted" + )); + } + if regular_file_exists(&repo_root.join(".cursor/mcp.json"))? { + findings.push("cursor_mcp: deferred; .cursor/mcp.json is not emitted".to_string()); + } + Ok(findings) +} + +fn doctor_cursor_rule_findings(repo_root: &Path) -> Result> { + let mut findings = Vec::new(); + collect_cursor_rule_findings(repo_root, &repo_root.join(".cursor/rules"), &mut findings)?; + Ok(findings) +} + +fn doctor_copilot_surface_findings(repo_root: &Path) -> Result> { + let mut findings = Vec::new(); + collect_copilot_instruction_findings( + repo_root, + &repo_root.join(".github/instructions"), + &mut findings, + )?; + collect_copilot_prompt_findings(repo_root, &repo_root.join(".github/prompts"), &mut findings)?; + collect_copilot_skill_findings( + repo_root, + &repo_root.join(".github/skills"), + false, + &mut findings, + )?; + collect_copilot_skill_findings( + repo_root, + &repo_root.join(".agents/skills"), + true, + &mut findings, + )?; + collect_copilot_agent_findings(repo_root, &repo_root.join(".github/agents"), &mut findings)?; + + let hooks = count_files(&repo_root.join(".github/hooks"))?; + if hooks > 0 { + findings.push(format!( + "copilot_hooks: deferred; {hooks} file(s) are not emitted" + )); + } + if regular_file_exists(&repo_root.join("mcp/repository-mcp-settings.json"))? { + findings.push( + "copilot_repository_mcp: deferred; repository MCP settings are not emitted".to_string(), + ); + } + if regular_file_exists(&repo_root.join(".github/workflows/copilot-setup-steps.yml"))? { + findings.push( + "copilot_setup_steps: deferred; Copilot setup steps configure execution environment and must never be emitted" + .to_string(), + ); + } + if regular_file_exists(&repo_root.join("environment/agent-environment.json"))? { + findings.push( + "copilot_agent_environment: deferred; Copilot agent environment variables and secrets are deferred and must never be emitted" + .to_string(), + ); + } + Ok(findings) +} + +fn doctor_gemini_surface_findings(repo_root: &Path) -> Result> { + let mut findings = Vec::new(); + collect_gemini_command_findings( + repo_root, + &repo_root.join(".gemini/commands"), + &mut findings, + )?; + collect_gemini_skill_findings( + repo_root, + &repo_root.join(".gemini/skills"), + false, + &mut findings, + )?; + collect_gemini_skill_findings( + repo_root, + &repo_root.join(".agents/skills"), + true, + &mut findings, + )?; + + let settings = repo_root.join(".gemini/settings.json"); + if regular_file_exists(&settings)? { + match read_json::(&settings) { + Ok(value) => { + if value.get("mcpServers").is_some() || value.get("mcp").is_some() { + findings.push( + "gemini_project_mcp: read-only diagnostic; project MCP settings are diagnostics-only and not emitted" + .to_string(), + ); + } + if value.get("policyPaths").is_some() || value.get("adminPolicyPaths").is_some() { + findings.push( + "gemini_policy_settings: read-only diagnostic; project policy settings are diagnostics-only and not emitted" + .to_string(), + ); + } + if value + .get("context") + .and_then(serde_json::Value::as_object) + .is_some_and(|context| context.contains_key("fileName")) + { + findings.push( + "gemini_custom_context_filenames: deferred; custom context filenames are detected but only GEMINI.md is emitted" + .to_string(), + ); + } + if value.get("hooks").is_some() { + findings.push( + "gemini_hooks: deferred; Gemini hooks are deferred and must never be emitted" + .to_string(), + ); + } + } + Err(source) => findings.push(format!( + "gemini_settings_invalid: {}: {source}", + relative_to(repo_root, &settings).display() + )), + } + } + + let subagents = count_files(&repo_root.join(".gemini/agents"))?; + if subagents > 0 { + findings.push(format!( + "gemini_subagents: deferred; {subagents} file(s) are not emitted" + )); + } + let hooks = count_files(&repo_root.join(".gemini/hooks"))?; + if hooks > 0 { + findings.push(format!( + "gemini_hooks: deferred; {hooks} file(s) are not emitted" + )); + } + let extensions = count_files(&repo_root.join(".gemini/extensions"))?; + if extensions > 0 { + findings.push(format!( + "gemini_extensions: deferred; {extensions} file(s) are not emitted" + )); + } + if regular_file_exists(&repo_root.join("gemini-extension.json"))? { + findings + .push("gemini_extensions: deferred; gemini-extension.json is not emitted".to_string()); + } + Ok(findings) +} + +fn collect_gemini_command_findings( + repo_root: &Path, + dir: &Path, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "gemini_command_invalid: {}: symlinked path is not supported", + relative_to(repo_root, dir).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let mut entries = fs::read_dir(dir) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "gemini_command_invalid: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if file_type.is_dir() { + collect_gemini_command_findings(repo_root, &path, findings)?; + continue; + } + if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("toml") + { + continue; + } + let relative = relative_to(repo_root, &path); + let content = fs::read_to_string(&path).map_err(|source| PipelineError::Io { + action: "read file", + path: path.clone(), + source, + })?; + match content.parse::() { + Ok(toml::Value::Table(table)) => { + if table + .get("prompt") + .and_then(toml::Value::as_str) + .is_none_or(|prompt| prompt.trim().is_empty()) + { + findings.push(format!( + "gemini_command_invalid: {}: missing required prompt field", + relative.display() + )); + } + } + Ok(_) => findings.push(format!( + "gemini_command_invalid: {}: TOML root must be a table", + relative.display() + )), + Err(source) => findings.push(format!( + "gemini_command_invalid: {}: failed to parse TOML: {source}", + relative.display() + )), + } + } + Ok(()) +} + +fn collect_gemini_skill_findings( + repo_root: &Path, + root: &Path, + shared: bool, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: root.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "gemini_skill_invalid: {}: symlinked path is not supported", + relative_to(repo_root, root).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let mut entries = fs::read_dir(root) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: root.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: root.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "gemini_skill_invalid: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if !file_type.is_dir() { + continue; + } + let skill = path.join("SKILL.md"); + if !regular_file_exists(&skill)? { + continue; + } + let content = fs::read_to_string(&skill).map_err(|source| PipelineError::Io { + action: "read file", + path: skill.clone(), + source, + })?; + if let Err(source) = split_canonical_markdown(&content) { + let message = if shared { + "invalid shared Gemini skill frontmatter must not be overwritten" + } else { + "invalid Gemini skill frontmatter must not be overwritten" + }; + findings.push(format!( + "gemini_skill_invalid: {}: {message}: {source}", + relative_to(repo_root, &skill).display() + )); + } + } + Ok(()) +} + +fn collect_copilot_instruction_findings( + repo_root: &Path, + dir: &Path, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "copilot_instruction_invalid: {}: symlinked path is not supported", + relative_to(repo_root, dir).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let mut entries = fs::read_dir(dir) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "copilot_instruction_invalid: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if file_type.is_dir() { + collect_copilot_instruction_findings(repo_root, &path, findings)?; + continue; + } + if !file_type.is_file() || !file_name_ends_with(&path, ".instructions.md") { + continue; + } + let relative = relative_to(repo_root, &path); + let content = fs::read_to_string(&path).map_err(|source| PipelineError::Io { + action: "read file", + path: path.clone(), + source, + })?; + match split_canonical_markdown(&content) { + Ok((frontmatter, _)) => { + if let Some(reason) = copilot_apply_to_diagnostic(&frontmatter) { + findings.push(format!( + "copilot_instruction_invalid: {}: {reason}", + relative.display() + )); + } + } + Err(source) => findings.push(format!( + "copilot_instruction_invalid: {}: invalid path-specific instruction frontmatter must not be overwritten: {source}", + relative.display() + )), + } + } + Ok(()) +} + +fn collect_copilot_prompt_findings( + repo_root: &Path, + dir: &Path, + findings: &mut Vec, +) -> Result<()> { + collect_copilot_markdown_file_findings( + repo_root, + dir, + ".prompt.md", + "copilot_prompt_invalid", + "invalid prompt frontmatter must not be overwritten", + findings, + ) +} + +fn collect_copilot_agent_findings( + repo_root: &Path, + dir: &Path, + findings: &mut Vec, +) -> Result<()> { + collect_copilot_markdown_file_findings( + repo_root, + dir, + ".md", + "copilot_custom_agent_invalid", + "invalid custom-agent frontmatter must not be overwritten", + findings, + ) +} + +fn collect_copilot_markdown_file_findings( + repo_root: &Path, + dir: &Path, + extension_suffix: &str, + label: &str, + parse_message: &str, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "{label}: {}: symlinked path is not supported", + relative_to(repo_root, dir).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let mut entries = fs::read_dir(dir) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "{label}: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if !file_type.is_file() || !file_name_ends_with(&path, extension_suffix) { + continue; + } + let relative = relative_to(repo_root, &path); + let content = fs::read_to_string(&path).map_err(|source| PipelineError::Io { + action: "read file", + path: path.clone(), + source, + })?; + if let Err(source) = split_canonical_markdown(&content) { + findings.push(format!( + "{label}: {}: {parse_message}: {source}", + relative.display() + )); + } + } + Ok(()) +} + +fn collect_copilot_skill_findings( + repo_root: &Path, + root: &Path, + shared: bool, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: root.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "copilot_skill_invalid: {}: symlinked path is not supported", + relative_to(repo_root, root).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + let mut entries = fs::read_dir(root) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: root.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: root.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "copilot_skill_invalid: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if !file_type.is_dir() { + continue; + } + let skill = path.join("SKILL.md"); + if !regular_file_exists(&skill)? { + continue; + } + let content = fs::read_to_string(&skill).map_err(|source| PipelineError::Io { + action: "read file", + path: skill.clone(), + source, + })?; + if let Err(source) = split_canonical_markdown(&content) { + let message = if shared { + "invalid shared skill frontmatter must not be overwritten" + } else { + "invalid skill frontmatter must not be overwritten" + }; + findings.push(format!( + "copilot_skill_invalid: {}: {message}: {source}", + relative_to(repo_root, &skill).display() + )); + } + } + Ok(()) +} + +fn file_name_ends_with(path: &Path, suffix: &str) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(suffix)) +} + +fn copilot_apply_to_diagnostic(frontmatter: &serde_norway::Mapping) -> Option<&'static str> { + match frontmatter.get(serde_norway::Value::String("applyTo".to_string())) { + Some(serde_norway::Value::String(value)) if !value.trim().is_empty() => None, + Some(serde_norway::Value::String(_)) | None => Some("missing applyTo frontmatter"), + Some(serde_norway::Value::Sequence(values)) => { + let mut count = 0; + for value in values { + let Some(scope) = value.as_str() else { + return Some("applyTo frontmatter must be a string or list of strings"); + }; + if scope.trim().is_empty() { + return Some("applyTo frontmatter entries must be non-empty strings"); + } + count += 1; + } + match count { + 0 => Some("missing applyTo frontmatter"), + 1 => None, + _ => Some("Copilot applyTo with multiple scopes cannot be represented losslessly"), + } + } + Some(_) => Some("applyTo frontmatter must be a string or list of strings"), + } +} + +fn collect_cursor_rule_findings( + repo_root: &Path, + dir: &Path, + findings: &mut Vec, +) -> Result<()> { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + findings.push(format!( + "cursor_rule_invalid: {}: symlinked path is not supported", + relative_to(repo_root, dir).display() + )); + return Ok(()); + } + if !metadata.is_dir() { + return Ok(()); + } + + let mut entries = fs::read_dir(dir) + .map_err(|source| PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type().map_err(|source| PipelineError::Io { + action: "read file type", + path: path.clone(), + source, + })?; + if file_type.is_symlink() { + findings.push(format!( + "cursor_rule_invalid: {}: symlinked path is not supported", + relative_to(repo_root, &path).display() + )); + continue; + } + if file_type.is_dir() { + collect_cursor_rule_findings(repo_root, &path, findings)?; + continue; + } + if !file_type.is_file() || path.extension().and_then(|value| value.to_str()) != Some("mdc") + { + continue; + } + let relative = relative_to(repo_root, &path); + let content = fs::read_to_string(&path).map_err(|source| PipelineError::Io { + action: "read file", + path: path.clone(), + source, + })?; + if let Err(source) = crate::merge::canonicalize_markdown(&content) { + findings.push(format!( + "cursor_rule_invalid: {}: {source}", + relative.display() + )); + continue; + } + let (frontmatter, _) = split_canonical_markdown(&content)?; + if let Some(reason) = cursor_globs_diagnostic(&frontmatter) { + findings.push(format!( + "cursor_rule_invalid: {}: {reason}", + relative.display(), + )); + } + } + Ok(()) +} + +fn cursor_globs_diagnostic(frontmatter: &serde_norway::Mapping) -> Option<&'static str> { + match frontmatter.get("globs") { + Some(serde_norway::Value::String(value)) if value.trim().is_empty() => { + Some("Cursor rule globs must contain one non-empty string scope") + } + Some(serde_norway::Value::String(_)) => None, + Some(serde_norway::Value::Sequence(values)) => { + let count = values + .iter() + .filter_map(serde_norway::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .count(); + match count { + 0 => Some("Cursor rule globs must contain one non-empty string scope"), + 1 => None, + _ => { + Some("Cursor rule globs with multiple scopes cannot be represented losslessly") + } + } + } + Some(_) => Some("Cursor rule globs must be a string or list of strings"), + None => None, + } +} + +fn regular_file_exists(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => Ok(metadata.is_file() && !metadata.file_type().is_symlink()), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(PipelineError::Io { + action: "read metadata", + path: path.to_path_buf(), + source, + }), + } +} + +fn count_files_with_extension(dir: &Path, extension: &str) -> Result { + count_files_matching(dir, &|path| { + path.extension().and_then(|value| value.to_str()) == Some(extension) + }) +} + +fn count_files(dir: &Path) -> Result { + count_files_matching(dir, &|_| true) +} + +fn count_files_matching(dir: &Path, predicate: &dyn Fn(&Path) -> bool) -> Result { + let metadata = match fs::symlink_metadata(dir) { + Ok(metadata) => metadata, + Err(source) if source.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(source) => { + return Err(PipelineError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + }); + } + }; + if metadata.file_type().is_symlink() { + return Ok(0); + } + if metadata.is_file() { + return Ok(usize::from(predicate(dir))); + } + if !metadata.is_dir() { + return Ok(0); + } + + let mut count = 0; + for entry in fs::read_dir(dir).map_err(|source| PipelineError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? { + let entry = entry.map_err(|source| PipelineError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + count += count_files_matching(&entry.path(), predicate)?; + } + Ok(count) +} + fn doctor_hook_findings(repo_root: &Path, cache: &CacheLayout) -> Result> { match read_hook_ownership(&cache.hook_ownership_json) { Ok(ownership) if ownership.0.is_empty() => Ok(vec!["hooks: none".to_string()]), diff --git a/crates/agentmesh-core/src/types.rs b/crates/agentmesh-core/src/types.rs index fe6f8dd..537cce2 100644 --- a/crates/agentmesh-core/src/types.rs +++ b/crates/agentmesh-core/src/types.rs @@ -48,7 +48,13 @@ impl EntityId { pub fn from_parts(entity_type: EntityType, slug: &str) -> Result { match entity_type { EntityType::Instructions if slug == "root" => Self::new("instructions:root"), - EntityType::Instructions => Self::new(format!("instructions:{slug}")), + EntityType::Instructions => Self::new(format!("instructions:scoped:{slug}")), + EntityType::Rule => Self::new(format!("rule:{slug}")), + EntityType::Prompt => Self::new(format!("prompt:{slug}")), + EntityType::Command => Self::new(format!("command:{slug}")), + EntityType::Hook => Self::new(format!("hook:{slug}")), + EntityType::McpBinding => Self::new(format!("mcp-binding:{slug}")), + EntityType::PermissionPolicy => Self::new(format!("permission-policy:{slug}")), EntityType::Skill => Self::new(format!("skill:{slug}")), EntityType::Subagent => Self::new(format!("subagent:{slug}")), } @@ -255,15 +261,26 @@ impl<'de> Deserialize<'de> for Hash { } fn is_valid_entity_id(value: &str) -> bool { - if value == "instructions:root" { - return true; - } - - let Some((kind, slug)) = value.split_once(':') else { + let mut parts = value.split(':'); + let Some(kind) = parts.next() else { return false; }; + let remaining = parts.collect::>(); - matches!(kind, "skill" | "subagent") && is_slug(slug) + match kind { + "instructions" => { + matches!(remaining.as_slice(), ["root"]) + || matches!(remaining.as_slice(), ["scoped", slug] if is_slug(slug)) + } + "command" => { + matches!(remaining.as_slice(), [slug] if is_slug(slug)) + || matches!(remaining.as_slice(), [namespace, slug] if is_slug(namespace) && is_slug(slug)) + } + "rule" | "prompt" | "hook" | "mcp-binding" | "permission-policy" | "skill" | "subagent" => { + matches!(remaining.as_slice(), [slug] if is_slug(slug)) + } + _ => false, + } } fn is_stable_name(value: &str) -> bool { @@ -289,10 +306,25 @@ mod tests { #[test] fn validates_entity_ids() { assert!(EntityId::new("instructions:root").is_ok()); + assert!(EntityId::new("instructions:scoped:api-review").is_ok()); + assert!(EntityId::new("rule:security").is_ok()); + assert!(EntityId::new("prompt:release-notes").is_ok()); + assert!(EntityId::new("command:git:commit").is_ok()); + assert!(EntityId::new("hook:gemini-project").is_ok()); + assert!(EntityId::new("mcp-binding:gemini-project").is_ok()); + assert!(EntityId::new("permission-policy:gemini-project").is_ok()); assert!(EntityId::new("skill:security-review-2").is_ok()); assert!(EntityId::new("subagent:code-reviewer").is_ok()); assert!(EntityId::new("instructions:foo").is_err()); + assert!(EntityId::new("instructions:scoped:").is_err()); + assert!(EntityId::new("command:").is_err()); + assert!(EntityId::new("command:git:").is_err()); + assert!(EntityId::new("command:git:commit:extra").is_err()); + assert!(EntityId::new("prompt:release_notes").is_err()); + assert!(EntityId::new("hook:Gemini").is_err()); + assert!(EntityId::new("mcp_binding:gemini-project").is_err()); + assert!(EntityId::new("permission-policy:../gemini").is_err()); assert!(EntityId::new("skill:Security").is_err()); assert!(EntityId::new("skill:").is_err()); } diff --git a/crates/agentmesh-core/tests/performance_budgets.rs b/crates/agentmesh-core/tests/performance_budgets.rs index 0de801e..7d069b0 100644 --- a/crates/agentmesh-core/tests/performance_budgets.rs +++ b/crates/agentmesh-core/tests/performance_budgets.rs @@ -102,6 +102,27 @@ fn with_runtime_adapter( message: source.to_string(), }) } + "copilot" => { + let adapter = agentmesh_adapter_copilot::CopilotAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } + "cursor" => { + let adapter = agentmesh_adapter_cursor::CursorAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } + "gemini" => { + let adapter = agentmesh_adapter_gemini::GeminiAdapter; + call(&adapter).map_err(|source| PipelineError::Adapter { + runtime: runtime.clone(), + message: source.to_string(), + }) + } _ => Err(PipelineError::Adapter { runtime: runtime.clone(), message: "unknown runtime adapter".to_string(), @@ -109,6 +130,25 @@ fn with_runtime_adapter( } } +fn write_seed_file( + path: &Path, + contents: impl AsRef<[u8]>, + action: &'static str, +) -> Result<(), PipelineError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| PipelineError::Io { + action: "create performance fixture directory", + path: parent.to_path_buf(), + source, + })?; + } + fs::write(path, contents).map_err(|source| PipelineError::Io { + action, + path: path.to_path_buf(), + source, + }) +} + fn seed_repo() -> Result<(TempDir, PathBuf), PipelineError> { let temp = TempDir::new().map_err(|source| PipelineError::Io { action: "create performance temp directory", @@ -126,6 +166,31 @@ fn seed_repo() -> Result<(TempDir, PathBuf), PipelineError> { path: repo.join(".codex"), source, })?; + write_seed_file( + &repo.join(".github/copilot-instructions.md"), + "Performance instructions.\n", + "write performance Copilot instructions", + )?; + write_seed_file( + &repo.join(".github/prompts/performance.prompt.md"), + "---\ndescription: Performance prompt\nmode: ask\n---\nReview performance.\n", + "write performance Copilot prompt", + )?; + write_seed_file( + &repo.join(".cursor/rules/performance.mdc"), + "---\ndescription: Performance rule\nalwaysApply: true\n---\nKeep hot paths efficient.\n", + "write performance Cursor rule", + )?; + write_seed_file( + &repo.join("GEMINI.md"), + "Performance instructions.\n", + "write performance Gemini context", + )?; + write_seed_file( + &repo.join(".gemini/commands/performance.toml"), + "description = \"Performance command\"\nprompt = \"Review performance for {{args}}.\"\n", + "write performance Gemini command", + )?; for index in 0..1000 { let slug = format!("skill-{index}"); let skill_dir = repo.join(".claude/skills").join(&slug); diff --git a/crates/agentmesh-core/tests/v02_fixture_manifests.rs b/crates/agentmesh-core/tests/v02_fixture_manifests.rs new file mode 100644 index 0000000..d4bf6e0 --- /dev/null +++ b/crates/agentmesh-core/tests/v02_fixture_manifests.rs @@ -0,0 +1,863 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use agentmesh_adapter_sdk_rust::Adapter; +use agentmesh_core::identity::derive_entity_id_as; +use agentmesh_core::{EntityId, EntityType}; +use agentmesh_protocol::{EmitEntity, EmitRequest, ImportRequest, RuntimeMode}; +use serde::Deserialize; + +const MINIMUM_SOURCE_CHECK_DATE: &str = "2026-06-18"; +const REQUIRED_WRITE_SURFACES: &[&str] = &[ + "copilot.repository-instructions", + "copilot.path-specific-instructions", + "copilot.prompt-file", + "copilot.skill", + "copilot.shared-skill", + "copilot.custom-agent", + "cursor.project-rule", + "claude.project-rule", + "claude.project-command", + "claude.project-hooks", + "claude.project-mcp", + "claude.project-permissions", + "codex.root-instructions", + "codex.scoped-instructions", + "codex.skill", + "codex.shared-skill", + "codex.subagent", + "codex.project-hook", + "codex.project-mcp", + "codex.permission-policy", + "gemini.root-context", + "gemini.nested-context", + "gemini.skill", + "gemini.shared-skill", + "gemini.project-command", +]; +const REQUIRED_READ_ONLY_SURFACES: &[&str] = &[ + "codex.inline-config-hooks", + "codex.experimental-rules", + "gemini.project-mcp-settings", + "gemini.policy-settings", +]; +const REQUIRED_DEFERRED_SURFACES: &[&str] = &[ + "copilot.hooks", + "copilot.repository-mcp", + "copilot.setup-steps", + "copilot.agent-environment", + "cursor.skills", + "cursor.hooks", + "cursor.commands", + "cursor.subagents", + "cursor.mcp", + "codex.custom-prompts", + "codex.project-commands", + "gemini.subagents", + "gemini.hooks", + "gemini.extensions", + "gemini.custom-context-filenames", +]; +const FIXTURE_GROUPS: &[&str] = &[ + "adapters/copilot/fixtures/instructions", + "adapters/copilot/fixtures/prompts", + "adapters/copilot/fixtures/skills", + "adapters/copilot/fixtures/agents", + "adapters/copilot/fixtures/deferred", + "adapters/cursor/fixtures/rules", + "adapters/cursor/fixtures/deferred", + "adapters/claude/fixtures/rules", + "adapters/claude/fixtures/commands", + "adapters/claude/fixtures/settings", + "adapters/claude/fixtures/mcp", + "adapters/codex/fixtures/instructions", + "adapters/codex/fixtures/skills", + "adapters/codex/fixtures/agents", + "adapters/codex/fixtures/hooks", + "adapters/codex/fixtures/config", + "adapters/codex/fixtures/diagnostics", + "adapters/codex/fixtures/deferred", + "adapters/gemini/fixtures/context", + "adapters/gemini/fixtures/skills", + "adapters/gemini/fixtures/commands", + "adapters/gemini/fixtures/diagnostics", +]; +const SUPPORT_LEVELS: &[&str] = &["write_enabled", "read_only", "deferred"]; +const CASES: &[&str] = &[ + "minimum_valid", + "all_supported_metadata", + "unknown_metadata", + "invalid_syntax", + "invalid_syntax_not_applicable", + "round_trip", + "sync_check", + "doctor", + "read_only_diagnostic", + "deferred_detection", +]; +const REQUIRED_WRITE_CASES: &[&str] = &[ + "minimum_valid", + "all_supported_metadata", + "unknown_metadata", + "round_trip", + "sync_check", + "doctor", +]; +const ENTITY_TYPES: &[&str] = &[ + "instructions:root", + "instructions:scoped", + "rule", + "prompt", + "command", + "hook", + "mcp_binding", + "permission_policy", + "skill", + "subagent", +]; + +#[derive(Debug, Deserialize)] +struct Manifest { + fixture: Vec, +} + +#[derive(Debug, Deserialize)] +struct Fixture { + name: String, + surface: String, + cases: Vec, + source_url: String, + source_checked_on: String, + support_level: String, + canonical_entity_type: String, + fixture_file_path: PathBuf, + fixture_root_path: Option, + expected_canonical_id: String, + expected_emitted_path: Option, + #[serde(default)] + expected_files: Vec, + #[serde(default)] + expected_diagnostics: Vec, + #[serde(default)] + expected_behaviors: Vec, +} + +#[test] +fn fixture_manifests_cover_declared_runtime_surfaces() { + let workspace = workspace_root(); + let mut fixture_count = 0; + let mut write_surface_cases = surface_case_map(REQUIRED_WRITE_SURFACES); + let mut read_only_surface_cases = surface_case_map(REQUIRED_READ_ONLY_SURFACES); + let mut deferred_surface_cases = surface_case_map(REQUIRED_DEFERRED_SURFACES); + + for group in FIXTURE_GROUPS { + let group_root = workspace.join(group); + let manifest_path = group_root.join("manifest.toml"); + let manifest = parse_manifest(&manifest_path); + let mut seen_names = BTreeSet::new(); + assert!( + !manifest.fixture.is_empty(), + "{} must contain at least one fixture entry", + manifest_path.display() + ); + + for fixture in manifest.fixture { + fixture_count += 1; + assert!( + seen_names.insert(fixture.name.clone()), + "{} duplicates fixture name {}", + manifest_path.display(), + fixture.name + ); + validate_fixture(&group_root, &manifest_path, &fixture); + record_surface_cases( + &mut write_surface_cases, + &mut read_only_surface_cases, + &mut deferred_surface_cases, + &manifest_path, + &fixture, + ); + } + } + + assert_write_surface_coverage(write_surface_cases); + assert_surface_case(read_only_surface_cases, "read_only_diagnostic", "read-only"); + assert_surface_case(deferred_surface_cases, "deferred_detection", "deferred"); + + assert!( + fixture_count >= FIXTURE_GROUPS.len(), + "expected fixture entries for all declared groups, found {fixture_count} entries" + ); +} + +fn parse_manifest(path: &Path) -> Manifest { + let content = fs::read_to_string(path).unwrap_or_else(|error| { + panic!("manifest should be readable at {}: {error}", path.display()) + }); + toml::from_str(&content) + .unwrap_or_else(|error| panic!("manifest should parse at {}: {error}", path.display())) +} + +fn validate_fixture(group_root: &Path, manifest_path: &Path, fixture: &Fixture) { + assert_field(manifest_path, fixture, "name", &fixture.name); + assert_field(manifest_path, fixture, "surface", &fixture.surface); + assert!( + valid_surface_id(&fixture.surface), + "{} fixture {} has invalid surface {}", + manifest_path.display(), + fixture.name, + fixture.surface + ); + assert!( + !fixture.cases.is_empty(), + "{} fixture {} must declare at least one case", + manifest_path.display(), + fixture.name + ); + for case in &fixture.cases { + assert!( + CASES.contains(&case.as_str()), + "{} fixture {} has unsupported case {}", + manifest_path.display(), + fixture.name, + case + ); + } + assert_field(manifest_path, fixture, "source_url", &fixture.source_url); + assert!( + fixture.source_url.starts_with("https://"), + "{} fixture {} source_url must be HTTPS", + manifest_path.display(), + fixture.name + ); + assert!( + valid_iso_date(&fixture.source_checked_on) + && fixture.source_checked_on.as_str() >= MINIMUM_SOURCE_CHECK_DATE, + "{} fixture {} has stale or invalid source_checked_on {}", + manifest_path.display(), + fixture.name, + fixture.source_checked_on + ); + assert!( + SUPPORT_LEVELS.contains(&fixture.support_level.as_str()), + "{} fixture {} has unsupported support_level {}", + manifest_path.display(), + fixture.name, + fixture.support_level + ); + assert!( + ENTITY_TYPES.contains(&fixture.canonical_entity_type.as_str()), + "{} fixture {} has unsupported canonical_entity_type {}", + manifest_path.display(), + fixture.name, + fixture.canonical_entity_type + ); + assert!( + valid_canonical_id(&fixture.expected_canonical_id), + "{} fixture {} has invalid expected_canonical_id {}", + manifest_path.display(), + fixture.name, + fixture.expected_canonical_id + ); + assert!( + EntityId::new(&fixture.expected_canonical_id).is_ok(), + "{} fixture {} expected_canonical_id {} is not accepted by core", + manifest_path.display(), + fixture.name, + fixture.expected_canonical_id + ); + validate_path_derived_id(manifest_path, fixture); + assert_safe_relative( + manifest_path, + &fixture.name, + "fixture_file_path", + &fixture.fixture_file_path, + ); + assert!( + group_root.join(&fixture.fixture_file_path).is_file(), + "{} fixture {} missing file {}", + manifest_path.display(), + fixture.name, + fixture.fixture_file_path.display() + ); + let fixture_root = if let Some(root_path) = &fixture.fixture_root_path { + assert_safe_relative(manifest_path, &fixture.name, "fixture_root_path", root_path); + let root = group_root.join(root_path); + assert!( + root.is_dir(), + "{} fixture {} missing root {}", + manifest_path.display(), + fixture.name, + root_path.display() + ); + root + } else { + group_root.to_path_buf() + }; + for expected_file in &fixture.expected_files { + assert_safe_relative( + manifest_path, + &fixture.name, + "expected_files", + expected_file, + ); + assert!( + fixture_root.join(expected_file).is_file(), + "{} fixture {} missing expected file {} under {}", + manifest_path.display(), + fixture.name, + expected_file.display(), + fixture_root.display() + ); + } + + if fixture.support_level == "write_enabled" { + if fixture.cases.iter().any(|case| case == "invalid_syntax") { + assert!( + fixture.expected_emitted_path.is_none(), + "{} fixture {} must not emit invalid syntax samples", + manifest_path.display(), + fixture.name + ); + assert!( + !fixture.expected_diagnostics.is_empty(), + "{} fixture {} must declare diagnostics for invalid syntax", + manifest_path.display(), + fixture.name + ); + } else if let Some(emitted_path) = &fixture.expected_emitted_path { + assert_safe_relative( + manifest_path, + &fixture.name, + "expected_emitted_path", + emitted_path, + ); + } else { + assert!( + !fixture.expected_diagnostics.is_empty(), + "{} fixture {} must declare diagnostics when emit is blocked", + manifest_path.display(), + fixture.name + ); + } + } else { + assert!( + fixture.expected_emitted_path.is_none(), + "{} fixture {} must not declare expected_emitted_path", + manifest_path.display(), + fixture.name + ); + assert!( + !fixture.expected_diagnostics.is_empty(), + "{} fixture {} must declare diagnostics for non-write-enabled surfaces", + manifest_path.display(), + fixture.name + ); + } + if fixture + .cases + .iter() + .any(|case| case == "invalid_syntax_not_applicable") + { + assert!( + !fixture.expected_behaviors.is_empty(), + "{} fixture {} must explain non-applicable syntax validation", + manifest_path.display(), + fixture.name + ); + } +} + +fn assert_field(manifest_path: &Path, fixture: &Fixture, field: &str, value: &str) { + assert!( + !value.trim().is_empty(), + "{} fixture {} has empty {field}", + manifest_path.display(), + fixture.name + ); +} + +fn assert_safe_relative(manifest_path: &Path, fixture_name: &str, field: &str, path: &Path) { + assert!( + !path.as_os_str().is_empty() && !path.is_absolute(), + "{} fixture {fixture_name} has invalid {field} {}", + manifest_path.display(), + path.display() + ); + assert!( + !path.to_string_lossy().contains('\\'), + "{} fixture {fixture_name} has platform-specific {field} {}", + manifest_path.display(), + path.display() + ); + assert!( + path.components() + .all(|component| matches!(component, Component::Normal(_))), + "{} fixture {fixture_name} has unsafe {field} {}", + manifest_path.display(), + path.display() + ); +} + +fn valid_iso_date(value: &str) -> bool { + let bytes = value.as_bytes(); + bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && bytes + .iter() + .enumerate() + .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit()) +} + +fn valid_canonical_id(value: &str) -> bool { + !value.is_empty() + && value.split(':').all(|part| { + !part.is_empty() + && part.split('-').all(|segment| { + !segment.is_empty() + && segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() + }) + }) + }) +} + +fn valid_surface_id(value: &str) -> bool { + !value.is_empty() + && value.split('.').all(|part| { + !part.is_empty() + && part.split('-').all(|segment| { + !segment.is_empty() + && segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() + }) + }) + }) +} + +fn validate_path_derived_id(manifest_path: &Path, fixture: &Fixture) { + if is_adapter_derived_identity(fixture) { + return; + } + let Some(runtime_path) = runtime_relative_fixture_path(&fixture.fixture_file_path) else { + assert!( + fixture.support_level == "deferred", + "{} fixture {} has no runtime-relative path for identity derivation", + manifest_path.display(), + fixture.name + ); + return; + }; + let Some(entity_type) = production_entity_type(&fixture.canonical_entity_type) else { + return; + }; + + match derive_entity_id_as(entity_type, &runtime_path) { + Ok(actual) => assert_eq!( + actual.as_str(), + fixture.expected_canonical_id, + "{} fixture {} expected_canonical_id does not match production derivation from {}", + manifest_path.display(), + fixture.name, + runtime_path.display() + ), + Err(error) => assert!( + fixture.support_level == "deferred", + "{} fixture {} path {} did not derive a production ID: {error}", + manifest_path.display(), + fixture.name, + runtime_path.display() + ), + } +} + +fn runtime_relative_fixture_path(path: &Path) -> Option { + let parts = path + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect::>(); + + for (index, part) in parts.iter().enumerate() { + if matches!( + *part, + ".mcp.json" + | ".ai" + | ".agents" + | ".claude" + | ".codex" + | ".github" + | ".gemini" + | ".cursor" + ) { + return Some(pathbuf_from_parts(&parts[index..])); + } + } + + if parts + .first() + .is_some_and(|part| matches!(*part, "environment" | "mcp")) + { + return Some(pathbuf_from_parts(&parts)); + } + + for (index, part) in parts.iter().enumerate() { + if matches!(*part, "AGENTS.md" | "CLAUDE.md" | "GEMINI.md") { + let start = if index > 0 && matches!(parts[0], "root" | "nested") { + 1 + } else { + index + }; + return Some(pathbuf_from_parts(&parts[start..])); + } + } + + None +} + +fn pathbuf_from_parts(parts: &[&str]) -> PathBuf { + parts.iter().fold(PathBuf::new(), |mut path, part| { + path.push(part); + path + }) +} + +fn production_entity_type(value: &str) -> Option { + match value { + "instructions:root" | "instructions:scoped" => Some(EntityType::Instructions), + "rule" => Some(EntityType::Rule), + "prompt" => Some(EntityType::Prompt), + "command" => Some(EntityType::Command), + "hook" => Some(EntityType::Hook), + "mcp_binding" => Some(EntityType::McpBinding), + "permission_policy" => Some(EntityType::PermissionPolicy), + "skill" => Some(EntityType::Skill), + "subagent" => Some(EntityType::Subagent), + _ => None, + } +} + +fn is_adapter_derived_identity(fixture: &Fixture) -> bool { + matches!( + fixture.surface.as_str(), + "copilot.hooks" + | "copilot.repository-mcp" + | "copilot.setup-steps" + | "copilot.agent-environment" + | "cursor.skills" + | "cursor.hooks" + | "cursor.commands" + | "cursor.subagents" + | "cursor.mcp" + | "codex.inline-config-hooks" + | "codex.custom-prompts" + | "codex.project-commands" + | "gemini.custom-context-filenames" + | "gemini.subagents" + | "gemini.hooks" + | "gemini.extensions" + ) || (matches!( + fixture.surface.as_str(), + "claude.project-rule" | "cursor.project-rule" + ) && fixture.canonical_entity_type == "instructions:scoped") +} + +fn surface_case_map(surfaces: &[&str]) -> BTreeMap> { + surfaces + .iter() + .map(|surface| ((*surface).to_string(), BTreeSet::new())) + .collect() +} + +fn record_surface_cases( + write_surfaces: &mut BTreeMap>, + read_only_surfaces: &mut BTreeMap>, + deferred_surfaces: &mut BTreeMap>, + manifest_path: &Path, + fixture: &Fixture, +) { + let cases = match fixture.support_level.as_str() { + "write_enabled" => write_surfaces.get_mut(&fixture.surface), + "read_only" => read_only_surfaces.get_mut(&fixture.surface), + "deferred" => deferred_surfaces.get_mut(&fixture.surface), + _ => None, + }; + let Some(cases) = cases else { + panic!( + "{} fixture {} declares unexpected surface {} for {}", + manifest_path.display(), + fixture.name, + fixture.surface, + fixture.support_level + ); + }; + cases.extend(fixture.cases.iter().cloned()); +} + +fn assert_write_surface_coverage(surfaces: BTreeMap>) { + for (surface, cases) in surfaces { + for required_case in REQUIRED_WRITE_CASES { + assert!( + cases.contains(*required_case), + "write-enabled surface {surface} is missing case {required_case}" + ); + } + assert!( + cases.contains("invalid_syntax") || cases.contains("invalid_syntax_not_applicable"), + "write-enabled surface {surface} is missing invalid syntax coverage" + ); + } +} + +fn assert_surface_case( + surfaces: BTreeMap>, + required_case: &str, + support_label: &str, +) { + for (surface, cases) in surfaces { + assert!( + cases.contains(required_case) && cases.contains("doctor"), + "{support_label} surface {surface} is missing {required_case} or doctor coverage" + ); + } +} + +#[test] +fn implemented_runtime_fixtures_match_adapter_behavior() { + let workspace = workspace_root(); + for group in FIXTURE_GROUPS.iter().filter(|group| { + group.starts_with("adapters/copilot") + || group.starts_with("adapters/claude") + || group.starts_with("adapters/codex") + || group.starts_with("adapters/cursor") + || group.starts_with("adapters/gemini") + }) { + let group_root = workspace.join(group); + let manifest = parse_manifest(&group_root.join("manifest.toml")); + for fixture in manifest.fixture { + validate_phase2_fixture_behavior(&group_root, &fixture); + } + } +} + +fn validate_phase2_fixture_behavior(group_root: &Path, fixture: &Fixture) { + let Some(runtime) = fixture.surface.split_once('.').map(|(runtime, _)| runtime) else { + panic!( + "fixture {} has invalid surface {}", + fixture.name, fixture.surface + ); + }; + let source_workspace = fixture_workspace(group_root, fixture); + let temp = + tempfile::tempdir().unwrap_or_else(|error| panic!("tempdir should be available: {error}")); + let repo = temp.path().join("repo"); + copy_dir_all(&source_workspace, &repo) + .unwrap_or_else(|error| panic!("fixture workspace should copy: {error}")); + seed_runtime_presence_marker(runtime, fixture, &repo); + + let imported = import_with_adapter(runtime, &repo) + .unwrap_or_else(|error| panic!("{} import should run: {error}", fixture.name)); + assert_expected_diagnostics(fixture, &imported.skipped); + let imported_entity = imported + .entities + .iter() + .find(|entity| entity.id == fixture.expected_canonical_id); + + if fixture.support_level == "write_enabled" + && !fixture.cases.iter().any(|case| case == "invalid_syntax") + { + let entity = imported_entity.unwrap_or_else(|| { + panic!( + "fixture {} did not import expected entity {}; imported ids: {:?}; skipped: {:?}", + fixture.name, + fixture.expected_canonical_id, + imported + .entities + .iter() + .map(|entity| entity.id.as_str()) + .collect::>(), + imported.skipped + ) + }); + assert_eq!( + entity.entity_type, + production_entity_type(&fixture.canonical_entity_type) + .unwrap_or_else(|| panic!("fixture {} has unsupported type", fixture.name)) + ); + let expected_path = fixture.expected_emitted_path.as_ref().unwrap_or_else(|| { + panic!( + "write-enabled fixture {} must declare expected_emitted_path", + fixture.name + ) + }); + let emit_repo = temp.path().join("emit"); + fs::create_dir_all(&emit_repo) + .unwrap_or_else(|error| panic!("emit repo should be created: {error}")); + emit_with_adapter(runtime, &emit_repo, entity.clone()) + .unwrap_or_else(|error| panic!("{} emit should run: {error}", fixture.name)); + assert!( + emit_repo.join(expected_path).is_file(), + "fixture {} did not emit expected path {}", + fixture.name, + expected_path.display() + ); + } else { + assert!( + imported_entity.is_none(), + "fixture {} should not import canonical entity {}", + fixture.name, + fixture.expected_canonical_id + ); + assert!( + !imported.skipped.is_empty(), + "fixture {} should report skipped diagnostics", + fixture.name + ); + } +} + +fn seed_runtime_presence_marker(runtime: &str, fixture: &Fixture, repo: &Path) { + if runtime == "gemini" && fixture.surface == "gemini.shared-skill" { + fs::write(repo.join("GEMINI.md"), "# Gemini\n") + .unwrap_or_else(|error| panic!("Gemini marker should be written: {error}")); + } +} + +fn assert_expected_diagnostics(fixture: &Fixture, skipped: &[agentmesh_protocol::SkippedPath]) { + for expected in &fixture.expected_diagnostics { + assert!( + skipped + .iter() + .any(|skipped| skipped.reason.contains(expected)), + "fixture {} did not report expected diagnostic {:?}; skipped: {:?}", + fixture.name, + expected, + skipped + ); + } +} + +fn import_with_adapter( + runtime: &str, + repo: &Path, +) -> Result { + match runtime { + "claude" => agentmesh_adapter_claude::ClaudeAdapter.import(ImportRequest { + canonical_dir: repo.join(".ai"), + runtime_dir: repo.join(".claude"), + filter: None, + }), + "codex" => agentmesh_adapter_codex::CodexAdapter.import(ImportRequest { + canonical_dir: repo.join(".ai"), + runtime_dir: repo.join(".codex"), + filter: None, + }), + "copilot" => agentmesh_adapter_copilot::CopilotAdapter.import(ImportRequest { + canonical_dir: repo.join(".ai"), + runtime_dir: repo.join(".github"), + filter: None, + }), + "cursor" => agentmesh_adapter_cursor::CursorAdapter.import(ImportRequest { + canonical_dir: repo.join(".ai"), + runtime_dir: repo.join(".cursor"), + filter: None, + }), + "gemini" => agentmesh_adapter_gemini::GeminiAdapter.import(ImportRequest { + canonical_dir: repo.join(".ai"), + runtime_dir: repo.join(".gemini"), + filter: None, + }), + other => panic!("unsupported implemented fixture runtime {other}"), + } +} + +fn emit_with_adapter( + runtime: &str, + repo: &Path, + entity: agentmesh_protocol::ImportedEntity, +) -> Result { + let request = EmitRequest { + runtime_dir: repo.join(format!(".{runtime}")), + mode: RuntimeMode::Managed, + entities: vec![EmitEntity { + id: entity.id, + entity_type: entity.entity_type, + scope: entity.scope, + source_path: Some(entity.source_path), + files: entity.files, + frontmatter: entity.frontmatter, + overrides: BTreeMap::new(), + }], + }; + match runtime { + "claude" => agentmesh_adapter_claude::ClaudeAdapter.emit(request), + "codex" => agentmesh_adapter_codex::CodexAdapter.emit(request), + "copilot" => agentmesh_adapter_copilot::CopilotAdapter.emit(EmitRequest { + runtime_dir: repo.join(".github"), + ..request + }), + "cursor" => agentmesh_adapter_cursor::CursorAdapter.emit(request), + "gemini" => agentmesh_adapter_gemini::GeminiAdapter.emit(request), + other => panic!("unsupported implemented fixture runtime {other}"), + } +} + +fn fixture_workspace(group_root: &Path, fixture: &Fixture) -> PathBuf { + let Some(runtime_path) = runtime_relative_fixture_path(&fixture.fixture_file_path) else { + assert_eq!( + fixture.support_level, "deferred", + "fixture {} should have runtime path", + fixture.name + ); + let parent = fixture + .fixture_file_path + .parent() + .unwrap_or_else(|| Path::new("")); + return group_root.join(parent); + }; + let runtime_component_count = runtime_path.components().count(); + let mut workspace_relative = fixture.fixture_file_path.clone(); + for _ in 0..runtime_component_count { + assert!( + workspace_relative.pop(), + "fixture {} path {} does not contain runtime path {}", + fixture.name, + fixture.fixture_file_path.display(), + runtime_path.display() + ); + } + group_root.join(workspace_relative) +} + +fn copy_dir_all(source: &Path, target: &Path) -> std::io::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let file_type = entry.file_type()?; + let target_path = target.join(entry.file_name()); + if file_type.is_dir() { + copy_dir_all(&entry.path(), &target_path)?; + } else if file_type.is_file() { + if let Some(parent) = target_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), target_path)?; + } + } + Ok(()) +} + +fn workspace_root() -> PathBuf { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let Some(workspace) = manifest_dir.parent().and_then(Path::parent) else { + panic!( + "workspace root should be two directories above {}", + manifest_dir.display() + ); + }; + workspace.to_path_buf() +} diff --git a/crates/agentmesh-protocol/src/lib.rs b/crates/agentmesh-protocol/src/lib.rs index 5695227..5c3e578 100644 --- a/crates/agentmesh-protocol/src/lib.rs +++ b/crates/agentmesh-protocol/src/lib.rs @@ -12,7 +12,7 @@ use serde_json::Value; use thiserror::Error; /// Adapter protocol version supported by this workspace. -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; /// JSON-RPC protocol marker. pub const JSONRPC_VERSION: &str = "2.0"; @@ -26,6 +26,18 @@ pub const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; pub enum EntityType { /// Project-wide instructions. Instructions, + /// Runtime rule or scoped instruction metadata. + Rule, + /// Reusable prompt file. + Prompt, + /// Runtime command definition. + Command, + /// Runtime hook configuration. + Hook, + /// MCP server or tool binding configuration. + McpBinding, + /// Runtime permission or policy configuration. + PermissionPolicy, /// A named skill with optional supporting files. Skill, /// A delegated task agent. @@ -38,10 +50,25 @@ impl EntityType { pub const fn as_str(self) -> &'static str { match self { Self::Instructions => "instructions", + Self::Rule => "rule", + Self::Prompt => "prompt", + Self::Command => "command", + Self::Hook => "hook", + Self::McpBinding => "mcp-binding", + Self::PermissionPolicy => "permission-policy", Self::Skill => "skill", Self::Subagent => "subagent", } } + + /// Returns whether the entity is configuration data that must never be executed by core. + #[must_use] + pub const fn is_configuration_only(self) -> bool { + matches!( + self, + Self::Command | Self::Hook | Self::McpBinding | Self::PermissionPolicy + ) + } } impl fmt::Display for EntityType { @@ -516,6 +543,9 @@ pub struct EmitEntity { /// Instruction scope when relevant. #[serde(default, skip_serializing_if = "Option::is_none")] pub scope: Option, + /// Preferred runtime source path when the entity has a durable native location. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_path: Option, /// Entity file contents keyed by entity-relative path. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub files: BTreeMap, @@ -903,6 +933,36 @@ mod tests { assert_eq!(value["encoding"], "utf-8"); } + #[test] + fn serializes_entity_type_spellings() { + let cases = [ + (EntityType::Instructions, "instructions"), + (EntityType::Rule, "rule"), + (EntityType::Prompt, "prompt"), + (EntityType::Command, "command"), + (EntityType::Hook, "hook"), + (EntityType::McpBinding, "mcp-binding"), + (EntityType::PermissionPolicy, "permission-policy"), + (EntityType::Skill, "skill"), + (EntityType::Subagent, "subagent"), + ]; + + for (entity_type, expected) in cases { + let encoded = match serde_json::to_string(&entity_type) { + Ok(encoded) => encoded, + Err(error) => panic!("entity type should serialize: {error}"), + }; + let decoded = match serde_json::from_str::(&encoded) { + Ok(decoded) => decoded, + Err(error) => panic!("entity type should deserialize: {error}"), + }; + + assert_eq!(encoded, format!("\"{expected}\"")); + assert_eq!(decoded, entity_type); + assert_eq!(entity_type.as_str(), expected); + } + } + #[test] fn entity_files_preserve_binary_payloads() { let file = EntityFile::from_bytes(vec![0, 159, 146, 150]); diff --git a/crates/agentmesh-watcher/src/lib.rs b/crates/agentmesh-watcher/src/lib.rs index 38d505f..e0a56ca 100644 --- a/crates/agentmesh-watcher/src/lib.rs +++ b/crates/agentmesh-watcher/src/lib.rs @@ -26,6 +26,9 @@ const BACKGROUND_START_TIMEOUT: Duration = Duration::from_secs(10); const LONG_POLL_TIMEOUT: Duration = Duration::from_secs(60); const MAX_LOG_BYTES: u64 = 10 * 1024 * 1024; const MAX_ROTATED_LOGS: u8 = 3; +const MAX_SKILL_TREE_DEPTH: usize = 32; +const MAX_SKILL_FILE_COUNT: usize = 1024; +const MAX_SKILL_TOTAL_BYTES: u64 = 64 * 1024 * 1024; const STATE_RUNNING: &str = "running"; const STATE_IDLE: &str = "idle"; @@ -218,6 +221,7 @@ struct SelfWriteEntry { location_key: String, lockfile_path: PathBuf, path: PathBuf, + entity_type: Option, hash: String, } @@ -234,6 +238,8 @@ struct SuppressionLockfile { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] struct SuppressionEntity { + #[serde(default, rename = "type")] + entity_type: Option, #[serde(default)] locations: BTreeMap, #[serde(default)] @@ -264,26 +270,25 @@ fn start_with_cache_root( let layout = WatcherLayout::new(repo_root, cache_root)?; layout.ensure_dirs()?; - if !opts.register_as_service { - if let Some(record) = read_active_record(&layout)? { - if is_running_state(&record.state) { - if opts.foreground - && record.pid == std::process::id() - && record.state == STATE_BACKGROUND_SPAWNED - { - return run_foreground(repo_root, opts, &layout); - } - append_log( - &layout.log_file, - "start-idempotent", - json!({ - "pid": record.pid, - "state": record.state, - }), - )?; - return Ok(handle(repo_root, &layout)); - } + if !opts.register_as_service + && let Some(record) = read_active_record(&layout)? + && is_running_state(&record.state) + { + if opts.foreground + && record.pid == std::process::id() + && record.state == STATE_BACKGROUND_SPAWNED + { + return run_foreground(repo_root, opts, &layout); } + append_log( + &layout.log_file, + "start-idempotent", + json!({ + "pid": record.pid, + "state": record.state, + }), + )?; + return Ok(handle(repo_root, &layout)); } if opts.register_as_service { @@ -303,18 +308,18 @@ fn spawn_background( opts: WatchOptions, layout: &WatcherLayout, ) -> Result { - if let Some(record) = read_active_record(layout)? { - if is_running_state(&record.state) { - append_log( - &layout.log_file, - "start-idempotent", - json!({ - "pid": record.pid, - "state": record.state, - }), - )?; - return Ok(handle(repo_root, layout)); - } + if let Some(record) = read_active_record(layout)? + && is_running_state(&record.state) + { + append_log( + &layout.log_file, + "start-idempotent", + json!({ + "pid": record.pid, + "state": record.state, + }), + )?; + return Ok(handle(repo_root, layout)); } let executable = env::current_exe().map_err(|source| WatcherError::Io { @@ -374,10 +379,12 @@ fn spawn_background( fn wait_for_background_start(layout: &WatcherLayout, pid: u32) -> Result<()> { let deadline = Instant::now() + BACKGROUND_START_TIMEOUT; loop { - if let Ok(record) = read_json::(&layout.state_file) { - if record.pid == pid && record.state == STATE_RUNNING && process_running(pid) { - return Ok(()); - } + if let Ok(record) = read_json::(&layout.state_file) + && record.pid == pid + && record.state == STATE_RUNNING + && process_running(pid) + { + return Ok(()); } if Instant::now() >= deadline { append_log( @@ -735,10 +742,17 @@ impl SelfWriteIndex { for entity in lockfile.entities.values() { for (location_key, location_path) in &entity.locations { if let Some(hash) = entity.emitted_native_sha256.get(location_key) { + let path = normalize_lockfile_location(repo_root, location_key, location_path); + if entity.entity_type.as_deref() == Some("skill") + && !skill_primary_path_allowed(repo_root, &path) + { + continue; + } entries.push(SelfWriteEntry { location_key: location_key.clone(), lockfile_path: location_path.clone(), - path: normalize_lockfile_location(repo_root, location_key, location_path), + path, + entity_type: entity.entity_type.clone(), hash: hash.to_ascii_lowercase(), }); } @@ -755,6 +769,9 @@ impl SelfWriteIndex { ) -> Option<&'a SelfWriteEntry> { let normalized = normalize_repo_path(repo_root, path); self.entries.iter().find(|entry| { + if entry.entity_type.as_deref() == Some("skill") { + return skill_hash_matches(&entry.path, &normalized, &entry.hash); + } entry.path == normalized && path.is_file() && matches!(sha256_file_hex(path), Ok(hash) if hash == entry.hash) @@ -762,6 +779,55 @@ impl SelfWriteIndex { } } +fn skill_primary_path_allowed(repo_root: &Path, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(repo_root) else { + return false; + }; + if !relative + .components() + .all(|component| matches!(component, Component::Normal(_))) + || relative.file_name() != Some(OsStr::new("SKILL.md")) + { + return false; + } + let Some(parent) = relative.parent() else { + return false; + }; + for root in [ + ".ai/skills", + ".agents/skills", + ".claude/skills", + ".codex/skills", + ".github/skills", + ".gemini/skills", + ] { + let Ok(slug) = parent.strip_prefix(root) else { + continue; + }; + if slug.components().count() == 1 + && slug + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| !name.starts_with('.')) + { + return true; + } + } + false +} + +fn skill_hash_matches( + primary_path: &Path, + normalized_event_path: &Path, + expected_hash: &str, +) -> bool { + let Some(skill_root) = primary_path.parent() else { + return false; + }; + normalized_event_path.starts_with(skill_root) + && matches!(skill_dir_hash_hex(skill_root), Ok(hash) if hash == expected_hash) +} + impl ForegroundLoop { fn new(opts: &WatchOptions) -> Self { let idle_deadline = idle_timeout(opts).map(|timeout| Instant::now() + timeout); @@ -1194,6 +1260,124 @@ fn sha256_file_hex(path: &Path) -> Result { Ok(hex_lower(digest.as_ref())) } +fn skill_dir_hash_hex(root: &Path) -> Result { + let mut files = BTreeMap::new(); + let mut total_bytes = 0; + collect_skill_files(root, root, 0, &mut files, &mut total_bytes)?; + let mut bytes = Vec::new(); + for (path, contents) in files { + bytes.extend_from_slice(path.as_os_str().as_encoded_bytes()); + bytes.push(0); + bytes.extend_from_slice(&contents); + bytes.push(0); + } + let digest = Sha256::digest(bytes); + Ok(hex_lower(digest.as_ref())) +} + +fn collect_skill_files( + root: &Path, + dir: &Path, + depth: usize, + files: &mut BTreeMap>, + total_bytes: &mut u64, +) -> Result<()> { + if depth > MAX_SKILL_TREE_DEPTH { + return Err(invalid_skill_hash_input( + dir, + format!("skill directory depth exceeds {MAX_SKILL_TREE_DEPTH}"), + )); + } + let metadata = fs::symlink_metadata(dir).map_err(|source| WatcherError::Io { + action: "read metadata", + path: dir.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(invalid_skill_hash_input( + dir, + "symlinked skill path is not supported", + )); + } + let mut entries = fs::read_dir(dir) + .map_err(|source| WatcherError::Io { + action: "read directory", + path: dir.to_path_buf(), + source, + })? + .collect::, _>>() + .map_err(|source| WatcherError::Io { + action: "read directory entry", + path: dir.to_path_buf(), + source, + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|source| WatcherError::Io { + action: "read metadata", + path: path.clone(), + source, + })?; + if metadata.file_type().is_symlink() { + return Err(invalid_skill_hash_input( + &path, + "symlinked skill path is not supported", + )); + } + if metadata.is_dir() { + collect_skill_files(root, &path, depth + 1, files, total_bytes)?; + continue; + } + if !metadata.is_file() { + continue; + } + if files.len() >= MAX_SKILL_FILE_COUNT { + return Err(invalid_skill_hash_input( + &path, + format!("skill file count exceeds {MAX_SKILL_FILE_COUNT}"), + )); + } + let projected_bytes = total_bytes + .checked_add(metadata.len()) + .ok_or_else(|| invalid_skill_hash_input(&path, "skill byte count overflowed"))?; + if projected_bytes > MAX_SKILL_TOTAL_BYTES { + return Err(invalid_skill_hash_input( + &path, + format!("skill byte size exceeds {MAX_SKILL_TOTAL_BYTES}"), + )); + } + let relative = path + .strip_prefix(root) + .map(Path::to_path_buf) + .unwrap_or_else(|_| path.clone()); + let contents = fs::read(&path).map_err(|source| WatcherError::Io { + action: "read file", + path: path.clone(), + source, + })?; + *total_bytes = total_bytes + .checked_add(u64::try_from(contents.len()).unwrap_or(u64::MAX)) + .ok_or_else(|| invalid_skill_hash_input(&path, "skill byte count overflowed"))?; + if *total_bytes > MAX_SKILL_TOTAL_BYTES { + return Err(invalid_skill_hash_input( + &path, + format!("skill byte size exceeds {MAX_SKILL_TOTAL_BYTES}"), + )); + } + files.insert(relative, contents); + } + Ok(()) +} + +fn invalid_skill_hash_input(path: &Path, message: impl Into) -> WatcherError { + WatcherError::Io { + action: "hash skill directory", + path: path.to_path_buf(), + source: std::io::Error::new(std::io::ErrorKind::InvalidInput, message.into()), + } +} + fn hex_lower(bytes: &[u8]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(bytes.len() * 2); @@ -1385,8 +1569,8 @@ mod tests { STATE_STOPPED, SelfWriteIndex, WatchOptions, WatcherLayout, WatcherRecord, append_log, contains_vcs_path, idle_timeout, rotated_log_path, service::{service_definition_contents, service_name}, - sha256_file_hex, start_with_cache_root, status_with_cache_root, stop_with_cache_root, - write_record, + sha256_file_hex, skill_dir_hash_hex, start_with_cache_root, status_with_cache_root, + stop_with_cache_root, write_record, }; #[test] @@ -1629,6 +1813,344 @@ entities: ); } + #[test] + fn self_write_index_suppresses_v02_native_surfaces() { + let temp = tempdir(); + let repo = temp.path().join("repo"); + create_dir(&repo); + let cache = temp.path().join("cache"); + let layout = layout(&repo, &cache); + let cases = [ + ( + "instructions:root", + "instructions", + ".ai", + "../AGENTS.md", + "AGENTS.md", + b"Root instructions\n".as_slice(), + ), + ( + "rule:claude-security", + "rule", + ".claude", + "rules/security.md", + ".claude/rules/security.md", + b"Security rule\n", + ), + ( + "command:claude-review", + "command", + ".claude", + "commands/review.md", + ".claude/commands/review.md", + b"Review command\n", + ), + ( + "skill:claude-release", + "skill", + ".claude", + "skills/release/SKILL.md", + ".claude/skills/release/SKILL.md", + b"Claude skill\n", + ), + ( + "subagent:claude-reviewer", + "subagent", + ".claude", + "agents/reviewer.md", + ".claude/agents/reviewer.md", + b"Claude agent\n", + ), + ( + "hook:claude-project", + "hook", + ".claude", + "settings.json", + ".claude/settings.json", + br#"{"hooks":{}}"#, + ), + ( + "mcp-binding:claude-project", + "mcp_binding", + ".claude", + "../.mcp.json", + ".mcp.json", + br#"{"mcpServers":{}}"#, + ), + ( + "skill:codex-release", + "skill", + ".codex", + "skills/release/SKILL.md", + ".codex/skills/release/SKILL.md", + b"Codex skill\n", + ), + ( + "subagent:codex-reviewer", + "subagent", + ".codex", + "agents/reviewer.toml", + ".codex/agents/reviewer.toml", + b"name = \"reviewer\"\n", + ), + ( + "hook:codex-project", + "hook", + ".codex", + "hooks.json", + ".codex/hooks.json", + br#"{"hooks":{}}"#, + ), + ( + "mcp-binding:codex-project", + "mcp_binding", + ".codex", + "config.toml", + ".codex/config.toml", + b"[mcp_servers.repo]\ncommand = \"repo\"\n", + ), + ( + "permission-policy:codex-project", + "permission_policy", + ".codex", + "config.toml", + ".codex/config.toml", + b"[mcp_servers.repo]\ncommand = \"repo\"\n", + ), + ( + "instructions:copilot-root", + "instructions", + ".copilot", + "../.github/copilot-instructions.md", + ".github/copilot-instructions.md", + b"Copilot root\n", + ), + ( + "instructions:scoped:api-review", + "instructions", + ".copilot", + "../.github/instructions/api.instructions.md", + ".github/instructions/api.instructions.md", + b"Copilot scoped\n", + ), + ( + "prompt:release", + "prompt", + ".copilot", + "../.github/prompts/release.prompt.md", + ".github/prompts/release.prompt.md", + b"Copilot prompt\n", + ), + ( + "skill:repo-map", + "skill", + ".copilot", + "../.github/skills/repo-map/SKILL.md", + ".github/skills/repo-map/SKILL.md", + b"Copilot skill\n", + ), + ( + "subagent:security-reviewer", + "subagent", + ".copilot", + "../.github/agents/security-reviewer.agent.md", + ".github/agents/security-reviewer.agent.md", + b"Copilot agent\n", + ), + ( + "rule:cursor-security", + "rule", + ".cursor", + "rules/security.mdc", + ".cursor/rules/security.mdc", + b"Cursor rule\n", + ), + ( + "instructions:gemini-root", + "instructions", + ".gemini", + "../GEMINI.md", + "GEMINI.md", + b"Gemini root\n", + ), + ( + "instructions:scoped:packages-api", + "instructions", + ".gemini", + "../packages/api/GEMINI.md", + "packages/api/GEMINI.md", + b"Gemini scoped\n", + ), + ( + "skill:gemini-release", + "skill", + ".gemini", + "skills/release/SKILL.md", + ".gemini/skills/release/SKILL.md", + b"Gemini skill\n", + ), + ( + "command:gemini-review", + "command", + ".gemini", + "commands/review.toml", + ".gemini/commands/review.toml", + b"prompt = \"Review\"\n", + ), + ( + "skill:shared-analysis", + "skill", + ".agents", + "skills/shared-analysis/SKILL.md", + ".agents/skills/shared-analysis/SKILL.md", + b"Shared skill\n", + ), + ]; + let mut lockfile = String::from("version: 1\nschema: 2\nentities:\n"); + let mut event_paths = Vec::new(); + for (id, entity_type, location, lockfile_path, repo_path, contents) in cases { + let native_path = repo.join(repo_path); + create_parent(&native_path); + write_file(&native_path, contents); + let hash = if entity_type == "skill" { + let skill_root = native_path + .parent() + .unwrap_or_else(|| panic!("skill path should have a parent")); + let asset_path = skill_root.join("references/notes.md"); + create_parent(&asset_path); + write_file(&asset_path, b"Notes\n"); + event_paths.push(asset_path); + match skill_dir_hash_hex(skill_root) { + Ok(hash) => hash, + Err(error) => panic!("skill hash should be computed: {error}"), + } + } else { + match sha256_file_hex(&native_path) { + Ok(hash) => hash, + Err(error) => panic!("hash should be computed: {error}"), + } + }; + lockfile.push_str(&format!( + " {id}:\n type: {entity_type}\n locations:\n {location}: {lockfile_path}\n canonical_sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n emitted_native_sha256:\n {location}: {hash}\n" + )); + event_paths.push(native_path); + } + write_file(&repo.join("agentmesh.lock"), lockfile.as_bytes()); + + let index = match SelfWriteIndex::load(&repo) { + Ok(index) => index, + Err(error) => panic!("self-write index should load: {error}"), + }; + for path in &event_paths { + assert!( + index.matching_self_write(&repo, path).is_some(), + "expected self-write match for {} with entries {:?}", + path.display(), + index.entries + ); + } + + let options = WatchOptions { + debounce: Duration::from_millis(1), + vcs_throttle: Duration::from_millis(1), + ..WatchOptions::default() + }; + let mut loop_state = ForegroundLoop::new(&options); + let expected_suppressed = event_paths.len(); + let event = Event { + kind: EventKind::Any, + paths: event_paths, + attrs: Default::default(), + }; + if let Err(error) = loop_state.observe_event(&repo, &options, &layout, event) { + panic!("event should be observed: {error}"); + } + + assert_eq!(loop_state.pending_event_count, 0); + assert!(loop_state.pending_paths.is_empty()); + assert_eq!(loop_state.suppressed_self_write_count, expected_suppressed); + } + + #[cfg(unix)] + #[test] + fn symlinked_skill_paths_are_not_suppressed_as_self_writes() { + use std::os::unix::fs::symlink; + + let temp = tempdir(); + let repo = temp.path().join("repo"); + create_dir(&repo); + let skill_path = repo.join(".codex/skills/release/SKILL.md"); + create_parent(&skill_path); + write_file(&skill_path, b"Release skill\n"); + let skill_root = skill_path + .parent() + .unwrap_or_else(|| panic!("skill path should have a parent")); + let hash = match skill_dir_hash_hex(skill_root) { + Ok(hash) => hash, + Err(error) => panic!("skill hash should be computed: {error}"), + }; + write_file( + &repo.join("agentmesh.lock"), + format!( + r#"version: 1 +schema: 2 +entities: + skill:release: + type: skill + locations: + .codex: skills/release/SKILL.md + canonical_sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + emitted_native_sha256: + .codex: {hash} +"# + ) + .as_bytes(), + ); + let outside = temp.path().join("outside.md"); + write_file(&outside, b"outside\n"); + let symlink_path = skill_root.join("references/secret.md"); + create_parent(&symlink_path); + symlink(&outside, &symlink_path) + .unwrap_or_else(|error| panic!("symlink should be created: {error}")); + let index = match SelfWriteIndex::load(&repo) { + Ok(index) => index, + Err(error) => panic!("self-write index should load: {error}"), + }; + + assert!(index.matching_self_write(&repo, &symlink_path).is_none()); + } + + #[test] + fn malformed_skill_lockfile_paths_do_not_create_suppression_roots() { + let temp = tempdir(); + let repo = temp.path().join("repo"); + create_dir(&repo); + let misplaced_skill = repo.join("SKILL.md"); + write_file(&misplaced_skill, b"not a runtime skill\n"); + write_file( + &repo.join("agentmesh.lock"), + r#"version: 1 +schema: 2 +entities: + skill:root: + type: skill + locations: + .codex: ../SKILL.md + canonical_sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + emitted_native_sha256: + .codex: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +"# + .as_bytes(), + ); + + let index = match SelfWriteIndex::load(&repo) { + Ok(index) => index, + Err(error) => panic!("self-write index should load: {error}"), + }; + + assert!(index.entries.is_empty()); + } + #[test] fn start_is_idempotent_for_running_pid_records() { let temp = tempdir(); diff --git a/crates/agentmesh/Cargo.toml b/crates/agentmesh/Cargo.toml index 51eeaf7..bf4fff6 100644 --- a/crates/agentmesh/Cargo.toml +++ b/crates/agentmesh/Cargo.toml @@ -15,6 +15,9 @@ path = "src/main.rs" [dependencies] agentmesh-adapter-claude.workspace = true agentmesh-adapter-codex.workspace = true +agentmesh-adapter-copilot.workspace = true +agentmesh-adapter-cursor.workspace = true +agentmesh-adapter-gemini.workspace = true agentmesh-adapter-sdk-rust.workspace = true agentmesh-core.workspace = true agentmesh-protocol.workspace = true diff --git a/crates/agentmesh/src/hooks.rs b/crates/agentmesh/src/hooks.rs index 4d2476d..12f40af 100644 --- a/crates/agentmesh/src/hooks.rs +++ b/crates/agentmesh/src/hooks.rs @@ -11,6 +11,33 @@ pub(crate) fn print_runtime_install_dry_run(context: &CliContext, runtime: &str) let overlay = match runtime { "claude" => ".claude/settings.local.json", "codex" => ".codex/hooks.json", + "cursor" => { + if !context.silent { + println!( + "{} Cursor has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } + "copilot" => { + if !context.silent { + println!( + "{} Copilot has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } + "gemini" => { + if !context.silent { + println!( + "{} Gemini has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } other => { return Err(CliError::new( format!("unknown bundled runtime: {other}"), @@ -113,15 +140,15 @@ pub(crate) fn install_git_pre_commit_hook(context: &CliContext, force: bool) -> if existing_is_agentmesh { saved.exists() } else { - if let Some(framework) = detect_pre_commit_framework(content) { - if !force { - return Err(CliError::new( - format!( - "detected {framework} managing pre-commit; add AgentMesh to that framework or rerun with --force" - ), - AgentmeshExitCode::Usage, - )); - } + if let Some(framework) = detect_pre_commit_framework(content) + && !force + { + return Err(CliError::new( + format!( + "detected {framework} managing pre-commit; add AgentMesh to that framework or rerun with --force" + ), + AgentmeshExitCode::Usage, + )); } if saved.exists() { return Err(CliError::new( @@ -204,6 +231,33 @@ pub(crate) fn install_runtime_hook(context: &CliContext, runtime: &str) -> Resul agentmesh_binary_path: binary_path, matcher_extra: None, }), + "cursor" => { + if !context.silent { + println!( + "{} Cursor has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } + "copilot" => { + if !context.silent { + println!( + "{} Copilot has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } + "gemini" => { + if !context.silent { + println!( + "{} Gemini has no installable AgentMesh runtime hook in v0.2", + context.paint(OutputStyle::Info, "→") + ); + } + return Ok(()); + } other => { return Err(CliError::new( format!("unknown bundled runtime: {other}"), diff --git a/crates/agentmesh/src/inspect.rs b/crates/agentmesh/src/inspect.rs index d69064b..e65795c 100644 --- a/crates/agentmesh/src/inspect.rs +++ b/crates/agentmesh/src/inspect.rs @@ -2,7 +2,7 @@ use std::fs; use std::path::{Path, PathBuf}; use agentmesh_adapter_sdk_rust::Adapter; -use agentmesh_protocol::ImportRequest; +use agentmesh_protocol::{ImportRequest, PROTOCOL_VERSION}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -77,6 +77,7 @@ pub(crate) struct RuntimeSnapshot { pub(crate) entities: Vec, pub(crate) import_error: Option, pub(crate) hook_overlay: PathBuf, + pub(crate) hook_supported: bool, pub(crate) hook_installed: bool, pub(crate) hook_note: Option, } @@ -94,6 +95,17 @@ pub(crate) struct ReviewedDiffSummary { pub(crate) entities_changed: usize, pub(crate) pending_conflicts: usize, pub(crate) capability_skipped: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) capability_skips: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ReviewedCapabilitySkip { + pub(crate) runtime: String, + pub(crate) entity_id: String, + pub(crate) entity_type: String, + pub(crate) fallback: String, + pub(crate) locations: Vec, } impl From<&agentmesh_core::SyncSummary> for ReviewedDiffSummary { @@ -103,10 +115,34 @@ impl From<&agentmesh_core::SyncSummary> for ReviewedDiffSummary { entities_changed: summary.entities_changed, pending_conflicts: summary.pending_conflicts, capability_skipped: summary.capability_skipped, + capability_skips: summary + .capability_skips + .iter() + .map(|finding| ReviewedCapabilitySkip { + runtime: finding.runtime.as_str().to_string(), + entity_id: finding.entity_id.as_str().to_string(), + entity_type: finding.entity_type.as_str().to_string(), + fallback: capability_fallback_name(finding.fallback).to_string(), + locations: finding + .locations + .iter() + .map(|(location, path)| format!("{}:{}", location.as_str(), path.display())) + .collect(), + }) + .collect(), } } } +fn capability_fallback_name(fallback: agentmesh_core::config::CapabilityFallback) -> &'static str { + match fallback { + agentmesh_core::config::CapabilityFallback::Skip => "skip", + agentmesh_core::config::CapabilityFallback::Warn => "warn", + agentmesh_core::config::CapabilityFallback::RenderAsDoc => "render-as-doc", + agentmesh_core::config::CapabilityFallback::Fail => "fail", + } +} + pub(crate) fn inspect_repo(context: &CliContext) -> Result { inspect_repo_with_options( context, @@ -151,6 +187,9 @@ fn inspect_repo_with_options( let runtimes = vec![ inspect_claude(context, options.import_entities)?, inspect_codex(context, options.import_entities)?, + inspect_copilot(context, options.import_entities)?, + inspect_cursor(context, options.import_entities)?, + inspect_gemini(context, options.import_entities)?, ]; let hook_ownership = inspect_hook_ownership(context, &cache, &runtimes)?; let (core_findings, core_health) = if options.include_core_findings { @@ -203,7 +242,19 @@ pub(crate) fn inspect_unknown_runtime_dirs(repo_root: &Path) -> Result Result Result { + let mut runtime = inspect_runtime( + context, + "cursor", + ".cursor", + ".cursor/hooks.json", + "__agentmesh-cursor-hook-not-supported__", + import_entities, + agentmesh_adapter_cursor::CursorAdapter, + )?; + runtime.hook_supported = false; + runtime.hook_installed = false; + Ok(runtime) +} + +fn inspect_copilot(context: &CliContext, import_entities: bool) -> Result { + let mut runtime = inspect_runtime( + context, + "copilot", + ".github", + ".github/hooks", + "__agentmesh-copilot-hook-not-supported__", + import_entities, + agentmesh_adapter_copilot::CopilotAdapter, + )?; + runtime.hook_supported = false; + runtime.hook_installed = false; + Ok(runtime) +} + +fn inspect_gemini(context: &CliContext, import_entities: bool) -> Result { + let mut runtime = inspect_runtime( + context, + "gemini", + ".gemini", + ".gemini/hooks", + "__agentmesh-gemini-hook-not-supported__", + import_entities, + agentmesh_adapter_gemini::GeminiAdapter, + )?; + runtime.hook_supported = false; + runtime.hook_installed = false; + Ok(runtime) +} + fn inspect_runtime( context: &CliContext, name: &'static str, @@ -526,10 +622,24 @@ fn inspect_runtime( where A: Adapter, { - let detected = adapter - .detect(&context.repo_root) - .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))?; let runtime_dir = context.repo_root.join(runtime_dir_name); + let overlay_path = PathBuf::from(overlay); + let detected = match adapter.detect(&context.repo_root) { + Ok(detected) => detected, + Err(error) => { + return Ok(RuntimeSnapshot { + name, + present: false, + evidence: Vec::new(), + entities: Vec::new(), + import_error: Some(format!("detect failed: {error}")), + hook_overlay: overlay_path, + hook_supported: true, + hook_installed: false, + hook_note: None, + }); + } + }; let mut entities = Vec::new(); let mut import_error = None; @@ -552,7 +662,6 @@ where } } - let overlay_path = PathBuf::from(overlay); let hook_installed = fs::read_to_string(context.repo_root.join(&overlay_path)) .map(|content| content.contains(hook_trigger)) .unwrap_or(false); @@ -564,6 +673,7 @@ where entities, import_error, hook_overlay: overlay_path, + hook_supported: true, hook_installed, hook_note: None, }) @@ -670,6 +780,7 @@ fn runtime_json(runtime: &RuntimeSnapshot) -> serde_json::Value { "entities": runtime.entities, "import_error": runtime.import_error, "hook_overlay": runtime.hook_overlay, + "hook_supported": runtime.hook_supported, "hook_installed": runtime.hook_installed, "hook_note": runtime.hook_note, }) @@ -705,6 +816,7 @@ pub(crate) fn print_status(_context: &CliContext, snapshot: &RepoSnapshot) { snapshot .runtimes .iter() + .filter(|runtime| runtime.hook_supported) .map(|runtime| format!( "{} {}", runtime.name, @@ -730,7 +842,11 @@ pub(crate) fn print_status(_context: &CliContext, snapshot: &RepoSnapshot) { " {:<7} present={} hook={} entities={}", runtime.name, runtime.present, - runtime.hook_installed, + if runtime.hook_supported { + runtime.hook_installed.to_string() + } else { + "unsupported".to_string() + }, runtime.entities.len() ); if _context.debug() && !runtime.evidence.is_empty() { @@ -804,8 +920,11 @@ pub(crate) fn print_doctor(context: &CliContext, snapshot: &RepoSnapshot) { format!("{} not detected", check(context, false)) }; println!( - " {:<7} {} bundled, protocol 1, entities [instructions, skill, subagent]", - runtime.name, state + " {:<7} {} bundled, protocol {}, entities [{}]", + runtime.name, + state, + PROTOCOL_VERSION, + bundled_adapter_entities(runtime.name) ); } for runtime in &snapshot.unknown_runtimes { @@ -819,7 +938,11 @@ pub(crate) fn print_doctor(context: &CliContext, snapshot: &RepoSnapshot) { print_integrity(snapshot); println!(); println!("Hook entries:"); - for runtime in &snapshot.runtimes { + for runtime in snapshot + .runtimes + .iter() + .filter(|runtime| runtime.hook_supported) + { println!( " {:<7} {} pinned-absolute ({})", runtime.name, @@ -885,7 +1008,7 @@ pub(crate) fn print_doctor(context: &CliContext, snapshot: &RepoSnapshot) { pub(crate) fn print_versions(snapshot: &RepoSnapshot) { println!("AgentMesh: {}", agentmesh_core::VERSION); - println!("Protocol versions: supported [1]"); + println!("Protocol versions: supported [{PROTOCOL_VERSION}]"); println!( "Lockfile schema: {}", snapshot @@ -896,8 +1019,42 @@ pub(crate) fn print_versions(snapshot: &RepoSnapshot) { ); println!(); println!("Built-in adapters:"); - println!(" claude bundled protocol [1] entities [instructions, skill, subagent]"); - println!(" codex bundled protocol [1] entities [instructions, skill, subagent]"); + println!( + " claude bundled protocol [{PROTOCOL_VERSION}] entities [{}]", + bundled_adapter_entities("claude") + ); + println!( + " codex bundled protocol [{PROTOCOL_VERSION}] entities [{}]", + bundled_adapter_entities("codex") + ); + println!( + " copilot bundled protocol [{PROTOCOL_VERSION}] entities [{}]", + bundled_adapter_entities("copilot") + ); + println!( + " cursor bundled protocol [{PROTOCOL_VERSION}] entities [{}]", + bundled_adapter_entities("cursor") + ); + println!( + " gemini bundled protocol [{PROTOCOL_VERSION}] entities [{}]", + bundled_adapter_entities("gemini") + ); +} + +fn bundled_adapter_entities(runtime: &str) -> String { + let entities = match runtime { + "claude" => agentmesh_adapter_claude::metadata().supported_entities, + "codex" => agentmesh_adapter_codex::metadata().supported_entities, + "copilot" => agentmesh_adapter_copilot::metadata().supported_entities, + "cursor" => agentmesh_adapter_cursor::metadata().supported_entities, + "gemini" => agentmesh_adapter_gemini::metadata().supported_entities, + _ => &[], + }; + entities + .iter() + .map(|entity| entity.as_str()) + .collect::>() + .join(", ") } pub(crate) fn print_integrity(snapshot: &RepoSnapshot) { diff --git a/crates/agentmesh/src/main.rs b/crates/agentmesh/src/main.rs index b1707c4..6654ce3 100644 --- a/crates/agentmesh/src/main.rs +++ b/crates/agentmesh/src/main.rs @@ -1,6 +1,6 @@ use std::fs; use std::io::{IsTerminal, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command as ProcessCommand, ExitCode, Stdio}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -576,6 +576,15 @@ impl agentmesh_core::AdapterRegistry for CliAdapterRegistry { "codex" => agentmesh_adapter_codex::CodexAdapter .detect(repo_root) .map_err(|error| cli_adapter_error(runtime, error)), + "copilot" => agentmesh_adapter_copilot::CopilotAdapter + .detect(repo_root) + .map_err(|error| cli_adapter_error(runtime, error)), + "cursor" => agentmesh_adapter_cursor::CursorAdapter + .detect(repo_root) + .map_err(|error| cli_adapter_error(runtime, error)), + "gemini" => agentmesh_adapter_gemini::GeminiAdapter + .detect(repo_root) + .map_err(|error| cli_adapter_error(runtime, error)), _ => Err(agentmesh_core::pipeline::PipelineError::Adapter { runtime: runtime.clone(), message: "unknown bundled adapter".to_string(), @@ -596,6 +605,15 @@ impl agentmesh_core::AdapterRegistry for CliAdapterRegistry { "codex" => agentmesh_adapter_codex::CodexAdapter .import(request) .map_err(|error| cli_adapter_error(runtime, error)), + "copilot" => agentmesh_adapter_copilot::CopilotAdapter + .import(request) + .map_err(|error| cli_adapter_error(runtime, error)), + "cursor" => agentmesh_adapter_cursor::CursorAdapter + .import(request) + .map_err(|error| cli_adapter_error(runtime, error)), + "gemini" => agentmesh_adapter_gemini::GeminiAdapter + .import(request) + .map_err(|error| cli_adapter_error(runtime, error)), _ => Err(agentmesh_core::pipeline::PipelineError::Adapter { runtime: runtime.clone(), message: "unknown bundled adapter".to_string(), @@ -616,6 +634,15 @@ impl agentmesh_core::AdapterRegistry for CliAdapterRegistry { "codex" => agentmesh_adapter_codex::CodexAdapter .emit(request) .map_err(|error| cli_adapter_error(runtime, error)), + "copilot" => agentmesh_adapter_copilot::CopilotAdapter + .emit(request) + .map_err(|error| cli_adapter_error(runtime, error)), + "cursor" => agentmesh_adapter_cursor::CursorAdapter + .emit(request) + .map_err(|error| cli_adapter_error(runtime, error)), + "gemini" => agentmesh_adapter_gemini::GeminiAdapter + .emit(request) + .map_err(|error| cli_adapter_error(runtime, error)), _ => Err(agentmesh_core::pipeline::PipelineError::Adapter { runtime: runtime.clone(), message: "unknown bundled adapter".to_string(), @@ -1026,6 +1053,7 @@ fn handle_diff(context: &CliContext, command: DiffCommand) -> Result>(), "reviewed_diff_state": review_path, })) .map_err(|error| CliError::new(error.to_string(), AgentmeshExitCode::Adapter))? @@ -1042,6 +1070,7 @@ fn handle_diff(context: &CliContext, command: DiffCommand) -> Result 0 { println!(" capability skips: {}", summary.capability_skipped); + print_capability_skip_details(&summary.capability_skips); } if let Some(path) = &review_path { println!(" review state: {}", path.display()); @@ -1199,7 +1228,7 @@ fn restore_plan( AgentmeshExitCode::Adapter, )); }; - let target_path = path_from_lockfile(&context.repo_root, &ai_location, canonical_path); + let target_path = path_from_lockfile(&context.repo_root, &ai_location, canonical_path)?; let cache = cache_layout(&context.repo_root)?; let preserved_path = find_preserved_version(&cache, entity_id, runtime, at)?; let timestamp = preserved_timestamp(&preserved_path, runtime); @@ -1265,12 +1294,139 @@ fn path_from_lockfile( repo_root: &Path, location: &agentmesh_core::LocationKey, lockfile_path: &Path, -) -> PathBuf { - if let Ok(root_relative) = lockfile_path.strip_prefix("..") { - return repo_root.join(root_relative); +) -> Result { + if let Some(root_relative) = repo_relative_lockfile_path(location, lockfile_path)? { + return Ok(repo_root.join(root_relative)); } - repo_root.join(location.as_str()).join(lockfile_path) + Ok(repo_root.join(location.as_str()).join(lockfile_path)) +} + +fn repo_relative_lockfile_path( + location: &agentmesh_core::LocationKey, + lockfile_path: &Path, +) -> Result> { + if lockfile_path.as_os_str().is_empty() || lockfile_path.is_absolute() { + return Err(CliError::new( + format!("unsafe lockfile path: {}", lockfile_path.display()), + AgentmeshExitCode::Adapter, + )); + } + + let mut components = lockfile_path.components(); + let Some(first) = components.next() else { + return Err(CliError::new( + format!("unsafe lockfile path: {}", lockfile_path.display()), + AgentmeshExitCode::Adapter, + )); + }; + + if first == Component::ParentDir { + let mut root_relative = PathBuf::new(); + for component in components { + let Component::Normal(part) = component else { + return Err(CliError::new( + format!( + "unsafe repo-relative lockfile path: {}", + lockfile_path.display() + ), + AgentmeshExitCode::Adapter, + )); + }; + root_relative.push(part); + } + if root_relative.as_os_str().is_empty() { + return Err(CliError::new( + format!( + "unsafe repo-relative lockfile path: {}", + lockfile_path.display() + ), + AgentmeshExitCode::Adapter, + )); + } + if !repo_relative_path_allowed(location, &root_relative) { + return Err(CliError::new( + format!( + "repo-relative lockfile path is not allowed for `{}`: {}", + location.as_str(), + lockfile_path.display() + ), + AgentmeshExitCode::Adapter, + )); + } + return Ok(Some(root_relative)); + } + + if !matches!(first, Component::Normal(_)) { + return Err(CliError::new( + format!("unsafe lockfile path: {}", lockfile_path.display()), + AgentmeshExitCode::Adapter, + )); + } + for component in components { + if !matches!(component, Component::Normal(_)) { + return Err(CliError::new( + format!("unsafe lockfile path: {}", lockfile_path.display()), + AgentmeshExitCode::Adapter, + )); + } + } + Ok(None) +} + +fn repo_relative_path_allowed( + location: &agentmesh_core::LocationKey, + root_relative: &Path, +) -> bool { + match location.as_str() { + ".ai" => root_relative == Path::new("AGENTS.md"), + ".claude" => { + root_relative == Path::new("CLAUDE.md") || root_relative == Path::new(".mcp.json") + } + ".codex" => codex_repo_relative_instruction_path(root_relative), + ".gemini" => gemini_repo_relative_context_path(root_relative), + ".copilot" => { + root_relative == Path::new(".github/copilot-instructions.md") + || root_relative.starts_with(".github/instructions") + || root_relative.starts_with(".github/prompts") + || root_relative.starts_with(".github/skills") + || root_relative.starts_with(".github/agents") + } + _ => false, + } +} + +fn gemini_repo_relative_context_path(root_relative: &Path) -> bool { + if root_relative == Path::new("GEMINI.md") { + return true; + } + if root_relative.file_name().and_then(|name| name.to_str()) != Some("GEMINI.md") { + return false; + } + safe_nested_repo_root(root_relative) +} + +fn codex_repo_relative_instruction_path(root_relative: &Path) -> bool { + if root_relative == Path::new("AGENTS.md") { + return true; + } + if root_relative.file_name().and_then(|name| name.to_str()) != Some("AGENTS.md") { + return false; + } + safe_nested_repo_root(root_relative) +} + +fn safe_nested_repo_root(root_relative: &Path) -> bool { + let Some(parent) = root_relative.parent() else { + return false; + }; + parent.components().all(|component| { + let Component::Normal(part) = component else { + return false; + }; + part.to_str() + .is_some_and(|part| !part.starts_with('.') && part != "target") + }) } fn print_restore_dry_run(context: &CliContext, plan: &RestorePlan) { @@ -1612,6 +1768,15 @@ fn handle_adapter(command: AdapterCommand) -> Result { agentmesh_adapter_sdk_rust::run_adapter(agentmesh_adapter_claude::ClaudeAdapter) } "codex" => agentmesh_adapter_sdk_rust::run_adapter(agentmesh_adapter_codex::CodexAdapter), + "copilot" => { + agentmesh_adapter_sdk_rust::run_adapter(agentmesh_adapter_copilot::CopilotAdapter) + } + "cursor" => { + agentmesh_adapter_sdk_rust::run_adapter(agentmesh_adapter_cursor::CursorAdapter) + } + "gemini" => { + agentmesh_adapter_sdk_rust::run_adapter(agentmesh_adapter_gemini::GeminiAdapter) + } other => { return Err(CliError::new( format!("unknown bundled adapter: {other}"), @@ -1629,7 +1794,7 @@ fn handle_reserved_v02( ) -> Result { let _ = command.args.len(); eprintln!( - "{} This command is available in AgentMesh v0.2+.", + "{} This command is reserved for a future AgentMesh release.", context.paint(OutputStyle::Warning, "⚠") ); Ok(AgentmeshExitCode::Usage) @@ -1865,6 +2030,7 @@ fn print_sync_check_details(context: &CliContext, summary: &agentmesh_core::Sync println!(" entities_changed={}", summary.entities_changed); println!(" pending_conflicts={}", summary.pending_conflicts); println!(" capability_skipped={}", summary.capability_skipped); + print_capability_skip_details(&summary.capability_skips); if summary.pending_conflicts > 0 { println!(" resolution=manual conflict acknowledgement required"); } else { @@ -1872,6 +2038,55 @@ fn print_sync_check_details(context: &CliContext, summary: &agentmesh_core::Sync } } +fn print_capability_skip_details(findings: &[agentmesh_core::CapabilitySkipFinding]) { + for finding in findings { + println!( + " capability_skip runtime={} entity={} type={} fallback={}", + finding.runtime.as_str(), + finding.entity_id.as_str(), + finding.entity_type.as_str(), + capability_fallback_name(finding.fallback) + ); + if !finding.locations.is_empty() { + let locations = finding + .locations + .iter() + .map(|(location, path)| format!("{}:{}", location.as_str(), path.display())) + .collect::>() + .join(","); + println!(" locations={locations}"); + } + } +} + +fn capability_skip_json(finding: &agentmesh_core::CapabilitySkipFinding) -> serde_json::Value { + json!({ + "runtime": finding.runtime.as_str(), + "entity_id": finding.entity_id.as_str(), + "entity_type": finding.entity_type.as_str(), + "fallback": capability_fallback_name(finding.fallback), + "locations": finding + .locations + .iter() + .map(|(location, path)| { + json!({ + "location": location.as_str(), + "path": path, + }) + }) + .collect::>(), + }) +} + +fn capability_fallback_name(fallback: agentmesh_core::config::CapabilityFallback) -> &'static str { + match fallback { + agentmesh_core::config::CapabilityFallback::Skip => "skip", + agentmesh_core::config::CapabilityFallback::Warn => "warn", + agentmesh_core::config::CapabilityFallback::RenderAsDoc => "render-as-doc", + agentmesh_core::config::CapabilityFallback::Fail => "fail", + } +} + fn print_summary(context: &CliContext, changed: bool, operation: &str) -> AgentmeshExitCode { if !context.silent { println!("{operation}: changed={changed}"); @@ -1912,7 +2127,8 @@ fn map_core_error(error: agentmesh_core::CoreError) -> CliError { | agentmesh_core::pipeline::PipelineError::PreservedVersionNotFound { .. } | agentmesh_core::pipeline::PipelineError::Adapter { .. } | agentmesh_core::pipeline::PipelineError::Protocol(_) - | agentmesh_core::pipeline::PipelineError::CapabilityMismatch { .. } => { + | agentmesh_core::pipeline::PipelineError::CapabilityMismatch { .. } + | agentmesh_core::pipeline::PipelineError::RenderAsDocUnsupported { .. } => { AgentmeshExitCode::Adapter } }, @@ -2123,12 +2339,12 @@ mod tests { } #[test] - fn unknown_runtime_scan_reports_cursor_rules() { + fn unknown_runtime_scan_reports_unbundled_rules() { let temp = match tempfile::tempdir() { Ok(temp) => temp, Err(error) => panic!("tempdir should be available: {error}"), }; - let rules_dir = temp.path().join(".cursor/rules"); + let rules_dir = temp.path().join(".windsurf/rules"); if let Err(error) = fs::create_dir_all(&rules_dir) { panic!("rules directory should be created: {error}"); } @@ -2141,7 +2357,7 @@ mod tests { Err(error) => panic!("unknown runtime scan should succeed: {error}"), }; - assert_eq!(unknown, vec![PathBuf::from(".cursor")]); + assert_eq!(unknown, vec![PathBuf::from(".windsurf")]); } #[test] @@ -2187,6 +2403,7 @@ mod tests { entities: vec!["instructions:root".to_string()], import_error: None, hook_overlay: PathBuf::from(".claude/settings.local.json"), + hook_supported: true, hook_installed: false, hook_note: None, }], diff --git a/crates/agentmesh/tests/cli_flows.rs b/crates/agentmesh/tests/cli_flows.rs index 734d382..674cd5f 100644 --- a/crates/agentmesh/tests/cli_flows.rs +++ b/crates/agentmesh/tests/cli_flows.rs @@ -228,10 +228,10 @@ fn wait_until(timeout: Duration, mut condition: impl FnMut() -> bool) -> bool { fn write(path: impl AsRef, content: &str) { let path = path.as_ref(); - if let Some(parent) = path.parent() { - if let Err(error) = fs::create_dir_all(parent) { - panic!("parent directory should be created: {error}"); - } + if let Some(parent) = path.parent() + && let Err(error) = fs::create_dir_all(parent) + { + panic!("parent directory should be created: {error}"); } if let Err(error) = fs::write(path, content) { panic!("file should be written: {error}"); @@ -253,6 +253,13 @@ fn json_string_fragment(value: &str) -> String { encoded.trim_matches('"').to_string() } +fn runtime_json<'a>(value: &'a Value, name: &str) -> &'a Value { + value["runtimes"] + .as_array() + .and_then(|runtimes| runtimes.iter().find(|runtime| runtime["name"] == name)) + .unwrap_or_else(|| panic!("runtime {name} should be present in JSON")) +} + fn find_named_file(root: &Path, file_name: &str) -> Option { let entries = fs::read_dir(root).ok()?; for entry in entries { @@ -261,10 +268,10 @@ fn find_named_file(root: &Path, file_name: &str) -> Option { if path.file_name().and_then(|name| name.to_str()) == Some(file_name) { return Some(path); } - if path.is_dir() { - if let Some(found) = find_named_file(&path, file_name) { - return Some(found); - } + if path.is_dir() + && let Some(found) = find_named_file(&path, file_name) + { + return Some(found); } } None @@ -276,15 +283,15 @@ fn find_file_containing(root: &Path, needle: &str) -> Option { let entry = entry.ok()?; let path = entry.path(); if path.is_file() { - if let Ok(contents) = fs::read_to_string(&path) { - if contents.contains(needle) { - return Some(path); - } - } - } else if path.is_dir() { - if let Some(found) = find_file_containing(&path, needle) { - return Some(found); + if let Ok(contents) = fs::read_to_string(&path) + && contents.contains(needle) + { + return Some(path); } + } else if path.is_dir() + && let Some(found) = find_file_containing(&path, needle) + { + return Some(found); } } None @@ -544,6 +551,172 @@ fn scan_reports_runtime_entities_without_writing_repository_state() { assert!(!repo.join("agentmesh.lock").exists()); } +#[test] +fn scan_status_and_doctor_cover_v02_runtime_surfaces() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write( + repo.join(".claude/rules/security.md"), + "---\ndescription: Security\n---\nReview security.\n", + ); + write( + repo.join(".claude/commands/review.md"), + "---\ndescription: Review\n---\nReview changes.\n", + ); + write( + repo.join(".codex/skills/repo-map/SKILL.md"), + "---\nname: repo-map\n---\nMap the repository.\n", + ); + write(repo.join(".codex/hooks.json"), r#"{"hooks":{}}"#); + write( + repo.join(".codex/config.toml"), + "[mcp_servers.repo]\ncommand = \"repo\"\n\n[hooks]\n", + ); + write(repo.join(".codex/rules/strict.rules"), "always\n"); + write(repo.join(".codex/prompts/release.md"), "Release prompt\n"); + write(repo.join(".codex/commands/review.md"), "Review command\n"); + write(repo.join(".github/copilot-instructions.md"), "# Copilot\n"); + write( + repo.join(".github/instructions/api.instructions.md"), + "---\napplyTo: \"crates/api/**\"\n---\n# API\n", + ); + write( + repo.join(".github/prompts/release.prompt.md"), + "---\ndescription: Release\n---\nWrite release notes.\n", + ); + write( + repo.join(".github/agents/security-reviewer.agent.md"), + "---\nname: security-reviewer\n---\nReview security.\n", + ); + write( + repo.join(".github/workflows/copilot-setup-steps.yml"), + "steps: []\n", + ); + write(repo.join(".github/hooks/pre-tool.json"), "{}\n"); + write(repo.join("mcp/repository-mcp-settings.json"), "{}\n"); + write(repo.join("environment/agent-environment.json"), "{}\n"); + write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\n---\nCursor security.\n", + ); + write( + repo.join(".cursor/skills/repo-audit/SKILL.md"), + "# Deferred Cursor skill\n", + ); + write(repo.join(".cursor/hooks.json"), "{}\n"); + write(repo.join(".cursor/commands/review.md"), "# Review\n"); + write(repo.join(".cursor/agents/investigator.md"), "# Agent\n"); + write(repo.join(".cursor/mcp.json"), "{}\n"); + write(repo.join("GEMINI.md"), "# Gemini\n"); + write(repo.join("packages/api/GEMINI.md"), "# Gemini API\n"); + write( + repo.join(".gemini/skills/release-check/SKILL.md"), + "---\nname: release-check\n---\nCheck release.\n", + ); + write( + repo.join(".gemini/commands/review.toml"), + "description = \"Review\"\nprompt = \"Review {{args}}\"\n", + ); + write( + repo.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo"}},"policyPaths":[".gemini/policies"],"context":{"fileName":["CONTEXT.md"]}}"#, + ); + write( + repo.join(".gemini/agents/investigator.md"), + "# Deferred agent\n", + ); + write(repo.join(".gemini/hooks/guard.json"), "{}\n"); + write(repo.join(".gemini/extensions/sample/manifest.json"), "{}\n"); + write(repo.join("gemini-extension.json"), "{}\n"); + + let scan = stdout_json(&run_agentmesh(&repo, &cache, &["scan", "--json"])); + for name in ["claude", "codex", "copilot", "cursor", "gemini"] { + assert_eq!(runtime_json(&scan, name)["present"], true); + } + assert!( + runtime_json(&scan, "copilot")["entities"] + .as_array() + .unwrap_or_else(|| panic!("copilot entities should be an array")) + .iter() + .any(|entity| entity == "prompt:release") + ); + assert!( + runtime_json(&scan, "cursor")["entities"] + .as_array() + .unwrap_or_else(|| panic!("cursor entities should be an array")) + .iter() + .any(|entity| entity == "rule:security") + ); + assert!( + runtime_json(&scan, "gemini")["entities"] + .as_array() + .unwrap_or_else(|| panic!("gemini entities should be an array")) + .iter() + .any(|entity| entity == "command:review") + ); + + let status = stdout_json(&run_agentmesh(&repo, &cache, &["status", "--json"])); + for name in ["copilot", "cursor", "gemini"] { + let runtime = runtime_json(&status, name); + assert_eq!(runtime["present"], true); + assert_eq!(runtime["hook_supported"], false); + assert_eq!(runtime["hook_installed"], false); + } + let status_verbose = run_agentmesh(&repo, &cache, &["--no-color", "-v", "status"]); + assert_success(&status_verbose); + let status_verbose_stdout = String::from_utf8_lossy(&status_verbose.stdout); + for expected in [ + "copilot present=true hook=unsupported", + "cursor present=true hook=unsupported", + "gemini present=true hook=unsupported", + ] { + assert!( + status_verbose_stdout.contains(expected), + "verbose status should include {expected}; stdout: {status_verbose_stdout}" + ); + } + + let doctor = stdout_json(&run_agentmesh(&repo, &cache, &["doctor", "--json"])); + let findings = doctor["core_findings"] + .as_array() + .unwrap_or_else(|| panic!("core findings should be an array")) + .iter() + .filter_map(Value::as_str) + .collect::>(); + for expected in [ + "codex_inline_config_hooks: read-only diagnostic", + "codex_experimental_rules: read-only diagnostic", + "codex_custom_prompts: deferred", + "codex_project_commands: deferred", + "copilot_hooks: deferred", + "copilot_repository_mcp: deferred", + "copilot_setup_steps: deferred", + "copilot_agent_environment: deferred", + "cursor_skills: deferred", + "cursor_hooks: deferred", + "cursor_commands: deferred", + "cursor_subagents: deferred", + "cursor_mcp: deferred", + "gemini_project_mcp: read-only diagnostic", + "gemini_policy_settings: read-only diagnostic", + "gemini_custom_context_filenames: deferred", + "gemini_hooks: deferred", + "gemini_subagents: deferred", + "gemini_extensions: deferred", + ] { + assert!( + findings.iter().any(|finding| finding.starts_with(expected)), + "missing finding {expected}; findings: {findings:?}" + ); + } + assert!(!repo.join(".ai").exists()); + assert!(!repo.join("agentmesh.lock").exists()); +} + #[test] fn init_projects_all_entities_and_installs_detected_runtime_hooks() { let temp = match tempfile::tempdir() { @@ -554,7 +727,7 @@ fn init_projects_all_entities_and_installs_detected_runtime_hooks() { let cache = temp.path().join("cache"); fixture_repo_with_both_runtimes(&repo); - let init = run_agentmesh( + let init = run_agentmesh_with_env( &repo, &cache, &[ @@ -564,6 +737,7 @@ fn init_projects_all_entities_and_installs_detected_runtime_hooks() { "--canonical-instructions", "CLAUDE.md", ], + &[("AGENTMESH_DISABLE_WATCHER_AUTOSTART", "1")], ); assert_success(&init); @@ -815,7 +989,7 @@ fn install_stop_and_start_are_machine_local_and_surgical() { ); write( repo.join(".codex/hooks.json"), - r#"{"PostToolUse":[{"matcher":"^Bash$","hooks":[{"type":"command","command":"echo user"}]}]}"#, + r#"{"hooks":{"PostToolUse":[{"matcher":"^Bash$","hooks":[{"type":"command","command":"echo user"}]}]}}"#, ); let original_lockfile = read(repo.join("agentmesh.lock")); @@ -897,6 +1071,37 @@ fn install_stop_and_start_are_machine_local_and_surgical() { assert_success(&stop); } +#[test] +fn install_dry_run_accepts_hookless_bundled_runtimes() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write(repo.join(".keep"), ""); + + for (runtime, label) in [ + ("cursor", "Cursor"), + ("copilot", "Copilot"), + ("gemini", "Gemini"), + ] { + let output = run_agentmesh( + &repo, + &cache, + &["--no-color", "install", "--runtime", runtime, "--dry-run"], + ); + assert_success(&output); + assert!( + String::from_utf8_lossy(&output.stdout).contains(&format!( + "{label} has no installable AgentMesh runtime hook" + )), + "stdout should explain hookless {runtime} runtime: {}", + String::from_utf8_lossy(&output.stdout) + ); + } +} + #[test] fn side_effect_commands_require_confirmation_in_non_tty() { let temp = match tempfile::tempdir() { @@ -958,7 +1163,12 @@ fn uninstall_removes_repository_state_after_cleaning_hooks() { "---\nname: remove\n---\nRemove skill.\n", ); write(repo.join(".codex/.keep"), ""); - assert_success(&run_agentmesh(&repo, &cache, &["--silent", "init", "-y"])); + assert_success(&run_agentmesh_with_env( + &repo, + &cache, + &["--silent", "init", "-y"], + &[("AGENTMESH_DISABLE_WATCHER_AUTOSTART", "1")], + )); assert!(repo.join(".ai").exists()); assert!(repo.join("agentmesh.lock").exists()); let agents_contents = read(repo.join("AGENTS.md")); @@ -1424,6 +1634,55 @@ fn status_and_doctor_exit_nonzero_for_pending_conflicts() { assert_eq!(value["core_health"]["pending_syncs"], 0); } +#[cfg(unix)] +#[test] +fn doctor_and_status_render_cursor_detect_errors() { + use std::os::unix::fs::symlink; + + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + let outside = temp.path().join("outside"); + write(repo.join(".cursor/.keep"), ""); + std::fs::create_dir_all(&outside) + .unwrap_or_else(|error| panic!("outside dir should be created: {error}")); + symlink(&outside, repo.join(".cursor/rules")) + .unwrap_or_else(|error| panic!("cursor rules symlink should be created: {error}")); + + let doctor = run_agentmesh(&repo, &cache, &["doctor", "--json"]); + let status = run_agentmesh(&repo, &cache, &["status", "--json"]); + + assert_success(&doctor); + assert_success(&status); + let doctor_json = parse_stdout_json(&doctor); + let findings = doctor_json["core_findings"] + .as_array() + .unwrap_or_else(|| panic!("core findings should be an array")); + assert!(findings.iter().any(|finding| { + finding + .as_str() + .is_some_and(|finding| finding.starts_with("adapter_cursor_detect_error:")) + })); + assert!(findings.iter().any(|finding| { + finding.as_str().is_some_and(|finding| { + finding == "cursor_rule_invalid: .cursor/rules: symlinked path is not supported" + }) + })); + let status_json = parse_stdout_json(&status); + let cursor = status_json["runtimes"] + .as_array() + .and_then(|runtimes| runtimes.iter().find(|runtime| runtime["name"] == "cursor")) + .unwrap_or_else(|| panic!("cursor runtime snapshot should be present")); + assert!( + cursor["import_error"] + .as_str() + .is_some_and(|error| error.contains("detect failed")) + ); +} + #[test] fn upgrade_rewrites_recorded_runtime_hooks_to_current_binary() { let temp = match tempfile::tempdir() { @@ -1437,7 +1696,12 @@ fn upgrade_rewrites_recorded_runtime_hooks_to_current_binary() { "---\nname: upgrade\n---\nUpgrade hook.\n", ); write(repo.join(".codex/.keep"), ""); - assert_success(&run_agentmesh(&repo, &cache, &["--silent", "init", "-y"])); + assert_success(&run_agentmesh_with_env( + &repo, + &cache, + &["--silent", "init", "-y"], + &[("AGENTMESH_DISABLE_WATCHER_AUTOSTART", "1")], + )); let binary = agentmesh_bin().display().to_string(); let stale_binary = temp.path().join("old-agentmesh").display().to_string(); let escaped_binary = json_string_fragment(&binary); @@ -1453,7 +1717,12 @@ fn upgrade_rewrites_recorded_runtime_hooks_to_current_binary() { assert!(read(&overlay).contains(&escaped_stale_binary)); } - let upgrade = run_agentmesh(&repo, &cache, &["--silent", "upgrade", "-y"]); + let upgrade = run_agentmesh_with_env( + &repo, + &cache, + &["--silent", "upgrade", "-y"], + &[("AGENTMESH_DISABLE_WATCHER_AUTOSTART", "1")], + ); assert_success(&upgrade); for overlay in [ @@ -1468,6 +1737,101 @@ fn upgrade_rewrites_recorded_runtime_hooks_to_current_binary() { assert_success(&run_agentmesh(&repo, &cache, &["--silent", "stop", "-y"])); } +#[test] +fn legacy_schema_one_lockfile_syncs_without_enabling_absent_runtimes() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write(repo.join("AGENTS.md"), "Legacy instructions.\n"); + write(repo.join("CLAUDE.md"), "Legacy instructions.\n"); + write( + repo.join(".claude/skills/legacy/SKILL.md"), + "---\nname: legacy\n---\nLegacy skill.\n", + ); + write( + repo.join(".codex/skills/legacy/SKILL.md"), + "---\nname: legacy\n---\nLegacy skill.\n", + ); + write( + repo.join("agentmesh.lock"), + r#"version: 1 +schema: 1 +entities: + instructions:root: + type: instructions + scope: root + locations: + .ai: instructions/root.md + .claude: ../CLAUDE.md + .codex: ../AGENTS.md + canonical_sha256: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef + emitted_native_sha256: {} + skill:legacy: + type: skill + locations: + .ai: skills/legacy/SKILL.md + .claude: skills/legacy/SKILL.md + .codex: skills/legacy/SKILL.md + canonical_sha256: fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 + emitted_native_sha256: {} +adapters: + claude: + mode: bundled + protocol_version: 1 + entities: + - instructions + - skill + - subagent + hooks: + - post-tool-use + codex: + mode: bundled + protocol_version: 1 + entities: + - instructions + - skill + - subagent + hooks: + - post-tool-use +"#, + ); + + assert_success(&run_agentmesh( + &repo, + &cache, + &["--silent", "sync", "--await-drain"], + )); + + let lockfile = read(repo.join("agentmesh.lock")); + assert!(lockfile.contains("schema: 2")); + assert!(lockfile.contains("entity_schema: 1")); + assert!(lockfile.contains(" claude:")); + assert!(lockfile.contains(" codex:")); + assert!(!lockfile.contains(" copilot:")); + assert!(!lockfile.contains(" cursor:")); + assert!(!lockfile.contains(" gemini:")); + assert!(repo.join(".ai/skills/legacy/SKILL.md").exists()); + assert!(!repo.join(".github").exists()); + assert!(!repo.join(".cursor").exists()); + assert!(!repo.join(".gemini").exists()); + assert!(!repo.join("GEMINI.md").exists()); + + let status = stdout_json(&run_agentmesh( + &repo, + &cache, + &["--silent", "status", "--json"], + )); + assert_eq!(status["lockfile"]["schema"], 2); + assert_eq!(runtime_json(&status, "claude")["present"], true); + assert_eq!(runtime_json(&status, "codex")["present"], true); + assert_eq!(runtime_json(&status, "copilot")["present"], false); + assert_eq!(runtime_json(&status, "cursor")["present"], false); + assert_eq!(runtime_json(&status, "gemini")["present"], false); +} + #[test] fn uninstall_stops_a_running_watcher() { let temp = match tempfile::tempdir() { @@ -1771,6 +2135,215 @@ fn sync_check_exit_codes_distinguish_generic_drift_and_strict_conflict() { assert_exit_code(&strict, 2); } +#[test] +fn sync_check_reports_strict_capability_skip_details() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write( + repo.join("agentmesh.config.yaml"), + "ci:\n fail_on_capability_skip: true\n", + ); + write(repo.join(".codex/.keep"), ""); + write( + repo.join(".claude/commands/review.md"), + "---\ndescription: Review changes\n---\nReview the current diff.\n", + ); + + let output = run_agentmesh(&repo, &cache, &["sync", "--check"]); + assert_exit_code(&output, 2); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("capability_skipped=1")); + assert!(stdout.contains( + "capability_skip runtime=codex entity=command:review type=command fallback=warn" + )); + let claude_review_path = Path::new("commands").join("review.md"); + assert!(stdout.contains(&format!(".claude:{}", claude_review_path.display()))); +} + +#[test] +fn sync_check_detects_v02_native_drift_without_writing() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write(repo.join("GEMINI.md"), "# Gemini root\n"); + write(repo.join("packages/api/GEMINI.md"), "# Gemini API\n"); + write( + repo.join(".gemini/skills/release-check/SKILL.md"), + "---\nname: release-check\n---\nCheck release.\n", + ); + write( + repo.join(".gemini/commands/review.toml"), + "description = \"Review\"\nprompt = \"Review {{args}}\"\n", + ); + write( + repo.join(".github/instructions/api.instructions.md"), + "---\napplyTo: \"crates/api/**\"\n---\n# API instructions\n", + ); + write( + repo.join(".github/prompts/release.prompt.md"), + "---\ndescription: Release\n---\nWrite release notes.\n", + ); + write( + repo.join(".github/skills/repo-map/SKILL.md"), + "---\nname: repo-map\n---\nMap repository.\n", + ); + write( + repo.join(".github/agents/security-reviewer.agent.md"), + "---\nname: security-reviewer\n---\nReview security.\n", + ); + write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\n---\nCursor security.\n", + ); + assert_success(&run_agentmesh( + &repo, + &cache, + &["--silent", "sync", "--await-drain"], + )); + let lockfile_before = read(repo.join("agentmesh.lock")); + let canonical_command_before = read(repo.join(".ai/commands/review.md")); + assert!(canonical_command_before.contains("Review {{args}}")); + write(repo.join("GEMINI.md"), "# Changed Gemini root\n"); + write( + repo.join("packages/api/GEMINI.md"), + "# Changed Gemini API\n", + ); + write( + repo.join(".gemini/skills/release-check/SKILL.md"), + "---\nname: release-check\n---\nChanged release.\n", + ); + write( + repo.join(".gemini/commands/review.toml"), + "description = \"Review\"\nprompt = \"Changed {{args}}\"\n", + ); + write( + repo.join(".github/instructions/api.instructions.md"), + "---\napplyTo: \"crates/api/**\"\n---\n# Changed API instructions\n", + ); + write( + repo.join(".github/prompts/release.prompt.md"), + "---\ndescription: Release\n---\nChanged release notes.\n", + ); + write( + repo.join(".github/skills/repo-map/SKILL.md"), + "---\nname: repo-map\n---\nChanged map.\n", + ); + write( + repo.join(".github/agents/security-reviewer.agent.md"), + "---\nname: security-reviewer\n---\nChanged security review.\n", + ); + write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\n---\nChanged cursor security.\n", + ); + + let drift = run_agentmesh(&repo, &cache, &["sync", "--check"]); + + assert_exit_code(&drift, 1); + let stdout = String::from_utf8_lossy(&drift.stdout); + assert!(stdout.contains("sync-check: drift detected")); + assert!(stdout.contains("entities_changed=")); + assert_eq!(read(repo.join("agentmesh.lock")), lockfile_before); + assert_eq!( + read(repo.join(".ai/commands/review.md")), + canonical_command_before + ); +} + +#[test] +fn sync_does_not_emit_deferred_or_read_only_v02_paths() { + let temp = match tempfile::tempdir() { + Ok(temp) => temp, + Err(error) => panic!("tempdir should be available: {error}"), + }; + let repo = temp.path().join("repo"); + let cache = temp.path().join("cache"); + write(repo.join("GEMINI.md"), "# Gemini\n"); + write(repo.join(".github/copilot-instructions.md"), "# Copilot\n"); + write( + repo.join(".cursor/rules/security.mdc"), + "---\ndescription: Security\nalwaysApply: true\n---\nCursor security.\n", + ); + write(repo.join(".codex/config.toml"), "[hooks]\n"); + write(repo.join(".codex/rules/strict.rules"), "strict\n"); + write(repo.join(".codex/prompts/release.md"), "Release prompt\n"); + write(repo.join(".codex/commands/review.md"), "Review command\n"); + write(repo.join(".github/hooks/pre-tool.json"), "{}\n"); + write( + repo.join(".github/workflows/copilot-setup-steps.yml"), + "steps: []\n", + ); + write( + repo.join(".cursor/skills/repo-audit/SKILL.md"), + "# Deferred Cursor skill\n", + ); + write( + repo.join(".gemini/settings.json"), + r#"{"mcpServers":{"repo":{"command":"repo"}},"policyPaths":[".gemini/policies"]}"#, + ); + write( + repo.join(".gemini/agents/investigator.md"), + "# Deferred agent\n", + ); + write(repo.join("gemini-extension.json"), "{}\n"); + write( + repo.join(".ai/hooks/project.json"), + r#"{"name":"project","command":"agentmesh"}"#, + ); + write( + repo.join(".ai/mcp-bindings/project.json"), + r#"{"name":"project","command":"repo"}"#, + ); + write( + repo.join(".ai/permission-policies/project.json"), + r#"{"name":"project","allow":["read"]}"#, + ); + + assert_success(&run_agentmesh( + &repo, + &cache, + &["--silent", "sync", "--await-drain"], + )); + + for path in [ + ".cursor/hooks.json", + ".cursor/mcp.json", + ".cursor/commands", + ".cursor/agents", + ".gemini/hooks", + ".gemini/extensions", + ] { + assert!( + !repo.join(path).exists(), + "deferred or read-only path should not be emitted: {path}" + ); + } + let lockfile = read(repo.join("agentmesh.lock")); + for path in [ + ".codex/rules/strict.rules", + ".codex/prompts/release.md", + ".codex/commands/review.md", + ".github/hooks/pre-tool.json", + ".github/workflows/copilot-setup-steps.yml", + ".cursor/skills/repo-audit/SKILL.md", + ".gemini/settings.json", + ".gemini/agents/investigator.md", + "gemini-extension.json", + ] { + assert!( + !lockfile.contains(path), + "read-only or deferred path should not be tracked in lockfile: {path}" + ); + } +} + #[test] fn scan_ignores_personal_runtime_directories() { let temp = match tempfile::tempdir() { diff --git a/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap b/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap index 72b6217..275fa86 100644 --- a/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap +++ b/crates/agentmesh/tests/snapshots/cli_flows__representative_command_outputs.snap @@ -27,6 +27,9 @@ AgentMesh repo: repo lockfile: not ready (failed to read lockfile runtime details: claude present=true hook=false entities=5 codex present=true hook=false entities=3 + copilot present=false hook=unsupported entities=0 + cursor present=false hook=unsupported entities=0 + gemini present=false hook=unsupported entities=0 stderr: @@ -37,6 +40,9 @@ stdout: Detected runtimes: ✓ claude (.claude, .claude/skills, .claude/agents, CLAUDE.md) ✓ codex (.codex, .codex/skills, .codex/agents) + ✗ copilot (not detected) + ✗ cursor (not detected) + ✗ gemini (not detected) Detected entities: instructions:root (claude) @@ -128,8 +134,11 @@ AgentMesh Repository: Adapters: - claude ✓ detected bundled, protocol 1, entities [instructions, skill, subagent] - codex ✓ detected bundled, protocol 1, entities [instructions, skill, subagent] + claude ✓ detected bundled, protocol 2, entities [instructions, rule, command, hook, mcp-binding, permission-policy, skill, subagent] + codex ✓ detected bundled, protocol 2, entities [instructions, hook, mcp-binding, permission-policy, skill, subagent] + copilot ✗ not detected bundled, protocol 2, entities [instructions, prompt, skill, subagent] + cursor ✗ not detected bundled, protocol 2, entities [instructions, rule] + gemini ✗ not detected bundled, protocol 2, entities [instructions, command, skill] Hook integrity: Status: pinned @@ -153,7 +162,7 @@ Watcher daemon: Lockfile: Status: present - Schema: 1 (current) + Schema: 2 (current) Entities: 8 Pending conflicts: 0 @@ -173,9 +182,18 @@ Core findings: integrity_current_binary: integrity_current_sha256: integrity_version: - adapter_claude: detected=true declared=true mode=bundled protocol=1 entities=3 hooks=1 - adapter_codex: detected=true declared=true mode=bundled protocol=1 entities=3 hooks=1 + adapter_claude: detected=true declared=true mode=bundled protocol=2 entities=8 hooks=1 + adapter_codex: detected=true declared=true mode=bundled protocol=2 entities=6 hooks=1 + adapter_copilot: detected=false declared=false + adapter_cursor: detected=false declared=false + adapter_gemini: detected=false declared=false adapter_coverage_instructions: claude,codex + adapter_coverage_rule: claude + adapter_coverage_prompt: none + adapter_coverage_command: claude + adapter_coverage_hook: claude,codex + adapter_coverage_mcp-binding: claude,codex + adapter_coverage_permission-policy: claude,codex adapter_coverage_skill: claude,codex adapter_coverage_subagent: claude,codex hooks: none @@ -236,7 +254,7 @@ exit: Some(64) stdout: stderr: -⚠ This command is available in AgentMesh v0.2+. +⚠ This command is reserved for a future AgentMesh release. ## reserved adapter @@ -244,7 +262,7 @@ exit: Some(64) stdout: stderr: -⚠ This command is available in AgentMesh v0.2+. +⚠ This command is reserved for a future AgentMesh release. ## restore dry-run diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 89d297a..e016d44 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -86,9 +86,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index a39a2a3..0f7951c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -2,7 +2,7 @@ name = "agentmesh-fuzz" version = "0.0.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" license = "MIT" publish = false diff --git a/installers/README.md b/installers/README.md index d2688c1..8a5f0f0 100644 --- a/installers/README.md +++ b/installers/README.md @@ -2,10 +2,12 @@ This directory holds packaging wrappers around the single AgentMesh binary. -| Path | Purpose | -| ------------- | ------------------------- | -| `install.sh` | macOS and Linux installer | -| `install.ps1` | Windows installer | +| Path | Purpose | +| ----------------- | ----------------------------------- | +| `install.sh` | macOS and Linux installer | +| `install.ps1` | Windows installer | +| `install-dev.sh` | macOS and Linux dev-channel wrapper | +| `install-dev.ps1` | Windows dev-channel wrapper | Release installers resolve the current platform archive, verify it against the published `SHA256SUMS` manifest, verify the manifest signature and Sigstore bundle with cosign, and install @@ -30,6 +32,15 @@ From a clone of this repository: sh installers/install.sh ``` +Dev-channel tester install: + +```bash +curl -fsSL https://agentmesh.sh/install-dev.sh | sh +``` + +The dev channel installs the mutable `dev` release built from the `dev` branch. Use it only for +pre-release validation in test repositories. + ### Windows Published one-liner (PowerShell): @@ -44,6 +55,12 @@ From a clone of this repository: pwsh -NoProfile -ExecutionPolicy Bypass -File installers/install.ps1 ``` +Dev-channel tester install: + +```powershell +irm https://agentmesh.sh/install-dev.ps1 | iex +``` + ### Build from source For contributors and local development only: diff --git a/installers/install-dev.ps1 b/installers/install-dev.ps1 new file mode 100644 index 0000000..57a74d2 --- /dev/null +++ b/installers/install-dev.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference = "Stop" +$env:AGENTMESH_CHANNEL = "dev" +if (-not $env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP) { + $env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP = "^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$" +} + +$localInstaller = if ($PSScriptRoot) { Join-Path $PSScriptRoot "install.ps1" } else { $null } +if ($localInstaller -and (Test-Path -LiteralPath $localInstaller -PathType Leaf)) { + & powershell -NoProfile -ExecutionPolicy Bypass -File $localInstaller @args + exit $LASTEXITCODE +} + +$installerUrl = if ($env:AGENTMESH_DEV_INSTALLER_URL) { + $env:AGENTMESH_DEV_INSTALLER_URL +} else { + "https://raw.githubusercontent.com/aranticlabs/agentmesh/dev/installers/install.ps1" +} +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) "agentmesh-install-dev-$([guid]::NewGuid()).ps1" +try { + Invoke-WebRequest -Uri $installerUrl -OutFile $tmp + & powershell -NoProfile -ExecutionPolicy Bypass -File $tmp @args + exit $LASTEXITCODE +} finally { + Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue +} diff --git a/installers/install-dev.sh b/installers/install-dev.sh new file mode 100755 index 0000000..66b6b46 --- /dev/null +++ b/installers/install-dev.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env sh +set -eu + +AGENTMESH_CHANNEL="dev" +AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP="${AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP:-^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$}" +export AGENTMESH_CHANNEL AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP + +installer_url="${AGENTMESH_DEV_INSTALLER_URL:-https://raw.githubusercontent.com/aranticlabs/agentmesh/dev/installers/install.sh}" + +fetch_url() { + url="$1" + output="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$output" + return + fi + if command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$output" + return + fi + echo "no download tool found; install curl or wget" >&2 + exit 1 +} + +case "$0" in + */*) + script_dir="$(CDPATH='' cd "$(dirname "$0")" && pwd)" + if [ -f "$script_dir/install.sh" ]; then + exec sh "$script_dir/install.sh" "$@" + fi + ;; +esac + +workdir="$(mktemp -d "${TMPDIR:-/tmp}/agentmesh-install-dev.XXXXXX")" +trap 'rm -rf "$workdir"' EXIT HUP INT TERM +installer="$workdir/install.sh" +fetch_url "$installer_url" "$installer" +exec sh "$installer" "$@" diff --git a/installers/install.ps1 b/installers/install.ps1 index 8818342..626f947 100644 --- a/installers/install.ps1 +++ b/installers/install.ps1 @@ -1,5 +1,5 @@ param( - [ValidateSet("stable", "nightly")] + [ValidateSet("stable", "dev", "nightly")] [string]$Channel = $(if ($env:AGENTMESH_CHANNEL) { $env:AGENTMESH_CHANNEL } else { "stable" }), [string]$InstallDir = $env:AGENTMESH_INSTALL_DIR, [switch]$PrintPlatform, @@ -17,7 +17,15 @@ $AgentMeshVersion = if ($env:AGENTMESH_VERSION) { $env:AGENTMESH_VERSION } else $BaseUrl = if ($env:AGENTMESH_BASE_URL) { $env:AGENTMESH_BASE_URL } else { "https://github.com/aranticlabs/agentmesh/releases/download" } $ReleaseApiUrl = if ($env:AGENTMESH_RELEASE_API_URL) { $env:AGENTMESH_RELEASE_API_URL } else { "https://api.github.com/repos/aranticlabs/agentmesh/releases/latest" } $CosignVersion = if ($env:AGENTMESH_COSIGN_VERSION) { $env:AGENTMESH_COSIGN_VERSION } else { "v2.6.3" } -$CosignIdentity = if ($env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP) { $env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP } else { "^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" } +$StableCosignIdentity = "^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" +$DevCosignIdentity = "^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$" +$CosignIdentity = if ($env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP) { + $env:AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP +} elseif ($Channel -eq "dev") { + $DevCosignIdentity +} else { + $StableCosignIdentity +} $CosignIssuer = if ($env:AGENTMESH_COSIGN_CERTIFICATE_OIDC_ISSUER) { $env:AGENTMESH_COSIGN_CERTIFICATE_OIDC_ISSUER } else { "https://token.actions.githubusercontent.com" } $script:SpinnerState = $null $script:SpinnerThread = $null @@ -151,6 +159,7 @@ function Get-AgentMeshPlatform { function Get-ReleaseTag { switch ($Channel) { "stable" { return "v$(Get-StableVersion)" } + "dev" { return "dev" } "nightly" { return "nightly" } } } @@ -159,6 +168,7 @@ function Get-ArtifactName { param([string]$Platform) switch ($Channel) { "stable" { return "agentmesh-v$(Get-StableVersion)-$Platform.tar.gz" } + "dev" { return "agentmesh-dev-$Platform.tar.gz" } "nightly" { return "agentmesh-nightly-$Platform.tar.gz" } } } @@ -167,6 +177,7 @@ function Get-SmokeArtifactName { param([string]$Platform) switch ($Channel) { "stable" { return "agentmesh-stable-$Platform.tar.gz" } + "dev" { return "agentmesh-dev-$Platform.tar.gz" } "nightly" { return "agentmesh-nightly-$Platform.tar.gz" } } } @@ -174,6 +185,7 @@ function Get-SmokeArtifactName { function Get-DisplayTag { switch ($Channel) { "stable" { return "latest" } + "dev" { return "dev" } "nightly" { return "nightly" } } } diff --git a/installers/install.sh b/installers/install.sh index b9b3c00..5126721 100755 --- a/installers/install.sh +++ b/installers/install.sh @@ -5,7 +5,9 @@ AGENTMESH_VERSION="${AGENTMESH_VERSION:-latest}" AGENTMESH_BASE_URL="${AGENTMESH_BASE_URL:-https://github.com/aranticlabs/agentmesh/releases/download}" AGENTMESH_RELEASE_API_URL="${AGENTMESH_RELEASE_API_URL:-https://api.github.com/repos/aranticlabs/agentmesh/releases/latest}" COSIGN_VERSION="${AGENTMESH_COSIGN_VERSION:-v2.6.3}" -COSIGN_CERTIFICATE_IDENTITY_REGEXP="${AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP:-^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*}" +STABLE_COSIGN_CERTIFICATE_IDENTITY_REGEXP="^https://github.com/aranticlabs/agentmesh/.github/workflows/release.yml@refs/tags/v.*" +DEV_COSIGN_CERTIFICATE_IDENTITY_REGEXP="^https://github.com/aranticlabs/agentmesh/.github/workflows/dev-release.yml@refs/heads/dev$" +COSIGN_CERTIFICATE_IDENTITY_REGEXP="${AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP:-$STABLE_COSIGN_CERTIFICATE_IDENTITY_REGEXP}" COSIGN_CERTIFICATE_OIDC_ISSUER="${AGENTMESH_COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com}" SPINNER_PID="" STABLE_VERSION="" @@ -171,6 +173,7 @@ detect_platform() { release_tag() { case "$channel" in stable) printf 'v%s\n' "$(stable_version)" ;; + dev) printf 'dev\n' ;; nightly) printf 'nightly\n' ;; *) echo "unsupported channel: $channel" >&2 @@ -183,6 +186,7 @@ artifact_name() { platform="$1" case "$channel" in stable) printf 'agentmesh-v%s-%s.tar.gz\n' "$(stable_version)" "$platform" ;; + dev) printf 'agentmesh-dev-%s.tar.gz\n' "$platform" ;; nightly) printf 'agentmesh-nightly-%s.tar.gz\n' "$platform" ;; esac } @@ -191,6 +195,7 @@ smoke_artifact_name() { platform="$1" case "$channel" in stable) printf 'agentmesh-stable-%s.tar.gz\n' "$platform" ;; + dev) printf 'agentmesh-dev-%s.tar.gz\n' "$platform" ;; nightly) printf 'agentmesh-nightly-%s.tar.gz\n' "$platform" ;; esac } @@ -587,7 +592,7 @@ install_archive() { esac } -channel="stable" +channel="${AGENTMESH_CHANNEL:-stable}" command="install" verify_file="" verify_expected="" @@ -655,6 +660,10 @@ while [ "$#" -gt 0 ]; do channel="stable" shift ;; + --channel=dev) + channel="dev" + shift + ;; --channel=nightly) channel="nightly" shift @@ -670,6 +679,10 @@ while [ "$#" -gt 0 ]; do esac done +if [ -z "${AGENTMESH_COSIGN_CERTIFICATE_IDENTITY_REGEXP:-}" ] && [ "$channel" = "dev" ]; then + COSIGN_CERTIFICATE_IDENTITY_REGEXP="$DEV_COSIGN_CERTIFICATE_IDENTITY_REGEXP" +fi + case "$command" in print-platform|verify-sha256|verify-sha256sums|verify-sha256sums-signature|help|upgrade-help|smoke) ;; *) @@ -685,9 +698,9 @@ case "$command" in AgentMesh installer Usage: - install.sh [--channel=stable|--channel=nightly] [--install-dir=] + install.sh [--channel=stable|dev|nightly] [--install-dir=] install.sh --print-platform - install.sh --print-url [--channel=stable|--channel=nightly] + install.sh --print-url [--channel=stable|dev|nightly] install.sh --verify-sha256 install.sh --verify-sha256sums install.sh --verify-sha256sums-signature @@ -698,7 +711,7 @@ The installer downloads the platform archive, verifies it against SHA256SUMS, verifies the SHA256SUMS signature with cosign, installs the single binary, and checks that the installed binary launches. Stable installs resolve the latest GitHub release by default. Set AGENTMESH_VERSION=x.y.z to install a specific -stable version. +stable version. Dev installs use the mutable dev release for pre-release testing. USAGE exit 0 ;; diff --git a/installers/test-install.sh b/installers/test-install.sh index 3e83f38..6dc43d0 100755 --- a/installers/test-install.sh +++ b/installers/test-install.sh @@ -6,7 +6,7 @@ tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/agentmesh-installer-test.XXXXXX")" trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM functions_file="$tmpdir/install-functions.sh" -awk '/^channel="stable"/ { exit } { print }' "$script_dir/install.sh" > "$functions_file" +awk '/^channel=/ { exit } { print }' "$script_dir/install.sh" > "$functions_file" write_fake_uname() { directory="$1" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e22c344..c6f8dfa 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.85.0" +channel = "1.88.0" components = ["clippy", "rustfmt"]