diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2df21bc..28ab949 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ on: - '.github/workflows/ci.yml' - 'data/agents.yml' - 'templates/**' + - 'site/**' permissions: contents: read @@ -30,6 +31,9 @@ jobs: - run: go test ./... - run: go build ./... - 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: runs-on: ubuntu-latest diff --git a/.github/workflows/update.yml b/.github/workflows/update.yml index 85b97a6..4203d91 100644 --- a/.github/workflows/update.yml +++ b/.github/workflows/update.yml @@ -8,7 +8,6 @@ on: branches: [main] paths: - 'data/agents.yml' - - 'site/**' - 'templates/**' - '**.go' - 'go.mod' @@ -18,11 +17,11 @@ permissions: # Uses GITHUB_TOKEN intentionally — a PAT would cause infinite trigger loops # on the auto-commit ("chore: daily ranking refresh") because GITHUB_TOKEN-authored # 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 - # 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: group: update-rankings @@ -31,9 +30,6 @@ concurrency: jobs: update: runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@v7 @@ -42,6 +38,9 @@ jobs: go-version: 'stable' 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 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -51,7 +50,7 @@ jobs: run: | git config user.name "github-actions[bot]" 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 echo "no changes" exit 0 @@ -59,7 +58,7 @@ jobs: git commit -m "chore: daily ranking refresh" # Rebase + retry guards against the race where two runs commit near-simultaneously. # 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. for attempt in 1 2 3; do if git push; then exit 0; fi @@ -72,31 +71,15 @@ jobs: conflicted=$(git diff --name-only --diff-filter=U) echo "conflicted files: $conflicted" # 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 echo "ERROR: conflict in human-maintained file(s): $non_bot — aborting rebase" git rebase --abort exit 1 fi # Safe to force-resolve: keep our (freshest) bot-generated content. - git checkout --theirs README.md data/history.jsonl 2>/dev/null || true - git add README.md data/history.jsonl + git checkout --theirs README.md data/history.jsonl data/metadata.json 2>/dev/null || true + git add README.md data/history.jsonl data/metadata.json GIT_EDITOR=true git rebase --continue done 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 diff --git a/.gitignore b/.gitignore index f60ce43..53b3b40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Go build artifacts -/awesome-coding-agents +/awesome-ai-dev-tools *.exe *.test *.out @@ -9,5 +9,7 @@ .idea/ .vscode/ -# Generated by the updater each run; deployed to Pages, never committed -site/data.json +# Build output from `go run . -build`; regenerated on every deploy. +# 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/ diff --git a/README.md b/README.md index 2082d66..cc4db0b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# Awesome Coding Agents +# Awesome AI Dev Tools -> Curated ranking of AI agent coding tools, sorted by GitHub stars. +> Curated ranking of AI-powered developer tools, sorted by GitHub stars. > Updated daily by GitHub Actions. -📊 **[Interactive dashboard with star-history charts →](https://tiennm99.github.io/awesome-coding-agents/)** +📊 **[Interactive dashboard with star-history charts →](https://awesome-ai-dev-tools.pages.dev/)** **Last updated:** 2026-09-16 14:01 UTC · **Tracked:** 41 repos **Top 7-day mover:** [earendil-works/pi](https://github.com/earendil-works/pi) (+2964 stars) @@ -60,8 +60,8 @@ 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. 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. -6. `site/data.json` is regenerated and the [dashboard](https://tiennm99.github.io/awesome-coding-agents/) (`site/index.html`) is redeployed to GitHub Pages. +5. This `README.md` and `data/metadata.json` are regenerated and committed back to the repo. +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. @@ -69,8 +69,8 @@ **Inclusion criteria** — a repo belongs on this list when it is: -1. **An AI coding agent or assistant itself** — a tool that autonomously writes, edits, or reviews code. Frameworks, wrappers, observability layers, and workspaces *for* agents are out of scope. -2. **Notable**: roughly **10,000+ GitHub stars** (the current list floor). +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*. +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. Archived or abandoned repos are kept only when they are historically significant, and are marked as such in [`data/agents.yml`](data/agents.yml). @@ -84,13 +84,13 @@ agents: tags: [terminal, byo-model, interactive, community] ``` -**Tags** describe a tool across five facets — the [dashboard](https://tiennm99.github.io/awesome-coding-agents/) 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 | |-------|------| | Surface (at least one) | `terminal` · `editor-plugin` · `ide` · `desktop` · `web` · `self-hosted` | | Model access | `byo-model` · `single-vendor` · `local-models` | -| Workflow | `interactive` · `autonomous` · `review` · `app-builder` · `research` | +| Workflow | `interactive` · `autonomous` · `review` · `app-builder` · `research` · `orchestration` | | Integration | `mcp` · `acp` · `headless` | | Origin (at most one) | `vendor` · `community` | diff --git a/build.go b/build.go new file mode 100644 index 0000000..11f0642 --- /dev/null +++ b/build.go @@ -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 + }) +} diff --git a/build_test.go b/build_test.go new file mode 100644 index 0000000..57df813 --- /dev/null +++ b/build_test.go @@ -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", "dashboard") + 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) + } +} diff --git a/data/agents.yml b/data/agents.yml index 811574b..e44cbde 100644 --- a/data/agents.yml +++ b/data/agents.yml @@ -17,10 +17,7 @@ agents: tags: [terminal, byo-model, local-models, interactive, community] - owner: cline repo: cline - tags: [editor-plugin, terminal, byo-model, local-models, interactive, mcp, community] - - owner: continuedev - repo: continue - 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: anomalyco repo: opencode tags: [terminal, desktop, byo-model, local-models, interactive, headless, mcp, community] @@ -30,44 +27,32 @@ agents: - owner: google-gemini repo: gemini-cli tags: [terminal, single-vendor, interactive, headless, mcp, vendor] - notes: Google announced a closed-source successor; OSS repo may be retired - owner: openai 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 repo: goose tags: [terminal, desktop, byo-model, local-models, interactive, mcp, acp, community] notes: Moved to the Agentic AI Foundation (Linux Foundation) - owner: AntonOsika 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 - owner: SWE-agent repo: SWE-agent 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 repo: tabby tags: [self-hosted, editor-plugin, local-models, interactive, community] - owner: zed-industries repo: zed 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 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 repo: avante.nvim 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 repo: pi tags: [terminal, byo-model, interactive, community] @@ -83,10 +68,10 @@ agents: tags: [terminal, editor-plugin, byo-model, autonomous, community] - owner: QwenLM 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 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 repo: onlook tags: [desktop, web, byo-model, app-builder, mcp, community] @@ -98,7 +83,7 @@ agents: tags: [terminal, single-vendor, interactive, headless, mcp, vendor] - owner: deepseek-ai repo: deepseek-harness - tags: [terminal, single-vendor, interactive, vendor] + tags: [terminal, web, single-vendor, interactive, vendor] notes: Developer preview; compatibility-breaking changes expected - owner: openinterpreter repo: openinterpreter @@ -108,10 +93,10 @@ agents: tags: [terminal, editor-plugin, desktop, byo-model, local-models, interactive, mcp, community] - owner: esengine 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 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 repo: oh-my-pi 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] - owner: alibaba repo: open-code-review - tags: [terminal, byo-model, review, mcp, vendor] + tags: [terminal, byo-model, review, headless, mcp, vendor] - owner: PrimeIntellect-ai repo: prime-agent tags: [terminal, byo-model, autonomous, headless, vendor] @@ -139,7 +124,11 @@ agents: - owner: Kuberwastaken repo: claurst 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 + repo: orca + 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." + - owner: getpaseo + repo: paseo + 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." diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 59c5bb5..c196e81 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -24,6 +24,41 @@ Optional fields: `notes` (for clarifications or caveats) | `tags` | list | Yes | Tags from the vocabulary below; at least one surface tag, at most one origin tag | | `notes` | string | No | Additional context or disclaimers | +## Scope + +This list ranks **developer tools built around AI** — things a developer uses to +build software, where an LLM is central to what the tool does. In practice that +spans several shapes: + +- **Coding agents** — they write, edit, or review code themselves. +- **Agent development environments (ADEs)** — their primary purpose is running and + coordinating those agents: parallel worktrees, session management, remote or + mobile control. Tag these `orchestration`. +- **AI-assisted editors, terminals, and review tools** — the developer stays in the + driver's seat and the model accelerates the work. + +The dividing line is **tools you use vs. building blocks you import**. Out of scope: +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. + +A tool also has to be *about* software development. General-purpose assistants, +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 + +**1,000 stars minimum.** This is not a judgement call and is not waived for +individual entries, however good the tool is. The updater enforces it: any entry +below the floor is dropped from the ranking and reported as an `::error::` in the +Actions log (`enforceStarFloor` in `github.go`). Because star counts require the +API, `go run . -check` cannot catch this offline — a below-floor entry passes CI +and is then dropped by the next daily run, so check the count before opening a PR. + +Maintenance requirements apply to every entry equally: no push in 6 months means +removal, and the daily run warns past 3 months. + ## Tag Vocabulary Tags replaced the old single-select `category` field, because one slot cannot @@ -31,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 five facets. `data/agents.yml` is the source of truth; the vocabulary itself 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. @@ -55,6 +90,7 @@ lives in `tagVocabulary` (`validate.go`) and reaches the dashboard through - **review** — reviews diffs or existing code rather than writing it - **app-builder** — prompt-to-app scaffolding, with preview and deploy - **research** — published as a research artifact or proof of concept +- **orchestration** — runs and coordinates *other* coding agents as its primary purpose (an ADE), rather than editing code itself **Integration** — what it plugs into. @@ -88,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. **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 @@ -95,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 - 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. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..dfb98c8 --- /dev/null +++ b/docs/DEPLOY.md @@ -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. diff --git a/docs/LOCAL_DEV.md b/docs/LOCAL_DEV.md index a3f42fb..4de6d95 100644 --- a/docs/LOCAL_DEV.md +++ b/docs/LOCAL_DEV.md @@ -9,25 +9,56 @@ 1. Go to [https://github.com/settings/tokens](https://github.com/settings/tokens) 2. Click "Generate new token" → "Generate new token (classic)" -3. Give it a name (e.g., "awesome-coding-agents") +3. Give it a name (e.g., "awesome-ai-dev-tools") 4. Select scope: **`public_repo`** (needed to read public repo metadata) 5. Click "Generate token" and copy the value 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 export GITHUB_TOKEN=ghp_your_token_here go run . ``` -The tool will: +This will: - 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` +- Write the fetched fields to `data/metadata.json` - 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 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 To add agents, see [CONTRIBUTING.md](./CONTRIBUTING.md). +To publish the site, see [DEPLOY.md](./DEPLOY.md). diff --git a/github.go b/github.go index 3a621d7..5f6a040 100644 --- a/github.go +++ b/github.go @@ -74,6 +74,13 @@ var graphqlURL = "https://api.github.com/graphql" // annotates the run. const staleWarnAfter = 90 * 24 * time.Hour +// minStars is the hard inclusion floor from the README's criteria. Unlike the +// staleness window, this is not a judgement call: an entry below the floor is +// dropped from the published ranking, so the list can never show a repo that +// does not meet it. It lives here rather than in -check because star counts +// need the API and -check runs offline. +const minStars = 1000 + // chunkSize is the max aliases per GraphQL request (GitHub node-limit safety margin). const chunkSize = 50 @@ -149,16 +156,21 @@ func fetchStats(token string, agents []Agent) ([]Stat, error) { }) } - // Sort by stars descending. Ties are ordered by CanonicalKey for determinism - // regardless of map-iteration or agents.yml order. + sortStats(stats) + + 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 { if stats[i].Stars != stats[j].Stars { return stats[i].Stars > stats[j].Stars } return stats[i].CanonicalKey < stats[j].CanonicalKey }) - - return stats, nil } // fetchChunk sends one GraphQL request for a slice of agents. aliasOffset @@ -244,7 +256,7 @@ func doWithRetry(token string, body []byte) ([]byte, int, error) { } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "awesome-coding-agents-updater") + req.Header.Set("User-Agent", "awesome-ai-dev-tools-updater") resp, err := httpClient.Do(req) if err != nil { @@ -272,3 +284,19 @@ func doWithRetry(token string, body []byte) ([]byte, int, error) { } return nil, 0, fmt.Errorf("all %d attempts failed; last error: %w", maxRetries, lastErr) } + +// enforceStarFloor drops entries below minStars and annotates each one as a run +// error. Dropping rather than failing the run keeps one below-floor entry from +// blocking the refresh of every other repo, while the ::error:: annotation +// makes the removal impossible to miss in the Actions log. +func enforceStarFloor(stats []Stat) []Stat { + kept := make([]Stat, 0, len(stats)) + for _, s := range stats { + if s.Stars < minStars { + fmt.Printf("::error::repo %s has %d stars, below the %d minimum — dropped from the ranking; remove its entry from data/agents.yml\n", s.CanonicalKey, s.Stars, minStars) + continue + } + kept = append(kept, s) + } + return kept +} diff --git a/github_test.go b/github_test.go index 27d63bc..7287702 100644 --- a/github_test.go +++ b/github_test.go @@ -312,3 +312,24 @@ func TestFetchStats_StaleWarning(t *testing.T) { t.Errorf("missing archived warning %q in:\n%s", want, logs) } } + +func TestEnforceStarFloor(t *testing.T) { + in := []Stat{ + {CanonicalKey: "a/above", Stars: minStars + 1}, + {CanonicalKey: "b/exactly", Stars: minStars}, + {CanonicalKey: "c/below", Stars: minStars - 1}, + {CanonicalKey: "d/zero", Stars: 0}, + } + + got := enforceStarFloor(in) + + want := []string{"a/above", "b/exactly"} + if len(got) != len(want) { + t.Fatalf("kept %d entries, want %d: %+v", len(got), len(want), got) + } + for i, w := range want { + if got[i].CanonicalKey != w { + t.Errorf("kept[%d] = %s, want %s", i, got[i].CanonicalKey, w) + } + } +} diff --git a/go.mod b/go.mod index 7d02bfe..a96e582 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/tiennm99/awesome-coding-agents +module github.com/tiennm99/awesome-ai-dev-tools go 1.23 diff --git a/history.go b/history.go index cb99382..4e340a5 100644 --- a/history.go +++ b/history.go @@ -195,8 +195,16 @@ func computeDeltas(history []Snapshot, current Snapshot) map[string]int { // the delta would be misleadingly labeled "Δd", so we return no delta // for that repo instead. 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{} - now := timeNow().UTC() + now := anchor.UTC() cutoff := now.AddDate(0, 0, -days).Format("2006-01-02") lowerBound := now.AddDate(0, 0, -(days + slackDays)).Format("2006-01-02") diff --git a/main.go b/main.go index b756b24..0d829dc 100644 --- a/main.go +++ b/main.go @@ -11,28 +11,52 @@ import ( const ( agentsPath = "data/agents.yml" historyPath = "data/history.jsonl" + metadataPath = "data/metadata.json" readmeTmpl = "templates/readme.tmpl" 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() { 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() - 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 { log.Printf("check failed: %v", err) os.Exit(1) } - return - } - - if err := run(); err != nil { - log.Fatalf("update failed: %v", err) + 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 { + 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 { agents, err := loadAgents(agentsPath) if err != nil { @@ -52,7 +76,12 @@ func run() error { return err } - snapshots, deltas7, deltas30, err := appendHistory(historyPath, stats) + stats = enforceStarFloor(stats) + if len(stats) == 0 { + return fmt.Errorf("no agents in %s meet the %d-star minimum", agentsPath, minStars) + } + + _, deltas7, _, err := appendHistory(historyPath, stats) if err != nil { return err } @@ -61,7 +90,7 @@ func run() error { return err } - if err := writeSiteData(siteDataPath, stats, deltas7, deltas30, snapshots); err != nil { + if err := writeMetadata(metadataPath, stats); err != nil { return err } diff --git a/metadata.go b/metadata.go new file mode 100644 index 0000000..c58be8f --- /dev/null +++ b/metadata.go @@ -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 +} diff --git a/plans/reports/research-260911-1328-agents-refresh.md b/plans/reports/research-260911-1328-agents-refresh.md index 515154f..5ca1895 100644 --- a/plans/reports/research-260911-1328-agents-refresh.md +++ b/plans/reports/research-260911-1328-agents-refresh.md @@ -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 diff --git a/site.go b/site.go index f596356..e1bb90b 100644 --- a/site.go +++ b/site.go @@ -5,7 +5,7 @@ import ( "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 { Key string `json:"key"` // canonical owner/repo, matches history keys NameWithOwner string `json:"nameWithOwner"` @@ -32,10 +32,13 @@ type siteData struct { Facets []tagFacet `json:"facets"` } -// writeSiteData emits the JSON payload for the GitHub Pages dashboard. -// The file is generated fresh on every updater run and is not 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 { +// writeSiteData emits the JSON payload the dashboard fetches at runtime. +// It is build output (dist/data.json), never committed. +// +// 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)) for i, s := range stats { 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 json.NewEncoder(w).Encode(siteData{ - UpdatedAt: timeNow().UTC().Format("2006-01-02 15:04 UTC"), + UpdatedAt: updatedAt, Rows: rows, History: history, Facets: tagVocabulary, diff --git a/site/_headers b/site/_headers new file mode 100644 index 0000000..8cd044c --- /dev/null +++ b/site/_headers @@ -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 diff --git a/site/index.html b/site/index.html index 4e84b00..e8ffa44 100644 --- a/site/index.html +++ b/site/index.html @@ -3,11 +3,11 @@ -Awesome Coding Agents — Rankings & History +Awesome AI Dev Tools — Rankings & History - + - + @@ -211,11 +211,11 @@

- Awesome Coding Agents + Awesome AI Dev Tools

Curated ranking of open-source AI coding agents by GitHub stars, refreshed daily.
- + GitHub @@ -225,7 +225,7 @@