diff --git a/.github/actions/setup-macos/action.yml b/.github/actions/setup-macos/action.yml index b8fd67438..9aa794515 100644 --- a/.github/actions/setup-macos/action.yml +++ b/.github/actions/setup-macos/action.yml @@ -1,34 +1,38 @@ name: Setup macOS build deps -description: Install + cache mise tools and Ghostty build outputs +description: Install + cache mise tools and ThirdParty build outputs (Ghostty, zmx) + +inputs: + disable-tools: + description: Comma-separated mise tools to skip, so CI installs only what it needs. + default: spm:swiftlang/swift-format,npm:create-dmg runs: using: composite steps: - # The bundled zig (0.15.2) self-hosted linker can't link the macOS 26.4+ SDK - # (ziglang/zig#31272), which breaks `make build-ghostty-xcframework`. Pin to the - # newest installed Xcode <= 26.3, whose SDK zig can link. - - name: Select Xcode <= 26.3 + # Job-wide so every later mise call (install, plus auto-install during the + # build steps under MISE_YES) skips the unneeded tools, not just this step. + - name: Limit mise to the tools CI needs + shell: bash + run: echo "MISE_DISABLE_TOOLS=${{ inputs.disable-tools }}" >> "$GITHUB_ENV" + # The bundled zig 0.15.2 linker can't link the macOS 26.4+ SDK (ziglang/zig#31658). + # Reuse the shared selector so CI and local agree on the Zig-linkable Xcode. + - name: Select a Zig-linkable Xcode shell: bash run: | set -euo pipefail - selected="" - for app in /Applications/Xcode_26.3*.app /Applications/Xcode_26.2*.app \ - /Applications/Xcode_26.1*.app /Applications/Xcode_26.0*.app; do - if [ -d "$app" ]; then selected="$app"; break; fi - done - if [ -z "$selected" ]; then - echo "No Xcode <= 26.3 found on runner; available:" >&2 - ls -d /Applications/Xcode_*.app >&2 || true - exit 1 - fi - sudo xcode-select -s "$selected/Contents/Developer" + developer_dir="$(./scripts/select-developer-dir.sh)" + sudo xcode-select -s "$developer_dir" xcodebuild -version - uses: jdx/mise-action@v4 with: version: 2026.3.0 cache: true + # Tuist auth (OIDC) only works on the canonical upstream repo, which is the + # one linked in the Tuist dashboard. Skip it on forks and on cross-fork PRs + # (external contributors can't access the OIDC trust) — `tuist generate` + # works fine without auth; auth only buys the Tuist remote cache. - name: Authenticate with Tuist - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + if: ${{ github.repository == 'supabitapp/supacode' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} shell: bash run: mise exec -- tuist auth login - name: Xcode cache key @@ -36,21 +40,35 @@ runs: run: | XCODE_VERSION="$(xcodebuild -version | tr '\n' '-' | sed 's/[^A-Za-z0-9._-]/_/g; s/-$//')" printf '%s\n' "XCODE_VERSION=$XCODE_VERSION" >> "$GITHUB_ENV" - - name: Ghostty cache key + - name: ThirdParty cache keys shell: bash run: | set -euo pipefail - GHOSTTY_SHA="$(git -C ThirdParty/ghostty rev-parse HEAD)" - printf '%s\n' "GHOSTTY_SHA=$GHOSTTY_SHA" >> "$GITHUB_ENV" + printf '%s\n' "GHOSTTY_SHA=$(git -C ThirdParty/ghostty rev-parse HEAD)" >> "$GITHUB_ENV" + printf '%s\n' "ZMX_SHA=$(git -C ThirdParty/zmx rev-parse HEAD)" >> "$GITHUB_ENV" + # Key on only the inputs that change the built artifact (submodule SHA, build + # script, zig version, patches) so unrelated action/project edits don't evict it. - name: Ghostty cache id: ghostty_cache uses: actions/cache@v5 with: path: .build/ghostty - key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-ghostty-v2-${{ env.GHOSTTY_SHA }}-${{ hashFiles('.github/actions/setup-macos/action.yml', 'Project.swift', 'scripts/build-ghostty.sh', 'mise.toml', '.gitmodules') }} + key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-ghostty-v3-${{ env.GHOSTTY_SHA }}-${{ hashFiles('scripts/build-ghostty.sh', 'mise.toml', '.gitmodules', 'patches/*.patch') }} - name: Build ghostty if: steps.ghostty_cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail make build-ghostty-xcframework + - name: zmx cache + id: zmx_cache + uses: actions/cache@v5 + with: + path: .build/zmx + key: ${{ runner.os }}-${{ runner.arch }}-xcode-${{ env.XCODE_VERSION }}-zmx-v1-${{ env.ZMX_SHA }}-${{ hashFiles('scripts/build-zmx.sh', 'mise.toml', '.gitmodules') }} + - name: Build zmx + if: steps.zmx_cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + make build-zmx diff --git a/.github/workflows/test.yml b/.github/workflows/ci.yml similarity index 82% rename from .github/workflows/test.yml rename to .github/workflows/ci.yml index f15d62dee..91cea384e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,6 @@ -name: test +name: ci on: - push: - branches: - - main pull_request: branches: - main @@ -27,5 +24,8 @@ jobs: submodules: recursive - uses: ./.github/actions/setup-macos - run: make lint + - run: make inspect-dependencies - run: make build-app + env: + XCODEBUILD_FLAGS: -showBuildTimingSummary - run: make test diff --git a/.github/workflows/fork-build.yml b/.github/workflows/fork-build.yml new file mode 100644 index 000000000..cfb833e46 --- /dev/null +++ b/.github/workflows/fork-build.yml @@ -0,0 +1,36 @@ +name: fork-build + +# Build the Debug app on feature branches and upload it as an artifact so a +# fork (where local builds are impossible) can download and install a testable +# build. No tests, no signing — just a runnable .app.zip per commit. +on: + push: + branches: + - "feat/**" + - "fix/**" + - "exp/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-26 + permissions: + contents: read + id-token: write + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos + - run: make package-app + - uses: actions/upload-artifact@v6 + with: + name: supacode-${{ github.sha }} + path: build/supacode.app.zip + if-no-files-found: error diff --git a/.github/workflows/inspect-dependencies.yml b/.github/workflows/inspect-dependencies.yml deleted file mode 100644 index 1310a39ef..000000000 --- a/.github/workflows/inspect-dependencies.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: inspect-dependencies - -on: - push: - branches: - - main - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - inspect-dependencies: - runs-on: macos-26 - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: make inspect-dependencies diff --git a/.github/workflows/release-tip.yml b/.github/workflows/main.yml similarity index 56% rename from .github/workflows/release-tip.yml rename to .github/workflows/main.yml index 2b81f4d79..f17c00321 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Release Tip +name: main on: push: @@ -6,13 +6,55 @@ on: workflow_dispatch: {} +# Never cancel in flight: this pipeline also cuts stable releases. Newer pushes queue. concurrency: group: ${{ github.workflow }} - cancel-in-progress: true + cancel-in-progress: false jobs: + # Warm the binary cache upstream so test and build consume it on the same commit. + warm: + runs-on: macos-26 + strategy: + matrix: + configuration: [Debug, Release] + permissions: + contents: read + id-token: write + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos + - run: make warm-cache + env: + TUIST_CACHE_CONFIGURATION: ${{ matrix.configuration }} + + test: + runs-on: macos-26 + needs: [warm] + permissions: + contents: read + id-token: write + env: + MISE_HTTP_TIMEOUT: 120 + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + - uses: ./.github/actions/setup-macos + - run: make lint + - run: make inspect-dependencies + - run: make build-app + env: + XCODEBUILD_FLAGS: -showBuildTimingSummary + - run: make test + check: - if: github.event_name == 'workflow_dispatch' || github.ref_name == 'main' runs-on: ubuntu-latest outputs: should_skip: ${{ steps.check.outputs.should_skip }} @@ -33,7 +75,7 @@ jobs: build: runs-on: macos-26 - needs: [check] + needs: [check, warm] if: needs.check.outputs.should_skip != 'true' permissions: contents: read @@ -55,7 +97,9 @@ jobs: with: submodules: recursive - uses: ./.github/actions/setup-macos - - name: Prepare tip overrides + with: + disable-tools: spm:swiftlang/swift-format + - name: Prepare build overrides env: SENTRY_DSN: ${{ secrets.SENTRY_DSN }} POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} @@ -65,19 +109,17 @@ jobs: : "${SENTRY_DSN:?secret SENTRY_DSN is not set}" : "${POSTHOG_API_KEY:?secret POSTHOG_API_KEY is not set}" : "${POSTHOG_HOST:?secret POSTHOG_HOST is not set}" - BASE=$(awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$/{print $2; exit}' Configurations/Project.xcconfig) - OFFSET=${{ github.run_number }} - if [ "$OFFSET" -gt 999 ]; then - echo "::error::Tip run_number ($OFFSET) exceeds 999. Bump CURRENT_PROJECT_VERSION before the next tip release." - exit 1 - fi - BUILD_NUMBER=$((BASE * 1000 + OFFSET)) + # Epoch build number: monotonic, computed once here so the single binary reused by tip and stable carries one version. + BUILD_NUMBER=$(date +%s) mkdir -p build + # xcconfig treats // as a comment, which truncates URL values (https://… → https:). + # Break every // with $() (evaluates to empty) so the parser never sees a literal //. + xcconfig_escape() { printf '%s' "$1" | sed 's#//#/$()/#g'; } cat > build/ReleaseOverrides.xcconfig <> "$GITHUB_ENV" echo "XCODE_XCCONFIG_FILE=$PWD/build/ReleaseOverrides.xcconfig" >> "$GITHUB_ENV" @@ -122,6 +164,28 @@ jobs: EOF make export-archive + - name: Verify embedded telemetry config + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} + POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} + run: | + set -euo pipefail + APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" + PLIST="$APP_PATH/Contents/Info.plist" + assert_eq() { + local key="$1" expected="$2" + local actual + actual="$(/usr/libexec/PlistBuddy -c "Print :$key" "$PLIST")" + if [ "$actual" != "$expected" ]; then + echo "::error::$key embedded as '$actual' but expected '$expected' (xcconfig // truncation?)" + exit 1 + fi + echo "$key OK" + } + assert_eq SentryDSN "$SENTRY_DSN" + assert_eq PostHogAPIKey "$POSTHOG_API_KEY" + assert_eq PostHogHost "$POSTHOG_HOST" - name: Re-sign frameworks run: | set -ex @@ -230,6 +294,39 @@ jobs: build/appcast.xml build/*.delta + # Gated on build so a stable release is only detected when the binary exists. + # The matching tag (pushed via --follow-tags) marks this exact commit as vX.Y.Z. + detect: + runs-on: ubuntu-latest + needs: [build] + permissions: + contents: read + outputs: + release_tag: ${{ steps.detect.outputs.release_tag }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + - id: detect + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION=$(awk -F' = ' '/^MARKETING_VERSION = /{gsub(/\r/,"",$2); print $2; exit}' Configurations/Project.xcconfig) + TAG="v$VERSION" + RELEASE_TAG="" + if [ "$(git rev-list -n 1 "$TAG" 2>/dev/null || true)" = "${{ github.sha }}" ]; then + # Idempotency: skip if this release was already published (re-run safety). + ASSET_COUNT=$(gh release view "$TAG" -R "${{ github.repository }}" --json assets --jq '.assets | length' 2>/dev/null || echo "0") + if [ "$ASSET_COUNT" -eq 0 ]; then + RELEASE_TAG="$TAG" + else + echo "$TAG already has $ASSET_COUNT assets, skipping stable publish" + fi + fi + echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + tag: runs-on: ubuntu-latest needs: [check, build] @@ -265,13 +362,120 @@ jobs: SENTRY_PROJECT: supacode run: sentry-cli debug-files upload --include-sources dsyms + # Stable release: reuses the SAME notarized binaries build produced (no second + # archive or notarization) and publishes them with auto-generated notes. + publish-stable: + runs-on: macos-26 + needs: [build, detect] + if: ${{ needs.detect.outputs.release_tag != '' }} + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} + RELEASE_REPO: ${{ github.repository }} + TAG: ${{ needs.detect.outputs.release_tag }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + fetch-tags: true + - uses: actions/download-artifact@v7 + with: + name: build-artifacts + path: build + - name: Drop the tip appcast/deltas from the shared artifact + run: rm -f build/appcast.xml build/*.delta + - name: Generate release notes + run: | + set -euo pipefail + PREV=$(gh release list --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") + if [ -n "$PREV" ] && [ "$PREV" != "$TAG" ]; then + gh api "repos/$RELEASE_REPO/releases/generate-notes" -f tag_name="$TAG" -f previous_tag_name="$PREV" --jq '.body' > build/release-notes.md + else + gh api "repos/$RELEASE_REPO/releases/generate-notes" -f tag_name="$TAG" --jq '.body' > build/release-notes.md + fi + # Prepend the tag's headline ahead of "## What's Changed". Gate on an + # annotated tag, else a lightweight tag reads the commit subject. + HEADLINE="" + if [ "$(git cat-file -t "$TAG" 2>/dev/null)" = tag ]; then + # Strip any signature defensively; these notes also feed the Sparkle appcast. + HEADLINE=$(git tag -l --format='%(contents:subject)%0a%0a%(contents:body)' "$TAG" | sed '/-----BEGIN .*SIGNATURE-----/,$d') + fi + # Prepend only a well-formed headline: a `## ` subject with a non-empty body. + SUBJECT=$(printf '%s\n' "$HEADLINE" | sed -n '1p') + BODY=$(printf '%s\n' "$HEADLINE" | sed '1,2d') + case "$SUBJECT" in + '## '*) + if [ -n "$(printf '%s' "$BODY" | tr -d '[:space:]')" ]; then + { printf '%s\n\n' "$HEADLINE"; cat build/release-notes.md; } > build/release-notes.md.new + mv build/release-notes.md.new build/release-notes.md + fi + ;; + esac + - name: Generate appcast with history and deltas + run: | + set -euo pipefail + MAX_DELTAS=10 + NOTES_FILE=build/release-notes.md + STAGING=$(mktemp -d) + ARCHIVE=build/supacode.app.zip + ARCHIVE_BASE=$(basename "$ARCHIVE") + ARCHIVE_BASE="${ARCHIVE_BASE%.zip}" + cp "$ARCHIVE" "$STAGING/" + cp "$NOTES_FILE" "$STAGING/$ARCHIVE_BASE.md" + + curl -fsSL "https://supacode.sh/download/latest/appcast.xml" -o "$STAGING/appcast.xml" || true + + SEEN_VERSIONS="" + for i in $(seq 1 $MAX_DELTAS); do + PREV_TAG=$(gh release list --limit $i --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq ".[$((i-1))].tagName" 2>/dev/null || true) + if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$TAG" ]; then + gh release download "$PREV_TAG" -p "supacode.app.zip" -O "$STAGING/supacode-$PREV_TAG.app.zip" -R "$RELEASE_REPO" 2>/dev/null || true + if [ -f "$STAGING/supacode-$PREV_TAG.app.zip" ]; then + BUNDLE_VERSION=$(unzip -p "$STAGING/supacode-$PREV_TAG.app.zip" "supacode.app/Contents/Info.plist" 2>/dev/null | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /dev/stdin 2>/dev/null || echo "") + if [ -n "$BUNDLE_VERSION" ] && echo "$SEEN_VERSIONS" | grep -q "^$BUNDLE_VERSION$"; then + rm -f "$STAGING/supacode-$PREV_TAG.app.zip" + else + [ -n "$BUNDLE_VERSION" ] && SEEN_VERSIONS="$SEEN_VERSIONS$BUNDLE_VERSION"$'\n' + gh release view "$PREV_TAG" -R "$RELEASE_REPO" --json body --jq '.body' > "$STAGING/supacode-$PREV_TAG.app.md" 2>/dev/null || true + fi + fi + fi + done + + printf "%s" "$SPARKLE_PRIVATE_KEY" | tr -d '\r\n\t ' | ./bins/generate_appcast --download-url-prefix "https://supacode.sh/download/$TAG/" --full-release-notes-url "https://github.com/supabitapp/supacode/releases" --embed-release-notes --maximum-versions $MAX_DELTAS --maximum-deltas $MAX_DELTAS --delta-compression lzma --ed-key-file - "$STAGING" + cp "$STAGING/appcast.xml" build/appcast.xml + find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true + - name: Generate checksums + run: | + set -euo pipefail + DELTA_FILES=$(find build -name "*.delta" -type f | sort | tr '\n' ' ' || true) + python3 scripts/generate_release_checksums.py "$TAG" build/checksums.json build/supacode.dmg build/supacode.app.zip $DELTA_FILES + - name: Create release and upload assets + run: | + set -euo pipefail + gh release create "$TAG" --title "$TAG" --notes-file build/release-notes.md --target "${{ github.sha }}" -R "$RELEASE_REPO" 2>/dev/null || \ + gh release edit "$TAG" --target "${{ github.sha }}" -R "$RELEASE_REPO" + DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) + gh release upload "$TAG" build/supacode.app.zip build/supacode.dmg build/appcast.xml build/checksums.json $DELTA_FILES --clobber -R "$RELEASE_REPO" + - name: Verify download URLs + run: | + set -euo pipefail + curl -fsSL "https://supacode.sh/download/$TAG/checksums.json" -o /dev/null + curl -fsSL "https://supacode.sh/download/$TAG/supacode.app.zip" -o /dev/null + curl -fsSL "https://supacode.sh/download/$TAG/supacode.dmg" -o /dev/null + + # Ordered after publish-stable so the tip item merges into any freshly cut release. Tolerates a skip + # (non-release commits) but blocks on its failure, so the tip merge never runs against a half-published release. publish: runs-on: ubuntu-latest - needs: [check, build, tag] - if: needs.check.outputs.should_skip != 'true' + needs: [check, build, tag, publish-stable] + if: ${{ !cancelled() && needs.check.outputs.should_skip != 'true' && needs.build.result == 'success' && needs.tag.result == 'success' && (needs.publish-stable.result == 'success' || needs.publish-stable.result == 'skipped') }} permissions: contents: write steps: + - uses: actions/checkout@v6 - uses: actions/download-artifact@v7 with: name: build-artifacts @@ -290,7 +494,8 @@ jobs: done DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) - gh release upload tip build/supacode.dmg build/supacode.app.zip build/appcast.xml $DELTA_FILES --clobber -R "${{ github.repository }}" + python3 scripts/generate_release_checksums.py tip build/checksums.json build/supacode.dmg build/supacode.app.zip $DELTA_FILES + gh release upload tip build/supacode.dmg build/supacode.app.zip build/appcast.xml build/checksums.json $DELTA_FILES --clobber -R "${{ github.repository }}" # Store current build as history asset for future delta generation MAX_DELTAS=10 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index a81f6692f..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,286 +0,0 @@ -name: Release - -on: - release: - types: [published] - -concurrency: - group: release - cancel-in-progress: false - -jobs: - check: - runs-on: ubuntu-latest - outputs: - should_skip: ${{ steps.check.outputs.should_skip }} - steps: - - name: Check if release already has artifacts - id: check - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.event.release.tag_name }} - run: | - ASSETS=$(gh release view "$TAG" -R "${{ github.repository }}" --json assets --jq '.assets | length') - if [ "$ASSETS" -gt 0 ]; then - echo "Release $TAG already has $ASSETS assets, skipping" - echo "should_skip=true" >> "$GITHUB_OUTPUT" - else - echo "should_skip=false" >> "$GITHUB_OUTPUT" - fi - - build: - runs-on: macos-26 - needs: [check] - if: needs.check.outputs.should_skip != 'true' - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEVELOPER_ID_CERT_P12: ${{ secrets.DEVELOPER_ID_CERT_P12 }} - DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }} - DEVELOPER_ID_IDENTITY: ${{ secrets.DEVELOPER_ID_IDENTITY }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_NOTARIZATION_ISSUER: ${{ secrets.APPLE_NOTARIZATION_ISSUER }} - APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - APPLE_NOTARIZATION_KEY: ${{ secrets.APPLE_NOTARIZATION_KEY }} - SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: echo "TAG=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" - - name: Prepare release overrides - env: - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_KEY }} - POSTHOG_HOST: ${{ secrets.POSTHOG_HOST }} - run: | - set -euo pipefail - : "${SENTRY_DSN:?secret SENTRY_DSN is not set}" - : "${POSTHOG_API_KEY:?secret POSTHOG_API_KEY is not set}" - : "${POSTHOG_HOST:?secret POSTHOG_HOST is not set}" - BASE=$(awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$/{print $2; exit}' Configurations/Project.xcconfig) - BUILD_NUMBER=$((BASE * 1000)) - mkdir -p build - cat > build/ReleaseOverrides.xcconfig <> "$GITHUB_ENV" - - name: Setup keychain - run: | - echo "$DEVELOPER_ID_CERT_P12" | base64 --decode > build-cert.p12 - security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security set-keychain-settings -t 3600 -u build.keychain - security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain - security import build-cert.p12 -k build.keychain -P "$DEVELOPER_ID_CERT_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/xcodebuild > /dev/null - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain > /dev/null - security list-keychains -d user -s build.keychain $(security list-keychains -d user | tr -d '"') - security default-keychain -s build.keychain - DEVELOPER_ID_IDENTITY_SHA=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application" | head -1 | awk '{print $2}') - if [ -z "$DEVELOPER_ID_IDENTITY_SHA" ]; then - echo "::error::Developer ID Application identity not found in keychain" - exit 1 - fi - echo "DEVELOPER_ID_IDENTITY_SHA=$DEVELOPER_ID_IDENTITY_SHA" >> "$GITHUB_ENV" - - name: Archive workspace - run: make archive - - name: Upload dSYMs - uses: actions/upload-artifact@v6 - with: - name: dsyms - path: build/supacode.xcarchive/dSYMs - - run: | - cat > build/ExportOptions.plist < - - - - method - developer-id - signingStyle - manual - signingCertificate - $DEVELOPER_ID_IDENTITY - teamID - $APPLE_TEAM_ID - - - EOF - make export-archive - - name: Re-sign frameworks - run: | - set -ex - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework/Versions/B" - bash ./.github/scripts/resign_exported_app.sh build/export - - codesign -d --entitlements - "$APP_PATH/Contents/MacOS/supacode" 2>&1 | tee /tmp/supacode-entitlements.txt - grep -q "com.apple.security.device.audio-input" /tmp/supacode-entitlements.txt - - codesign -dv --verbose=4 "$APP_PATH" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$APP_PATH/Contents/MacOS/supacode" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/Updater.app/Contents/MacOS/Updater" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/XPCServices/Installer.xpc/Contents/MacOS/Installer" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - codesign -dv --verbose=4 "$SPARKLE/XPCServices/Downloader.xpc/Contents/MacOS/Downloader" 2>&1 | grep -E "Authority=Developer ID Application|Timestamp=" - echo "Signature verified successfully" - - name: Store notarization credentials - run: | - echo "$APPLE_NOTARIZATION_KEY" > notarization_key.p8 - xcrun notarytool store-credentials "notarytool-profile" \ - --key notarization_key.p8 \ - --key-id "$APPLE_NOTARIZATION_KEY_ID" \ - --issuer "$APPLE_NOTARIZATION_ISSUER" - rm notarization_key.p8 - - name: Build DMG - run: | - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - - mise exec -- create-dmg "$APP_PATH" build/ \ - --overwrite \ - --dmg-title="Supacode" \ - --identity="$DEVELOPER_ID_IDENTITY_SHA" - - DMG_OUTPUT=$(find build -name "*.dmg" -maxdepth 1 | head -1) - if [ "$DMG_OUTPUT" != "build/supacode.dmg" ]; then - mv "$DMG_OUTPUT" build/supacode.dmg - fi - - name: Notarize and staple - run: | - set -euo pipefail - APP_PATH="$(find build/export -name "supacode.app" -maxdepth 3 -print -quit)" - NOTARY_RESULT_PATH=build/notarytool-submit.json - NOTARY_LOG_PATH=build/notarytool-log.json - NOTARY_STATUS="" - NOTARY_ID="" - for attempt in 1 2 3; do - echo "Notarization attempt $attempt..." - submit_exit=0 - if ! xcrun notarytool submit build/supacode.dmg \ - --keychain-profile "notarytool-profile" \ - --wait \ - --output-format json > "$NOTARY_RESULT_PATH"; then - submit_exit=$? - fi - NOTARY_STATUS="$(jq -r '.status // empty' "$NOTARY_RESULT_PATH" 2>/dev/null || true)" - NOTARY_ID="$(jq -r '.id // empty' "$NOTARY_RESULT_PATH" 2>/dev/null || true)" - cat "$NOTARY_RESULT_PATH" || true - if [ "$submit_exit" -eq 0 ] && [ "$NOTARY_STATUS" = "Accepted" ]; then - break - fi - if [ -n "$NOTARY_ID" ]; then - xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" > "$NOTARY_LOG_PATH" || true - cat "$NOTARY_LOG_PATH" || true - fi - echo "Attempt $attempt failed, retrying in 30s..." - sleep 30 - done - if [ "$NOTARY_STATUS" != "Accepted" ]; then - echo "::error::Notarization failed with status '$NOTARY_STATUS'" - exit 1 - fi - xcrun stapler staple build/supacode.dmg - xcrun stapler staple "$APP_PATH" - ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" build/supacode.app.zip - VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist") - echo "VERSION=$VERSION" >> "$GITHUB_ENV" - - name: Fetch release notes - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release view "$TAG" --json body --jq '.body' > build/release-notes.md - - name: Generate appcast with history and deltas - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - MAX_DELTAS=10 - NOTES_FILE=build/release-notes.md - STAGING=$(mktemp -d) - ARCHIVE=build/supacode.app.zip - ARCHIVE_BASE=$(basename "$ARCHIVE") - ARCHIVE_BASE="${ARCHIVE_BASE%.zip}" - cp "$ARCHIVE" "$STAGING/" - cp "$NOTES_FILE" "$STAGING/$ARCHIVE_BASE.md" - - curl -fsSL "https://supacode.sh/download/latest/appcast.xml" -o "$STAGING/appcast.xml" || true - - RELEASE_REPO="supabitapp/supacode" - SEEN_VERSIONS="" - for i in $(seq 1 $MAX_DELTAS); do - PREV_TAG=$(gh release list --limit $i --exclude-drafts --exclude-pre-releases -R "$RELEASE_REPO" --json tagName --jq ".[$((i-1))].tagName" 2>/dev/null || true) - if [ -n "$PREV_TAG" ] && [ "$PREV_TAG" != "$TAG" ]; then - echo "Downloading $PREV_TAG for delta generation..." - gh release download "$PREV_TAG" -p "supacode.app.zip" -O "$STAGING/supacode-$PREV_TAG.app.zip" -R "$RELEASE_REPO" 2>/dev/null || true - if [ -f "$STAGING/supacode-$PREV_TAG.app.zip" ]; then - BUNDLE_VERSION=$(unzip -p "$STAGING/supacode-$PREV_TAG.app.zip" "supacode.app/Contents/Info.plist" 2>/dev/null | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /dev/stdin 2>/dev/null || echo "") - if [ -n "$BUNDLE_VERSION" ] && echo "$SEEN_VERSIONS" | grep -q "^$BUNDLE_VERSION$"; then - echo "Skipping $PREV_TAG (duplicate bundle version $BUNDLE_VERSION)" - rm -f "$STAGING/supacode-$PREV_TAG.app.zip" - else - [ -n "$BUNDLE_VERSION" ] && SEEN_VERSIONS="$SEEN_VERSIONS$BUNDLE_VERSION"$'\n' - gh release view "$PREV_TAG" -R "$RELEASE_REPO" --json body --jq '.body' > "$STAGING/supacode-$PREV_TAG.app.md" 2>/dev/null || true - fi - fi - fi - done - - printf "%s" "$SPARKLE_PRIVATE_KEY" | tr -d '\r\n\t ' | ./bins/generate_appcast --download-url-prefix "https://supacode.sh/download/$TAG/" --full-release-notes-url "https://github.com/supabitapp/supacode/releases" --embed-release-notes --maximum-versions $MAX_DELTAS --maximum-deltas $MAX_DELTAS --delta-compression lzma --ed-key-file - "$STAGING" - cp "$STAGING/appcast.xml" build/appcast.xml - find "$STAGING" -name "*.delta" -exec cp {} build/ \; 2>/dev/null || true - - uses: actions/upload-artifact@v6 - with: - name: build-artifacts - path: | - build/supacode.app.zip - build/supacode.dmg - build/appcast.xml - build/*.delta - - sentry-dsym: - runs-on: ubuntu-latest - needs: [check, build] - if: needs.check.outputs.should_skip != 'true' - steps: - - name: Install sentry-cli - run: curl -sL https://sentry.io/get-cli/ | sh - - uses: actions/download-artifact@v7 - with: - name: dsyms - path: dsyms - - name: Upload dSYMs to Sentry - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: supabit - SENTRY_PROJECT: supacode - run: sentry-cli debug-files upload --include-sources dsyms - - publish: - runs-on: ubuntu-latest - needs: [check, build] - if: needs.check.outputs.should_skip != 'true' - permissions: - contents: write - env: - RELEASE_REPO: supabitapp/supacode - steps: - - run: echo "TAG=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" - - uses: actions/download-artifact@v7 - with: - name: build-artifacts - path: build - - run: | - DELTA_FILES=$(find build -name "*.delta" -type f 2>/dev/null | tr '\n' ' ' || true) - gh release upload "$TAG" build/supacode.app.zip build/supacode.dmg build/appcast.xml $DELTA_FILES --clobber -R "$RELEASE_REPO" - env: - GH_TOKEN: ${{ github.token }} - - run: | - set -euo pipefail - curl -fsSL "https://supacode.sh/download/$TAG/supacode.app.zip" -o /dev/null - curl -fsSL "https://supacode.sh/download/$TAG/supacode.dmg" -o /dev/null diff --git a/.github/workflows/warm-cache.yml b/.github/workflows/warm-cache.yml deleted file mode 100644 index 374c37def..000000000 --- a/.github/workflows/warm-cache.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: warm-cache - -on: - push: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - warm-cache-debug: - runs-on: macos-26 - permissions: - contents: read - id-token: write - env: - MISE_HTTP_TIMEOUT: 120 - MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - uses: actions/checkout@v6 - with: - submodules: recursive - - uses: ./.github/actions/setup-macos - - run: make warm-cache diff --git a/.gitignore b/.gitignore index 4a5148706..61a5a0cb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ ## User settings xcuserdata/ +## Local build output (xcodebuild -derivedDataPath build). +build/ + ## Obj-C/Swift specific *.hmap diff --git a/AGENTS.md b/AGENTS.md index ec09b2092..ddf02bb3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ ## Build Commands ```bash +make doctor # Diagnose build prerequisites (run this first on a new machine) make build-ghostty-xcframework # Rebuild GhosttyKit from Zig source (requires mise) make build-app # Build macOS app (Debug) via xcodebuild make run-app # Build and launch Debug app @@ -21,7 +22,23 @@ xcodebuild test -project supacode.xcodeproj -scheme supacode -destination "platf CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation ``` -Requires [mise](https://mise.jdx.dev/) for zig, swiftlint, and xcsift tooling. +Requires [mise](https://mise.jdx.dev/) for zig, swiftlint, swift-format, xcbeautify, and xcsift tooling. Run `mise install` once to fetch the pinned versions. + +## Building on macOS 26.4+ (Tahoe) + +On macOS 26.4+ the GhosttyKit build fails to link with a wall of `undefined symbol: _malloc, _free, _sigaction, …` in `build_zcu.o`. **The fix is to build against Xcode 26.3, not the toolchain version.** + +**Run `make doctor` first** — it verifies every prerequisite below (mise on PATH, submodules, a Zig-linkable Xcode, license/first-launch, Metal Toolchain, pinned mise tools) and prints the exact command to fix each failure. The build targets also run it automatically as a quiet preflight (skipped on CI, or set `SUPACODE_SKIP_PREFLIGHT=1` to skip it locally). First-time setup, in order: + +1. **mise on PATH.** `make` targets call `mise exec`, but mise installs at `~/.local/bin/mise`, which non-login shells don't pick up. Activate it: `echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc` (or add `~/.local/bin` to `PATH`), then `mise install`. +2. **Submodules.** `git submodule update --init --recursive` (ghostty, zmx, git-wt). +3. **Xcode 26.3.** The pinned Zig (`0.15.2`, required exactly by ghostty's `build.zig` `requireZig`, and it uses 0.15.2-only stdlib APIs, so bumping Zig is not an option) cannot link the macOS 26.4+ SDK: that SDK's `usr/lib/libSystem.tbd` dropped the plain `arm64-macos` target (keeping only `arm64e-macos`), and Zig 0.15.2's linker won't match — [ziglang/zig#31658](https://github.com/ziglang/zig/issues/31658), fixed only in Zig 0.16+. Install [Xcode 26.3](https://developer.apple.com/download/all/?q=Xcode%2026.3), which ships the macOS 26.2 SDK whose `.tbd` still has `arm64-macos`. **You do not need to `sudo xcode-select -s` it globally** — keep your newer Xcode as the default for other projects. The build auto-detects a Zig-linkable Xcode via `scripts/select-developer-dir.sh` and pins `DEVELOPER_DIR` for just that build (override with `DEVELOPER_DIR=… make build-app` if you want a specific one). +4. **License + first launch.** A freshly installed Xcode 26.3 must complete these before `DEVELOPER_DIR` works (we observed `DEVELOPER_DIR` alone is insufficient until then): `sudo DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer xcodebuild -license accept` and `… -runFirstLaunch`. +5. **Metal Toolchain.** A fresh Xcode 26.3 ships it uninstalled, and ghostty compiles Metal shaders → `cannot execute tool 'metal' due to missing Metal Toolchain`. Install it into that Xcode (target it explicitly so it lands in 26.3, not whatever is globally selected): `sudo DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer xcodebuild -downloadComponent MetalToolchain`. + +**Verification quirk:** check the SDK *version*, not just the `arm64-macos` slice. macOS 26.4+ SDKs still list `arm64-macos` in `libSystem.tbd` yet Zig 0.15.2 cannot link them, so grepping that string gives false positives (it accepts Xcode 26.5). `scripts/select-developer-dir.sh` gates on `xcrun --sdk macosx --show-sdk-version` being `<= 26.3` instead. Use the `--sdk macosx` form, not bare `xcrun --show-sdk-version`, which can resolve to the CommandLineTools SDK and mislead you. + +**Why no `patches/` entry:** the link failure is in Zig's own self-hosted linker (`build_zcu.o`, the build runner itself), not in ghostty source, so the `patches/*.patch` mechanism — which only patches the ghostty submodule working tree — cannot fix it; and ghostty pins Zig to exactly 0.15.2, so bumping Zig is out. The older-SDK + auto-`DEVELOPER_DIR` approach is the long-term fix until ghostty supports Zig 0.16+. ## Architecture @@ -112,6 +129,7 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream ### Formatting & Linting - 2-space indentation, 120 character line length (enforced by `.swift-format.json`) +- `make format` runs the mise-pinned `swift-format` (`spm:swiftlang/swift-format` in `mise.toml`), NOT the Xcode toolchain's built-in `swift format`. The pin keeps formatting reproducible across contributors' Xcodes — an unpinned toolchain formatter rewrites the whole tree (e.g. Swift call-site trailing commas) and produces spurious churn. Bump the pin in lockstep with the Swift toolchain (tag `60X.x` ↔ Swift 6.X). - Trailing commas are mandatory (enforced by `.swiftlint.yml`) - SwiftLint runs in strict mode; never disable lint rules without permission - Custom SwiftLint rule: `store_state_mutation_in_views` — do not mutate `store.*` directly in view files; send actions instead @@ -181,5 +199,5 @@ Reducer ← .repositories(.worktreeInfoEvent(Event)) ← AsyncStream ## Submodules -- `ThirdParty/ghostty` (`https://github.com/ghostty-org/ghostty`): Source dependency used to build `Frameworks/GhosttyKit.xcframework` and terminal resources. +- `ThirdParty/ghostty` (`https://github.com/ghostty-org/ghostty`): Source dependency used to build `Frameworks/GhosttyKit.xcframework` and terminal resources. The pin tracks upstream; local changes live as out-of-tree patches in `patches/*.patch`, applied to the working tree by `scripts/build-ghostty.sh` before `zig build` and reverted on exit (the pin is never moved, no fork). On a ghostty bump a patch may stop applying and the build fails loudly: refresh the patch, and prefer upstreaming it to retire the carry cost. Run one ghostty build at a time (the apply/revert shares the submodule working tree). - `Resources/git-wt` (`https://github.com/khoi/git-wt.git`): Bundled `wt` CLI used by Supacode Git worktree flows at runtime. diff --git a/Configurations/Project.xcconfig b/Configurations/Project.xcconfig index 673e9d9af..a91124f13 100644 --- a/Configurations/Project.xcconfig +++ b/Configurations/Project.xcconfig @@ -1,5 +1,5 @@ -MARKETING_VERSION = 0.10.1 -CURRENT_PROJECT_VERSION = 141 +MARKETING_VERSION = 0.10.4 +CURRENT_PROJECT_VERSION = 144 POSTHOG_API_KEY = POSTHOG_HOST = SENTRY_DSN = diff --git a/Makefile b/Makefile index 78c6c58be..9d44b1a14 100644 --- a/Makefile +++ b/Makefile @@ -16,19 +16,29 @@ TUIST_GENERATION_STAMP_DIR := $(CURRENT_MAKEFILE_DIR)/.build/.tuist-generated-st TUIST_INSTALL_STAMP := $(TUIST_GENERATION_STAMP_DIR)/.installed TUIST_DEVELOPMENT_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/development TUIST_SOURCE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/none -TUIST_SOURCE_RELEASE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/none-release +TUIST_RELEASE_GENERATION_STAMP := $(TUIST_GENERATION_STAMP_DIR)/development-release TUIST_GENERATION_INPUTS := Project.swift Workspace.swift Tuist.swift Tuist/Package.swift $(wildcard Tuist/Package.resolved) $(PROJECT_CONFIG_PATH) mise.toml scripts/build-ghostty.sh scripts/build-zmx.sh TUIST_GENERATE_CACHE_PROFILE ?= development TUIST_CACHE_CONFIGURATION ?= Debug VERSION ?= BUILD ?= +TITLE ?= +BODY ?= +# Export so headline markdown reaches the script without a shell re-parse of quotes/backticks. +export VERSION BUILD TITLE BODY XCODEBUILD_FLAGS ?= +SUPACODE_SKIP_PREFLIGHT ?= + +# Export a Zig-linkable Xcode per build recipe (no global xcode-select -s). Plain +# assignment so a missing Xcode aborts the recipe under -e. +SELECT_DEVELOPER_DIR = DEVELOPER_DIR="$$(./scripts/select-developer-dir.sh)"; export DEVELOPER_DIR .DEFAULT_GOAL := help -.PHONY: build-ghostty-xcframework build-zmx generate-project generate-project-sources inspect-dependencies warm-cache build-app run-app install-dev-build archive export-archive format lint check test bump-version bump-and-release log-stream +.PHONY: doctor preflight build-ghostty-xcframework build-zmx generate-project generate-project-sources inspect-dependencies warm-cache build-app package-app run-app install-dev-build archive export-archive format lint check test bump-version bump-and-release log-stream ifdef CI TUIST_INSTALL_FLAGS := --force-resolved-versions +SUPACODE_SKIP_PREFLIGHT := 1 else TUIST_INSTALL_FLAGS := endif @@ -42,7 +52,7 @@ generate-project: $(TUIST_GENERATION_STAMP_DIR)/$(TUIST_GENERATE_CACHE_PROFILE) generate-project-sources: $(TUIST_SOURCE_GENERATION_STAMP) # Resolve packages and generate a source-only Xcode workspace -$(TUIST_INSTALL_STAMP): $(TUIST_GENERATION_INPUTS) +$(TUIST_INSTALL_STAMP): $(TUIST_GENERATION_INPUTS) | preflight mkdir -p "$(TUIST_GENERATION_STAMP_DIR)" mise exec -- tuist install $(TUIST_INSTALL_FLAGS) touch "$@" @@ -58,7 +68,8 @@ $(TUIST_GENERATION_STAMP_DIR)/%: $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAM mise exec -- tuist generate --no-open --cache-profile "$*" touch "$@" -$(TUIST_SOURCE_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAMP) +# Consumes the warmed Release binary cache, so archive compiles only the app shell. +$(TUIST_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INSTALL_STAMP) mkdir -p "$(TUIST_GENERATION_STAMP_DIR)" find "$(TUIST_GENERATION_STAMP_DIR)" -mindepth 1 -maxdepth 1 ! -name '.installed' -delete rm -rf supacode.xcodeproj supacode.xcworkspace @@ -66,13 +77,22 @@ $(TUIST_SOURCE_RELEASE_GENERATION_STAMP): $(TUIST_GENERATION_INPUTS) $(TUIST_INS [ -e "$$path" ] || continue; \ rm -rf "$$path"; \ done - mise exec -- tuist generate --no-open --cache-profile none --configuration Release + mise exec -- tuist generate --no-open --cache-profile development --configuration Release touch "$@" -build-ghostty-xcframework: # Build ghostty framework +doctor: # Diagnose build prerequisites and print the fix for each failure + @./scripts/doctor.sh + +# Order-only preflight on the install stamp, so every build flow fails fast with +# doctor's actionable message before tuist / xcodebuild / zig run. Never forces a +# rebuild. Skipped when SUPACODE_SKIP_PREFLIGHT is set (CI sets it above). +preflight: + @[ -n "$(SUPACODE_SKIP_PREFLIGHT)" ] || ./scripts/doctor.sh --quiet + +build-ghostty-xcframework: | preflight # Build ghostty framework ./scripts/build-ghostty.sh -build-zmx: # Build bundled zmx binary from ThirdParty/zmx submodule +build-zmx: | preflight # Build bundled zmx binary from ThirdParty/zmx submodule ./scripts/build-zmx.sh inspect-dependencies: $(TUIST_INSTALL_STAMP) # Check for implicit Tuist dependencies @@ -82,17 +102,31 @@ warm-cache: $(TUIST_INSTALL_STAMP) # Warm the full Tuist cacheable graph mise exec -- tuist cache warm --configuration $(TUIST_CACHE_CONFIGURATION) build-app: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Build the macOS app (Debug) - bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation 2>&1 | mise exec -- xcbeautify --disable-logging' + $(SELECT_DEVELOPER_DIR); \ + bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug build -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | { mise exec -- xcbeautify --disable-logging || cat; }' -run-app: build-app # Build then launch (Debug) with log streaming +package-app: build-app # Zip the Debug build to build/supacode.app.zip (for CI artifacts) + @mkdir -p build @settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ build_dir="$$(echo "$$settings" | jq -r '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -r '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ + src="$$build_dir/$$product"; \ + if [ ! -d "$$src" ]; then echo "app not found: $$src"; exit 1; fi; \ + rm -f build/supacode.app.zip; \ + ditto -c -k --sequesterRsrc --keepParent "$$src" build/supacode.app.zip; \ + echo "packaged build/supacode.app.zip" + +run-app: build-app # Build then launch (Debug) with log streaming + @$(SELECT_DEVELOPER_DIR); \ + settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + build_dir="$$(echo "$$settings" | jq -r '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ + product="$$(echo "$$settings" | jq -r '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ exec_name="$$(echo "$$settings" | jq -r '.[0].buildSettings.EXECUTABLE_NAME')"; \ "$$build_dir/$$product/Contents/MacOS/$$exec_name" install-dev-build: build-app # install dev build to /Applications - @settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ + @$(SELECT_DEVELOPER_DIR); \ + settings="$$(xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Debug -showBuildSettings -json 2>/dev/null)"; \ build_dir="$$(echo "$$settings" | jq -r '.[0].buildSettings.BUILT_PRODUCTS_DIR')"; \ product="$$(echo "$$settings" | jq -r '.[0].buildSettings.FULL_PRODUCT_NAME')"; \ src="$$build_dir/$$product"; \ @@ -106,22 +140,25 @@ install-dev-build: build-app # install dev build to /Applications ditto "$$src" "$$dst"; \ echo "installed $$dst" -archive: $(TUIST_SOURCE_RELEASE_GENERATION_STAMP) # Archive Release build for distribution +archive: $(TUIST_RELEASE_GENERATION_STAMP) # Archive Release build for distribution mkdir -p build - bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Release -destination "generic/platform=macOS" -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | mise exec -- xcbeautify --quiet --disable-logging' + $(SELECT_DEVELOPER_DIR); \ + bash -o pipefail -c 'xcodebuild -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -configuration Release -destination "generic/platform=macOS" -archivePath build/supacode.xcarchive archive CODE_SIGN_STYLE=Manual DEVELOPMENT_TEAM="$$APPLE_TEAM_ID" CODE_SIGN_IDENTITY="$$DEVELOPER_ID_IDENTITY_SHA" OTHER_CODE_SIGN_FLAGS="--timestamp" -skipMacroValidation $(XCODEBUILD_FLAGS) 2>&1 | { mise exec -- xcbeautify --quiet --disable-logging || cat; }' export-archive: # Export xarchive - bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | mise exec -- xcbeautify --quiet --disable-logging' + $(SELECT_DEVELOPER_DIR); \ + bash -o pipefail -c 'xcodebuild -exportArchive -archivePath build/supacode.xcarchive -exportPath build/export -exportOptionsPlist build/ExportOptions.plist 2>&1 | { mise exec -- xcbeautify --quiet --disable-logging || cat; }' test: $(TUIST_DEVELOPMENT_GENERATION_STAMP) # Run all tests - @if [ -t 1 ]; then \ - bash -o pipefail -c 'xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO 2>&1 | mise exec -- xcbeautify --disable-logging'; \ + @$(SELECT_DEVELOPER_DIR); \ + if [ -t 1 ]; then \ + bash -o pipefail -c 'xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO 2>&1 | { mise exec -- xcbeautify --disable-logging || cat; }'; \ else \ xcodebuild test -workspace "$(PROJECT_WORKSPACE)" -scheme "$(APP_SCHEME)" -destination "platform=macOS" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" -skipMacroValidation -parallel-testing-enabled NO; \ fi -format: # Format code with swift format (local only). - swift format -p --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests SupacodeSettingsShared SupacodeSettingsFeature +format: # Format code with swift-format (mise-pinned for reproducibility). + mise exec -- swift-format --parallel --in-place --recursive --configuration ./.swift-format.json supacode supacode-cli supacodeTests SupacodeSettingsShared SupacodeSettingsFeature lint: # Lint code with swiftlint mise exec -- swiftlint lint --quiet --config .swiftlint.yml @@ -131,58 +168,9 @@ check: format lint # Format and lint log-stream: # Stream logs from the app via log stream log stream --predicate 'subsystem == "app.supabit.supacode"' --style compact --color always -bump-version: # Bump app version (usage: make bump-version [VERSION=x.x.x] [BUILD=123]) - @if [ -z "$(VERSION)" ]; then \ - current="$$(/usr/bin/awk -F' = ' '/^MARKETING_VERSION = [0-9.]+$$/{print $$2; exit}' "$(PROJECT_CONFIG_PATH)")"; \ - if [ -z "$$current" ]; then \ - echo "error: MARKETING_VERSION not found"; \ - exit 1; \ - fi; \ - major="$$(echo "$$current" | cut -d. -f1)"; \ - minor="$$(echo "$$current" | cut -d. -f2)"; \ - patch="$$(echo "$$current" | cut -d. -f3)"; \ - version="$$major.$$minor.$$((patch + 1))"; \ - else \ - if ! echo "$(VERSION)" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$$'; then \ - echo "error: VERSION must be in x.x.x format"; \ - exit 1; \ - fi; \ - version="$(VERSION)"; \ - fi; \ - if [ -z "$(BUILD)" ]; then \ - build="$$(/usr/bin/awk -F' = ' '/^CURRENT_PROJECT_VERSION = [0-9]+$$/{print $$2; exit}' "$(PROJECT_CONFIG_PATH)")"; \ - if [ -z "$$build" ]; then \ - echo "error: CURRENT_PROJECT_VERSION not found"; \ - exit 1; \ - fi; \ - build="$$((build + 1))"; \ - else \ - if ! echo "$(BUILD)" | grep -qE '^[0-9]+$$'; then \ - echo "error: BUILD must be an integer"; \ - exit 1; \ - fi; \ - build="$(BUILD)"; \ - fi; \ - sed -i '' "s/^MARKETING_VERSION = [0-9.]*/MARKETING_VERSION = $$version/g" \ - "$(PROJECT_CONFIG_PATH)"; \ - sed -i '' "s/^CURRENT_PROJECT_VERSION = [0-9]*/CURRENT_PROJECT_VERSION = $$build/g" \ - "$(PROJECT_CONFIG_PATH)"; \ - git add "$(PROJECT_CONFIG_PATH)"; \ - git commit -S -m "bump v$$version"; \ - git tag -s "v$$version" -m "v$$version"; \ - echo "version bumped to $$version (build $$build), tagged v$$version" - -bump-and-release: bump-version # Bump version and push tags to trigger release +bump-version: # Bump app version (usage: make bump-version VERSION=x.y.z [BUILD=123] [TITLE=… BODY=…]) + @./scripts/bump-version.sh + +# main.yml detects the tag at HEAD and cuts the release, prepending its headline. +bump-and-release: bump-version # Bump version and push tags to trigger the release (usage: make bump-and-release VERSION=x.y.z [TITLE=… BODY=…]) git push --follow-tags - @tag="$$(git describe --tags --abbrev=0)"; \ - repo="$$(gh repo view --json nameWithOwner -q .nameWithOwner)"; \ - prev="$$(gh release view --json tagName -q .tagName 2>/dev/null || echo '')"; \ - tmp="$$(mktemp)"; \ - if [ -n "$$prev" ]; then \ - gh api "repos/$$repo/releases/generate-notes" -f tag_name="$$tag" -f previous_tag_name="$$prev" --jq '.body' > "$$tmp"; \ - else \ - gh api "repos/$$repo/releases/generate-notes" -f tag_name="$$tag" --jq '.body' > "$$tmp"; \ - fi; \ - $${EDITOR:-vim} "$$tmp"; \ - gh release create "$$tag" --notes-file "$$tmp"; \ - rm -f "$$tmp" diff --git a/README.md b/README.md index e61930a1e..46642d805 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,23 @@ Native terminal coding agents command center. ## Requirements - macOS 26.0+ -- [mise](https://mise.jdx.dev/) (for dependencies) +- [mise](https://mise.jdx.dev/) (for dependencies) — add `~/.local/bin` to your `PATH` +- **Xcode 26.3** if you are on macOS 26.4+ — see below +- git submodules: `git submodule update --init --recursive` + +### Building on macOS 26.4+ (Tahoe) + +The GhosttyKit build uses a pinned Zig (`0.15.2`, required exactly by ghostty) whose linker can't link the macOS 26.4+ SDK — that SDK dropped the `arm64-macos` slice from `libSystem.tbd` ([ziglang/zig#31658](https://github.com/ziglang/zig/issues/31658)), so the build fails with a wall of `undefined symbol` errors. Install [Xcode 26.3](https://developer.apple.com/download/all/?q=Xcode%2026.3) (it ships the macOS 26.2 SDK, which still has `arm64-macos`). You **don't** need to switch it globally — the build auto-detects a Zig-linkable Xcode and pins it for just that build, so a newer Xcode can stay your default. After installing it once: + +```bash +# accept the license and finish first launch (required before the build can use it) +sudo DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer xcodebuild -license accept +sudo DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer xcodebuild -runFirstLaunch +# install the Metal Toolchain into that Xcode (ghostty compiles Metal shaders; a fresh Xcode ships it uninstalled) +sudo DEVELOPER_DIR=/Applications/Xcode_26.3.app/Contents/Developer xcodebuild -downloadComponent MetalToolchain +``` + +See [AGENTS.md](AGENTS.md) for the full rationale. ## Building @@ -25,11 +41,14 @@ make mac-warm-cache ``` ```bash +make doctor # Diagnose build prerequisites and print fixes make build-ghostty-xcframework # Build GhosttyKit from Zig source make build-app # Build macOS app (Debug) make run-app # Build and launch ``` +`make doctor` checks every prerequisite (mise, submodules, a Zig-linkable Xcode, the Metal Toolchain, pinned tools) and prints the exact fix for anything missing. The build targets run it automatically as a quiet preflight. + ## Development ```bash diff --git a/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift b/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift index f488c8fe8..aa3e06952 100644 --- a/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift +++ b/SupacodeSettingsFeature/Models/SettingsRepositorySummary.swift @@ -1,17 +1,33 @@ import Foundation +import SupacodeSettingsShared public struct SettingsRepositorySummary: Equatable, Hashable, Sendable { public var id: String public var name: String public var isGitRepository: Bool + /// The SSH host the repository lives on, `nil` for local. Carried (rather than + /// a bare bool) so the per-repo settings key can brand remote repos by host. + public var host: RemoteHost? + /// The repository's real root URL, used to key its per-repo settings. For a + /// local repo this equals `URL(fileURLWithPath: id)`; for a remote repo `id` + /// is a `remote:` key (not a path), so the bare remote path must be passed in + /// explicitly so the settings key matches the worktree's `repositoryRootURL`. + public var rootURL: URL - public var rootURL: URL { - URL(fileURLWithPath: id).standardizedFileURL - } + /// Lives on an SSH host. Partitions the settings sidebar into Local / Remote. + public var isRemote: Bool { host != nil } - public init(id: String, name: String, isGitRepository: Bool = true) { + public init( + id: String, + name: String, + isGitRepository: Bool = true, + host: RemoteHost? = nil, + rootURL: URL? = nil + ) { self.id = id self.name = name self.isGitRepository = isGitRepository + self.host = host + self.rootURL = (rootURL ?? URL(fileURLWithPath: id)).standardizedFileURL } } diff --git a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift index 2ffcfa2e7..c4341c480 100644 --- a/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/RepositorySettingsFeature.swift @@ -8,6 +8,7 @@ public struct RepositorySettingsFeature { @ObservableState public struct State: Equatable { public var rootURL: URL + public var host: RemoteHost? public var isGitRepository: Bool public var settings: RepositorySettings public var globalDefaultWorktreeBaseDirectoryPath: String? @@ -32,6 +33,7 @@ public struct RepositorySettingsFeature { public init( rootURL: URL, + host: RemoteHost? = nil, isGitRepository: Bool = true, settings: RepositorySettings, globalDefaultWorktreeBaseDirectoryPath: String? = nil, @@ -44,6 +46,7 @@ public struct RepositorySettingsFeature { isBranchDataLoaded: Bool = false ) { self.rootURL = rootURL + self.host = host self.isGitRepository = isGitRepository self.settings = settings self.globalDefaultWorktreeBaseDirectoryPath = globalDefaultWorktreeBaseDirectoryPath @@ -82,7 +85,7 @@ public struct RepositorySettingsFeature { @CasePathable public enum Delegate: Equatable { - case settingsChanged(URL) + case settingsChanged(URL, host: RemoteHost?) } @Dependency(RepositorySettingsGitClient.self) private var gitClient @@ -96,7 +99,7 @@ public struct RepositorySettingsFeature { case .task: let rootURL = state.rootURL let isGitRepository = state.isGitRepository - @Shared(.repositorySettings(rootURL)) var repositorySettings + @Shared(.repositorySettings(rootURL, host: state.host)) var repositorySettings @Shared(.settingsFile) var settingsFile let settings = repositorySettings let global = settingsFile.global @@ -175,9 +178,10 @@ public struct RepositorySettingsFeature { state.isBareRepository = isBareRepository guard updatedSettings != settings else { return .none } let rootURL = state.rootURL - @Shared(.repositorySettings(rootURL)) var repositorySettings + let host = state.host + @Shared(.repositorySettings(rootURL, host: host)) var repositorySettings $repositorySettings.withLock { $0 = updatedSettings } - return .send(.delegate(.settingsChanged(rootURL))) + return .send(.delegate(.settingsChanged(rootURL, host: host))) case .branchDataLoaded(let branches, let defaultBaseRef): state.defaultWorktreeBaseRef = defaultBaseRef @@ -244,13 +248,14 @@ public struct RepositorySettingsFeature { /// Persists the current settings and notifies the delegate. private func persistAndNotify(state: inout State) -> Effect { let rootURL = state.rootURL + let host = state.host var normalizedSettings = state.settings normalizedSettings.worktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath( normalizedSettings.worktreeBaseDirectoryPath, repositoryRootURL: rootURL ) - @Shared(.repositorySettings(rootURL)) var repositorySettings + @Shared(.repositorySettings(rootURL, host: host)) var repositorySettings $repositorySettings.withLock { $0 = normalizedSettings } - return .send(.delegate(.settingsChanged(rootURL))) + return .send(.delegate(.settingsChanged(rootURL, host: host))) } } diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index 2dcebacd0..502fcc07b 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -255,9 +255,7 @@ public struct SettingsFeature { var updatedSettings = settings updatedSettings.defaultEditorID = normalizedDefaultEditorID updatedSettings.defaultWorktreeBaseDirectoryPath = normalizedWorktreeBaseDirPath - normalizedSettings = updatedSettings - @Shared(.settingsFile) var settingsFile - $settingsFile.withLock { $0.global = normalizedSettings } + normalizedSettings = persistGlobalSettings(updatedSettings) } state.appearanceMode = normalizedSettings.appearanceMode state.defaultEditorID = normalizedSettings.defaultEditorID @@ -580,15 +578,22 @@ public struct SettingsFeature { } private func persist(_ state: State) -> Effect { - let settings = state.globalSettings - @Shared(.settingsFile) var settingsFile - $settingsFile.withLock { $0.global = settings } + let settings = persistGlobalSettings(state.globalSettings) if settings.analyticsEnabled { analyticsClient.capture("settings_changed", nil) } return .send(.delegate(.settingsChanged(settings))) } + @discardableResult + private func persistGlobalSettings(_ settings: GlobalSettings) -> GlobalSettings { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { + $0.global = settings + } + return settings + } + private func synchronizeRepositorySelection(for state: inout State) { guard let selection = state.selection else { state.repositorySettings = nil @@ -603,10 +608,15 @@ public struct SettingsFeature { state.repositorySettings = nil return } - if state.repositorySettings?.rootURL != summary.rootURL { - @Shared(.repositorySettings(summary.rootURL)) var repositorySettings + // Compare on host too: two remote hosts at the same path share a `rootURL` + // but are distinct repositories, so a path-only check would keep stale state. + if state.repositorySettings?.rootURL != summary.rootURL + || state.repositorySettings?.host != summary.host + { + @Shared(.repositorySettings(summary.rootURL, host: summary.host)) var repositorySettings state.repositorySettings = RepositorySettingsFeature.State( rootURL: summary.rootURL, + host: summary.host, isGitRepository: summary.isGitRepository, settings: repositorySettings ) diff --git a/SupacodeSettingsShared/App/AppShortcuts.swift b/SupacodeSettingsShared/App/AppShortcuts.swift index 0ed4ce5c3..a621d2726 100644 --- a/SupacodeSettingsShared/App/AppShortcuts.swift +++ b/SupacodeSettingsShared/App/AppShortcuts.swift @@ -5,14 +5,14 @@ import SwiftUI // Compile-time checkable shortcut identifier. public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRepresentable { - case commandPalette, openSettings, checkForUpdates, showMainWindow + case commandPalette, worktreeSwitcher, openSettings, checkForUpdates, showMainWindow case toggleLeftSidebar, revealInSidebar case newWorktree, refreshWorktrees, archivedWorktrees, archiveWorktree case deleteWorktree, confirmWorktreeAction case selectNextWorktree, selectPreviousWorktree case worktreeHistoryBack, worktreeHistoryForward case selectWorktree(Int) - case openWorktree, revealInFinder, openRepository, openPullRequest, copyPath + case openWorktree, revealInFinder, openRepository, addRemoteRepository, openPullRequest, copyPath case runScript, stopRunScript case jumpToLatestUnread @@ -36,6 +36,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep private var stableKey: String { switch self { case .commandPalette: "commandPalette" + case .worktreeSwitcher: "worktreeSwitcher" case .openSettings: "openSettings" case .checkForUpdates: "checkForUpdates" case .showMainWindow: "showMainWindow" @@ -55,6 +56,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .openWorktree: "openWorktree" case .revealInFinder: "revealInFinder" case .openRepository: "openRepository" + case .addRemoteRepository: "addRemoteRepository" case .openPullRequest: "openPullRequest" case .copyPath: "copyPath" case .runScript: "runScript" @@ -65,6 +67,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep private static let stableKeyMap: [String: AppShortcutID] = [ "commandPalette": .commandPalette, + "worktreeSwitcher": .worktreeSwitcher, "openSettings": .openSettings, "checkForUpdates": .checkForUpdates, "showMainWindow": .showMainWindow, @@ -84,6 +87,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep "openFinder": .openWorktree, "revealInFinder": .revealInFinder, "openRepository": .openRepository, + "addRemoteRepository": .addRemoteRepository, "openPullRequest": .openPullRequest, "copyPath": .copyPath, "runScript": .runScript, @@ -106,6 +110,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep public var displayName: String { switch self { case .commandPalette: "Command Palette" + case .worktreeSwitcher: "Go to Worktree" case .openSettings: "Open Settings" case .checkForUpdates: "Check For Updates" case .showMainWindow: "Show Main Window" @@ -125,6 +130,7 @@ public nonisolated enum AppShortcutID: Codable, Hashable, Sendable, CodingKeyRep case .openWorktree: "Open Worktree" case .revealInFinder: "Reveal in Finder" case .openRepository: "Open Repository or Folder" + case .addRemoteRepository: "Add Remote Repository or Folder" case .openPullRequest: "Open Pull Request" case .copyPath: "Copy Path" case .runScript: "Run Script" @@ -265,31 +271,13 @@ public struct AppShortcutGroup: Identifiable { // MARK: - Registry. public enum AppShortcuts { - private struct TabSelectionBinding { - let unicode: String - let physical: String - let tabIndex: Int - } - - private static let tabSelectionBindings: [TabSelectionBinding] = [ - TabSelectionBinding(unicode: "1", physical: "digit_1", tabIndex: 1), - TabSelectionBinding(unicode: "2", physical: "digit_2", tabIndex: 2), - TabSelectionBinding(unicode: "3", physical: "digit_3", tabIndex: 3), - TabSelectionBinding(unicode: "4", physical: "digit_4", tabIndex: 4), - TabSelectionBinding(unicode: "5", physical: "digit_5", tabIndex: 5), - TabSelectionBinding(unicode: "6", physical: "digit_6", tabIndex: 6), - TabSelectionBinding(unicode: "7", physical: "digit_7", tabIndex: 7), - TabSelectionBinding(unicode: "8", physical: "digit_8", tabIndex: 8), - TabSelectionBinding(unicode: "9", physical: "digit_9", tabIndex: 9), - TabSelectionBinding(unicode: "0", physical: "digit_0", tabIndex: 10), - ] - // MARK: - Shortcut definitions. - public static let commandPalette = AppShortcut(id: .commandPalette, key: "p", modifiers: .command) + public static let commandPalette = AppShortcut(id: .commandPalette, key: "p", modifiers: [.command, .shift]) + public static let worktreeSwitcher = AppShortcut(id: .worktreeSwitcher, key: "p", modifiers: .command) public static let openSettings = AppShortcut(id: .openSettings, key: ",", modifiers: .command) public static let checkForUpdates = AppShortcut(id: .checkForUpdates, key: "u", modifiers: .command) - public static let showMainWindow = AppShortcut(id: .showMainWindow, key: "0", modifiers: .command) + public static let showMainWindow = AppShortcut(id: .showMainWindow, key: "0", modifiers: [.command, .shift]) public static let toggleLeftSidebar = AppShortcut(id: .toggleLeftSidebar, key: "[", modifiers: .command) public static let revealInSidebar = AppShortcut(id: .revealInSidebar, key: "e", modifiers: [.command, .shift]) @@ -335,11 +323,13 @@ public enum AppShortcuts { public static let selectWorktree7 = AppShortcut(id: .selectWorktree(7), key: "7", modifiers: [.control]) public static let selectWorktree8 = AppShortcut(id: .selectWorktree(8), key: "8", modifiers: [.control]) public static let selectWorktree9 = AppShortcut(id: .selectWorktree(9), key: "9", modifiers: [.control]) - public static let selectWorktree0 = AppShortcut(id: .selectWorktree(0), key: "0", modifiers: [.control]) public static let openWorktree = AppShortcut(id: .openWorktree, key: "o", modifiers: .command) public static let revealInFinder = AppShortcut(id: .revealInFinder, key: "r", modifiers: [.command, .option]) public static let openRepository = AppShortcut(id: .openRepository, key: "o", modifiers: [.command, .shift]) + public static let addRemoteRepository = AppShortcut( + id: .addRemoteRepository, key: "k", modifiers: [.command, .shift] + ) public static let openPullRequest = AppShortcut(id: .openPullRequest, key: "g", modifiers: [.command, .control]) public static let copyPath = AppShortcut(id: .copyPath, key: "c", modifiers: [.command, .shift]) public static let runScript = AppShortcut(id: .runScript, key: "r", modifiers: .command) @@ -350,7 +340,7 @@ public enum AppShortcuts { public static let worktreeSelection: [AppShortcut] = [ selectWorktree1, selectWorktree2, selectWorktree3, selectWorktree4, selectWorktree5, - selectWorktree6, selectWorktree7, selectWorktree8, selectWorktree9, selectWorktree0, + selectWorktree6, selectWorktree7, selectWorktree8, selectWorktree9, ] public static func worktreeSelectionShortcutDisplay( @@ -378,7 +368,7 @@ public enum AppShortcuts { public static let groups: [AppShortcutGroup] = [ AppShortcutGroup( category: .general, - shortcuts: [commandPalette, openSettings, checkForUpdates, showMainWindow] + shortcuts: [worktreeSwitcher, commandPalette, openSettings, checkForUpdates, showMainWindow] ), AppShortcutGroup(category: .sidebar, shortcuts: [toggleLeftSidebar, revealInSidebar]), AppShortcutGroup( @@ -393,8 +383,8 @@ public enum AppShortcuts { AppShortcutGroup( category: .actions, shortcuts: [ - openWorktree, revealInFinder, openRepository, openPullRequest, copyPath, runScript, stopRunScript, - jumpToLatestUnread, + openWorktree, revealInFinder, openRepository, addRemoteRepository, openPullRequest, + copyPath, runScript, stopRunScript, jumpToLatestUnread, ] ), ] @@ -405,11 +395,28 @@ public enum AppShortcuts { // MARK: - Tab selection Ghostty bindings. - public static let tabSelectionGhosttyKeybindArguments: [String] = tabSelectionBindings.flatMap { binding in - [ - "--keybind=ctrl+\(binding.unicode)=goto_tab:\(binding.tabIndex)", - "--keybind=ctrl+\(binding.physical)=goto_tab:\(binding.tabIndex)", - ] + // Ghostty `goto_tab` bindings for worktree selection, derived from the user's + // effective shortcuts instead of a fixed list. A disabled shortcut produces no + // binding, so its chord (e.g. ⌃6) reaches the terminal instead of being captured. + // A remapped shortcut moves the binding to the chosen key. The physical `digit_N` + // variant is emitted only while the binding stays the default Control+digit, so + // non-US keyboard layouts keep working. + public static func tabSelectionGhosttyKeybindArguments( + from overrides: [AppShortcutID: AppShortcutOverride] + ) -> [String] { + worktreeSelection.flatMap { shortcut -> [String] in + guard case .selectWorktree(let slot) = shortcut.id, + let effective = shortcut.effective(from: overrides) + else { + return [] + } + let tabIndex = slot == 0 ? 10 : slot + var arguments = ["--keybind=\(effective.ghosttyKeybind)=goto_tab:\(tabIndex)"] + if effective.ghosttyKeybind == "ctrl+\(slot)" { + arguments.append("--keybind=ctrl+digit_\(slot)=goto_tab:\(tabIndex)") + } + return arguments + } } // MARK: - Ghostty CLI arguments. @@ -420,7 +427,7 @@ public enum AppShortcuts { public static func ghosttyCLIKeybindArguments(from overrides: [AppShortcutID: AppShortcutOverride]) -> [String] { let effectiveShortcuts = all.compactMap { $0.effective(from: overrides) } - return effectiveShortcuts.map(\.ghosttyUnbindArgument) + tabSelectionGhosttyKeybindArguments + return effectiveShortcuts.map(\.ghosttyUnbindArgument) + tabSelectionGhosttyKeybindArguments(from: overrides) } // MARK: - Conflict detection. diff --git a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift index e3fb815f9..e49c44289 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentHookSettingsCommand.swift @@ -13,7 +13,7 @@ nonisolated enum HookEvent: String { nonisolated enum AgentHookSettingsCommand { /// Sentinel comment appended to every Supacode-installed hook command. - /// `AgentHookCommandOwnership` uses this — and ONLY this — to identify + /// `AgentHookCommandOwnership` uses this (and ONLY this) to identify /// managed commands. `SUPACODE_SOCKET_PATH` is documented public API /// (CLI skill env table, Pi extension example, deeplink reference), so /// matching on the env-var name alone would silently strip user-authored @@ -22,7 +22,7 @@ nonisolated enum AgentHookSettingsCommand { /// Documented public env var. Used as ONE half of the legacy CLI-shim /// fingerprint (paired with `supacode integration event`); never matched - /// alone — user-authored hooks reference it legitimately. + /// alone. User-authored hooks reference it legitimately. static let socketPathEnvVar = "SUPACODE_SOCKET_PATH" /// Markers present in legacy Supacode hook commands (pre-socket). @@ -43,22 +43,11 @@ nonisolated enum AgentHookSettingsCommand { + #" && [ -n "${SUPACODE_TAB_ID:-}" ]"# + #" && [ -n "${SUPACODE_SURFACE_ID:-}" ]"# - private static let ids = - "$SUPACODE_WORKTREE_ID $SUPACODE_TAB_ID $SUPACODE_SURFACE_ID" - - /// Both stdout AND stderr go to /dev/null — Codex parses hook stdout as - /// structured JSON and would reject the socket ack otherwise. - private static func managed(_ pipeline: String) -> String { - "\(envCheck) && \(pipeline) >/dev/null 2>&1 || true \(ownershipMarker)" - } - - /// Builds a single shell command that fires every `event` envelope and - /// optionally forwards stdin as a notification, all under one envCheck - /// guard with one sentinel. Stdin is consumed once via `payload=$(cat)` - /// so the same payload can be relayed after the fixed envelopes. The - /// precondition rejects a no-op invocation because the empty-empty - /// fallthrough would otherwise emit `{ ; }` (shell syntax error masked - /// by `|| true`). + /// Composes the OSC 3008 hook command: one guard, then (once that passes) the + /// tty resolve plus a presence emit per event and/or a notify emit, all in a + /// single brace group whose output is suppressed. Guarding first keeps the + /// command truly inert outside Supacode (no `ps` runs when the surface id is + /// unset). The precondition rejects a no-op invocation that would emit nothing. static func compositeCommand( events: [HookEvent], forwardStdinAsNotification: Bool, @@ -68,43 +57,16 @@ nonisolated enum AgentHookSettingsCommand { !events.isEmpty || forwardStdinAsNotification, "compositeCommand needs at least one side-effect (events or stdin forward).", ) - if events.count == 1, !forwardStdinAsNotification { - return managed(envelopePipeline(event: events[0], agent: agent)) - } - if events.isEmpty, forwardStdinAsNotification { - return managed(notifyPipeline(agent: agent, payloadExpr: nil)) - } - var steps: [String] = [] - if forwardStdinAsNotification { steps.append("payload=$(cat)") } - for event in events { - steps.append(envelopePipeline(event: event, agent: agent)) - } - if forwardStdinAsNotification { - steps.append(notifyPipeline(agent: agent, payloadExpr: #""$payload""#)) - } - return managed("{ \(steps.joined(separator: "; ")); }") - } - - private static func envelopePipeline(event: HookEvent, agent: SkillAgent) -> String { - let envelope = - #"{\"event\":\"\#(event.rawValue)\","# - + #"\"v\":1,\"agent\":\"\#(agent.rawValue)\","# - + #"\"surface_id\":\"$SUPACODE_SURFACE_ID\",\"pid\":$PPID}"# - return #"printf '%s' "\#(envelope)" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# + var steps: [String] = [AgentPresenceOSC.ttyResolveSnippet] + steps += events.map { AgentPresenceOSC.emitShell(event: $0, agent: agent) } + if forwardStdinAsNotification { steps.append(AgentPresenceOSC.emitNotifyShell(agent: agent)) } + return "\(oscGuardExpr) && { \(steps.joined(separator: "; ")); } >/dev/null 2>&1 || true \(ownershipMarker)" } - /// `payloadExpr == nil` → forward stdin live via `cat`. Non-nil → relay - /// a previously-stashed shell expression (so the composite path can - /// consume stdin once and reuse it after event envelopes). - private static func notifyPipeline(agent: SkillAgent, payloadExpr: String?) -> String { - let body: String - if let payloadExpr { - body = #"printf '%s' \#(payloadExpr)"# - } else { - body = "cat" - } - return - #"{ printf '%s \#(agent.rawValue)\n' "\#(ids)"; \#(body); }"# - + #" | /usr/bin/nc -U -w1 "$SUPACODE_SOCKET_PATH""# + /// Guard for the OSC command: a surface id present (the no-op-outside-Supacode + /// gate). Fires both locally and over SSH; the pid suffix inside the presence + /// emit is what's gated on the socket path, not the emission itself. + private static var oscGuardExpr: String { + #"[ -n "${\#(AgentPresenceOSC.surfaceEnvVar):-}" ]"# } } diff --git a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift index de55e7328..175635672 100644 --- a/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift +++ b/SupacodeSettingsShared/BusinessLogic/AgentIntegrationFactory.swift @@ -12,8 +12,10 @@ nonisolated enum AgentIntegrationFactory { switch agent { case .claude: claude(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .codex: codex(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + case .copilot: copilot(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .kiro: kiro(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) case .pi: pi(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + case .opencode: opencode(homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) } } @@ -91,6 +93,42 @@ nonisolated enum AgentIntegrationFactory { ) } + private static func opencode(homeDirectoryURL: URL, fileManager: FileManager) -> AgentIntegration { + let installer = OpenCodePluginInstaller( + homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + let skill = CLISkillInstaller() + return AgentIntegration( + agent: .opencode, + components: [ + AgentIntegration.Component( + kind: .unifiedHooks, + state: { installer.installState() }, + install: { try installer.install() }, + uninstall: { try installer.uninstall() } + ), + skillComponent(agent: .opencode, installer: skill), + ] + ) + } + + private static func copilot(homeDirectoryURL: URL, fileManager: FileManager) -> AgentIntegration { + let installer = CopilotHooksInstaller( + homeDirectoryURL: homeDirectoryURL, fileManager: fileManager) + let skill = CLISkillInstaller() + return AgentIntegration( + agent: .copilot, + components: [ + AgentIntegration.Component( + kind: .unifiedHooks, + state: { installer.installState() }, + install: { try installer.install() }, + uninstall: { try installer.uninstall() } + ), + skillComponent(agent: .copilot, installer: skill), + ] + ) + } + private static func skillComponent( agent: SkillAgent, installer: CLISkillInstaller ) -> AgentIntegration.Component { diff --git a/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift new file mode 100644 index 000000000..689ce1984 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/AgentPresenceOSC.swift @@ -0,0 +1,262 @@ +import Foundation + +/// OSC 3008 (UAPI hierarchical context signal) wire format that carries the +/// agent-presence event lifecycle over the terminal stream, so the badge tracks +/// state over SSH where the local Unix socket can't be reached. The sequence is +/// inert in any terminal that doesn't handle OSC 3008 (no toast, no side effect). +/// +/// Emit shape: `OSC 3008 ; = ; event=[ ; pid=] ST`. +/// libghostty splits that into `id = ` (the context id, up to the first +/// `;`) and `metadata = "event=[;pid=]"`, which is what `parse` +/// receives. `parse` derives the event solely from the `event=` field and ignores +/// the start/end action byte. +/// - attribution is by the receiving surface, so no surface id is carried; +/// - `event` is the `HookEvent` rawValue; +/// - `pid` is the agent's LOCAL process id, present only when the hook ran on the +/// same host (gated on `SUPACODE_SOCKET_PATH`); it feeds the app's liveness +/// sweep so a crashed local agent is reaped. Omitted over SSH. +/// +/// The same transport also carries the rich notification leg +/// (`kind=notify;title=;body=`); the emitter extracts the display +/// title/body so the wire stays small and the app carries no agent-specific JSON +/// shape. Presence and notify are disjoint metadata shapes. +/// +/// Signals are unauthenticated: anything that can write to the terminal can emit +/// one, and the worst case is a spurious badge or notification (text is +/// control-char-sanitized and length-capped app-side). Emission is gated on +/// `SUPACODE_SURFACE_ID` so it no-ops outside a Supacode surface. +/// +/// Single source of truth for both the emit side (the agent hook) and the parse +/// side (the app), so the field names can't drift. +public nonisolated enum AgentPresenceOSC { + /// Env var present only on Supacode surfaces, so its presence is the + /// no-op-outside-Supacode emit gate. + public static let surfaceEnvVar = "SUPACODE_SURFACE_ID" + + static let eventField = "event" + static let pidField = "pid" + static let kindField = "kind" + static let titleField = "title" + static let bodyField = "body" + static let notifyKind = "notify" + + /// Notify body source keys, in display precedence. Used by the shell extractor + /// (`emitNotifyShell`); the Pi extension sends its body directly. + public static let notifyBodyKeys = ["message", "last_assistant_message", "assistant_response"] + + /// Emit-side byte caps. Keep the notify metadata under libghostty's 2048-byte + /// OSC buffer, over which the whole sequence is discarded (not truncated). + static let notifyBodyByteBudget = 1000 + static let notifyTitleByteBudget = 160 + + /// A parsed presence signal. + public struct Signal: Equatable, Sendable { + /// Context id, i.e. the agent rawValue. + public let agent: String + /// A known HookEvent rawValue. Parse rejects unknown values; stored as + /// String so wire concerns don't leak into the enum. + public let eventRawValue: String + /// The agent's LOCAL process id. The emit gates it on `SUPACODE_SOCKET_PATH` + /// so a local hook carries it and a remote one omits it; a forged positive + /// pid at worst pins a live-looking badge until surface close. + public let pid: pid_t? + } + + /// Parse the OSC 3008 context id + raw key=value metadata (as surfaced by + /// libghostty) into a `Signal`. Returns nil for anything that isn't a + /// well-formed presence signal with a known event. + public static func parse(id: String, metadata: String) -> Signal? { + guard !id.isEmpty else { return nil } + guard let fields = parseFields(metadata) else { return nil } + guard + let rawEvent = fields[Substring(eventField)], + HookEvent(rawValue: String(rawEvent)) != nil + else { return nil } + return Signal( + agent: id, + eventRawValue: String(rawEvent), + pid: parsePid(fields[Substring(pidField)]), + ) + } + + /// Parse the optional `pid=` field. Rejects non-numeric and non-positive + /// values: a 0 / negative pid would let `kill(_:0)` match the caller's process + /// group and pin a permanent badge in the liveness sweep. + private static func parsePid(_ raw: Substring?) -> pid_t? { + guard let raw, let value = pid_t(raw), value > 0 else { return nil } + return value + } + + /// True when the metadata carries `kind=notify`. Cheap routing check (presence + /// vs notify) that inspects the `kind` field, not a raw substring, so a base64 + /// `body` value that happens to contain "kind=notify" can't misroute. + public static func isNotifyMetadata(_ metadata: String) -> Bool { + parseFields(metadata)?[Substring(kindField)] == Substring(notifyKind) + } + + /// Split the OSC 3008 raw metadata into its `key=value` fields. Standard base64 + /// values are framing-safe here: their alphabet (A-Za-z0-9+/=) has no `;`, and + /// the value keeps everything after the FIRST `=` (`firstIndex(of:)`), so base64 + /// `=` padding survives intact. + /// + /// Duplicate `event` / `kind` keys are rejected: a repeated key would otherwise + /// pin perceived state to the last occurrence, which a splice into the wire + /// could exploit to flip `event=` or inject `kind=notify`. All other duplicate + /// keys keep the historical last-write-wins behavior. + public static func parseFields(_ metadata: String) -> [Substring: Substring]? { + var fields: [Substring: Substring] = [:] + for pair in metadata.split(separator: ";", omittingEmptySubsequences: true) { + guard let equalsIndex = pair.firstIndex(of: "=") else { continue } + let key = pair[.. = [ + Substring(eventField), Substring(kindField), + ] + + /// A parsed notification signal with already-decoded display text. + public struct NotifySignal: Equatable, Sendable { + public let agent: String + /// Both nil-on-empty; the caller falls back to the agent name for a missing + /// title and shows a title-only toast for a missing body. + public let title: String? + public let body: String? + /// Raw base64 byte count of the body field on the wire, before decode. A + /// non-zero count alongside a nil `body` means a truncation the shed loop + /// couldn't recover, so the caller can log the silent-failure case. + public let wireBodyByteCount: Int + } + + /// Parse `kind=notify;title=;body=`. Requires the notify kind; + /// title/body are optional. + public static func parseNotify(id: String, metadata: String) -> NotifySignal? { + guard !id.isEmpty else { return nil } + guard let fields = parseFields(metadata) else { return nil } + guard fields[Substring(kindField)] == Substring(notifyKind) else { return nil } + return NotifySignal( + agent: id, + title: decodedNotifyField(fields[Substring(titleField)]), + body: decodedNotifyField(fields[Substring(bodyField)]), + wireBodyByteCount: fields[Substring(bodyField)]?.utf8.count ?? 0, + ) + } + + private static func decodedNotifyField(_ raw: Substring?) -> String? { + guard let raw, let text = decodeNotifyValue(String(raw)), !text.isEmpty else { return nil } + return text + } + + /// Reverse one base64 notify field back to display text. A field byte-capped at + /// emit can end mid-escape or mid-UTF8, so trailing bytes are shed until the + /// quoted value parses as a JSON string (`JSONDecoder` rejects both an invalid + /// UTF-8 tail and a dangling escape). nil only on non-base64 input; an + /// undecodable-but-base64 field collapses to "" (treated as absent, not a + /// parse failure). + static func decodeNotifyValue(_ base64: String) -> String? { + guard var data = Data(base64Encoded: base64) else { return nil } + let quote = Data([0x22]) + let decoder = JSONDecoder() + // 12 = full `\uXXXX\uXXXX` surrogate-pair length; covers any mid-pair cut (worst dangling tail is 11 bytes). + for _ in 0...min(12, data.count) { + if let text = try? decoder.decode(String.self, from: quote + data + quote) { + return text + } + if data.isEmpty { break } + data.removeLast() + } + return "" + } + + /// The OSC 3008 action for an event: session_end ends a context, everything + /// else starts / updates one. The app keys off `event=` in the metadata, not + /// this action, so it is descriptive rather than load-bearing. + static func action(for event: HookEvent) -> String { + event == .sessionEnd ? "end" : "start" + } + + /// The `key=value` metadata a PRESENCE signal carries (everything after the + /// context id). `parse` recovers the event from this exact shape. `pidSuffix` + /// is appended verbatim (e.g. `;pid=123`) so the emit can splice in a + /// shell-built, conditionally-empty suffix. See `notifyMetadata` for the + /// notify counterpart. + static func metadata(event: HookEvent, pidSuffix: String = "") -> String { + "\(eventField)=\(event.rawValue)\(pidSuffix)" + } + + /// Shell that resolves `$__tty` to a writable terminal device for the OSC emits. + /// Agents run hooks without a controlling terminal (`/dev/tty` open fails), so + /// the hook recovers the terminal its parent agent is attached to via + /// `ps -o tty=`. `ps` reports a bare name (`ttys039` on macOS, `pts/5` on + /// Linux), so a `/dev/` prefix is added; a parent with no tty (`??`) falls back + /// to `/dev/tty` for the rare context that does have a controlling terminal. + static let ttyResolveSnippet = + #"__tty=$(ps -o tty= -p "$PPID" 2>/dev/null | tr -d '[:space:]'); "# + + #"case "$__tty" in *[0-9]*) __tty="/dev/${__tty#/dev/}";; *) __tty="/dev/tty";; esac"# + + /// Shell `printf` that emits the OSC 3008 presence sequence for `event`. Written + /// to the `$__tty` device resolved by `ttyResolveSnippet` so it reaches the + /// terminal even though the hook has no controlling terminal and captured + /// stdout. The caller guards emission on `SUPACODE_SURFACE_ID` and runs + /// `ttyResolveSnippet` first. + /// + /// The pid suffix is gated on `SUPACODE_SOCKET_PATH` (set only on the local + /// host) so a local hook carries `$PPID` and a remote one omits it; a forged + /// positive pid at worst pins a live-looking badge until surface close. The + /// suffix is built in shell and filled into a trailing `%s`, empty when remote. + static func emitShell(event: HookEvent, agent: SkillAgent) -> String { + // Trailing %s for the shell-built, conditionally-empty pid suffix. + let meta = metadata(event: event, pidSuffix: "%s") + let payload = #"\033]3008;\#(action(for: event))=\#(agent.rawValue);\#(meta)\033\\"# + return #"__sp=""; [ -n "${SUPACODE_SOCKET_PATH:-}" ] && __sp=";\#(pidField)=$PPID"; "# + + #"printf '\#(payload)' "$__sp" > "$__tty""# + } + + /// The `key=value` metadata a notify signal carries; `title` / `body` are base64. + static func notifyMetadata(title: String, body: String) -> String { + "\(kindField)=\(notifyKind);\(titleField)=\(title);\(bodyField)=\(body)" + } + + /// Portable awk that extracts one JSON string value from the agent's hook JSON + /// on stdin. `keys` is a comma-separated precedence list (first non-empty wins); + /// the raw escaped value is copied verbatim up to the first unescaped `"` and + /// capped to `budget` (a mid-escape cut is tolerated by `decodeNotifyValue`). + /// Matches the first `"key":` occurrence, assuming a flat top-level payload. + /// No `RS`/`\x` tricks and no single quote, so it is portable and shell-safe. + /// The caller runs it under `LC_ALL=C` so `length`/`substr` are byte-based + /// (gawk in a UTF-8 locale would otherwise count characters and overshoot 2048). + /// Best-effort by design: a body nested inside an object (e.g. the key appears + /// in an inner object before the top-level one) is not extracted correctly. The + /// agents we target emit flat payloads; the structured Pi extension path is the + /// canonical one when a nested shape is needed. + static let notifyExtractAwk = + #"function ws(c){return c==" "||c=="\t"||c=="\n"||c=="\r"}"# + + #"function fv(s,key, p,i,n,c,o,e){p="\""key"\"";i=index(s,p);if(i==0)return "";"# + + #"i+=length(p);n=length(s);while(i<=n){if(ws(substr(s,i,1)))i++;else break}"# + + #"if(substr(s,i,1)!=":")return "";i++;while(i<=n){if(ws(substr(s,i,1)))i++;else break}"# + + #"if(substr(s,i,1)!="\"")return "";i++;o="";e=0;while(i<=n){c=substr(s,i,1);"# + + #"if(e){o=o c;e=0;i++;continue}if(c=="\\"){o=o c;e=1;i++;continue}if(c=="\"")break;o=o c;i++}return o}"# + + #"{d=d $0}END{n=split(keys,ks,",");v="";for(j=1;j<=n;j++){v=fv(d,ks[j]);if(v!="")break}"# + + #"if(length(v)>budget+0)v=substr(v,1,budget+0);printf "%s",v}"# + + /// Reads the hook JSON from stdin once, extracts a bounded title/body via a + /// portable `awk` pass (no `jq`/`python`, so it works over SSH), base64s each, + /// and emits the OSC 3008 notify. Sending only the display fields keeps the wire + /// under libghostty's 2048-byte OSC ceiling. Locked to STANDARD base64. + /// `readsStdin: false` skips the `__in=$(cat)` capture when the caller already set `$__in`. + static func emitNotifyShell(agent: SkillAgent, readsStdin: Bool = true) -> String { + let payload = #"\033]3008;start=\#(agent.rawValue);\#(notifyMetadata(title: "%s", body: "%s"))\033\\"# + let bodyKeys = notifyBodyKeys.joined(separator: ",") + return (readsStdin ? #"__in=$(cat); "# : "") + + #"__t=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(titleField)" "# + + #"-v budget=\#(notifyTitleByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# + + #"__b=$(printf '%s' "$__in" | LC_ALL=C awk -v keys="\#(bodyKeys)" "# + + #"-v budget=\#(notifyBodyByteBudget) '\#(notifyExtractAwk)' | base64 | tr -d '\n'); "# + + #"printf '\#(payload)' "$__t" "$__b" > "$__tty""# + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift b/SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift index 1a54580d9..e7e89090b 100644 --- a/SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift +++ b/SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift @@ -365,4 +365,66 @@ nonisolated enum CLISkillContent { Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). Env var defaults only target your own shell session. Pass explicit IDs for created resources. """ + // MARK: - OpenCode. + + // OpenCode uses SKILL.md with YAML frontmatter (same structure as Kiro). + static let opencodeSkillMd = """ + --- + name: \(skillName) + description: \(description) + --- + + # Supacode CLI + + Control Supacode from the terminal. The `supacode` command is available in all Supacode terminal sessions. + + ## CRITICAL: ID Tracking + + **NEVER call `supacode tab new` or `supacode surface split` without capturing + the output.** They print the new UUID to stdout. Without it you cannot target + the resource afterward. + + **NEVER omit `-t`/`-s` when targeting a created resource.** The env vars point + to your own shell, not to anything you created. + + For new tabs, surface ID = tab ID. + + ### Correct: + + ```sh + TAB_ID=$(supacode tab new -i "npm start") + SPLIT_ID=$(supacode surface split -t "$TAB_ID" -s "$TAB_ID" -d v -i "npm test") + supacode surface close -t "$TAB_ID" -s "$SPLIT_ID" + supacode tab close -t "$TAB_ID" + ``` + + ### WRONG: + + ```sh + supacode tab new -i "npm start" # BAD: not captured + supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell + ``` + + ## Commands + + - `supacode worktree [list [-f]|focus|run [-c]|stop [-c]|script list|archive|unarchive|delete|pin|unpin] [-w ]` + - `supacode tab [list [-w] [-f]|focus|new|close] [-w ] [-t ] [-i ] [-n ]` + - `supacode surface [list [-w] [-t] [-f]|focus|split|close] [-w ] [-t ] [-s ] [-i ] [-d h|v] [-n ]` + - `supacode repo [list | open | worktree-new [-r ] [--branch] [--base] [--fetch] [--name] [--location]]` + - `supacode settings [
]` + - `supacode socket` + + `list` outputs one ID per line (percent-encoded for worktrees/repos, UUIDs for tabs/surfaces). + `worktree script list` outputs tab-separated `\\t\\t` rows; running scripts are ANSI-underlined. + Use these IDs directly as `-w`, `-t`, `-s`, `-r`, `-c` flag values. + + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). + Env var defaults only target your own shell session. Pass explicit IDs for created resources. + """ + + // MARK: - Copilot. + + // Generic CLI doc, same as OpenCode's (content is agent-agnostic). + static let copilotSkillMd = opencodeSkillMd + } diff --git a/SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift b/SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift index 6d81a5f70..eb128b863 100644 --- a/SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift @@ -48,8 +48,10 @@ nonisolated struct CLISkillInstaller { switch agent { case .claude: CLISkillContent.claudeSkill case .codex: CLISkillContent.codexSkillMd + case .copilot: CLISkillContent.copilotSkillMd case .kiro: CLISkillContent.kiroSkillMd case .pi: CLISkillContent.piSkillMd + case .opencode: CLISkillContent.opencodeSkillMd } } } diff --git a/SupacodeSettingsShared/BusinessLogic/CopilotHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/CopilotHookSettings.swift new file mode 100644 index 000000000..74fdd92c5 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/CopilotHookSettings.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Builds `~/.copilot/hooks/supacode.json`. Copilot auto-loads every JSON file +/// there, so Supacode owns its own file (like Pi/OpenCode) and emits the shared +/// OSC 3008 presence signals from per-event `bash` hooks. +nonisolated enum CopilotHookSettings { + static let fileName = "supacode.json" + + /// Sentinel marking the file as Supacode-managed; install/uninstall key off it. + static let ownershipMarker = AgentHookSettingsCommand.ownershipMarker + + /// Deterministic, so `installState` can detect drift by a byte-for-byte compare. + /// Throws rather than returning a sentinel: an empty string would be written as a + /// valid-looking but marker-less file the installer could no longer recognize. + static func source() throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data = try encoder.encode(Payload()) + guard let string = String(data: data, encoding: .utf8) else { + throw CopilotHooksInstallerError.encodingFailed + } + return string + } + + private static func command(for events: [HookEvent], notify: Bool = false) -> String { + AgentHookSettingsCommand.compositeCommand( + events: events, forwardStdinAsNotification: notify, agent: .copilot) + } + + /// Branches on the payload (so it hand-composes the OSC pieces rather than using + /// `compositeCommand`): permission / elicitation prompts flip to awaitingInput + + /// alert; other notification types no-op since `agentStop` owns the done-alert. + private static var notificationCommand: String { + let surfaceGuard = #"[ -n "${\#(AgentPresenceOSC.surfaceEnvVar):-}" ]"# + let needsYou = + #"\#(AgentPresenceOSC.ttyResolveSnippet); "# + + AgentPresenceOSC.emitShell(event: .awaitingInput, agent: .copilot) + "; " + + AgentPresenceOSC.emitNotifyShell(agent: .copilot, readsStdin: false) + let steps = + #"__in=$(cat); case "$__in" in *permission_prompt*|*elicitation_dialog*) \#(needsYou) ;; esac"# + return #"\#(surfaceGuard) && { \#(steps); } >/dev/null 2>&1 || true \#(ownershipMarker)"# + } + + private struct Payload: Encodable { + let version = 1 + let hooks: [String: [Hook]] = [ + "sessionStart": [Hook(bash: CopilotHookSettings.command(for: [.sessionStart]), timeoutSec: 5)], + "userPromptSubmitted": [Hook(bash: CopilotHookSettings.command(for: [.busy]), timeoutSec: 10)], + "preToolUse": [Hook(bash: CopilotHookSettings.command(for: [.busy]), timeoutSec: 5)], + "postToolUse": [Hook(bash: CopilotHookSettings.command(for: [.busy]), timeoutSec: 5)], + "agentStop": [Hook(bash: CopilotHookSettings.command(for: [.idle], notify: true), timeoutSec: 10)], + "sessionEnd": [Hook(bash: CopilotHookSettings.command(for: [.sessionEnd]), timeoutSec: 5)], + "notification": [Hook(bash: CopilotHookSettings.notificationCommand, timeoutSec: 10)], + ] + } + + private struct Hook: Encodable { + let type = "command" + let bash: String + let timeoutSec: Int + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/CopilotHooksInstaller.swift b/SupacodeSettingsShared/BusinessLogic/CopilotHooksInstaller.swift new file mode 100644 index 000000000..c06ab6c84 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/CopilotHooksInstaller.swift @@ -0,0 +1,81 @@ +import Foundation + +private nonisolated let copilotInstallerLogger = SupaLogger("Settings") + +/// Writes / removes Supacode's own `~/.copilot/hooks/supacode.json`. The hooks +/// dir is shared with the user's files, so only `supacode.json` is ever touched. +nonisolated struct CopilotHooksInstaller { + let homeDirectoryURL: URL + let fileManager: FileManager + + init( + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) { + self.homeDirectoryURL = homeDirectoryURL + self.fileManager = fileManager + } + + /// Marker present but content differs → `.outdated`; no marker → `.notInstalled` + /// (so auto-update never overwrites a user file that shares the name). + func installState() -> ComponentInstallState { + guard let contents = try? String(contentsOf: hookFileURL, encoding: .utf8) else { + return .notInstalled + } + if let source = try? CopilotHookSettings.source(), contents == source { return .installed } + return contents.contains(CopilotHookSettings.ownershipMarker) ? .outdated : .notInstalled + } + + func install() throws { + let path = hookFileURL.path(percentEncoded: false) + if fileManager.fileExists(atPath: path) { + let contents = try String(contentsOf: hookFileURL, encoding: .utf8) + guard contents.contains(CopilotHookSettings.ownershipMarker) else { + throw CopilotHooksInstallerError.fileNotManaged + } + } + try fileManager.createDirectory(at: hooksDirectoryURL, withIntermediateDirectories: true) + try CopilotHookSettings.source().write(to: hookFileURL, atomically: true, encoding: .utf8) + copilotInstallerLogger.info("Installed Copilot hooks at \(path)") + } + + func uninstall() throws { + let path = hookFileURL.path(percentEncoded: false) + guard fileManager.fileExists(atPath: path) else { return } + // Never remove a user file that merely shares the name. + let contents = try String(contentsOf: hookFileURL, encoding: .utf8) + guard contents.contains(CopilotHookSettings.ownershipMarker) else { + throw CopilotHooksInstallerError.fileNotManaged + } + try fileManager.removeItem(at: hookFileURL) + copilotInstallerLogger.info("Uninstalled Copilot hooks from \(path)") + } + + var hookFileURL: URL { + hooksDirectoryURL.appending(path: CopilotHookSettings.fileName, directoryHint: .notDirectory) + } + + private var hooksDirectoryURL: URL { + Self.hooksDirectoryURL(homeDirectoryURL: homeDirectoryURL) + } + + static func hooksDirectoryURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appending(path: ".copilot", directoryHint: .isDirectory) + .appending(path: "hooks", directoryHint: .isDirectory) + } +} + +nonisolated enum CopilotHooksInstallerError: Error, Equatable, LocalizedError { + case fileNotManaged + case encodingFailed + + var errorDescription: String? { + switch self { + case .fileNotManaged: + "The Copilot hook file at ~/.copilot/hooks/supacode.json is not managed by Supacode." + case .encodingFailed: + "Failed to encode the Copilot hook payload." + } + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift index de5ff8d17..7b97456b0 100644 --- a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift @@ -14,9 +14,9 @@ nonisolated struct KiroSettingsInstaller { /// defaults in `ensureDefaultAgentConfig` may no longer match upstream and would /// silently override a legitimately different config — gate on this prefix to /// fail loudly instead. - static let supportedVersionPrefix = "1." + static let supportedVersionPrefix = "2." - /// Maximum time to wait on `kiro --version`. A misconfigured login shell (e.g. + /// Maximum time to wait on `kiro-cli --version`. A misconfigured login shell (e.g. /// an rc file blocking on stdin) can hang the child indefinitely; when that /// happens we terminate the process so `waitUntilExit` cannot pin the /// cooperative pool thread. @@ -28,19 +28,19 @@ nonisolated struct KiroSettingsInstaller { init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, - fileManager: FileManager = .default + fileManager: FileManager = .default, ) { self.init( homeDirectoryURL: homeDirectoryURL, fileManager: fileManager, - runKiroVersionCommand: Self.runKiroVersionCommand + runKiroVersionCommand: Self.runKiroVersionCommand, ) } init( homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, fileManager: FileManager = .default, - runKiroVersionCommand: @escaping @Sendable () async throws -> CommandResult + runKiroVersionCommand: @escaping @Sendable () async throws -> CommandResult, ) { self.homeDirectoryURL = homeDirectoryURL self.fileManager = fileManager @@ -68,14 +68,14 @@ nonisolated struct KiroSettingsInstaller { try await ensureDefaultAgentConfig() try fileInstaller.install( settingsURL: settingsURL, - hookEntriesByEvent: try KiroHookSettings.hooksByEvent() + hookEntriesByEvent: try KiroHookSettings.hooksByEvent(), ) } func uninstallAllHooks() throws { try fileInstaller.uninstall( settingsURL: settingsURL, - hookEntriesByEvent: try KiroHookSettings.hooksByEvent() + hookEntriesByEvent: try KiroHookSettings.hooksByEvent(), ) } @@ -90,7 +90,7 @@ nonisolated struct KiroSettingsInstaller { try await validateSupportedKiroVersion() try fileManager.createDirectory( at: settingsURL.deletingLastPathComponent(), - withIntermediateDirectories: true + withIntermediateDirectories: true, ) let defaultConfig: [String: JSONValue] = [ "name": .string("kiro_default"), @@ -186,7 +186,7 @@ nonisolated struct KiroSettingsInstaller { static func runKiroVersionCommand() async throws -> CommandResult { let process = Process() process.executableURL = CodexSettingsInstaller.loginShellURL() - process.arguments = ["-l", "-c", "kiro --version"] + process.arguments = ["-l", "-c", "kiro-cli --version"] let outputPipe = Pipe() let errorPipe = Pipe() process.standardOutput = outputPipe @@ -197,7 +197,7 @@ nonisolated struct KiroSettingsInstaller { try? await Task.sleep(nanoseconds: versionCommandTimeoutSeconds * 1_000_000_000) if process.isRunning { kiroVersionLogger.warning( - "kiro --version exceeded \(versionCommandTimeoutSeconds)s; terminating.") + "kiro-cli --version exceeded \(versionCommandTimeoutSeconds)s; terminating.") process.terminate() } } @@ -217,7 +217,7 @@ nonisolated struct KiroSettingsInstaller { return .init( status: process.terminationStatus, standardOutput: standardOutput, - standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines) + standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines), ) } @@ -257,8 +257,8 @@ nonisolated struct KiroSettingsInstaller { invalidEventHooks: { KiroSettingsInstallerError.invalidEventHooks($0) }, invalidHooksObject: { KiroSettingsInstallerError.invalidHooksObject }, invalidJSON: { KiroSettingsInstallerError.invalidJSON($0) }, - invalidRootObject: { KiroSettingsInstallerError.invalidRootObject } - ) + invalidRootObject: { KiroSettingsInstallerError.invalidRootObject }, + ), ) } } diff --git a/SupacodeSettingsShared/BusinessLogic/OpenCodePluginContent.swift b/SupacodeSettingsShared/BusinessLogic/OpenCodePluginContent.swift new file mode 100644 index 000000000..66e78cf66 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/OpenCodePluginContent.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Source for the OpenCode plugin that bridges OpenCode's plugin events to +/// Supacode's OSC 3008 agent-presence protocol. +/// +/// OpenCode does NOT consume a Claude-style declarative `hooks` block in its +/// config (`opencode.json`). Its extensibility surface is JS/TS plugins +/// auto-loaded from `~/.config/opencode/plugins/`. So instead of writing a +/// settings file, Supacode installs a small plugin that runs the exact same +/// guarded shell command every other agent's hooks run — keeping the OSC +/// template, surface guard, tty resolve, and pid-liveness suffix single-sourced +/// in `AgentHookSettingsCommand`. +nonisolated enum OpenCodePluginContent { + /// File name written under `~/.config/opencode/plugins/`. + static let pluginFileName = "supacode-presence.js" + + /// Marker that identifies a Supacode-managed plugin file. The composite + /// command embeds `AgentHookSettingsCommand.ownershipMarker` verbatim, so the + /// generated source always contains it — uninstall keys off this so it never + /// deletes a user file that merely shares the name. + static let ownershipMarker = AgentHookSettingsCommand.ownershipMarker + + /// Deterministic plugin source (no timestamps), so `installState` can detect + /// a byte-for-byte match vs. an older Supacode version's plugin (`.outdated`). + static func source() -> String { + """ + // \(ownershipMarker) + // + // Generated by Supacode — do not edit. Bridges OpenCode plugin events to + // Supacode's OSC 3008 agent-presence protocol by running the same guarded + // shell command Supacode installs for every other agent. The command checks + // SUPACODE_SURFACE_ID first, so it is inert outside a Supacode surface and + // safe to keep installed anywhere. + // + // Presence only: OpenCode's session.idle event carries no assistant text + // (unlike Claude Code's Stop hook stdin), so the system-notification leg is + // intentionally omitted rather than fed a fabricated body. + export const SupacodePresence = async ({ $ }) => { + const emit = (command) => $`sh -c ${command}`.quiet().nothrow() + + // Agent is now present in this surface. + await emit(\(jsString(command(for: [.sessionStart])))) + + return { + // Agent (and its surface presence) is going away. + dispose: async () => { + await emit(\(jsString(command(for: [.sessionEnd, .idle])))) + }, + // Tool-level activity shimmer: a tool started / finished. + "tool.execute.before": async () => { + await emit(\(jsString(command(for: [.busy])))) + }, + "tool.execute.after": async () => { + await emit(\(jsString(command(for: [.idle])))) + }, + // A permission prompt is the "needs you" state. + "permission.ask": async () => { + await emit(\(jsString(command(for: [.awaitingInput])))) + }, + event: async ({ event }) => { + if (event.type === "session.idle") { + await emit(\(jsString(command(for: [.idle])))) + } else if (event.type === "permission.replied") { + await emit(\(jsString(command(for: [.busy])))) + } + }, + } + } + """ + } + + private static func command(for events: [HookEvent]) -> String { + AgentHookSettingsCommand.compositeCommand( + events: events, forwardStdinAsNotification: false, agent: .opencode) + } + + /// JSON-encoding a `String` yields a valid double-quoted JavaScript string + /// literal: it escapes `"`, `\`, and control characters, and the composite + /// command contains no newlines. `$` and backticks are literal inside a + /// double-quoted JS string, so no extra escaping is needed. + private static func jsString(_ value: String) -> String { + guard + let data = try? JSONEncoder().encode(value), + let literal = String(data: data, encoding: .utf8) + else { + return "\"\"" + } + return literal + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/OpenCodePluginInstaller.swift b/SupacodeSettingsShared/BusinessLogic/OpenCodePluginInstaller.swift new file mode 100644 index 000000000..a02cdeb75 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/OpenCodePluginInstaller.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Installs (and removes) the Supacode presence plugin for OpenCode. +/// +/// Unlike the Claude/Kiro JSON-merge installers, OpenCode loads plugins as +/// files from `~/.config/opencode/plugins/`, so this mirrors `CLISkillInstaller` +/// (write / remove a single owned file) rather than `ClaudeSettingsInstaller` +/// (prune-append into a shared JSON object). There is no foreign config to +/// preserve and no schema to satisfy. +nonisolated struct OpenCodePluginInstaller { + let homeDirectoryURL: URL + let fileManager: FileManager + + init( + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) { + self.homeDirectoryURL = homeDirectoryURL + self.fileManager = fileManager + } + + /// `.installed` only on a byte-for-byte match, so an older Supacode version's + /// plugin reports `.outdated` and the next install upgrades it in place. A + /// file Supacode does NOT own (no ownership marker) reports `.notInstalled`, + /// not `.outdated`, so auto-update never silently overwrites a user plugin + /// that happens to share the name — symmetric with `uninstall`. + func installState() -> ComponentInstallState { + guard let contents = try? String(contentsOf: pluginFileURL, encoding: .utf8) else { + return .notInstalled + } + if contents == OpenCodePluginContent.source() { return .installed } + return contents.contains(OpenCodePluginContent.ownershipMarker) ? .outdated : .notInstalled + } + + func install() throws { + try fileManager.createDirectory(at: pluginDirectoryURL, withIntermediateDirectories: true) + try OpenCodePluginContent.source().write(to: pluginFileURL, atomically: true, encoding: .utf8) + } + + func uninstall() throws { + // Only remove a file Supacode owns — never clobber a user plugin that + // happens to share the name. + guard let contents = try? String(contentsOf: pluginFileURL, encoding: .utf8), + contents.contains(OpenCodePluginContent.ownershipMarker) + else { + return + } + try fileManager.removeItem(at: pluginFileURL) + } + + var pluginFileURL: URL { + pluginDirectoryURL.appendingPathComponent(OpenCodePluginContent.pluginFileName, isDirectory: false) + } + + private var pluginDirectoryURL: URL { + Self.pluginDirectoryURL(homeDirectoryURL: homeDirectoryURL) + } + + static func pluginDirectoryURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appendingPathComponent(".config", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + .appendingPathComponent("plugins", isDirectory: true) + } +} diff --git a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift index 690e3c1ae..b68ddf578 100644 --- a/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift +++ b/SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift @@ -11,111 +11,123 @@ nonisolated enum PiExtensionContent { static let indexTs = """ \(ownershipMarker) /** - * Supacode ↔ Pi integration extension. + * Supacode + Pi integration extension. * - * Reports agent lifecycle hooks back to Supacode via the Unix domain socket - * it injects into every managed terminal session, matching the semantics of - * the existing Claude and Codex hook integrations. + * Reports agent lifecycle and notifications to Supacode by emitting OSC 3008 + * escape sequences to the controlling terminal. The sequences are inert in any + * terminal that does not handle OSC 3008, and reach Supacode over SSH too (no + * local socket needed), matching the Claude / Codex / Kiro hook integrations. * - * Required env vars (injected automatically by Supacode): - * SUPACODE_SOCKET_PATH — path to the Unix domain socket - * SUPACODE_WORKTREE_ID — worktree identifier - * SUPACODE_TAB_ID — tab UUID - * SUPACODE_SURFACE_ID — terminal surface UUID + * Required env var (injected automatically by Supacode on every surface): + * SUPACODE_SURFACE_ID present only on a Supacode surface; absence is the + * no-op gate. Signals are unauthenticated. + * Optional: + * SUPACODE_SOCKET_PATH present only on the local host; gates the local pid + * so the app's liveness sweep can reap a crashed agent. * * Hook event mapping: - * extension load → session_start (agent presence badge) - * Pi agent_start → busy (UserPromptSubmit equivalent) - * Pi agent_end → idle (Stop equivalent — resets activity) - * → notification with last_assistant_message - * Pi session_shutdown → session_end (SessionEnd equivalent) - * → idle (defensive activity reset) + * extension load -> session_start (agent presence badge) + * Pi agent_start -> busy + * Pi agent_end -> idle + notification with last_assistant_message + * Pi session_shutdown -> session_end + idle (defensive activity reset) */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; - import { createConnection } from "node:net"; + import { openSync, writeSync, closeSync } from "node:fs"; - interface SupacodeEnv { - socketPath: string; - worktreeId: string; - tabId: string; - surfaceId: string; - } - - interface HookPayload { - hook_event_name: string; + interface NotifyContent { title?: string; - message?: string; - last_assistant_message?: string; + body?: string; } - function readSupacodeEnv(): SupacodeEnv | null { - const socketPath = process.env["SUPACODE_SOCKET_PATH"]; - const worktreeId = process.env["SUPACODE_WORKTREE_ID"]; - const tabId = process.env["SUPACODE_TAB_ID"]; - const surfaceId = process.env["SUPACODE_SURFACE_ID"]; + const AGENT = "pi"; - if (!socketPath || !worktreeId || !tabId || !surfaceId) return null; - return { socketPath, worktreeId, tabId, surfaceId }; + let lastWarnedAt = 0; + const WARN_INTERVAL_MS = 60_000; + + function isSupacodeSurface(): boolean { + const id = process.env["SUPACODE_SURFACE_ID"]; + return !!id && id.length > 0; + } + + /** + * The agent's local process id as an OSC pid suffix, but only on the local + * host (SUPACODE_SOCKET_PATH is set). A remote pid over SSH would be + * meaningless to the app's liveness sweep, so it is omitted there. + */ + function localPidSuffix(): string { + return process.env["SUPACODE_SOCKET_PATH"] ? `;pid=${process.pid}` : ""; } /** - * Sends raw bytes to a Unix domain socket and closes the connection. - * Times out after 1 s (Pi serializes hook callbacks, so a stalled - * delivery would stall the agent) and swallows all errors — - * hook delivery is best-effort. + * Writes an OSC sequence to the controlling terminal. The extension runs + * inside the Pi TUI process, which owns the terminal, so /dev/tty resolves. + * Best-effort, but a systematically-failing tty is logged at most once per + * `WARN_INTERVAL_MS` to stderr so a broken write path is distinguishable + * from "not a Supacode surface" without spamming the log on every emit. */ - function sendToSocket(socketPath: string, data: Buffer): Promise { - return new Promise((resolve) => { - let settled = false; - const done = () => { - if (!settled) { - settled = true; - resolve(); + function writeToTerminal(sequence: string): void { + try { + const fd = openSync("/dev/tty", "w"); + try { + // Loop until the full byte length lands: a short write would leave a + // half OSC 3008 with no ST (ESC\\) and corrupt the terminal parser. + const bytes = Buffer.from(sequence, "utf8"); + let offset = 0; + while (offset < bytes.length) { + try { + const written = writeSync(fd, bytes, offset, bytes.length - offset); + if (written <= 0) { + throw new Error(`short write (${offset}/${bytes.length} bytes)`); + } + offset += written; + } catch (writeErr) { + // Retry interrupted / non-blocking transient errors; abort on anything else. + const code = (writeErr as NodeJS.ErrnoException).code; + if (code === "EINTR" || code === "EAGAIN") continue; + throw writeErr; + } } - }; - - const client = createConnection({ path: socketPath }); - const timer = setTimeout(() => { - client.destroy(); - done(); - }, 1000); - - client.on("connect", () => { - client.write(data, () => { - client.end(); - clearTimeout(timer); - done(); - }); - }); - - client.on("error", () => { - clearTimeout(timer); - done(); - }); - }); + } finally { + closeSync(fd); + } + } catch (err) { + const now = Date.now(); + if (now - lastWarnedAt > WARN_INTERVAL_MS) { + lastWarnedAt = now; + const e = err as NodeJS.ErrnoException; + const code = e.code ?? ""; + const errno = e.errno ?? ""; + const message = e.message ?? String(err); + process.stderr.write( + `supacode: OSC emit failed: code=${code} errno=${errno} message=${message}\\n`, + ); + } + } } - function sendNotification(env: SupacodeEnv, payload: HookPayload): Promise { - const header = `${env.worktreeId} ${env.tabId} ${env.surfaceId} pi\\n`; - const body = JSON.stringify(payload) + "\\n"; - return sendToSocket(env.socketPath, Buffer.from(header + body, "utf8")); + function emitPresence(event: string): void { + const action = event === "session_end" ? "end" : "start"; + const meta = `event=${event}${localPidSuffix()}`; + writeToTerminal(`\\x1b]3008;${action}=${AGENT};${meta}\\x1b\\\\`); } - /** - * Sends a hook event (session lifecycle or per-turn activity) using - * the same JSON envelope every other agent emits. - */ - function sendEvent(env: SupacodeEnv, eventName: string): Promise { - const envelope = { - event: eventName, - v: 1, - agent: "pi", - surface_id: env.surfaceId, - pid: process.pid, - }; - const body = JSON.stringify(envelope) + "\\n"; - return sendToSocket(env.socketPath, Buffer.from(body, "utf8")); + // JSON-escape (minus the surrounding quotes) so the wire matches the shell + // awk path, byte-cap to the same budget, then base64. App-side + // decodeNotifyValue reverses both and tolerates a mid-escape cut. + function notifyField(value: string, budget: number): string { + const escaped = JSON.stringify(value).slice(1, -1); + const buf = Buffer.from(escaped, "utf8"); + const capped = buf.length > budget ? buf.subarray(0, budget) : buf; + return capped.toString("base64"); + } + + function emitNotification(content: NotifyContent): void { + const meta = + `kind=notify` + + `;title=${notifyField(content.title ?? "", \(AgentPresenceOSC.notifyTitleByteBudget))}` + + `;body=${notifyField(content.body ?? "", \(AgentPresenceOSC.notifyBodyByteBudget))}`; + writeToTerminal(`\\x1b]3008;start=${AGENT};${meta}\\x1b\\\\`); } function lastAssistantText(ctx: { sessionManager: { getEntries(): any[] } }): string | undefined { @@ -140,34 +152,27 @@ nonisolated enum PiExtensionContent { } export default function (pi: ExtensionAPI) { - const env = readSupacodeEnv(); - - // Not running under Supacode — skip lifecycle hooks. - if (!env) return; + // Not running under Supacode, or not a Supacode surface: stay inert. + if (!isSupacodeSurface()) return; // Extension load = agent process running. Pi has no equivalent of // Claude's SessionStart hook, so we fire it ourselves. - void sendEvent(env, "session_start"); + emitPresence("session_start"); - pi.on("agent_start", async (_event, _ctx) => { - await sendEvent(env, "busy"); + pi.on("agent_start", (_event, _ctx) => { + emitPresence("busy"); }); - pi.on("agent_end", async (_event, ctx) => { + pi.on("agent_end", (_event, ctx) => { // Atomic state-set: `idle` overwrites whatever was running on the - // Supacode side — turn-level Stop equivalent. - await sendEvent(env, "idle"); - - const lastMessage = lastAssistantText(ctx); - await sendNotification(env, { - hook_event_name: "Stop", - last_assistant_message: lastMessage, - }); + // Supacode side (turn-level Stop equivalent). + emitPresence("idle"); + emitNotification({ body: lastAssistantText(ctx) }); }); - pi.on("session_shutdown", async (_event, _ctx) => { - await sendEvent(env, "session_end"); - await sendEvent(env, "idle"); + pi.on("session_shutdown", (_event, _ctx) => { + emitPresence("session_end"); + emitPresence("idle"); }); } """ diff --git a/SupacodeSettingsShared/BusinessLogic/RepositoryLocalSettingsPersistence.swift b/SupacodeSettingsShared/BusinessLogic/RepositoryLocalSettingsPersistence.swift index 9d0796450..17e113fc9 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositoryLocalSettingsPersistence.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositoryLocalSettingsPersistence.swift @@ -18,6 +18,10 @@ nonisolated enum RepositoryLocalSettingsStorageKey: DependencyKey { static var liveValue: RepositoryLocalSettingsStorage { RepositoryLocalSettingsStorage( load: { try Data(contentsOf: $0) }, + // Do not follow symlinks here: this writes `/supacode.json`, whose + // contents come from a possibly-untrusted cloned repository. A symlink there + // could point at any user-writable file, so the atomic write must replace the + // link in the repo rather than write through it (an arbitrary-overwrite path). save: { data, url in let directory = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) diff --git a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift index 97d0a31fe..c8c90ce94 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositoryPersistenceKeys.swift @@ -48,6 +48,69 @@ public nonisolated struct RepositoryRootsKey: SharedKey { } } +public nonisolated struct RemoteRepositoryRootsKeyID: Hashable, Sendable { + public init() {} +} + +/// Persists the self-descriptive remote repository ids. Kept apart from the +/// local-path `RepositoryPathNormalizer` (which would mangle a `user@host/path` +/// authority through `URL(fileURLWithPath:)`); remote ids only need a trim + +/// order-preserving dedupe. +public nonisolated struct RemoteRepositoryRootsKey: SharedKey { + public init() {} + + public var id: RemoteRepositoryRootsKeyID { + RemoteRepositoryRootsKeyID() + } + + static func normalize(_ ids: [String]) -> [String] { + var seen = Set() + var normalized: [String] = [] + normalized.reserveCapacity(ids.count) + for id in ids { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, seen.insert(trimmed).inserted else { continue } + normalized.append(trimmed) + } + return normalized + } + + public func load( + context _: LoadContext<[String]>, + continuation: LoadContinuation<[String]> + ) { + @Shared(.settingsFile) var settingsFile: SettingsFile + let roots = $settingsFile.withLock { settings in + let normalized = Self.normalize(settings.remoteRepositoryRoots) + if normalized != settings.remoteRepositoryRoots { + settings.remoteRepositoryRoots = normalized + } + return normalized + } + continuation.resume(returning: roots) + } + + public func subscribe( + context _: LoadContext<[String]>, + subscriber _: SharedSubscriber<[String]> + ) -> SharedSubscription { + SharedSubscription {} + } + + public func save( + _ value: [String], + context _: SaveContext, + continuation: SaveContinuation + ) { + @Shared(.settingsFile) var settingsFile: SettingsFile + let normalized = Self.normalize(value) + $settingsFile.withLock { + $0.remoteRepositoryRoots = normalized + } + continuation.resume() + } +} + public nonisolated struct PinnedWorktreeIDsKeyID: Hashable, Sendable { public init() {} } @@ -100,6 +163,12 @@ nonisolated extension SharedReaderKey where Self == RepositoryRootsKey.Default { } } +nonisolated extension SharedReaderKey where Self == RemoteRepositoryRootsKey.Default { + public static var remoteRepositoryRoots: Self { + Self[RemoteRepositoryRootsKey(), default: []] + } +} + nonisolated extension SharedReaderKey where Self == PinnedWorktreeIDsKey.Default { public static var pinnedWorktreeIDs: Self { Self[PinnedWorktreeIDsKey(), default: []] @@ -116,6 +185,11 @@ public nonisolated enum RepositoryPathNormalizer { public static func normalize(_ path: String) -> String? { let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } + // A remote id (`[user@]host[:port]`) is not a filesystem path; routing + // it through `URL(fileURLWithPath:)` would prepend the cwd and mangle it. A + // local id is always an absolute path, so the leading slash is the + // discriminator: only those get filesystem standardization. + guard trimmed.hasPrefix("/") else { return trimmed } return URL(fileURLWithPath: trimmed) .standardizedFileURL .path(percentEncoded: false) @@ -140,11 +214,7 @@ public nonisolated enum RepositoryPathNormalizer { var normalized: [String: Date] = [:] normalized.reserveCapacity(dictionary.count) for (key, value) in dictionary { - let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - let resolved = URL(fileURLWithPath: trimmed) - .standardizedFileURL - .path(percentEncoded: false) + guard let resolved = normalize(key) else { continue } // On collision, keep the more recent (greater) date. if let existing = normalized[resolved], existing > value { continue diff --git a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift index 6fadb94e2..173cd92f0 100644 --- a/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift +++ b/SupacodeSettingsShared/BusinessLogic/RepositorySettingsKey.swift @@ -13,10 +13,19 @@ public nonisolated struct RepositorySettingsKeyID: Hashable, Sendable { public nonisolated struct RepositorySettingsKey: SharedKey { public let repositoryID: String public let rootURL: URL + public let host: RemoteHost? - public init(rootURL: URL) { + public init(rootURL: URL, host: RemoteHost? = nil) { self.rootURL = rootURL.standardizedFileURL - repositoryID = self.rootURL.path(percentEncoded: false) + self.host = host + if let host { + // Brand remote keys with the host (matching `RepositoryLocation.id`) so + // two hosts at the same path can't share settings, and so a local repo + // at that path keeps its own bare-path key. + repositoryID = host.authority + self.rootURL.path(percentEncoded: false) + } else { + repositoryID = self.rootURL.path(percentEncoded: false) + } } public var id: RepositorySettingsKeyID { @@ -27,18 +36,22 @@ public nonisolated struct RepositorySettingsKey: SharedKey { context: LoadContext, continuation: LoadContinuation ) { - @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage - let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) - if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { - let decoder = JSONDecoder() - if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { - continuation.resume(returning: settings) - return + // Remote repos never own a local `supacode.json`; the synthetic `rootURL` + // points at the remote path, which must not be read off the local disk. + if host == nil { + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + if let localData = try? repositoryLocalSettingsStorage.load(repositorySettingsURL) { + let decoder = JSONDecoder() + if let settings = try? decoder.decode(RepositorySettings.self, from: localData) { + continuation.resume(returning: settings) + return + } + let path = repositorySettingsURL.path(percentEncoded: false) + SupaLogger("Settings").warning( + "Unable to decode repository settings at \(path); falling back to global settings." + ) } - let path = repositorySettingsURL.path(percentEncoded: false) - SupaLogger("Settings").warning( - "Unable to decode repository settings at \(path); falling back to global settings." - ) } @Shared(.settingsFile) var settingsFile: SettingsFile @@ -65,19 +78,22 @@ public nonisolated struct RepositorySettingsKey: SharedKey { context _: SaveContext, continuation: SaveContinuation ) { - @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage - let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) - if (try? repositoryLocalSettingsStorage.load(repositorySettingsURL)) != nil { - do { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(value) - try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) - continuation.resume() - } catch { - continuation.resume(throwing: error) + // Mirror `load`: only a local repo may persist to an on-disk `supacode.json`. + if host == nil { + @Dependency(\.repositoryLocalSettingsStorage) var repositoryLocalSettingsStorage + let repositorySettingsURL = SupacodePaths.repositorySettingsURL(for: rootURL) + if (try? repositoryLocalSettingsStorage.load(repositorySettingsURL)) != nil { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(value) + try repositoryLocalSettingsStorage.save(data, repositorySettingsURL) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + return } - return } @Shared(.settingsFile) var settingsFile: SettingsFile @@ -88,7 +104,7 @@ public nonisolated struct RepositorySettingsKey: SharedKey { } } nonisolated extension SharedReaderKey where Self == RepositorySettingsKey.Default { - public static func repositorySettings(_ rootURL: URL) -> Self { - Self[RepositorySettingsKey(rootURL: rootURL), default: .default] + public static func repositorySettings(_ rootURL: URL, host: RemoteHost? = nil) -> Self { + Self[RepositorySettingsKey(rootURL: rootURL, host: host), default: .default] } } diff --git a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift index 81c111c7a..2787f4d46 100644 --- a/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift +++ b/SupacodeSettingsShared/BusinessLogic/SettingsFilePersistence.swift @@ -19,11 +19,7 @@ public nonisolated enum SettingsFileStorageKey: DependencyKey { public static var liveValue: SettingsFileStorage { SettingsFileStorage( load: { try Data(contentsOf: $0) }, - save: { data, url in - let directory = url.deletingLastPathComponent() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - try data.write(to: url, options: [.atomic]) - } + save: { data, url in try SymlinkPreservingFileWriter.write(data, to: url) } ) } public static var previewValue: SettingsFileStorage { .inMemory() } @@ -58,10 +54,6 @@ extension SettingsFileStorage { } } -nonisolated enum SettingsFileStorageError: Error { - case missing -} - nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable { private let lock = NSLock() private var dataByURL: [URL: Data] = [:] @@ -70,7 +62,9 @@ nonisolated final class InMemorySettingsFileStorage: @unchecked Sendable { lock.lock() defer { lock.unlock() } guard let data = dataByURL[url] else { - throw SettingsFileStorageError.missing + // Mirror real-disk semantics so callers that distinguish "file absent" + // (fresh start) from a read failure see the same `fileReadNoSuchFile`. + throw CocoaError(.fileReadNoSuchFile) } return data } diff --git a/SupacodeSettingsShared/BusinessLogic/SymlinkPreservingFileWriter.swift b/SupacodeSettingsShared/BusinessLogic/SymlinkPreservingFileWriter.swift new file mode 100644 index 000000000..1b3552494 --- /dev/null +++ b/SupacodeSettingsShared/BusinessLogic/SymlinkPreservingFileWriter.swift @@ -0,0 +1,85 @@ +import Foundation + +public nonisolated enum SymlinkPreservingFileWriterError: Error, Equatable { + /// The destination resolves through a symlink cycle, so there is no real file + /// to write without clobbering one of the links. + case symbolicLinkCycle(URL) + /// The chain exceeds the kernel's symlink-resolution limit, so the loader + /// could never follow it; refuse rather than write a file it can't read back. + case symbolicLinkChainTooDeep(URL) +} + +/// Atomic file writes that survive a symlinked destination. When the target is a +/// symlink (e.g. a `~/.supacode/settings.json` linked into a dotfiles repo), the +/// write follows the link to its real file so the temp+rename replaces the +/// target, leaving the link intact, instead of overwriting the link with a +/// regular file. +public nonisolated enum SymlinkPreservingFileWriter { + /// Atomically writes `data` to `url`, following a symlink at `url` so the link + /// is preserved. Creates the destination's own parent directory when missing, + /// but never a symlink target's parent (a link into a missing directory fails + /// the write rather than fabricating a phantom tree there). + public static func write(_ data: Data, to url: URL) throws { + let target = try resolvedTarget(for: url) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + // The atomic temp+rename happens in the target's directory, so a symlink at + // `url` is written through and preserved instead of replaced. + try data.write(to: target, options: [.atomic]) + } + + /// Moves the file at `url` aside to `destination`, preserving a symlink at + /// `url` by relocating the link's resolved target so the link survives (left + /// dangling for the next `write` to recreate). Falls back to moving the link + /// itself when it can't be resolved to a real target (a cycle or an over-deep + /// chain), where there is nothing else to relocate. + public static func moveAside(at url: URL, to destination: URL) throws { + let source: URL + do { + source = try resolvedTarget(for: url) + } catch is SymlinkPreservingFileWriterError { + source = url + } + try FileManager.default.moveItem(at: source, to: destination) + } + + /// macOS resolves at most MAXSYMLINKS (32) links before ELOOP, so a deeper + /// chain is one the loader's `Data(contentsOf:)` could never read back. + private static let maxFollowedSymbolicLinks = 32 + + /// Follows a symlink chain at `url` to its final real file. Returns `url` + /// unchanged when it is not a symlink (including a not-yet-created file). + /// Relative link targets resolve against the link's real directory so a link + /// under a symlinked parent still lands on the file the kernel would. Throws + /// on a cycle or an over-deep chain rather than silently overwriting a link in + /// the loop, and surfaces a real read error rather than misreading an + /// unreadable link as a plain file (which would clobber it). + private static func resolvedTarget(for url: URL) throws -> URL { + let fileManager = FileManager.default + var current = url + var visited: Set = [] + while true { + let isSymbolicLink: Bool + do { + isSymbolicLink = try current.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink ?? false + } catch CocoaError.fileReadNoSuchFile { + return current + } + guard isSymbolicLink else { return current } + let linkPath = current.path(percentEncoded: false) + guard visited.insert(linkPath).inserted else { + throw SymlinkPreservingFileWriterError.symbolicLinkCycle(url) + } + guard visited.count <= Self.maxFollowedSymbolicLinks else { + throw SymlinkPreservingFileWriterError.symbolicLinkChainTooDeep(url) + } + let destination = try fileManager.destinationOfSymbolicLink(atPath: linkPath) + // A relative target resolves against the link's real directory; an absolute one ignores the base. + let base = + destination.hasPrefix("/") ? nil : current.deletingLastPathComponent().resolvingSymlinksInPath() + current = URL(filePath: destination, directoryHint: .notDirectory, relativeTo: base).standardizedFileURL + } + } +} diff --git a/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift b/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift new file mode 100644 index 000000000..4ce31352e --- /dev/null +++ b/SupacodeSettingsShared/Clients/Shell/ShellClient+SSH.swift @@ -0,0 +1,61 @@ +import Foundation + +extension ShellClient { + /// Host-aware transport: a `ShellClient` that runs every command on `host` + /// over SSH instead of locally, by rewriting each call into an `ssh + /// ` invocation (the working directory becomes a remote `cd`) + /// and delegating to `base` (defaults to `.live`; tests inject a recorder). + /// This is the single chokepoint that makes the rest of the stack remote. ssh + /// already runs the remote command through the user's login shell, so the + /// `runLogin*` entries must not re-wrap it and route to the plain `base.run*`. + /// `extraOptions` injects per-call `ssh -o` flags (e.g. the non-interactive + /// background-probe profile) on top of the shared multiplexing options. + public static func ssh( + host: RemoteHost, + base: ShellClient = .live, + extraOptions: [String] = [] + ) -> ShellClient { + ShellClient( + run: { executableURL, arguments, currentDirectoryURL in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return try await base.run(invocation.executableURL, invocation.arguments, nil) + }, + runLoginImpl: { executableURL, arguments, currentDirectoryURL, _ in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return try await base.run(invocation.executableURL, invocation.arguments, nil) + }, + runStream: { executableURL, arguments, currentDirectoryURL in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return base.runStream(invocation.executableURL, invocation.arguments, nil) + }, + runLoginStreamImpl: { executableURL, arguments, currentDirectoryURL, _ in + let invocation = SSHCommand.invocation( + host: host, + executable: executableURL.path(percentEncoded: false), + arguments: arguments, + workingDirectory: currentDirectoryURL, + extraOptions: extraOptions + ) + return base.runStream(invocation.executableURL, invocation.arguments, nil) + } + ) + } +} diff --git a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift index 8a3cbcda6..0a7f1e898 100644 --- a/SupacodeSettingsShared/Clients/Shell/ShellClient.swift +++ b/SupacodeSettingsShared/Clients/Shell/ShellClient.swift @@ -78,8 +78,8 @@ extension ShellClient: DependencyKey { ) }, runLoginImpl: { executableURL, arguments, currentDirectoryURL, log in - let shellURL = URL(fileURLWithPath: defaultShellPath()) - let execCommand = shellExecCommand(for: shellURL) + let (shellURL, execCommand) = ShellClient.loginShellInvocation( + userShell: URL(fileURLWithPath: defaultShellPath())) let shellArguments = ["-l", "-c", execCommand, "--", executableURL.path(percentEncoded: false)] + arguments if log { @@ -102,8 +102,8 @@ extension ShellClient: DependencyKey { ) }, runLoginStreamImpl: { executableURL, arguments, currentDirectoryURL, log in - let shellURL = URL(fileURLWithPath: defaultShellPath()) - let execCommand = shellExecCommand(for: shellURL) + let (shellURL, execCommand) = ShellClient.loginShellInvocation( + userShell: URL(fileURLWithPath: defaultShellPath())) let shellArguments = ["-l", "-c", execCommand, "--", executableURL.path(percentEncoded: false)] + arguments if log { @@ -184,6 +184,21 @@ nonisolated private func runProcessStream( let command = ([executableURL.path(percentEncoded: false)] + arguments).joined(separator: " ") do { try process.run() + // Terminate the child only when the consuming task is cancelled (e.g. a + // remote load probe timing out); on normal completion the process already + // exited, so signalling its pid would risk a reused pid. Without this the + // `waitUntilExit()` below blocks its thread forever on a stalled ssh + // connection and no timeout can fire. SIGTERM first, then SIGKILL after a + // short grace so an ssh that ignores SIGTERM can't keep the task hung. + let pid = process.processIdentifier + continuation.onTermination = { @Sendable termination in + guard case .cancelled = termination, pid > 0 else { return } + kill(pid, SIGTERM) + Task.detached { + try? await Task.sleep(for: .seconds(2)) + if kill(pid, 0) == 0 { kill(pid, SIGKILL) } + } + } let stdoutTask = Task.detached { for await line in lineStream(from: outputHandle) { await outputAccumulator.append(line, source: .stdout) @@ -250,14 +265,41 @@ nonisolated private func collectOutput( return finalOutput } -nonisolated private func shellExecCommand(for shellURL: URL) -> String { - switch shellURL.lastPathComponent { - case "fish": - return "test -f ~/.config/fish/config.fish; and source ~/.config/fish/config.fish >/dev/null 2>&1; exec $argv" - case "bash": - return "[ -f ~/.bashrc ] && . ~/.bashrc >/dev/null 2>&1; exec \"$@\"" - default: - return "[ -f ~/.zshrc ] && . ~/.zshrc >/dev/null 2>&1; exec \"$@\"" +extension ShellClient { + /// Builds the `(shell, -c command)` pair for a one-shot login-shell command. + /// We only drive shells we have a correct rc snippet for — zsh, bash, fish. + /// Anything else (nushell, sh/dash/ksh, pwsh, …) falls back to /bin/zsh, which + /// can actually parse the snippet, so the command runs instead of failing + /// (issue #100). The interactive terminal still uses the user's real shell. + nonisolated static func loginShellInvocation(userShell: URL) -> (shell: URL, command: String) { + let drivable: Set = ["zsh", "bash", "fish"] + let shell = + drivable.contains(userShell.lastPathComponent) + ? userShell : URL(fileURLWithPath: "/bin/zsh") + let command: String + switch shell.lastPathComponent { + case "fish": + command = "test -f ~/.config/fish/config.fish; and source ~/.config/fish/config.fish >/dev/null 2>&1; exec $argv" + case "bash": + command = posixLoginCommand(rcFile: "~/.bashrc") + default: + command = posixLoginCommand(rcFile: "~/.zshrc") + } + return (shell, command) + } + + /// Builds the zsh/bash one-shot command: capture the positional parameters, clear them, then source + /// the rc file and exec from the saved array. Sourcing shares `$@` with the caller, so an rc that + /// resets the positionals (e.g. `set --`) would otherwise wipe the command before `exec` (#441). + /// Clearing `$@` with `set --` before sourcing also keeps the target command out of the rc's view: + /// a dual-mode script dispatching on `$1` (e.g. `fzf-git.sh`) would otherwise see the probe's + /// arguments, hit its own `exit`, and kill the probe shell before `exec` ran (#477). The exec reads + /// from the saved array, so clearing the live positionals is safe. + nonisolated private static func posixLoginCommand(rcFile: String) -> String { + let capture = "__supacode_login_argv=(\"$@\")" + let clear = "set --" + let source = "[ -f \(rcFile) ] && . \(rcFile) >/dev/null 2>&1" + return "\(capture); \(clear); \(source); exec \"${__supacode_login_argv[@]}\"" } } diff --git a/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift b/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift index ee4956639..6253aef3f 100644 --- a/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift +++ b/SupacodeSettingsShared/Domain/OpenWorktreeAction.swift @@ -1,5 +1,45 @@ import AppKit +public enum OpenTarget: Equatable, Sendable { + case workingDirectory + case url(URL) + case search(String, excludeDirectories: String? = nil, maxDepth: Int = 3) + + public static let `default`: Self = .workingDirectory +} + +public enum OpenBehavior: Equatable, Sendable { + public struct WorkspaceConfiguration: Equatable, Sendable { + public var createsNewApplicationInstance: Bool + public var arguments: [Argument] + + public init( + createsNewApplicationInstance: Bool = false, + arguments: [Argument] = [] + ) { + self.createsNewApplicationInstance = createsNewApplicationInstance + self.arguments = arguments + } + } + + public enum ProcessExecutable: Equatable, Sendable { + case path(String) + case appRelativePath(String) + } + + public enum Argument: Equatable, Sendable { + case literal(String) + case appPath + case targetPath + case targetURL + } + + case workspace(configuration: WorkspaceConfiguration? = nil) + case process(ProcessExecutable, args: [Argument]) + + public static let `default`: Self = .workspace(configuration: nil) +} + public enum OpenWorktreeAction: CaseIterable, Identifiable { public enum MenuIcon { case app(NSImage) @@ -17,8 +57,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case gitkraken case gitup case ghostty + case goland case intellij + case intellijEAP case kitty + case nova case pycharm case rubymine case rustrover @@ -35,6 +78,7 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case windsurf case xcode case zed + case zedPreview public var id: String { title } @@ -50,8 +94,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .gitkraken: "GitKraken" case .gitup: "GitUp" case .ghostty: "Ghostty" + case .goland: "GoLand" case .intellij: "IntelliJ IDEA" + case .intellijEAP: "IntelliJ IDEA EAP" case .kitty: "Kitty" + case .nova: "Nova" case .pycharm: "PyCharm" case .rubymine: "RubyMine" case .rustrover: "RustRover" @@ -69,6 +116,7 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .xcode: "Xcode" case .fork: "Fork" case .zed: "Zed" + case .zedPreview: "Zed Preview" } } @@ -77,9 +125,9 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder: "Finder" case .editor: "$EDITOR" case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, - .gitup, .ghostty, .intellij, .kitty, .pycharm, .rubymine, .rustrover, .smartgit, - .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, - .webstorm, .wezterm, .windsurf, .xcode, .zed: + .gitup, .ghostty, .goland, .intellij, .intellijEAP, .kitty, .nova, .pycharm, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, + .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed, .zedPreview: title } } @@ -100,9 +148,9 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .finder, .editor: return true case .alacritty, .androidStudio, .antigravity, .cursor, .fork, .githubDesktop, .gitkraken, - .gitup, .ghostty, .intellij, .kitty, .pycharm, .rubymine, .rustrover, .smartgit, - .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, .vscodium, .warp, - .webstorm, .wezterm, .windsurf, .xcode, .zed: + .gitup, .ghostty, .goland, .intellij, .intellijEAP, .kitty, .nova, .pycharm, .rubymine, + .rustrover, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, + .vscodium, .warp, .webstorm, .wezterm, .windsurf, .xcode, .zed, .zedPreview: return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil } } @@ -120,8 +168,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .gitkraken: "gitkraken" case .gitup: "gitup" case .ghostty: "ghostty" + case .goland: "goland" case .intellij: "intellij" + case .intellijEAP: "intellijEAP" case .kitty: "kitty" + case .nova: "nova" case .pycharm: "pycharm" case .rubymine: "rubymine" case .rustrover: "rustrover" @@ -138,6 +189,7 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .windsurf: "windsurf" case .xcode: "xcode" case .zed: "zed" + case .zedPreview: "zed-preview" } } @@ -154,8 +206,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .gitkraken: "com.axosoft.gitkraken" case .gitup: "co.gitup.mac" case .ghostty: "com.mitchellh.ghostty" + case .goland: "com.jetbrains.goland" case .intellij: "com.jetbrains.intellij" + case .intellijEAP: "com.jetbrains.intellij-EAP" case .kitty: "net.kovidgoyal.kitty" + case .nova: "com.panic.Nova" case .pycharm: "com.jetbrains.pycharm" case .rubymine: "com.jetbrains.rubymine" case .rustrover: "com.jetbrains.rustrover" @@ -172,6 +227,50 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { case .windsurf: "com.exafunction.windsurf" case .xcode: "com.apple.dt.Xcode" case .zed: "dev.zed.Zed" + case .zedPreview: "dev.zed.Zed-Preview" + } + } + + public var openTargets: [OpenTarget] { + switch self { + case .xcode: + [ + .search(#"\.xcworkspace$"#, excludeDirectories: Self.xcodeSearchExcludedDirectories), + .search(#"\.xcodeproj$"#, excludeDirectories: Self.xcodeSearchExcludedDirectories), + .default, + ] + case .alacritty, .androidStudio, .antigravity, .cursor, .editor, .finder, .fork, .githubDesktop, + .gitkraken, .gitup, .ghostty, .goland, .intellij, .intellijEAP, .kitty, .nova, .pycharm, + .rubymine, .rustrover, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, + .vscodeInsiders, .vscodium, .warp, .webstorm, .wezterm, .windsurf, .zed, .zedPreview: + [.default] + } + } + + public var openBehaviors: [OpenBehavior] { + switch self { + case .androidStudio, .goland, .intellij, .intellijEAP, .webstorm, .pycharm, .rubymine, .rustrover: + [ + .workspace( + configuration: + .init( + createsNewApplicationInstance: true, + arguments: [.targetPath] + ) + ) + ] + case .zed, .zedPreview: + [ + .process( + .appRelativePath("Contents/MacOS/cli"), + args: [.targetPath] + ), + .default, + ] + case .alacritty, .antigravity, .cursor, .editor, .finder, .fork, .githubDesktop, .gitkraken, .gitup, + .ghostty, .kitty, .nova, .smartgit, .sourcetree, .sublimeMerge, .terminal, .vscode, .vscodeInsiders, + .vscodium, .warp, .wezterm, .windsurf, .xcode: + [.default] } } @@ -180,16 +279,20 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { public static let editorPriority: [OpenWorktreeAction] = [ .cursor, .zed, + .zedPreview, .vscode, .windsurf, .vscodeInsiders, .vscodium, .androidStudio, + .goland, .intellij, + .intellijEAP, .webstorm, .pycharm, .rubymine, .rustrover, + .nova, .antigravity, ] public static let terminalPriority: [OpenWorktreeAction] = [ @@ -255,4 +358,11 @@ public enum OpenWorktreeAction: CaseIterable, Identifiable { public static func preferredDefault() -> OpenWorktreeAction { defaultPriority.first(where: \.isInstalled) ?? .finder } + + private static let xcodeSearchExcludedDirectories = + #"(^|/)("# + + #"\.build|\.dart_tool|\.expo|\.expo-shared|\.git|\.gradle|\.pnpm-store"# + + #"|\.swiftpm|\.symlinks|\.yarn|Carthage|DerivedData|Pods|build|node_modules"# + + #"|[^/]+\.xcodeproj|[^/]+\.xcworkspace"# + + #")(/|$)"# } diff --git a/SupacodeSettingsShared/Models/RemoteHost.swift b/SupacodeSettingsShared/Models/RemoteHost.swift new file mode 100644 index 000000000..70f48daa6 --- /dev/null +++ b/SupacodeSettingsShared/Models/RemoteHost.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Describes an SSH destination a worktree can live on. A `nil` `RemoteHost` +/// everywhere means "local": the unchanged Process-on-this-machine path. +/// +/// `alias` is whatever `ssh` itself accepts as a host: a `~/.ssh/config` alias +/// or a bare hostname. `username` / `port` are optional overrides for callers +/// that don't want to encode them in ssh config. +public nonisolated struct RemoteHost: Codable, Hashable, Sendable { + public var alias: String + public var username: String? + public var port: Int? + + public init( + alias: String, + username: String? = nil, + port: Int? = nil + ) { + self.alias = alias + self.username = username + self.port = port + } + + /// Inverse of `authority`: parse `[user@]host[:port]` back into a host. A + /// bracketed IPv6 host keeps its colons inside the brackets. `nil` if host empty. + public init?(authority: String) { + let trimmed = authority.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + let user: String? + let hostPort: Substring + if let atIndex = trimmed.lastIndex(of: "@") { + user = String(trimmed[.. [String] { + [ + "-o", "ControlMaster=auto", + "-o", "ControlPath=\(controlPath)", + "-o", "ControlPersist=10m", + ] + } + + /// Options for a non-interactive background probe (e.g. resolving a remote + /// repository at launch). `BatchMode` so it fails fast instead of blocking on + /// a password / host-key prompt; `ConnectTimeout` bounds the TCP+handshake; + /// `ServerAlive*` aborts a connection that stalls mid-command (~10s). A live + /// ControlMaster (an open terminal) bypasses auth, so the common case is fast. + public static let backgroundProbeOptions: [String] = [ + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=10", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", + ] + + /// POSIX single-quote a token so a parent shell passes it through literally. + public static func shellQuote(_ value: String) -> String { + "'" + value.replacing("'", with: "'\\''") + "'" + } + + /// The command string the *remote* shell runs for a local + /// `(executable, arguments, workingDirectory)` invocation. A working + /// directory becomes `cd -- && exec ...` so the remote process starts + /// in the worktree and replaces the shell (signals / exit status map + /// straight through). + public static func remoteCommand( + executable: String, + arguments: [String], + workingDirectory: URL? + ) -> String { + let invocation = ([executable] + arguments).map(shellQuote).joined(separator: " ") + guard let workingDirectory else { + return invocation + } + let directory = shellQuote(workingDirectory.path(percentEncoded: false)) + return "cd -- \(directory) && exec \(invocation)" + } + + /// Wrap a remote command so it runs under a **login** shell. ssh's default + /// `$SHELL -c ` is non-interactive *and* non-login, so on macOS it only + /// inherits `~/.zshenv`'s bare PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), so + /// Homebrew's `/opt/homebrew/bin` (where remote `zmx` / `git` / the `wt` shim + /// live) is NOT on it and the remote command fails with `command not found`. + /// A login shell reads `/etc/zprofile` (path_helper) + `~/.zprofile` + /// (`brew shellenv`), restoring the full PATH. `$SHELL` is expanded by ssh's + /// own outer shell; `exec` replaces it so signals / exit status pass through. + public static func loginShellWrapped(_ remoteScript: String) -> String { + "exec \"$SHELL\" -l -c " + shellQuote(remoteScript) + } + + /// Login-shell-wrapped remote command that also forwards positional arguments + /// (`$0`, `$1`, …) to the `-c` script, so an arbitrary payload (e.g. a user + /// script) rides as `$1` instead of being concatenated into the script text. + /// `exec [env NAME=…] "$SHELL" -l -c '