diff --git a/.github/workflows/sync-skills.yml b/.github/workflows/sync-skills.yml index 062c240..677db7d 100644 --- a/.github/workflows/sync-skills.yml +++ b/.github/workflows/sync-skills.yml @@ -18,7 +18,7 @@ jobs: fetch-depth: 0 - name: Sync skills from clawdbot - run: scripts/sync-skills.sh + run: go run ./cmd/sync-skills - name: Commit & push if changed run: | diff --git a/.github/workflows/update-tools.yml b/.github/workflows/update-tools.yml index cf5fd80..20e6238 100644 --- a/.github/workflows/update-tools.yml +++ b/.github/workflows/update-tools.yml @@ -23,7 +23,7 @@ jobs: - name: Update tool versions and hashes env: GH_TOKEN: ${{ github.token }} - run: scripts/update-tools.sh + run: go run ./cmd/update-tools - name: Commit & push if changed run: | diff --git a/README.md b/README.md index beacefb..57d942c 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ inputs.nix-steipete-tools.packages.aarch64-darwin.peekaboo Skills are vendored from [clawdbot/clawdbot](https://github.com/clawdbot/clawdbot) main branch. No pinning - we track latest. ```bash -scripts/sync-skills.sh +go run ./cmd/sync-skills ``` Pulls latest main via sparse checkout, only updates files when contents actually change. @@ -75,7 +75,7 @@ Pulls latest main via sparse checkout, only updates files when contents actually Tools track upstream GitHub releases directly (not Homebrew). ```bash -scripts/update-tools.sh +go run ./cmd/update-tools ``` Fetches latest release versions/URLs/hashes and updates the Nix expressions. Oracle uses pnpm and auto-derives its hash via build mismatch. diff --git a/cmd/sync-skills/main.go b/cmd/sync-skills/main.go new file mode 100644 index 0000000..1951a44 --- /dev/null +++ b/cmd/sync-skills/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" +) + +type Mapping struct { + Tool string + Up string +} + +func run(dir string, name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Dir = dir + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s %v: %v: %s", name, args, err, out.String()) + } + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Close() +} + +func main() { + repoRoot, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + workdir, err := os.MkdirTemp("", "clawdbot-skills-") + if err != nil { + log.Fatal(err) + } + defer os.RemoveAll(workdir) + + mappings := []Mapping{ + {"summarize", "skills/summarize"}, + {"gogcli", "skills/gog"}, + {"camsnap", "skills/camsnap"}, + {"sonoscli", "skills/sonoscli"}, + {"bird", "skills/bird"}, + {"peekaboo", "skills/peekaboo"}, + {"sag", "skills/sag"}, + {"imsg", "skills/imsg"}, + {"oracle", "skills/oracle"}, + } + + log.Printf("[sync-skills] cloning clawdbot main") + if err := run("", "git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", "https://github.com/clawdbot/clawdbot.git", workdir); err != nil { + log.Fatal(err) + } + paths := []string{} + for _, m := range mappings { + paths = append(paths, m.Up) + } + args := append([]string{"sparse-checkout", "set"}, paths...) + if err := run(workdir, "git", args...); err != nil { + log.Fatal(err) + } + + updated := false + for _, m := range mappings { + src := filepath.Join(workdir, m.Up, "SKILL.md") + dest := filepath.Join(repoRoot, "tools", m.Tool, "skills", filepath.Base(m.Up), "SKILL.md") + if _, err := os.Stat(src); err != nil { + log.Printf("[sync-skills] missing %s", src) + continue + } + same := false + if b1, err1 := os.ReadFile(src); err1 == nil { + if b2, err2 := os.ReadFile(dest); err2 == nil && bytes.Equal(b1, b2) { + same = true + } + } + if !same { + if err := copyFile(src, dest); err != nil { + log.Fatalf("copy %s -> %s: %v", src, dest, err) + } + updated = true + log.Printf("[sync-skills] updated %s", m.Tool) + } + } + + if !updated { + log.Printf("[sync-skills] no changes") + } +} diff --git a/cmd/update-tools/main.go b/cmd/update-tools/main.go new file mode 100644 index 0000000..bbce333 --- /dev/null +++ b/cmd/update-tools/main.go @@ -0,0 +1,147 @@ +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/clawdbot/nix-stepiete-tools/internal" +) + +type Tool struct { + Name string + Repo string + AssetRegex *regexp.Regexp + NixFile string +} + +func updateTool(tool Tool) error { + log.Printf("[update-tools] %s", tool.Name) + rel, err := internal.LatestRelease(tool.Repo) + if err != nil { + return err + } + version := strings.TrimPrefix(rel.TagName, "v") + var assetURL string + for _, a := range rel.Assets { + if tool.AssetRegex.MatchString(a.Name) { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return fmt.Errorf("no asset matched for %s", tool.Name) + } + hash, err := internal.PrefetchHash(assetURL) + if err != nil { + return err + } + + if err := internal.ReplaceOnce(tool.NixFile, regexp.MustCompile(`version = "[^"]+";`), fmt.Sprintf(`version = "%s";`, version)); err != nil { + return err + } + if err := internal.ReplaceOnce(tool.NixFile, regexp.MustCompile(`url = "[^"]+";`), fmt.Sprintf(`url = "%s";`, assetURL)); err != nil { + return err + } + if err := internal.ReplaceOnce(tool.NixFile, regexp.MustCompile(`hash = "sha256-[^"]+";`), fmt.Sprintf(`hash = "%s";`, hash)); err != nil { + return err + } + + return nil +} + +func updateOracle(repoRoot string) error { + log.Printf("[update-tools] oracle") + rel, err := internal.LatestRelease("steipete/oracle") + if err != nil { + return err + } + version := strings.TrimPrefix(rel.TagName, "v") + var assetURL string + for _, a := range rel.Assets { + if matched, _ := regexp.MatchString(`oracle-[0-9.]+\.tgz`, a.Name); matched { + assetURL = a.BrowserDownloadURL + break + } + } + if assetURL == "" { + return fmt.Errorf("no asset matched for oracle") + } + assetHash, err := internal.PrefetchHash(assetURL) + if err != nil { + return err + } + lockURL := fmt.Sprintf("https://github.com/steipete/oracle/archive/refs/tags/%s.tar.gz", rel.TagName) + lockHash, err := internal.PrefetchHash(lockURL) + if err != nil { + return err + } + + oracleFile := filepath.Join(repoRoot, "nix", "pkgs", "oracle.nix") + if err := internal.ReplaceOnce(oracleFile, regexp.MustCompile(`version = "[^"]+";`), fmt.Sprintf(`version = "%s";`, version)); err != nil { + return err + } + if err := internal.ReplaceOnce(oracleFile, regexp.MustCompile(`url = "[^"]+";`), fmt.Sprintf(`url = "%s";`, assetURL)); err != nil { + return err + } + if err := internal.ReplaceOnce(oracleFile, regexp.MustCompile(`hash = "sha256-[^"]+";`), fmt.Sprintf(`hash = "%s";`, assetHash)); err != nil { + return err + } + lockRe := regexp.MustCompile(`lockSrc = fetchFromGitHub \{[^}]*?hash = "sha256-[^"]+";`) + if err := internal.ReplaceOnceFunc(oracleFile, lockRe, func(s string) string { + return regexp.MustCompile(`hash = "sha256-[^"]+";`).ReplaceAllString(s, fmt.Sprintf(`hash = "%s";`, lockHash)) + }); err != nil { + return err + } + pnpmRe := regexp.MustCompile(`pnpmDeps.*?hash = "sha256-[^"]+";`) + if err := internal.ReplaceOnceFunc(oracleFile, pnpmRe, func(s string) string { + return regexp.MustCompile(`hash = "sha256-[^"]+";`).ReplaceAllString(s, `hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";`) + }); err != nil { + return err + } + + log.Printf("[update-tools] oracle: deriving pnpm hash") + logText, _ := internal.NixBuildOracle() + pnpmHash := internal.ExtractGotHash(logText) + if pnpmHash == "" { + return fmt.Errorf("oracle pnpm hash not found in build output") + } + if err := internal.ReplaceOnceFunc(oracleFile, pnpmRe, func(s string) string { + return regexp.MustCompile(`hash = "sha256-[^"]+";`).ReplaceAllString(s, fmt.Sprintf(`hash = "%s";`, pnpmHash)) + }); err != nil { + return err + } + return nil +} + +func main() { + repoRoot, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + tools := []Tool{ + {"summarize", "steipete/summarize", regexp.MustCompile(`summarize-macos-arm64-v[0-9.]+\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "summarize.nix")}, + {"gogcli", "steipete/gogcli", regexp.MustCompile(`gogcli_[0-9.]+_darwin_arm64\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "gogcli.nix")}, + {"camsnap", "steipete/camsnap", regexp.MustCompile(`camsnap-macos-arm64\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "camsnap.nix")}, + {"sonoscli", "steipete/sonoscli", regexp.MustCompile(`sonoscli-macos-arm64\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "sonoscli.nix")}, + {"bird", "steipete/bird", regexp.MustCompile(`bird-macos-universal-v[0-9.]+\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "bird.nix")}, + {"peekaboo", "steipete/peekaboo", regexp.MustCompile(`peekaboo-macos-universal\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "peekaboo.nix")}, + {"poltergeist", "steipete/poltergeist", regexp.MustCompile(`poltergeist-macos-universal-v[0-9.]+\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "poltergeist.nix")}, + {"sag", "steipete/sag", regexp.MustCompile(`sag_[0-9.]+_darwin_universal\.tar\.gz`), filepath.Join(repoRoot, "nix", "pkgs", "sag.nix")}, + {"imsg", "steipete/imsg", regexp.MustCompile(`imsg-macos\.zip`), filepath.Join(repoRoot, "nix", "pkgs", "imsg.nix")}, + } + + for _, tool := range tools { + if err := updateTool(tool); err != nil { + log.Fatalf("update %s failed: %v", tool.Name, err) + } + } + + if err := updateOracle(repoRoot); err != nil { + log.Fatalf("update oracle failed: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3c38714 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/clawdbot/nix-stepiete-tools + +go 1.22 diff --git a/internal/fs.go b/internal/fs.go new file mode 100644 index 0000000..b531baf --- /dev/null +++ b/internal/fs.go @@ -0,0 +1,33 @@ +package internal + +import ( + "fmt" + "os" + "regexp" +) + +func ReplaceOnce(path string, re *regexp.Regexp, replace string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + orig := string(data) + out := re.ReplaceAllString(orig, replace) + if out == orig { + return fmt.Errorf("pattern not found in %s", path) + } + return os.WriteFile(path, []byte(out), 0644) +} + +func ReplaceOnceFunc(path string, re *regexp.Regexp, fn func(string) string) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + orig := string(data) + out := re.ReplaceAllStringFunc(orig, fn) + if out == orig { + return fmt.Errorf("pattern not found in %s", path) + } + return os.WriteFile(path, []byte(out), 0644) +} diff --git a/internal/github.go b/internal/github.go new file mode 100644 index 0000000..77e7aba --- /dev/null +++ b/internal/github.go @@ -0,0 +1,49 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" +) + +type Release struct { + TagName string `json:"tag_name"` + Assets []Asset `json:"assets"` +} + +type Asset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func LatestRelease(repo string) (*Release, error) { + url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token := os.Getenv("GH_TOKEN"); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("github api %s: %s", resp.Status, string(body)) + } + var rel Release + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return nil, err + } + if rel.TagName == "" { + return nil, errors.New("missing tag_name in release") + } + return &rel, nil +} diff --git a/internal/nix.go b/internal/nix.go new file mode 100644 index 0000000..59d21e6 --- /dev/null +++ b/internal/nix.go @@ -0,0 +1,49 @@ +package internal + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +type PrefetchResult struct { + Hash string `json:"hash"` +} + +func PrefetchHash(url string) (string, error) { + cmd := exec.Command("nix", "store", "prefetch-file", "--json", url) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("prefetch failed: %v: %s", err, out.String()) + } + var res PrefetchResult + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + return "", err + } + if res.Hash == "" { + return "", fmt.Errorf("empty hash for %s", url) + } + return res.Hash, nil +} + +func NixBuildOracle() (string, error) { + cmd := exec.Command("nix", "build", ".#oracle") + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + return out.String(), err +} + +func ExtractGotHash(log string) string { + for _, line := range strings.Split(log, "\n") { + if idx := strings.Index(line, "got: sha256-"); idx != -1 { + return strings.TrimSpace(line[idx+5:]) + } + } + return "" +} diff --git a/scripts/sync-skills.sh b/scripts/sync-skills.sh deleted file mode 100755 index 1c88f67..0000000 --- a/scripts/sync-skills.sh +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -workdir="$(mktemp -d)" -trap 'rm -rf "${workdir}"' EXIT - -# Mapping: tool -> upstream skill dir (relative to clawdbot repo) -declare -A SKILLS -SKILLS[summarize]="skills/summarize" -SKILLS[gogcli]="skills/gog" -SKILLS[camsnap]="skills/camsnap" -SKILLS[sonoscli]="skills/sonoscli" -SKILLS[bird]="skills/bird" -SKILLS[peekaboo]="skills/peekaboo" -SKILLS[sag]="skills/sag" -SKILLS[imsg]="skills/imsg" -SKILLS[oracle]="skills/oracle" - -# Clone only the needed paths from latest main. -# Note: no pinning; this is explicitly "latest main". -git -c advice.detachedHead=false clone --depth 1 --filter=blob:none --sparse \ - https://github.com/clawdbot/clawdbot.git "${workdir}/clawdbot" >/dev/null - -pushd "${workdir}/clawdbot" >/dev/null - git sparse-checkout set "${SKILLS[@]}" >/dev/null -popd >/dev/null - -updated=0 -for tool in "${!SKILLS[@]}"; do - src_dir="${workdir}/clawdbot/${SKILLS[$tool]}" - dest_dir="${repo_root}/tools/${tool}/skills/$(basename "${SKILLS[$tool]}")" - - if [[ ! -d "${src_dir}" ]]; then - echo "[sync-skills] missing upstream skill: ${SKILLS[$tool]} (skip)" >&2 - continue - fi - - mkdir -p "${dest_dir}" - - # Copy only if content changed (avoid noisy commits). - if ! cmp -s "${src_dir}/SKILL.md" "${dest_dir}/SKILL.md" 2>/dev/null; then - cp "${src_dir}/SKILL.md" "${dest_dir}/SKILL.md" - updated=1 - echo "[sync-skills] updated ${tool}" >&2 - fi - - # If upstream has extra files in the skill dir, sync them too. - rsync -a --delete --exclude SKILL.md "${src_dir}/" "${dest_dir}/" >/dev/null - if [[ -n "$(git -C "${repo_root}" status --porcelain "${dest_dir}" 2>/dev/null)" ]]; then - updated=1 - fi - -done - -if [[ $updated -eq 0 ]]; then - echo "[sync-skills] no changes" >&2 -fi diff --git a/scripts/update-tools.sh b/scripts/update-tools.sh deleted file mode 100755 index 3aa7422..0000000 --- a/scripts/update-tools.sh +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" - -if ! command -v jq >/dev/null 2>&1; then - echo "[update-tools] jq is required" >&2 - exit 1 -fi - -if ! command -v nix >/dev/null 2>&1; then - echo "[update-tools] nix is required" >&2 - exit 1 -fi - -prefetch_hash() { - local url="$1" - nix store prefetch-file --json "$url" | jq -r .hash -} - -latest_release() { - local repo="$1" - gh api "repos/${repo}/releases/latest" -} - -update_nix_file() { - local file="$1" - local version="$2" - local url="$3" - local hash="$4" - - VERSION="$version" URL="$url" HASH="$hash" FILE="$file" python3 - <<'PY' -from pathlib import Path -import os -import re - -path = Path(os.environ["FILE"]) -version = os.environ["VERSION"] -url = os.environ["URL"] -hash_ = os.environ["HASH"] - -text = path.read_text() -text, n1 = re.subn(r'version = "[^"]+";', f'version = "{version}";', text, count=1) -text, n2 = re.subn(r'url = "[^"]+";', f'url = "{url}";', text, count=1) -text, n3 = re.subn(r'hash = "sha256-[^"]+";', f'hash = "{hash_}";', text, count=1) - -if n1 == 0 or n2 == 0 or n3 == 0: - raise SystemExit(f"update failed for {path}: version/url/hash not found") - -path.write_text(text) -PY -} - -update_tool() { - local tool="$1" - local repo="$2" - local asset_regex="$3" - local nix_file="$4" - - echo "[update-tools] ${tool}" >&2 - local json - json=$(latest_release "$repo") - - local tag - tag=$(echo "$json" | jq -r .tag_name) - local version - version="${tag#v}" - - local asset - asset=$(echo "$json" | jq -r --arg re "$asset_regex" '.assets[] | select(.name|test($re)) | .browser_download_url' | head -1) - - if [[ -z "$asset" ]]; then - echo "[update-tools] no asset matched for ${tool} (${asset_regex})" >&2 - return 1 - fi - - local hash - hash=$(prefetch_hash "$asset") - - update_nix_file "$nix_file" "$version" "$asset" "$hash" -} - -update_oracle() { - local tool="oracle" - local repo="steipete/oracle" - local nix_file="$repo_root/nix/pkgs/oracle.nix" - - echo "[update-tools] ${tool}" >&2 - local json - json=$(latest_release "$repo") - - local tag - tag=$(echo "$json" | jq -r .tag_name) - local version - version="${tag#v}" - - local asset - asset=$(echo "$json" | jq -r '.assets[] | select(.name|test("oracle-[0-9.]+\\.tgz")) | .browser_download_url' | head -1) - - if [[ -z "$asset" ]]; then - echo "[update-tools] no asset matched for oracle" >&2 - return 1 - fi - - local asset_hash - asset_hash=$(prefetch_hash "$asset") - - local lock_url - lock_url="https://github.com/steipete/oracle/archive/refs/tags/${tag}.tar.gz" - local lock_hash - lock_hash=$(prefetch_hash "$lock_url") - - VERSION="$version" URL="$asset" HASH="$asset_hash" LOCK_HASH="$lock_hash" FILE="$nix_file" python3 - <<'PY' -from pathlib import Path -import os -import re - -path = Path(os.environ["FILE"]) -version = os.environ["VERSION"] -url = os.environ["URL"] -hash_ = os.environ["HASH"] -lock_hash = os.environ["LOCK_HASH"] - -text = path.read_text() -text, n1 = re.subn(r'version = "[^"]+";', f'version = "{version}";', text, count=1) -text, n2 = re.subn(r'url = "[^"]+";', f'url = "{url}";', text, count=1) -text, n3 = re.subn(r'hash = "sha256-[^"]+";', f'hash = "{hash_}";', text, count=1) -text, n4 = re.subn(r'lockSrc = fetchFromGitHub \{[^}]*?hash = "sha256-[^"]+";', - lambda m: re.sub(r'hash = "sha256-[^"]+";', f'hash = "{lock_hash}";', m.group(0)), - text, count=1, flags=re.S) -text, n5 = re.subn(r'pnpmDeps.*?hash = "sha256-[^"]+";', - lambda m: re.sub(r'hash = "sha256-[^"]+";', 'hash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";', m.group(0)), - text, count=1, flags=re.S) - -if n1 == 0 or n2 == 0 or n3 == 0 or n4 == 0 or n5 == 0: - raise SystemExit(f"update failed for {path}: fields not found") - -path.write_text(text) -PY - - # Derive pnpmDeps hash from a build mismatch - set +e - build_log=$(nix build .#oracle 2>&1) - set -e - if echo "$build_log" | grep -q "got: sha256-"; then - pnpm_hash=$(echo "$build_log" | sed -n 's/.*got: \(sha256-[A-Za-z0-9+/=]*\).*/\1/p' | head -1) - if [[ -n "$pnpm_hash" ]]; then - PNPM_HASH="$pnpm_hash" FILE="$nix_file" python3 - <<'PY' -from pathlib import Path -import os -import re - -path = Path(os.environ["FILE"]) -pnpm_hash = os.environ["PNPM_HASH"] -text = path.read_text() -text, n = re.subn(r'pnpmDeps.*?hash = "sha256-[^"]+";', - lambda m: re.sub(r'hash = "sha256-[^"]+";', f'hash = "{pnpm_hash}";', m.group(0)), - text, count=1, flags=re.S) -if n == 0: - raise SystemExit(f"update failed for {path}: pnpmDeps hash not found") -path.write_text(text) -PY - else - echo "[update-tools] failed to extract pnpmDeps hash" >&2 - return 1 - fi - else - echo "[update-tools] no pnpmDeps hash mismatch found" >&2 - return 1 - fi -} - -update_tool summarize "steipete/summarize" "summarize-macos-arm64-v[0-9.]+\\.tar\\.gz" "$repo_root/nix/pkgs/summarize.nix" -update_tool gogcli "steipete/gogcli" "gogcli_[0-9.]+_darwin_arm64\\.tar\\.gz" "$repo_root/nix/pkgs/gogcli.nix" -update_tool camsnap "steipete/camsnap" "camsnap-macos-arm64\\.tar\\.gz" "$repo_root/nix/pkgs/camsnap.nix" -update_tool sonoscli "steipete/sonoscli" "sonoscli-macos-arm64\\.tar\\.gz" "$repo_root/nix/pkgs/sonoscli.nix" -update_tool bird "steipete/bird" "bird-macos-universal-v[0-9.]+\\.tar\\.gz" "$repo_root/nix/pkgs/bird.nix" -update_tool peekaboo "steipete/peekaboo" "peekaboo-macos-universal\\.tar\\.gz" "$repo_root/nix/pkgs/peekaboo.nix" -update_tool poltergeist "steipete/poltergeist" "poltergeist-macos-universal-v[0-9.]+\\.tar\\.gz" "$repo_root/nix/pkgs/poltergeist.nix" -update_tool sag "steipete/sag" "sag_[0-9.]+_darwin_universal\\.tar\\.gz" "$repo_root/nix/pkgs/sag.nix" -update_tool imsg "steipete/imsg" "imsg-macos\\.zip" "$repo_root/nix/pkgs/imsg.nix" -update_oracle