agtop 2.4.5

Terminal UI for monitoring AI coding agents (Claude Code, Codex, Aider, Cursor, Gemini, Goose, ...) — like top, but for agents.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
name: Release

on:
  push:
    tags: ['v*.*.*']
  # Allow auto-tag.yml to invoke the release flow against a freshly-
  # pushed tag — GITHUB_TOKEN-pushed tags don't trigger `on: push:tags`.
  workflow_dispatch:

permissions:
  contents: write

jobs:
  build:
    name: ${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  target: x86_64-unknown-linux-gnu,  suffix: linux-x86_64,  ext: ''     }
          - { os: ubuntu-latest,  target: aarch64-unknown-linux-gnu, suffix: linux-aarch64, ext: '',    cross: true }
          - { os: macos-latest,   target: x86_64-apple-darwin,       suffix: macos-x86_64,  ext: ''     }
          - { os: macos-latest,   target: aarch64-apple-darwin,      suffix: macos-aarch64, ext: ''     }
          - { os: windows-latest, target: x86_64-pc-windows-msvc,    suffix: windows-x86_64, ext: '.exe' }
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}
      - uses: Swatinem/rust-cache@v2

      - name: Install cross-compile toolchain (linux/aarch64)
        if: matrix.cross
        run: |
          sudo apt-get update
          sudo apt-get install -y gcc-aarch64-linux-gnu
          mkdir -p .cargo
          cat >> .cargo/config.toml <<EOF
          [target.aarch64-unknown-linux-gnu]
          linker = "aarch64-linux-gnu-gcc"
          EOF

      - name: Build release
        run: cargo build --release --locked --target ${{ matrix.target }}

      - name: Package (unix)
        if: runner.os != 'Windows'
        shell: bash
        run: |
          name="agtop-${{ matrix.suffix }}"
          mkdir -p "dist/$name"
          cp "target/${{ matrix.target }}/release/agtop${{ matrix.ext }}" "dist/$name/"
          cp README.md LICENSE "dist/$name/"
          cd dist
          tar -czf "${name}.tar.gz" "$name"

      - name: Package (windows)
        if: runner.os == 'Windows'
        shell: pwsh
        run: |
          $name = "agtop-${{ matrix.suffix }}"
          New-Item -ItemType Directory -Force -Path "dist/$name" | Out-Null
          Copy-Item "target/${{ matrix.target }}/release/agtop${{ matrix.ext }}" "dist/$name/"
          Copy-Item "README.md","LICENSE" "dist/$name/"
          Compress-Archive -Path "dist/$name" -DestinationPath "dist/$name.zip" -Force

      - uses: softprops/action-gh-release@v3
        with:
          files: |
            dist/agtop-${{ matrix.suffix }}.tar.gz
            dist/agtop-${{ matrix.suffix }}.zip
          fail_on_unmatched_files: false
          generate_release_notes: true

  sha256sums:
    name: Aggregate SHA256SUMS
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Download release assets, hash, and upload SHA256SUMS
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail
          version=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          tag="v$version"
          mkdir -p sums
          cd sums
          # gh release download retries internally; the build matrix has
          # already completed by the time we get here so all 5 assets exist.
          gh release download "$tag" --repo "${{ github.repository }}" \
            --pattern 'agtop-*.tar.gz' --pattern 'agtop-*.zip'
          # Standard `sha256sum`-format file: `<sha>  <filename>` per line.
          # Sort for determinism so re-runs produce identical bytes.
          sha256sum agtop-*.tar.gz agtop-*.zip 2>/dev/null | sort > SHA256SUMS
          echo "── SHA256SUMS ──"
          cat SHA256SUMS
          gh release upload "$tag" SHA256SUMS --clobber \
            --repo "${{ github.repository }}"

  publish-crates:
    name: Publish to crates.io
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - name: cargo publish
        env:
          CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
        run: |
          if [ -z "$CRATES_IO_TOKEN" ]; then
            echo "::error::CRATES_IO_TOKEN secret not set"
            exit 1
          fi
          version=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          # Direct API check (cargo search has index-propagation lag of
          # several minutes after a fresh upload).
          if curl -sfo /dev/null "https://crates.io/api/v1/crates/agtop/$version"; then
            echo "agtop $version already on crates.io — skipping"
            exit 0
          fi
          # Even with the API check above, a re-dispatched run can race
          # against a still-propagating publish.  Trap the canonical
          # "already exists" error and treat as success; hard-fail on
          # anything else so silent breakage can't ship.
          if out=$(cargo publish --token "$CRATES_IO_TOKEN" 2>&1); then
            echo "$out"
            exit 0
          fi
          echo "$out"
          if echo "$out" | grep -q "already exists on crates.io index"; then
            echo "agtop $version already on crates.io (caught via publish error) — treating as success"
            exit 0
          fi
          exit 1

  publish-npm:
    name: Publish to npm (@mbrassey/agtop)
    # SHA256SUMS must exist before npm publishes — the install.js
    # postinstall fetches it to verify the downloaded prebuilt.
    needs: [build, sha256sums]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://registry.npmjs.org"
      - name: Build npm tarball
        run: bash packages/npm/build.sh
      - name: Publish (skip if version already on npm)
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          if [ -z "$NODE_AUTH_TOKEN" ]; then
            echo "::error::NPM_TOKEN secret not set"
            exit 1
          fi
          cd packages/npm/build
          version=$(node -p "require('./package.json').version")
          name=$(node -p "require('./package.json').name")
          published=$(npm view "$name@$version" version 2>/dev/null || true)
          if [ -n "$published" ]; then
            echo "$name@$version already on npm — skipping"
            exit 0
          fi
          npm publish --access public

  publish-aur:
    name: Publish to AUR
    needs: build
    runs-on: ubuntu-latest
    # Run inside an Arch container so makepkg / .SRCINFO regeneration
    # is native rather than chained through a third-party action.
    container:
      image: archlinux:latest
    steps:
      - name: Install host deps
        run: |
          pacman -Sy --noconfirm --needed git openssh sudo base-devel curl awk
          useradd -m builder
          echo 'builder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers

      - uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup SSH for AUR
        env:
          AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
        run: |
          if [ -z "$AUR_SSH_PRIVATE_KEY" ]; then
            echo "::error::AUR_SSH_PRIVATE_KEY secret not set"
            exit 1
          fi
          mkdir -p /home/builder/.ssh
          printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > /home/builder/.ssh/id_aur
          chmod 600 /home/builder/.ssh/id_aur
          ssh-keyscan -t ed25519,rsa,ecdsa aur.archlinux.org > /home/builder/.ssh/known_hosts 2>/dev/null
          cat > /home/builder/.ssh/config <<'EOF'
          Host aur.archlinux.org
            IdentityFile ~/.ssh/id_aur
            User aur
            StrictHostKeyChecking yes
            IdentitiesOnly yes
          EOF
          chmod 600 /home/builder/.ssh/config /home/builder/.ssh/known_hosts
          chown -R builder:builder /home/builder/.ssh

      - name: Resolve version
        id: ver
        run: |
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          echo "version=$v" >> "$GITHUB_OUTPUT"

      - name: Compute release tarball sha256
        id: sha
        run: |
          url="https://github.com/mbrassey/agtop/archive/v${{ steps.ver.outputs.version }}.tar.gz"
          for i in $(seq 1 10); do
            if curl -sfL -o /tmp/agtop.tar.gz "$url"; then break; fi
            echo "tarball not ready (attempt $i) — sleeping 10s"
            sleep 10
          done
          sha=$(sha256sum /tmp/agtop.tar.gz | awk '{print $1}')
          echo "sha=$sha" >> "$GITHUB_OUTPUT"

      - name: Update AUR repo
        env:
          VERSION: ${{ steps.ver.outputs.version }}
          SHA256:  ${{ steps.sha.outputs.sha }}
        run: |
          # The Actions checkout lives at $GITHUB_WORKSPACE inside the
          # container.  Stage the PKGBUILD into builder's homedir so
          # makepkg can run as the unprivileged user (it refuses root).
          cp packages/pacman/PKGBUILD /home/builder/PKGBUILD.in
          chown builder:builder /home/builder/PKGBUILD.in
          sudo -u builder -H bash -se <<INNER
          set -euo pipefail
          cd "\$HOME"
          git clone ssh://aur@aur.archlinux.org/agtop.git aur-repo
          cd aur-repo
          cp ../PKGBUILD.in PKGBUILD
          # Rewrite pkgver, pkgrel, source, and sha256sums.  Pre-2.4.3
          # this step skipped pkgver entirely, which left the AUR repo
          # forever pinned to whatever literal pkgver= was committed
          # in packages/pacman/PKGBUILD on disk (2.3.9), while the
          # sha256 was bumped to match the new tarball — every release
          # shipped a broken AUR PKGBUILD that failed integrity check.
          # \\\${pkgver} stays literal in the source URL so PKGBUILD's
          # own variable substitution drives the URL; \${VERSION} and
          # \${SHA256} substitute here from the outer GH Actions step.
          sed -i "s|^pkgver=.*|pkgver=${VERSION}|" PKGBUILD
          sed -i "s|^pkgrel=.*|pkgrel=1|" PKGBUILD
          sed -i 's|^source=.*|source=("agtop-\${pkgver}.tar.gz::https://github.com/mbrassey/agtop/archive/v\${pkgver}.tar.gz")|' PKGBUILD
          sed -i "s|^sha256sums=.*|sha256sums=('${SHA256}')|" PKGBUILD
          # Sanity check: fail loud if any of the three rewrites
          # didn't take.  Defends against future PKGBUILD reformat.
          grep -q "^pkgver=${VERSION}\$"          PKGBUILD || { echo "pkgver rewrite failed"; exit 1; }
          grep -q "^sha256sums=('${SHA256}')\$"   PKGBUILD || { echo "sha256 rewrite failed"; exit 1; }
          makepkg --printsrcinfo > .SRCINFO
          git config user.name  'mbrassey'
          git config user.email 'matt@brassey.io'
          git add PKGBUILD .SRCINFO
          if git diff --cached --quiet; then
            echo "AUR repo already at this version — skipping push"
            exit 0
          fi
          git commit -m "agtop ${VERSION}"
          git push
          INNER

  publish-debian:
    name: Publish .deb to apt repo (mbrassey.github.io/apt)
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Resolve version
        id: ver
        run: |
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          echo "version=$v" >> "$GITHUB_OUTPUT"

      - name: Install apt-repo tooling
        run: |
          sudo apt-get update -qq
          sudo apt-get install -y -qq dpkg-dev gpg

      # Pull the prebuilt linux-x86_64 binary this run's matrix
      # already produced — saves ~2 min vs recompiling.
      - name: Download linux-x86_64 prebuilt
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          mkdir -p deb-bin
          gh release download "v${{ steps.ver.outputs.version }}" \
            --repo "${{ github.repository }}" \
            --pattern 'agtop-linux-x86_64.tar.gz' --dir deb-bin
          tar -xzf deb-bin/agtop-linux-x86_64.tar.gz -C deb-bin
          mkdir -p target/release
          install -m 0755 deb-bin/agtop-linux-x86_64/agtop target/release/agtop

      - name: Build .deb
        run: bash packages/deb/build.sh

      - name: Import GPG signing key
        env:
          KEY: ${{ secrets.APT_REPO_GPG_PRIVATE_KEY }}
        run: |
          if [ -z "$KEY" ]; then
            echo "::error::APT_REPO_GPG_PRIVATE_KEY secret not set"; exit 1
          fi
          mkdir -p ~/.gnupg && chmod 700 ~/.gnupg
          printf '%s' "$KEY" | gpg --batch --import 2>&1 | tail -3
          echo "GPG_KEY_ID=$(gpg --list-secret-keys --with-colons \
            | awk -F: '/^sec/{print $5; exit}')" >> "$GITHUB_ENV"

      - name: Stage apt repo (clone + add deb + regen metadata)
        env:
          TOK: ${{ secrets.APT_REPO_TOKEN }}
          VERSION: ${{ steps.ver.outputs.version }}
        run: |
          if [ -z "$TOK" ]; then
            echo "::error::APT_REPO_TOKEN secret not set"; exit 1
          fi
          tap=$(mktemp -d)
          git clone --depth=1 \
            "https://x-access-token:${TOK}@github.com/MBrassey/apt.git" "$tap"
          DEB="packages/deb/agtop_${VERSION}_amd64.deb"
          [ -f "$DEB" ] || { echo "::error::missing $DEB"; ls packages/deb/; exit 1; }
          # Flat repo layout — no dists/ tree.  Users add a single
          # `deb [signed-by=...] https://mbrassey.github.io/apt ./` line.
          mkdir -p "$tap/pool"
          cp "$DEB" "$tap/pool/"
          ( cd "$tap" && dpkg-scanpackages --multiversion pool /dev/null \
              > Packages
            gzip -9k -f Packages
            apt-ftparchive release \
              -o APT::FTPArchive::Release::Origin="MBrassey" \
              -o APT::FTPArchive::Release::Label="agtop" \
              -o APT::FTPArchive::Release::Suite="stable" \
              -o APT::FTPArchive::Release::Codename="agtop" \
              -o APT::FTPArchive::Release::Architectures="amd64" \
              -o APT::FTPArchive::Release::Components="main" \
              . > Release
            gpg --batch --yes --default-key "${GPG_KEY_ID}" -abs -o Release.gpg Release
            gpg --batch --yes --default-key "${GPG_KEY_ID}" --clearsign -o InRelease Release
          )

          cd "$tap"
          git config user.name  "mbrassey"
          git config user.email "matt@brassey.io"
          git add -A
          if git diff --cached --quiet; then
            echo "no changes — apt repo already at v${VERSION}"
            exit 0
          fi
          git commit -m "agtop ${VERSION}"
          git push

      # Hardening: tear down the gpg-agent and shred the imported
      # private key as soon as the publish step completes, so any
      # later step in the same job (none today, but defensive
      # against future additions) cannot inherit signing capability.
      # Runs even on prior-step failure via `if: always()`.
      - name: Wipe GPG signing material
        if: always()
        run: |
          gpgconf --kill all 2>/dev/null || true
          if [ -d "$HOME/.gnupg" ]; then
            find "$HOME/.gnupg" -type f -exec shred -u {} + 2>/dev/null || true
            rm -rf "$HOME/.gnupg"
          fi

  # Snap Store publish was retired in 2.4.2: GitHub Actions runners
  # repeatedly hit `error: unable to contact snap store` during the
  # `snap install lxd` bootstrap step inside snapcore/action-build,
  # which was outside our control and burned ~4m per release.  apt
  # repo at mbrassey.github.io/apt covers Debian / Ubuntu / Mint /
  # Pop!_OS users with a signed .deb on every tag, so the channel
  # we lost was redundant.  snap/snapcraft.yaml is kept around for
  # users who still want to build locally with `snapcraft pack`.

  publish-homebrew:
    name: Publish to Homebrew tap
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Resolve version + sha256
        id: meta
        run: |
          set -euo pipefail
          v=$(awk -F'"' '/^version[[:space:]]*=/{print $2; exit}' Cargo.toml)
          url="https://github.com/mbrassey/agtop/archive/refs/tags/v${v}.tar.gz"
          for i in $(seq 1 10); do
            if curl -sfL -o /tmp/src.tar.gz "$url"; then break; fi
            sleep 10
          done
          sha=$(sha256sum /tmp/src.tar.gz | awk '{print $1}')
          echo "version=$v"  >> "$GITHUB_OUTPUT"
          echo "sha=$sha"    >> "$GITHUB_OUTPUT"
          echo "url=$url"    >> "$GITHUB_OUTPUT"

      - name: Template formula
        run: |
          mkdir -p homebrew-out
          sed \
            -e "s|^  url \".*\"\$|  url \"${{ steps.meta.outputs.url }}\"|" \
            -e "s|^  sha256 \".*\"\$|  sha256 \"${{ steps.meta.outputs.sha }}\"|" \
            homebrew/agtop.rb > homebrew-out/agtop.rb
          echo "── final formula ──"
          cat homebrew-out/agtop.rb

      - name: Push to mbrassey/homebrew-tap
        env:
          HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
        run: |
          if [ -z "$HOMEBREW_TAP_TOKEN" ]; then
            echo "::warning::HOMEBREW_TAP_TOKEN secret not set — skipping tap push (formula was templated above for inspection)"
            exit 0
          fi
          git config --global user.name  mbrassey
          git config --global user.email matt@brassey.io
          tap_dir=$(mktemp -d)
          git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/MBrassey/homebrew-tap.git" "$tap_dir"
          mkdir -p "$tap_dir/Formula"
          cp homebrew-out/agtop.rb "$tap_dir/Formula/agtop.rb"
          cd "$tap_dir"
          git add Formula/agtop.rb
          if git diff --cached --quiet; then
            echo "tap formula unchanged — skipping push"
            exit 0
          fi
          git commit -m "agtop ${{ steps.meta.outputs.version }}"
          git push