Compare commits

...
9 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 4d034cc5c8 chore(release): bump workspace version to 0.1.21
Release / publish release (push) Successful in 3s
Release / prepare draft release (push) Successful in 8s
Release / build (x86_64-apple-darwin) (push) Successful in 1h4m24s
Release / build (x86_64-unknown-linux-gnu) (push) Successful in 26m25s
Release / build (aarch64-apple-darwin) (push) Successful in 1h8m15s
Release / build (aarch64-unknown-linux-gnu) (push) Successful in 27m25s
Release / build (x86_64-pc-windows-msvc) (push) Successful in 20m43s
The first release built and published on Gitea since the forge moved back.
THIRD-PARTY-NOTICES carries the new workspace crate versions; no dependency changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:54:48 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ddecaea075 Return the forge to Gitea: releases, self-update, links, CI
The GitHub account is gone again, so git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI
is the canonical repo. Reverse f7a4cb6:

- .gitea/workflows/release.yml: restore the Gitea edition retired in f7a4cb6
  (draft release, per-target upload, SHA256SUMS, then publish). The Windows
  leg runs on the windows-amd64 label the current runner advertises.
- .github/workflows/{release,warm-cache}.yml: drop them; they target hosted
  GitHub runners and softprops, and Gitea ignores them once .gitea exists.
- kigi-env: update_base_url defaults to the Gitea Releases API again.
- install.sh / install.ps1: resolve releases from git.zacharyzhang.com.
- feedback, /docs, SECURITY.md, frontend worker + site: repo, issues and
  raw-file links point at git.zacharyzhang.com.
- docs/RELEASE.md: Gitea edition of the checklist with the current runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:25:59 -04:00
ZacharyZhang-NY 963a3383df docs: add AGit pull request instructions 2026-10-09 19:46:41 +00:00
ZacharyZhang-NY cdeae6db89 feat(videos): add kigi-graph-bauhaus HyperFrames promo video project
Keep env.md (local MiniMax API key read by tts.py) out of the repo.
2026-10-09 15:43:10 -04:00
ZacharyZhang-NYandClaude Opus 5.5 719638875b docs(agents): upstream references come from the local grok-build clone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:42:01 -04:00
雷电芽衣 e63d36468b chore(legal): regenerate THIRD-PARTY-NOTICES for the 0.1.20 workspace version
Release / build (aarch64-apple-darwin) (push) Canceled after 0s
Release / build (aarch64-unknown-linux-gnu) (push) Canceled after 0s
Release / build (x86_64-unknown-linux-gnu) (push) Failing after 6s
Release / build (x86_64-apple-darwin) (push) Canceled after 0s
Release / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Release / publish GitHub Release (push) Canceled after 0s
2026-09-29 22:02:32 -04:00
雷电芽衣 389a931cad chore(release): bump workspace version to 0.1.20 2026-09-29 22:02:32 -04:00
雷电芽衣 9a2a9eac99 feat(login): custom provider logins fetch and select models
After the base URL, name, and key steps, the login now opens a model
selection screen instead of blindly saving. The listing fetch runs
concurrently with a manual-id input — a WAF challenge page or a 404 no
longer passes validation silently: a base without a /vN tail is probed
once as {base}/v1 and the working base is adopted, an anthropic-wire
listing that 401s on x-api-key retries once with Bearer (relays gate
GET /models on Bearer while /messages takes the key header), and a
failed fetch shows its reason while manual add stays usable. The chosen
ids persist as [platforms.<name>] models = [...]; catalog assembly
synthesizes selected ids the listing never served (before [model.*]
overrides so they compose) and filters unselected provider entries
(after overrides, authoritative in the provider namespace). Provider-
owned entries keep their declared wire against donor backend
inheritance, and listing errors quote line/column only — a reflected
body could carry the key.
2026-09-29 22:00:20 -04:00
雷电芽衣 1d631a07fa fix(tui): welcome auth inputs paste with Ctrl/Super+V
The API-key, custom-provider, and loopback paste boxes matched
KeyCode::Char without checking modifiers, so Ctrl+V inserted a literal
'v' and no chord read the host clipboard. The arms now read the
clipboard through is_paste_key/is_inline_paste_key (the picker.rs
pattern) and the char fallthrough is gated by is_text_input_key, so
Ctrl+letter chords no longer type.
2026-09-29 21:57:19 -04:00
71 changed files with 3611 additions and 469 deletions

No files matched your search

