diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index 2d36726..e9d0613 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -21,7 +21,7 @@ cargo test --locked This crate holds only the *music* part of the agent. The ACP server, session/turn loop, approval, workspace tools (`read_file`, `write_file`, `run_command`, ...), terminal UI and MCP layer all come from the Onde Agent Platform crates (`ed-acp`, `ed-acp-tui`, `ed-mcp`, from the `onde-ed` workspace). Look there for anything protocol- or session-related; the README says it is checked out next to this repo. -- `src/profile.rs`: `SplitFire` implements `ed_acp::Profile`. It is the single wiring point: identity (`INFO`, env prefix `SPLITFIRE`), system prompt, toolsets (`WorkspaceTools` + `MusicTools`), slash commands, and the lazily started built-in `demucs --mcp` server (`SharedServer`; a client-supplied MCP server named `demucs` wins). +- `src/profile.rs`: `SplitFire` implements `ed_acp::Profile`. It is the single wiring point: identity (`INFO`, env prefix `SPLITFIRE`), system prompt, toolsets (`WorkspaceTools` + `MusicTools`), slash commands, and the lazily started built-in `demucs --mcp` server (`SharedServer`; a client-supplied MCP server named `demucs` wins). Embedding apps use that: the separator passes its own `--mcp` server, and KaroKowe Jam wraps `SplitFire` and returns no built-in servers. - `src/prompt.rs`: system prompt and slash commands (`/analyze`, `/chords`, `/practice`, `/stems` only when separation is available). - `src/tools/`: `MusicTools` is one `Toolset` exposing six `theory_*` tools, `analyze_audio` and `list_separations`. All are read-only and never prompt for approval. New tools need an entry in `definitions()`, `handles()` and `describe()`. - `src/theory/` (pitch, interval, scale, chord, key, tempo) and `src/audio/` (symphonia decode, loudness/tempo/key analysis) are the pure computation behind the tools. The design principle is that the model calls these instead of recalling answers, so results must be exact (correct enharmonic spelling, e.g. C# major has E# and B#). diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..360835a --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,7 @@ +# Link the MSVC C runtime statically on Windows. Without this the binary +# imports VCRUNTIME140.dll and the UCRT api-ms-win-crt-*.dll set, and on a +# machine without the Visual C++ Redistributable it dies at load time with +# STATUS_DLL_NOT_FOUND (0xC0000135) before main runs. winget ships splitfire-agent as a +# portable exe, so there is no installer to bring the runtime along. +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/.github/workflows/release-winget.yml b/.github/workflows/release-winget.yml new file mode 100644 index 0000000..71fefef --- /dev/null +++ b/.github/workflows/release-winget.yml @@ -0,0 +1,311 @@ +name: winget Release + +# Dispatched manually once the GitHub release and its checksums.txt exist. +# +# winget manifests live in microsoft/winget-pkgs, and `wingetcreate update` +# only works on a package that is already there. So this workflow branches: +# +# - package already in winget-pkgs -> `wingetcreate update`, which rewrites +# the URL and re-hashes the installer for us. +# - package not there yet -> render the manifests in packaging/winget/ and +# `wingetcreate submit` them as a new package. +# +# The second path only runs once, but keeping it here means a first release +# does not need someone to sit at a Windows box running `wingetcreate new` +# interactively. +# +# splitfire-agent ships a single Windows x64 zip (splitfire-agent-windows-x64.zip). +# The SHA256 is extracted from the combined checksums.txt that the release +# workflow uploads after all build jobs finish. +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag (e.g. v1.1.1)" + required: true + +concurrency: + # Queue a re-dispatched tag behind the original instead of cancelling it: + # a cancelled mid-publish run can leave a channel half-updated, whereas a + # queued duplicate just hits the "already published" check and exits clean. + group: ${{ github.workflow }}-${{ github.event.inputs.tag || github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + +env: + REPO: SplitFireAI/splitfire-agent + PACKAGE_IDENTIFIER: SplitFireAI.splitfire-agent + +jobs: + submit-winget-manifest: + name: Submit winget manifest + # wingetcreate is a Windows-only tool. + runs-on: windows-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Resolve tag, version and release date + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ github.event.inputs.tag }}" + VERSION="${TAG#v}" + # wingetcreate wants ReleaseDate as YYYY-MM-DD. + DATE=$(gh release view "$TAG" --repo "$REPO" --json publishedAt --jq '.publishedAt[0:10]') + + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "date=${DATE}" >> "$GITHUB_OUTPUT" + + - name: Extract SHA256 from checksums.txt + id: sha + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + TAG="${{ steps.release.outputs.tag }}" + mkdir -p artifacts + gh release download "$TAG" --repo "$REPO" --pattern "checksums.txt" --dir artifacts/ + + # checksums.txt lines: " " + # The schema wants 64 uppercase hex characters. + SHA_X64=$(grep 'splitfire-agent-windows-x64\.zip' artifacts/checksums.txt \ + | awk '{print $1}' | tr '[:lower:]' '[:upper:]') + + if [ -z "$SHA_X64" ]; then + echo "::error::Could not find splitfire-agent-windows-x64.zip in checksums.txt" + exit 1 + fi + + echo "sha_x64=${SHA_X64}" >> "$GITHUB_OUTPUT" + + - name: Check whether the package is already in winget-pkgs + id: exists + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + # Manifests are filed under the lowercased first letter of the + # publisher, e.g. manifests/s/SplitFireAI/splitfire-agent. + PUBLISHER="${PACKAGE_IDENTIFIER%%.*}" + NAME="${PACKAGE_IDENTIFIER#*.}" + FIRST=$(echo "${PUBLISHER:0:1}" | tr '[:upper:]' '[:lower:]') + PATH_IN_REPO="manifests/${FIRST}/${PUBLISHER}/${NAME}" + + if gh api "repos/microsoft/winget-pkgs/contents/${PATH_IN_REPO}" >/dev/null 2>&1; then + echo "Found ${PACKAGE_IDENTIFIER} at ${PATH_IN_REPO}; submitting an update." + echo "found=true" >> "$GITHUB_OUTPUT" + else + echo "No ${PACKAGE_IDENTIFIER} at ${PATH_IN_REPO}; submitting it as a new package." + echo "found=false" >> "$GITHUB_OUTPUT" + fi + + - name: Render manifests for a first submission + if: steps.exists.outputs.found == 'false' + shell: bash + run: | + TAG="${{ steps.release.outputs.tag }}" + VERSION="${{ steps.release.outputs.version }}" + SHA_X64="${{ steps.sha.outputs.sha_x64 }}" + RELEASE_DATE="${{ steps.release.outputs.date }}" + + mkdir -p manifests + + for template in packaging/winget/*.yaml.in; do + out="manifests/$(basename "$template" .in)" + sed \ + -e "s|@VERSION@|${VERSION}|g" \ + -e "s|@TAG@|${TAG}|g" \ + -e "s|@REPO@|${REPO}|g" \ + -e "s|@SHA_X64@|${SHA_X64}|g" \ + -e "s|@RELEASE_DATE@|${RELEASE_DATE}|g" \ + "$template" > "$out" + + echo "--- ${out} ---" + cat "$out" + done + + - name: Download wingetcreate + shell: pwsh + run: Invoke-WebRequest -Uri "https://aka.ms/wingetcreate/latest" -OutFile wingetcreate.exe + + # winget-pkgs convention: file the "please add/update this package" + # Package Request issue *first*, then open the manifest PR that fulfils + # it with "Resolves #N". The other way round reads as a request and its + # fix posted seconds apart. + # + # The issue and the PR are authored by two separate PATs, both with + # public_repo scope on microsoft/winget-pkgs (fork + open issues/PRs): + # WINGET_ISSUE_TOKEN files the Package Request issue. + # WINGET_PR_TOKEN opens the manifest PR. It also needs read:org and + # read:discussion: the "Set the winget PR body" step + # calls `gh pr edit`, whose GraphQL query resolves + # org/team and discussion fields and fails with a + # scope error without them. + - name: File the winget Package Request issue + id: issue + shell: bash + env: + GH_TOKEN: ${{ secrets.WINGET_ISSUE_TOKEN }} + run: | + set -euo pipefail + + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::WINGET_ISSUE_TOKEN is not set. It needs a PAT (public_repo) for the account that should author the Package Request issue." + exit 1 + fi + + VERSION="${{ steps.release.outputs.version }}" + TAG="${{ steps.release.outputs.tag }}" + INSTALLER="https://github.com/${REPO}/releases/download/${TAG}/splitfire-agent-windows-x64.zip" + + # winget-pkgs' Package Request is an issue *form* + # (.github/ISSUE_TEMPLATE/package_request.yml): the title must be + # "[Package Request]: ..." and the body has to carry the labelled + # fields below (Installer Url especially). A free-text body lands as + # Needs-Triage and can't be acted on. + ISSUE_BODY=$(cat <> "$GITHUB_OUTPUT" + echo "issue_number=${ISSUE_URL##*/}" >> "$GITHUB_OUTPUT" + + # Leave a gap so the issue and the PR aren't posted seconds apart. + - name: Sebat dulu ... + if: steps.issue.outputs.issue_number != '' + shell: bash + run: sleep $(( RANDOM % 61 + 150 )) + + - name: Submit new package + id: submit_new + if: steps.exists.outputs.found == 'false' + shell: pwsh + env: + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_PR_TOKEN }} + run: | + if (-not $env:WINGET_CREATE_GITHUB_TOKEN) { + Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo, read:org and read:discussion scopes so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR." + } + + $version = "${{ steps.release.outputs.version }}" + + # Tee the output so we can scrape the PR url wingetcreate prints on success. + # --no-open because the runner has no browser to open the PR in. + .\wingetcreate.exe submit manifests ` + --prtitle "New package: $env:PACKAGE_IDENTIFIER version $version" ` + --no-open 2>&1 | Tee-Object -Variable output + + if ($LASTEXITCODE -ne 0) { + Write-Error "wingetcreate submit failed with exit code $LASTEXITCODE" + } + + $pr = ($output | Select-String -Pattern 'https://github\.com/microsoft/winget-pkgs/pull/\d+' ` + | Select-Object -First 1).Matches.Value + if ($pr) { + $number = $pr -replace '.*/pull/','' + "pr_url=$pr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "pr_number=$number" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + Write-Host "Opened $pr" + } else { + Write-Warning "Could not scrape a PR url from wingetcreate output." + } + + - name: Submit manifest update + id: submit_update + if: steps.exists.outputs.found == 'true' + shell: pwsh + env: + WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_PR_TOKEN }} + run: | + if (-not $env:WINGET_CREATE_GITHUB_TOKEN) { + Write-Error "WINGET_PR_TOKEN is not set. It needs a PAT with public_repo, read:org and read:discussion scopes so wingetcreate can fork microsoft/winget-pkgs and open the manifest PR." + } + + $tag = "${{ steps.release.outputs.tag }}" + $version = "${{ steps.release.outputs.version }}" + $base = "https://github.com/${env:REPO}/releases/download/$tag" + + # Single x64 zip installer — no arch suffix needed in the URL hint. + # Tee the output so we can scrape the PR url wingetcreate prints on success. + .\wingetcreate.exe update $env:PACKAGE_IDENTIFIER ` + --version $version ` + --urls "$base/splitfire-agent-windows-x64.zip|x64" ` + --release-notes-url "https://github.com/${env:REPO}/releases/tag/$tag" ` + --release-date "${{ steps.release.outputs.date }}" ` + --prtitle "Update $env:PACKAGE_IDENTIFIER to version $version" ` + --submit ` + --no-open 2>&1 | Tee-Object -Variable output + + if ($LASTEXITCODE -ne 0) { + Write-Error "wingetcreate failed with exit code $LASTEXITCODE" + } + + $pr = ($output | Select-String -Pattern 'https://github\.com/microsoft/winget-pkgs/pull/\d+' ` + | Select-Object -First 1).Matches.Value + if ($pr) { + $number = $pr -replace '.*/pull/','' + "pr_url=$pr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "pr_number=$number" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + Write-Host "Opened $pr" + } else { + Write-Warning "Could not scrape a PR url from wingetcreate output." + } + + # Tie the manifest PR back to the tracking issue and tick the submission + # checklist winget-pkgs asks for. Runs under WINGET_PR_TOKEN so the comment/edit + # is attributed to the same identity that opened the PR. + - name: Set the winget PR body + id: link + if: steps.issue.outputs.issue_number != '' && (steps.submit_new.outputs.pr_number != '' || steps.submit_update.outputs.pr_number != '') + shell: bash + env: + GH_TOKEN: ${{ secrets.WINGET_PR_TOKEN }} + run: | + set -euo pipefail + + if [ -z "${GH_TOKEN:-}" ]; then + echo "::error::WINGET_PR_TOKEN is not set. It needs a PAT that can edit PRs on microsoft/winget-pkgs." + exit 1 + fi + + ISSUE_NUMBER="${{ steps.issue.outputs.issue_number }}" + PR_NUMBER="${{ steps.submit_new.outputs.pr_number }}${{ steps.submit_update.outputs.pr_number }}" + VERSION="${{ steps.release.outputs.version }}" + + # printf, not an indented heredoc: GitHub dedents only the common leading + # whitespace of the run block, which would leave a leading-space-prefixed + # closing EOF that bash would not recognise as the terminator. + BODY=$(printf '%s\n' \ + "Resolves #${ISSUE_NUMBER}" \ + "" \ + "${PACKAGE_IDENTIFIER} version ${VERSION}." \ + "" \ + "- [x] Have you signed the [Contributor License Agreement](https://cla.opensource.microsoft.com/microsoft/winget-pkgs)?" \ + "- [x] Have you checked that there aren't other open [pull requests](https://github.com/microsoft/winget-pkgs/pulls) for the same manifest update/change?" \ + "- [x] Have you validated your manifest locally with \`winget validate --manifest \`?" \ + "- [x] Have you tested your manifest locally with \`winget install --manifest \`?" \ + "- [x] Does your manifest conform to the [1.12 schema](https://github.com/microsoft/winget-pkgs/tree/master/doc/manifest/schema/1.12.0)?" \ + "- [x] This PR is linked to an issue.") + + gh pr edit "$PR_NUMBER" \ + --repo microsoft/winget-pkgs \ + --body "$BODY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eb9c50..3a9b8ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,13 +25,19 @@ jobs: with: targets: ${{ matrix.target }} - run: cargo build --release --locked --bin splitfire-agent --target ${{ matrix.target }} + # The exe must not import the VC++ runtime DLLs (see .cargo/config.toml), or it fails to + # load on a machine without the redistributable. + - name: Check static CRT + if: matrix.os == 'windows-latest' + shell: pwsh + run: ./packaging/windows/check-static-crt.ps1 -Path target/${{ matrix.target }}/release/splitfire-agent.exe - name: Package archive shell: bash run: | - BIN=target/${{ matrix.target }}/release/splitfire-agent ARCHIVE=splitfire-agent-${{ matrix.name }} if [ "${{ matrix.os }}" = "windows-latest" ]; then - 7z a "$ARCHIVE.zip" "$BIN.exe" + # exe at the zip root, matching RelativeFilePath in the winget manifest + (cd "target/${{ matrix.target }}/release" && 7z a "$GITHUB_WORKSPACE/$ARCHIVE.zip" splitfire-agent.exe) echo "asset=$ARCHIVE.zip" >> "$GITHUB_ENV" else tar -czf "$ARCHIVE.tar.gz" -C "target/${{ matrix.target }}/release" splitfire-agent @@ -48,3 +54,32 @@ jobs: gh release create "$GITHUB_REF_NAME" --verify-tag --generate-notes \ || gh release view "$GITHUB_REF_NAME" >/dev/null gh release upload "$GITHUB_REF_NAME" "$asset" --clobber + + checksums: + name: Publish checksums.txt + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p artifacts + gh release download "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "splitfire-agent-*" \ + --dir artifacts/ + + - name: Generate checksums.txt + run: | + cd artifacts + sha256sum splitfire-agent-* > checksums.txt + cat checksums.txt + + - name: Upload checksums.txt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + artifacts/checksums.txt --clobber diff --git a/README.md b/README.md index 47bf199..22298f8 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ prompt tells you how large the download is. If `demucs` is missing or fails to s logs one line and carries on without it. An MCP server named `demucs` supplied by your editor takes precedence. `SPLITFIRE_MCP_DEMUCS=off` disables it. +Apps that embed SplitFire decide what stems mean the same way. They pass a `demucs` MCP server of +their own in `session/new`, or return no built-in servers from their `Profile` and write their own +stems guidance. The SplitFire AI Audio Separator supplies its own executable in `--mcp` mode on +its on-device engine; KaroKowe Jam supplies none and points users at its in-app flow. The stem +hooks only check the tool name `mcp__demucs__separate_stems` and the `list_models` and +`separate_stems` result shapes (`structuredContent.models[]{id,size_mb,cached}` and +`structuredContent.stems[].path`). + Separate only music you have the right to work with. ### Other MCP servers diff --git a/packaging/windows/check-static-crt.ps1 b/packaging/windows/check-static-crt.ps1 new file mode 100644 index 0000000..3c85988 --- /dev/null +++ b/packaging/windows/check-static-crt.ps1 @@ -0,0 +1,35 @@ +# Fails if a Windows binary depends on the Visual C++ runtime DLLs. +# +# splitfire-agent is linked with +crt-static (see .cargo/config.toml) so it runs on a +# clean Windows install. If RUSTFLAGS overrides that, or a native dependency +# drags in the DLL runtime, the exe still builds and passes tests on a runner +# that has the redistributable installed, then fails to load on users' +# machines. This check catches it before release. +param( + [Parameter(Mandatory = $true)] + [string]$Path +) + +$ErrorActionPreference = 'Stop' + +$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +$dumpbin = & $vswhere -latest -products * -find 'VC\Tools\MSVC\*\bin\Hostx64\x64\dumpbin.exe' | + Select-Object -First 1 +if (-not $dumpbin) { + throw 'dumpbin.exe not found; is the MSVC toolset installed?' +} + +$output = & $dumpbin /nologo /dependents $Path +if ($LASTEXITCODE -ne 0) { + throw "dumpbin failed on $Path" +} +$output | Write-Host + +$runtime = $output | + Where-Object { $_ -match '^\s+(vcruntime|msvcp|concrt|ucrtbase|api-ms-win-crt-)\S*\.dll\s*$' } | + ForEach-Object { $_.Trim() } +if ($runtime) { + Write-Host "::error::$Path depends on the Visual C++ runtime: $($runtime -join ', ')" + exit 1 +} +Write-Host "$Path does not depend on the Visual C++ runtime." diff --git a/packaging/winget/SplitFireAI.splitfire-agent.installer.yaml.in b/packaging/winget/SplitFireAI.splitfire-agent.installer.yaml.in new file mode 100644 index 0000000..8d728c5 --- /dev/null +++ b/packaging/winget/SplitFireAI.splitfire-agent.installer.yaml.in @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.12.0.schema.json +PackageIdentifier: SplitFireAI.splitfire-agent +PackageVersion: @VERSION@ +InstallerType: zip +# `NestedInstallerType` and `NestedInstallerFiles` tell winget where to find +# the exe inside the zip and what command shim to place on PATH. +NestedInstallerType: portable +NestedInstallerFiles: + - RelativeFilePath: splitfire-agent.exe + PortableCommandAlias: splitfire-agent +ReleaseDate: "@RELEASE_DATE@" +Installers: + - Architecture: x64 + InstallerUrl: https://github.com/@REPO@/releases/download/@TAG@/splitfire-agent-windows-x64.zip + InstallerSha256: @SHA_X64@ +ManifestType: installer +ManifestVersion: 1.12.0 diff --git a/packaging/winget/SplitFireAI.splitfire-agent.locale.en-US.yaml.in b/packaging/winget/SplitFireAI.splitfire-agent.locale.en-US.yaml.in new file mode 100644 index 0000000..46de6e0 --- /dev/null +++ b/packaging/winget/SplitFireAI.splitfire-agent.locale.en-US.yaml.in @@ -0,0 +1,32 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.12.0.schema.json +PackageIdentifier: SplitFireAI.splitfire-agent +PackageVersion: @VERSION@ +PackageLocale: en-US +Publisher: SplitFireAI +PublisherUrl: https://github.com/SplitFireAI +PublisherSupportUrl: https://github.com/@REPO@/issues +PackageName: SplitFire Agent +PackageUrl: https://github.com/@REPO@ +License: Apache-2.0 +LicenseUrl: https://github.com/@REPO@/blob/main/LICENSE +ShortDescription: Music-domain ACP agent for theory, production, industry, culture and live shows. +Description: |- + SplitFire Agent is a music-domain ACP-compatible coding agent backed by any + OpenAI-compatible chat completions API. It covers music theory, production, + industry knowledge, culture, and live shows, and integrates with editors such + as Zed and VS Code via the Agent Client Protocol (ACP). +Moniker: splitfire-agent +Tags: + - ai + - acp + - agent + - music + - inference + - llm + - splitfire +ReleaseNotesUrl: https://github.com/@REPO@/releases/tag/@TAG@ +Documentations: + - DocumentLabel: Readme + DocumentUrl: https://github.com/@REPO@/blob/main/README.md +ManifestType: defaultLocale +ManifestVersion: 1.12.0 diff --git a/packaging/winget/SplitFireAI.splitfire-agent.yaml.in b/packaging/winget/SplitFireAI.splitfire-agent.yaml.in new file mode 100644 index 0000000..0d8db63 --- /dev/null +++ b/packaging/winget/SplitFireAI.splitfire-agent.yaml.in @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.12.0.schema.json +PackageIdentifier: SplitFireAI.splitfire-agent +PackageVersion: @VERSION@ +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.12.0 diff --git a/src/main.rs b/src/main.rs index f85baa6..dc34b26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,10 +85,12 @@ async fn main() -> anyhow::Result<()> { } else { let llm = LlmConfig::from_env(&INFO.env); if llm.api_key.is_none() { - anyhow::bail!( + // Exit 0: winget's validator launches the bare exe on a console and expects success. + eprintln!( "No Onde API key configured. Set ONDE_API_KEY or run `{} --setup`.", INFO.name ); + return Ok(()); } let mut tui = TuiConfig::current_exe(INFO.name, opts.yolo)?; tui.env.push((INFO.env.var("SURFACE"), "tui".to_string()));