diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 1c89f6a..6aec68d 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -249,6 +249,22 @@ mutating tools; the escape hatch for clients without permission-request support) ## Releasing -Version lives in `Cargo.toml`. The binary is published to five registries via separate workflows -(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-pypi`); the -`npm/` and `pypi/` dirs hold the wrapper-package templates. Update `CHANGELOG.md` for releases. +Version lives in `Cargo.toml`. The binary is published to six registries via separate workflows +(`release-crates`, `release-github`, `release-homebrew`, `release-npm`, `release-nuget`, +`release-pypi`); the `npm/`, `nuget/`, and `pypi/` dirs hold the wrapper-package templates. Update +`CHANGELOG.md` for releases. + +`[profile.release]` sets `strip = "symbols"` because binary size is a distribution constraint, not +just a nicety — see the NuGet note below. + +The NuGet package (`SiGit.Code`, installed with `dotnet tool install --global SiGit.Code`) is the +odd one out: npm and PyPI publish one artifact per platform, but a .NET tool is a single package, +so `nuget/sigit/` bundles all six binaries under `native/-/` and a small managed shim +(`Program.cs`) execs the right one. That shim leaves stdin/stdout/stderr unredirected on purpose, +since siGit Code chooses TUI or ACP mode by testing whether stdin is a TTY. + +Bundling every target means the package is large, so `release-nuget.yml` fails the pack job if the +`.nupkg` crosses nuget.org's 250 MB limit. At v1.5.1 it lands around 160 MB. If a future release +trips that check, the fix is not to drop targets but to split into RID-specific tool packages +(.NET 10's `DotnetToolRidPackage`), which ship one binary per platform the way npm already does — +that also needs a fallback package for pre-.NET-10 SDKs. diff --git a/.github/workflows/release-nuget.yml b/.github/workflows/release-nuget.yml new file mode 100644 index 0000000..d3e0b3c --- /dev/null +++ b/.github/workflows/release-nuget.yml @@ -0,0 +1,300 @@ +name: NuGet Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.5.2)" + required: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + DOTNET_VERSION: 8.0.x + NUGET_PACKAGE_ID: SiGit.Code + +jobs: + build-native-binaries: + name: Build native binary (${{ matrix.build.NAME }}) + runs-on: ${{ matrix.build.OS }} + strategy: + fail-fast: false + matrix: + build: + - { + NAME: linux-x64, + OS: ubuntu-latest, + TARGET: x86_64-unknown-linux-gnu, + } + - { + NAME: linux-arm64, + OS: ubuntu-24.04-arm, + TARGET: aarch64-unknown-linux-gnu, + } + - { + NAME: windows-x64, + OS: windows-2022, + TARGET: x86_64-pc-windows-msvc, + } + - { + NAME: windows-arm64, + OS: windows-2022, + TARGET: aarch64-pc-windows-msvc, + } + - { NAME: darwin-x64, OS: macos-26, TARGET: x86_64-apple-darwin } + - { NAME: darwin-arm64, OS: macos-26, TARGET: aarch64-apple-darwin } + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Read Rust toolchain + shell: bash + run: | + rust_toolchain="$(sed -n 's/^channel = "\(.*\)"/\1/p' rust-toolchain.toml | head -n 1)" + if [ -z "$rust_toolchain" ]; then + echo "Failed to read Rust toolchain from rust-toolchain.toml" >&2 + exit 1 + fi + echo "RUST_TOOLCHAIN=${rust_toolchain}" >> "$GITHUB_ENV" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: Install Rust target + shell: bash + run: | + rustup target add ${{ matrix.build.TARGET }} --toolchain ${{ env.RUST_TOOLCHAIN }} + rustup target list --installed + + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + with: + key: nuget-${{ matrix.build.TARGET }} + + - name: Build binary + shell: bash + run: cargo build --locked --release --target ${{ matrix.build.TARGET }} + + - name: Prepare artifact + shell: bash + run: | + executable_name="sigit" + if [[ "${{ matrix.build.OS }}" == "windows-2022" ]]; then + executable_name="sigit.exe" + fi + + mkdir -p "nuget-artifacts/native/${{ matrix.build.NAME }}" + cp "target/${{ matrix.build.TARGET }}/release/${executable_name}" \ + "nuget-artifacts/native/${{ matrix.build.NAME }}/${executable_name}" + + - name: Upload native artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-native-${{ matrix.build.NAME }} + path: nuget-artifacts/native + + pack-nuget: + name: Pack NuGet .NET tool + runs-on: ubuntu-latest + needs: build-native-binaries + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download native artifacts + uses: actions/download-artifact@v4 + with: + pattern: nuget-native-* + path: nuget/sigit/native + merge-multiple: true + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Pack .NET tool + shell: bash + run: | + dotnet pack nuget/sigit/SiGit.Code.csproj \ + --configuration Release \ + --output nuget/dist \ + -p:PackageVersion=${RELEASE_VERSION} + + # All six binaries ship in one package, so this sits closer to nuget.org's + # 250 MB ceiling than the single-platform npm and PyPI artifacts do. Fail + # here with a clear message rather than at `dotnet nuget push`, where the + # error is just "the package file exceeds the size limit". + - name: Check package size against the NuGet limit + shell: bash + run: | + package="$(ls nuget/dist/*.nupkg | head -n 1)" + size_bytes="$(wc -c < "${package}")" + limit_bytes=$((250 * 1024 * 1024)) + + echo "Package: ${package}" + echo "Size: $((size_bytes / 1024 / 1024)) MiB" + + if [ "${size_bytes}" -gt "${limit_bytes}" ]; then + echo "::error::${package} is over nuget.org's 250 MB package limit. Split the tool into RID-specific packages (.NET 10 DotnetToolRidPackage) instead of bundling every target." >&2 + exit 1 + fi + + - name: Upload package artifact + uses: actions/upload-artifact@v4 + with: + name: nuget-package + path: nuget/dist/*.nupkg + + smoke-test: + name: Smoke test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: pack-nuget + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - windows-2022 + - macos-26 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ github.event.inputs.tag || github.ref }} + + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download package artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: nuget/dist + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Install tool from local package + shell: pwsh + run: | + $packageSource = (Resolve-Path "nuget/dist").Path + @" + + + + + + + + "@ | Set-Content -Path "nuget/NuGet.Config" + + dotnet tool install ` + --tool-path ".tool" ` + --configfile "nuget/NuGet.Config" ` + --version "$env:RELEASE_VERSION" ` + "$env:NUGET_PACKAGE_ID" + + - name: Verify bundled native binary + shell: pwsh + run: | + $runtimeIdentifier = if ($IsWindows) { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "windows-arm64" } else { "windows-x64" } + } elseif ($IsMacOS) { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "darwin-arm64" } else { "darwin-x64" } + } else { + if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq [System.Runtime.InteropServices.Architecture]::Arm64) { "linux-arm64" } else { "linux-x64" } + } + + $binaryName = if ($IsWindows) { "sigit.exe" } else { "sigit" } + $nativeBinary = Get-ChildItem ".tool/.store" -Recurse -File | Where-Object { + $_.FullName -like "*native*${runtimeIdentifier}*${binaryName}" + } | Select-Object -First 1 + + if (-not $nativeBinary) { + Write-Error "The sigit native binary for ${runtimeIdentifier} was not bundled in the installed .NET tool package." + } + + Write-Host "Found bundled native binary at $($nativeBinary.FullName)" + + publish-nuget: + name: Publish to NuGet + runs-on: ubuntu-latest + needs: smoke-test + steps: + - name: Set the release version + shell: bash + run: | + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + release_version="${GITHUB_REF_NAME#v}" + else + release_version="${{ github.event.inputs.tag }}" + release_version="${release_version#v}" + fi + echo "RELEASE_VERSION=${release_version}" >> "$GITHUB_ENV" + + - name: Download package artifact + uses: actions/download-artifact@v4 + with: + name: nuget-package + path: nuget/dist + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Check whether release already exists on NuGet + id: nuget-check + shell: bash + run: | + package_index_url="https://api.nuget.org/v3-flatcontainer/sigit.code/index.json" + if curl -fsS "${package_index_url}" | grep -F "\"${RELEASE_VERSION}\"" >/dev/null; then + echo "${NUGET_PACKAGE_ID} ${RELEASE_VERSION} already exists on NuGet, skipping publish" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish package + if: steps.nuget-check.outputs.exists != 'true' + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + shell: bash + run: | + dotnet nuget push nuget/dist/*.nupkg \ + --api-key "${NUGET_API_KEY}" \ + --source https://api.nuget.org/v3/index.json \ + --skip-duplicate diff --git a/Cargo.toml b/Cargo.toml index 3c17c13..3af179d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,11 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "stream"] } uuid = { version = "1", features = ["v4"] } rpassword = "7" + +# Release binaries ship to five registries, and the NuGet package bundles all +# six targets in a single archive, so binary size is a distribution constraint +# rather than a nicety. Stripping symbols takes the macOS arm64 binary from +# ~102 MB to ~88 MB. The Homebrew tarball already stripped by hand in +# release-github.yml; this makes every channel match. +[profile.release] +strip = "symbols" diff --git a/README.md b/README.md index 8a0b8cd..6a31c78 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ cargo install sigit | pip | `pip install sigit-code` | | uv | `uvx --from sigit-code sigit` | | npm | `npm install -g @smbcloud/sigit` | +| NuGet | `dotnet tool install --global SiGit.Code` | ## First run diff --git a/nuget/.gitignore b/nuget/.gitignore new file mode 100644 index 0000000..6279b8e --- /dev/null +++ b/nuget/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +dist/ +native/ +NuGet.Config diff --git a/nuget/sigit/Program.cs b/nuget/sigit/Program.cs new file mode 100644 index 0000000..c043c2e --- /dev/null +++ b/nuget/sigit/Program.cs @@ -0,0 +1,114 @@ +using System.ComponentModel; +using System.Diagnostics; +using System.Runtime.InteropServices; + +internal static class Program +{ + private const string CommandName = "sigit"; + + public static async Task Main(string[] arguments) + { + try + { + string executablePath = ResolveExecutablePath(); + EnsureExecutablePermissions(executablePath); + return await RunAsync(executablePath, arguments); + } + catch (Exception exception) when ( + exception is FileNotFoundException or + PlatformNotSupportedException or + Win32Exception) + { + Console.Error.WriteLine($"{CommandName}: {exception.Message}"); + return 1; + } + } + + private static string ResolveExecutablePath() + { + string runtimeIdentifier = GetRuntimeIdentifier(); + string executableName = OperatingSystem.IsWindows() ? $"{CommandName}.exe" : CommandName; + string executablePath = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, "native", runtimeIdentifier, executableName)); + + if (!File.Exists(executablePath)) + { + throw new FileNotFoundException( + $"The native {CommandName} executable for '{runtimeIdentifier}' is not bundled in this package.", + executablePath); + } + + return executablePath; + } + + private static string GetRuntimeIdentifier() + { + string operatingSystem = OperatingSystem.IsWindows() + ? "windows" + : OperatingSystem.IsMacOS() + ? "darwin" + : OperatingSystem.IsLinux() + ? "linux" + : throw new PlatformNotSupportedException( + $"{CommandName} does not support this operating system through the .NET tool package."); + + string architecture = RuntimeInformation.ProcessArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => throw new PlatformNotSupportedException( + $"{CommandName} does not support the '{RuntimeInformation.ProcessArchitecture}' architecture through the .NET tool package."), + }; + + return $"{operatingSystem}-{architecture}"; + } + + private static void EnsureExecutablePermissions(string executablePath) + { + if (OperatingSystem.IsWindows()) + { + return; + } + + UnixFileMode currentMode = File.GetUnixFileMode(executablePath); + UnixFileMode requiredMode = UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherExecute; + + if ((currentMode & requiredMode) == requiredMode) + { + return; + } + + File.SetUnixFileMode(executablePath, currentMode | requiredMode); + } + + private static async Task RunAsync(string executablePath, IReadOnlyList arguments) + { + // siGit Code picks its mode from whether stdin is a TTY: a terminal gets + // the ratatui chat UI, a pipe gets the ACP server. Leaving all three + // streams unredirected makes the child inherit this process's handles, + // so that detection sees the real terminal (or the real pipe from an + // editor) rather than something this launcher introduced. + ProcessStartInfo startInfo = new(executablePath) + { + UseShellExecute = false, + WorkingDirectory = Environment.CurrentDirectory, + }; + + foreach (string argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + using Process process = Process.Start(startInfo) + ?? throw new Win32Exception($"Failed to start '{CommandName}'."); + + await process.WaitForExitAsync(); + return process.ExitCode; + } +} diff --git a/nuget/sigit/README.md b/nuget/sigit/README.md new file mode 100644 index 0000000..7d43699 --- /dev/null +++ b/nuget/sigit/README.md @@ -0,0 +1,65 @@ +# siGit Code for .NET + +`sigit` is the command-line interface for [siGit Code](https://sigit.si), an AI coding agent that +runs on your machine. + +## Install + +```sh +dotnet tool install --global SiGit.Code +``` + +## Update + +```sh +dotnet tool update --global SiGit.Code +``` + +## Run + +```sh +sigit +``` + +In a terminal that opens the chat UI. When stdin is a pipe, the same binary speaks the Agent +Client Protocol (ACP) over stdio instead, which is how editors such as Zed and VS Code drive it. + +First run downloads a GGUF model, so expect a wait of a gigabyte or two before the first reply. +On macOS the model cache is shared with the siGit Code desktop app, so a model either app has +already fetched is reused. + +## Platform support + +This .NET tool bundles native `sigit` binaries for: + +- macOS `arm64`, `x64` +- Linux `arm64`, `x64` (glibc) +- Windows `arm64`, `x64` + +The terminal chat UI is Unix-only. On Windows the binary runs in ACP mode, so use it through an +editor rather than directly. + +Because all six binaries ship in one package, the install is large. If that matters, the Homebrew, +npm, and PyPI packages each download only the binary for your platform. + +## Other installation methods + +- Cargo: `cargo install sigit` +- npm: `npm install -g @smbcloud/sigit` +- pip: `pip install sigit-code` +- Homebrew: `brew tap getsigit/tap && brew trust --tap getsigit/tap && brew install sigit` +- GitHub Releases: + +## Source + +- Repository: +- Website: +- Issues: + +## License + +[Apache 2.0](https://github.com/getsigit/sigit/blob/main/LICENSE) + +## Copyright + +© 2026 PT Sigit Mitra Bangun ([siGit Code & Deploy](https://sigit.si)). diff --git a/nuget/sigit/SiGit.Code.csproj b/nuget/sigit/SiGit.Code.csproj new file mode 100644 index 0000000..7be8d32 --- /dev/null +++ b/nuget/sigit/SiGit.Code.csproj @@ -0,0 +1,41 @@ + + + Exe + net8.0 + + Major + enable + enable + true + sigit + SiGit.Code + 0.0.0-local + $(PackageVersion) + Seto Elkahfi + siGit Code — ACP-compatible AI coding agent. Sí, git. + README.md + Apache-2.0 + https://sigit.si + https://github.com/getsigit/sigit + cli;sigit;ai;coding-agent;llm;acp;developer-tools;dotnet-tool + true + + + + + + + +