+375
View File
@@ -0,0 +1,375 @@
name: Release
# PRD F8, Gitea edition. On a v* tag push, build the single-file kigi binary
# for the five supported targets, package kigi-<version>-<target-triple>.
# {tar.gz|zip} (binary + LICENSE + NOTICE + THIRD-PARTY-NOTICES), and publish
# them as a Gitea Release alongside SHA256SUMS. install.sh, install.ps1 and
# auto_update::release_asset_name() all resolve these exact names — keep the
# naming in lockstep with them.
#
# RUNNERS. Gitea has no hosted runners; every label below must be a runner you
# registered yourself (docs/RELEASE.md has the register commands):
#
# ubuntu-latest Linux x86_64, container mode — the label the Linux server's
# runner already advertises. Builds both Linux targets;
# aarch64 is a cross-compile, so this leg installs the
# aarch64 GNU toolchain. Because the image is a bare
# runner-image, the Linux legs provision their own apt
# packages and rustup (see the provision steps below).
# Registering a dedicated `linux-x86_64:docker://...` runner
# instead is fine — swap the three labels back if you do.
# macos-arm64 Apple Silicon Mac, host mode. Builds both Darwin targets;
# x86_64 is a cross-compile against the same SDK. macOS
# CANNOT be built anywhere else — Apple's SDK licence
# restricts it to Apple hardware.
# windows-amd64 Windows x86_64, host mode, with VS 2022 Build Tools
# (C++ workload). Needed because the tree links ring,
# mimalloc, zstd-sys and libz-sys against a static MSVC CRT
# (-C target-feature=+crt-static).
#
# SHELLS. Every step names its shell explicitly and the Windows leg never uses
# bash. On a normal Windows box `bash` resolves to WSL (not Git Bash), and
# `pwsh` (PowerShell 7) is usually absent — so Windows steps use `powershell`,
# which is the built-in 5.1. Keep those steps 5.1-compatible: no `&&`, no
# ternary, no null-coalescing.
#
# Note: because this file exists, Gitea ignores .github/workflows entirely.
#
# PUBLISH ORDER. The release is created as a draft and only made public once
# SHA256SUMS is attached, so `releases/latest` never hands the installer a
# release whose checksum manifest is still missing.
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
# Matches the version pinned by the dotslash launcher in bin/protoc. That
# launcher is not used here: it has no Windows entry and needs a `cargo
# install dotslash` first, so every platform fetches protoc directly.
PROTOC_VERSION: "29.3"
jobs:
prepare:
name: prepare draft release
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Create or reuse draft release
id: draft
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
run: |
set -eu
# The runner-image is bare; jq and curl are both used below.
if ! command -v jq >/dev/null || ! command -v curl >/dev/null; then
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends jq curl ca-certificates
fi
# Idempotent so a re-run of a failed tag build reuses the same draft
# instead of erroring on a duplicate tag_name.
existing="$(curl -sS -H "Authorization: token $TOKEN" \
"$API/releases/tags/$GITHUB_REF_NAME")"
release_id="$(printf '%s' "$existing" | jq -r '.id // empty')"
if [ -z "$release_id" ]; then
prerelease=false
case "$GITHUB_REF_NAME" in *-*) prerelease=true ;; esac
created="$(curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"$GITHUB_REF_NAME\",\"name\":\"$GITHUB_REF_NAME\",\"draft\":true,\"prerelease\":$prerelease}" \
"$API/releases")"
release_id="$(printf '%s' "$created" | jq -r '.id')"
echo "Created draft release $release_id (prerelease=$prerelease)"
else
echo "Reusing release $release_id for $GITHUB_REF_NAME"
fi
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
build:
name: build (${{ matrix.target }})
needs: prepare
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
- target: aarch64-unknown-linux-gnu
runner: ubuntu-latest
protoc: linux-x86_64
ext: tar.gz
linux: true
cross_gnu: true
- target: aarch64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-apple-darwin
runner: macos-arm64
protoc: osx-aarch_64
ext: tar.gz
- target: x86_64-pc-windows-msvc
runner: windows-amd64
protoc: win64
ext: zip
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
# ── linux container provisioning ──────────────────────────────────────
# The Linux legs run in a bare gitea runner-image, which ships git and
# node for the JavaScript actions but no compiler, no unzip (protoc) and
# no rustup. Host-mode macOS and Windows already have their toolchains
# installed, so this step is Linux-only.
- name: Provision Linux build deps
if: matrix.linux
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
export DEBIAN_FRONTEND=noninteractive
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq --no-install-recommends \
build-essential pkg-config ca-certificates curl unzip tar jq
# ── protoc ────────────────────────────────────────────────────────────
- name: Install protoc (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
url="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-${{ matrix.protoc }}.zip"
curl -fsSL -o protoc.zip "$url"
rm -rf "$HOME/protoc"
unzip -q -o protoc.zip -d "$HOME/protoc"
chmod +x "$HOME/protoc/bin/protoc"
echo "$HOME/protoc/bin" >> "$GITHUB_PATH"
- name: Install protoc (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v$env:PROTOC_VERSION/protoc-$env:PROTOC_VERSION-${{ matrix.protoc }}.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
$dest = Join-Path $env:USERPROFILE "protoc"
if (Test-Path $dest) { Remove-Item $dest -Recurse -Force }
Expand-Archive protoc.zip -DestinationPath $dest -Force
Add-Content -Path $env:GITHUB_PATH -Value (Join-Path $dest "bin")
# ── toolchain ─────────────────────────────────────────────────────────
# rust-toolchain.toml pins the channel (1.97.0) and preinstalls both
# Linux targets; `rustup show` materialises exactly that.
- name: Install toolchain (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: |
set -eu
# No-op on the macOS host runner, which has rustup installed already.
# `--default-toolchain none` leaves the channel choice to
# rust-toolchain.toml, which `rustup show` then materialises.
if ! command -v rustup >/dev/null; then
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \
| sh -s -- -y --no-modify-path --profile minimal --default-toolchain none
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
export PATH="$HOME/.cargo/bin:$PATH"
fi
rustup show
rustup target add ${{ matrix.target }}
- name: Install toolchain (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
rustup show
rustup target add ${{ matrix.target }}
# ring, mimalloc, zstd-sys and libz-sys are all cc-rs builds, so the
# cross target needs its own C toolchain — cc-rs picks these up from the
# target-suffixed vars.
- name: Install aarch64 cross toolchain
if: matrix.cross_gnu
shell: bash
run: |
set -eu
SUDO=""; command -v sudo >/dev/null && SUDO=sudo
$SUDO apt-get update -qq
$SUDO apt-get install -y -qq gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
{
echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc"
echo "CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc"
echo "CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++"
echo "AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar"
} >> "$GITHUB_ENV"
# ── build ─────────────────────────────────────────────────────────────
- name: Build kigi (unix)
if: matrix.ext == 'tar.gz'
shell: bash
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Build kigi (windows)
if: matrix.ext == 'zip'
shell: powershell
run: |
$ErrorActionPreference = "Stop"
cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
# ── package, upload, checksum ─────────────────────────────────────────
# Archives go straight to the release rather than through the artifact
# store: they are large, and the release is the only place install.sh
# can read them from anyway. Only the checksum line travels as an
# artifact, for the publish job to concatenate.
- name: Package and upload (unix)
if: matrix.ext == 'tar.gz'
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@${archive}" \
"$API/releases/$RELEASE_ID/assets?name=${archive}" > /dev/null
echo "Uploaded $archive"
if command -v sha256sum >/dev/null; then
sha256sum "$archive" > "checksum-${{ matrix.target }}.txt"
else
shasum -a 256 "$archive" > "checksum-${{ matrix.target }}.txt"
fi
cat "checksum-${{ matrix.target }}.txt"
- name: Package and upload (windows)
if: matrix.ext == 'zip'
shell: powershell
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
$ErrorActionPreference = "Stop"
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = Join-Path $env:RUNNER_TEMP "staging"
if (Test-Path $staging) { Remove-Item $staging -Recurse -Force }
New-Item -ItemType Directory -Force -Path $staging | Out-Null
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive -Force
# System32\curl.exe, not PowerShell's `curl` alias for
# Invoke-WebRequest, which cannot do multipart uploads.
$api = "$env:API/releases/$env:RELEASE_ID/assets?name=$archive"
& "$env:SystemRoot\System32\curl.exe" -fsS -X POST `
-H "Authorization: token $env:TOKEN" `
-F "attachment=@$archive" $api
if ($LASTEXITCODE -ne 0) { throw "asset upload failed ($LASTEXITCODE)" }
Write-Host "Uploaded $archive"
# sha256sum's exact format, so the publish job can just concatenate.
# ASCII keeps the BOM out — a BOM would corrupt the first hash.
$hash = (Get-FileHash -Algorithm SHA256 -Path $archive).Hash.ToLowerInvariant()
"$hash $archive" | Out-File -FilePath "checksum-${{ matrix.target }}.txt" -Encoding ascii
Get-Content "checksum-${{ matrix.target }}.txt"
- uses: actions/upload-artifact@v3
with:
name: checksum-${{ matrix.target }}
path: checksum-${{ matrix.target }}.txt
if-no-files-found: error
publish:
name: publish release
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v3
with:
path: checksums
- name: Assemble SHA256SUMS
shell: bash
run: |
set -eu
# download-artifact v3 nests each artifact in its own directory. The
# Windows line arrives CRLF-terminated; strip CR so every line is LF
# for install.sh's `read -r hash name`.
find checksums -name 'checksum-*.txt' -exec cat {} + \
| tr -d '\r' | grep -v '^$' | sort -k2 > SHA256SUMS
lines="$(wc -l < SHA256SUMS)"
if [ "$lines" -ne 5 ]; then
echo "expected 5 checksum lines, got $lines" >&2
cat SHA256SUMS >&2
exit 1
fi
cat SHA256SUMS
- name: Attach SHA256SUMS and publish
shell: bash
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
run: |
set -eu
curl -fsS -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@SHA256SUMS" \
"$API/releases/$RELEASE_ID/assets?name=SHA256SUMS" > /dev/null
# Flip the draft public only now that every asset install.sh looks
# for is attached.
curl -fsS -X PATCH \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"$API/releases/$RELEASE_ID" > /dev/null
echo "Published $GITHUB_REF_NAME"
-147
View File
@@ -1,147 +0,0 @@
name: Release
# PRD F8: on tag push v*, build the single-file kigi binary for the five
# supported targets, package per-target archives named
# kigi-<version>-<target-triple>.{tar.gz|zip} (binary + LICENSE + NOTICE +
# THIRD-PARTY-NOTICES), generate SHA256SUMS, and publish everything as a
# GitHub Release. install.sh / install.ps1 and the in-app self-updater
# (kigi-update) both resolve these exact asset names — keep the naming in
# lockstep with auto_update::release_asset_name().
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build:
name: build (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
# macos-14 runners are arm64; the x86_64 slice is a cross-compile
# against the same SDK (macos-13 Intel runners are deprecated).
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Check tag matches workspace version
shell: bash
run: |
tag_version="${GITHUB_REF_NAME#v}"
cargo_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)"
if [ "$tag_version" != "$cargo_version" ]; then
echo "Tag $GITHUB_REF_NAME does not match workspace version $cargo_version" >&2
exit 1
fi
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
# Windows cannot exec the dotslash launcher directly (no shebang
# support → os error 193), and the build script's documented fallback
# is a protoc on PATH — install the same version the dotslash file
# pins (v29.3).
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
- name: Package archive (tar.gz)
if: runner.os != 'Windows'
shell: bash
run: |
version="${GITHUB_REF_NAME#v}"
archive="kigi-${version}-${{ matrix.target }}.tar.gz"
staging="$(mktemp -d)"
cp "target/${{ matrix.target }}/release-dist/kigi" "$staging/kigi"
cp LICENSE "$staging/"
[ -f NOTICE ] && cp NOTICE "$staging/"
for f in THIRD-PARTY-NOTICES THIRD-PARTY-NOTICES.md; do
[ -f "$f" ] && cp "$f" "$staging/"
done
tar -C "$staging" -czf "$archive" .
shasum -a 256 "$archive" || sha256sum "$archive"
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Package archive (zip)
if: runner.os == 'Windows'
shell: pwsh
run: |
$version = $env:GITHUB_REF_NAME.TrimStart("v")
$archive = "kigi-$version-${{ matrix.target }}.zip"
$staging = New-Item -ItemType Directory -Path (Join-Path $env:RUNNER_TEMP "staging")
Copy-Item "target/${{ matrix.target }}/release-dist/kigi.exe" (Join-Path $staging "kigi.exe")
Copy-Item LICENSE $staging
if (Test-Path NOTICE) { Copy-Item NOTICE $staging }
foreach ($f in @("THIRD-PARTY-NOTICES", "THIRD-PARTY-NOTICES.md")) {
if (Test-Path $f) { Copy-Item $f $staging }
}
Compress-Archive -Path (Join-Path $staging "*") -DestinationPath $archive
Get-FileHash -Algorithm SHA256 $archive
Add-Content -Path $env:GITHUB_ENV -Value "ARCHIVE=$archive"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARCHIVE }}
path: ${{ env.ARCHIVE }}
if-no-files-found: error
release:
name: publish GitHub Release
needs: build
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Generate SHA256SUMS
working-directory: dist
run: |
ls -l
sha256sum kigi-* > SHA256SUMS
cat SHA256SUMS
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
prerelease: ${{ contains(github.ref_name, '-') }}
generate_release_notes: true
fail_on_unmatched_files: true
-85
View File
@@ -1,85 +0,0 @@
name: Warm build cache
# Release builds run on TAG refs, and GitHub Actions cache isolation only
# lets a run restore caches created on its OWN ref or the DEFAULT branch.
# No workflow ran on `main`, so every release compiled the whole workspace
# cold on all five targets (~50 min wall clock, gated by Windows). This
# workflow builds the same `release-dist` profile on `main` so tag builds
# restore a warm default-branch cache.
#
# Triggers: dependency-affecting pushes to main (each release's version-bump
# commit warms the cache for the NEXT release), a weekly refresh so the
# cache never hits GitHub's 7-day unused-eviction, and manual dispatch.
#
# The setup steps mirror release.yml's build job (toolchain, target,
# dotslash/protoc, rust-cache key) — keep them in lockstep, or the cache
# key won't match and releases go back to cold builds.
on:
push:
branches: ["main"]
paths:
- "Cargo.lock"
- "Cargo.toml"
- "rust-toolchain.toml"
- ".github/workflows/warm-cache.yml"
schedule:
- cron: "17 5 * * 1"
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
concurrency:
group: warm-cache
cancel-in-progress: true
jobs:
warm:
name: warm (${{ matrix.target }})
strategy:
fail-fast: false
matrix:
include:
- target: aarch64-apple-darwin
os: macos-14
- target: x86_64-apple-darwin
os: macos-14
- target: x86_64-unknown-linux-gnu
os: ubuntu-24.04
- target: aarch64-unknown-linux-gnu
os: ubuntu-24.04-arm
- target: x86_64-pc-windows-msvc
os: windows-2022
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rust-toolchain.toml)
run: rustup show
- name: Add build target
run: rustup target add ${{ matrix.target }}
- name: Install dotslash (protoc launcher)
run: cargo install dotslash --locked
- name: Install protoc (Windows PATH fallback)
if: runner.os == 'Windows'
shell: pwsh
run: |
$url = "https://github.com/protocolbuffers/protobuf/releases/download/v29.3/protoc-29.3-win64.zip"
Invoke-WebRequest -Uri $url -OutFile protoc.zip
Expand-Archive protoc.zip -DestinationPath "$env:USERPROFILE\protoc"
Add-Content $env:GITHUB_PATH "$env:USERPROFILE\protoc\bin"
# Same key as release.yml so tag builds restore this cache verbatim.
- uses: Swatinem/rust-cache@v2
with:
key: ${{ matrix.target }}
- name: Build kigi (release-dist)
run: cargo build --profile release-dist -p kigi-bin --locked --target ${{ matrix.target }}
+9
View File
@@ -8,3 +8,12 @@ node_modules/
# Runner-setup prompt; holds a Gitea registration token once filled in.
/prompt.txt
# Local test credentials
cred.md
# Upstream xai-org/grok-build reference clone
/grok-build/
# Local API keys (MiniMax TTS key for kigi-videos)
env.md
+28 -4
View File
@@ -20,6 +20,11 @@ selectively ported through upstream `77cd7eb` (2026-08-25, ≈v1.0.10) in
the 0.1.13 cycle, through `3794978` (2026-09-09, ≈v1.0.24) in the 0.1.15
cycle, through `a28ee2b` (2026-09-17, ≈v1.0.35) in the 0.1.16 cycle, and
through `2bdd1d6` (2026-09-29, ≈v1.0.45) in the 0.1.19 cycle.
The local release is 0.1.20; it adds custom providers declared by base
URL (`[platforms.<name>]`, see CUSTOM PROVIDERS below).
Every upstream reference comes from the local clone at `./grok-build`
(gitignored, never committed): `git -C grok-build pull` before a sync,
then read upstream code and history there, not from the web.
Upstream is daily "Synced from monorepo" snapshots —
the commit BODIES carry per-change bullet lists, and
`crates/codegen/xai-grok-shell/changelogs/` maps releases to dates;
@@ -63,7 +68,7 @@ a design against the 0.1.15 teardown), MCP servers on protocol 2026-07-28
## Hard constraints
- **Zero egress**: only `auth.kimi.com`, `api.kimi.com`, `api.moonshot.cn`,
`api.moonshot.ai`, GitHub Releases, user-configured MCP servers, credentialed
`api.moonshot.ai`, Gitea Releases (`git.zacharyzhang.com`), user-configured MCP servers, credentialed
provider endpoints, and `models.dev` (metadata refresh, ONLY when an enabled
platform's `/models` lacks it — `wire_serves_metadata=false`, never
Kimi/Moonshot, `KIGI_MODELS_DEV_URL=0` disables). No telemetry, no analytics,
@@ -485,8 +490,27 @@ client-side, no backend surface.
instead of `x-api-key` (this also repaired `anthropic` and `minimax`).
Login: picker rows `custom-openai` / `custom-anthropic` are not loginable
(`AuthMethodKind::Unknown`); the TUI asks base URL, name (default from the
host), key, then `save_custom_provider` writes config.toml (through
`config_write_dest`) and auth.json, and `authenticate("custom:<name>")`
host), key, then opens a MODEL-SELECTION screen (`custom_entry.rs`
`CustomSelectState`, welcome `AuthMode::CustomProviderSelect`): the listing
fetch (`fetch_listing_for_login`) runs concurrently with the manual-id input
— both visible at once, failure shows its reason — space toggles fetched
rows, enter adds a typed id or finishes (a finish pressed mid-fetch waits for
it: `finish_requested`), and the chosen ids persist as `[platforms.<name>]
models = [...]` via `save_custom_provider`. The listing fetch probes
`{base}/v1` once when `{base}/models` 404s or answers unparsable (a WAF
challenge is HTTP 200 HTML) and the base has no `/vN` tail, adopting the
probed base for the saved definition; anthropic-wire listings that 401 on
`x-api-key` retry once with `Bearer` (relays gate `GET /models` on Bearer
while `/messages` takes `x-api-key`); listing errors never quote response
content (line/column only — a reflected body could carry the key). Catalog
assembly honors the allowlist in `selection.rs`: `synthesize_selected` (BEFORE
`[model.*]` overrides, so an override composes with a synthesized entry)
builds wire-default entries for selected ids the listing never served, and
`filter_unselected` (AFTER overrides) is authoritative in the provider's
namespace — even a `[model."name/x"]` entry for an unselected id is dropped.
Provider-owned entries never take donor `api_backend` inheritance in
`resolve_model_list` (the declared wire wins); no `models` key = the whole
listing (the declarative path). `authenticate("custom:<name>")` then
validates `GET {base}/models` (401/403 = rejected key, other non-2xx and network
errors surface). Limits: context window 256K unless the listing serves
`context_length` (Anthropic: `max_input_tokens`); no models.dev enrichment; a
@@ -616,4 +640,4 @@ client-side, no backend surface.
grok.com references live only there and in `kigi-sampler`/proxy types.
- M2: server-side search/fetch (F5), command parity with kimi-cli 1.49.0
(F6), one-time `~/.kimi/config.toml` import (F7), F9 smoke list, perf CI.
- M3: GitHub Releases distribution, install scripts, self-update (F8).
- M3: Gitea Releases distribution, install scripts, self-update (F8).
+14
View File
@@ -20,3 +20,17 @@ cargo deny check advisories
first-party endpoints lives in `crates/codegen/kigi-env`.
- Upstream syncs are evaluated commit-by-commit and cherry-picked manually;
see the PRD §8.1 policy (`git range-diff` records the adopted set).
## Opening a pull request on git.zacharyzhang.com
Forking is disabled on this server. Open pull requests with AGit instead:
1. Register an account at https://git.zacharyzhang.com
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
```bash
git clone https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI.git
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
```
3. To update the pull request, push again with the same `topic`.
Generated
+62 -62
View File
@@ -5442,7 +5442,7 @@ dependencies = [
[[package]]
name = "kigi-acp-lib"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5456,7 +5456,7 @@ dependencies = [
[[package]]
name = "kigi-agent"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"chrono",
"dirs 6.0.0",
@@ -5486,7 +5486,7 @@ dependencies = [
[[package]]
name = "kigi-agent-lifecycle"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"async-trait",
"tokio",
@@ -5495,7 +5495,7 @@ dependencies = [
[[package]]
name = "kigi-auth"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"async-trait",
"http 1.4.2",
@@ -5508,7 +5508,7 @@ dependencies = [
[[package]]
name = "kigi-bin"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"clap",
@@ -5543,7 +5543,7 @@ dependencies = [
[[package]]
name = "kigi-chat-state"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"indexmap",
"kigi-compaction",
@@ -5560,7 +5560,7 @@ dependencies = [
[[package]]
name = "kigi-codebase-graph"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"ahash",
"clap",
@@ -5596,7 +5596,7 @@ dependencies = [
[[package]]
name = "kigi-compaction"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"async-trait",
@@ -5609,7 +5609,7 @@ dependencies = [
[[package]]
name = "kigi-config"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"base64",
"blake3",
@@ -5632,7 +5632,7 @@ dependencies = [
[[package]]
name = "kigi-config-types"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"indexmap",
@@ -5646,7 +5646,7 @@ dependencies = [
[[package]]
name = "kigi-crash-handler"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"backtrace",
"libc",
@@ -5657,7 +5657,7 @@ dependencies = [
[[package]]
name = "kigi-env"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"tracing",
"url",
@@ -5665,7 +5665,7 @@ dependencies = [
[[package]]
name = "kigi-fast-worktree"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"bytes",
@@ -5697,7 +5697,7 @@ dependencies = [
[[package]]
name = "kigi-file-utils"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"aws-config",
@@ -5721,7 +5721,7 @@ dependencies = [
[[package]]
name = "kigi-fsnotify"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"criterion",
"dunce",
@@ -5742,7 +5742,7 @@ dependencies = [
[[package]]
name = "kigi-gix-status"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"gix",
"kigi-test-utils",
@@ -5752,7 +5752,7 @@ dependencies = [
[[package]]
name = "kigi-hooks"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"fastrand",
"kigi-config",
@@ -5771,7 +5771,7 @@ dependencies = [
[[package]]
name = "kigi-hooks-plugins-types"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"serde",
"serde_json",
@@ -5779,7 +5779,7 @@ dependencies = [
[[package]]
name = "kigi-http"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"kigi-auth",
"kigi-log",
@@ -5794,7 +5794,7 @@ dependencies = [
[[package]]
name = "kigi-hunk-tracker"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"chrono",
"dunce",
@@ -5815,14 +5815,14 @@ dependencies = [
[[package]]
name = "kigi-interjection-core"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"serde",
]
[[package]]
name = "kigi-log"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"chrono",
@@ -5840,7 +5840,7 @@ dependencies = [
[[package]]
name = "kigi-markdown"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anstyle",
"anstyle-lossy",
@@ -5864,14 +5864,14 @@ dependencies = [
[[package]]
name = "kigi-markdown-core"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "kigi-mcp"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"async-trait",
@@ -5908,7 +5908,7 @@ dependencies = [
[[package]]
name = "kigi-memory"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"arc-swap",
@@ -5942,7 +5942,7 @@ dependencies = [
[[package]]
name = "kigi-mermaid"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"fontdb",
"image",
@@ -5960,7 +5960,7 @@ dependencies = [
[[package]]
name = "kigi-models"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"kigi-env",
"serde",
@@ -5970,7 +5970,7 @@ dependencies = [
[[package]]
name = "kigi-pager-minimal"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"chrono",
"crossterm",
@@ -5987,7 +5987,7 @@ dependencies = [
[[package]]
name = "kigi-pager-pty-harness"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -6012,7 +6012,7 @@ dependencies = [
[[package]]
name = "kigi-pager-render"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"anstyle",
@@ -6064,7 +6064,7 @@ dependencies = [
[[package]]
name = "kigi-paths"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"camino",
"serde",
@@ -6074,7 +6074,7 @@ dependencies = [
[[package]]
name = "kigi-prompt-queue"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"serde",
"serde_json",
@@ -6082,7 +6082,7 @@ dependencies = [
[[package]]
name = "kigi-proto-build"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"pbjson-build",
@@ -6093,7 +6093,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-inline"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"ansi-width",
"anstyle-parse 0.2.7",
@@ -6110,7 +6110,7 @@ dependencies = [
[[package]]
name = "kigi-ratatui-textarea"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"arboard",
"chrono",
@@ -6131,7 +6131,7 @@ dependencies = [
[[package]]
name = "kigi-sampler"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"async-openai",
"async-stream",
@@ -6155,7 +6155,7 @@ dependencies = [
[[package]]
name = "kigi-sampling-types"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"assert_matches",
"async-openai",
@@ -6172,7 +6172,7 @@ dependencies = [
[[package]]
name = "kigi-sandbox"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"chrono",
@@ -6194,7 +6194,7 @@ dependencies = [
[[package]]
name = "kigi-secrets"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"regex",
"serde_json",
@@ -6232,7 +6232,7 @@ dependencies = [
[[package]]
name = "kigi-shell"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6370,7 +6370,7 @@ dependencies = [
[[package]]
name = "kigi-shell-base"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"chrono",
@@ -6395,7 +6395,7 @@ dependencies = [
[[package]]
name = "kigi-sqlite-journal"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"libc",
"rusqlite",
@@ -6406,7 +6406,7 @@ dependencies = [
[[package]]
name = "kigi-subagent-resolution"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"kigi-sampling-types",
"kigi-tool-types",
@@ -6421,7 +6421,7 @@ dependencies = [
[[package]]
name = "kigi-system-power"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"windows-sys 0.59.0",
"zbus",
@@ -6429,7 +6429,7 @@ dependencies = [
[[package]]
name = "kigi-test-support"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "kigi-test-utils"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"runfiles",
"tracing",
@@ -6460,11 +6460,11 @@ dependencies = [
[[package]]
name = "kigi-token-estimation"
version = "0.1.19"
version = "0.1.21"
[[package]]
name = "kigi-tool-protocol"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"kigi-tool-types",
"serde",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "kigi-tool-runtime"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"async-trait",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "kigi-tool-types"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"minijinja",
"schemars 1.2.1",
@@ -6503,7 +6503,7 @@ dependencies = [
[[package]]
name = "kigi-tools"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"arc-swap",
@@ -6580,7 +6580,7 @@ dependencies = [
[[package]]
name = "kigi-tools-api"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"kigi-proto-build",
"kigi-tool-protocol",
@@ -6593,11 +6593,11 @@ dependencies = [
[[package]]
name = "kigi-tracing-macros"
version = "0.1.19"
version = "0.1.21"
[[package]]
name = "kigi-tty-utils"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"libc",
"nix 0.30.1",
@@ -6607,7 +6607,7 @@ dependencies = [
[[package]]
name = "kigi-tui"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"ansi-to-tui",
@@ -6694,7 +6694,7 @@ dependencies = [
[[package]]
name = "kigi-update"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"dunce",
@@ -6723,14 +6723,14 @@ dependencies = [
[[package]]
name = "kigi-version"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"semver",
]
[[package]]
name = "kigi-workspace"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -6809,7 +6809,7 @@ dependencies = [
[[package]]
name = "kigi-workspace-types"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"base64",
"chrono",
@@ -8843,7 +8843,7 @@ dependencies = [
[[package]]
name = "ptyctl"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"alacritty_terminal",
"anyhow",
@@ -8861,7 +8861,7 @@ dependencies = [
[[package]]
name = "ptyctl-cli"
version = "0.1.19"
version = "0.1.21"
dependencies = [
"anyhow",
"axum",
+1 -1
View File
@@ -76,7 +76,7 @@ members = [
]
[workspace.package]
version = "0.1.19"
version = "0.1.21"
edition = "2024"
license = "Apache-2.0"
+1 -1
View File
@@ -14,7 +14,7 @@ Modifications relative to the upstream work (Apache License 2.0 §4(b) notice):
subscription-proxy endpoints.
- Endpoints re-targeted at the Kimi Code subscription API
(api.kimi.com/coding/v1), the Moonshot open platform (api.moonshot.cn/v1,
api.moonshot.ai/v1), and GitHub Releases for self-update.
api.moonshot.ai/v1), and Gitea Releases (git.zacharyzhang.com) for self-update.
This product includes code ported from openai/codex and sst/opencode; see
crates/codegen/kigi-tools/THIRD_PARTY_NOTICES.md for the license terms and
+1 -1
View File
@@ -1,7 +1,7 @@
# Security Policy
Kigi is a community build; report vulnerabilities on the
[Kigi issue tracker](https://github.com/ZacharyZhang-NY/Kigi-CLI/issues).
[Kigi issue tracker](https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues).
Issues there are public, so please leave out any detail that would make a
report directly exploitable — open with the affected area and impact, and a
maintainer will follow up for the rest.
+63 -62
View File
@@ -16436,68 +16436,68 @@ limitations under the License.
Applies to:
- [kigi-proto-build 0.1.19](https://crates.io/crates/kigi-proto-build)
- [kigi-acp-lib 0.1.19](https://crates.io/crates/kigi-acp-lib)
- [kigi-agent 0.1.19](https://crates.io/crates/kigi-agent)
- [kigi-agent-lifecycle 0.1.19](https://crates.io/crates/kigi-agent-lifecycle)
- [kigi-auth 0.1.19](https://crates.io/crates/kigi-auth)
- [kigi-bin 0.1.19](https://crates.io/crates/kigi-bin)
- [kigi-chat-state 0.1.19](https://crates.io/crates/kigi-chat-state)
- [kigi-codebase-graph 0.1.19](https://crates.io/crates/kigi-codebase-graph)
- [kigi-config 0.1.19](https://crates.io/crates/kigi-config)
- [kigi-config-types 0.1.19](https://crates.io/crates/kigi-config-types)
- [kigi-crash-handler 0.1.19](https://crates.io/crates/kigi-crash-handler)
- [kigi-env 0.1.19](https://crates.io/crates/kigi-env)
- [kigi-fast-worktree 0.1.19](https://crates.io/crates/kigi-fast-worktree)
- [kigi-file-utils 0.1.19](https://crates.io/crates/kigi-file-utils)
- [kigi-fsnotify 0.1.19](https://crates.io/crates/kigi-fsnotify)
- [kigi-gix-status 0.1.19](https://crates.io/crates/kigi-gix-status)
- [kigi-hooks 0.1.19](https://crates.io/crates/kigi-hooks)
- [kigi-hooks-plugins-types 0.1.19](https://crates.io/crates/kigi-hooks-plugins-types)
- [kigi-http 0.1.19](https://crates.io/crates/kigi-http)
- [kigi-hunk-tracker 0.1.19](https://crates.io/crates/kigi-hunk-tracker)
- [kigi-log 0.1.19](https://crates.io/crates/kigi-log)
- [kigi-markdown 0.1.19](https://crates.io/crates/kigi-markdown)
- [kigi-markdown-core 0.1.19](https://crates.io/crates/kigi-markdown-core)
- [kigi-mcp 0.1.19](https://crates.io/crates/kigi-mcp)
- [kigi-memory 0.1.19](https://crates.io/crates/kigi-memory)
- [kigi-models 0.1.19](https://crates.io/crates/kigi-models)
- [kigi-pager-minimal 0.1.19](https://crates.io/crates/kigi-pager-minimal)
- [kigi-pager-pty-harness 0.1.19](https://crates.io/crates/kigi-pager-pty-harness)
- [kigi-pager-render 0.1.19](https://crates.io/crates/kigi-pager-render)
- [kigi-paths 0.1.19](https://crates.io/crates/kigi-paths)
- [kigi-prompt-queue 0.1.19](https://crates.io/crates/kigi-prompt-queue)
- [kigi-ratatui-inline 0.1.19](https://crates.io/crates/kigi-ratatui-inline)
- [kigi-ratatui-textarea 0.1.19](https://crates.io/crates/kigi-ratatui-textarea)
- [kigi-sampler 0.1.19](https://crates.io/crates/kigi-sampler)
- [kigi-sampling-types 0.1.19](https://crates.io/crates/kigi-sampling-types)
- [kigi-sandbox 0.1.19](https://crates.io/crates/kigi-sandbox)
- [kigi-secrets 0.1.19](https://crates.io/crates/kigi-secrets)
- [kigi-proto-build 0.1.21](https://crates.io/crates/kigi-proto-build)
- [kigi-acp-lib 0.1.21](https://crates.io/crates/kigi-acp-lib)
- [kigi-agent 0.1.21](https://crates.io/crates/kigi-agent)
- [kigi-agent-lifecycle 0.1.21](https://crates.io/crates/kigi-agent-lifecycle)
- [kigi-auth 0.1.21](https://crates.io/crates/kigi-auth)
- [kigi-bin 0.1.21](https://crates.io/crates/kigi-bin)
- [kigi-chat-state 0.1.21](https://crates.io/crates/kigi-chat-state)
- [kigi-codebase-graph 0.1.21](https://crates.io/crates/kigi-codebase-graph)
- [kigi-config 0.1.21](https://crates.io/crates/kigi-config)
- [kigi-config-types 0.1.21](https://crates.io/crates/kigi-config-types)
- [kigi-crash-handler 0.1.21](https://crates.io/crates/kigi-crash-handler)
- [kigi-env 0.1.21](https://crates.io/crates/kigi-env)
- [kigi-fast-worktree 0.1.21](https://crates.io/crates/kigi-fast-worktree)
- [kigi-file-utils 0.1.21](https://crates.io/crates/kigi-file-utils)
- [kigi-fsnotify 0.1.21](https://crates.io/crates/kigi-fsnotify)
- [kigi-gix-status 0.1.21](https://crates.io/crates/kigi-gix-status)
- [kigi-hooks 0.1.21](https://crates.io/crates/kigi-hooks)
- [kigi-hooks-plugins-types 0.1.21](https://crates.io/crates/kigi-hooks-plugins-types)
- [kigi-http 0.1.21](https://crates.io/crates/kigi-http)
- [kigi-hunk-tracker 0.1.21](https://crates.io/crates/kigi-hunk-tracker)
- [kigi-log 0.1.21](https://crates.io/crates/kigi-log)
- [kigi-markdown 0.1.21](https://crates.io/crates/kigi-markdown)
- [kigi-markdown-core 0.1.21](https://crates.io/crates/kigi-markdown-core)
- [kigi-mcp 0.1.21](https://crates.io/crates/kigi-mcp)
- [kigi-memory 0.1.21](https://crates.io/crates/kigi-memory)
- [kigi-models 0.1.21](https://crates.io/crates/kigi-models)
- [kigi-pager-minimal 0.1.21](https://crates.io/crates/kigi-pager-minimal)
- [kigi-pager-pty-harness 0.1.21](https://crates.io/crates/kigi-pager-pty-harness)
- [kigi-pager-render 0.1.21](https://crates.io/crates/kigi-pager-render)
- [kigi-paths 0.1.21](https://crates.io/crates/kigi-paths)
- [kigi-prompt-queue 0.1.21](https://crates.io/crates/kigi-prompt-queue)
- [kigi-ratatui-inline 0.1.21](https://crates.io/crates/kigi-ratatui-inline)
- [kigi-ratatui-textarea 0.1.21](https://crates.io/crates/kigi-ratatui-textarea)
- [kigi-sampler 0.1.21](https://crates.io/crates/kigi-sampler)
- [kigi-sampling-types 0.1.21](https://crates.io/crates/kigi-sampling-types)
- [kigi-sandbox 0.1.21](https://crates.io/crates/kigi-sandbox)
- [kigi-secrets 0.1.21](https://crates.io/crates/kigi-secrets)
- [kigi-shared 0.0.0](https://crates.io/crates/kigi-shared)
- [kigi-shell 0.1.19](https://crates.io/crates/kigi-shell)
- [kigi-shell-base 0.1.19](https://crates.io/crates/kigi-shell-base)
- [kigi-sqlite-journal 0.1.19](https://crates.io/crates/kigi-sqlite-journal)
- [kigi-subagent-resolution 0.1.19](https://crates.io/crates/kigi-subagent-resolution)
- [kigi-system-power 0.1.19](https://crates.io/crates/kigi-system-power)
- [kigi-test-support 0.1.19](https://crates.io/crates/kigi-test-support)
- [kigi-token-estimation 0.1.19](https://crates.io/crates/kigi-token-estimation)
- [kigi-tools 0.1.19](https://crates.io/crates/kigi-tools)
- [kigi-tools-api 0.1.19](https://crates.io/crates/kigi-tools-api)
- [kigi-tracing-macros 0.1.19](https://crates.io/crates/kigi-tracing-macros)
- [kigi-tty-utils 0.1.19](https://crates.io/crates/kigi-tty-utils)
- [kigi-tui 0.1.19](https://crates.io/crates/kigi-tui)
- [kigi-update 0.1.19](https://crates.io/crates/kigi-update)
- [kigi-version 0.1.19](https://crates.io/crates/kigi-version)
- [kigi-workspace 0.1.19](https://crates.io/crates/kigi-workspace)
- [kigi-workspace-types 0.1.19](https://crates.io/crates/kigi-workspace-types)
- [ptyctl 0.1.19](https://crates.io/crates/ptyctl)
- [ptyctl-cli 0.1.19](https://crates.io/crates/ptyctl-cli)
- [kigi-compaction 0.1.19](https://crates.io/crates/kigi-compaction)
- [kigi-interjection-core 0.1.19](https://crates.io/crates/kigi-interjection-core)
- [kigi-test-utils 0.1.19](https://crates.io/crates/kigi-test-utils)
- [kigi-tool-protocol 0.1.19](https://crates.io/crates/kigi-tool-protocol)
- [kigi-tool-runtime 0.1.19](https://crates.io/crates/kigi-tool-runtime)
- [kigi-tool-types 0.1.19](https://crates.io/crates/kigi-tool-types)
- [kigi-shell 0.1.21](https://crates.io/crates/kigi-shell)
- [kigi-shell-base 0.1.21](https://crates.io/crates/kigi-shell-base)
- [kigi-sqlite-journal 0.1.21](https://crates.io/crates/kigi-sqlite-journal)
- [kigi-subagent-resolution 0.1.21](https://crates.io/crates/kigi-subagent-resolution)
- [kigi-system-power 0.1.21](https://crates.io/crates/kigi-system-power)
- [kigi-test-support 0.1.21](https://crates.io/crates/kigi-test-support)
- [kigi-token-estimation 0.1.21](https://crates.io/crates/kigi-token-estimation)
- [kigi-tools 0.1.21](https://crates.io/crates/kigi-tools)
- [kigi-tools-api 0.1.21](https://crates.io/crates/kigi-tools-api)
- [kigi-tracing-macros 0.1.21](https://crates.io/crates/kigi-tracing-macros)
- [kigi-tty-utils 0.1.21](https://crates.io/crates/kigi-tty-utils)
- [kigi-tui 0.1.21](https://crates.io/crates/kigi-tui)
- [kigi-update 0.1.21](https://crates.io/crates/kigi-update)
- [kigi-version 0.1.21](https://crates.io/crates/kigi-version)
- [kigi-workspace 0.1.21](https://crates.io/crates/kigi-workspace)
- [kigi-workspace-types 0.1.21](https://crates.io/crates/kigi-workspace-types)
- [ptyctl 0.1.21](https://crates.io/crates/ptyctl)
- [ptyctl-cli 0.1.21](https://crates.io/crates/ptyctl-cli)
- [kigi-compaction 0.1.21](https://crates.io/crates/kigi-compaction)
- [kigi-interjection-core 0.1.21](https://crates.io/crates/kigi-interjection-core)
- [kigi-test-utils 0.1.21](https://crates.io/crates/kigi-test-utils)
- [kigi-tool-protocol 0.1.21](https://crates.io/crates/kigi-tool-protocol)
- [kigi-tool-runtime 0.1.21](https://crates.io/crates/kigi-tool-runtime)
- [kigi-tool-types 0.1.21](https://crates.io/crates/kigi-tool-types)
- [agent-client-protocol 0.10.4](https://github.com/agentclientprotocol/rust-sdk)
- [allocator-api2 0.2.21](https://github.com/zakarumych/allocator-api2)
- [anes 0.1.6](https://github.com/zrzka/anes-rs)
@@ -16772,7 +16772,7 @@ limitations under the License.
Applies to:
- [kigi-mermaid 0.1.19](https://crates.io/crates/kigi-mermaid)
- [kigi-mermaid 0.1.21](https://crates.io/crates/kigi-mermaid)
```
Roboto-Regular.ttf
@@ -25334,3 +25334,4 @@ the following restrictions:
3. This notice may not be removed or altered from any source distribution.
```
+1 -1
View File
@@ -21,7 +21,7 @@ pub struct KigiEndpoints {
pub const PRODUCTION_ENDPOINTS: KigiEndpoints = KigiEndpoints {
coding_api_base_url: "https://api.kimi.com/coding/v1",
oauth_host: "https://auth.kimi.com",
update_base_url: "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases",
update_base_url: "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases",
upgrade_page_url: "https://www.kimi.com/code/",
};
+12
View File
@@ -165,6 +165,18 @@ pub fn normalize_base_url(raw: &str) -> Result<String, CustomProviderError> {
Ok(url.to_owned())
}
/// A manually added model id: no whitespace, no controls, bounded length.
pub fn validate_model_id(id: &str) -> Result<(), CustomProviderError> {
let err = |reason: &str| Err(CustomProviderError::Name(reason.to_owned()));
if id.is_empty() || id.len() > 128 {
return err("model id must be 1 to 128 characters");
}
if id.chars().any(|c| c.is_whitespace() || c.is_control()) {
return err("model id must not contain whitespace");
}
Ok(())
}
/// A valid provider name derived from a host.
pub fn default_name(base_url: &str) -> String {
let host = base_url
+17
View File
@@ -1756,6 +1756,23 @@ pub struct WireThinkEfforts {
pub default_effort: Option<String>,
}
impl WireModel {
/// An id-only model, as if the listing served no metadata.
pub fn bare(id: String) -> Self {
Self {
id,
context_length: 0,
supports_reasoning: false,
supports_image_in: false,
supports_video_in: false,
display_name: None,
max_output_tokens: 0,
supports_thinking_type: None,
think_efforts: None,
}
}
}
/// `GET {base}/models` response envelope.
#[derive(Debug, Clone, serde::Deserialize)]
pub struct WireModelsResponse {
@@ -812,6 +812,9 @@ pub struct PlatformCredentialConfig {
/// `"openai"` or `"anthropic"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub api: Option<String>,
/// A custom provider's login-curated model allowlist; absent = whole listing.
#[serde(skip_serializing_if = "Option::is_none")]
pub models: Option<Vec<String>>,
}
/// Resolve the API key for an API-key registry platform with injected env
@@ -2628,6 +2631,11 @@ pub(crate) fn resolve_model_list(
}
resolved = prefetched;
}
crate::agent::custom_providers::synthesize_selected(
&mut resolved,
&cfg.platforms,
platform_keys.custom(),
);
let mut explicit_api_backend_keys = std::collections::HashSet::new();
for (key, model_override) in &cfg.config_models {
let had_base = resolved.contains_key(key);
@@ -2656,8 +2664,18 @@ pub(crate) fn resolve_model_list(
);
resolved.insert(key.clone(), entry);
}
crate::agent::custom_providers::filter_unselected(
&mut resolved,
&cfg.platforms,
platform_keys.custom(),
);
{
let default_cw = DEFAULT_CONTEXT_WINDOW;
let custom_prefixes: Vec<String> = platform_keys
.custom()
.iter()
.map(|c| format!("{}/", c.provider.name))
.collect();
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
resolved
.values()
@@ -2680,7 +2698,11 @@ pub(crate) fn resolve_model_list(
entry.info.context_window = *donor_cw;
}
// An explicit `[model.X] api_backend` wins even when it is the default.
// A provider-owned entry keeps its declared wire too.
if !explicit_api_backend_keys.contains(key.as_str())
&& !custom_prefixes
.iter()
.any(|p| key.as_str().starts_with(p.as_str()))
&& entry.info.api_backend == ApiBackend::default()
&& *donor_backend != ApiBackend::default()
{
@@ -13,32 +13,17 @@ pub(crate) fn fetch_models_blocking(
credentialed: &CredentialedProvider,
) -> Result<Vec<ModelEntryConfig>, BackendError> {
let provider = &credentialed.provider;
let url = provider.models_url();
tracing::info!(provider = %provider.name, api = provider.api.as_str(), %url, "fetching custom provider models");
let client = crate::http::shared_blocking_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
return Err(BackendError::RequestFailed { status, body });
}
let body = response.text()?;
let listing = match provider.api {
CustomApi::OpenAi => kigi_models::parse_openai_listing(&body),
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(&body),
};
let wire = listing.map_err(|e| BackendError::RequestFailed {
tracing::info!(provider = %provider.name, api = provider.api.as_str(), url = %provider.models_url(), "fetching custom provider models");
let body = listing_body(provider, credentialed.key())?;
let wire = parse_listing(provider, &body).map_err(|e| BackendError::RequestFailed {
// No error content: serde quotes the offending value, a reflected key risk.
status: 200,
body: format!("{} listing parse failed: {e}", provider.api.as_str()),
body: format!(
"{} listing parse failed at line {} column {}",
provider.api.as_str(),
e.line(),
e.column()
),
})?;
if wire.is_empty() {
tracing::warn!(provider = %provider.name, "custom provider listed no models");
@@ -46,6 +31,153 @@ pub(crate) fn fetch_models_blocking(
Ok(wire.into_iter().map(|w| entry(provider, w)).collect())
}
/// The listing GET with the dialect's key header; status-gated body.
fn listing_body(provider: &CustomProvider, key: &str) -> Result<String, BackendError> {
let url = provider.models_url();
let client = crate::http::shared_blocking_client();
let send = |bearer_fallback: bool| {
let request = match (provider.api, bearer_fallback) {
(CustomApi::OpenAi, _) => client
.get(&url)
.header("Authorization", format!("Bearer {key}")),
(CustomApi::Anthropic, false) => client
.get(&url)
.header("x-api-key", key)
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
(CustomApi::Anthropic, true) => client
.get(&url)
.header("Authorization", format!("Bearer {key}"))
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
request.send()
};
let mut response = send(false)?;
// Relays may gate the listing on Bearer even for the Messages wire.
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
tracing::info!(provider = %provider.name, "listing 401 with x-api-key; retrying Bearer");
response = send(true)?;
}
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
return Err(BackendError::RequestFailed { status, body });
}
Ok(response.text()?)
}
fn parse_listing(
provider: &CustomProvider,
body: &str,
) -> Result<Vec<WireModel>, serde_json::Error> {
match provider.api {
CustomApi::OpenAi => kigi_models::parse_openai_listing(body),
CustomApi::Anthropic => kigi_models::parse_anthropic_listing(body),
}
}
/// The login-step listing result; `provider` carries the adopted base URL.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoginFetch {
pub provider: CustomProvider,
pub model_ids: Vec<String>,
}
/// Login listing fetch; probes `{base}/v1` once on a 404 or unparsable body and adopts it.
pub fn fetch_listing_for_login(provider: &CustomProvider, key: &str) -> Result<LoginFetch, String> {
match list_wire(provider, key) {
Ok(model_ids) => Ok(LoginFetch {
provider: provider.clone(),
model_ids,
}),
Err(first) => {
if !first.retryable() || has_version_tail(&provider.base_url) {
return Err(first.into_message());
}
let probed = CustomProvider::new(
&provider.name,
provider.api,
&format!("{}/v1", provider.base_url),
)
.map_err(|e| e.to_string())?;
tracing::info!(
provider = %provider.name,
base_url = %probed.base_url,
"custom provider listing retrying with a /v1 base"
);
match list_wire(&probed, key) {
Ok(model_ids) => Ok(LoginFetch {
provider: probed,
model_ids,
}),
Err(second) => Err(format!(
"{}; {} also failed: {}",
first.into_message(),
probed.models_url(),
second.into_message()
)),
}
}
}
}
/// A listing failure; `retryable` marks wrong-base-URL shapes only.
enum ListFail {
Retryable(String),
Fatal(String),
}
impl ListFail {
fn retryable(&self) -> bool {
matches!(self, Self::Retryable(_))
}
fn into_message(self) -> String {
match self {
Self::Retryable(m) | Self::Fatal(m) => m,
}
}
}
fn list_wire(provider: &CustomProvider, key: &str) -> Result<Vec<String>, ListFail> {
let url = provider.models_url();
let body = listing_body(provider, key).map_err(|e| match &e {
BackendError::RequestFailed { status, .. } => {
// No body: a reflected error could echo the key.
let msg = format!("{url} answered HTTP {status}");
if *status == 404 {
ListFail::Retryable(msg)
} else {
ListFail::Fatal(msg)
}
}
BackendError::Network(_) => ListFail::Fatal(format!("Couldn't reach {url}: {e}")),
BackendError::Auth(_) => ListFail::Fatal(e.to_string()),
})?;
let wire = parse_listing(provider, &body).map_err(|e| {
ListFail::Retryable(format!(
"{url} did not serve a {} model listing (invalid JSON at line {} column {})",
provider.api.as_str(),
e.line(),
e.column()
))
})?;
Ok(wire.into_iter().map(|w| w.id).collect())
}
/// True when the base URL's last path segment is a version like `v1`.
fn has_version_tail(base_url: &str) -> bool {
let Some(tail) = base_url.rsplit('/').next() else {
return false;
};
let digits = tail.strip_prefix('v').unwrap_or("");
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}
/// A selected id the listing never served becomes an entry with wire defaults.
pub(crate) fn manual_entry(provider: &CustomProvider, id: &str) -> ModelEntryConfig {
entry(provider, WireModel::bare(id.to_owned()))
}
fn entry(provider: &CustomProvider, wire: WireModel) -> ModelEntryConfig {
let mut entry = wire_model_to_entry(
provider.managed_model_key(&wire.id),
@@ -129,6 +261,133 @@ mod tests {
.unwrap()
}
async fn login_fetch_on_thread(
provider: CustomProvider,
key: &str,
) -> Result<LoginFetch, String> {
let key = key.to_owned();
tokio::task::spawn_blocking(move || fetch_listing_for_login(&provider, &key))
.await
.unwrap()
}
#[test]
fn version_tail_detection() {
assert!(has_version_tail("https://h.example/v1"));
assert!(has_version_tail("https://h.example/api/v2"));
assert!(!has_version_tail("https://h.example"));
assert!(!has_version_tail("https://h.example/coding"));
assert!(!has_version_tail("https://h.example/v"));
assert!(!has_version_tail("https://h.example/v1x"));
}
#[tokio::test(flavor = "multi_thread")]
async fn login_fetch_returns_the_served_ids() {
let server = MockServer::start().await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "m1"}, {"id": "m2"}]
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
assert_eq!(fetch.provider.base_url, base);
assert_eq!(fetch.model_ids, ["m1", "m2"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn login_fetch_probes_v1_on_a_404_and_adopts_the_base() {
let server = MockServer::start().await;
Mock::given(path("/coding/models"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/coding/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "m1"}]
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/coding", server.uri());
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
assert_eq!(fetch.provider.base_url, format!("{base}/v1"));
assert_eq!(fetch.model_ids, ["m1"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn login_fetch_probes_v1_when_the_200_body_is_not_a_listing() {
// A WAF answers every path with an HTML challenge page at HTTP 200.
let server = MockServer::start().await;
Mock::given(path("/models"))
.respond_with(ResponseTemplate::new(200).set_body_string("<html>challenge</html>"))
.expect(1)
.mount(&server)
.await;
Mock::given(path("/v1/models"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "m1"}]
})))
.expect(1)
.mount(&server)
.await;
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
let fetch = login_fetch_on_thread(provider, "sk-k").await.unwrap();
assert_eq!(fetch.provider.base_url, format!("{}/v1", server.uri()));
assert_eq!(fetch.model_ids, ["m1"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn login_fetch_does_not_probe_auth_failures_or_versioned_bases() {
let server = MockServer::start().await;
Mock::given(path("/models"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
let err = login_fetch_on_thread(provider, "bad").await.unwrap_err();
assert!(err.contains("401"), "{err}");
let server = MockServer::start().await;
Mock::given(path("/v2/models"))
.respond_with(ResponseTemplate::new(404))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v2", server.uri());
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &base).unwrap();
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
assert!(err.contains("404") && !err.contains("also failed"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn login_fetch_reports_both_hops_when_the_probe_fails_too() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(404))
.expect(2)
.mount(&server)
.await;
let provider = CustomProvider::new("proxy", CustomApi::OpenAi, &server.uri()).unwrap();
let err = login_fetch_on_thread(provider, "sk-k").await.unwrap_err();
assert!(err.contains("also failed"), "{err}");
assert!(!err.contains("sk-k"), "{err}");
}
#[tokio::test(flavor = "multi_thread")]
async fn openai_listing_becomes_chat_completions_entries() {
let server = MockServer::start().await;
@@ -204,6 +463,54 @@ mod tests {
assert_eq!(m.auth_scheme, Some(kigi_sampler::AuthScheme::XApiKey));
}
#[tokio::test(flavor = "multi_thread")]
async fn anthropic_listing_falls_back_to_bearer_on_a_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-gw"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-gw"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"data": [{"id": "c1"}], "has_more": false
})))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let models = fetch_on_thread(credentialed("gw", CustomApi::Anthropic, &base, "sk-gw"))
.await
.unwrap();
assert_eq!(models.len(), 1);
assert_eq!(models[0].id.as_deref(), Some("gw/c1"));
}
#[tokio::test(flavor = "multi_thread")]
async fn openai_listing_never_retries_on_a_401() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
let err = fetch_on_thread(credentialed("proxy", CustomApi::OpenAi, &base, "bad"))
.await
.unwrap_err();
assert!(matches!(
err,
BackendError::RequestFailed { status: 401, .. }
));
}
#[tokio::test(flavor = "multi_thread")]
async fn listing_failures_surface_status_and_parse_errors() {
let server = MockServer::start().await;
@@ -46,19 +46,32 @@ pub(crate) async fn validate_key(credentialed: &CredentialedProvider) -> Result<
let provider = &credentialed.provider;
let url = provider.models_url();
let client = crate::http::shared_client();
let request = match provider.api {
CustomApi::OpenAi => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
CustomApi::Anthropic => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
let send = |bearer_fallback: bool| {
let request = match (provider.api, bearer_fallback) {
(CustomApi::OpenAi, _) => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key())),
(CustomApi::Anthropic, false) => client
.get(&url)
.header("x-api-key", credentialed.key())
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
(CustomApi::Anthropic, true) => client
.get(&url)
.header("Authorization", format!("Bearer {}", credentialed.key()))
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
request.send()
};
let response = request
.send()
let mut response = send(false)
.await
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
// Mirrors listing_body: relays may gate the listing on Bearer.
if response.status().as_u16() == 401 && provider.api == CustomApi::Anthropic {
tracing::info!(provider = %provider.name, "validation 401 with x-api-key; retrying Bearer");
response = send(true)
.await
.map_err(|e| format!("Couldn't reach {url}: {e}"))?;
}
let status = response.status().as_u16();
match status {
200..=299 => Ok(()),
@@ -136,6 +149,29 @@ mod tests {
.unwrap();
}
#[tokio::test]
async fn validation_falls_back_to_bearer_for_anthropic_relays() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("x-api-key", "sk-gw"))
.respond_with(ResponseTemplate::new(401))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", "Bearer sk-gw"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.expect(1)
.mount(&server)
.await;
let base = format!("{}/v1", server.uri());
validate_key(&credentialed(CustomApi::Anthropic, &base, "sk-gw"))
.await
.unwrap();
}
#[tokio::test]
async fn validation_names_the_failure_without_the_key() {
let server = MockServer::start().await;
@@ -7,11 +7,14 @@ use crate::agent::config::{PlatformCredentialConfig, PlatformsConfig};
mod fetch;
mod login;
mod save;
mod selection;
pub use fetch::{LoginFetch, fetch_listing_for_login};
pub(crate) use fetch::{drop_orphans, fetch_models_blocking, stamp_credentials};
pub(crate) use login::validate_key;
pub use login::{login_method_id, login_provider_name, picker_api, picker_methods};
pub use save::save_custom_provider;
pub(crate) use selection::{filter_unselected, synthesize_selected};
/// Tables setting `base_url` or `api`, valid or not.
fn declarations(platforms: &PlatformsConfig) -> Vec<(&str, Result<CustomProvider, String>)> {
@@ -4,9 +4,14 @@ use toml::Value as TomlValue;
use toml::map::Map as TomlMap;
/// Writes `[platforms.<name>]` to config.toml and the key to auth.json.
pub async fn save_custom_provider(provider: &CustomProvider, api_key: &str) -> Result<()> {
pub async fn save_custom_provider(
provider: &CustomProvider,
api_key: &str,
models: &[String],
) -> Result<()> {
let provider = provider.clone();
let api_key = api_key.to_owned();
let models = models.to_owned();
let _guard = crate::util::config::SAVE_LOCK.lock().await;
tokio::task::spawn_blocking(move || {
save_custom_provider_in(
@@ -14,6 +19,7 @@ pub async fn save_custom_provider(provider: &CustomProvider, api_key: &str) -> R
&crate::util::kigi_home::kigi_home(),
&provider,
&api_key,
&models,
)
})
.await
@@ -26,6 +32,7 @@ pub(crate) fn save_custom_provider_in(
kigi_home: &std::path::Path,
provider: &CustomProvider,
api_key: &str,
models: &[String],
) -> Result<()> {
let api_key = api_key.trim();
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
@@ -37,7 +44,7 @@ pub(crate) fn save_custom_provider_in(
config_path.display()
)
})?;
upsert_definition(&mut root, provider)?;
upsert_definition(&mut root, provider, models)?;
let dest = crate::util::config::config_write_dest(config_path)?;
crate::util::config::atomic_write_string(&dest, &toml::to_string_pretty(&root)?)
.with_context(|| format!("writing {}", dest.display()))?;
@@ -47,12 +54,17 @@ pub(crate) fn save_custom_provider_in(
provider = %provider.name,
api = provider.api.as_str(),
base_url = %provider.base_url,
models = models.len(),
"custom provider saved"
);
Ok(())
}
fn upsert_definition(root: &mut TomlValue, provider: &CustomProvider) -> Result<()> {
fn upsert_definition(
root: &mut TomlValue,
provider: &CustomProvider,
models: &[String],
) -> Result<()> {
let top = root
.as_table_mut()
.ok_or_else(|| anyhow!("config root is not a table"))?;
@@ -68,6 +80,15 @@ fn upsert_definition(root: &mut TomlValue, provider: &CustomProvider) -> Result<
.ok_or_else(|| anyhow!("[platforms.{}] is not a table", provider.name))?;
entry.insert("api".into(), provider.api.as_str().into());
entry.insert("base_url".into(), provider.base_url.as_str().into());
entry.insert(
"models".into(),
TomlValue::Array(
models
.iter()
.map(|m| TomlValue::String(m.clone()))
.collect(),
),
);
Ok(())
}
@@ -91,7 +112,7 @@ mod tests {
.unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1/");
save_custom_provider_in(&config, dir.path(), &p, " sk-new ").unwrap();
save_custom_provider_in(&config, dir.path(), &p, " sk-new ", &["m1".to_owned()]).unwrap();
let saved: TomlValue = toml::from_str(&std::fs::read_to_string(&config).unwrap()).unwrap();
assert_eq!(saved["ui"]["theme"].as_str(), Some("kiginight"));
@@ -103,6 +124,10 @@ mod tests {
assert_eq!(entry["api"].as_str(), Some("openai"));
assert_eq!(entry["base_url"].as_str(), Some("https://h.example/v1"));
assert_eq!(entry["api_key"].as_str(), Some("hand-written"));
assert_eq!(
entry["models"].as_array().unwrap(),
&[TomlValue::String("m1".into())]
);
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["proxy"].key, "sk-new");
}
@@ -116,6 +141,7 @@ mod tests {
dir.path(),
&provider("gw", CustomApi::OpenAi, "https://a.example/v1"),
"k1",
&["a1".to_owned()],
)
.unwrap();
save_custom_provider_in(
@@ -123,6 +149,7 @@ mod tests {
dir.path(),
&provider("gw", CustomApi::Anthropic, "https://b.example/v1"),
"k2",
&["b1".to_owned()],
)
.unwrap();
@@ -132,6 +159,10 @@ mod tests {
saved["platforms"]["gw"]["base_url"].as_str(),
Some("https://b.example/v1")
);
assert_eq!(
saved["platforms"]["gw"]["models"].as_array().unwrap(),
&[TomlValue::String("b1".into())]
);
let stored = crate::auth::read_auth_json(&dir.path().join("auth.json")).unwrap();
assert_eq!(stored["gw"].key, "k2");
}
@@ -142,12 +173,12 @@ mod tests {
let config = dir.path().join("config.toml");
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
assert!(save_custom_provider_in(&config, dir.path(), &p, " ").is_err());
assert!(save_custom_provider_in(&config, dir.path(), &p, " ", &[]).is_err());
assert!(!config.exists());
assert!(!dir.path().join("auth.json").exists());
std::fs::write(&config, "[platforms\nnope").unwrap();
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
let err = save_custom_provider_in(&config, dir.path(), &p, "sk", &[]).unwrap_err();
assert!(err.to_string().contains("unparseable"), "{err}");
assert_eq!(
std::fs::read_to_string(&config).unwrap(),
@@ -162,7 +193,7 @@ mod tests {
let config = dir.path().join("config.toml");
std::fs::write(&config, "platforms = \"x\"\n").unwrap();
let p = provider("proxy", CustomApi::OpenAi, "https://h.example/v1");
let err = save_custom_provider_in(&config, dir.path(), &p, "sk").unwrap_err();
let err = save_custom_provider_in(&config, dir.path(), &p, "sk", &[]).unwrap_err();
assert!(err.to_string().contains("not a table"), "{err}");
assert!(!dir.path().join("auth.json").exists());
}
@@ -0,0 +1,233 @@
//! The `[platforms.<name>] models` allowlist: keep the selected, synthesize the manual.
use indexmap::IndexMap;
use super::CredentialedProvider;
use crate::agent::config::{ModelEntry, PlatformsConfig};
/// Synthesizes selected ids the listing never served; runs before `[model.*]` overrides.
pub(crate) fn synthesize_selected(
resolved: &mut IndexMap<String, ModelEntry>,
platforms: &PlatformsConfig,
keyed: &[CredentialedProvider],
) {
for credentialed in keyed {
let provider = &credentialed.provider;
let Some(list) = platforms
.entries
.get(&provider.name)
.and_then(|e| e.models.as_ref())
else {
continue;
};
for id in list.iter().map(|m| m.trim()) {
if let Err(e) = kigi_models::custom::validate_model_id(id) {
tracing::warn!(provider = %provider.name, %e, "skipping an invalid selected model id");
continue;
}
let key = provider.managed_model_key(id);
if resolved.contains_key(&key) {
continue;
}
let entry = super::fetch::manual_entry(provider, id);
resolved.insert(key, ModelEntry::from_config_entry(&entry));
}
}
}
/// Drops `{name}/*` entries outside the provider's selection; runs after `[model.*]` overrides.
pub(crate) fn filter_unselected(
resolved: &mut IndexMap<String, ModelEntry>,
platforms: &PlatformsConfig,
keyed: &[CredentialedProvider],
) {
for credentialed in keyed {
let provider = &credentialed.provider;
let Some(list) = platforms
.entries
.get(&provider.name)
.and_then(|e| e.models.as_ref())
else {
continue;
};
let list: Vec<&str> = list.iter().map(|m| m.trim()).collect();
let prefix = format!("{}/", provider.name);
resolved.retain(|key, entry| {
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
id.strip_prefix(&prefix)
.is_none_or(|bare| list.contains(&bare))
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use kigi_models::custom::{CustomApi, CustomProvider};
use crate::agent::models::PlatformApiKeys;
fn provider(name: &str, base: &str) -> CustomProvider {
CustomProvider::new(name, CustomApi::OpenAi, base).unwrap()
}
fn fetched_entry(provider: &CustomProvider, id: &str) -> (String, ModelEntry) {
let cfg = super::super::fetch::manual_entry(provider, id);
let key = cfg.id.clone().unwrap();
(key, ModelEntry::from_config_entry(&cfg))
}
fn keyed(provider: CustomProvider) -> Vec<CredentialedProvider> {
vec![CredentialedProvider::for_test(provider, "sk-x")]
}
#[test]
fn selection_filters_the_listing_and_synthesizes_the_manual() {
let proxy = provider("proxy", "https://p.example/v1");
let mut resolved = IndexMap::new();
for id in ["m1", "m2"] {
let (key, entry) = fetched_entry(&proxy, id);
resolved.insert(key, entry);
}
let platforms: PlatformsConfig = toml::from_str(
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\", \"manual-1\"]\n",
)
.unwrap();
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
let ids: Vec<_> = resolved.keys().map(String::as_str).collect();
assert_eq!(ids, ["proxy/m2", "proxy/manual-1"]);
assert_eq!(resolved["proxy/manual-1"].info.model, "manual-1");
}
#[test]
fn a_provider_without_the_models_key_keeps_its_whole_listing() {
let proxy = provider("proxy", "https://p.example/v1");
let mut resolved = IndexMap::new();
let (key, entry) = fetched_entry(&proxy, "m1");
resolved.insert(key, entry);
let platforms: PlatformsConfig =
toml::from_str("[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\n")
.unwrap();
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
assert!(resolved.contains_key("proxy/m1"));
}
#[test]
fn an_empty_selection_drops_every_fetched_entry() {
let proxy = provider("proxy", "https://p.example/v1");
let mut resolved = IndexMap::new();
let (key, entry) = fetched_entry(&proxy, "m1");
resolved.insert(key, entry);
let platforms: PlatformsConfig = toml::from_str(
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = []\n",
)
.unwrap();
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
assert!(resolved.is_empty());
}
#[test]
fn invalid_selected_ids_are_skipped_with_a_warning() {
let proxy = provider("proxy", "https://p.example/v1");
let mut resolved = IndexMap::new();
let platforms: PlatformsConfig = toml::from_str(
"[proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"ok\", \"has space\"]\n",
)
.unwrap();
synthesize_selected(&mut resolved, &platforms, &keyed(proxy.clone()));
filter_unselected(&mut resolved, &platforms, &keyed(proxy));
assert!(resolved.contains_key("proxy/ok"));
assert_eq!(resolved.len(), 1);
}
#[test]
fn selection_applies_through_resolve_model_list_and_gets_the_key() {
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy = provider("proxy", "https://p.example/v1");
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
let entry = resolved
.get("proxy/manual-1")
.expect("a manual model survives without any fetched catalog");
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
assert!(entry.has_own_credentials());
assert_eq!(entry.info.context_window.get(), 256_000);
}
#[test]
fn a_model_override_composes_with_the_synthesized_entry() {
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"manual-1\"]\n\n[model.\"proxy/manual-1\"]\ncontext_window = 1000000\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy = provider("proxy", "https://p.example/v1");
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
let entry = &resolved["proxy/manual-1"];
assert_eq!(entry.info.context_window.get(), 1_000_000);
assert_eq!(entry.info.base_url, "https://p.example/v1");
assert_eq!(entry.api_key.as_deref(), Some("sk-proxy"));
}
#[test]
fn the_selection_is_authoritative_in_the_provider_namespace() {
// A context-only override cannot resurrect a deselected model.
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"m2\"]\n\n[model.\"proxy/m1\"]\ncontext_window = 123456\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy = provider("proxy", "https://p.example/v1");
let (key, entry) = fetched_entry(&proxy, "m1");
let prefetched = IndexMap::from([(key, entry)]);
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(&cfg, Some(prefetched), &keys);
assert!(!resolved.contains_key("proxy/m1"));
assert!(resolved.contains_key("proxy/m2"));
}
#[test]
fn provider_owned_entries_keep_their_declared_wire_against_donors() {
let raw: toml::Value = toml::from_str(
"[platforms.proxy]\nbase_url = \"https://p.example/v1\"\napi = \"openai\"\nmodels = [\"shared\"]\n\n[model.donorx]\nmodel = \"shared\"\napi_backend = \"messages\"\ncontext_window = 500000\n",
)
.unwrap();
let cfg = crate::agent::config::Config::new_from_toml_cfg(&raw).unwrap();
let proxy = provider("proxy", "https://p.example/v1");
let keys =
PlatformApiKeys::test_custom(vec![CredentialedProvider::for_test(proxy, "sk-proxy")]);
let resolved = crate::agent::config::resolve_model_list(&cfg, None, &keys);
let entry = &resolved["proxy/shared"];
assert_eq!(
entry.info.api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
}
}
@@ -12,7 +12,7 @@ use std::sync::Arc;
use serde::Serialize;
/// Where non-subscription users (no OAuth session) submit feedback instead.
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str = "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// HTTP error from the feedback endpoint with a preserved status code, so
/// callers can distinguish auth failures (401) without string matching.
@@ -574,6 +574,8 @@ pub enum Action {
BeginCustomProviderEntry(kigi_shell::models::custom::CustomApi),
/// Enter in a custom provider step.
SubmitCustomProviderInput(String),
/// Enter on an empty manual-model box: finish the model selection.
SubmitCustomProviderSelection,
/// Copy the auth URL to the clipboard during authentication.
CopyAuthUrl,
/// Show the raw auth URL with mouse capture disabled for manual copy.
@@ -1623,6 +1625,13 @@ pub enum Effect {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
models: Vec<String>,
},
/// List the provider's models for the login selection screen.
FetchCustomProviderModels {
request_seq: u64,
provider: kigi_shell::models::custom::CustomProvider,
key: String,
},
/// Fetch MCP server list from the shell (kigi/mcp/list).
FetchMcpsList {
@@ -2166,6 +2175,11 @@ pub enum TaskResult {
AuthCodeSubmitted {
request_seq: u64,
},
/// The login-step model listing came back (or failed).
CustomProviderModelsListed {
request_seq: u64,
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
},
/// MCP server list fetched from shell.
McpsListLoaded {
agent_id: AgentId,
+172 -3
View File
@@ -264,6 +264,8 @@ pub enum AuthMode {
ApiKeyEntry(PlatformLogin),
/// Custom provider login, one field per step.
CustomProviderEntry(crate::app::custom_entry::CustomEntryStep),
/// Custom provider login, the model-selection screen.
CustomProviderSelect,
}
/// API-key platform login target, selected from the welcome picker. Wraps a
/// non-OAuth registry platform ([`kigi_shell::models::PlatformId`]);
@@ -924,6 +926,8 @@ pub struct AppView {
pub auth_code_input: String,
/// Values collected by the custom provider login steps.
pub custom_entry: crate::app::custom_entry::CustomEntryDraft,
/// Model-selection state of a custom provider login; present in that mode.
pub custom_select: Option<crate::app::custom_entry::CustomSelectState>,
/// Monotonically increasing sequence number for auth requests.
pub next_auth_request_seq: u64,
/// Every session/chat/worktree/prompt action deferred behind startup gates.
@@ -1128,6 +1132,7 @@ impl AppView {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -1663,6 +1668,7 @@ impl AppView {
mid_session_login: self.auth_return_view.is_some(),
auth_methods: &self.auth_methods,
auth_code_input: &mut self.auth_code_input,
custom_select: &mut self.custom_select,
prompt: &mut self.welcome_prompt,
prompt_focused: &mut self.welcome_prompt_focused,
new_worktree_dialog: &mut self.new_worktree_dialog,
@@ -2188,6 +2194,7 @@ struct WelcomeInputCtx<'a> {
/// the `AuthState::Pending` welcome menu.
auth_methods: &'a [acp::AuthMethod],
auth_code_input: &'a mut String,
custom_select: &'a mut Option<crate::app::custom_entry::CustomSelectState>,
prompt: &'a mut PromptWidget,
prompt_focused: &'a mut bool,
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
@@ -2689,11 +2696,89 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
return InputOutcome::Unchanged;
}
if crate::input::key::is_paste_key(key)
|| crate::input::key::is_inline_paste_key(key)
{
if let Some(text) = crate::clipboard::system_clipboard_get() {
let cleaned: String =
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
ctx.auth_code_input.push_str(&cleaned);
return InputOutcome::Changed;
}
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
return InputOutcome::Unchanged;
}
if key!(Backspace).matches(key) {
ctx.auth_code_input.pop();
return InputOutcome::Changed;
}
if let crossterm::event::KeyCode::Char(c) = key.code {
if let crossterm::event::KeyCode::Char(c) = key.code
&& crate::input::key::is_text_input_key(key)
{
ctx.auth_code_input.push(c);
return InputOutcome::Changed;
}
}
AuthState::Authenticating {
mode: AuthMode::CustomProviderSelect,
..
} => {
// Esc cancels back to the picker, like the entry steps.
if key!(Esc).matches(key) {
return InputOutcome::Action(Action::CancelPlatformKeyEntry);
}
if key!('q', CONTROL).matches(key) || key!('c', CONTROL).matches(key) {
if ctx.mid_session_login {
return InputOutcome::Action(Action::CancelLogin);
}
return InputOutcome::Action(Action::QuitConfirmed);
}
let Some(select) = ctx.custom_select.as_mut() else {
return InputOutcome::Unchanged;
};
if key!(Up).matches(key) {
select.move_highlight(-1);
return InputOutcome::Changed;
}
if key!(Down).matches(key) {
select.move_highlight(1);
return InputOutcome::Changed;
}
// Model ids hold no spaces, so the spacebar toggles rows.
if key!(Char(' ')).matches(key) {
select.toggle_highlighted();
return InputOutcome::Changed;
}
if crate::input::key::is_paste_key(key)
|| crate::input::key::is_inline_paste_key(key)
{
if let Some(text) = crate::clipboard::system_clipboard_get() {
let cleaned: String =
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
ctx.auth_code_input.push_str(&cleaned);
return InputOutcome::Changed;
}
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
return InputOutcome::Unchanged;
}
if key!(Enter).matches(key) {
let trimmed = ctx.auth_code_input.trim().to_string();
if trimmed.is_empty() {
return InputOutcome::Action(Action::SubmitCustomProviderSelection);
}
select.add_manual(&trimmed);
if select.error.is_none() {
ctx.auth_code_input.clear();
}
return InputOutcome::Changed;
}
if key!(Backspace).matches(key) {
ctx.auth_code_input.pop();
return InputOutcome::Changed;
}
if let crossterm::event::KeyCode::Char(c) = key.code
&& crate::input::key::is_text_input_key(key)
{
ctx.auth_code_input.push(c);
return InputOutcome::Changed;
}
@@ -2718,11 +2803,25 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
return InputOutcome::Unchanged;
}
if crate::input::key::is_paste_key(key)
|| crate::input::key::is_inline_paste_key(key)
{
if let Some(text) = crate::clipboard::system_clipboard_get() {
let cleaned: String =
text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
ctx.auth_code_input.push_str(&cleaned);
return InputOutcome::Changed;
}
crate::clipboard::log_paste_key_empty_host_clipboard("auth input");
return InputOutcome::Unchanged;
}
if key!(Backspace).matches(key) {
ctx.auth_code_input.pop();
return InputOutcome::Changed;
}
if let crossterm::event::KeyCode::Char(c) = key.code {
if let crossterm::event::KeyCode::Char(c) = key.code
&& crate::input::key::is_text_input_key(key)
{
ctx.auth_code_input.push(c);
return InputOutcome::Changed;
}
@@ -2747,7 +2846,10 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
AuthState::Authenticating {
mode:
AuthMode::Loopback | AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
AuthMode::Loopback
| AuthMode::ApiKeyEntry(_)
| AuthMode::CustomProviderEntry(_)
| AuthMode::CustomProviderSelect,
..
} => {
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
@@ -3248,6 +3350,7 @@ impl AppView {
login_label: self.login_label.as_deref(),
auth_code_input: &self.auth_code_input,
custom_entry: &self.custom_entry,
custom_select: self.custom_select.as_ref(),
clipboard_copied: self.auth_clipboard_copied,
show_raw_url: self.auth_show_raw_url,
tip,
@@ -4384,6 +4487,7 @@ pub(crate) mod tests {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
@@ -7601,6 +7705,71 @@ pub(crate) mod tests {
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input, "token_value");
}
/// Ctrl+V / Super+V paste the host clipboard into every auth input.
#[test]
fn auth_inputs_paste_key_chords_insert_clipboard_text() {
let select_state = || {
crate::app::custom_entry::CustomSelectState::new(
kigi_shell::models::custom::CustomProvider::new(
"gw",
kigi_shell::models::custom::CustomApi::OpenAi,
"https://h.example/v1",
)
.unwrap(),
"sk".to_owned(),
)
};
let modes = [
AuthMode::Loopback,
AuthMode::ApiKeyEntry(PlatformLogin(kigi_shell::models::PlatformId::MoonshotCn)),
AuthMode::CustomProviderEntry(crate::app::custom_entry::CustomEntryStep::first(
kigi_shell::models::custom::CustomApi::OpenAi,
)),
AuthMode::CustomProviderSelect,
];
for mode in modes {
for mods in [KeyModifiers::CONTROL, KeyModifiers::SUPER] {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode,
};
if matches!(mode, AuthMode::CustomProviderSelect) {
app.custom_select = Some(select_state());
}
crate::clipboard::set_clipboard_probe_hook(crate::clipboard::ClipboardProbeHook {
text: Some("sk-pasted\nkey".to_owned()),
..Default::default()
});
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), mods));
crate::clipboard::clear_clipboard_probe_hook();
assert!(
matches!(outcome, InputOutcome::Changed),
"{mode:?} + {mods:?} must paste, got {outcome:?}"
);
assert_eq!(
app.auth_code_input, "sk-pastedkey",
"newlines stripped and no literal 'v' for {mode:?}"
);
}
}
}
/// Ctrl+letter chords are not text input (the old Ctrl+V inserted 'v').
#[test]
fn auth_inputs_ignore_control_letter_chords() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Loopback,
};
let outcome = app.handle_input(&key_event(KeyCode::Char('x'), KeyModifiers::CONTROL));
assert!(matches!(outcome, InputOutcome::Unchanged));
assert!(app.auth_code_input.is_empty());
}
#[test]
fn authenticating_loopback_enter_empty_is_noop() {
let mut app = test_app();
+201 -1
View File
@@ -1,7 +1,7 @@
//! Custom provider login steps: base URL, name, key.
use kigi_shell::models::custom::{
CustomApi, CustomProvider, default_name, normalize_base_url, validate_name,
CustomApi, CustomProvider, default_name, normalize_base_url, validate_model_id, validate_name,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -141,6 +141,141 @@ impl CustomEntryDraft {
}
}
/// Fetch status of the model-selection screen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectFetch {
InFlight,
Listed,
Failed(String),
}
/// One selectable model row; `manual` marks user-typed ids.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectRow {
pub id: String,
pub selected: bool,
pub manual: bool,
}
/// Model selection after the key step: fetched rows plus manual adds.
#[derive(Clone)]
pub struct CustomSelectState {
/// The fetch may adopt a `/v1` base URL.
pub provider: CustomProvider,
key: String,
pub rows: Vec<SelectRow>,
pub highlight: usize,
pub fetch: SelectFetch,
pub error: Option<String>,
/// Enter hit while the listing is in flight; it completes on arrival.
pub finish_requested: bool,
}
impl CustomSelectState {
pub fn new(provider: CustomProvider, key: String) -> Self {
Self {
provider,
key,
rows: Vec::new(),
highlight: 0,
fetch: SelectFetch::InFlight,
error: None,
finish_requested: false,
}
}
pub fn take_key(self) -> String {
self.key
}
pub fn apply_fetch(&mut self, fetch: kigi_shell::agent::custom_providers::LoginFetch) {
self.provider = fetch.provider;
for id in fetch.model_ids {
if self.rows.iter().any(|r| r.id == id) {
continue;
}
self.rows.push(SelectRow {
id,
selected: false,
manual: false,
});
}
self.fetch = SelectFetch::Listed;
}
pub fn fail_fetch(&mut self, reason: String) {
self.fetch = SelectFetch::Failed(reason);
}
pub fn move_highlight(&mut self, delta: isize) {
if self.rows.is_empty() {
return;
}
let max = self.rows.len() - 1;
self.highlight = self.highlight.saturating_add_signed(delta).min(max);
}
pub fn toggle_highlighted(&mut self) {
if let Some(row) = self.rows.get_mut(self.highlight) {
row.selected = !row.selected;
}
}
/// Adds a typed id selected; an id already listed just gets selected.
pub fn add_manual(&mut self, raw: &str) {
let id = raw.trim();
if let Err(e) = validate_model_id(id) {
self.error = Some(e.to_string());
return;
}
if let Some(row) = self.rows.iter_mut().find(|r| r.id == id) {
row.selected = true;
} else {
self.rows.push(SelectRow {
id: id.to_owned(),
selected: true,
manual: true,
});
}
self.error = None;
}
/// The chosen ids, or a refusal when nothing is chosen.
pub fn try_finish(&mut self) -> Option<Vec<String>> {
// Wait for the listing: it may adopt a probed `/v1` base URL.
if self.fetch == SelectFetch::InFlight {
self.finish_requested = true;
return None;
}
let ids: Vec<String> = self
.rows
.iter()
.filter(|r| r.selected)
.map(|r| r.id.clone())
.collect();
if ids.is_empty() {
self.finish_requested = false;
self.error = Some("Select at least one model, or type one and press enter".to_owned());
return None;
}
Some(ids)
}
}
impl std::fmt::Debug for CustomSelectState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CustomSelectState")
.field("provider", &self.provider)
.field("key", &"<set>")
.field("rows", &self.rows)
.field("highlight", &self.highlight)
.field("fetch", &self.fetch)
.field("error", &self.error)
.field("finish_requested", &self.finish_requested)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -236,4 +371,69 @@ mod tests {
assert!(!step(CustomEntryField::BaseUrl).masks_input());
assert!(!step(CustomEntryField::Name).masks_input());
}
fn select_state() -> CustomSelectState {
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
CustomSelectState::new(provider, "sk-secret".to_owned())
}
#[test]
fn select_state_toggles_adds_and_finishes() {
let mut state = select_state();
assert!(state.try_finish().is_none(), "in flight: finish waits");
assert!(state.finish_requested && state.error.is_none());
state.finish_requested = false;
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: state.provider.clone(),
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
assert!(state.try_finish().is_none());
assert!(state.error.is_some(), "listed: an empty selection refuses");
assert_eq!(state.rows.len(), 2);
state.toggle_highlighted();
state.move_highlight(1);
state.toggle_highlighted();
state.move_highlight(9);
assert_eq!(state.highlight, 1, "highlight clamps at the last row");
state.add_manual(" claude-opus-5-5[1m] ");
assert_eq!(state.rows.len(), 3);
state.add_manual("m1");
assert_eq!(
state.rows.len(),
3,
"a listed id is selected, not duplicated"
);
state.add_manual("has space");
assert!(state.error.is_some());
assert_eq!(state.rows.len(), 3);
let ids = state.try_finish().expect("three selected");
assert_eq!(ids, ["m1", "m2", "claude-opus-5-5[1m]"]);
}
#[test]
fn select_state_apply_fetch_adopts_the_probed_base_and_dedups() {
let mut state = select_state();
state.add_manual("m1");
let adopted =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/coding/v1").unwrap();
state.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
assert_eq!(state.provider.base_url, "https://h.example/coding/v1");
assert_eq!(state.rows.len(), 2, "the manual row covers the fetched id");
assert!(state.rows[0].manual && state.rows[0].selected);
assert!(!state.rows[1].selected);
}
#[test]
fn select_state_debug_hides_the_key() {
let state = select_state();
assert!(!format!("{state:?}").contains("sk-secret"));
}
}
@@ -282,6 +282,7 @@ pub(super) fn dispatch_cancel_login(app: &mut AppView) -> Vec<Effect> {
app.auth_state = AuthState::Done;
app.auth_show_raw_url = false;
app.auth_code_input.clear();
app.custom_select = None;
app.auth_in_flight_method = None;
restore_auth_return_view(app, return_view);
// The user bailed out of re-auth — drop stashed prompts and strip the
@@ -335,6 +336,7 @@ pub(super) fn dispatch_begin_custom_provider_entry(
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.custom_select = None;
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
@@ -373,18 +375,20 @@ pub(super) fn dispatch_submit_custom_provider_input(
vec![]
}
CustomEntryOutcome::Done { provider, key } => {
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
// Key accepted: open the model selection and fetch concurrently.
app.custom_entry.clear();
app.auth_code_input.clear();
app.custom_select = Some(crate::app::custom_entry::CustomSelectState::new(
provider.clone(),
key.clone(),
));
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
mode: AuthMode::CustomProviderSelect,
};
vec![Effect::PersistCustomProviderAndAuthenticate {
vec![Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
@@ -393,6 +397,81 @@ pub(super) fn dispatch_submit_custom_provider_input(
}
}
/// Enter on an empty manual-model box: persist the chosen models, then auth.
pub(super) fn dispatch_submit_custom_provider_selection(app: &mut AppView) -> Vec<Effect> {
let request_seq = match &app.auth_state {
AuthState::Authenticating {
request_seq,
mode: AuthMode::CustomProviderSelect,
..
} => *request_seq,
_ => return vec![],
};
complete_custom_provider_selection(app, request_seq)
}
/// Persists the selection once the listing no longer gates the base URL.
fn complete_custom_provider_selection(app: &mut AppView, request_seq: u64) -> Vec<Effect> {
let Some(state) = app.custom_select.as_mut() else {
return vec![];
};
let Some(models) = state.try_finish() else {
return vec![];
};
let state = app.custom_select.take().expect("checked above");
let provider = state.provider.clone();
let key = state.take_key();
app.auth_in_flight_method = Some(kigi_shell::agent::custom_providers::login_method_id(
&provider.name,
));
app.auth_code_input.clear();
app.auth_state = AuthState::Authenticating {
request_seq,
handle: None,
auth_url: None,
mode: AuthMode::Pending,
};
vec![Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
models,
}]
}
/// The login-step model listing came back; fill or flag the selection.
pub(super) fn handle_custom_provider_models_listed(
app: &mut AppView,
request_seq: u64,
result: Result<kigi_shell::agent::custom_providers::LoginFetch, String>,
) -> Vec<Effect> {
let in_flight = matches!(
&app.auth_state,
AuthState::Authenticating {
request_seq: current_seq,
mode: AuthMode::CustomProviderSelect,
..
} if *current_seq == request_seq
);
if !in_flight {
return vec![];
}
match (app.custom_select.as_mut(), result) {
(Some(state), Ok(fetch)) => state.apply_fetch(fetch),
(Some(state), Err(reason)) => state.fail_fetch(reason),
(None, _) => {}
}
// A finish pressed during the fetch completes now, with the adopted base.
if app
.custom_select
.as_ref()
.is_some_and(|s| s.finish_requested)
{
return complete_custom_provider_selection(app, request_seq);
}
vec![]
}
/// Esc in the API-key paste box: back to the login picker (no error line).
/// Bumps the request seq so any stale in-flight auth result is dropped by
/// the `AuthComplete`/`AuthFailed` guards.
@@ -400,7 +479,9 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
if !matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::ApiKeyEntry(_) | AuthMode::CustomProviderEntry(_),
mode: AuthMode::ApiKeyEntry(_)
| AuthMode::CustomProviderEntry(_)
| AuthMode::CustomProviderSelect,
..
}
) {
@@ -409,6 +490,7 @@ pub(super) fn dispatch_cancel_platform_key_entry(app: &mut AppView) -> Vec<Effec
app.next_auth_request_seq += 1;
app.auth_code_input.clear();
app.custom_entry.clear();
app.custom_select = None;
app.auth_state = AuthState::Pending { error: None };
vec![]
}
@@ -3,7 +3,8 @@ use super::auth::{
dispatch_begin_custom_provider_entry, dispatch_begin_platform_key_entry, dispatch_cancel_login,
dispatch_cancel_platform_key_entry, dispatch_login, dispatch_login_with, dispatch_logout,
dispatch_open_login_picker, dispatch_submit_auth_code, dispatch_submit_custom_provider_input,
dispatch_submit_platform_api_key, dispatch_switch_account,
dispatch_submit_custom_provider_selection, dispatch_submit_platform_api_key,
dispatch_switch_account,
};
use super::ctx::{
active_agent_session_id, get_active_agent_mut, navigate_clearing_selection,
@@ -894,6 +895,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SubmitCustomProviderInput(input) => {
dispatch_submit_custom_provider_input(app, input)
}
Action::SubmitCustomProviderSelection => dispatch_submit_custom_provider_selection(app),
Action::CopyAuthUrl => {
if let AuthState::Authenticating {
auth_url: Some(url),
@@ -509,9 +509,14 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
{
app.auth_state = AuthState::Pending { error: Some(error) };
app.auth_code_input.clear();
app.custom_select = None;
}
vec![]
}
TaskResult::CustomProviderModelsListed {
request_seq,
result,
} => super::auth::handle_custom_provider_models_listed(app, request_seq, result),
TaskResult::AuthUrlReady {
request_seq,
auth_url,
@@ -798,6 +803,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
app.login_method_id = None;
ensure_login_method(app);
app.auth_clipboard_copied = false;
app.custom_select = None;
let effects = dispatch_exit_session(app);
app.welcome_prompt_focused = false;
effects
@@ -582,9 +582,9 @@ fn custom_field(app: &AppView) -> crate::app::custom_entry::CustomEntryField {
}
}
/// URL, name, key; the last Enter emits one persist effect.
/// URL, name, key; the last Enter opens the selection screen and fetches.
#[test]
fn custom_provider_steps_end_in_one_persist_effect() {
fn custom_provider_steps_end_in_the_model_selection() {
use crate::app::custom_entry::CustomEntryField;
use kigi_shell::models::custom::CustomApi;
@@ -619,7 +619,7 @@ fn custom_provider_steps_end_in_one_persist_effect() {
let effects = dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
@@ -631,21 +631,236 @@ fn custom_provider_steps_end_in_one_persist_effect() {
assert_eq!(provider.base_url, "https://api.gw.example/v1");
assert_eq!(key, "sk-gw");
}
other => panic!("expected exactly the persist effect, got {other:?}"),
other => panic!("expected exactly the fetch effect, got {other:?}"),
}
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
request_seq,
mode: AuthMode::Pending,
mode: AuthMode::CustomProviderSelect,
..
} if request_seq == seq
));
assert!(app.custom_select.is_some());
assert!(app.auth_code_input.is_empty());
}
/// The listing fills rows; the selection persists only the chosen models.
#[test]
fn custom_provider_selection_persists_the_chosen_models() {
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
// The listing lands: rows appear, the probed base is adopted.
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let listed = kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
};
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Ok(listed),
}),
&mut app,
);
assert!(effects.is_empty());
let state = app.custom_select.as_ref().expect("selection lives");
assert_eq!(state.provider.base_url, "https://h.example/v1");
assert_eq!(state.rows.len(), 2);
assert!(!state.rows[0].selected);
// An empty selection refuses to finish once the listing resolved.
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
assert!(
app.custom_select
.as_ref()
.and_then(|s| s.error.as_deref())
.is_some()
);
// A late manual id typed before the fetch survives the fill.
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("claude-opus-5-5[1m]");
assert_eq!(state.rows.len(), 3);
assert!(state.rows[2].manual && state.rows[2].selected);
// Select m2 as well, then finish.
let state = app.custom_select.as_mut().expect("selection lives");
state.highlight = 1;
state.toggle_highlighted();
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
request_seq,
provider,
key,
models,
},
] => {
assert_eq!(*request_seq, seq);
assert_eq!(provider.base_url, "https://h.example/v1");
assert_eq!(key, "sk-gw");
assert_eq!(models, &["m2".to_owned(), "claude-opus-5-5[1m]".to_owned()]);
}
other => panic!("expected the persist effect, got {other:?}"),
}
assert!(app.custom_select.is_none());
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::Pending,
..
}
));
assert_eq!(
app.auth_in_flight_method.as_ref().map(|m| m.0.as_ref()),
Some("custom:gw")
);
assert!(app.auth_code_input.is_empty());
}
/// Finishing while the listing is in flight completes when it lands.
#[test]
fn custom_provider_selection_finish_waits_for_the_fetch() {
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("m1");
// Enter with an empty box while the fetch runs: no persist yet.
assert!(dispatch(Action::SubmitCustomProviderSelection, &mut app).is_empty());
assert!(app.custom_select.as_ref().expect("lives").finish_requested);
assert!(matches!(
app.auth_state,
AuthState::Authenticating {
mode: AuthMode::CustomProviderSelect,
..
}
));
// The listing lands with an adopted /v1 base; the finish fires now.
let adopted = CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
provider: adopted,
model_ids: vec!["m2".to_owned()],
}),
}),
&mut app,
);
match effects.as_slice() {
[
Effect::PersistCustomProviderAndAuthenticate {
provider, models, ..
},
] => {
assert_eq!(provider.base_url, "https://h.example/v1");
assert_eq!(models, &["m1".to_owned()]);
}
other => panic!("expected the persist effect, got {other:?}"),
}
assert!(app.custom_select.is_none());
}
/// A failed listing keeps the manual input usable and shows the reason.
#[test]
fn custom_provider_selection_survives_a_failed_fetch() {
use kigi_shell::models::custom::CustomApi;
let mut app = test_app();
app.auth_state = AuthState::Pending { error: None };
dispatch(
Action::BeginCustomProviderEntry(CustomApi::OpenAi),
&mut app,
);
dispatch(
Action::SubmitCustomProviderInput("https://h.example/v1".into()),
&mut app,
);
dispatch(Action::SubmitCustomProviderInput("gw".into()), &mut app);
dispatch(Action::SubmitCustomProviderInput("sk-gw".into()), &mut app);
let seq = match &app.auth_state {
AuthState::Authenticating { request_seq, .. } => *request_seq,
_ => unreachable!(),
};
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq,
result: Err("https://h.example/v1/models answered HTTP 404".to_owned()),
}),
&mut app,
);
assert!(effects.is_empty());
let state = app.custom_select.as_ref().expect("selection lives");
assert!(matches!(
&state.fetch,
crate::app::custom_entry::SelectFetch::Failed(reason) if reason.contains("404")
));
// A stale result for an older request never clobbers the screen.
let effects = dispatch(
Action::TaskComplete(TaskResult::CustomProviderModelsListed {
request_seq: seq + 99,
result: Ok(kigi_shell::agent::custom_providers::LoginFetch {
provider: state.provider.clone(),
model_ids: vec!["stale".to_owned()],
}),
}),
&mut app,
);
assert!(effects.is_empty());
assert!(
app.custom_select
.as_ref()
.expect("selection lives")
.rows
.is_empty()
);
// Manual add still finishes the flow.
let state = app.custom_select.as_mut().expect("selection lives");
state.add_manual("m1");
let effects = dispatch(Action::SubmitCustomProviderSelection, &mut app);
assert!(matches!(
effects.as_slice(),
[Effect::PersistCustomProviderAndAuthenticate { models, .. }] if models == &["m1".to_owned()]
));
}
/// A refused field keeps the user on it.
@@ -127,6 +127,7 @@ fn test_app() -> AppView {
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
custom_entry: Default::default(),
custom_select: None,
next_auth_request_seq: 1,
deferred_startup: Default::default(),
auth_use_oauth: false,
+22 -3
View File
@@ -1915,13 +1915,15 @@ pub(crate) fn execute(
request_seq,
provider,
key,
models,
} => {
let tx = acp_tx.clone();
let abort_handle = tasks.spawn(async move {
// Persist first so authenticate finds the definition and key.
if let Err(e) =
kigi_shell::agent::custom_providers::save_custom_provider(&provider, &key)
.await
if let Err(e) = kigi_shell::agent::custom_providers::save_custom_provider(
&provider, &key, &models,
)
.await
{
let error = format!("Couldn't save the provider: {e:#}");
ulog::error(
@@ -1940,6 +1942,23 @@ pub(crate) fn execute(
});
meta.auth_abort_handle = Some((request_seq, abort_handle));
}
Effect::FetchCustomProviderModels {
request_seq,
provider,
key,
} => {
tasks.spawn(async move {
let result = tokio::task::spawn_blocking(move || {
kigi_shell::agent::custom_providers::fetch_listing_for_login(&provider, &key)
})
.await
.unwrap_or_else(|e| Err(format!("listing task failed: {e}")));
TaskResult::CustomProviderModelsListed {
request_seq,
result,
}
});
}
Effect::SubmitAuthCode { request_seq, code } => {
let tx = acp_tx.clone();
tasks
@@ -10,7 +10,7 @@ use crate::slash::command::{AppCtx, ArgItem, CommandExecCtx, CommandResult, Slas
/// Online docs landing page (hardcoded like other TUI deep-links; the
/// project README is the docs entry point for the repo-hosted docs).
pub const BUILD_DOCS_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/blob/main/README.md";
pub const BUILD_DOCS_URL: &str = "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/src/branch/main/README.md";
/// Open How-to Guides or online Build docs.
pub struct DocsCommand;
@@ -6,7 +6,7 @@ use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
/// Where feedback goes: the project's own issue tracker. Kigi is a community
/// build, so its feedback belongs on its own repo — mirroring the official
/// kimi-cli, whose `/feedback` opens its repo's issues page.
pub const FEEDBACK_ISSUES_URL: &str = "https://github.com/ZacharyZhang-NY/Kigi-CLI/issues";
pub const FEEDBACK_ISSUES_URL: &str = "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/issues";
/// Open the Kigi issue tracker in the browser.
pub struct FeedbackCommand;
@@ -462,6 +462,7 @@ pub struct WelcomeRenderParams<'a> {
pub login_label: Option<&'a str>,
pub auth_code_input: &'a str,
pub custom_entry: &'a crate::app::custom_entry::CustomEntryDraft,
pub custom_select: Option<&'a crate::app::custom_entry::CustomSelectState>,
pub clipboard_copied: bool,
pub show_raw_url: bool,
pub tip: Option<&'a str>,
@@ -605,6 +606,7 @@ pub fn render_welcome(
*mode,
params.auth_code_input,
params.custom_entry,
params.custom_select,
params.clipboard_copied,
params.show_raw_url,
);
@@ -1179,6 +1181,7 @@ fn render_welcome_authenticating(
mode: AuthMode,
auth_code_input: &str,
custom_entry: &crate::app::custom_entry::CustomEntryDraft,
custom_select: Option<&crate::app::custom_entry::CustomSelectState>,
clipboard_copied: bool,
show_raw_url: bool,
) -> (Option<Rect>, Option<Rect>) {
@@ -1336,6 +1339,21 @@ fn render_welcome_authenticating(
(None, None)
}
AuthMode::CustomProviderSelect => {
if let Some(select) = custom_select {
render_custom_select(
content_area,
buf,
theme,
top_pad,
logo_line_count,
select,
auth_code_input,
);
}
(None, None)
}
AuthMode::Command => render_browser_status_arm(
content_area,
buf,
@@ -2034,6 +2052,182 @@ fn render_entry_box(
Paragraph::new(hints).render(hint_area, buf);
}
/// The custom provider model-selection screen: fetched rows + manual input.
fn render_custom_select(
content_area: Rect,
buf: &mut Buffer,
theme: &Theme,
top_pad: u16,
logo_line_count: u16,
select: &crate::app::custom_entry::CustomSelectState,
input: &str,
) {
use crate::app::custom_entry::SelectFetch;
let h_pad: u16 = content_area.width / 8;
let inner_width = content_area.width.saturating_sub(h_pad * 2).max(1);
let instruction = format!(
"Select the models to add for {} ({})",
select.provider.name, select.provider.base_url
);
let status: Option<(String, Style)> = match &select.fetch {
SelectFetch::InFlight if select.finish_requested => Some((
"Fetching models... finishing when the list lands".to_owned(),
Style::default().fg(theme.gray),
)),
SelectFetch::InFlight => Some((
"Fetching models...".to_owned(),
Style::default().fg(theme.gray),
)),
SelectFetch::Listed if select.rows.is_empty() => Some((
"The provider served no models; add one below".to_owned(),
Style::default().fg(theme.warning),
)),
SelectFetch::Listed => None,
SelectFetch::Failed(reason) => Some((
format!("Fetch failed: {reason}"),
Style::default().fg(theme.warning),
)),
};
// Word-wrap-aware rows; byte math undercounts and clips the failure reason.
let rows_of = |text: &str| -> u16 {
crate::render::wrapping::word_wrap_line(
&Line::from(Span::raw(text.to_owned())),
inner_width as usize,
)
.len() as u16
};
let msg_height = rows_of(&instruction)
+ status.as_ref().map_or(0, |(s, _)| rows_of(s))
+ select.error.as_deref().map_or(0, rows_of);
let [
_,
logo_area,
_,
msg_area,
_,
rows_area,
_,
prompt_area,
_,
hint_area,
_,
] = Layout::vertical([
Constraint::Length(top_pad),
Constraint::Length(logo_line_count),
Constraint::Length(1),
// Min: spare rows land here, so a mis-measured wrap never clips.
Constraint::Min(msg_height),
Constraint::Length(1),
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.areas(content_area);
render_logo(logo_area, buf, theme, content_area.height);
let mut lines = vec![
Line::from(Span::styled(
instruction,
Style::default().fg(theme.gray_bright),
))
.alignment(Alignment::Center),
];
if let Some((text, style)) = status {
lines.push(Line::from(Span::styled(text, style)).alignment(Alignment::Center));
}
if let Some(error) = select.error.as_deref() {
lines.push(
Line::from(Span::styled(error, Style::default().fg(theme.warning)))
.alignment(Alignment::Center),
);
}
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(msg_area, buf);
let visible = rows_area.height as usize;
let start = select
.highlight
.saturating_add(1)
.saturating_sub(visible)
.min(select.rows.len().saturating_sub(visible));
let mut row_lines = Vec::new();
for (idx, row) in select.rows.iter().enumerate().skip(start).take(visible) {
let highlighted = idx == select.highlight;
let check = if row.selected { "[x]" } else { "[ ]" };
let suffix = if row.manual { " (manual)" } else { "" };
let style = if highlighted {
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray_bright)
};
let marker = if highlighted { "› " } else { " " };
row_lines.push(Line::from(Span::styled(
format!("{marker}{check} {}{suffix}", row.id),
style,
)));
}
Paragraph::new(row_lines)
.block(Block::default().padding(Padding::horizontal(h_pad)))
.render(rows_area, buf);
let [_, prompt_centered, _] = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(content_area.width),
Constraint::Min(0),
])
.flex(Flex::Center)
.areas(prompt_area);
render_auth_input_box(
prompt_centered,
buf,
theme,
input,
"Type a model id and press enter to add",
false,
);
let hints = Line::from(vec![
Span::styled(
"↑↓",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" navigate ", Style::default().fg(theme.gray)),
Span::styled(
"space",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" toggle ", Style::default().fg(theme.gray)),
Span::styled(
"enter",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" add model / done ", Style::default().fg(theme.gray)),
Span::styled(
"esc",
Style::default()
.fg(theme.accent_user)
.add_modifier(Modifier::BOLD),
),
Span::styled(" back", Style::default().fg(theme.gray)),
])
.alignment(Alignment::Center);
Paragraph::new(hints).render(hint_area, buf);
}
/// Render the auth token input box (loopback mode).
fn render_auth_input_box(
area: Rect,
@@ -2197,6 +2391,7 @@ mod tests {
login_label: None,
auth_code_input: "",
custom_entry: &EMPTY_CUSTOM_ENTRY,
custom_select: None,
clipboard_copied: false,
show_raw_url: false,
tip: None,
@@ -2409,12 +2604,87 @@ mod tests {
AuthMode::CustomProviderEntry(step),
input,
draft,
None,
false,
false,
);
buffer_text(&buf)
}
fn render_custom_select(
select: &crate::app::custom_entry::CustomSelectState,
input: &str,
) -> String {
let area = Rect::new(0, 0, 100, 40);
let mut buf = Buffer::empty(area);
render_welcome_authenticating(
area,
&mut buf,
&Theme::current(),
logo_line_count(area.height),
None,
AuthMode::CustomProviderSelect,
input,
&crate::app::custom_entry::CustomEntryDraft::new(),
Some(select),
false,
false,
);
buffer_text(&buf)
}
/// The selection screen lists rows, status, and the manual input together.
#[test]
fn custom_select_arm_lists_rows_status_and_input() {
use crate::app::custom_entry::CustomSelectState;
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let mut select = CustomSelectState::new(provider, "sk".to_owned());
let fetching = render_custom_select(&select, "");
assert!(fetching.contains("Fetching models"), "{fetching}");
assert!(fetching.contains("add model / done"), "{fetching}");
select.apply_fetch(kigi_shell::agent::custom_providers::LoginFetch {
provider: select.provider.clone(),
model_ids: vec!["m1".to_owned(), "m2".to_owned()],
});
select.toggle_highlighted();
select.add_manual("claude-opus-5-5[1m]");
let listed = render_custom_select(&select, "typed-so-far");
assert!(listed.contains("[x] m1"), "{listed}");
assert!(listed.contains("[ ] m2"), "{listed}");
assert!(listed.contains("claude-opus-5-5[1m] (manual)"), "{listed}");
assert!(listed.contains("typed-so-far"), "{listed}");
select.fail_fetch("boom".to_owned());
let failed = render_custom_select(&select, "");
assert!(failed.contains("Fetch failed: boom"), "{failed}");
// A two-hop failure reason must render whole, not clip at the wrap.
let reason = "https://h.example/models answered HTTP 404; https://h.example/v1/models also failed: https://h.example/v1/models answered HTTP 500";
select.fail_fetch(reason.to_owned());
let text = render_custom_select(&select, "");
assert!(text.contains("Fetch failed:"), "{text}");
assert!(text.contains("HTTP 500"), "{text}");
}
/// An empty selection refuses once the fetch resolved.
#[test]
fn custom_select_arm_shows_the_finish_refusal() {
use crate::app::custom_entry::CustomSelectState;
use kigi_shell::models::custom::{CustomApi, CustomProvider};
let provider =
CustomProvider::new("gw", CustomApi::OpenAi, "https://h.example/v1").unwrap();
let mut select = CustomSelectState::new(provider, "sk".to_owned());
select.fail_fetch("boom".to_owned());
assert!(select.try_finish().is_none());
let text = render_custom_select(&select, "");
assert!(text.contains("Select at least one model"), "{text}");
}
/// Only the key step masks its input.
#[test]
fn custom_entry_arm_masks_only_the_key() {
@@ -2479,6 +2749,7 @@ mod tests {
// auth_code_input
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url
@@ -3147,6 +3418,7 @@ mod tests {
// auth_code_input — unused in device mode
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url
@@ -3204,6 +3476,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3232,6 +3505,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3271,6 +3545,7 @@ mod tests {
AuthMode::Device,
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
false,
// show_raw_url
true,
@@ -3313,6 +3588,7 @@ mod tests {
// auth_code_input — unused
"",
&crate::app::custom_entry::CustomEntryDraft::new(),
None,
// clipboard_copied
false,
// show_raw_url
@@ -695,7 +695,7 @@ pub(crate) fn detect_platform() -> Result<(&'static str, &'static str)> {
/// Rust target triple for this build — the key that maps a platform to its
/// release-asset name. Must stay in lockstep with the five targets built by
/// `.github/workflows/release.yml` and the tables in install.sh/install.ps1.
/// `.gitea/workflows/release.yml` and the tables in install.sh/install.ps1.
pub(crate) fn target_triple() -> Result<&'static str> {
let triple = if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"aarch64-apple-darwin"
@@ -2653,7 +2653,7 @@ mod tests {
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
#[test]
fn test_release_asset_name_matches_release_workflow_naming() {
// Must stay in lockstep with .github/workflows/release.yml, which
// Must stay in lockstep with .gitea/workflows/release.yml, which
// publishes kigi-<version>-<target-triple>.{tar.gz|zip}.
let triple = target_triple().unwrap();
assert!(
+5 -5
View File
@@ -10,7 +10,7 @@ const TTL_SECONDS_BEFORE_AUTO_UPDATE: Duration = Duration::from_secs(60 * 30);
/// Release channel: this repo's Releases (PRD F8). The API base is resolved
/// through [`kigi_env::update_base_url`] — production default
/// `https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
/// `https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases`,
/// overridable via `KIGI_UPDATE_BASE_URL` for mirrors and tests.
pub(crate) fn update_base_url() -> String {
kigi_env::update_base_url()
@@ -533,7 +533,7 @@ mod tests {
#[test]
fn test_release_json_parses_github_wire_shape() {
let json = r#"{
"url": "https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases/1",
"url": "https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases/1",
"tag_name": "v0.1.0",
"name": "Kigi 0.1.0",
"draft": false,
@@ -541,13 +541,13 @@ mod tests {
"assets": [
{
"name": "kigi-0.1.0-aarch64-apple-darwin.tar.gz",
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
"browser_download_url": "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz",
"size": 123,
"content_type": "application/gzip"
},
{
"name": "SHA256SUMS",
"browser_download_url": "https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
"browser_download_url": "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/SHA256SUMS"
}
]
}"#;
@@ -560,7 +560,7 @@ mod tests {
let asset = r.asset("kigi-0.1.0-aarch64-apple-darwin.tar.gz").unwrap();
assert_eq!(
asset.browser_download_url,
"https://github.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
"https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/releases/download/v0.1.0/kigi-0.1.0-aarch64-apple-darwin.tar.gz"
);
assert_eq!(
r.asset("SHA256SUMS").unwrap().name,
+133 -25
View File
@@ -1,14 +1,17 @@
# Release checklist (PRD F8)
Kigi ships as a single-file binary for five targets, published on this
repo's GitHub Releases by `.github/workflows/release.yml`. Users install via
repo's Gitea Releases by `.gitea/workflows/release.yml`. Users install via
`install.sh` / `install.ps1` and stay current through the in-app
self-updater (`kigi-update`), which resolves the same Releases API.
`.github/workflows/release.yml` is the retired GitHub pipeline. Gitea ignores
`.github/workflows` entirely whenever `.gitea/workflows` is non-empty, so it
does not run — it is kept only as a reference for the GitHub-hosted layout.
## Cutting a release
1. Bump `[workspace.package] version` in `Cargo.toml` (and refresh
`Cargo.lock` with `cargo update --workspace`); land the change on
1. Bump `[workspace.package] version` in `Cargo.toml`; land the change on
`main` with green CI.
2. Regenerate the third-party notices (not enforced by CI — this is the
step that keeps `THIRD-PARTY-NOTICES.md` fresh):
@@ -31,10 +34,15 @@ self-updater (`kigi-update`), which resolves the same Releases API.
`release-dist` profile, packages
`kigi-<version>-<target-triple>.{tar.gz|zip}` archives (binary +
LICENSE + NOTICE + THIRD-PARTY-NOTICES), generates `SHA256SUMS`, and
publishes the GitHub Release with every asset attached in one step, so
`releases/latest` never serves a release whose checksum manifest is
missing. Tags containing `-` (e.g. `v0.2.0-alpha.1`) publish as
pre-releases, which only the `alpha` update channel picks up.
publishes the Gitea Release. Tags containing `-` (e.g.
`v0.2.0-alpha.1`) publish as pre-releases, which only the `alpha`
update channel picks up.
The release is created as a **draft** and only flipped public once
`SHA256SUMS` is attached, so `releases/latest` never hands the installer
a release whose checksum manifest is still missing. A failed run leaves
the draft in place; re-pushing the tag reuses it rather than erroring on
a duplicate `tag_name`.
5. Smoke-test an installed artifact:
```sh
@@ -44,27 +52,127 @@ self-updater (`kigi-update`), which resolves the same Releases API.
## Build runners
All five legs run on GitHub-hosted runners — nothing self-hosted to keep
alive:
Gitea has no hosted runners, so all three `runs-on` labels below are machines
we register ourselves with `gitea-runner` (the binary formerly called
`act_runner`). Labels are declared in the runner's `config.yaml` as
`<label>:host` (run directly on the machine) or `<label>:docker://<image>` (run
in a container) — with `gitea-runner` v2, `register --labels` is ignored
whenever a config file supplies them.
| `runs-on` | Builds |
| ------------------ | ------------------------------------------------------- |
| `macos-14` (arm64) | `aarch64-apple-darwin`, `x86_64-apple-darwin` (cross) |
| `ubuntu-24.04` | `x86_64-unknown-linux-gnu` |
| `ubuntu-24.04-arm` | `aarch64-unknown-linux-gnu` (native) |
| `windows-2022` | `x86_64-pc-windows-msvc` |
| Label | Machine | Mode | Builds |
| ---------------- | ------------------------------ | ------ | -------------------------------------------------- |
| `ubuntu-latest` | the Linux server | docker | `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` |
| `macos-arm64` | Mac Mini (M4) | host | `aarch64-apple-darwin`, `x86_64-apple-darwin` |
| `windows-amd64` | RaidenMei (Windows PC) | host | `x86_64-pc-windows-msvc` |
`warm-cache.yml` rebuilds the `release-dist` profile on `main` whenever the
dependency graph changes (plus a weekly refresh), so tag builds restore a
warm default-branch cache instead of compiling the workspace cold. Its setup
steps mirror `release.yml`'s build job — keep them in lockstep, or the cache
key stops matching.
The Linux legs use `ubuntu-latest` because that is what the Linux server's
runner already advertises. That image is a bare runner-image, so those legs
provision their own apt packages and rustup per run (`rustup show` then picks
up the `rust-toolchain.toml` pin). If you register a dedicated
`linux-x86_64:docker://…` runner later, change the three Linux `runs-on`
values back to match it.
> History note: between 2026-08 and 2026-09 this pipeline ran as
> `.gitea/workflows/release.yml` on self-hosted runners against
> `git.zacharyzhang.com` while the GitHub account was unavailable. The Gitea
> instance still hosts the pre-migration releases (`v0.1.12`, `v0.1.13`);
> everything from `v0.1.14` on is built and published here.
**The Mac is not optional.** Apple's SDK licence limits macOS builds to
Apple-branded hardware, and the Darwin targets need the real Apple linker
anyway (`.cargo/config.toml` passes `-ObjC` and `-undefined dynamic_lookup`).
There is no supported way to produce our macOS artifacts on the Linux server.
One Apple Silicon Mac covers both Darwin targets — `x86_64-apple-darwin` is a
cross-compile against the same SDK, exactly as the old GitHub `macos-14`
runner did it.
Windows is a softer requirement but still wants a real machine: the tree links
`ring`, `mimalloc`, `zstd-sys` and `libz-sys` against a static MSVC CRT
(`-C target-feature=+crt-static`). Cross-compiling that from Linux with
`cargo-xwin` is possible but means debugging four C builds under `clang-cl`;
a Windows runner with the VS 2022 C++ Build Tools reproduces today's binary
exactly.
Linux arm64 *is* cross-compiled on the x86_64 box — the workflow installs
`gcc-aarch64-linux-gnu` and points `cc-rs` at it. The one thing this loses is
the ability to smoke-test the arm64 binary before publishing (the old GitHub
pipeline did not test it either). Register a native arm64 runner and change
that matrix leg's `runner:` if you want it built natively.
### Registering a runner
Get a registration token from **Settings → Actions → Runners** (repo scope),
your user settings (all your repos) or the site admin panel (instance-wide).
Over the API it is a **POST**, not a GET — a GET is parsed as runner id `0` and
404s:
```sh
curl -fsS -X POST -H "Authorization: token $GITEA_API_TOKEN" \
https://git.zacharyzhang.com/api/v1/user/actions/runners/registration-token
```
Write the labels into `config.yaml` first (see `runner.labels` in
`gitea-runner generate-config`), then register on each machine:
```sh
# Linux (the Linux server) — container mode
gitea-runner --config config.yaml register --no-interactive \
--instance https://git.zacharyzhang.com \
--token <REGISTRATION_TOKEN> \
--name kigi-linux-x86_64
# macOS (Apple Silicon) — host mode, needs Xcode CLT + rustup + Node on PATH
gitea-runner --config config.yaml register --no-interactive \
--instance https://git.zacharyzhang.com \
--token <REGISTRATION_TOKEN> \
--name kigi-macos-arm64
# Windows — host mode, needs VS 2022 Build Tools + rustup + Node on PATH
gitea-runner --config config.yaml register --no-interactive `
--instance https://git.zacharyzhang.com `
--token <REGISTRATION_TOKEN> `
--name kigi-windows-amd64
```
Host-mode runners execute steps with the daemon's own environment, so each
needs its build prerequisites installed up front — and the supervisor that
launches the daemon must put them on `PATH`:
- **macOS**: `xcode-select --install`, `rustup`, and Node (for
`actions/checkout`, which is a JavaScript action).
- **Windows**: VS 2022 Build Tools with the "Desktop development with C++"
workload, `rustup`, Git for Windows, and Node. Every Windows step in the
workflow uses `shell: powershell` (5.1) — never `bash`, which on a normal
Windows box resolves to WSL.
- **Linux (docker mode)**: nothing on the host — the workflow installs
`build-essential`, `jq`, `unzip`, protoc, rustup and the cross toolchain
inside the container per run.
### Keeping the runners alive
Each host-mode runner is supervised so it survives reboots and crashes. Both
supervisors export `CARGO_HOME`/`RUSTUP_HOME` and prepend the toolchain, git
and node directories to `PATH`, because host-mode jobs inherit exactly the
daemon's environment.
| Machine | Supervisor | Manage with |
| -------- | --------------------------------------------- | ------------------------------------------------------ |
| Windows | Task Scheduler task `Gitea Runner (Kigi)` running `C:\Users\zhang\gitea-runner\run-runner.ps1` | `Get-ScheduledTask`, `Start-ScheduledTask`, `Stop-ScheduledTask` |
| macOS | LaunchAgent `com.kigi.gitea-runner` (`KeepAlive`) | `launchctl kickstart -k gui/$(id -u)/com.kigi.gitea-runner` |
`prompt.txt` at the repo root is a one-shot setup prompt for the Mac Mini leg.
Both runners also expose a liveness probe on `http://127.0.0.1:9101/healthz`.
### Gitea-specific things that differ from GitHub
- Workflows live in `.gitea/workflows/`. Once that directory is non-empty,
`.github/workflows/` is ignored completely — you cannot run both.
- The automatic token is `secrets.GITEA_TOKEN`, and the release steps call the
Gitea API directly (`POST /releases`, `POST /releases/{id}/assets`,
`PATCH /releases/{id}`) rather than `softprops/action-gh-release`, which
targets GitHub's API and does not work here.
- `uses:` actions are fetched from `github.com` by default
(`DEFAULT_ACTIONS_URL`), so the runners need outbound access to it — our
GitHub *account* being suspended does not affect downloading public actions.
Absolute URLs (`uses: https://gitea.com/actions/checkout@v4`) work too.
- Artifacts carry only the five checksum lines; the archives themselves go
straight to the release. If `actions/upload-artifact@v3` misbehaves on the
instance, the fallback is to drop the artifact steps and have `publish`
download the archives from the release and hash them there.
## Invariants to keep in lockstep
+1 -1
View File
@@ -11,4 +11,4 @@ Error generating stack: `+e.message+`
<path d="M12 16.25C11.5858 16.25 11.25 16.5858 11.25 17C11.25 17.4142 11.5858 17.75 12 17.75H17C17.4142 17.75 17.75 17.4142 17.75 17C17.75 16.5858 17.4142 16.25 17 16.25H12Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1318 2.25H5.86821C5.12513 2.24999 4.52196 2.24999 4.03273 2.28996C3.52778 2.33121 3.07851 2.41876 2.66103 2.63148C2.00247 2.96703 1.46703 3.50247 1.13148 4.16103C0.918762 4.57851 0.831214 5.02778 0.789958 5.53273C0.749986 6.02196 0.749993 6.62511 0.75 7.36819V16.6318C0.749993 17.3749 0.749986 17.978 0.789958 18.4673C0.831214 18.9722 0.918762 19.4215 1.13148 19.839C1.46703 20.4975 2.00247 21.033 2.66103 21.3685C3.07851 21.5812 3.52778 21.6688 4.03273 21.71C4.52195 21.75 5.12507 21.75 5.86811 21.75H18.1318C18.8748 21.75 19.4781 21.75 19.9673 21.71C20.4722 21.6688 20.9215 21.5812 21.339 21.3685C21.9975 21.033 22.533 20.4975 22.8685 19.839C23.0812 19.4215 23.1688 18.9722 23.21 18.4673C23.25 17.9781 23.25 17.3749 23.25 16.6319V7.36819C23.25 6.62515 23.25 6.02195 23.21 5.53273C23.1688 5.02778 23.0812 4.57851 22.8685 4.16103C22.533 3.50247 21.9975 2.96703 21.339 2.63148C20.9215 2.41876 20.4722 2.33121 19.9673 2.28996C19.478 2.24999 18.8749 2.24999 18.1318 2.25ZM21.75 7.25C21.7498 6.54743 21.7472 6.04838 21.715 5.65488C21.6813 5.24269 21.619 5.0128 21.532 4.84202C21.3403 4.4657 21.0343 4.15973 20.658 3.96799C20.4872 3.88097 20.2573 3.81865 19.8451 3.78498C19.4242 3.75058 18.8824 3.75 18.1 3.75H5.9C5.11755 3.75 4.57582 3.75058 4.15488 3.78498C3.74269 3.81865 3.5128 3.88097 3.34202 3.96799C2.9657 4.15973 2.65973 4.4657 2.46799 4.84202C2.38097 5.0128 2.31865 5.24269 2.28498 5.65488C2.25283 6.04838 2.25022 6.54743 2.25002 7.25H21.75ZM2.25 8.75H21.75V16.6C21.75 17.3824 21.7494 17.9242 21.715 18.3451C21.6813 18.7573 21.619 18.9872 21.532 19.158C21.3403 19.5343 21.0343 19.8403 20.658 20.032C20.4872 20.119 20.2573 20.1813 19.8451 20.215C19.4242 20.2494 18.8824 20.25 18.1 20.25H5.9C5.11755 20.25 4.57582 20.2494 4.15488 20.215C3.74269 20.1813 3.5128 20.119 3.34202 20.032C2.9657 19.8403 2.65973 19.5343 2.46799 19.158C2.38097 18.9872 2.31865 18.7573 2.28498 18.3451C2.25058 17.9242 2.25 17.3824 2.25 16.6V8.75Z" fill="currentColor"/>`,F:`<path d="M7.46967 10.4697C7.76256 10.1768 8.23744 10.1768 8.53033 10.4697L11.5303 13.4697C11.8232 13.7626 11.8232 14.2374 11.5303 14.5303L8.53033 17.5303C8.23744 17.8232 7.76256 17.8232 7.46967 17.5303C7.17678 17.2374 7.17678 16.7626 7.46967 16.4697L9.93934 14L7.46967 11.5303C7.17678 11.2374 7.17678 10.7626 7.46967 10.4697Z" fill="currentColor"/>
<path d="M12 16.25C11.5858 16.25 11.25 16.5858 11.25 17C11.25 17.4142 11.5858 17.75 12 17.75H17C17.4142 17.75 17.75 17.4142 17.75 17C17.75 16.5858 17.4142 16.25 17 16.25H12Z" fill="currentColor"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.03273 2.28996C4.52196 2.24999 5.12511 2.24999 5.86819 2.25H18.1318C18.8749 2.24999 19.478 2.24999 19.9673 2.28996C20.4722 2.33121 20.9215 2.41876 21.339 2.63148C21.9975 2.96703 22.533 3.50247 22.8685 4.16103C23.0812 4.57851 23.1688 5.02778 23.21 5.53273C23.25 6.02195 23.25 6.62507 23.25 7.36811V16.6318C23.25 17.3748 23.25 17.9781 23.21 18.4673C23.1688 18.9722 23.0812 19.4215 22.8685 19.839C22.533 20.4975 21.9975 21.033 21.339 21.3685C20.9215 21.5812 20.4722 21.6688 19.9673 21.71C19.4781 21.75 18.8749 21.75 18.1319 21.75H5.86819C5.12515 21.75 4.52195 21.75 4.03273 21.71C3.52778 21.6688 3.07851 21.5812 2.66103 21.3685C2.00247 21.033 1.46703 20.4975 1.13148 19.839C0.918762 19.4215 0.831214 18.9722 0.789958 18.4673C0.749986 17.978 0.749993 17.3749 0.75 16.6318V7.36821C0.749993 6.62513 0.749986 6.02196 0.789958 5.53273C0.831214 5.02778 0.918762 4.57851 1.13148 4.16103C1.46703 3.50247 2.00247 2.96703 2.66103 2.63148C3.07851 2.41876 3.52778 2.33121 4.03273 2.28996ZM2.25 8.75V16.6C2.25 17.3824 2.25058 17.9242 2.28498 18.3451C2.31865 18.7573 2.38097 18.9872 2.46799 19.158C2.65973 19.5343 2.9657 19.8403 3.34202 20.032C3.5128 20.119 3.74269 20.1813 4.15488 20.215C4.57582 20.2494 5.11755 20.25 5.9 20.25H18.1C18.8824 20.25 19.4242 20.2494 19.8451 20.215C20.2573 20.1813 20.4872 20.119 20.658 20.032C21.0343 19.8403 21.3403 19.5343 21.532 19.158C21.619 18.9872 21.6813 18.7573 21.715 18.3451C21.7494 17.9242 21.75 17.3824 21.75 16.6V8.75H2.25Z" fill="currentColor"/>`}),m=d(`Check`,{O:`<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>`,F:`<path d="M21.7071 5.29289C22.0976 5.68342 22.0976 6.31658 21.7071 6.70711L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071L2.29289 12.7071C1.90237 12.3166 1.90237 11.6834 2.29289 11.2929C2.68342 10.9024 3.31658 10.9024 3.70711 11.2929L9 16.5858L20.2929 5.29289C20.6834 4.90237 21.3166 4.90237 21.7071 5.29289Z" fill="currentColor"/>`}),h=d(`ChevronDown`,{F:`<g transform="scale(1.33333)"><path d="M9,13.5c-.192,0-.384-.073-.53-.22L2.22,7.03c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l5.72,5.72,5.72-5.72c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-6.25,6.25c-.146,.146-.338,.22-.53,.22Z" fill="currentColor"></path></g>`,O:`<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>`}),g=d(`Code`,{O:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z" fill="currentColor"/><path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z" fill="currentColor"/>`,F:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.331Line truncated
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.03273 2.28996C4.52196 2.24999 5.12511 2.24999 5.86819 2.25H18.1318C18.8749 2.24999 19.478 2.24999 19.9673 2.28996C20.4722 2.33121 20.9215 2.41876 21.339 2.63148C21.9975 2.96703 22.533 3.50247 22.8685 4.16103C23.0812 4.57851 23.1688 5.02778 23.21 5.53273C23.25 6.02195 23.25 6.62507 23.25 7.36811V16.6318C23.25 17.3748 23.25 17.9781 23.21 18.4673C23.1688 18.9722 23.0812 19.4215 22.8685 19.839C22.533 20.4975 21.9975 21.033 21.339 21.3685C20.9215 21.5812 20.4722 21.6688 19.9673 21.71C19.4781 21.75 18.8749 21.75 18.1319 21.75H5.86819C5.12515 21.75 4.52195 21.75 4.03273 21.71C3.52778 21.6688 3.07851 21.5812 2.66103 21.3685C2.00247 21.033 1.46703 20.4975 1.13148 19.839C0.918762 19.4215 0.831214 18.9722 0.789958 18.4673C0.749986 17.978 0.749993 17.3749 0.75 16.6318V7.36821C0.749993 6.62513 0.749986 6.02196 0.789958 5.53273C0.831214 5.02778 0.918762 4.57851 1.13148 4.16103C1.46703 3.50247 2.00247 2.96703 2.66103 2.63148C3.07851 2.41876 3.52778 2.33121 4.03273 2.28996ZM2.25 8.75V16.6C2.25 17.3824 2.25058 17.9242 2.28498 18.3451C2.31865 18.7573 2.38097 18.9872 2.46799 19.158C2.65973 19.5343 2.9657 19.8403 3.34202 20.032C3.5128 20.119 3.74269 20.1813 4.15488 20.215C4.57582 20.2494 5.11755 20.25 5.9 20.25H18.1C18.8824 20.25 19.4242 20.2494 19.8451 20.215C20.2573 20.1813 20.4872 20.119 20.658 20.032C21.0343 19.8403 21.3403 19.5343 21.532 19.158C21.619 18.9872 21.6813 18.7573 21.715 18.3451C21.7494 17.9242 21.75 17.3824 21.75 16.6V8.75H2.25Z" fill="currentColor"/>`}),m=d(`Check`,{O:`<path d="M21.5303 5.46967C21.8232 5.76256 21.8232 6.23744 21.5303 6.53033L9.53033 18.5303C9.23744 18.8232 8.76256 18.8232 8.46967 18.5303L2.46967 12.5303C2.17678 12.2374 2.17678 11.7626 2.46967 11.4697C2.76256 11.1768 3.23744 11.1768 3.53033 11.4697L9 16.9393L20.4697 5.46967C20.7626 5.17678 21.2374 5.17678 21.5303 5.46967Z" fill="currentColor"/>`,F:`<path d="M21.7071 5.29289C22.0976 5.68342 22.0976 6.31658 21.7071 6.70711L9.70711 18.7071C9.31658 19.0976 8.68342 19.0976 8.29289 18.7071L2.29289 12.7071C1.90237 12.3166 1.90237 11.6834 2.29289 11.2929C2.68342 10.9024 3.31658 10.9024 3.70711 11.2929L9 16.5858L20.2929 5.29289C20.6834 4.90237 21.3166 4.90237 21.7071 5.29289Z" fill="currentColor"/>`}),h=d(`ChevronDown`,{F:`<g transform="scale(1.33333)"><path d="M9,13.5c-.192,0-.384-.073-.53-.22L2.22,7.03c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l5.72,5.72,5.72-5.72c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-6.25,6.25c-.146,.146-.338,.22-.53,.22Z" fill="currentColor"></path></g>`,O:`<g transform="scale(1.33333)"><polyline points="15.25 6.5 9 12.75 2.75 6.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></polyline></g>`}),g=d(`Code`,{O:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.3313C16.1654 17.0234 16.1904 16.5492 16.4983 16.2721L18.1947 14.7452C18.9826 14.0362 19.5138 13.5558 19.8579 13.1467C20.1882 12.7541 20.2603 12.525 20.2603 12.3295C20.2603 12.1341 20.1882 11.9049 19.8579 11.5123C19.5138 11.1033 18.9826 10.6229 18.1947 9.91383L16.4983 8.387C16.1904 8.10991 16.1654 7.63569 16.4425 7.32781Z" fill="currentColor"/><path d="M7.50178 8.387C7.80966 8.10991 7.83462 7.63569 7.55752 7.32781C7.28043 7.01993 6.80621 6.99497 6.49833 7.27206L4.76084 8.8358C4.0245 9.49847 3.41369 10.0482 2.99428 10.5467C2.55325 11.071 2.23975 11.6342 2.23975 12.3295C2.23975 13.0248 2.55325 13.5881 2.99428 14.1123C3.41369 14.6109 4.02449 15.1606 4.76082 15.8232L6.49833 17.387C6.80621 17.6641 7.28043 17.6391 7.55752 17.3313C7.83462 17.0234 7.80966 16.5492 7.50178 16.2721L5.80531 14.7452C5.01743 14.0362 4.48623 13.5558 4.14213 13.1467C3.81188 12.7541 3.73975 12.525 3.73975 12.3295C3.73975 12.1341 3.81188 11.9049 4.14213 11.5123C4.48623 11.1033 5.01743 10.6229 5.80531 9.91383L7.50178 8.387Z" fill="currentColor"/>`,F:`<path d="M14.1809 4.2755C14.581 4.3827 14.8185 4.79396 14.7113 5.19406L10.7377 20.0238C10.6304 20.4239 10.2192 20.6613 9.81909 20.5541C9.41899 20.4469 9.18156 20.0356 9.28876 19.6355L13.2624 4.80583C13.3696 4.40573 13.7808 4.16829 14.1809 4.2755Z" fill="currentColor"/><path d="M16.4425 7.32781C16.7196 7.01993 17.1938 6.99497 17.5017 7.27206L19.2392 8.8358C19.9756 9.49847 20.5864 10.0482 21.0058 10.5467C21.4468 11.071 21.7603 11.6342 21.7603 12.3295C21.7603 13.0248 21.4468 13.5881 21.0058 14.1123C20.5864 14.6109 19.9756 15.1606 19.2392 15.8233L17.5017 17.387C17.1938 17.6641 16.7196 17.6391 16.4425 17.331Line truncated
+3 -3
View File
@@ -16,11 +16,11 @@ interface Env {
ASSETS: Fetcher;
}
/** Raw-file base for the canonical Kigi repo (GitHub, `main` branch). */
const UPSTREAM = "https://raw.githubusercontent.com/ZacharyZhang-NY/Kigi-CLI/main";
/** Raw-file base for the canonical Kigi repo (Gitea, `main` branch). */
const UPSTREAM = "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI/raw/branch/main";
/** Human-facing repo URL, used in the installer-unavailable message. */
const REPO_URL = "https://github.com/ZacharyZhang-NY/Kigi-CLI";
const REPO_URL = "https://git.zacharyzhang.com/ZacharyZhang-NY/Kigi-CLI";
interface Installer {
file: string;
+2 -2
View File
@@ -11,7 +11,7 @@
# Environment:
# KIGI_SHARE_DIR install root (default: %USERPROFILE%\.kigi)
# KIGI_UPDATE_BASE_URL releases API base, GitHub/Gitea-shaped (default:
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
# https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases)
[CmdletBinding()]
param(
@@ -27,7 +27,7 @@ function Fail([string]$Message) {
}
$Repo = "ZacharyZhang-NY/Kigi-CLI"
$ApiBase = if ($env:KIGI_UPDATE_BASE_URL) { $env:KIGI_UPDATE_BASE_URL } else { "https://api.github.com/repos/$Repo/releases" }
$ApiBase = if ($env:KIGI_UPDATE_BASE_URL) { $env:KIGI_UPDATE_BASE_URL } else { "https://git.zacharyzhang.com/api/v1/repos/$Repo/releases" }
$KigiHome = if ($env:KIGI_SHARE_DIR) { $env:KIGI_SHARE_DIR } else { Join-Path $env:USERPROFILE ".kigi" }
$Triple = "x86_64-pc-windows-msvc"
+2 -2
View File
@@ -14,14 +14,14 @@
# Environment:
# KIGI_SHARE_DIR install root (default: ~/.kigi)
# KIGI_UPDATE_BASE_URL releases API base, GitHub/Gitea-shaped (default:
# https://api.github.com/repos/ZacharyZhang-NY/Kigi-CLI/releases)
# https://git.zacharyzhang.com/api/v1/repos/ZacharyZhang-NY/Kigi-CLI/releases)
#
# Fails fast on any error; never leaves a partial binary as the active kigi.
set -eu
REPO="ZacharyZhang-NY/Kigi-CLI"
API_BASE="${KIGI_UPDATE_BASE_URL:-https://api.github.com/repos/${REPO}/releases}"
API_BASE="${KIGI_UPDATE_BASE_URL:-https://git.zacharyzhang.com/api/v1/repos/${REPO}/releases}"
KIGI_HOME="${KIGI_SHARE_DIR:-$HOME/.kigi}"
err() {
+107
View File
@@ -0,0 +1,107 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
```bash
npm run dev # human-operated foreground preview (blocks until stopped)
npx hyperframes preview --background # agent-safe persistent Studio preview
npx hyperframes preview --status # verify the persistent preview is listening
npx hyperframes preview --stop # stop it when review is finished
npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely
> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process
> remains owned by the invoking session and can disappear while the browser stays open,
> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it
> alive through review, and stop it explicitly with `preview --stop` afterward.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
```bash
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
```
https://hyperframes.heygen.com/llms.txt
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
```bash
npm run check
```
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
3. Register one paused root timeline per composition on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
Scene timelines manually added to this root must not be paused. A paused
child does not advance when the root is seeked. The runtime activates
registered composition siblings, not arbitrary nested scene timelines.
4. A video with sound keeps it on the `<video>` (`data-has-audio="true"`, no `muted`). Use a separate `<audio>` for music, voiceover, replacement audio, J/L cuts, or audio detached in Studio. Silent footage and b-roll: `muted`.
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
+107
View File
@@ -0,0 +1,107 @@
# HyperFrames Composition Project
## Skills — USE THESE FIRST
**Always invoke the relevant skill before writing or modifying compositions.** Skills encode framework-specific patterns (e.g., `window.__timelines` registration, `data-*` attribute semantics, shader-compatible CSS rules) that are NOT in generic web docs. Skipping them produces broken compositions.
**Doing anything with HyperFrames?** Start at `/hyperframes` — it tells you what HyperFrames can do and which skill or workflow handles your intent (make a video, TTS / BGM, prep footage, author / animate, render, install blocks), confirms your brief up front (the intent layer), and routes every "make me a…" request (a video, a deck, a composition port) to the right workflow. Read it first, especially when there's no project context to orient you. The workflows it routes to:
- `/product-launch-video` — any **website** URL or brief / script → a product launch / SaaS / promo video, or a site tour / showcase featuring the site's own captured visuals.
- `/faceless-explainer` — arbitrary text (topic / article / notes), **no URL, no website capture** → 60-90s faceless explainer.
- `/embedded-captions` — an existing talking-head video (MP4) → the same footage with captions / subtitles added (rail + embed, or pure-cinematic embed); the footage itself is untouched.
- `/talking-head-recut` — an existing talking-head / interview / podcast video (MP4) → the same footage **packaged with designed graphic overlays** (kinetic titles, lower-thirds, data callouts, pull-quotes, side panels, pip) synced to the transcript; the clip plays unchanged underneath. (Plain captions/subtitles → `/embedded-captions`.)
- `/pr-to-video` — a GitHub PR (URL / `owner/repo#N` / "this PR") → 30-90s code-change explainer (changelog / feature reveal / fix / refactor).
- `/motion-graphics` — a short (typically under 10s) design-led **motion graphic**, motion-is-the-message, no narration: kinetic type, a stat / number count-up, a chart, a logo sting, a lower-third / overlay, or an animated tweet / headline / captured-page highlight; rendered to MP4 or a transparent overlay. Longer / narrated / custom → `/general-video`.
- `/music-to-video` — a **music track** (audio file, video to pull audio from, or one generated from a mood brief) → beat-synced video (lyric / slideshow / kinetic promo). Music drives pacing; user-supplied images / videos are cut onto the same beat grid.
- `/slideshow` — a **presentation / pitch deck / interactive deck** — discrete slides, fragment reveals, branching, hotspot navigation, presenter mode. Output is a navigable deck, not a rendered video.
- `/general-video` — fallback for any other video (title card, longer brand / sizzle reel, multi-scene montage, static loop, custom composition) and the home of **companion mode** — co-create with the full HyperFrames toolbox; the original hyperframes authoring flow, any length.
**Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above.
The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-keyframes`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-audio`, `/hyperframes-registry`, `/figma`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent.
**Changing how real footage or images look or reveal?** Load `/media-use` and read its `references/media-treatments.md` before editing, even when the request only says dark, flat, boring, retro, private, or “make the reveal cooler.” It governs how footage is treated, never whether media may be used. Use canonical media treatments and seek-safe motion; do not improvise equivalent CSS/SVG filters or overlays.
> **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`.
> **Using a HyperFrames plugin?** Load skills from that installed bundle and follow
> its `hyperframes/references/plugin-installation.md` execution rules. Update via
> the plugin manager, not the standalone commands below.
>
> **Standalone skill missing or stale?** Run `npx hyperframes skills update <name>` to install/refresh
> the specific skill you need (the `/hyperframes` router does this automatically before
> entering a workflow), or bare `npx hyperframes skills update` to refresh the core set plus
> everything already installed — neither pulls the full set. Restart the agent session so
> newly installed skills load.
## Commands
```bash
npm run dev # human-operated foreground preview (blocks until stopped)
npx hyperframes preview --background # agent-safe persistent Studio preview
npx hyperframes preview --status # verify the persistent preview is listening
npx hyperframes preview --stop # stop it when review is finished
npm run check # lint + runtime + layout + motion + contrast (one command)
npm run render # render to MP4
npm run publish # publish and get a shareable link
npx hyperframes lint --verbose # include info-level findings
npx hyperframes lint --json # machine-readable output for CI
npx hyperframes docs <topic> # reference docs in terminal
```
> **Agents must use `npx hyperframes preview --background` for Studio handoff.** Do not rely
> on a shell/tool `run_in_background` wrapper around `npm run dev`: that foreground process
> remains owned by the invoking session and can disappear while the browser stays open,
> leaving refreshes at `ERR_CONNECTION_TIMED_OUT`. Verify with `preview --status`, keep it
> alive through review, and stop it explicitly with `preview --stop` afterward.
> **Pinned CLI version.** These scripts pin an exact `hyperframes@X.Y.Z` so this project re-renders identically over time. Weeks later that pin lags fixes shipped since. To move up: `npx hyperframes@latest upgrade --project . --check` (shows the delta), then `npx hyperframes@latest upgrade --project .` to rewrite the pins. Always unpinned — the pinned script re-runs the old version against itself.
## Documentation
**For quick reference**, use the local CLI docs command (no network required):
```bash
npx hyperframes docs <topic>
```
Topics: `data-attributes`, `gsap`, `compositions`, `rendering`, `examples`, `troubleshooting`
**For full documentation**, discover pages via the machine-readable index — do NOT guess URLs:
```
https://hyperframes.heygen.com/llms.txt
```
## Project Structure
- `index.html` — main composition (root timeline)
- `compositions/` — sub-compositions referenced via `data-composition-src`
- `meta.json` — project metadata (id, name)
- `transcript.json` — whisper word-level transcript (if generated)
## Linting — ALWAYS RUN AFTER CHANGES
After creating or editing any `.html` composition, **always** run the full check before considering the task complete:
```bash
npm run check
```
Fix all errors before presenting the result. Warnings should be reviewed before rendering.
## Key Rules
1. Every timed element needs `data-start` and a duration. `data-start` is what marks it as timed; `data-track-index` is an optional Studio display lane the render never reads
2. Give timed visual elements `class="clip"`. The framework keys visibility off `data-start`, not the class, but the shared `.clip` CSS is what gives a scene its full-frame box, and `lint` warns without it
3. Register one paused root timeline per composition on `window.__timelines`:
```js
window.__timelines = window.__timelines || {};
window.__timelines["composition-id"] = gsap.timeline({ paused: true });
```
Scene timelines manually added to this root must not be paused. A paused
child does not advance when the root is seeked. The runtime activates
registered composition siblings, not arbitrary nested scene timelines.
4. A video with sound keeps it on the `<video>` (`data-has-audio="true"`, no `muted`). Use a separate `<audio>` for music, voiceover, replacement audio, J/L cuts, or audio detached in Studio. Silent footage and b-roll: `muted`.
5. Sub-compositions use `data-composition-src="compositions/file.html"` to reference other HTML files
6. Only deterministic logic — no `Date.now()`, no `Math.random()`, no network fetches
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
import json, pathlib, concurrent.futures as cf
from tts import tts
VOICE = "moss_audio_ce44fc67-7ce3-11f0-8de5-96e35d26fb85"
script = json.load(open("script.json", encoding="utf-8"))
def run(sc):
text = "".join(s.get("tts", s["zh"]) for s in sc["segs"])
info, subs = tts(text, f"audio/vo_{sc['id']}.mp3", VOICE, speed=1.05)
words = [w for seg in subs for w in seg["timestamped_words"]]
# map segment char ranges -> times
out, pos = [], 0
for s in sc["segs"]:
t = s.get("tts", s["zh"]); a, b = pos, pos + len(t); pos = b
ws = [w for w in words if w["word_begin"] >= a and w["word_end"] <= b and w["word"].strip(",。:;——?!、“” ")]
out.append({"zh": s["zh"], "en": s["en"], "t0": ws[0]["time_begin"]/1000, "t1": ws[-1]["time_end"]/1000})
return sc["id"], {"len": info["audio_length"]/1000, "segs": out}
with cf.ThreadPoolExecutor(6) as ex:
res = dict(ex.map(run, script))
json.dump({k: res[k] for k in sorted(res)}, open("vo_timing.json","w",encoding="utf-8"), ensure_ascii=False, indent=1)
for k in sorted(res): print(k, res[k]["len"], [ (round(s["t0"],2), round(s["t1"],2)) for s in res[k]["segs"]])
@@ -0,0 +1,12 @@
{
"$schema": "https://hyperframes.heygen.com/schema/hyperframes.json",
"registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
"paths": {
"blocks": "compositions",
"components": "compositions/components",
"assets": "assets"
},
"media": {
"autoProxy": true
}
}
+514
View File
@@ -0,0 +1,514 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1920, height=1080" />
<title>Kigi /graph — Bauhaus</title>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
@font-face { font-family: "Jost"; src: url("fonts/Jost.ttf") format("truetype"); font-weight: 100 900; }
@font-face { font-family: "Noto Sans SC"; src: url("fonts/NotoSansSC.ttf") format("truetype"); font-weight: 100 900; }
@font-face { font-family: "JetBrains Mono"; src: url("fonts/JetBrainsMono.ttf") format("truetype"); font-weight: 100 800; }
:root { --paper:#EEE6D6; --ink:#141414; --red:#D42A1E; --yellow:#F2B300; --blue:#1D4FA0; --grey:#BDB5A6; }
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:1920px; height:1080px; overflow:hidden; background:var(--paper); }
#root { position:relative; width:100%; height:100%; overflow:hidden; font-family:"Jost", sans-serif; color:var(--ink); }
.layer { position:absolute; inset:0; }
.abs { position:absolute; display:block; }
.mono { font-family:"JetBrains Mono", monospace; }
.zh { font-family:"Noto Sans SC", sans-serif; }
.sq { background:var(--red); }
.ci { background:var(--blue); border-radius:50%; }
.tri { background:var(--yellow); clip-path:polygon(50% 0, 100% 100%, 0 100%); }
.nowrap { white-space:nowrap; }
/* grid */
.gl { position:absolute; background:var(--ink); opacity:.08; }
.glv { width:1px; top:0; height:1080px; transform-origin:50% 0; }
.glh { height:1px; left:0; width:1920px; transform-origin:0 50%; }
/* s1 */
#s1-goal { left:1080px; top:120px; width:600px; height:600px; }
.s1-cell { position:absolute; width:200px; height:200px; background:var(--ink); }
#s1-goal-txt { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--paper); }
#s1-goal-zh { font-size:180px; font-weight:900; line-height:1; }
#s1-goal-en { font-size:44px; font-weight:600; letter-spacing:.4em; margin-top:24px; padding-left:.4em; }
#s1-agent { left:240px; top:600px; width:120px; height:120px; }
#s1-agent-in { width:120px; height:120px; }
#s1-agent-dot { width:120px; height:120px; }
#s1-agent-lbl { left:240px; top:744px; font-size:22px; letter-spacing:.2em; font-weight:600; }
#s1-svg { position:absolute; left:0; top:0; }
#s1-floor { left:120px; top:720px; width:1680px; height:8px; background:var(--ink); transform-origin:0 50%; }
/* s2 */
#s2-field { left:0; top:0; width:1080px; height:1080px; background:var(--red); }
#s2-kigi { left:104px; top:70px; display:flex; font-size:330px; font-weight:800; line-height:1; color:var(--paper); letter-spacing:-.01em; }
#s2-kigi span { display:block; }
#s2-slash { left:120px; top:470px; height:170px; overflow:hidden; font-size:150px; font-weight:700; line-height:170px; color:var(--ink); width:0; }
#s2-cursor { left:120px; top:490px; width:80px; height:130px; background:var(--ink); }
#s2-tu { left:1188px; top:60px; font-size:560px; font-weight:900; line-height:1; }
#s2-circle { left:840px; top:420px; width:360px; height:360px; mix-blend-mode:multiply; }
#s2-tri { left:1500px; top:600px; width:300px; height:240px; transform-origin:50% 100%; }
#s2-sub { left:120px; top:720px; font-size:30px; font-weight:600; letter-spacing:.32em; color:var(--paper); }
/* s3 */
#s3-cam { position:absolute; inset:0; transform-origin:960px 360px; }
.s3-node { position:absolute; top:240px; width:240px; height:240px; }
.s3-ring { position:absolute; left:40px; top:40px; width:160px; height:160px; border:3px solid var(--paper); border-radius:50%; opacity:.9; }
.s3-orb { position:absolute; left:0; top:0; width:240px; height:240px; }
.s3-ball { position:absolute; left:96px; top:16px; width:48px; height:48px; }
.s3-tag { position:absolute; font-size:11px; color:var(--paper); letter-spacing:.12em; }
.s3-edge { position:absolute; top:356px; height:8px; width:360px; background:var(--ink); transform-origin:0 50%; }
.s3-ruler { position:absolute; top:372px; height:14px; width:360px; transform-origin:0 50%;
background:repeating-linear-gradient(90deg, var(--ink) 0 2px, transparent 2px 30px); }
.s3-arrow { position:absolute; top:340px; width:28px; height:40px; background:var(--ink); clip-path:polygon(0 0, 100% 50%, 0 100%); }
.s3-elbl { position:absolute; top:396px; font-size:22px; font-weight:500; }
#s3-big1 { left:120px; top:540px; font-size:104px; font-weight:800; line-height:1; color:var(--blue); letter-spacing:-.01em; }
#s3-big2 { left:120px; top:660px; font-size:104px; font-weight:800; line-height:1; letter-spacing:-.01em; }
.s3-leg { position:absolute; left:1200px; display:flex; align-items:center; gap:28px; height:64px; }
.s3-leg-shape { width:56px; height:56px; flex:none; }
.s3-leg-txt { font-size:30px; font-weight:700; letter-spacing:.08em; }
.s3-leg-txt .zh { font-size:26px; font-weight:500; margin-left:14px; letter-spacing:0; }
#s3-foot { left:1200px; top:780px; font-size:17px; opacity:.75; letter-spacing:.04em; }
/* s4 */
#s4-ground { background:var(--ink); }
#s4-head { left:120px; top:84px; font-size:24px; color:var(--paper); letter-spacing:.06em; overflow:hidden; height:32px; width:0; }
#s4-x3 { left:1560px; top:40px; font-size:150px; font-weight:800; line-height:1; color:var(--yellow); }
.s4-lane { position:absolute; left:360px; width:1320px; height:3px; background:var(--paper); transform-origin:0 50%; }
.s4-lbl { position:absolute; left:360px; font-size:20px; color:var(--paper); opacity:.8; }
.s4-node { position:absolute; width:96px; height:96px; }
.s4-w { position:absolute; width:72px; height:72px; }
.s4-commit { position:absolute; width:24px; height:24px; background:var(--paper); }
.s4-tri { position:absolute; left:1584px; width:96px; height:84px; transform-origin:50% 100%; }
.s4-check { position:absolute; left:1584px; width:96px; text-align:center; font-size:40px; font-weight:800; color:var(--ink); }
.s4-verdict { position:absolute; left:1176px; font-size:20px; text-align:right; width:380px; }
.s4-round { position:absolute; left:1700px; font-size:22px; color:var(--paper); width:100px; }
/* s5 */
#s5-yellow { background:var(--yellow); clip-path:circle(0% at 960px 690px); }
#s5-svg { position:absolute; left:0; top:0; }
.s5-node { position:absolute; width:96px; height:96px; }
.s5-name { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; color:var(--paper); font-size:22px; font-weight:600; letter-spacing:.06em; }
.s5-id { position:absolute; width:160px; text-align:center; font-size:14px; }
.s5-flesh { position:absolute; left:18px; top:18px; width:60px; height:60px; }
#s5-final { left:810px; top:560px; width:300px; height:260px; transform-origin:50% 100%; }
#s5-final-lbl { left:1150px; top:700px; font-size:22px; }
#s5-final-lbl b { display:block; font-family:"Jost"; font-size:34px; font-weight:700; letter-spacing:.12em; }
#s5-final-lbl i { display:block; font-style:normal; font-family:"Noto Sans SC"; font-size:24px; font-weight:700; }
#s5-bones-wrap { left:-220px; top:410px; width:800px; height:100px; transform:rotate(-90deg); }
#s5-bones { font-size:62px; font-weight:800; line-height:100px; text-align:center; letter-spacing:.02em; }
#s5-flesh-wrap { left:1340px; top:410px; width:800px; height:100px; transform:rotate(90deg); }
#s5-flesh-t { font-size:62px; font-weight:800; line-height:100px; text-align:center; color:var(--blue); letter-spacing:.02em; }
#s5-zh1 { left:270px; top:120px; writing-mode:vertical-rl; font-size:44px; font-weight:900; letter-spacing:.2em; }
#s5-zh2 { left:1594px; top:120px; writing-mode:vertical-rl; font-size:44px; font-weight:900; letter-spacing:.2em; color:var(--blue); }
/* logo */
#s5-logo { background:var(--paper); }
#s5-kigi { left:104px; top:110px; font-size:340px; font-weight:800; line-height:1; letter-spacing:-.01em; }
#s5-kigi span { display:inline-block; }
#s5-graph { left:120px; top:470px; font-size:130px; font-weight:700; color:var(--red); line-height:1; overflow:hidden; height:150px; width:0; }
#s5-l-sq { left:1080px; top:300px; width:200px; height:200px; }
#s5-l-ci { left:1320px; top:300px; width:200px; height:200px; }
#s5-l-tri { left:1560px; top:300px; width:220px; height:200px; transform-origin:50% 100%; }
#s5-rule { left:1080px; top:560px; width:700px; height:8px; background:var(--ink); transform-origin:0 50%; }
#s5-tag { left:1080px; top:596px; font-size:22px; letter-spacing:.06em; }
#s5-tag2 { left:1080px; top:640px; font-size:28px; font-weight:700; }
#s5-cursor { width:22px; height:40px; background:var(--red); display:inline-block; vertical-align:-6px; margin-left:8px; }
/* transitions */
.wipe { position:absolute; left:0; top:0; width:1920px; height:1080px; }
/* HUD */
#hud { position:absolute; inset:0; pointer-events:none; }
#hud .hud-l { left:40px; top:22px; font-size:15px; letter-spacing:.14em; font-weight:500; }
#hud .hud-l span { font-weight:800; }
.beat { position:absolute; top:24px; width:14px; height:14px; border:2px solid var(--ink); }
/* captions */
#band { position:absolute; left:0; top:880px; width:1920px; height:200px; background:var(--paper); }
#band-rule { left:120px; top:888px; width:1680px; height:3px; background:var(--ink); transform-origin:0 50%; }
#band-mark { left:120px; top:922px; width:28px; height:28px; background:var(--red); }
.cap { position:absolute; left:180px; top:906px; width:1620px; height:150px; }
.cap-zh { position:absolute; left:0; top:0; font-family:"Noto Sans SC"; font-size:42px; font-weight:700; line-height:60px; white-space:nowrap; }
.cap-en { position:absolute; left:0; top:68px; font-size:28px; font-weight:500; line-height:40px; letter-spacing:.02em; opacity:.72; white-space:nowrap; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="34" data-width="1920" data-height="1080">
<div id="bg" class="clip layer" data-start="0" data-duration="34" data-track-index="0" style="background:#EEE6D6"></div>
<div id="grid" class="clip layer" data-start="0" data-duration="34" data-track-index="1"></div>
<!-- ============ S1 · HOOK ============ -->
<section id="sc1" class="clip layer" data-start="0" data-duration="6" data-track-index="2">
<div id="s1-floor" class="abs"></div>
<svg id="s1-svg" width="1920" height="1080" viewBox="0 0 1920 1080">
<path id="s1-path" d="M840 660 C 700 300, 300 820, 420 420 S 900 180, 660 560 S 180 780, 300 300 S 780 120, 560 690 S 200 520, 380 640"
fill="none" stroke="#141414" stroke-width="6" stroke-linecap="round" />
</svg>
<div id="s1-goal" class="abs">
<div class="s1-cell" style="left:0;top:0"></div><div class="s1-cell" style="left:200px;top:0"></div><div class="s1-cell" style="left:400px;top:0"></div>
<div class="s1-cell" style="left:0;top:200px"></div><div class="s1-cell" style="left:200px;top:200px"></div><div class="s1-cell" style="left:400px;top:200px"></div>
<div class="s1-cell" style="left:0;top:400px"></div><div class="s1-cell" style="left:200px;top:400px"></div><div class="s1-cell" style="left:400px;top:400px"></div>
<div id="s1-goal-txt"><div id="s1-goal-zh" class="zh">目标</div><div id="s1-goal-en">GOAL</div></div>
</div>
<div id="s1-agent" class="abs"><div id="s1-agent-in" class="abs"><div id="s1-agent-dot" class="ci abs"></div></div></div>
<div id="s1-agent-lbl" class="abs nowrap">AGENT <span class="zh">智能体</span></div>
</section>
<!-- ============ S2 · TITLE ============ -->
<section id="sc2" class="clip layer" data-start="6" data-duration="4" data-track-index="2">
<div id="s2-field" class="abs"></div>
<div id="s2-kigi" class="abs"><span>K</span><span>I</span><span>G</span><span>I</span></div>
<div id="s2-slash" class="abs mono nowrap">/graph</div>
<div id="s2-cursor" class="abs"></div>
<div id="s2-tu" class="abs zh">图</div>
<div id="s2-circle" class="abs ci"></div>
<div id="s2-tri" class="abs tri"></div>
<div id="s2-sub" class="abs nowrap">GRAPH ENGINE · <span class="zh">图引擎</span></div>
</section>
<!-- ============ S3 · PRINCIPLE ============ -->
<section id="sc3" class="clip layer" data-start="10" data-duration="8" data-track-index="2">
<div id="s3-cam">
<div class="s3-edge" id="s3-e1" style="left:480px"></div><div class="s3-ruler" id="s3-r1" style="left:480px"></div>
<div class="s3-edge" id="s3-e2" style="left:1080px"></div><div class="s3-ruler" id="s3-r2" style="left:1080px"></div>
<div class="s3-arrow" id="s3-a1" style="left:812px"></div><div class="s3-arrow" id="s3-a2" style="left:1412px"></div>
<div class="s3-elbl mono nowrap" id="s3-l1" style="left:492px">fn ready() → bool</div>
<div class="s3-elbl mono nowrap" id="s3-l2" style="left:1092px">DepKind::Blocks</div>
<div class="s3-node sq" id="s3-n1" style="left:240px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o1"><div class="s3-ball ci"></div></div></div>
<div class="s3-node sq" id="s3-n2" style="left:840px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o2"><div class="s3-ball ci"></div></div>
<div class="s3-tag mono nowrap" id="s3-t1" style="left:98px;top:96px">PLAN</div>
<div class="s3-tag mono nowrap" id="s3-t2" style="left:103px;top:116px">ACT</div>
<div class="s3-tag mono nowrap" id="s3-t3" style="left:94px;top:136px">CHECK</div>
<div class="s3-tag nowrap" id="s3-t4" style="left:12px;top:210px;font-family:Jost;font-weight:700">AGENT LOOP · <span class="zh">智能体循环</span></div>
</div>
<div class="s3-node sq" id="s3-n3" style="left:1440px"><div class="s3-ring"></div><div class="s3-orb" id="s3-o3"><div class="s3-ball ci"></div></div></div>
</div>
<div id="s3-big1" class="abs nowrap">LOOP INSIDE.</div>
<div id="s3-big2" class="abs nowrap">EDGES: RUST.</div>
<div class="s3-leg" id="s3-g1" style="top:530px"><div class="s3-leg-shape ci"></div><div class="s3-leg-txt nowrap">AGENT<span class="zh">智能体</span></div></div>
<div class="s3-leg" id="s3-g2" style="top:610px"><div class="s3-leg-shape sq"></div><div class="s3-leg-txt nowrap">NODE<span class="zh">结构</span></div></div>
<div class="s3-leg" id="s3-g3" style="top:690px"><div class="s3-leg-shape tri"></div><div class="s3-leg-txt nowrap">SKEPTIC<span class="zh">审查者</span></div></div>
<div id="s3-foot" class="abs mono nowrap">KANDINSKY · BAUHAUS 1923 · ▲ YELLOW ■ RED ● BLUE</div>
</section>
<!-- ============ S4 · FAN-OUT ============ -->
<section id="sc4" class="clip layer" data-start="18" data-duration="6" data-track-index="2">
<div id="s4-ground" class="layer"></div>
<div id="s4-head" class="abs mono nowrap">$ KIGI_GRAPH_CONCURRENCY=3 · git worktree add ×3</div>
<div id="s4-x3" class="abs nowrap">×3</div>
<div id="s4-lanes"></div>
</section>
<!-- ============ S5 · gn-final → bones/flesh → logo ============ -->
<section id="sc5" class="clip layer" data-start="24" data-duration="10" data-track-index="2">
<div id="s5-yellow" class="layer"></div>
<svg id="s5-svg" width="1920" height="1080" viewBox="0 0 1920 1080"></svg>
<div id="s5-nodes"></div>
<div id="s5-final" class="abs tri"></div>
<div id="s5-final-lbl" class="abs mono nowrap">gn-final<b>FINAL VERIFICATION</b><i>最终验收 · 整个目标</i></div>
<div id="s5-bones-wrap" class="abs"><div id="s5-bones" class="nowrap">DETERMINISTIC BONES</div></div>
<div id="s5-flesh-wrap" class="abs"><div id="s5-flesh-t" class="nowrap">INTELLIGENT FLESH</div></div>
<div id="s5-zh1" class="abs zh">确定性的骨架</div>
<div id="s5-zh2" class="abs zh">智能的血肉</div>
<div id="s5-logo" class="layer">
<div id="s5-kigi" class="abs nowrap"><span>K</span><span>I</span><span>G</span><span>I</span></div>
<div id="s5-graph" class="abs mono nowrap">/graph</div>
<div id="s5-l-sq" class="abs sq"></div>
<div id="s5-l-ci" class="abs ci"></div>
<div id="s5-l-tri" class="abs tri"></div>
<div id="s5-rule" class="abs"></div>
<div id="s5-tag" class="abs mono nowrap">/graph &lt;objective&gt;<span id="s5-cursor"></span></div>
<div id="s5-tag2" class="abs nowrap">Kimi Code CLI · <span class="zh">社区构建版</span></div>
</div>
</section>
<!-- transitions -->
<div id="wipes" class="clip layer" data-start="0" data-duration="34" data-track-index="3">
<div id="w6" class="wipe" style="background:#D42A1E;width:1080px"></div>
<div id="w10" class="wipe" style="background:#F2B300"></div>
<div id="w18" class="wipe" style="background:#1D4FA0;border-radius:50%"></div>
<div id="w24" class="wipe" style="background:#D42A1E"></div>
<div id="w30" class="wipe" style="background:#141414"></div>
</div>
<!-- captions band -->
<div id="capband" class="clip layer" data-start="0" data-duration="34" data-track-index="4">
<div id="band"></div>
<div id="band-rule" class="abs"></div>
<div id="band-mark" class="abs"></div>
</div>
<div id="cap0" class="clip cap" data-start="1.44" data-duration="3.4" data-track-index="5"><div class="cap-zh" id="cap0-zh">一个智能体,扛不动一个庞大的目标。</div><div class="cap-en" id="cap0-en">One agent can't carry one huge goal.</div></div>
<div id="cap1" class="clip cap" data-start="6.44" data-duration="2.26" data-track-index="5"><div class="cap-zh" id="cap1-zh">Kigi 的答案:一张图。</div><div class="cap-en" id="cap1-en">Kigi's answer: a graph.</div></div>
<div id="cap2" class="clip cap" data-start="10.44" data-duration="2.42" data-track-index="5"><div class="cap-zh" id="cap2-zh">智能体的循环,活在节点内部;</div><div class="cap-en" id="cap2-en">The agentic loop lives inside each node;</div></div>
<div id="cap3" class="clip cap" data-start="12.88" data-duration="3.52" data-track-index="5"><div class="cap-zh" id="cap3-zh">节点之间的边,是确定性的 Rust。</div><div class="cap-en" id="cap3-en">the edges between them are deterministic Rust.</div></div>
<div id="cap4" class="clip cap" data-start="18.19" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap4-zh">就绪的节点三路并行,各占一棵工作树,</div><div class="cap-en" id="cap4-en">Ready nodes run three at a time, each in its own worktree,</div></div>
<div id="cap5" class="clip cap" data-start="21.24" data-duration="2.87" data-track-index="5"><div class="cap-zh" id="cap5-zh">由怀疑论者逐一审查。</div><div class="cap-en" id="cap5-en">each checked by a skeptic.</div></div>
<div id="cap6" class="clip cap" data-start="24.44" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap6-zh">最后,gn-final 重新验收整个目标。</div><div class="cap-en" id="cap6-en">Finally, gn-final re-verifies the whole objective.</div></div>
<div id="cap7" class="clip cap" data-start="27.49" data-duration="2.46" data-track-index="5"><div class="cap-zh" id="cap7-zh">确定性的骨架,智能的血肉。</div><div class="cap-en" id="cap7-en">Deterministic bones. Intelligent flesh.</div></div>
<div id="cap8" class="clip cap" data-start="29.97" data-duration="3.03" data-track-index="5"><div class="cap-zh" id="cap8-zh">Kigi /graph。</div><div class="cap-en" id="cap8-en">Kigi /graph.</div></div>
<!-- HUD -->
<div id="hud" class="clip" data-start="0" data-duration="34" data-track-index="6">
<div id="hud-0" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>01</span>/05 · HOOK</div>
<div id="hud-1" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>02</span>/05 · TITLE</div>
<div id="hud-2" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>03</span>/05 · PRINCIPLE</div>
<div id="hud-3" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>04</span>/05 · FAN-OUT</div>
<div id="hud-4" class="abs mono nowrap hud-l">KIGI <span>/GRAPH</span> — <span>05</span>/05 · VERIFY</div>
<div class="beat" id="bt0" style="left:1806px"></div><div class="beat" id="bt1" style="left:1828px"></div>
<div class="beat" id="bt2" style="left:1850px"></div><div class="beat" id="bt3" style="left:1872px"></div>
</div>
<audio id="mix" src="audio/mix.wav" data-start="0" data-duration="34" data-track-index="9" data-volume="1"></audio>
</div>
<script>
(function () {
const tl = gsap.timeline({ paused: true });
const $ = (s) => document.querySelector(s);
const mk = (parent, cls, css, html) => { const d = document.createElement("div"); d.className = cls; Object.assign(d.style, css); if (html) d.innerHTML = html; parent.appendChild(d); return d; };
const SVGNS = "http://www.w3.org/2000/svg";
/* ---------- grid (16×9 modules of 120px) ---------- */
const grid = $("#grid");
for (let i = 1; i < 16; i++) { const l = mk(grid, "gl glv", { left: i * 120 + "px" }); tl.fromTo(l, { scaleY: 0 }, { scaleY: 1, duration: 0.5, ease: "expo.out" }, (i % 4) * 0.125); }
for (let j = 1; j < 9; j++) { const l = mk(grid, "gl glh", { top: j * 120 + "px" }); tl.fromTo(l, { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, (j % 4) * 0.125); }
/* ---------- HUD: scene index + beat meter ---------- */
const sceneT = [0, 6, 10, 18, 24];
sceneT.forEach((t, i) => { if (i > 0) tl.fromTo("#hud-" + i, { opacity: 0 }, { opacity: 1, duration: 0.01 }, t); if (i < 4) tl.to("#hud-" + i, { opacity: 0, duration: 0.01 }, sceneT[i + 1]); });
for (let b = 0; b < 68; b++) {
const t = b * 0.5, k = b % 4;
tl.set("#bt" + k, { backgroundColor: "#D42A1E" }, t);
tl.set("#bt" + k, { backgroundColor: "rgba(0,0,0,0)" }, t + 0.5);
}
tl.set([".hud-l", ".beat"], { color: "#EEE6D6", borderColor: "#EEE6D6" }, 18);
tl.set([".hud-l", ".beat"], { color: "#141414", borderColor: "#141414" }, 24);
/* ---------- S1 · HOOK (0–6) ---------- */
tl.fromTo("#s1-floor", { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 0);
tl.fromTo("#s1-goal", { y: -900 }, { y: 0, duration: 0.25, ease: "power3.in" }, 0.25);
tl.fromTo("#s1-goal", { scaleY: 1 }, { keyframes: [{ scaleY: 0.9, duration: 0.06 }, { scaleY: 1, duration: 0.44, ease: "elastic.out(1,0.4)" }], transformOrigin: "50% 100%" }, 0.5);
tl.fromTo("#s1-agent", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(2)" }, 1.0);
tl.fromTo("#s1-agent-lbl", { opacity: 0, x: -30 }, { opacity: 1, x: 0, duration: 0.25, ease: "expo.out" }, 1.0);
tl.to("#s1-agent-lbl", { opacity: 0, duration: 0.25 }, 3.5);
// push, push — the goal doesn't move
tl.to("#s1-agent", { x: 720, rotation: 360, duration: 0.5, ease: "power2.in" }, 1.5);
tl.to("#s1-agent-dot", { keyframes: [{ scaleX: 0.7, scaleY: 1.2, duration: 0.08 }, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1,0.3)" }], transformOrigin: "100% 50%" }, 2.0);
tl.to("#s1-goal", { keyframes: [{ x: 14, duration: 0.05 }, { x: 0, duration: 0.2, ease: "power2.out" }] }, 2.0);
tl.to("#s1-agent", { x: 480, rotation: 120, duration: 0.5, ease: "power2.out" }, 2.0);
tl.to("#s1-agent", { x: 720, rotation: 360, duration: 0.5, ease: "power2.in" }, 2.5);
tl.to("#s1-agent-dot", { keyframes: [{ scaleX: 0.7, scaleY: 1.2, duration: 0.08 }, { scaleX: 1, scaleY: 1, duration: 0.3, ease: "elastic.out(1,0.3)" }], transformOrigin: "100% 50%" }, 3.0);
tl.to("#s1-goal", { keyframes: [{ x: 14, duration: 0.05 }, { x: 0, duration: 0.2, ease: "power2.out" }] }, 3.0);
tl.to("#s1-agent", { x: 540, rotation: 200, duration: 0.5, ease: "power2.out" }, 3.0);
// lost: the agent wanders off the grid, leaving a scribble
const path = $("#s1-path"), L = path.getTotalLength();
gsap.set(path, { strokeDasharray: L, strokeDashoffset: L });
tl.fromTo(path, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 3.5);
tl.fromTo(path, { strokeDashoffset: L }, { strokeDashoffset: 0, duration: 1.5, ease: "none" }, 3.5);
const p0 = path.getPointAtLength(0), kf = [];
for (let i = 1; i <= 48; i++) { const p = path.getPointAtLength((L * i) / 48); kf.push({ x: p.x - p0.x, y: p.y - p0.y, duration: 1.5 / 48, ease: "none" }); }
tl.fromTo("#s1-agent-in", { x: 0, y: 0 }, { keyframes: kf, immediateRender: false }, 3.5);
tl.to("#s1-agent-dot", { backgroundColor: "#BDB5A6", scale: 0.6, duration: 0.5, ease: "power2.inOut" }, 4.5);
tl.to(path, { stroke: "#BDB5A6", duration: 0.5 }, 5.0);
// the goal decomposes into nodes
tl.to("#s1-goal-txt", { opacity: 0, duration: 0.25 }, 5.0);
tl.to(".s1-cell", { backgroundColor: "#D42A1E", scale: 0.6, duration: 0.25, ease: "back.out(2)", stagger: 0.0625 }, 5.0);
/* ---------- S2 · TITLE (6–10) ---------- */
tl.fromTo("#w6", { x: -1080 }, { x: 0, duration: 0.25, ease: "power3.in" }, 5.75);
tl.set("#w6", { opacity: 0 }, 6.0);
tl.set("#w6", { opacity: 1 }, 0);
["6.5", "6.75", "7.0", "7.25"].forEach((t, i) =>
tl.fromTo(`#s2-kigi span:nth-child(${i + 1})`, { opacity: 0, scale: 1.5, y: -40 }, { opacity: 1, scale: 1, y: 0, duration: 0.125, ease: "power4.in" }, +t));
tl.fromTo("#s2-slash", { width: 0 }, { width: 560, duration: 0.5, ease: "steps(6)" }, 7.5);
tl.fromTo("#s2-cursor", { x: 0, opacity: 0 }, { x: 560, opacity: 1, duration: 0.5, ease: "steps(6)" }, 7.5);
tl.to("#s2-cursor", { opacity: 0, duration: 0.01, repeat: 7, yoyo: true, repeatDelay: 0.24 }, 8.0);
tl.fromTo("#s2-tu", { y: -900 }, { y: 0, duration: 0.25, ease: "power4.in" }, 7.75);
tl.to("#s2-tu", { keyframes: [{ scaleY: 0.92, duration: 0.05 }, { scaleY: 1, duration: 0.4, ease: "elastic.out(1,0.35)" }], transformOrigin: "50% 100%" }, 8.0);
tl.fromTo("#s2-circle", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(1.8)" }, 8.5);
tl.fromTo("#s2-tri", { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 9.0);
tl.fromTo("#s2-sub", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(8)" }, 9.0);
tl.to("#s2-circle", { x: 120, duration: 0.5, ease: "power2.inOut" }, 9.5);
/* ---------- transition 10 (yellow, left→right) ---------- */
const wipe = (id, t, from, mid, to) => {
tl.fromTo(id, from, { ...mid, duration: 0.25, ease: "power3.in", immediateRender: true }, t - 0.25);
tl.to(id, { ...to, duration: 0.25, ease: "power3.out" }, t);
};
wipe("#w10", 10, { x: -1920 }, { x: 0 }, { x: 1920 });
/* ---------- S3 · PRINCIPLE (10–18) ---------- */
[1, 2, 3].forEach((n, i) => {
tl.fromTo("#s3-n" + n, { y: -480 }, { y: 0, duration: 0.25, ease: "power3.in" }, 10 + i * 0.25);
tl.fromTo("#s3-o" + n, { rotation: i * 120 }, { rotation: i * 120 + 360 * 4, duration: 8, ease: "none" }, 10);
});
// zoom into the middle node: the loop lives inside
tl.fromTo("#s3-cam", { scale: 1 }, { scale: 2.6, duration: 0.5, ease: "expo.inOut" }, 11.0);
tl.fromTo(["#s3-t1", "#s3-t2", "#s3-t3"], { opacity: 0 }, { opacity: 1, duration: 0.125, stagger: 0.25 }, 11.5);
tl.fromTo("#s3-t4", { opacity: 0 }, { opacity: 1, duration: 0.25 }, 12.0);
tl.to("#s3-cam", { scale: 1, duration: 0.5, ease: "expo.inOut" }, 12.5);
tl.to(["#s3-t1", "#s3-t2", "#s3-t3", "#s3-t4"], { opacity: 0, duration: 0.25 }, 12.75);
tl.fromTo("#s3-big1", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(12)" }, 12.0);
// edges: ruler-straight, stepped — deterministic
tl.fromTo(["#s3-e1", "#s3-e2"], { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "steps(12)", stagger: 0.5 }, 13.0);
tl.fromTo(["#s3-r1", "#s3-r2"], { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "steps(12)", stagger: 0.5 }, 13.0);
tl.fromTo(["#s3-a1", "#s3-a2"], { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(3)", stagger: 0.5 }, 14.0);
tl.fromTo(["#s3-l1", "#s3-l2"], { opacity: 0, y: 12 }, { opacity: 1, y: 0, duration: 0.25, ease: "expo.out", stagger: 0.5 }, 14.0);
tl.fromTo("#s3-big2", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(12)" }, 14.5);
// legend: Kandinsky's triad
[1, 2, 3].forEach((n, i) => tl.fromTo("#s3-g" + n, { x: 80, opacity: 0 }, { x: 0, opacity: 1, duration: 0.25, ease: "expo.out" }, 16 + i * 0.5));
tl.fromTo("#s3-foot", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(16)" }, 17.0);
/* ---------- transition 18 (blue circle iris) ---------- */
tl.fromTo("#w18", { scale: 0, x: 0 }, { scale: 2.4, duration: 0.25, ease: "power3.in", immediateRender: true }, 17.75);
tl.to("#w18", { scale: 0, duration: 0.25, ease: "power3.out" }, 18.0);
/* ---------- S4 · FAN-OUT (18–24) ---------- */
tl.fromTo("#s4-head", { width: 0 }, { width: 900, duration: 1.0, ease: "steps(36)" }, 18.0);
tl.fromTo("#s4-x3", { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.2)" }, 18.5);
const lanesEl = $("#s4-lanes");
const lanes = [{ y: 300, id: "gn-386aaa87", name: "api" }, { y: 500, id: "gn-0d903d89", name: "cli" }, { y: 700, id: "gn-527d0336", name: "schema" }];
lanes.forEach((ln, i) => {
const off = i * 0.125;
const lane = mk(lanesEl, "s4-lane", { top: ln.y + "px" });
const lbl = mk(lanesEl, "s4-lbl mono nowrap", { top: ln.y - 90 + "px" }, `worktree/${ln.id} · ${ln.name}`);
const node = mk(lanesEl, "s4-node sq", { left: "360px", top: ln.y - 48 + "px" });
const w = mk(lanesEl, "s4-w ci", { left: "372px", top: ln.y - 36 + "px" });
const tri = mk(lanesEl, "s4-tri tri", { top: ln.y - 84 + "px" });
const chk = mk(lanesEl, "s4-check", { top: ln.y - 66 + "px" }, "✓");
const verdict = mk(lanesEl, "s4-verdict mono nowrap", { top: ln.y + 50 + "px", color: "#F2B300" }, "NODE_VERDICT: achieved");
const round = mk(lanesEl, "s4-round mono nowrap", { top: ln.y - 14 + "px" }, "1/3");
tl.fromTo(lane, { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 18.0 + off);
tl.fromTo(lbl, { opacity: 0 }, { opacity: 0.8, duration: 0.25 }, 18.25 + off);
tl.fromTo(node, { x: -240 }, { x: 0, duration: 0.25, ease: "expo.out" }, 18.5);
tl.fromTo(tri, { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 18.75 + off);
tl.fromTo(round, { opacity: 0 }, { opacity: 1, duration: 0.25 }, 18.75 + off);
tl.fromTo(w, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2)" }, 18.75);
// the worker builds: four steps, a commit dropped on every step
const failRound = i === 1;
const steps = [0, 1, 2, 3];
steps.forEach((s) => {
const t = 19.0 + s * 0.5 + off;
tl.to(w, { x: 240 * (s + 1), duration: 0.25, ease: "power2.inOut" }, t);
const c = mk(lanesEl, "s4-commit", { left: 480 + 240 * s + 24 + "px", top: ln.y - 12 + "px" });
tl.fromTo(c, { scale: 0 }, { scale: 1, duration: 0.125, ease: "back.out(3)" }, t + 0.125);
});
// skeptic reviews
if (failRound) {
const gaps = mk(lanesEl, "s4-verdict mono nowrap", { top: ln.y + 50 + "px", color: "#D42A1E" }, "NODE_VERDICT: not_achieved");
tl.fromTo(tri, { backgroundColor: "#F2B300" }, { backgroundColor: "#D42A1E", duration: 0.01, immediateRender: false }, 21.0);
tl.fromTo(gaps, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 21.0);
tl.to(gaps, { opacity: 0, duration: 0.01 }, 22.0);
tl.to(tri, { backgroundColor: "#F2B300", duration: 0.01 }, 21.5);
tl.to(w, { x: 480, duration: 0.25, ease: "power2.inOut" }, 21.25);
const round2 = mk(lanesEl, "s4-round mono nowrap", { top: ln.y - 14 + "px", color: "#F2B300" }, "2/3");
tl.fromTo(round2, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 21.5);
tl.to(round, { opacity: 0, duration: 0.01 }, 21.5);
tl.to(w, { x: 960, duration: 0.5, ease: "power2.inOut" }, 22.0);
tl.to(tri, { keyframes: [{ scale: 1.3, duration: 0.06 }, { scale: 1, duration: 0.2 }] }, 23.0);
tl.fromTo(chk, { opacity: 0, scale: 2 }, { opacity: 1, scale: 1, duration: 0.125, ease: "power4.in" }, 23.0);
tl.fromTo(verdict, { opacity: 0 }, { opacity: 1, duration: 0.01 }, 23.0);
} else {
const tv = i === 0 ? 22.0 : 22.5;
tl.to(tri, { keyframes: [{ scale: 1.3, duration: 0.06 }, { scale: 1, duration: 0.2 }] }, tv);
tl.fromTo(chk, { opacity: 0, scale: 2 }, { opacity: 1, scale: 1, duration: 0.125, ease: "power4.in" }, tv);
tl.fromTo(verdict, { opacity: 0 }, { opacity: 1, duration: 0.01 }, tv);
}
// sequential merge-back
tl.to(node, { x: 1320 - 96, backgroundColor: "#F2B300", duration: 0.25, ease: "expo.inOut" }, 23.5 + i * 0.125);
});
/* ---------- transition 24 (red, top→down) ---------- */
wipe("#w24", 24, { y: -1080 }, { y: 0 }, { y: 1080 });
/* ---------- S5 · gn-final (24–30) ---------- */
const svg = $("#s5-svg"), nodesEl = $("#s5-nodes");
const N = { core: [960, 100, "gn-dd5e607e"], api: [600, 260, "gn-386aaa87"], cli: [960, 260, "gn-0d903d89"], schema: [1320, 260, "gn-527d0336"], docs: [780, 420, "gn-28eb34d2"], tests: [1140, 420, "gn-5b22cf22"] };
const layerT = { core: 24.0, api: 24.5, cli: 24.5, schema: 24.5, docs: 25.0, tests: 25.0 };
const path5 = (d, w, t, dur, color) => {
const p = document.createElementNS(SVGNS, "path");
p.setAttribute("d", d); p.setAttribute("fill", "none"); p.setAttribute("stroke", color || "#141414");
p.setAttribute("stroke-width", w); p.setAttribute("stroke-linecap", "square"); svg.appendChild(p);
const len = p.getTotalLength(); p.style.strokeDasharray = len;
tl.fromTo(p, { strokeDashoffset: len }, { strokeDashoffset: 0, duration: dur, ease: "power2.inOut" }, t);
return p;
};
// structural edges (orthogonal routing via a bus between layers)
const E = [["core", "api"], ["core", "cli"], ["core", "schema"], ["api", "docs"], ["cli", "docs"], ["cli", "tests"], ["schema", "tests"]];
E.forEach(([a, b], k) => {
const [ax, ay] = N[a], [bx, by] = N[b], busY = ay + 96 + (by - ay - 96) / 2;
path5(`M${ax} ${ay + 96} V${busY} H${bx} V${by}`, 6, (layerT[b]) - 0.25, 0.5);
});
// every node → gn-final
Object.entries(N).forEach(([k, [x, y]], i) => {
const busY = 530 - 0;
path5(`M${x + (k === "core" || k === "cli" ? 30 : 0)} ${y + 96} V${busY} H960 V560`, 3, 26.0, 0.5, "#141414");
});
Object.entries(N).forEach(([k, [x, y, id]]) => {
const n = mk(nodesEl, "s5-node sq", { left: x - 48 + "px", top: y + "px" }, `<div class="s5-name">${k}</div>`);
const lab = mk(nodesEl, "s5-id mono nowrap", { left: x + 52 + "px", top: y + 4 + "px", width: "auto", textAlign: "left" }, id);
const flesh = mk(n, "s5-flesh ci", {});
tl.fromTo(n, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.2)" }, layerT[k]);
tl.fromTo(lab, { opacity: 0 }, { opacity: 1, duration: 0.125 }, layerT[k] + 0.25);
// bones & flesh: nodes become outlined bones, then fill with agents
tl.to(n, { backgroundColor: "rgba(0,0,0,0)", boxShadow: "inset 0 0 0 6px #141414", duration: 0.01 }, 27.5);
tl.to(n.querySelector(".s5-name"), { color: "#141414", opacity: 0, duration: 0.01 }, 27.5);
tl.fromTo(flesh, { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2.4)" }, 28.5 + Object.keys(N).indexOf(k) * 0.125);
tl.to(lab, { opacity: 0, duration: 0.01 }, 27.5);
});
tl.fromTo("#s5-final", { scaleY: 0 }, { scaleY: 1, duration: 0.5, ease: "expo.out" }, 26.5);
tl.fromTo("#s5-final-lbl", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(10)" }, 26.5);
// the verdict: yellow floods out of the triangle
tl.fromTo("#s5-yellow", { clipPath: "circle(0% at 960px 690px)" }, { clipPath: "circle(150% at 960px 690px)", duration: 0.5, ease: "expo.out" }, 27.5);
tl.to("#s5-final", { backgroundColor: "#141414", duration: 0.01 }, 27.5);
tl.to("#s5-final-lbl", { opacity: 0, duration: 0.01 }, 27.5);
tl.fromTo("#s5-bones", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(19)" }, 27.75);
tl.fromTo("#s5-zh1", { clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0% 0)", duration: 0.5, ease: "steps(6)" }, 28.0);
tl.fromTo("#s5-flesh-t", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(17)" }, 28.75);
tl.fromTo("#s5-zh2", { clipPath: "inset(0 0 100% 0)" }, { clipPath: "inset(0 0 0% 0)", duration: 0.5, ease: "steps(5)" }, 29.0);
/* ---------- transition 30 (ink, right→left) + LOGO (30–34) ---------- */
wipe("#w30", 30, { x: 1920 }, { x: 0 }, { x: -1920 });
tl.fromTo("#s5-logo", { opacity: 0 }, { opacity: 1, duration: 0.01 }, 30.0);
["30.25", "30.375", "30.5", "30.625"].forEach((t, i) =>
tl.fromTo(`#s5-kigi span:nth-child(${i + 1})`, { y: -500, opacity: 0 }, { y: 0, opacity: 1, duration: 0.125, ease: "power4.in" }, +t));
tl.fromTo("#s5-graph", { width: 0 }, { width: 500, duration: 0.5, ease: "steps(6)" }, 30.5);
tl.fromTo("#s5-l-sq", { y: -700 }, { y: 0, duration: 0.25, ease: "power4.in" }, 30.75);
tl.fromTo("#s5-l-ci", { scale: 0 }, { scale: 1, duration: 0.5, ease: "back.out(2)" }, 31.25);
tl.fromTo("#s5-l-tri", { scaleY: 0 }, { scaleY: 1, duration: 0.25, ease: "expo.out" }, 32.0);
tl.fromTo("#s5-rule", { scaleX: 0 }, { scaleX: 1, duration: 0.5, ease: "expo.out" }, 32.0);
tl.fromTo("#s5-tag", { clipPath: "inset(0 100% 0 0)" }, { clipPath: "inset(0 0% 0 0)", duration: 0.5, ease: "steps(19)" }, 32.25);
tl.fromTo("#s5-tag2", { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.25, ease: "expo.out" }, 32.75);
tl.to("#s5-cursor", { opacity: 0, duration: 0.01, repeat: 3, yoyo: true, repeatDelay: 0.49 }, 32.75);
// final downbeat: the three shapes nudge together on 33.0
tl.to(["#s5-l-sq", "#s5-l-ci", "#s5-l-tri"], { y: -20, duration: 0.125, ease: "power2.out", yoyo: true, repeat: 1 }, 33.0);
/* ---------- caption band ---------- */
tl.fromTo("#band-rule", { scaleX: 0 }, { scaleX: 1, duration: 1.0, ease: "expo.out" }, 0.5);
tl.fromTo("#band-mark", { scale: 0 }, { scale: 1, duration: 0.25, ease: "back.out(2)" }, 1.5);
tl.fromTo("#cap0-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 1.44);
tl.fromTo("#cap0-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 1.565);
tl.fromTo("#cap1-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 6.44);
tl.fromTo("#cap1-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 6.565);
tl.fromTo("#cap2-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 10.44);
tl.fromTo("#cap2-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 10.565);
tl.fromTo("#cap3-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 12.88);
tl.fromTo("#cap3-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 13.005);
tl.fromTo("#cap4-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 18.19);
tl.fromTo("#cap4-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 18.315);
tl.fromTo("#cap5-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 21.24);
tl.fromTo("#cap5-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 21.365);
tl.fromTo("#cap6-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 24.44);
tl.fromTo("#cap6-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 24.565);
tl.fromTo("#cap7-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 27.49);
tl.fromTo("#cap7-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 27.615);
tl.fromTo("#cap8-zh", { clipPath: "inset(0 0 100% 0)", y: 24 }, { clipPath: "inset(0 0 0% 0)", y: 0, duration: 0.25, ease: "expo.out" }, 29.97);
tl.fromTo("#cap8-en", { opacity: 0, x: -24 }, { opacity: 0.72, x: 0, duration: 0.25, ease: "expo.out" }, 30.095);
window.__timelines["main"] = tl;
})();
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
{
"id": "kigi-graph-bauhaus",
"name": "kigi-graph-bauhaus",
"createdAt": "2026-10-04T17:49:43.118Z"
}
+211
View File
@@ -0,0 +1,211 @@
"""Procedural 120 BPM score for the 34 s cut: every hit is placed on the same beat grid the picture uses."""
import json, subprocess, numpy as np, soundfile as sf
from scipy.signal import fftconvolve, butter, sosfilt
FFMPEG = r"C:\Users\zhang\AppData\Local\Microsoft\WinGet\Packages\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-9.0.2-full_build\bin\ffmpeg.exe"
SR, TOTAL, BEAT = 48000, 34.0, 0.5
N = int(SR * TOTAL)
rng = np.random.default_rng(7)
bus = {k: np.zeros(N) for k in ["drums", "music", "fx"]}
def env(n, decay, attack=0.002):
t = np.arange(n) / SR
a = np.minimum(1, t / attack) if attack > 0 else 1
return a * np.exp(-t / decay)
def put(b, t, sig, gain=1.0):
i = int(round(t * SR))
if i >= N: return
sig = sig[: N - i]
bus[b][i : i + len(sig)] += sig * gain
def hz(m): return 440 * 2 ** ((m - 69) / 12)
def lp(x, f, order=2): return sosfilt(butter(order, f, "low", fs=SR, output="sos"), x)
def hp(x, f, order=2): return sosfilt(butter(order, f, "high", fs=SR, output="sos"), x)
def bp(x, lo, hi): return sosfilt(butter(2, [lo, hi], "band", fs=SR, output="sos"), x)
# ---------- instruments ----------
def kick(g=1.0):
n = int(0.45 * SR); t = np.arange(n) / SR
f = 45 + 110 * np.exp(-t / 0.03)
s = np.sin(2 * np.pi * np.cumsum(f) / SR) * env(n, 0.22)
s += hp(rng.standard_normal(n), 2000) * env(n, 0.004) * 0.4
return np.tanh(s * 1.6) * g
def hat(open_=False):
n = int((0.25 if open_ else 0.06) * SR)
return hp(rng.standard_normal(n), 7000, 4) * env(n, 0.09 if open_ else 0.018) * 0.35
def clap():
n = int(0.3 * SR); s = bp(rng.standard_normal(n), 900, 3000)
e = sum(env(n, 0.008) * (np.arange(n) >= int(k * SR)) for k in (0, 0.011, 0.022)) + env(n, 0.09) * 0.6
return s * e * 0.55
def tick():
n = int(0.03 * SR); return hp(rng.standard_normal(n), 4000) * env(n, 0.004) * 0.5
def marimba(m, dur=0.35, g=0.3):
n = int(dur * 2 * SR); t = np.arange(n) / SR; f = hz(m)
s = np.sin(2*np.pi*f*t)*env(n, dur*0.6) + 0.35*np.sin(2*np.pi*f*3.93*t)*env(n, dur*0.12) + 0.1*np.sin(2*np.pi*f*9.2*t)*env(n, 0.02)
return s * g
def bass(m, dur=0.24, g=0.5):
n = int(dur * SR); t = np.arange(n) / SR; f = hz(m)
saw = 2 * ((f * t) % 1) - 1
s = lp(saw, 500) + 0.6 * np.sin(2 * np.pi * f * t)
e = env(n, dur * 0.8, 0.004) * np.minimum(1, (n - np.arange(n)) / (0.01 * SR))
return np.tanh(s * e * 1.4) * g
def pad(ms, dur, g=0.11):
n = int(dur * SR); t = np.arange(n) / SR; s = np.zeros(n)
for m in ms:
for d in (-0.08, 0.0, 0.08):
f = hz(m + d); s += 2 * ((f * t + rng.random()) % 1) - 1
s = lp(s, 1400)
a = np.minimum(1, t / 0.4) * np.minimum(1, (dur - t) / 0.5)
return s * a * g / len(ms)
def impact(g=1.0):
n = int(2.5 * SR); t = np.arange(n) / SR
boom = np.sin(2 * np.pi * (38 + 40 * np.exp(-t / 0.08)) * t) * env(n, 0.7)
crash = hp(rng.standard_normal(n), 3000) * env(n, 0.9) * 0.25
return (np.tanh(boom * 1.4) * 0.9 + crash) * g
def riser(dur=1.0, g=0.35):
n = int(dur * SR); t = np.arange(n) / SR
x = rng.standard_normal(n); out = np.zeros(n); seg = int(0.05 * SR)
for i in range(0, n, seg): # stepped filter sweep
f = 400 + 7000 * (i / n) ** 2
out[i:i+seg] = bp(x[i:i+seg], f, min(f * 1.6, 20000))
return out * (t / dur) ** 2 * g
def whoosh(g=0.5):
n = int(0.35 * SR); t = np.arange(n) / SR
s = bp(rng.standard_normal(n), 600, 6000)
return s * np.sin(np.pi * t / t[-1]) ** 2 * g
def stamp(g=0.8, pitch=60):
n = int(0.4 * SR); t = np.arange(n) / SR
s = np.sin(2 * np.pi * (hz(pitch - 24) + 80 * np.exp(-t / 0.02)) * t) * env(n, 0.12)
s += bp(rng.standard_normal(n), 1500, 5000) * env(n, 0.01) * 0.6
return np.tanh(s * 2) * g
def buzz(g=0.35):
n = int(0.45 * SR); t = np.arange(n) / SR
s = np.sign(np.sin(2 * np.pi * 110 * t)) + np.sign(np.sin(2 * np.pi * 116.5 * t))
return lp(s, 1800) * env(n, 0.18) * g
# ---------- arrangement (A minor; bar = 2 s) ----------
CHORDS = {0: (57, [57, 60, 64]), 1: (53, [53, 57, 60]), 2: (48, [52, 55, 60]), 3: (55, [55, 59, 62])} # Am F C G
def chord(bar): return CHORDS[bar % 4]
def on(t0, t1, step):
return [round(x, 4) for x in np.arange(t0, t1 - 1e-9, step)]
# intro: metronome eighths 0-2, kick at 0.5 (square drop)
for t in on(0, 2, 0.25): put("drums", t, tick(), 0.6 if (t * 4) % 2 == 0 else 0.3)
put("fx", 0.5, impact(0.7)); put("drums", 0.5, kick(1.0))
def groove(t0, t1, hats=0.5, claps=True, kicks=True, hat16=False):
for t in on(t0, t1, BEAT):
if kicks: put("drums", t, kick())
if claps and round((t / BEAT)) % 2 == 1: put("drums", t, clap())
for t in on(t0, t1, 0.125 if hat16 else 0.25):
off = abs((t / 0.5) % 1 - 0.5) < 1e-6
put("drums", t, hat(open_=off and not hat16), hats * (1.0 if off else 0.6))
def ostinato(t0, t1, octave=12, g=0.26, step=0.125, cycle=3):
for k, t in enumerate(on(t0, t1, step)):
root, tri = chord(int(t // 2)); note = tri[[0, 1, 2, 1, 2][k % cycle] if cycle == 5 else k % 3]
put("music", t, marimba(note + octave, 0.3, g * (1.0 if k % 4 == 0 else 0.7)))
def bassline(t0, t1, g=0.5):
for t in on(t0, t1, 0.25):
root, _ = chord(int(t // 2)); pos = round((t % 0.5) / 0.25)
if pos == 1 or (t % 2) < 1e-6: put("music", t, bass(root - 12 + (12 if pos == 1 and (t % 1) > 0.5 else 0), 0.22, g))
def pads(t0, t1):
for b in range(int(t0 // 2), int(t1 // 2)):
put("music", b * 2.0, pad(chord(b)[1], 2.05))
# hook 2-6
groove(2, 6, hats=0.35, claps=False); ostinato(2, 6, g=0.2); bassline(4, 6, 0.4)
put("fx", 5.0, riser(1.0, 0.3))
# title 6-10
put("fx", 6.0, impact(1.0))
groove(6, 10); ostinato(6, 10); bassline(6, 10); pads(6, 10)
for t in (6.5, 6.75, 7.0, 7.25): put("fx", t, stamp(0.45, 72))
for t in on(7.5, 8.0, 0.0625): put("fx", t, tick(), 0.5)
put("fx", 8.0, stamp(0.9, 48))
# principle 10-18
put("fx", 10.0, impact(0.55))
groove(10, 18, hats=0.45); ostinato(10, 18); bassline(10, 18); pads(10, 18)
for t in on(13.0, 15.5, 0.25): put("fx", t, tick(), 0.35) # ruler ticks while edges draw
for t in (16.0, 16.5, 17.0): put("fx", t, marimba(84, 0.4, 0.35))
put("fx", 17.0, riser(1.0, 0.35))
# fan-out 18-24 (peak)
put("fx", 18.0, impact(0.9))
groove(18, 24, hats=0.45, hat16=True); ostinato(18, 24, g=0.24); ostinato(18.125, 24, octave=24, g=0.12, step=0.25, cycle=5)
bassline(18, 24, 0.55); pads(18, 24)
put("fx", 21.0, buzz())
for t in (22.0, 22.5, 23.0): put("fx", t, stamp(0.8, 67))
put("fx", 23.0, riser(1.0, 0.35))
# gn-final 24-27.5: suspense (no kick), then the verdict hit
put("fx", 24.0, impact(0.8))
ostinato(24, 27.5, g=0.2); pads(24, 28)
for t in on(24, 27.5, 0.5): put("drums", t, hat(), 0.25)
for t in (24.0, 24.5, 25.0, 25.5): put("fx", t, marimba(88, 0.25, 0.25))
put("fx", 26.5, riser(1.0, 0.4))
put("fx", 27.5, impact(1.1)); put("fx", 27.5, stamp(0.9, 52))
# bones & flesh 27.5-30: full groove
groove(27.5, 30, hats=0.5); ostinato(27.5, 30); bassline(27.5, 30); pads(28, 30)
# logo 30-34
put("fx", 30.0, impact(0.7)); groove(30, 32, hats=0.4); ostinato(30, 32, g=0.22); bassline(30, 32)
for t, m in ((31.0, 76), (31.5, 79), (32.0, 81)): put("fx", t, marimba(m, 0.6, 0.45))
put("fx", 32.0, impact(1.0)); put("drums", 32.0, kick(1.1))
put("music", 32.0, pad([45, 57, 60, 64, 69], 2.0, 0.22))
for m in (57, 60, 64, 69): put("music", 32.0, marimba(m, 1.2, 0.25))
# transition whooshes (end on the cut)
for t in (6.0, 10.0, 18.0, 24.0, 30.0): put("fx", t - 0.3, whoosh(0.45))
# ---------- reverb, voice, ducking, master ----------
def reverb(x, sec=1.4, wet=0.18):
n = int(sec * SR); ir = rng.standard_normal(n) * np.exp(-np.arange(n) / SR / (sec / 5))
ir = lp(ir, 5000); ir /= np.sqrt((ir ** 2).sum())
return x + fftconvolve(x, ir)[: len(x)] * wet
music = reverb(bus["music"], wet=0.25) + bus["drums"] * 0.9 + reverb(bus["fx"], wet=0.2)
vo = np.zeros(N)
tl = json.load(open("timeline.json", encoding="utf-8"))["scenes"]
for sid, (st, dur, off) in tl.items():
raw = subprocess.run([FFMPEG, "-v", "quiet", "-i", f"audio/vo_{sid}.mp3", "-f", "f32le", "-ac", "1", "-ar", str(SR), "-"], capture_output=True).stdout
v = np.frombuffer(raw, dtype=np.float32).astype(float)
i = int((st + off) * SR); vo[i : i + len(v)] += v[: N - i]
vo = hp(vo, 70) / (np.abs(vo).max() + 1e-9) * 0.85
# sidechain: duck music ~7 dB under narration, 30 ms attack, 250 ms release
lvl = np.abs(vo); k = int(0.02 * SR)
lvl = np.convolve(lvl, np.ones(k) / k, mode="same")
gate = (lvl > 0.02).astype(float)
duck = np.zeros(N); a, r = np.exp(-1 / (0.03 * SR)), np.exp(-1 / (0.25 * SR)); g = 0.0
for i in range(0, N, 64): # block-rate envelope follower
target = gate[i]; c = a ** 64 if target > g else r ** 64
g = target + (g - target) * c; duck[i : i + 64] = g
music *= 1 - 0.55 * duck
music = music / (np.abs(music).max() + 1e-9) * 0.62
mix = np.tanh((music + vo) * 1.1)
fade = np.ones(N); fl = int(0.6 * SR); fade[-fl:] = np.linspace(1, 0, fl)
mix *= fade
mix = mix / np.abs(mix).max() * 0.94
stereo = np.stack([mix, mix], 1)
sf.write("audio/mix.wav", stereo, SR, subtype="PCM_16")
sf.write("audio/score.wav", np.stack([music, music], 1) * 0.9, SR, subtype="PCM_16")
print("ok", stereo.shape, "peak", np.abs(mix).max())
@@ -0,0 +1,11 @@
{
"name": "kigi-graph-bauhaus",
"private": true,
"type": "module",
"scripts": {
"dev": "npx --yes hyperframes@0.8.124 preview",
"check": "npx --yes hyperframes@0.8.124 check",
"render": "npx --yes hyperframes@0.8.124 render",
"publish": "npx --yes hyperframes@0.8.124 publish"
}
}
@@ -0,0 +1,7 @@
[
{"id":"s1","segs":[{"zh":"一个智能体,扛不动一个庞大的目标。","en":"One agent can't carry one huge goal."}]},
{"id":"s2","segs":[{"zh":"Kigi 的答案:一张图。","en":"Kigi's answer: a graph."}]},
{"id":"s3","segs":[{"zh":"智能体的循环,活在节点内部;","en":"The agentic loop lives inside each node;"},{"zh":"节点之间的边,是确定性的 Rust。","en":"the edges between them are deterministic Rust."}]},
{"id":"s4","segs":[{"zh":"就绪的节点三路并行,各占一棵工作树,","en":"Ready nodes run three at a time, each in its own worktree,"},{"zh":"由怀疑论者逐一审查。","en":"each checked by a skeptic."}]},
{"id":"s5","segs":[{"zh":"最后,gn-final 重新验收整个目标。","tts":"最后,G N final 重新验收整个目标。","en":"Finally, gn-final re-verifies the whole objective."},{"zh":"确定性的骨架,智能的血肉。","en":"Deterministic bones. Intelligent flesh."},{"zh":"Kigi /graph。","tts":"Kigi,斜杠 graph。","en":"Kigi /graph."}]}
]
@@ -0,0 +1,3 @@
@font-face { font-family: "Jost"; src: url("../fonts/Jost.ttf") format("truetype"); font-weight: 100 900; }
@font-face { font-family: "Noto Sans SC"; src: url("../fonts/NotoSansSC.ttf") format("truetype"); font-weight: 100 900; }
@font-face { font-family: "JetBrains Mono"; src: url("../fonts/JetBrainsMono.ttf") format("truetype"); font-weight: 100 800; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

@@ -0,0 +1,85 @@
{
"scenes": {
"s1": [
0,
6,
1.5
],
"s2": [
6,
4,
0.5
],
"s3": [
10,
8,
0.5
],
"s4": [
18,
6,
0.25
],
"s5": [
24,
10,
0.5
]
},
"caps": [
{
"zh": "一个智能体,扛不动一个庞大的目标。",
"en": "One agent can't carry one huge goal.",
"t0": 1.54,
"t1": 4.14
},
{
"zh": "Kigi 的答案:一张图。",
"en": "Kigi's answer: a graph.",
"t0": 6.54,
"t1": 8.0
},
{
"zh": "智能体的循环,活在节点内部;",
"en": "The agentic loop lives inside each node;",
"t0": 10.54,
"t1": 12.82
},
{
"zh": "节点之间的边,是确定性的 Rust。",
"en": "the edges between them are deterministic Rust.",
"t0": 12.98,
"t1": 15.7
},
{
"zh": "就绪的节点三路并行,各占一棵工作树,",
"en": "Ready nodes run three at a time, each in its own worktree,",
"t0": 18.29,
"t1": 21.3
},
{
"zh": "由怀疑论者逐一审查。",
"en": "each checked by a skeptic.",
"t0": 21.34,
"t1": 23.41
},
{
"zh": "最后,gn-final 重新验收整个目标。",
"en": "Finally, gn-final re-verifies the whole objective.",
"t0": 24.54,
"t1": 27.51
},
{
"zh": "确定性的骨架,智能的血肉。",
"en": "Deterministic bones. Intelligent flesh.",
"t0": 27.59,
"t1": 30.03
},
{
"zh": "Kigi /graph。",
"en": "Kigi /graph.",
"t0": 30.07,
"t1": 31.81
}
]
}
@@ -0,0 +1,15 @@
import json, math
vo = json.load(open("vo_timing.json", encoding="utf-8"))
OFF = {"s01": 2.0, "s02": 1.5}
MINTAIL = {"s13": 7.0, "s02": 1.8}
t, out = 0.0, []
for sid, v in vo.items():
off = OFF.get(sid, 0.5)
need = off + v["len"] + MINTAIL.get(sid, 0.6)
dur = math.ceil(need / 2.0) * 2.0
segs = [{**s, "t0": round(s["t0"] + off, 3), "t1": round(s["t1"] + off, 3)} for s in v["segs"]]
out.append({"id": sid, "start": t, "dur": dur, "vo_off": off, "vo_len": v["len"], "segs": segs})
t += dur
json.dump({"bpm": 120, "total": t, "scenes": out}, open("timeline.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
for s in out: print(s["id"], s["start"], s["dur"], "bars", s["dur"]/2)
print("total", t)
+21
View File
@@ -0,0 +1,21 @@
import json, sys, urllib.request, pathlib
KEY = pathlib.Path(r"C:\Users\zhang\Documents\Github\Kigi-CLI\env.md").read_text(encoding="utf-8").splitlines()[1].split(":",1)[1].strip()
def tts(text, out, voice, speed=1.0, extra=None):
body = {"model":"speech-2.8-hd","text":text,"stream":False,"language_boost":"Chinese",
"output_format":"hex","aigc_watermark":False,
"voice_setting":{"voice_id":voice,"speed":speed,"vol":1,"pitch":0},
"audio_setting":{"sample_rate":44100,"bitrate":256000,"format":"mp3","channel":1},
"subtitle_enable":True,"subtitle_type":"word"}
if extra: body.update(extra)
req = urllib.request.Request("https://api.minimax.io/v1/t2a_v2", data=json.dumps(body).encode(),
headers={"Authorization":"Bearer "+KEY,"Content-Type":"application/json"})
d = json.load(urllib.request.urlopen(req, timeout=180))
if d["base_resp"]["status_code"] != 0: raise SystemExit(d["base_resp"])
pathlib.Path(out).write_bytes(bytes.fromhex(d["data"]["audio"]))
subs = None
if d["data"].get("subtitle_file"):
subs = json.load(urllib.request.urlopen(d["data"]["subtitle_file"], timeout=60))
return d["extra_info"], subs
if __name__ == "__main__":
info, subs = tts(sys.argv[2], sys.argv[1], sys.argv[3])
print(info); print(json.dumps(subs, ensure_ascii=False)[:1500])
@@ -0,0 +1,81 @@
{
"s1": {
"len": 3.004,
"segs": [
{
"zh": "一个智能体,扛不动一个庞大的目标。",
"en": "One agent can't carry one huge goal.",
"t0": 0.040634920634920635,
"t1": 2.6412698412698417
}
]
},
"s2": {
"len": 1.985,
"segs": [
{
"zh": "Kigi 的答案:一张图。",
"en": "Kigi's answer: a graph.",
"t0": 0.040634920634920635,
"t1": 1.5034920634920637
}
]
},
"s3": {
"len": 5.72,
"segs": [
{
"zh": "智能体的循环,活在节点内部;",
"en": "The agentic loop lives inside each node;",
"t0": 0.040634920634920635,
"t1": 2.316190476190476
},
{
"zh": "节点之间的边,是确定性的 Rust。",
"en": "the edges between them are deterministic Rust.",
"t0": 2.4787301587301585,
"t1": 5.201269841269841
}
]
},
"s4": {
"len": 5.564,
"segs": [
{
"zh": "就绪的节点三路并行,各占一棵工作树,",
"en": "Ready nodes run three at a time, each in its own worktree,",
"t0": 0.040634920634920635,
"t1": 3.0476190476190474
},
{
"zh": "由怀疑论者逐一审查。",
"en": "each checked by a skeptic.",
"t0": 3.088253968253968,
"t1": 5.16063492063492
}
]
},
"s5": {
"len": 7.915,
"segs": [
{
"zh": "最后,gn-final 重新验收整个目标。",
"en": "Finally, gn-final re-verifies the whole objective.",
"t0": 0.040634920634920635,
"t1": 3.0069841269841273
},
{
"zh": "确定性的骨架,智能的血肉。",
"en": "Deterministic bones. Intelligent flesh.",
"t0": 3.088253968253968,
"t1": 5.526349206349206
},
{
"zh": "Kigi /graph。",
"en": "Kigi /graph.",
"t0": 5.566984126984127,
"t1": 7.314285714285714
}
]
}
}