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 "Δ
- Awesome Coding Agents
+ Awesome AI Dev Tools