mirror of
https://github.com/tiennm99/awesome-ai-dev-tools.git
synced 2026-10-11 03:13:05 +00:00
feat: split data update from site build; publish via Cloudflare Pages
The updater fetched GitHub metadata and rendered the dashboard payload in a single step, so anything that published the site needed a GITHUB_TOKEN. Cloudflare Pages builds from a Git webhook and has no business holding one. Split the tool into three modes. The default update step still fetches GitHub and now records every API-sourced field in data/metadata.json, which is committed alongside README.md and data/history.jsonl. The new -build mode joins that snapshot with data/agents.yml and data/history.jsonl to render dist/ with no network access and no token; -check is unchanged. Supporting changes: - computeDeltaAt anchors the delta windows to when the data was fetched rather than the wall clock, so a redeploy days later reproduces the same Δ7d instead of sliding the window past its slack allowance. - writeSiteData takes updatedAt explicitly for the same reason: the timestamp labels data freshness, not build time. - sortStats is extracted from fetchStats so the build step re-ranks identically from committed metadata. - An agents.yml entry with no metadata yet is omitted with a warning instead of failing the build, which would otherwise block every deploy between merging a new entry and the next nightly run. - update.yml drops the GitHub Pages deploy steps and commits data/metadata.json; ci.yml runs `go run . -build` so a build that would break on deploy breaks in CI first. - site/_headers stops the edge serving a stale data.json after a refresh. - docs/DEPLOY.md covers the Cloudflare setup, including the one-time bootstrap of data/metadata.json that the build depends on. Also carries the in-progress curation work already in the tree: the module rename to awesome-ai-dev-tools, retagged entries, and removal of the archived Roo-Code, void, continue and suna entries.
This commit is contained in:
1 parent
3310ed36d4
commit
00c90c5be9
22 files changed
+725
-113
No files matched your search
@@ -11,6 +11,7 @@ on:
|
|||||||
- '.github/workflows/ci.yml'
|
- '.github/workflows/ci.yml'
|
||||||
- 'data/agents.yml'
|
- 'data/agents.yml'
|
||||||
- 'templates/**'
|
- 'templates/**'
|
||||||
|
- 'site/**'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -30,6 +31,9 @@ jobs:
|
|||||||
- run: go test ./...
|
- run: go test ./...
|
||||||
- run: go build ./...
|
- run: go build ./...
|
||||||
- run: go run . -check
|
- run: go run . -check
|
||||||
|
# Runs exactly what Cloudflare Pages runs, so a build that would fail on
|
||||||
|
# deploy fails here instead. Needs no token — that is the point of the split.
|
||||||
|
- run: go run . -build
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'data/agents.yml'
|
- 'data/agents.yml'
|
||||||
- 'site/**'
|
|
||||||
- 'templates/**'
|
- 'templates/**'
|
||||||
- '**.go'
|
- '**.go'
|
||||||
- 'go.mod'
|
- 'go.mod'
|
||||||
@@ -18,11 +17,11 @@ permissions:
|
|||||||
# Uses GITHUB_TOKEN intentionally — a PAT would cause infinite trigger loops
|
# Uses GITHUB_TOKEN intentionally — a PAT would cause infinite trigger loops
|
||||||
# on the auto-commit ("chore: daily ranking refresh") because GITHUB_TOKEN-authored
|
# on the auto-commit ("chore: daily ranking refresh") because GITHUB_TOKEN-authored
|
||||||
# pushes do NOT re-trigger workflows, whereas a PAT would.
|
# pushes do NOT re-trigger workflows, whereas a PAT would.
|
||||||
|
#
|
||||||
|
# This job only refreshes data. Publishing is Cloudflare Pages' job: it builds
|
||||||
|
# from the commit this job pushes (webhook-driven, so the GITHUB_TOKEN caveat
|
||||||
|
# above does not apply to it) by running `go run . -build`, which needs no token.
|
||||||
contents: write
|
contents: write
|
||||||
# Pages deploy happens in THIS workflow (a separate push-triggered Pages
|
|
||||||
# workflow would never fire — see GITHUB_TOKEN note above).
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: update-rankings
|
group: update-rankings
|
||||||
@@ -31,9 +30,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
update:
|
update:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deploy.outputs.page_url }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
@@ -42,6 +38,9 @@ jobs:
|
|||||||
go-version: 'stable'
|
go-version: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
|
||||||
|
# The only step that touches the network or needs a token. It refreshes
|
||||||
|
# README.md, data/history.jsonl and data/metadata.json; rendering the
|
||||||
|
# site from those files happens later, on Cloudflare.
|
||||||
- name: Run updater
|
- name: Run updater
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -51,7 +50,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
git config user.name "github-actions[bot]"
|
git config user.name "github-actions[bot]"
|
||||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
git add README.md data/history.jsonl
|
git add README.md data/history.jsonl data/metadata.json
|
||||||
if git diff --staged --quiet; then
|
if git diff --staged --quiet; then
|
||||||
echo "no changes"
|
echo "no changes"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -59,7 +58,7 @@ jobs:
|
|||||||
git commit -m "chore: daily ranking refresh"
|
git commit -m "chore: daily ranking refresh"
|
||||||
# Rebase + retry guards against the race where two runs commit near-simultaneously.
|
# Rebase + retry guards against the race where two runs commit near-simultaneously.
|
||||||
# Strategy: plain rebase first; only force-resolve bot-generated files (README.md,
|
# Strategy: plain rebase first; only force-resolve bot-generated files (README.md,
|
||||||
# data/history.jsonl) when they are the sole conflicts. If any human-maintained file
|
# data/history.jsonl, data/metadata.json) when they are the sole conflicts. If any human-maintained file
|
||||||
# (e.g. data/agents.yml) conflicts, abort and fail loudly so a human can investigate.
|
# (e.g. data/agents.yml) conflicts, abort and fail loudly so a human can investigate.
|
||||||
for attempt in 1 2 3; do
|
for attempt in 1 2 3; do
|
||||||
if git push; then exit 0; fi
|
if git push; then exit 0; fi
|
||||||
@@ -72,31 +71,15 @@ jobs:
|
|||||||
conflicted=$(git diff --name-only --diff-filter=U)
|
conflicted=$(git diff --name-only --diff-filter=U)
|
||||||
echo "conflicted files: $conflicted"
|
echo "conflicted files: $conflicted"
|
||||||
# Only auto-resolve if ALL conflicts are in bot-generated files.
|
# Only auto-resolve if ALL conflicts are in bot-generated files.
|
||||||
non_bot=$(echo "$conflicted" | grep -v -E '^(README\.md|data/history\.jsonl)$' || true)
|
non_bot=$(echo "$conflicted" | grep -v -E '^(README\.md|data/history\.jsonl|data/metadata\.json)$' || true)
|
||||||
if [ -n "$non_bot" ]; then
|
if [ -n "$non_bot" ]; then
|
||||||
echo "ERROR: conflict in human-maintained file(s): $non_bot — aborting rebase"
|
echo "ERROR: conflict in human-maintained file(s): $non_bot — aborting rebase"
|
||||||
git rebase --abort
|
git rebase --abort
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Safe to force-resolve: keep our (freshest) bot-generated content.
|
# Safe to force-resolve: keep our (freshest) bot-generated content.
|
||||||
git checkout --theirs README.md data/history.jsonl 2>/dev/null || true
|
git checkout --theirs README.md data/history.jsonl data/metadata.json 2>/dev/null || true
|
||||||
git add README.md data/history.jsonl
|
git add README.md data/history.jsonl data/metadata.json
|
||||||
GIT_EDITOR=true git rebase --continue
|
GIT_EDITOR=true git rebase --continue
|
||||||
done
|
done
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
# site/data.json was generated by the updater run above; deploy the
|
|
||||||
# static dashboard (site/) to GitHub Pages every run.
|
|
||||||
- name: Configure Pages
|
|
||||||
uses: actions/configure-pages@v6
|
|
||||||
with:
|
|
||||||
enablement: true
|
|
||||||
|
|
||||||
- name: Upload Pages artifact
|
|
||||||
uses: actions/upload-pages-artifact@v5
|
|
||||||
with:
|
|
||||||
path: site
|
|
||||||
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deploy
|
|
||||||
uses: actions/deploy-pages@v5
|
|
||||||
+5
-3
@@ -1,5 +1,5 @@
|
|||||||
# Go build artifacts
|
# Go build artifacts
|
||||||
/ai-dev-tools
|
/awesome-ai-dev-tools
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.out
|
*.out
|
||||||
@@ -9,5 +9,7 @@
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# Generated by the updater each run; deployed to Pages, never committed
|
# Build output from `go run . -build`; regenerated on every deploy.
|
||||||
site/data.json
|
# data/metadata.json IS committed — it is the update step's handoff to the
|
||||||
|
# build step, and the reason the build needs no GITHUB_TOKEN.
|
||||||
|
/dist/
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# AI Dev Tools
|
# Awesome AI Dev Tools
|
||||||
|
|
||||||
> Curated ranking of AI-powered developer tools, sorted by GitHub stars.
|
> Curated ranking of AI-powered developer tools, sorted by GitHub stars.
|
||||||
> Updated daily by GitHub Actions.
|
> Updated daily by GitHub Actions.
|
||||||
|
|
||||||
📊 **[Interactive dashboard with star-history charts →](https://tiennm99.github.io/ai-dev-tools/)**
|
📊 **[Interactive dashboard with star-history charts →](https://awesome-ai-dev-tools.pages.dev/)**
|
||||||
|
|
||||||
**Last updated:** 2026-09-16 03:54 UTC · **Tracked:** 40 repos
|
**Last updated:** 2026-09-16 03:54 UTC · **Tracked:** 40 repos
|
||||||
**Top 7-day mover:** [earendil-works/pi](https://github.com/earendil-works/pi) (+2642 stars)
|
**Top 7-day mover:** [earendil-works/pi](https://github.com/earendil-works/pi) (+2642 stars)
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
2. A daily GitHub Actions workflow (`.github/workflows/update.yml`) runs the Go updater.
|
2. A daily GitHub Actions workflow (`.github/workflows/update.yml`) runs the Go updater.
|
||||||
3. The updater fetches live repo metadata via the GitHub GraphQL API in one batched query.
|
3. The updater fetches live repo metadata via the GitHub GraphQL API in one batched query.
|
||||||
4. Star counts are appended to `data/history.jsonl` for 7-day delta computation.
|
4. Star counts are appended to `data/history.jsonl` for 7-day delta computation.
|
||||||
5. This `README.md` is regenerated from `templates/readme.tmpl` and committed back to the repo.
|
5. This `README.md` and `data/metadata.json` are regenerated and committed back to the repo.
|
||||||
6. `site/data.json` is regenerated and the [dashboard](https://tiennm99.github.io/ai-dev-tools/) (`site/index.html`) is redeployed to GitHub Pages.
|
6. That push triggers Cloudflare Pages, which runs `go run . -build` to render the [dashboard](https://awesome-ai-dev-tools.pages.dev/) from the committed data — no API token needed at build time. See [docs/DEPLOY.md](./docs/DEPLOY.md).
|
||||||
|
|
||||||
**Δ7d:** Change in stars over the past 7 days; `—` means fewer than 7 days of history.
|
**Δ7d:** Change in stars over the past 7 days; `—` means fewer than 7 days of history.
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ agents:
|
|||||||
tags: [terminal, byo-model, interactive, community]
|
tags: [terminal, byo-model, interactive, community]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tags** describe a tool across five facets — the [dashboard](https://tiennm99.github.io/ai-dev-tools/) filters on them, OR within a facet and AND across facets:
|
**Tags** describe a tool across five facets — the [dashboard](https://awesome-ai-dev-tools.pages.dev/) filters on them, OR within a facet and AND across facets:
|
||||||
|
|
||||||
| Facet | Tags |
|
| Facet | Tags |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runBuild renders the static dashboard into distDir from committed inputs
|
||||||
|
// only — data/agents.yml, data/metadata.json and data/history.jsonl. It makes
|
||||||
|
// no network calls and needs no GITHUB_TOKEN, which is what lets an untrusted
|
||||||
|
// build environment (Cloudflare Pages) run it.
|
||||||
|
//
|
||||||
|
// Splitting this out from the update step also means a pure curation change
|
||||||
|
// (retagging an entry, editing a note) republishes immediately on push,
|
||||||
|
// reusing the last fetched star figures instead of waiting for the nightly run.
|
||||||
|
func runBuild(agentsPath, metadataPath, historyPath, siteDir, distDir string) error {
|
||||||
|
agents, err := loadAgents(agentsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, err := readMetadata(metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := statsFromMetadata(agents, meta)
|
||||||
|
stats = enforceStarFloor(stats)
|
||||||
|
if len(stats) == 0 {
|
||||||
|
return fmt.Errorf("no entries left to publish after joining %s with %s", agentsPath, metadataPath)
|
||||||
|
}
|
||||||
|
sortStats(stats)
|
||||||
|
|
||||||
|
history, err := readSnapshots(historyPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor the delta windows to when the data was fetched, not to wall clock.
|
||||||
|
// A rebuild triggered days later (a docs push, a manual redeploy) must show
|
||||||
|
// the same "Δ7d" the nightly run computed, not a window silently slid
|
||||||
|
// forward past its slack allowance.
|
||||||
|
current := Snapshot{
|
||||||
|
Date: meta.FetchedAt.UTC().Format("2006-01-02"),
|
||||||
|
Stars: make(map[string]int, len(stats)),
|
||||||
|
}
|
||||||
|
for _, s := range stats {
|
||||||
|
current.Stars[s.CanonicalKey] = s.Stars
|
||||||
|
}
|
||||||
|
deltas7 := computeDeltaAt(history, current, meta.FetchedAt, 7, 3)
|
||||||
|
deltas30 := computeDeltaAt(history, current, meta.FetchedAt, 30, 5)
|
||||||
|
|
||||||
|
if err := copyDir(siteDir, distDir); err != nil {
|
||||||
|
return fmt.Errorf("copy %s to %s: %w", siteDir, distDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedAt := meta.FetchedAt.UTC().Format("2006-01-02 15:04 UTC")
|
||||||
|
if err := writeSiteData(filepath.Join(distDir, "data.json"), updatedAt, stats, deltas7, deltas30, history); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("built %s: %d entries, data fetched %s\n", distDir, len(stats), updatedAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyDir replaces dst with a fresh copy of src. The wipe matters: dist/ is
|
||||||
|
// build output, and a file deleted from site/ must not survive in a deploy
|
||||||
|
// because a previous build left it there.
|
||||||
|
func copyDir(src, dst string) error {
|
||||||
|
if err := os.RemoveAll(dst); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
target := filepath.Join(dst, rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(target, 0o755)
|
||||||
|
}
|
||||||
|
if !d.Type().IsRegular() {
|
||||||
|
return nil // skip symlinks and other irregular entries
|
||||||
|
}
|
||||||
|
return copyFile(path, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = in.Close() }() // read-only; close error is not actionable
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return atomicWriteFile(dst, func(w io.Writer) error {
|
||||||
|
_, err := io.Copy(w, in)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// writeBuildFixture lays down a minimal repo layout the build step can read:
|
||||||
|
// agents.yml (curation), metadata.json (fetched figures), history.jsonl
|
||||||
|
// (delta baselines) and a site/ source directory.
|
||||||
|
func writeBuildFixture(t *testing.T, fetchedAt time.Time) (dir string) {
|
||||||
|
t.Helper()
|
||||||
|
dir = t.TempDir()
|
||||||
|
|
||||||
|
mustWrite := func(rel, content string) {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, rel)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir for %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", rel, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWrite("data/agents.yml", `agents:
|
||||||
|
- owner: org
|
||||||
|
repo: small
|
||||||
|
tags: [terminal, community]
|
||||||
|
- owner: org
|
||||||
|
repo: big
|
||||||
|
tags: [web, vendor]
|
||||||
|
notes: a note
|
||||||
|
`)
|
||||||
|
|
||||||
|
meta := metadataFile{
|
||||||
|
FetchedAt: fetchedAt,
|
||||||
|
Repos: map[string]repoMeta{
|
||||||
|
"org/big": {
|
||||||
|
NameWithOwner: "org/big", URL: "https://github.com/org/big",
|
||||||
|
Stars: 5000, Language: "Go", PushedAt: fetchedAt,
|
||||||
|
Description: "big one",
|
||||||
|
},
|
||||||
|
"org/small": {
|
||||||
|
NameWithOwner: "org/small", URL: "https://github.com/org/small",
|
||||||
|
Stars: 2000, Language: "Rust", PushedAt: fetchedAt,
|
||||||
|
Description: "small one", IsArchived: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(meta)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal metadata: %v", err)
|
||||||
|
}
|
||||||
|
mustWrite("data/metadata.json", string(raw))
|
||||||
|
|
||||||
|
// Baseline 7 days before fetchedAt (2026-09-09) so deltas resolve.
|
||||||
|
mustWrite("data/history.jsonl", `{"date":"2026-09-09","stars":{"org/big":4900,"org/small":1990}}
|
||||||
|
{"date":"2026-09-16","stars":{"org/big":5000,"org/small":2000}}
|
||||||
|
`)
|
||||||
|
|
||||||
|
mustWrite("site/index.html", "<html>dashboard</html>")
|
||||||
|
mustWrite("site/_headers", "/data.json\n Cache-Control: no-cache\n")
|
||||||
|
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBuiltSiteData(t *testing.T, dir string) siteData {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "dist", "data.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read dist/data.json: %v", err)
|
||||||
|
}
|
||||||
|
var got siteData
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil {
|
||||||
|
t.Fatalf("unmarshal dist/data.json: %v", err)
|
||||||
|
}
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func runBuildIn(t *testing.T, dir string) error {
|
||||||
|
t.Helper()
|
||||||
|
return runBuild(
|
||||||
|
filepath.Join(dir, "data/agents.yml"),
|
||||||
|
filepath.Join(dir, "data/metadata.json"),
|
||||||
|
filepath.Join(dir, "data/history.jsonl"),
|
||||||
|
filepath.Join(dir, "site"),
|
||||||
|
filepath.Join(dir, "dist"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBuild_JoinsCurationWithMetadataAndCopiesSite(t *testing.T) {
|
||||||
|
fetchedAt := time.Date(2026, 9, 16, 22, 10, 0, 0, time.UTC)
|
||||||
|
dir := writeBuildFixture(t, fetchedAt)
|
||||||
|
|
||||||
|
if err := runBuildIn(t, dir); err != nil {
|
||||||
|
t.Fatalf("runBuild: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readBuiltSiteData(t, dir)
|
||||||
|
|
||||||
|
// updatedAt must label the fetch, not the build.
|
||||||
|
if got.UpdatedAt != "2026-09-16 22:10 UTC" {
|
||||||
|
t.Errorf("UpdatedAt: expected the metadata fetch time, got %q", got.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(got.Rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(got.Rows))
|
||||||
|
}
|
||||||
|
// Ranked by stars descending, independent of agents.yml order.
|
||||||
|
if got.Rows[0].Key != "org/big" || got.Rows[1].Key != "org/small" {
|
||||||
|
t.Errorf("expected big ranked above small, got %q then %q", got.Rows[0].Key, got.Rows[1].Key)
|
||||||
|
}
|
||||||
|
// Curation comes from agents.yml...
|
||||||
|
if got.Rows[0].Notes != "a note" {
|
||||||
|
t.Errorf("expected notes from agents.yml, got %q", got.Rows[0].Notes)
|
||||||
|
}
|
||||||
|
// ...figures from metadata.json.
|
||||||
|
if got.Rows[0].Stars != 5000 || got.Rows[0].Language != "Go" || got.Rows[0].Description != "big one" {
|
||||||
|
t.Errorf("row0 metadata not applied: %+v", got.Rows[0])
|
||||||
|
}
|
||||||
|
if !got.Rows[1].Archived {
|
||||||
|
t.Error("expected archived flag to survive the round trip")
|
||||||
|
}
|
||||||
|
if !got.Rows[0].HasDelta || got.Rows[0].Delta7d != 100 {
|
||||||
|
t.Errorf("row0 delta7d: expected +100 from the 2026-09-09 baseline, got hasDelta=%v delta=%d", got.Rows[0].HasDelta, got.Rows[0].Delta7d)
|
||||||
|
}
|
||||||
|
if len(got.Facets) == 0 {
|
||||||
|
t.Error("expected tag facets in the payload")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every static file in site/ must land in dist/ alongside data.json.
|
||||||
|
for _, name := range []string{"index.html", "_headers"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "dist", name)); err != nil {
|
||||||
|
t.Errorf("expected dist/%s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The build must not depend on the clock: it runs whenever Cloudflare happens
|
||||||
|
// to redeploy, which may be long after the data was fetched.
|
||||||
|
func TestRunBuild_DeltasAnchoredToFetchTimeNotWallClock(t *testing.T) {
|
||||||
|
fetchedAt := time.Date(2026, 9, 16, 22, 10, 0, 0, time.UTC)
|
||||||
|
dir := writeBuildFixture(t, fetchedAt)
|
||||||
|
|
||||||
|
orig := timeNow
|
||||||
|
// Simulate a redeploy 40 days later; a wall-clock anchor would slide the
|
||||||
|
// 7-day window past its slack and drop the delta entirely.
|
||||||
|
timeNow = func() time.Time { return fetchedAt.AddDate(0, 0, 40) }
|
||||||
|
defer func() { timeNow = orig }()
|
||||||
|
|
||||||
|
if err := runBuildIn(t, dir); err != nil {
|
||||||
|
t.Fatalf("runBuild: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readBuiltSiteData(t, dir)
|
||||||
|
if !got.Rows[0].HasDelta || got.Rows[0].Delta7d != 100 {
|
||||||
|
t.Errorf("delta should be unchanged by a late rebuild, got hasDelta=%v delta=%d", got.Rows[0].HasDelta, got.Rows[0].Delta7d)
|
||||||
|
}
|
||||||
|
if got.UpdatedAt != "2026-09-16 22:10 UTC" {
|
||||||
|
t.Errorf("UpdatedAt should still report the fetch time, got %q", got.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly contributed entry has no metadata until the next update run. The
|
||||||
|
// build must degrade to omitting it, not fail — otherwise every deploy is
|
||||||
|
// blocked between the PR merge and the nightly refresh.
|
||||||
|
func TestRunBuild_SkipsEntriesWithoutMetadataYet(t *testing.T) {
|
||||||
|
fetchedAt := time.Date(2026, 9, 16, 22, 10, 0, 0, time.UTC)
|
||||||
|
dir := writeBuildFixture(t, fetchedAt)
|
||||||
|
|
||||||
|
agentsPath := filepath.Join(dir, "data/agents.yml")
|
||||||
|
existing, err := os.ReadFile(agentsPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read agents.yml: %v", err)
|
||||||
|
}
|
||||||
|
added := string(existing) + " - owner: org\n repo: brandnew\n tags: [terminal]\n"
|
||||||
|
if err := os.WriteFile(agentsPath, []byte(added), 0o644); err != nil {
|
||||||
|
t.Fatalf("write agents.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runBuildIn(t, dir); err != nil {
|
||||||
|
t.Fatalf("runBuild should tolerate a missing metadata entry, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := readBuiltSiteData(t, dir)
|
||||||
|
if len(got.Rows) != 2 {
|
||||||
|
t.Fatalf("expected the un-fetched entry to be omitted, got %d rows", len(got.Rows))
|
||||||
|
}
|
||||||
|
for _, r := range got.Rows {
|
||||||
|
if r.Key == "org/brandnew" {
|
||||||
|
t.Error("org/brandnew has no metadata and must not be published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dist/ is build output: a file removed from site/ must not survive there.
|
||||||
|
func TestRunBuild_WipesStaleDistFiles(t *testing.T) {
|
||||||
|
fetchedAt := time.Date(2026, 9, 16, 22, 10, 0, 0, time.UTC)
|
||||||
|
dir := writeBuildFixture(t, fetchedAt)
|
||||||
|
|
||||||
|
stale := filepath.Join(dir, "dist", "old.html")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(stale), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir dist: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(stale, []byte("stale"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write stale file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := runBuildIn(t, dir); err != nil {
|
||||||
|
t.Fatalf("runBuild: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(stale); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("expected stale dist file to be removed, stat err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunBuild_MissingMetadataIsActionable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "data"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "data/agents.yml"), []byte("agents:\n - owner: org\n repo: big\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write agents.yml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := runBuildIn(t, dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when metadata.json is absent")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "metadata.json") || !strings.Contains(err.Error(), "GITHUB_TOKEN") {
|
||||||
|
t.Errorf("error should name the missing file and how to produce it, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
-30
@@ -17,10 +17,7 @@ agents:
|
|||||||
tags: [terminal, byo-model, local-models, interactive, community]
|
tags: [terminal, byo-model, local-models, interactive, community]
|
||||||
- owner: cline
|
- owner: cline
|
||||||
repo: cline
|
repo: cline
|
||||||
tags: [editor-plugin, terminal, byo-model, local-models, interactive, mcp, community]
|
tags: [editor-plugin, terminal, desktop, byo-model, local-models, interactive, headless, mcp, community]
|
||||||
- owner: continuedev
|
|
||||||
repo: continue
|
|
||||||
tags: [editor-plugin, terminal, byo-model, local-models, interactive, mcp, community]
|
|
||||||
- owner: anomalyco
|
- owner: anomalyco
|
||||||
repo: opencode
|
repo: opencode
|
||||||
tags: [terminal, desktop, byo-model, local-models, interactive, headless, mcp, community]
|
tags: [terminal, desktop, byo-model, local-models, interactive, headless, mcp, community]
|
||||||
@@ -30,44 +27,32 @@ agents:
|
|||||||
- owner: google-gemini
|
- owner: google-gemini
|
||||||
repo: gemini-cli
|
repo: gemini-cli
|
||||||
tags: [terminal, single-vendor, interactive, headless, mcp, vendor]
|
tags: [terminal, single-vendor, interactive, headless, mcp, vendor]
|
||||||
notes: Google announced a closed-source successor; OSS repo may be retired
|
|
||||||
- owner: openai
|
- owner: openai
|
||||||
repo: codex
|
repo: codex
|
||||||
tags: [terminal, editor-plugin, single-vendor, interactive, headless, mcp, vendor]
|
tags: [terminal, editor-plugin, desktop, single-vendor, interactive, headless, mcp, vendor]
|
||||||
- owner: aaif-goose
|
- owner: aaif-goose
|
||||||
repo: goose
|
repo: goose
|
||||||
tags: [terminal, desktop, byo-model, local-models, interactive, mcp, acp, community]
|
tags: [terminal, desktop, byo-model, local-models, interactive, mcp, acp, community]
|
||||||
notes: Moved to the Agentic AI Foundation (Linux Foundation)
|
notes: Moved to the Agentic AI Foundation (Linux Foundation)
|
||||||
- owner: AntonOsika
|
- owner: AntonOsika
|
||||||
repo: gpt-engineer
|
repo: gpt-engineer
|
||||||
tags: [terminal, byo-model, autonomous, community]
|
tags: [terminal, byo-model, local-models, autonomous, community]
|
||||||
notes: Archived upstream (read-only); kept for historical significance
|
notes: Archived upstream (read-only); kept for historical significance
|
||||||
- owner: SWE-agent
|
- owner: SWE-agent
|
||||||
repo: SWE-agent
|
repo: SWE-agent
|
||||||
tags: [terminal, byo-model, autonomous, research, community]
|
tags: [terminal, byo-model, autonomous, research, community]
|
||||||
- owner: RooCodeInc
|
|
||||||
repo: Roo-Code
|
|
||||||
tags: [editor-plugin, byo-model, interactive, mcp, community]
|
|
||||||
notes: Archived upstream (read-only)
|
|
||||||
- owner: TabbyML
|
- owner: TabbyML
|
||||||
repo: tabby
|
repo: tabby
|
||||||
tags: [self-hosted, editor-plugin, local-models, interactive, community]
|
tags: [self-hosted, editor-plugin, local-models, interactive, community]
|
||||||
- owner: zed-industries
|
- owner: zed-industries
|
||||||
repo: zed
|
repo: zed
|
||||||
tags: [ide, byo-model, local-models, interactive, acp, community]
|
tags: [ide, byo-model, local-models, interactive, acp, community]
|
||||||
- owner: voideditor
|
|
||||||
repo: void
|
|
||||||
tags: [ide, byo-model, local-models, interactive, community]
|
|
||||||
notes: Archived upstream (read-only)
|
|
||||||
- owner: anthropics
|
- owner: anthropics
|
||||||
repo: claude-code
|
repo: claude-code
|
||||||
tags: [terminal, editor-plugin, single-vendor, interactive, headless, mcp, vendor]
|
tags: [terminal, editor-plugin, desktop, single-vendor, interactive, headless, mcp, vendor]
|
||||||
- owner: avante-corp
|
- owner: avante-corp
|
||||||
repo: avante.nvim
|
repo: avante.nvim
|
||||||
tags: [editor-plugin, byo-model, local-models, interactive, mcp, acp, community]
|
tags: [editor-plugin, byo-model, local-models, interactive, mcp, acp, community]
|
||||||
- owner: kortix-ai
|
|
||||||
repo: suna
|
|
||||||
tags: [web, self-hosted, byo-model, autonomous, mcp, community]
|
|
||||||
- owner: earendil-works
|
- owner: earendil-works
|
||||||
repo: pi
|
repo: pi
|
||||||
tags: [terminal, byo-model, interactive, community]
|
tags: [terminal, byo-model, interactive, community]
|
||||||
@@ -83,10 +68,10 @@ agents:
|
|||||||
tags: [terminal, editor-plugin, byo-model, autonomous, community]
|
tags: [terminal, editor-plugin, byo-model, autonomous, community]
|
||||||
- owner: QwenLM
|
- owner: QwenLM
|
||||||
repo: qwen-code
|
repo: qwen-code
|
||||||
tags: [terminal, editor-plugin, desktop, byo-model, local-models, interactive, headless, mcp, acp, vendor]
|
tags: [terminal, editor-plugin, desktop, web, byo-model, local-models, interactive, headless, mcp, acp, vendor]
|
||||||
- owner: Kilo-Org
|
- owner: Kilo-Org
|
||||||
repo: kilocode
|
repo: kilocode
|
||||||
tags: [editor-plugin, terminal, byo-model, interactive, mcp, community]
|
tags: [editor-plugin, terminal, byo-model, interactive, autonomous, headless, mcp, community]
|
||||||
- owner: onlook-dev
|
- owner: onlook-dev
|
||||||
repo: onlook
|
repo: onlook
|
||||||
tags: [desktop, web, byo-model, app-builder, mcp, community]
|
tags: [desktop, web, byo-model, app-builder, mcp, community]
|
||||||
@@ -98,7 +83,7 @@ agents:
|
|||||||
tags: [terminal, single-vendor, interactive, headless, mcp, vendor]
|
tags: [terminal, single-vendor, interactive, headless, mcp, vendor]
|
||||||
- owner: deepseek-ai
|
- owner: deepseek-ai
|
||||||
repo: deepseek-harness
|
repo: deepseek-harness
|
||||||
tags: [terminal, single-vendor, interactive, vendor]
|
tags: [terminal, web, single-vendor, interactive, vendor]
|
||||||
notes: Developer preview; compatibility-breaking changes expected
|
notes: Developer preview; compatibility-breaking changes expected
|
||||||
- owner: openinterpreter
|
- owner: openinterpreter
|
||||||
repo: openinterpreter
|
repo: openinterpreter
|
||||||
@@ -108,10 +93,10 @@ agents:
|
|||||||
tags: [terminal, editor-plugin, desktop, byo-model, local-models, interactive, mcp, community]
|
tags: [terminal, editor-plugin, desktop, byo-model, local-models, interactive, mcp, community]
|
||||||
- owner: esengine
|
- owner: esengine
|
||||||
repo: DeepSeek-Reasonix
|
repo: DeepSeek-Reasonix
|
||||||
tags: [terminal, desktop, web, editor-plugin, byo-model, interactive, autonomous, mcp, acp, community]
|
tags: [terminal, desktop, web, editor-plugin, single-vendor, interactive, autonomous, mcp, acp, community]
|
||||||
- owner: Gitlawb
|
- owner: Gitlawb
|
||||||
repo: openclaude
|
repo: openclaude
|
||||||
tags: [terminal, byo-model, local-models, interactive, headless, mcp, community]
|
tags: [terminal, editor-plugin, byo-model, local-models, interactive, headless, mcp, community]
|
||||||
- owner: can1357
|
- owner: can1357
|
||||||
repo: oh-my-pi
|
repo: oh-my-pi
|
||||||
tags: [terminal, editor-plugin, byo-model, local-models, interactive, mcp, acp, headless, community]
|
tags: [terminal, editor-plugin, byo-model, local-models, interactive, mcp, acp, headless, community]
|
||||||
@@ -120,7 +105,7 @@ agents:
|
|||||||
tags: [terminal, editor-plugin, single-vendor, interactive, headless, mcp, acp, vendor]
|
tags: [terminal, editor-plugin, single-vendor, interactive, headless, mcp, acp, vendor]
|
||||||
- owner: alibaba
|
- owner: alibaba
|
||||||
repo: open-code-review
|
repo: open-code-review
|
||||||
tags: [terminal, byo-model, review, mcp, vendor]
|
tags: [terminal, byo-model, review, headless, mcp, vendor]
|
||||||
- owner: PrimeIntellect-ai
|
- owner: PrimeIntellect-ai
|
||||||
repo: prime-agent
|
repo: prime-agent
|
||||||
tags: [terminal, byo-model, autonomous, headless, vendor]
|
tags: [terminal, byo-model, autonomous, headless, vendor]
|
||||||
@@ -139,15 +124,11 @@ agents:
|
|||||||
- owner: Kuberwastaken
|
- owner: Kuberwastaken
|
||||||
repo: claurst
|
repo: claurst
|
||||||
tags: [terminal, byo-model, interactive, headless, acp, community]
|
tags: [terminal, byo-model, interactive, headless, acp, community]
|
||||||
- owner: Orkas-AI
|
|
||||||
repo: Orkas
|
|
||||||
tags: [desktop, byo-model, interactive, community]
|
|
||||||
notes: "Open-source, local-first desktop AI workforce coordinated by a Commander through one chat; supports local coding-agent workflows."
|
|
||||||
- owner: stablyai
|
- owner: stablyai
|
||||||
repo: orca
|
repo: orca
|
||||||
tags: [desktop, terminal, byo-model, orchestration, headless, community]
|
tags: [desktop, terminal, byo-model, orchestration, headless, community]
|
||||||
notes: "ADE: runs Codex, Claude Code, OpenCode and Pi side-by-side, each in its own git worktree, with a mobile companion app."
|
notes: "ADE: runs Codex, Claude Code, OpenCode and Pi side-by-side, each in its own git worktree, with a mobile companion app."
|
||||||
- owner: getpaseo
|
- owner: getpaseo
|
||||||
repo: paseo
|
repo: paseo
|
||||||
tags: [desktop, web, terminal, self-hosted, byo-model, orchestration, headless, community]
|
tags: [desktop, web, terminal, editor-plugin, self-hosted, byo-model, orchestration, headless, mcp, community]
|
||||||
notes: "ADE: self-hosted daemon that runs Claude Code, Codex, Copilot, OpenCode and Pi on your own machines, driven from desktop, mobile, web or CLI."
|
notes: "ADE: self-hosted daemon that runs Claude Code, Codex, Copilot, OpenCode and Pi on your own machines, driven from desktop, mobile, web or CLI."
|
||||||
+11
-4
@@ -41,8 +41,11 @@ The dividing line is **tools you use vs. building blocks you import**. Out of sc
|
|||||||
libraries and SDKs, agent frameworks meant to be built on, model weights, prompt or
|
libraries and SDKs, agent frameworks meant to be built on, model weights, prompt or
|
||||||
skill collections, and dashboards that only observe a tool without being one.
|
skill collections, and dashboards that only observe a tool without being one.
|
||||||
|
|
||||||
A tool also has to be *about* software development. General-purpose assistants and
|
A tool also has to be *about* software development. General-purpose assistants,
|
||||||
chat UIs do not qualify just because a developer could use them.
|
chat UIs, and multi-agent "digital workforce" apps — the ones whose specialists write
|
||||||
|
reports, decks, and marketing copy — do not qualify just because a developer could use
|
||||||
|
them, or because one of their agents happens to touch code. Judge the tool by what it is
|
||||||
|
built to do, not by the widest thing it can be pointed at.
|
||||||
|
|
||||||
### The star floor is hard
|
### The star floor is hard
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ describe a tool that ships as a CLI, an editor plugin and a desktop app at the
|
|||||||
same time — which most of them now do. An entry carries several tags across
|
same time — which most of them now do. An entry carries several tags across
|
||||||
five facets. `data/agents.yml` is the source of truth; the vocabulary itself
|
five facets. `data/agents.yml` is the source of truth; the vocabulary itself
|
||||||
lives in `tagVocabulary` (`validate.go`) and reaches the dashboard through
|
lives in `tagVocabulary` (`validate.go`) and reaches the dashboard through
|
||||||
`site/data.json`, so it is defined exactly once.
|
the generated `dist/data.json`, so it is defined exactly once.
|
||||||
|
|
||||||
**Surface** — where you run it. At least one required.
|
**Surface** — where you run it. At least one required.
|
||||||
|
|
||||||
@@ -121,6 +124,9 @@ say nothing about choosing the tool. GitHub topics are a drafting aid only —
|
|||||||
**Deprecation:** To remove an agent, delete its entry from `data/agents.yml`. The next run drops it from the README; its history stays in `data/history.jsonl`, so re-adding the entry later restores its star chart.
|
**Deprecation:** To remove an agent, delete its entry from `data/agents.yml`. The next run drops it from the README; its history stays in `data/history.jsonl`, so re-adding the entry later restores its star chart.
|
||||||
|
|
||||||
**Staleness:** An entry with no push in **6 months** is dropped. Past **3 months** the updater prints a `::warning::` naming the repo and its days idle, so the daily run surfaces candidates without anyone auditing the list by hand. Removal stays a human decision: a repo can go quiet between releases, and a historically significant one (`gpt-engineer`) is kept with a `notes` marker instead.
|
**Staleness:** An entry with no push in **6 months** is dropped. Past **3 months** the updater prints a `::warning::` naming the repo and its days idle, so the daily run surfaces candidates without anyone auditing the list by hand. Removal stays a human decision: a repo can go quiet between releases, and a historically significant one (`gpt-engineer`) is kept with a `notes` marker instead.
|
||||||
|
A repo the maintainers have **archived or declared deprecated** is different: it will never
|
||||||
|
be pushed again, so it is removed without waiting out the 6 months unless it earns the same
|
||||||
|
historical-significance exception.
|
||||||
|
|
||||||
## PR Review
|
## PR Review
|
||||||
|
|
||||||
@@ -128,4 +134,5 @@ say nothing about choosing the tool. GitHub topics are a drafting aid only —
|
|||||||
- The daily GitHub Actions workflow (runs at 00:00 UTC) picks up merged PRs automatically
|
- The daily GitHub Actions workflow (runs at 00:00 UTC) picks up merged PRs automatically
|
||||||
- No manual review required; the updater regenerates the README after your PR merges
|
- No manual review required; the updater regenerates the README after your PR merges
|
||||||
|
|
||||||
For local testing before opening a PR, see [LOCAL_DEV.md](./LOCAL_DEV.md).
|
For local testing before opening a PR, see [LOCAL_DEV.md](./LOCAL_DEV.md). A tag
|
||||||
|
or note change needs only `go run . -build` — no GitHub token.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Deploying to Cloudflare Pages
|
||||||
|
|
||||||
|
The site is published by Cloudflare Pages' Git integration. Cloudflare builds
|
||||||
|
from the repository on every push to `main` and never holds a GitHub token.
|
||||||
|
|
||||||
|
## How the split works
|
||||||
|
|
||||||
|
Data refresh and site build are two separate steps, in two separate places:
|
||||||
|
|
||||||
|
| Step | Command | Runs where | Needs a token? | Writes |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| Update | `go run .` | GitHub Actions (`update.yml`, nightly + manual) | Yes — `GITHUB_TOKEN` | `README.md`, `data/history.jsonl`, `data/metadata.json` (all committed) |
|
||||||
|
| Build | `go run . -build` | Cloudflare Pages | No | `dist/` (never committed) |
|
||||||
|
|
||||||
|
`data/metadata.json` is the handoff. The update step records every GitHub-sourced
|
||||||
|
field there — stars, language, description, push date, archived flag — so the
|
||||||
|
build step can render the dashboard from committed files alone.
|
||||||
|
|
||||||
|
That has two consequences worth knowing:
|
||||||
|
|
||||||
|
- Cloudflare's build environment never sees a GitHub token, because it has no
|
||||||
|
reason to call the GitHub API.
|
||||||
|
- A pure curation change (retagging an entry, editing a note in
|
||||||
|
`data/agents.yml`) republishes as soon as you push, reusing the last fetched
|
||||||
|
star figures. You do not wait for the nightly run.
|
||||||
|
|
||||||
|
The nightly Actions run commits refreshed data to `main`; that push fires
|
||||||
|
Cloudflare's build webhook, which redeploys with the new numbers.
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
### 1. Bootstrap `data/metadata.json`
|
||||||
|
|
||||||
|
The build fails without it, so generate it before connecting Cloudflare. Either
|
||||||
|
trigger the **Update rankings** workflow manually (Actions tab →
|
||||||
|
*Update rankings* → *Run workflow*), or run the updater locally and commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GITHUB_TOKEN=ghp_your_token_here
|
||||||
|
go run .
|
||||||
|
git add data/metadata.json data/history.jsonl README.md
|
||||||
|
git commit -m "chore: bootstrap metadata snapshot"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Pages project
|
||||||
|
|
||||||
|
Cloudflare dashboard → **Workers & Pages** → **Create** → **Pages** →
|
||||||
|
**Connect to Git** → pick this repository, then set:
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Production branch | `main` |
|
||||||
|
| Framework preset | None |
|
||||||
|
| Build command | `go run . -build` |
|
||||||
|
| Build output directory | `dist` |
|
||||||
|
| Root directory | *(leave blank)* |
|
||||||
|
|
||||||
|
### 3. Set the build environment variable
|
||||||
|
|
||||||
|
Under **Settings → Environment variables → Production** (and Preview, if you
|
||||||
|
want PR previews) add:
|
||||||
|
|
||||||
|
| Variable | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| `GO_VERSION` | `1.23` |
|
||||||
|
|
||||||
|
Do **not** add `GITHUB_TOKEN` — the build does not use one, and adding it would
|
||||||
|
hand a credential to an environment that has no need for it.
|
||||||
|
|
||||||
|
Cloudflare's build image ships Go and honours `GO_VERSION`. Even on an older
|
||||||
|
image, Go's toolchain directive in `go.mod` downloads the matching toolchain
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
### 4. Deploy
|
||||||
|
|
||||||
|
Save and deploy. Subsequent pushes to `main` — yours and the nightly bot's —
|
||||||
|
redeploy automatically.
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
`site/_headers` marks `data.json` as `must-revalidate`, so the edge cannot serve
|
||||||
|
yesterday's ranking after a refresh. `index.html` and everything else in `site/`
|
||||||
|
are copied into `dist/` as-is and use Cloudflare's defaults.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Build fails with "data/metadata.json not found"** — the bootstrap in step 1
|
||||||
|
has not been committed yet.
|
||||||
|
|
||||||
|
**A newly added tool is missing from the site** — expected between merging the
|
||||||
|
`agents.yml` entry and the next update run. The build logs a warning and omits
|
||||||
|
entries it has no metadata for, rather than failing the deploy. Trigger the
|
||||||
|
*Update rankings* workflow manually to fetch it immediately.
|
||||||
|
|
||||||
|
**Stars look stale** — check the Actions tab: publishing is healthy, but the
|
||||||
|
update workflow has not committed recently. The dashboard's "updated" timestamp
|
||||||
|
reports when the data was fetched, not when the site was built, so a redeploy
|
||||||
|
never makes stale figures look fresh.
|
||||||
+36
-4
@@ -9,25 +9,56 @@
|
|||||||
|
|
||||||
1. Go to [https://github.com/settings/tokens](https://github.com/settings/tokens)
|
1. Go to [https://github.com/settings/tokens](https://github.com/settings/tokens)
|
||||||
2. Click "Generate new token" → "Generate new token (classic)"
|
2. Click "Generate new token" → "Generate new token (classic)"
|
||||||
3. Give it a name (e.g., "ai-dev-tools")
|
3. Give it a name (e.g., "awesome-ai-dev-tools")
|
||||||
4. Select scope: **`public_repo`** (needed to read public repo metadata)
|
4. Select scope: **`public_repo`** (needed to read public repo metadata)
|
||||||
5. Click "Generate token" and copy the value
|
5. Click "Generate token" and copy the value
|
||||||
|
|
||||||
The updater reads public repos only; `public_repo` scope is sufficient and safe.
|
The updater reads public repos only; `public_repo` scope is sufficient and safe.
|
||||||
|
|
||||||
## Running Locally
|
## The two steps
|
||||||
|
|
||||||
|
The tool separates fetching data from rendering the site, so only the fetch
|
||||||
|
needs a token. See [DEPLOY.md](./DEPLOY.md) for why.
|
||||||
|
|
||||||
|
### Update (needs a token, hits the network)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export GITHUB_TOKEN=ghp_your_token_here
|
export GITHUB_TOKEN=ghp_your_token_here
|
||||||
go run .
|
go run .
|
||||||
```
|
```
|
||||||
|
|
||||||
The tool will:
|
This will:
|
||||||
- Read `data/agents.yml`
|
- Read `data/agents.yml`
|
||||||
- Fetch live metadata from GitHub GraphQL API
|
- Fetch live metadata from the GitHub GraphQL API
|
||||||
- Append star counts to `data/history.jsonl`
|
- Append star counts to `data/history.jsonl`
|
||||||
|
- Write the fetched fields to `data/metadata.json`
|
||||||
- Regenerate `README.md` from `templates/readme.tmpl`
|
- Regenerate `README.md` from `templates/readme.tmpl`
|
||||||
|
|
||||||
|
### Build (offline, no token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -build
|
||||||
|
```
|
||||||
|
|
||||||
|
This joins `data/agents.yml` (tags and notes) with `data/metadata.json` (stars
|
||||||
|
and repo metadata) and renders `dist/` — a copy of `site/` plus the generated
|
||||||
|
`dist/data.json`. This is exactly what Cloudflare Pages runs.
|
||||||
|
|
||||||
|
Preview it with any static server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -build && python3 -m http.server -d dist 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
If you only touched `site/index.html` or the tags in `data/agents.yml`, the
|
||||||
|
build step alone is enough — no token required.
|
||||||
|
|
||||||
|
### Validate (offline, no token)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run . -check
|
||||||
|
```
|
||||||
|
|
||||||
## Reverting Local Changes
|
## Reverting Local Changes
|
||||||
|
|
||||||
Before opening a PR, undo local modifications:
|
Before opening a PR, undo local modifications:
|
||||||
@@ -48,3 +79,4 @@ Always set `GITHUB_TOKEN` when testing locally to avoid hitting the unauthentica
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
To add agents, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
To add agents, see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||||
|
To publish the site, see [DEPLOY.md](./DEPLOY.md).
|
||||||
@@ -156,16 +156,21 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by stars descending. Ties are ordered by CanonicalKey for determinism
|
sortStats(stats)
|
||||||
// regardless of map-iteration or agents.yml order.
|
|
||||||
|
return stats, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortStats orders the ranking by stars descending. Ties break on CanonicalKey
|
||||||
|
// for determinism regardless of map-iteration or agents.yml order. Shared with
|
||||||
|
// the build step, which re-ranks from committed metadata.
|
||||||
|
func sortStats(stats []Stat) {
|
||||||
sort.Slice(stats, func(i, j int) bool {
|
sort.Slice(stats, func(i, j int) bool {
|
||||||
if stats[i].Stars != stats[j].Stars {
|
if stats[i].Stars != stats[j].Stars {
|
||||||
return stats[i].Stars > stats[j].Stars
|
return stats[i].Stars > stats[j].Stars
|
||||||
}
|
}
|
||||||
return stats[i].CanonicalKey < stats[j].CanonicalKey
|
return stats[i].CanonicalKey < stats[j].CanonicalKey
|
||||||
})
|
})
|
||||||
|
|
||||||
return stats, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchChunk sends one GraphQL request for a slice of agents. aliasOffset
|
// fetchChunk sends one GraphQL request for a slice of agents. aliasOffset
|
||||||
@@ -251,7 +256,7 @@ func doWithRetry(token string, body []byte) ([]byte, int, error) {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("User-Agent", "ai-dev-tools-updater")
|
req.Header.Set("User-Agent", "awesome-ai-dev-tools-updater")
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module github.com/tiennm99/ai-dev-tools
|
module github.com/tiennm99/awesome-ai-dev-tools
|
||||||
|
|
||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -195,8 +195,16 @@ func computeDeltas(history []Snapshot, current Snapshot) map[string]int {
|
|||||||
// the delta would be misleadingly labeled "Δ<days>d", so we return no delta
|
// the delta would be misleadingly labeled "Δ<days>d", so we return no delta
|
||||||
// for that repo instead.
|
// for that repo instead.
|
||||||
func computeDeltaOver(history []Snapshot, current Snapshot, days, slackDays int) map[string]int {
|
func computeDeltaOver(history []Snapshot, current Snapshot, days, slackDays int) map[string]int {
|
||||||
|
return computeDeltaAt(history, current, timeNow(), days, slackDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeDeltaAt is computeDeltaOver with an explicit anchor instead of the
|
||||||
|
// wall clock. The build step passes the time the data was fetched, so
|
||||||
|
// rebuilding an unchanged dataset later reproduces identical deltas rather
|
||||||
|
// than sliding the window forward off its baseline.
|
||||||
|
func computeDeltaAt(history []Snapshot, current Snapshot, anchor time.Time, days, slackDays int) map[string]int {
|
||||||
deltas := map[string]int{}
|
deltas := map[string]int{}
|
||||||
now := timeNow().UTC()
|
now := anchor.UTC()
|
||||||
cutoff := now.AddDate(0, 0, -days).Format("2006-01-02")
|
cutoff := now.AddDate(0, 0, -days).Format("2006-01-02")
|
||||||
lowerBound := now.AddDate(0, 0, -(days + slackDays)).Format("2006-01-02")
|
lowerBound := now.AddDate(0, 0, -(days + slackDays)).Format("2006-01-02")
|
||||||
|
|
||||||
|
|||||||
@@ -11,28 +11,52 @@ import (
|
|||||||
const (
|
const (
|
||||||
agentsPath = "data/agents.yml"
|
agentsPath = "data/agents.yml"
|
||||||
historyPath = "data/history.jsonl"
|
historyPath = "data/history.jsonl"
|
||||||
|
metadataPath = "data/metadata.json"
|
||||||
readmeTmpl = "templates/readme.tmpl"
|
readmeTmpl = "templates/readme.tmpl"
|
||||||
readmePath = "README.md"
|
readmePath = "README.md"
|
||||||
siteDataPath = "site/data.json"
|
siteDir = "site"
|
||||||
|
distDir = "dist"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The tool has three modes, deliberately separated so the only step that needs
|
||||||
|
// network access and a GITHUB_TOKEN is the one that runs in CI:
|
||||||
|
//
|
||||||
|
// (default) update — fetch GitHub, refresh history/metadata/README
|
||||||
|
// -build — render dist/ from committed data, offline
|
||||||
|
// -check — validate data/agents.yml, offline
|
||||||
|
//
|
||||||
|
// The split is what lets an untrusted build environment (Cloudflare Pages)
|
||||||
|
// publish the site without ever holding a token.
|
||||||
func main() {
|
func main() {
|
||||||
check := flag.Bool("check", false, "validate data/agents.yml offline (no network, no token) and exit")
|
check := flag.Bool("check", false, "validate data/agents.yml offline (no network, no token) and exit")
|
||||||
|
build := flag.Bool("build", false, "render the static site into dist/ from committed data (no network, no token) and exit")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *check {
|
if *check && *build {
|
||||||
|
log.Print("-check and -build are mutually exclusive")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case *check:
|
||||||
if err := runCheck(agentsPath); err != nil {
|
if err := runCheck(agentsPath); err != nil {
|
||||||
log.Printf("check failed: %v", err)
|
log.Printf("check failed: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return
|
case *build:
|
||||||
|
if err := runBuild(agentsPath, metadataPath, historyPath, siteDir, distDir); err != nil {
|
||||||
|
log.Printf("build failed: %v", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
if err := run(); err != nil {
|
if err := run(); err != nil {
|
||||||
log.Fatalf("update failed: %v", err)
|
log.Fatalf("update failed: %v", err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// run is the update step: it is the only mode that talks to GitHub. It writes
|
||||||
|
// data/metadata.json, which the build step later consumes offline.
|
||||||
func run() error {
|
func run() error {
|
||||||
agents, err := loadAgents(agentsPath)
|
agents, err := loadAgents(agentsPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -57,7 +81,7 @@ func run() error {
|
|||||||
return fmt.Errorf("no agents in %s meet the %d-star minimum", agentsPath, minStars)
|
return fmt.Errorf("no agents in %s meet the %d-star minimum", agentsPath, minStars)
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshots, deltas7, deltas30, err := appendHistory(historyPath, stats)
|
_, deltas7, _, err := appendHistory(historyPath, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -66,7 +90,7 @@ func run() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeSiteData(siteDataPath, stats, deltas7, deltas30, snapshots); err != nil {
|
if err := writeMetadata(metadataPath, stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// repoMeta is the GitHub-sourced half of a ranking row: every field the
|
||||||
|
// dashboard needs that cannot be derived from data/agents.yml. It is written
|
||||||
|
// by the update step (which has a token) and committed, so the build step can
|
||||||
|
// render the site offline.
|
||||||
|
type repoMeta struct {
|
||||||
|
NameWithOwner string `json:"nameWithOwner"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Stars int `json:"stars"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
PushedAt time.Time `json:"pushedAt"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsArchived bool `json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// metadataFile is the on-disk shape of data/metadata.json. Repos is keyed by
|
||||||
|
// canonical owner/repo (the same key history.jsonl uses), so entries survive
|
||||||
|
// renames and Go's sorted map marshalling keeps commit diffs minimal.
|
||||||
|
type metadataFile struct {
|
||||||
|
FetchedAt time.Time `json:"fetchedAt"`
|
||||||
|
Repos map[string]repoMeta `json:"repos"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeMetadata persists the fetched GitHub metadata. Unlike the old
|
||||||
|
// site/data.json this file IS committed: it is the handoff between the update
|
||||||
|
// step and the build step, and the only reason the build can run without a
|
||||||
|
// GITHUB_TOKEN.
|
||||||
|
func writeMetadata(path string, stats []Stat) error {
|
||||||
|
repos := make(map[string]repoMeta, len(stats))
|
||||||
|
for _, s := range stats {
|
||||||
|
repos[s.CanonicalKey] = repoMeta{
|
||||||
|
NameWithOwner: s.NameWithOwner,
|
||||||
|
URL: s.URL,
|
||||||
|
Stars: s.Stars,
|
||||||
|
Language: s.Language,
|
||||||
|
PushedAt: s.PushedAt,
|
||||||
|
Description: s.Description,
|
||||||
|
IsArchived: s.IsArchived,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return atomicWriteFile(path, func(w io.Writer) error {
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
// Indented so the committed file reviews as a readable diff.
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(metadataFile{
|
||||||
|
FetchedAt: timeNow().UTC(),
|
||||||
|
Repos: repos,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func readMetadata(path string) (metadataFile, error) {
|
||||||
|
var m metadataFile
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return m, fmt.Errorf("%s not found — run the updater (`go run .` with GITHUB_TOKEN set) to generate it", path)
|
||||||
|
}
|
||||||
|
return m, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return m, fmt.Errorf("parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if len(m.Repos) == 0 {
|
||||||
|
return m, fmt.Errorf("%s contains no repos", path)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// statsFromMetadata rejoins the two halves of a row: curation (order-independent
|
||||||
|
// tags and notes) from agents.yml, live figures from metadata.json.
|
||||||
|
//
|
||||||
|
// A repo present in agents.yml but absent from metadata.json is skipped with a
|
||||||
|
// warning rather than failing the build. That is the normal state between
|
||||||
|
// "contributor adds an entry" and "the next nightly update fetches it", and a
|
||||||
|
// hard failure there would block every Cloudflare deploy in the meantime.
|
||||||
|
func statsFromMetadata(agents []Agent, meta metadataFile) []Stat {
|
||||||
|
stats := make([]Stat, 0, len(agents))
|
||||||
|
for _, a := range agents {
|
||||||
|
key := a.Owner + "/" + a.Repo
|
||||||
|
m, ok := meta.Repos[key]
|
||||||
|
if !ok {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: %s has no entry in metadata yet — omitted from this build; the next updater run will add it\n", key)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats = append(stats, Stat{
|
||||||
|
CanonicalKey: key,
|
||||||
|
Owner: a.Owner,
|
||||||
|
Repo: a.Repo,
|
||||||
|
Tags: a.Tags,
|
||||||
|
Notes: a.Notes,
|
||||||
|
Description: m.Description,
|
||||||
|
Stars: m.Stars,
|
||||||
|
Language: m.Language,
|
||||||
|
PushedAt: m.PushedAt,
|
||||||
|
URL: m.URL,
|
||||||
|
NameWithOwner: m.NameWithOwner,
|
||||||
|
IsArchived: m.IsArchived,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Research Report: awesome-coding-agents list refresh (additions + stale cleanup)
|
# Research Report: awesome-ai-dev-tools list refresh (additions + stale cleanup)
|
||||||
|
|
||||||
**Conducted:** 2026-09-11 13:28 (+07) · **Repo:** `tiennm99/awesome-coding-agents` · **Tracked now:** 29
|
**Conducted:** 2026-09-11 13:28 (+07) · **Repo:** `tiennm99/awesome-ai-dev-tools` · **Tracked now:** 29
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// siteRow is one ranked repo in site/data.json, consumed by site/index.html.
|
// siteRow is one ranked repo in the generated dist/data.json, consumed by site/index.html.
|
||||||
type siteRow struct {
|
type siteRow struct {
|
||||||
Key string `json:"key"` // canonical owner/repo, matches history keys
|
Key string `json:"key"` // canonical owner/repo, matches history keys
|
||||||
NameWithOwner string `json:"nameWithOwner"`
|
NameWithOwner string `json:"nameWithOwner"`
|
||||||
@@ -32,10 +32,13 @@ type siteData struct {
|
|||||||
Facets []tagFacet `json:"facets"`
|
Facets []tagFacet `json:"facets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSiteData emits the JSON payload for the GitHub Pages dashboard.
|
// writeSiteData emits the JSON payload the dashboard fetches at runtime.
|
||||||
// The file is generated fresh on every updater run and is not committed;
|
// It is build output (dist/data.json), never committed.
|
||||||
// the Pages deploy step in the workflow picks it up from the working tree.
|
//
|
||||||
func writeSiteData(path string, stats []Stat, deltas7, deltas30 map[string]int, history []Snapshot) error {
|
// updatedAt is passed in rather than read from the clock because it labels
|
||||||
|
// data freshness, not build time: a redeploy of unchanged data must not claim
|
||||||
|
// the figures are newer than the fetch that produced them.
|
||||||
|
func writeSiteData(path string, updatedAt string, stats []Stat, deltas7, deltas30 map[string]int, history []Snapshot) error {
|
||||||
rows := make([]siteRow, len(stats))
|
rows := make([]siteRow, len(stats))
|
||||||
for i, s := range stats {
|
for i, s := range stats {
|
||||||
delta7, has7 := deltas7[s.CanonicalKey]
|
delta7, has7 := deltas7[s.CanonicalKey]
|
||||||
@@ -60,7 +63,7 @@ func writeSiteData(path string, stats []Stat, deltas7, deltas30 map[string]int,
|
|||||||
|
|
||||||
return atomicWriteFile(path, func(w io.Writer) error {
|
return atomicWriteFile(path, func(w io.Writer) error {
|
||||||
return json.NewEncoder(w).Encode(siteData{
|
return json.NewEncoder(w).Encode(siteData{
|
||||||
UpdatedAt: timeNow().UTC().Format("2006-01-02 15:04 UTC"),
|
UpdatedAt: updatedAt,
|
||||||
Rows: rows,
|
Rows: rows,
|
||||||
History: history,
|
History: history,
|
||||||
Facets: tagVocabulary,
|
Facets: tagVocabulary,
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# data.json is regenerated on every deploy and read by the dashboard at load.
|
||||||
|
# Cloudflare's edge would otherwise keep serving yesterday's ranking after a
|
||||||
|
# nightly refresh; the page's own `cache: 'no-cache'` only covers the browser.
|
||||||
|
/data.json
|
||||||
|
Cache-Control: public, max-age=0, must-revalidate
|
||||||
+9
-9
@@ -3,11 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>AI Dev Tools — Rankings & History</title>
|
<title>Awesome AI Dev Tools — Rankings & History</title>
|
||||||
<meta name="description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
<meta name="description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
||||||
<meta property="og:title" content="AI Dev Tools — Rankings & History">
|
<meta property="og:title" content="Awesome AI Dev Tools — Rankings & History">
|
||||||
<meta property="og:description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
<meta property="og:description" content="Daily-refreshed ranking of open-source AI coding agents by GitHub stars, with 7-day deltas and star-history charts.">
|
||||||
<meta property="og:url" content="https://tiennm99.github.io/ai-dev-tools/">
|
<meta property="og:url" content="https://awesome-ai-dev-tools.pages.dev/">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#f8fafc" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)">
|
||||||
@@ -211,11 +211,11 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>
|
<h1>
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.9 6.26L21.5 9.1l-4.9 4.5 1.3 6.7L12 17l-5.9 3.3 1.3-6.7-4.9-4.5 6.6-.84L12 2z"/></svg>
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2l2.9 6.26L21.5 9.1l-4.9 4.5 1.3 6.7L12 17l-5.9 3.3 1.3-6.7-4.9-4.5 6.6-.84L12 2z"/></svg>
|
||||||
AI Dev Tools
|
Awesome AI Dev Tools
|
||||||
</h1>
|
</h1>
|
||||||
<div class="sub">Curated ranking of open-source AI coding agents by GitHub stars, refreshed daily. <span id="meta"></span></div>
|
<div class="sub">Curated ranking of open-source AI coding agents by GitHub stars, refreshed daily. <span id="meta"></span></div>
|
||||||
</div>
|
</div>
|
||||||
<a class="ghlink" href="https://github.com/tiennm99/ai-dev-tools">
|
<a class="ghlink" href="https://github.com/tiennm99/awesome-ai-dev-tools">
|
||||||
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
<p>This dashboard needs JavaScript to render the ranking table and charts.
|
<p>This dashboard needs JavaScript to render the ranking table and charts.
|
||||||
You can view the same ranking in the
|
You can view the same ranking in the
|
||||||
<a href="https://github.com/tiennm99/ai-dev-tools#readme">project README on GitHub</a>.</p>
|
<a href="https://github.com/tiennm99/awesome-ai-dev-tools#readme">project README on GitHub</a>.</p>
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<div class="stats" id="stats" hidden>
|
<div class="stats" id="stats" hidden>
|
||||||
@@ -272,8 +272,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>Data refreshed daily by <a href="https://github.com/tiennm99/ai-dev-tools/actions">GitHub Actions</a>.</span>
|
<span>Data refreshed daily by <a href="https://github.com/tiennm99/awesome-ai-dev-tools/actions">GitHub Actions</a>.</span>
|
||||||
<span>Add an agent via <a href="https://github.com/tiennm99/ai-dev-tools#contributing">a pull request</a>.</span>
|
<span>Add an agent via <a href="https://github.com/tiennm99/awesome-ai-dev-tools#contributing">a pull request</a>.</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||||
@@ -309,7 +309,7 @@
|
|||||||
const isStale = r => r.pushedAt && new Date(r.pushedAt) < staleCutoff;
|
const isStale = r => r.pushedAt && new Date(r.pushedAt) < staleCutoff;
|
||||||
|
|
||||||
// --- facet filter chips (multi-select) ---
|
// --- facet filter chips (multi-select) ---
|
||||||
// Vocabulary comes from site/data.json, which the Go updater fills from
|
// Vocabulary comes from the generated data.json, which the Go build step fills from
|
||||||
// tagVocabulary — no second copy of the tag list lives in this file.
|
// tagVocabulary — no second copy of the tag list lives in this file.
|
||||||
const facets = Array.isArray(data.facets) ? data.facets : [];
|
const facets = Array.isArray(data.facets) ? data.facets : [];
|
||||||
// Only offer a tag some row actually carries, so the rows never disagree
|
// Only offer a tag some row actually carries, so the rows never disagree
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ func TestWriteSiteData_JSONShapeAndDeltaFields(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
tmpFile := tmpDir + "/data.json"
|
tmpFile := tmpDir + "/data.json"
|
||||||
|
|
||||||
if err := writeSiteData(tmpFile, stats, deltas7, deltas30, history); err != nil {
|
if err := writeSiteData(tmpFile, "2026-08-09 12:00 UTC", stats, deltas7, deltas30, history); err != nil {
|
||||||
t.Fatalf("writeSiteData: %v", err)
|
t.Fatalf("writeSiteData: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# AI Dev Tools
|
# Awesome AI Dev Tools
|
||||||
|
|
||||||
> Curated ranking of AI-powered developer tools, sorted by GitHub stars.
|
> Curated ranking of AI-powered developer tools, sorted by GitHub stars.
|
||||||
> Updated daily by GitHub Actions.
|
> Updated daily by GitHub Actions.
|
||||||
|
|
||||||
📊 **[Interactive dashboard with star-history charts →](https://tiennm99.github.io/ai-dev-tools/)**
|
📊 **[Interactive dashboard with star-history charts →](https://awesome-ai-dev-tools.pages.dev/)**
|
||||||
|
|
||||||
**Last updated:** {{.UpdatedAt}} · **Tracked:** {{.Total}} repos
|
**Last updated:** {{.UpdatedAt}} · **Tracked:** {{.Total}} repos
|
||||||
{{- if .TopMover.HasMover }}
|
{{- if .TopMover.HasMover }}
|
||||||
@@ -24,8 +24,8 @@
|
|||||||
2. A daily GitHub Actions workflow (`.github/workflows/update.yml`) runs the Go updater.
|
2. A daily GitHub Actions workflow (`.github/workflows/update.yml`) runs the Go updater.
|
||||||
3. The updater fetches live repo metadata via the GitHub GraphQL API in one batched query.
|
3. The updater fetches live repo metadata via the GitHub GraphQL API in one batched query.
|
||||||
4. Star counts are appended to `data/history.jsonl` for 7-day delta computation.
|
4. Star counts are appended to `data/history.jsonl` for 7-day delta computation.
|
||||||
5. This `README.md` is regenerated from `templates/readme.tmpl` and committed back to the repo.
|
5. This `README.md` and `data/metadata.json` are regenerated and committed back to the repo.
|
||||||
6. `site/data.json` is regenerated and the [dashboard](https://tiennm99.github.io/ai-dev-tools/) (`site/index.html`) is redeployed to GitHub Pages.
|
6. That push triggers Cloudflare Pages, which runs `go run . -build` to render the [dashboard](https://awesome-ai-dev-tools.pages.dev/) from the committed data — no API token needed at build time. See [docs/DEPLOY.md](./docs/DEPLOY.md).
|
||||||
|
|
||||||
**Δ7d:** Change in stars over the past 7 days; `—` means fewer than 7 days of history.
|
**Δ7d:** Change in stars over the past 7 days; `—` means fewer than 7 days of history.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
**Inclusion criteria** — a repo belongs on this list when it is:
|
**Inclusion criteria** — a repo belongs on this list when it is:
|
||||||
|
|
||||||
1. **A developer tool built around AI** — something a developer uses to build software, where an LLM is central to what it does. That covers agents that write, edit, or review code; agent development environments that run and coordinate them; and AI-assisted editors, terminals, and review tools. Libraries, SDKs, model weights, and prompt or skill collections are out of scope — this list ranks tools you *use*, not building blocks you *import*.
|
1. **A developer tool built around AI** — something a developer uses to build software, where an LLM is central to what it does. That covers agents that write, edit, or review code; agent development environments that run and coordinate them; and AI-assisted editors, terminals, and review tools. Libraries, SDKs, model weights, and prompt or skill collections are out of scope — this list ranks tools you *use*, not building blocks you *import*. General-purpose assistants and multi-agent "digital workforce" apps are out as well, even when one of their agents can write code — the tool itself has to be aimed at building software.
|
||||||
2. **At least 1,000 GitHub stars** — a hard floor, enforced by the updater. An entry below it is dropped from the ranking automatically, so the threshold is never waived for an individual entry.
|
2. **At least 1,000 GitHub stars** — a hard floor, enforced by the updater. An entry below it is dropped from the ranking automatically, so the threshold is never waived for an individual entry.
|
||||||
3. **Open source and actively maintained** — no push in **6 months** means the entry is dropped. The daily run flags anything past **3 months** for review, so the ranking reflects tools people can actually use today.
|
3. **Open source and actively maintained** — no push in **6 months** means the entry is dropped. The daily run flags anything past **3 months** for review, so the ranking reflects tools people can actually use today.
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ agents:
|
|||||||
tags: [terminal, byo-model, interactive, community]
|
tags: [terminal, byo-model, interactive, community]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Tags** describe a tool across five facets — the [dashboard](https://tiennm99.github.io/ai-dev-tools/) filters on them, OR within a facet and AND across facets:
|
**Tags** describe a tool across five facets — the [dashboard](https://awesome-ai-dev-tools.pages.dev/) filters on them, OR within a facet and AND across facets:
|
||||||
|
|
||||||
| Facet | Tags |
|
| Facet | Tags |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ var repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
|||||||
//
|
//
|
||||||
// This slice is the single source of truth: the lookup map below, the
|
// This slice is the single source of truth: the lookup map below, the
|
||||||
// validation messages, and the dashboard's filter chips (shipped in
|
// validation messages, and the dashboard's filter chips (shipped in
|
||||||
// site/data.json) are all derived from it.
|
// the generated dist/data.json) are all derived from it.
|
||||||
var tagVocabulary = []tagFacet{
|
var tagVocabulary = []tagFacet{
|
||||||
{ID: facetSurface, Label: "Surface", Tags: []string{"terminal", "editor-plugin", "ide", "desktop", "web", "self-hosted"}},
|
{ID: facetSurface, Label: "Surface", Tags: []string{"terminal", "editor-plugin", "ide", "desktop", "web", "self-hosted"}},
|
||||||
{ID: facetModel, Label: "Model access", Tags: []string{"byo-model", "single-vendor", "local-models"}},
|
{ID: facetModel, Label: "Model access", Tags: []string{"byo-model", "single-vendor", "local-models"}},
|
||||||
|
|||||||
Reference in new issue
Block a user