@rtrentjones/greenlight 0.2.29 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ ---
2
+ name: provider-gemini
3
+ description: How the `agent` lane works in Greenlight — an autonomous cron-triggered Cloudflare Worker backed by Google Gemini (free tier). Covers the GEMINI_API_KEY (Google AI Studio, no billing), the gemini-2.5-flash generateContent call, the wrangler deploy (cron + KV + secret + custom_domain), the /, /status, /run surface, api-mode verify, and the free-tier safety envelope. Use when building, deploying, or verifying an agent tool.
4
+ ---
5
+
6
+ # provider-gemini
7
+
8
+ The `agent` lane is an **autonomous tool**: a Cloudflare Worker that wakes on a **cron trigger**,
9
+ calls **Gemini** (Google's LLM, free tier), does low-stakes work, stores the result in KV, and
10
+ exposes a tiny HTTP surface. It's the keepalive Worker pattern promoted to a user tool — free,
11
+ always-available, immune to repo-inactivity, no OCI box, no new paid account.
12
+
13
+ `agent` → target **workers**, data **none | kv** (kv holds the last output + run metadata).
14
+
15
+ ## Token — `GEMINI_API_KEY`
16
+
17
+ Create it at **Google AI Studio** (https://aistudio.google.com/apikey) — **free tier, no billing,
18
+ no card**. `greenlight add` verifies it against `…/v1beta/models?key=…` (HTTP 200). One key serves
19
+ every agent (shared, not per-tool). It is a **Cloudflare Worker secret** (`wrangler secret put
20
+ GEMINI_API_KEY`) — never in the repo.
21
+
22
+ ## The model + call
23
+
24
+ `gemini-2.5-flash` (fast; generous free limits — ~15 RPM / 1500 req/day, so a daily cron is ~1/day).
25
+
26
+ ```
27
+ POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}
28
+ { "contents": [{ "parts": [{ "text": "<prompt>" }] }] }
29
+ → candidates[0].content.parts[0].text
30
+ ```
31
+
32
+ ## Deploy — emitted CI (push to main)
33
+
34
+ `greenlight add` resolves the Cloudflare **account id** into `wrangler.toml` (so wrangler skips the
35
+ `/memberships` call a scoped token can't do) and emits **`.github/workflows/deploy-<name>.yml`**. On
36
+ a push to main that touches `tools/<name>`, that workflow: **creates the KV namespace** (find-or-create
37
+ in-CI — no manual step, the id stays a placeholder), deploys the Worker (cron + `custom_domain` from
38
+ `wrangler.toml`), sets the `GEMINI_API_KEY` + `RUN_TOKEN` **Worker secrets** from GitHub secrets, seeds
39
+ the first run, and verifies. So the only setup is **adding those two GitHub secrets**. (Local instead:
40
+ `pnpm exec wrangler deploy --env prod`.)
41
+
42
+ `wrangler.toml` carries the cron + KV binding + the per-env `custom_domain` route; no Terraform.
43
+
44
+ ```toml
45
+ [triggers]
46
+ crons = ["0 13 * * *"] # daily; stays far under the free-tier quota
47
+ [[kv_namespaces]]
48
+ binding = "STATE"
49
+ [[routes]]
50
+ pattern = "<name>.<domain>"
51
+ custom_domain = true
52
+ ```
53
+
54
+ ## Surface
55
+
56
+ | route | purpose |
57
+ |---|---|
58
+ | `scheduled()` | the cron: prompt Gemini → `STATE.put(today, text + metadata)` |
59
+ | `GET /` | the latest output (public, read-only) |
60
+ | `GET /status` | `{ ok, lastRun, model, preview }` — the **api-mode verify** target |
61
+ | `POST /run` | force a run — **bearer-gated** (a `RUN_TOKEN` secret) so randoms can't burn the Gemini quota; lets deploy/verify seed the first output |
62
+
63
+ ## Verify — `api` mode on `/status`
64
+
65
+ `verify.config.ts` hits `/status` and asserts `ok: true` + a recent run. (Output *quality* is a
66
+ future `eval` mode — LLM-judged.) Because the first cron may not have fired at deploy time, the
67
+ deploy step `POST /run`s once to seed, then verifies.
68
+
69
+ ## Safety envelope
70
+
71
+ - **Low-stakes / read-only** first agents (generate → store → serve; no destructive external actions).
72
+ - **Bearer on `/run`**; the cron frequency stays far under the free-tier daily limit.
73
+ - Key is **secret-only** (a Worker secret), never committed or echoed.
74
+
75
+ ## No keepalive
76
+
77
+ An agent needs no keepalive — the cron *is* its heartbeat and the Worker is always-available
78
+ (Cloudflare's edge, not a reclaimable box). Don't add it to `module.keepalive.targets_json`.
package/dist/bin.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  resolveUrl,
7
7
  scanSqlFiles,
8
8
  verifyAll
9
- } from "./chunk-HMU7D7R2.js";
9
+ } from "./chunk-P6FRYOOV.js";
10
10
  import "./chunk-HX7VA25D.js";
11
11
  import "./chunk-N3IKUCSF.js";
12
12
  import "./chunk-KP3Y6WRU.js";
@@ -15,8 +15,97 @@ import "./chunk-XWTOJHLV.js";
15
15
  import "./chunk-QFKE5JKC.js";
16
16
 
17
17
  // src/commands/add.ts
18
- import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
19
- import { join, resolve as resolve5 } from "path";
18
+ import { cpSync as cpSync2, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync, writeFileSync as writeFileSync3 } from "fs";
19
+ import { join, resolve as resolve6 } from "path";
20
+
21
+ // src/agent-deploy.ts
22
+ function emitAgentDeployWorkflow(name, domain) {
23
+ return `name: deploy-${name}
24
+
25
+ # Agent "${name}" \u2014 a cron-triggered Cloudflare Worker (Gemini-backed). Emitted by \`greenlight add\`.
26
+ # On a push to main touching tools/${name}, or manually: deploys the Worker, sets its secrets from
27
+ # GitHub secrets, seeds the first run, and verifies. Creds-guarded (skips if the secrets are absent).
28
+ on:
29
+ push:
30
+ branches: [main]
31
+ paths: ['tools/${name}/**']
32
+ workflow_dispatch:
33
+
34
+ permissions:
35
+ contents: read
36
+
37
+ concurrency:
38
+ group: deploy-${name}
39
+ cancel-in-progress: false
40
+
41
+ jobs:
42
+ deploy:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ - uses: jdx/mise-action@v2
47
+ - run: pnpm install --frozen-lockfile
48
+
49
+ - name: Check creds
50
+ id: creds
51
+ env:
52
+ CF: \${{ secrets.CLOUDFLARE_API_TOKEN }}
53
+ GK: \${{ secrets.GEMINI_API_KEY }}
54
+ run: |
55
+ if [ -n "$CF" ] && [ -n "$GK" ]; then echo "have=1" >> "$GITHUB_OUTPUT"; else echo "have=0" >> "$GITHUB_OUTPUT"; fi
56
+
57
+ - name: Deploy + Worker secrets + seed
58
+ if: steps.creds.outputs.have == '1'
59
+ env:
60
+ CLOUDFLARE_API_TOKEN: \${{ secrets.CLOUDFLARE_API_TOKEN }}
61
+ GEMINI_API_KEY: \${{ secrets.GEMINI_API_KEY }}
62
+ RUN_TOKEN: \${{ secrets.RUN_TOKEN }}
63
+ run: |
64
+ cd tools/${name}
65
+ # KV namespace as code: find-or-create the STATE namespace (idempotent), then inject its id
66
+ # into wrangler.toml for this deploy. The id is non-secret + derived, so the repo keeps the
67
+ # REPLACE_WITH_KV_NAMESPACE_ID placeholder \u2014 no manual create, no hardcoded id.
68
+ ID=$(pnpm exec wrangler kv namespace list 2>/dev/null | jq -r '.[] | select(.title | test("${name}.*STATE")) | .id' | head -1)
69
+ if [ -z "$ID" ] || [ "$ID" = "null" ]; then
70
+ pnpm exec wrangler kv namespace create STATE || true
71
+ ID=$(pnpm exec wrangler kv namespace list 2>/dev/null | jq -r '.[] | select(.title | test("${name}.*STATE")) | .id' | head -1)
72
+ fi
73
+ if [ -z "$ID" ] || [ "$ID" = "null" ]; then echo "::error::could not resolve the STATE KV namespace id (token needs Workers KV Storage:Edit?)"; exit 1; fi
74
+ sed -i "s/REPLACE_WITH_KV_NAMESPACE_ID/$ID/g" wrangler.toml
75
+ pnpm exec wrangler deploy --env prod
76
+ printf '%s' "$GEMINI_API_KEY" | pnpm exec wrangler secret put GEMINI_API_KEY --env prod
77
+ printf '%s' "$RUN_TOKEN" | pnpm exec wrangler secret put RUN_TOKEN --env prod
78
+ cd ../..
79
+ # Seed the first run (the cron is daily). Retry while the custom domain propagates.
80
+ for i in $(seq 1 8); do
81
+ if curl -fsS -XPOST "https://${name}.${domain}/run" -H "Authorization: Bearer $RUN_TOKEN" >/dev/null; then
82
+ echo "seeded"; break
83
+ fi
84
+ echo "seed attempt $i: not ready, retrying in 10s"; sleep 10
85
+ done
86
+
87
+ - name: Verify
88
+ if: steps.creds.outputs.have == '1'
89
+ run: pnpm exec greenlight verify ${name} --env prod
90
+
91
+ - name: Skip notice
92
+ if: steps.creds.outputs.have != '1'
93
+ run: echo "Missing CLOUDFLARE_API_TOKEN or GEMINI_API_KEY \u2014 ${name} deploy skipped."
94
+ `;
95
+ }
96
+ async function resolveCloudflareAccountId(domain, token) {
97
+ try {
98
+ const res = await fetch(
99
+ `https://api.cloudflare.com/client/v4/zones?name=${encodeURIComponent(domain)}`,
100
+ { headers: { Authorization: `Bearer ${token}` } }
101
+ );
102
+ if (!res.ok) return null;
103
+ const data = await res.json();
104
+ return data.result?.[0]?.account?.id ?? null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
20
109
 
21
110
  // src/asset-paths.ts
22
111
  import { existsSync } from "fs";
@@ -351,6 +440,29 @@ var PACKS = [
351
440
  skill: "provider-neon",
352
441
  tfModules: ["neon"]
353
442
  },
443
+ {
444
+ id: "gemini",
445
+ name: "Google Gemini (free tier)",
446
+ // The LLM behind the `agent` lane. (A future `llm` axis would generalize this beyond agents.)
447
+ appliesTo: (t) => t.lane === "agent",
448
+ guide: "docs/provider-tokens.md \u2014 GEMINI_API_KEY (Google AI Studio, free tier, no billing)",
449
+ setupUrl: "https://aistudio.google.com/apikey",
450
+ tokens: [
451
+ {
452
+ // The agent Worker's LLM credential. Free tier (no billing / no card); set as a Cloudflare
453
+ // Worker secret, never in the repo. One key serves every agent (shared, not per-tool).
454
+ envVar: "GEMINI_API_KEY",
455
+ label: "Google AI Studio API key (Gemini free tier)",
456
+ verify: async (t) => {
457
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${t}`);
458
+ return { ok: okStatus(r), detail: `HTTP ${r.status}` };
459
+ }
460
+ }
461
+ ],
462
+ skill: "provider-gemini"
463
+ // No tfModules: the agent Worker (cron + KV + secret + custom_domain) deploys via wrangler,
464
+ // like the astro blog — KV/DNS are wrangler-managed for the workers target.
465
+ },
354
466
  {
355
467
  id: "hcp",
356
468
  name: "HCP Terraform (remote state)",
@@ -478,7 +590,7 @@ function tokensForTool(tool) {
478
590
  }
479
591
 
480
592
  // src/version.ts
481
- var MODULE_REF = "v0.2.29";
593
+ var MODULE_REF = "v0.3.1";
482
594
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
483
595
  function moduleSource(module, ref = MODULE_REF) {
484
596
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -488,6 +600,14 @@ function moduleSource(module, ref = MODULE_REF) {
488
600
  var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
489
601
  function emitToolTf(opts) {
490
602
  const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
603
+ if (lane === "agent") {
604
+ const suffix = data && data !== "none" ? `/${data}` : "";
605
+ return `# ${name} \u2014 agent/${target}${suffix}, emitted by \`greenlight add\`.
606
+ # Wrangler-managed: the Worker (cron + KV + custom_domain route + GEMINI_API_KEY/RUN_TOKEN
607
+ # secrets) deploys via \`wrangler deploy\` from tools/${name}/. No Terraform here \u2014 see that
608
+ # wrangler.toml + the provider-gemini skill + docs/agents-plan.md.
609
+ `;
610
+ }
491
611
  const port = opts.port ?? 8e3;
492
612
  const slug = opts.slug ?? `OWNER/${name}`;
493
613
  const useSupabase = data === "supabase";
@@ -795,103 +915,15 @@ function providersForTool(tool) {
795
915
  return out;
796
916
  }
797
917
 
798
- // src/commands/agent.ts
799
- import { cpSync, existsSync as existsSync3, mkdirSync, readFileSync, writeFileSync } from "fs";
800
- import { resolve as resolve3 } from "path";
801
-
802
- // src/agent-kit.ts
803
- function recommendedMcp(tool) {
804
- return mcpForTool(tool);
805
- }
806
- function mergeMcpServers(existing, add) {
807
- const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
808
- for (const [name, val] of Object.entries(add)) {
809
- if (out.mcpServers[name]) continue;
810
- out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
811
- }
812
- return out;
813
- }
814
-
815
- // src/commands/agent.ts
816
- var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
817
-
818
- This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
819
- tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
820
- branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
821
- push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
822
- Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
823
- pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
824
-
825
- Agentic kit:
826
- - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
827
- - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
828
- Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
829
- - Best-practice skills (one-time, user scope):
830
- \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
831
- `;
832
- function materializeAgentKit(dir, tool) {
833
- const src = skillAssetDir();
834
- if (!existsSync3(src)) throw new Error(`skill asset not found at ${src}`);
835
- const dest = resolve3(dir, ".claude/skills/deploy-verify-promote");
836
- mkdirSync(dest, { recursive: true });
837
- cpSync(src, dest, { recursive: true });
838
- console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
839
- for (const pack of packsForTool(tool)) {
840
- if (!pack.skill) continue;
841
- const skillSrc = skillAssetDir(pack.skill);
842
- if (!existsSync3(skillSrc)) continue;
843
- const skillDest = resolve3(dir, ".claude/skills", pack.skill);
844
- mkdirSync(skillDest, { recursive: true });
845
- cpSync(skillSrc, skillDest, { recursive: true });
846
- console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
847
- }
848
- const mcpPath = resolve3(dir, ".mcp.json");
849
- const existingMcp = existsSync3(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : null;
850
- const servers = recommendedMcp(tool);
851
- writeFileSync(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
852
- `);
853
- console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
854
- const claudePath = resolve3(dir, "CLAUDE.md");
855
- const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
856
- const existing = existsSync3(claudePath) ? readFileSync(claudePath, "utf8") : "";
857
- if (existing.includes(marker)) {
858
- console.log("\xB7 CLAUDE.md already has the loop block");
859
- } else {
860
- writeFileSync(claudePath, existing ? `${existing.trimEnd()}
861
-
862
- ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
863
- console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
864
- }
865
- }
866
- async function agentCommand(args) {
867
- if (args[0] !== "sync") {
868
- console.log(
869
- "usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
870
- );
871
- process.exit(args[0] ? 1 : 0);
872
- }
873
- const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
874
- if (name) {
875
- const { config } = await loadManifest();
876
- const entry = resolveEntry(config, name);
877
- const dir = resolve3(process.cwd(), entry.dir ?? ".");
878
- materializeAgentKit(dir, { target: entry.target, data: entry.data });
879
- console.log(
880
- `
881
- Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
882
- );
883
- return;
884
- }
885
- materializeAgentKit(process.cwd());
886
- console.log(
887
- "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
888
- );
889
- }
918
+ // src/tokens.ts
919
+ import { existsSync as existsSync4, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
920
+ import { resolve as resolve4 } from "path";
921
+ import { createInterface as createInterface2 } from "readline/promises";
890
922
 
891
923
  // src/commands/secrets.ts
892
924
  import { execFileSync } from "child_process";
893
- import { existsSync as existsSync4, readFileSync as readFileSync2 } from "fs";
894
- import { resolve as resolve4 } from "path";
925
+ import { existsSync as existsSync3, readFileSync } from "fs";
926
+ import { resolve as resolve3 } from "path";
895
927
  import { createInterface } from "readline";
896
928
  function parseSecretsEnv(text) {
897
929
  const out = [];
@@ -917,7 +949,7 @@ function parseOciConfig(text) {
917
949
  return out;
918
950
  }
919
951
  function ociPrefill(configPath, keyPath) {
920
- const cfg = parseOciConfig(readFileSync2(configPath, "utf8"));
952
+ const cfg = parseOciConfig(readFileSync(configPath, "utf8"));
921
953
  const map = /* @__PURE__ */ new Map();
922
954
  const set = (k, v) => {
923
955
  if (v) map.set(k, v);
@@ -927,8 +959,8 @@ function ociPrefill(configPath, keyPath) {
927
959
  set("TF_VAR_OCI_TENANCY_OCID", cfg.tenancy);
928
960
  set("TF_VAR_OCI_REGION", cfg.region);
929
961
  const pem = keyPath ?? cfg.key_file;
930
- if (pem && existsSync4(pem)) {
931
- map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync2(pem, "utf8"));
962
+ if (pem && existsSync3(pem)) {
963
+ map.set("TF_VAR_OCI_PRIVATE_KEY", readFileSync(pem, "utf8"));
932
964
  } else if (pem) {
933
965
  console.log(` ! PEM not found at ${pem} \u2014 set TF_VAR_OCI_PRIVATE_KEY manually (--oci-key)`);
934
966
  }
@@ -962,11 +994,11 @@ function syncSecrets(opts) {
962
994
  "could not determine the repo \u2014 pass --repo owner/repo (no github.com origin remote)"
963
995
  );
964
996
  }
965
- const path = resolve4(opts.cwd, ".greenlight/secrets.env");
966
- if (!existsSync4(path)) {
997
+ const path = resolve3(opts.cwd, ".greenlight/secrets.env");
998
+ if (!existsSync3(path)) {
967
999
  throw new Error("no .greenlight/secrets.env \u2014 run `greenlight init` with tokens first");
968
1000
  }
969
- const entries = parseSecretsEnv(readFileSync2(path, "utf8"));
1001
+ const entries = parseSecretsEnv(readFileSync(path, "utf8"));
970
1002
  const target = opts.env ? `env "${opts.env}"` : "repo";
971
1003
  for (const { key, value } of entries) {
972
1004
  const ghArgs = ["secret", "set", key, "--repo", repo];
@@ -1035,7 +1067,7 @@ function setGitHubSecret(repo, env, key, value) {
1035
1067
  async function gatherSecrets(name, repo, env, prefill) {
1036
1068
  const { config } = await loadManifest();
1037
1069
  const entry = resolveEntry(config, name);
1038
- const packs = packsForTool({ target: entry.target, data: entry.data });
1070
+ const packs = packsForTool({ lane: entry.lane, target: entry.target, data: entry.data });
1039
1071
  const dest = env ? `env "${env}" of ${repo}` : repo;
1040
1072
  const existing = listGitHubSecrets(repo, env);
1041
1073
  console.log(`Gathering secrets for "${name}" \u2192 GitHub ${dest}`);
@@ -1107,7 +1139,7 @@ async function secretsCommand(args) {
1107
1139
  if (!repo) throw new Error("could not determine the repo \u2014 pass --repo owner/repo");
1108
1140
  const ociConfig2 = flag(args, "--oci-config");
1109
1141
  const ociKey = flag(args, "--oci-key");
1110
- const prefill = ociConfig2 ? ociPrefill(resolve4(process.cwd(), ociConfig2), ociKey && resolve4(process.cwd(), ociKey)) : void 0;
1142
+ const prefill = ociConfig2 ? ociPrefill(resolve3(process.cwd(), ociConfig2), ociKey && resolve3(process.cwd(), ociKey)) : void 0;
1111
1143
  await gatherSecrets(name, repo, flag(args, "--env"), prefill);
1112
1144
  return;
1113
1145
  }
@@ -1132,6 +1164,177 @@ ${count} secret(s) synced. (Prefer GitHub OIDC over long-lived tokens where supp
1132
1164
  );
1133
1165
  }
1134
1166
 
1167
+ // src/tokens.ts
1168
+ var SECRETS_DIR = ".greenlight";
1169
+ var SECRETS_FILE = "secrets.env";
1170
+ function presentEnv(cwd) {
1171
+ const out = {};
1172
+ const p = resolve4(cwd, SECRETS_DIR, SECRETS_FILE);
1173
+ if (existsSync4(p)) {
1174
+ for (const { key, value } of parseSecretsEnv(readFileSync2(p, "utf8"))) out[key] = value;
1175
+ }
1176
+ for (const [k, v] of Object.entries(process.env)) {
1177
+ if (v !== void 0 && !(k in out)) out[k] = v;
1178
+ }
1179
+ return out;
1180
+ }
1181
+ function upsertSecret(cwd, key, value) {
1182
+ const dir = resolve4(cwd, SECRETS_DIR);
1183
+ mkdirSync(dir, { recursive: true });
1184
+ const p = resolve4(dir, SECRETS_FILE);
1185
+ const lines = existsSync4(p) ? readFileSync2(p, "utf8").split("\n") : [];
1186
+ const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
1187
+ if (idx >= 0) lines[idx] = `${key}=${value}`;
1188
+ else {
1189
+ while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
1190
+ lines.push(`${key}=${value}`);
1191
+ }
1192
+ writeFileSync(p, `${lines.join("\n").replace(/\n*$/, "")}
1193
+ `, { mode: 384 });
1194
+ }
1195
+ async function ensureTokensForTool(cwd, tool, opts = {}) {
1196
+ const doVerify = opts.verify !== false;
1197
+ const interactive = Boolean(process.stdin.isTTY);
1198
+ const env = presentEnv(cwd);
1199
+ const results = [];
1200
+ const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
1201
+ try {
1202
+ for (const spec of tokensForTool(tool)) {
1203
+ let value = env[spec.envVar];
1204
+ if (value) {
1205
+ results.push({ envVar: spec.envVar, outcome: "present" });
1206
+ } else if (rl) {
1207
+ console.log(`
1208
+ ${spec.envVar} \u2014 ${spec.label}`);
1209
+ if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
1210
+ const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
1211
+ if (!entered) {
1212
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1213
+ continue;
1214
+ }
1215
+ upsertSecret(cwd, spec.envVar, entered);
1216
+ env[spec.envVar] = entered;
1217
+ value = entered;
1218
+ results.push({ envVar: spec.envVar, outcome: "entered" });
1219
+ } else {
1220
+ results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
1221
+ continue;
1222
+ }
1223
+ if (value && doVerify && spec.verify) {
1224
+ let check;
1225
+ try {
1226
+ check = await spec.verify(value, env);
1227
+ } catch (e) {
1228
+ check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
1229
+ }
1230
+ const last = results[results.length - 1];
1231
+ if (last) last.verify = check;
1232
+ if (!check.ok && !spec.optional) {
1233
+ throw new Error(
1234
+ `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
1235
+ );
1236
+ }
1237
+ }
1238
+ }
1239
+ } finally {
1240
+ rl?.close();
1241
+ }
1242
+ return results;
1243
+ }
1244
+
1245
+ // src/commands/agent.ts
1246
+ import { cpSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1247
+ import { resolve as resolve5 } from "path";
1248
+
1249
+ // src/agent-kit.ts
1250
+ function recommendedMcp(tool) {
1251
+ return mcpForTool(tool);
1252
+ }
1253
+ function mergeMcpServers(existing, add) {
1254
+ const out = { mcpServers: { ...existing?.mcpServers ?? {} } };
1255
+ for (const [name, val] of Object.entries(add)) {
1256
+ if (out.mcpServers[name]) continue;
1257
+ out.mcpServers[name] = typeof val === "string" ? { type: "http", url: val } : val;
1258
+ }
1259
+ return out;
1260
+ }
1261
+
1262
+ // src/commands/agent.ts
1263
+ var CLAUDE_BLOCK = `## Greenlight loop (deploy \u2192 verify \u2192 promote)
1264
+
1265
+ This repo uses Greenlight. Deliver every change through the ONE model (same shape for web + MCP
1266
+ tools \u2014 the deploy-verify-promote skill has the lane\xD7target matrix):
1267
+ branch \u2192 change \u2192 \`greenlight preview <name>\` (local gate) \u2192 add it to the tool's verify.config \u2192
1268
+ push (CI gates on the tool's own tests) \u2192 deploy \u2192 \`greenlight verify <name> --env prod\`.
1269
+ Web tools also get beta + \`greenlight promote\`; oci is direct-to-prod (the local gate is the
1270
+ pre-prod safety). \`greenlight status <name>\` shows the run chain; \`greenlight doctor\` flags drift.
1271
+
1272
+ Agentic kit:
1273
+ - Skill: \`.claude/skills/deploy-verify-promote/SKILL.md\` (the one model + the matrix).
1274
+ - MCP servers: \`.mcp.json\` recommends the relevant providers \u2014 run \`/mcp\` to authenticate.
1275
+ Vercel is OAuth; Supabase needs \`SUPABASE_ACCESS_TOKEN\` (+ \`SUPABASE_PROJECT_REF\`) in your env.
1276
+ - Best-practice skills (one-time, user scope):
1277
+ \`claude plugin marketplace add cloudflare/skills && claude plugin install cloudflare@cloudflare\`
1278
+ `;
1279
+ function materializeAgentKit(dir, tool) {
1280
+ const src = skillAssetDir();
1281
+ if (!existsSync5(src)) throw new Error(`skill asset not found at ${src}`);
1282
+ const dest = resolve5(dir, ".claude/skills/deploy-verify-promote");
1283
+ mkdirSync2(dest, { recursive: true });
1284
+ cpSync(src, dest, { recursive: true });
1285
+ console.log("\u2714 .claude/skills/deploy-verify-promote/SKILL.md");
1286
+ for (const pack of packsForTool(tool)) {
1287
+ if (!pack.skill) continue;
1288
+ const skillSrc = skillAssetDir(pack.skill);
1289
+ if (!existsSync5(skillSrc)) continue;
1290
+ const skillDest = resolve5(dir, ".claude/skills", pack.skill);
1291
+ mkdirSync2(skillDest, { recursive: true });
1292
+ cpSync(skillSrc, skillDest, { recursive: true });
1293
+ console.log(`\u2714 .claude/skills/${pack.skill}/SKILL.md`);
1294
+ }
1295
+ const mcpPath = resolve5(dir, ".mcp.json");
1296
+ const existingMcp = existsSync5(mcpPath) ? JSON.parse(readFileSync3(mcpPath, "utf8")) : null;
1297
+ const servers = recommendedMcp(tool);
1298
+ writeFileSync2(mcpPath, `${JSON.stringify(mergeMcpServers(existingMcp, servers), null, 2)}
1299
+ `);
1300
+ console.log(`\u2714 .mcp.json (${Object.keys(servers).length} recommended MCP server(s))`);
1301
+ const claudePath = resolve5(dir, "CLAUDE.md");
1302
+ const marker = "Greenlight loop (deploy \u2192 verify \u2192 promote)";
1303
+ const existing = existsSync5(claudePath) ? readFileSync3(claudePath, "utf8") : "";
1304
+ if (existing.includes(marker)) {
1305
+ console.log("\xB7 CLAUDE.md already has the loop block");
1306
+ } else {
1307
+ writeFileSync2(claudePath, existing ? `${existing.trimEnd()}
1308
+
1309
+ ${CLAUDE_BLOCK}` : CLAUDE_BLOCK);
1310
+ console.log(`\u2714 CLAUDE.md (${existing ? "appended" : "created"})`);
1311
+ }
1312
+ }
1313
+ async function agentCommand(args) {
1314
+ if (args[0] !== "sync") {
1315
+ console.log(
1316
+ "usage: greenlight agent sync [<name>]\n (no name) write the generic loop kit into THIS repo (the fallback)\n <name> load the manifest and sync that tool's kit into its dir, with the\n target-specific provider skills (oci/vercel/supabase), not just the always-on ones"
1317
+ );
1318
+ process.exit(args[0] ? 1 : 0);
1319
+ }
1320
+ const name = args[1] && !args[1].startsWith("-") ? args[1] : void 0;
1321
+ if (name) {
1322
+ const { config } = await loadManifest();
1323
+ const entry = resolveEntry(config, name);
1324
+ const dir = resolve5(process.cwd(), entry.dir ?? ".");
1325
+ materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
1326
+ console.log(
1327
+ `
1328
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
1329
+ );
1330
+ return;
1331
+ }
1332
+ materializeAgentKit(process.cwd());
1333
+ console.log(
1334
+ "\nNote: the Greenlight Claude Code plugin (user scope) is the preferred path; this sync is the fallback.\nRun `/mcp` to authenticate the MCP servers."
1335
+ );
1336
+ }
1337
+
1135
1338
  // src/commands/add.ts
1136
1339
  function flag2(args, name) {
1137
1340
  const i = args.indexOf(name);
@@ -1143,25 +1346,25 @@ function templateDir(lane, target) {
1143
1346
  }
1144
1347
  function registerWorkspaceMember(cwd, member) {
1145
1348
  const wsPath = join(cwd, "pnpm-workspace.yaml");
1146
- if (!existsSync5(wsPath)) {
1147
- writeFileSync2(wsPath, `packages:
1349
+ if (!existsSync6(wsPath)) {
1350
+ writeFileSync3(wsPath, `packages:
1148
1351
  - "${member}"
1149
1352
  `);
1150
1353
  console.log(`\u2714 created pnpm-workspace.yaml (member ${member})`);
1151
1354
  return;
1152
1355
  }
1153
- const text = readFileSync3(wsPath, "utf8");
1356
+ const text = readFileSync4(wsPath, "utf8");
1154
1357
  if (text.includes(member) || /^\s*-\s*["']?tools\/\*/m.test(text)) return;
1155
1358
  const lines = text.split("\n");
1156
1359
  const pkgIdx = lines.findIndex((l) => /^packages\s*:/.test(l));
1157
1360
  if (pkgIdx === -1) {
1158
- writeFileSync2(wsPath, `${text.replace(/\s*$/, "")}
1361
+ writeFileSync3(wsPath, `${text.replace(/\s*$/, "")}
1159
1362
  packages:
1160
1363
  - "${member}"
1161
1364
  `);
1162
1365
  } else {
1163
1366
  lines.splice(pkgIdx + 1, 0, ` - "${member}"`);
1164
- writeFileSync2(wsPath, lines.join("\n"));
1367
+ writeFileSync3(wsPath, lines.join("\n"));
1165
1368
  }
1166
1369
  console.log(`\u2714 registered ${member} in pnpm-workspace.yaml`);
1167
1370
  }
@@ -1194,44 +1397,61 @@ async function addCommand(args) {
1194
1397
  const entry = next.tools.find((t) => t.name === name);
1195
1398
  const data = entry?.data ?? "none";
1196
1399
  const envs = entry?.envs ?? ["beta", "prod"];
1197
- const toolInfo = { target, data };
1198
- const dest = resolve5(process.cwd(), "tools", name);
1199
- if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1400
+ const toolInfo = { lane, target, data };
1401
+ const dest = resolve6(process.cwd(), "tools", name);
1402
+ if (existsSync6(dest)) throw new Error(`tools/${name} already exists`);
1200
1403
  const src = templateDir(lane, target);
1201
- if (existsSync5(src)) {
1404
+ if (existsSync6(src)) {
1202
1405
  cpSync2(src, dest, { recursive: true });
1203
1406
  const pkgPath = join(dest, "package.json");
1204
- if (existsSync5(pkgPath)) {
1205
- const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
1407
+ if (existsSync6(pkgPath)) {
1408
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
1206
1409
  pkg.name = name;
1207
- writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1410
+ writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
1208
1411
  `);
1209
1412
  }
1210
1413
  const shippedGitignore = join(dest, "gitignore");
1211
- if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1414
+ if (existsSync6(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1415
+ const wranglerPath = join(dest, "wrangler.toml");
1416
+ if (existsSync6(wranglerPath)) {
1417
+ let wt = readFileSync4(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1418
+ if (wt.includes("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID")) {
1419
+ const token = presentEnv(process.cwd()).CLOUDFLARE_API_TOKEN;
1420
+ const acct = token ? await resolveCloudflareAccountId(config.domain, token) : null;
1421
+ if (acct) {
1422
+ wt = wt.replaceAll("REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID", acct);
1423
+ console.log("\u2714 resolved the Cloudflare account id into wrangler.toml");
1424
+ } else {
1425
+ console.log(
1426
+ "\xB7 could not resolve the Cloudflare account id \u2014 set account_id in wrangler.toml"
1427
+ );
1428
+ }
1429
+ }
1430
+ writeFileSync3(wranglerPath, wt);
1431
+ }
1212
1432
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1213
- if (existsSync5(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1433
+ if (existsSync6(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1214
1434
  } else {
1215
1435
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1216
1436
  }
1217
- writeFileSync2(path, serializeConfig(next));
1437
+ writeFileSync3(path, serializeConfig(next));
1218
1438
  console.log(`\u2714 added "${name}" (${lane}/${target}) to the manifest`);
1219
1439
  const cwd = process.cwd();
1220
1440
  const providers = providersForTool(toolInfo);
1221
- const infraDir = resolve5(cwd, "infra");
1441
+ const infraDir = resolve6(cwd, "infra");
1222
1442
  const mainTf = join(infraDir, "main.tf");
1223
- if (!existsSync5(mainTf)) {
1224
- mkdirSync2(infraDir, { recursive: true });
1225
- writeFileSync2(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1443
+ if (!existsSync6(mainTf)) {
1444
+ mkdirSync3(infraDir, { recursive: true });
1445
+ writeFileSync3(mainTf, emitWrapperMainTf({ domain: config.domain, providers }));
1226
1446
  console.log("\u2714 scaffolded infra/main.tf (providers + HCP backend placeholder)");
1227
1447
  } else if (providers.some((p) => p !== "cloudflare" && p !== "github")) {
1228
1448
  console.log(`\xB7 infra/main.tf exists \u2014 ensure it declares provider(s): ${providers.join(", ")}`);
1229
1449
  }
1230
1450
  const toolTf = join(infraDir, `${name}.tf`);
1231
- if (existsSync5(toolTf)) {
1451
+ if (existsSync6(toolTf)) {
1232
1452
  console.log(`\xB7 infra/${name}.tf exists \u2014 left as-is`);
1233
1453
  } else {
1234
- writeFileSync2(
1454
+ writeFileSync3(
1235
1455
  toolTf,
1236
1456
  emitToolTf({
1237
1457
  name,
@@ -1247,6 +1467,17 @@ async function addCommand(args) {
1247
1467
  );
1248
1468
  console.log(`\u2714 wrote infra/${name}.tf (modules: ${providers.join(", ")})`);
1249
1469
  }
1470
+ if (lane === "agent") {
1471
+ const wfDir = resolve6(cwd, ".github/workflows");
1472
+ const wfPath = join(wfDir, `deploy-${name}.yml`);
1473
+ if (existsSync6(wfPath)) {
1474
+ console.log(`\xB7 .github/workflows/deploy-${name}.yml exists \u2014 left as-is`);
1475
+ } else {
1476
+ mkdirSync3(wfDir, { recursive: true });
1477
+ writeFileSync3(wfPath, emitAgentDeployWorkflow(name, config.domain));
1478
+ console.log(`\u2714 wrote .github/workflows/deploy-${name}.yml`);
1479
+ }
1480
+ }
1250
1481
  materializeAgentKit(cwd, toolInfo);
1251
1482
  const repo = flag2(args, "--repo") ?? detectRepo(cwd) ?? "";
1252
1483
  const gather = !args.includes("--no-tokens") && process.stdin.isTTY && repo !== "";
@@ -1269,8 +1500,8 @@ Next:${gather ? "" : `
1269
1500
 
1270
1501
  // src/commands/adopt.ts
1271
1502
  import { execFileSync as execFileSync2 } from "child_process";
1272
- import { cpSync as cpSync3, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
1273
- import { join as join2, resolve as resolve6 } from "path";
1503
+ import { cpSync as cpSync3, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync5, readdirSync, writeFileSync as writeFileSync4 } from "fs";
1504
+ import { join as join2, resolve as resolve7 } from "path";
1274
1505
  var REF = MODULE_REF;
1275
1506
  function flag3(args, name) {
1276
1507
  const i = args.indexOf(name);
@@ -1286,7 +1517,7 @@ function mergePackageJson(existing, repoName, vendor) {
1286
1517
  }
1287
1518
  function vendorDeps(vendorDir) {
1288
1519
  const out = {};
1289
- if (!existsSync6(vendorDir)) return out;
1520
+ if (!existsSync7(vendorDir)) return out;
1290
1521
  for (const f of readdirSync(vendorDir)) {
1291
1522
  if (!f.endsWith(".tgz")) continue;
1292
1523
  const base = f.replace(/-\d+\.\d+\.\d+(-[\w.]+)?\.tgz$/, "");
@@ -1741,12 +1972,12 @@ export default [api, ...agentWeb];
1741
1972
  `;
1742
1973
  }
1743
1974
  function writeIfAbsent(path, contents, label) {
1744
- if (existsSync6(path)) {
1975
+ if (existsSync7(path)) {
1745
1976
  console.log(`\xB7 ${label} exists \u2014 left as-is`);
1746
1977
  return;
1747
1978
  }
1748
- mkdirSync3(resolve6(path, ".."), { recursive: true });
1749
- writeFileSync3(path, contents);
1979
+ mkdirSync4(resolve7(path, ".."), { recursive: true });
1980
+ writeFileSync4(path, contents);
1750
1981
  console.log(`\u2714 ${label}`);
1751
1982
  }
1752
1983
  async function adoptCommand(args) {
@@ -1782,10 +2013,10 @@ async function adoptWrapper(ctx) {
1782
2013
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1783
2014
  const cwd = process.cwd();
1784
2015
  const toolRel = `tools/${name}`;
1785
- const dest = resolve6(cwd, toolRel);
2016
+ const dest = resolve7(cwd, toolRel);
1786
2017
  console.log(`adopting "${name}" (${lane}/${target}) as the submodule ${toolRel}
1787
2018
  `);
1788
- if (!existsSync6(dest)) {
2019
+ if (!existsSync7(dest)) {
1789
2020
  try {
1790
2021
  execFileSync2("git", ["submodule", "add", repoArg, toolRel], { cwd, stdio: "inherit" });
1791
2022
  console.log(`\u2714 git submodule add ${repoArg} ${toolRel}`);
@@ -1821,7 +2052,7 @@ async function adoptWrapper(ctx) {
1821
2052
  }
1822
2053
  } : {}
1823
2054
  });
1824
- writeFileSync3(regPath, serializeConfig(nextReg));
2055
+ writeFileSync4(regPath, serializeConfig(nextReg));
1825
2056
  console.log(
1826
2057
  `\u2714 ${existed ? "updated" : "registered"} "${name}" (external, dir ${toolRel}) in the wrapper manifest`
1827
2058
  );
@@ -1838,11 +2069,11 @@ async function adoptWrapper(ctx) {
1838
2069
  `verify/${name}.config.ts`
1839
2070
  );
1840
2071
  }
1841
- const providers = providersForTool({ target, data });
1842
- if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
2072
+ const providers = providersForTool({ lane, target, data });
2073
+ if (existsSync7(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1843
2074
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1844
2075
  }
1845
- materializeAgentKit(dest, { target, data });
2076
+ materializeAgentKit(dest, { lane, target, data });
1846
2077
  addGreenlightScript(dest);
1847
2078
  if (target === "oci") {
1848
2079
  const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
@@ -1891,9 +2122,9 @@ Next:
1891
2122
  }
1892
2123
  async function adoptStandalone(ctx) {
1893
2124
  const { name, repoArg, lane, target, data, auth, envs, domain, reg, regPath } = ctx;
1894
- const repo = resolve6(process.cwd(), repoArg);
1895
- if (!existsSync6(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
1896
- const regVendor = resolve6(process.cwd(), "vendor");
2125
+ const repo = resolve7(process.cwd(), repoArg);
2126
+ if (!existsSync7(repo)) throw new Error(`no such repo: ${repo} (--standalone needs a local path)`);
2127
+ const regVendor = resolve7(process.cwd(), "vendor");
1897
2128
  const vendor = vendorDeps(regVendor);
1898
2129
  if (Object.keys(vendor).length === 0) {
1899
2130
  throw new Error(
@@ -1911,15 +2142,15 @@ async function adoptStandalone(ctx) {
1911
2142
  );
1912
2143
  const slug = parseRepo(safeGit(repo, ["remote", "get-url", "origin"])) ?? `OWNER/${name}`;
1913
2144
  const pkgPath = join2(repo, "package.json");
1914
- const existingPkg = existsSync6(pkgPath) ? JSON.parse(readFileSync4(pkgPath, "utf8")) : null;
1915
- writeFileSync3(
2145
+ const existingPkg = existsSync7(pkgPath) ? JSON.parse(readFileSync5(pkgPath, "utf8")) : null;
2146
+ writeFileSync4(
1916
2147
  pkgPath,
1917
2148
  `${JSON.stringify(mergePackageJson(existingPkg, name, vendor), null, 2)}
1918
2149
  `
1919
2150
  );
1920
2151
  console.log("\u2714 package.json (merged framework deps + overrides)");
1921
2152
  const repoVendor = join2(repo, "vendor");
1922
- mkdirSync3(repoVendor, { recursive: true });
2153
+ mkdirSync4(repoVendor, { recursive: true });
1923
2154
  for (const f of readdirSync(regVendor)) {
1924
2155
  if (f.endsWith(".tgz")) cpSync3(join2(regVendor, f), join2(repoVendor, f));
1925
2156
  }
@@ -1940,7 +2171,7 @@ async function adoptStandalone(ctx) {
1940
2171
  ".github/workflows/greenlight-promote.yml"
1941
2172
  );
1942
2173
  writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
1943
- materializeAgentKit(repo, { target, data });
2174
+ materializeAgentKit(repo, { lane, target, data });
1944
2175
  writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
1945
2176
  writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
1946
2177
  const nextReg = addTool(reg, {
@@ -1953,7 +2184,7 @@ async function adoptStandalone(ctx) {
1953
2184
  external: true,
1954
2185
  adopted: true
1955
2186
  });
1956
- writeFileSync3(regPath, serializeConfig(nextReg));
2187
+ writeFileSync4(regPath, serializeConfig(nextReg));
1957
2188
  console.log(`\u2714 registered "${name}" in ${regPath.replace(`${process.cwd()}/`, "")} (external)`);
1958
2189
  console.log(`
1959
2190
  Next (in the adopted repo):
@@ -1966,20 +2197,20 @@ Note: deploying ${target} needs the ${target} adapter (workers is built; oci/ver
1966
2197
  }
1967
2198
  function addGreenlightScript(dir) {
1968
2199
  const pkgPath = join2(dir, "package.json");
1969
- if (!existsSync6(pkgPath)) {
2200
+ if (!existsSync7(pkgPath)) {
1970
2201
  console.log(
1971
2202
  "\xB7 no package.json (non-Node tool) \u2014 run the loop via `npx @rtrentjones/greenlight`"
1972
2203
  );
1973
2204
  return;
1974
2205
  }
1975
- const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
2206
+ const pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
1976
2207
  pkg.scripts = { ...pkg.scripts ?? {} };
1977
2208
  if (pkg.scripts.greenlight) {
1978
2209
  console.log("\xB7 package.json already has a greenlight script");
1979
2210
  return;
1980
2211
  }
1981
2212
  pkg.scripts.greenlight = "npx @rtrentjones/greenlight";
1982
- writeFileSync3(pkgPath, `${JSON.stringify(pkg, null, 2)}
2213
+ writeFileSync4(pkgPath, `${JSON.stringify(pkg, null, 2)}
1983
2214
  `);
1984
2215
  console.log("\u2714 package.json (greenlight script \u2014 runnable via npx post-publish)");
1985
2216
  }
@@ -2124,10 +2355,10 @@ async function deployCommand(args) {
2124
2355
  // src/commands/doctor.ts
2125
2356
  import { execFileSync as execFileSync4 } from "child_process";
2126
2357
  import { lookup } from "dns/promises";
2127
- import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync2 } from "fs";
2358
+ import { existsSync as existsSync8, readFileSync as readFileSync6, readdirSync as readdirSync2 } from "fs";
2128
2359
  import { join as join4 } from "path";
2129
2360
  function dirCheck(label, dir) {
2130
- return existsSync7(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2361
+ return existsSync8(dir) ? { name: `${label}: directory`, status: "ok" } : { name: `${label}: directory`, status: "fail", detail: `missing ${dir}` };
2131
2362
  }
2132
2363
  function conformanceChecks(t, root) {
2133
2364
  const out = [];
@@ -2137,7 +2368,7 @@ function conformanceChecks(t, root) {
2137
2368
  join4(toolDir, `verify/${t.name}.config.ts`),
2138
2369
  join4(toolDir, "verify.config.ts")
2139
2370
  ] : [join4(toolDir, "verify.config.ts")];
2140
- const found = specCandidates.find((p) => existsSync7(join4(root, p)));
2371
+ const found = specCandidates.find((p) => existsSync8(join4(root, p)));
2141
2372
  out.push({
2142
2373
  name: `${t.name}: in the verify loop`,
2143
2374
  status: found ? "ok" : "warn",
@@ -2163,14 +2394,14 @@ function conformanceChecks(t, root) {
2163
2394
  }
2164
2395
  if (!t.external && t.lane === "next" && t.target === "vercel") {
2165
2396
  const wsPath = join4(root, "pnpm-workspace.yaml");
2166
- const ws = existsSync7(wsPath) ? readFileSync5(wsPath, "utf8") : "";
2397
+ const ws = existsSync8(wsPath) ? readFileSync6(wsPath, "utf8") : "";
2167
2398
  const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
2168
2399
  out.push({
2169
2400
  name: `${t.name}: pnpm workspace member`,
2170
2401
  status: member ? "ok" : "warn",
2171
2402
  detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
2172
2403
  });
2173
- const hasVercelJson = existsSync7(join4(root, toolDir, "vercel.json"));
2404
+ const hasVercelJson = existsSync8(join4(root, toolDir, "vercel.json"));
2174
2405
  out.push({
2175
2406
  name: `${t.name}: vercel.json framework`,
2176
2407
  status: hasVercelJson ? "ok" : "warn",
@@ -2184,7 +2415,7 @@ function versionDriftCheck(root) {
2184
2415
  let installed;
2185
2416
  try {
2186
2417
  const pkg = JSON.parse(
2187
- readFileSync5(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2418
+ readFileSync6(join4(root, "node_modules/@rtrentjones/greenlight/package.json"), "utf8")
2188
2419
  );
2189
2420
  installed = pkg.version;
2190
2421
  } catch {
@@ -2192,7 +2423,7 @@ function versionDriftCheck(root) {
2192
2423
  const refs = /* @__PURE__ */ new Set();
2193
2424
  try {
2194
2425
  for (const f of readdirSync2(join4(root, "infra")).filter((f2) => f2.endsWith(".tf"))) {
2195
- const body = readFileSync5(join4(root, "infra", f), "utf8");
2426
+ const body = readFileSync6(join4(root, "infra", f), "utf8");
2196
2427
  for (const m of body.matchAll(/greenlight\.git\/\/infra\/modules\/[^?"]+\?ref=(v[0-9.]+)/g)) {
2197
2428
  if (m[1]) refs.add(m[1]);
2198
2429
  }
@@ -2248,7 +2479,7 @@ function runDoctor(config, root) {
2248
2479
  checks.push({ name: `${t.name}: external (registry)`, status: "ok", detail: url });
2249
2480
  if (t.dir) {
2250
2481
  checks.push(
2251
- existsSync7(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2482
+ existsSync8(join4(root, t.dir)) ? { name: `${t.name}: dir present`, status: "ok", detail: t.dir } : {
2252
2483
  name: `${t.name}: dir present`,
2253
2484
  status: "warn",
2254
2485
  detail: `declared dir "${t.dir}" missing \u2014 run \`git submodule update --init\``
@@ -2342,89 +2573,6 @@ ${failed === 0 ? "\u2714 no failures" : `\u2718 ${failed} failure(s)`}`);
2342
2573
  import { existsSync as existsSync9, mkdirSync as mkdirSync5, writeFileSync as writeFileSync5 } from "fs";
2343
2574
  import { resolve as resolve8 } from "path";
2344
2575
  import { createInterface as createInterface3 } from "readline/promises";
2345
-
2346
- // src/tokens.ts
2347
- import { existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
2348
- import { resolve as resolve7 } from "path";
2349
- import { createInterface as createInterface2 } from "readline/promises";
2350
- var SECRETS_DIR = ".greenlight";
2351
- var SECRETS_FILE = "secrets.env";
2352
- function presentEnv(cwd) {
2353
- const out = {};
2354
- const p = resolve7(cwd, SECRETS_DIR, SECRETS_FILE);
2355
- if (existsSync8(p)) {
2356
- for (const { key, value } of parseSecretsEnv(readFileSync6(p, "utf8"))) out[key] = value;
2357
- }
2358
- for (const [k, v] of Object.entries(process.env)) {
2359
- if (v !== void 0 && !(k in out)) out[k] = v;
2360
- }
2361
- return out;
2362
- }
2363
- function upsertSecret(cwd, key, value) {
2364
- const dir = resolve7(cwd, SECRETS_DIR);
2365
- mkdirSync4(dir, { recursive: true });
2366
- const p = resolve7(dir, SECRETS_FILE);
2367
- const lines = existsSync8(p) ? readFileSync6(p, "utf8").split("\n") : [];
2368
- const idx = lines.findIndex((l) => l.startsWith(`${key}=`));
2369
- if (idx >= 0) lines[idx] = `${key}=${value}`;
2370
- else {
2371
- while (lines.length && (lines[lines.length - 1] ?? "").trim() === "") lines.pop();
2372
- lines.push(`${key}=${value}`);
2373
- }
2374
- writeFileSync4(p, `${lines.join("\n").replace(/\n*$/, "")}
2375
- `, { mode: 384 });
2376
- }
2377
- async function ensureTokensForTool(cwd, tool, opts = {}) {
2378
- const doVerify = opts.verify !== false;
2379
- const interactive = Boolean(process.stdin.isTTY);
2380
- const env = presentEnv(cwd);
2381
- const results = [];
2382
- const rl = interactive ? createInterface2({ input: process.stdin, output: process.stdout }) : null;
2383
- try {
2384
- for (const spec of tokensForTool(tool)) {
2385
- let value = env[spec.envVar];
2386
- if (value) {
2387
- results.push({ envVar: spec.envVar, outcome: "present" });
2388
- } else if (rl) {
2389
- console.log(`
2390
- ${spec.envVar} \u2014 ${spec.label}`);
2391
- if (spec.scopes?.length) console.log(` scopes: ${spec.scopes.join(", ")}`);
2392
- const entered = (await rl.question(` paste value${spec.optional ? " (optional, Enter to skip)" : ""}: `)).trim();
2393
- if (!entered) {
2394
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2395
- continue;
2396
- }
2397
- upsertSecret(cwd, spec.envVar, entered);
2398
- env[spec.envVar] = entered;
2399
- value = entered;
2400
- results.push({ envVar: spec.envVar, outcome: "entered" });
2401
- } else {
2402
- results.push({ envVar: spec.envVar, outcome: spec.optional ? "skipped" : "missing" });
2403
- continue;
2404
- }
2405
- if (value && doVerify && spec.verify) {
2406
- let check;
2407
- try {
2408
- check = await spec.verify(value, env);
2409
- } catch (e) {
2410
- check = { ok: false, detail: e instanceof Error ? e.message : String(e) };
2411
- }
2412
- const last = results[results.length - 1];
2413
- if (last) last.verify = check;
2414
- if (!check.ok && !spec.optional) {
2415
- throw new Error(
2416
- `${spec.envVar} failed verification${check.detail ? ` (${check.detail})` : ""} \u2014 check the token's scopes (${spec.label}).`
2417
- );
2418
- }
2419
- }
2420
- }
2421
- } finally {
2422
- rl?.close();
2423
- }
2424
- return results;
2425
- }
2426
-
2427
- // src/commands/init.ts
2428
2576
  function flag5(args, name) {
2429
2577
  const i = args.indexOf(name);
2430
2578
  return i >= 0 ? args[i + 1] : void 0;
@@ -2674,6 +2822,13 @@ function defaultSpec(lane) {
2674
2822
  return { mode: "api", checks: [{ path: "/", status: 200 }] };
2675
2823
  case "mcp":
2676
2824
  return { mode: "mcp", expectTools: [] };
2825
+ case "agent":
2826
+ return {
2827
+ mode: "api",
2828
+ checks: [{ path: "/status", status: 200 }],
2829
+ settleRetries: 6,
2830
+ settleMs: 5e3
2831
+ };
2677
2832
  }
2678
2833
  }
2679
2834
  function printReport(report) {
@@ -13,7 +13,7 @@ import { createJiti } from "jiti";
13
13
 
14
14
  // ../packages/shared/src/schema.ts
15
15
  import { z } from "zod";
16
- var LaneEnum = z.enum(["astro", "next", "mcp"]);
16
+ var LaneEnum = z.enum(["astro", "next", "mcp", "agent"]);
17
17
  var TargetEnum = z.enum(["workers", "vercel", "oci"]);
18
18
  var DataEnum = z.enum(["none", "d1", "kv", "supabase", "neon"]);
19
19
  var AuthEnum = z.enum(["none", "bearer", "oauth"]);
@@ -22,7 +22,8 @@ var EnvEnum = z.enum(["preview", "beta", "prod"]);
22
22
  var MATRIX = {
23
23
  astro: { targets: ["workers"], data: ["none", "d1", "kv"] },
24
24
  next: { targets: ["vercel"], data: ["none", "supabase", "neon"] },
25
- mcp: { targets: ["workers", "oci"], data: ["none"] }
25
+ mcp: { targets: ["workers", "oci"], data: ["none"] },
26
+ agent: { targets: ["workers"], data: ["none", "kv"] }
26
27
  };
27
28
  var ToolSchema = z.object({
28
29
  name: z.string().regex(/^[a-z][a-z0-9-]*$/, "tool name must be kebab-case starting with a letter"),
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import {
2
2
  defineConfig,
3
3
  defineVerify,
4
4
  loadConfig
5
- } from "./chunk-HMU7D7R2.js";
5
+ } from "./chunk-P6FRYOOV.js";
6
6
  import "./chunk-HX7VA25D.js";
7
7
  import "./chunk-N3IKUCSF.js";
8
8
  import "./chunk-KP3Y6WRU.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtrentjones/greenlight",
3
- "version": "0.2.29",
3
+ "version": "0.3.1",
4
4
  "description": "Greenlight CLI — setup and lifecycle for the harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,10 +31,10 @@
31
31
  "@anthropic-ai/sdk": "^0.69.0"
32
32
  },
33
33
  "devDependencies": {
34
- "@rtrentjones/greenlight-adapters": "0.2.29",
35
- "@rtrentjones/greenlight-loop": "0.2.29",
36
- "@rtrentjones/greenlight-verify": "0.2.29",
37
- "@rtrentjones/greenlight-shared": "0.2.29"
34
+ "@rtrentjones/greenlight-adapters": "0.3.1",
35
+ "@rtrentjones/greenlight-loop": "0.3.1",
36
+ "@rtrentjones/greenlight-shared": "0.3.1",
37
+ "@rtrentjones/greenlight-verify": "0.3.1"
38
38
  },
39
39
  "scripts": {
40
40
  "build": "node scripts/copy-assets.mjs && tsup",
@@ -0,0 +1,33 @@
1
+ # agent — autonomous Gemini-backed Worker
2
+
3
+ A cron-triggered Cloudflare Worker that calls **Gemini** (free tier), stores the result in **KV**,
4
+ and serves it. Scaffolded by `greenlight add <name> --lane agent --target workers --data kv`.
5
+
6
+ ## What it does
7
+
8
+ - `scheduled` (cron, daily) → prompt Gemini → store the text + metadata in KV.
9
+ - `GET /` — the latest output (plain text).
10
+ - `GET /status` — `{ ok, lastRun, model, preview }` (the verify target).
11
+ - `POST /run` — force a run; **bearer-gated** (`Authorization: Bearer $RUN_TOKEN`).
12
+
13
+ ## Setup (once)
14
+
15
+ `greenlight add` already rewrote the worker `name` + route domain in `wrangler.toml`. Then:
16
+
17
+ 1. **KV namespace** — `pnpm exec wrangler kv namespace create STATE`, and paste the id into the
18
+ three `id = "REPLACE_WITH_KV_NAMESPACE_ID"` slots in `wrangler.toml`.
19
+ 2. **Secrets** (per env — the key never goes in the repo):
20
+ - `pnpm exec wrangler secret put GEMINI_API_KEY --env prod` — your Google AI Studio key (free,
21
+ `aistudio.google.com/apikey`).
22
+ - `pnpm exec wrangler secret put RUN_TOKEN --env prod` — any random string.
23
+ 3. **Deploy → seed → verify**:
24
+ - `pnpm greenlight deploy <name>` (or `pnpm exec wrangler deploy --env prod`).
25
+ - `curl -XPOST https://<name>.<domain>/run -H "Authorization: Bearer $RUN_TOKEN"` (seed the first run).
26
+ - `pnpm greenlight verify <name> --env prod`.
27
+
28
+ ## Free-tier safety
29
+
30
+ The daily cron is ~1 Gemini call/day (the free tier allows ~1500/day). `/run` is bearer-gated so it
31
+ can't be used to burn the quota. The key lives **only** as a Worker secret.
32
+
33
+ See the `provider-gemini` skill + [docs/agents-plan.md](../../docs/agents-plan.md).
@@ -0,0 +1,5 @@
1
+ # Shipped as `gitignore` (no dot) so it survives the npm tarball; `greenlight add` renames it to
2
+ # `.gitignore` on scaffold.
3
+ node_modules/
4
+ .wrangler/
5
+ dist/
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "_template-agent",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "wrangler dev",
7
+ "deploy": "wrangler deploy"
8
+ },
9
+ "devDependencies": {
10
+ "@cloudflare/workers-types": "^4.20250601.0",
11
+ "typescript": "^5.6.0",
12
+ "wrangler": "^3.90.0"
13
+ }
14
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Agent Worker — autonomous, cron-triggered, Gemini-backed.
3
+ *
4
+ * On the cron (`scheduled`) it asks Gemini for content and stores it in KV. The `fetch` handler
5
+ * serves the latest output (`GET /`), reports last-run metadata (`GET /status` — the verify
6
+ * target), and force-runs (`POST /run`, bearer-gated so it can't be used to burn the free-tier
7
+ * quota; the deploy step uses it to seed the first output before verify).
8
+ *
9
+ * GEMINI_API_KEY + RUN_TOKEN are Worker secrets (`wrangler secret put …`), never in wrangler.toml.
10
+ */
11
+
12
+ export interface Env {
13
+ STATE: KVNamespace;
14
+ GEMINI_API_KEY: string;
15
+ RUN_TOKEN?: string;
16
+ MODEL: string;
17
+ GREENLIGHT_ENV: string;
18
+ }
19
+
20
+ interface AgentRecord {
21
+ ok: true;
22
+ text: string;
23
+ lastRun: string;
24
+ model: string;
25
+ }
26
+
27
+ const PROMPT =
28
+ 'In one or two sentences, share a single concrete, non-obvious tip about deploying software ' +
29
+ 'safely. Vary the topic each time. Plain text, no preamble, no markdown.';
30
+
31
+ async function callGemini(env: Env): Promise<string> {
32
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${env.MODEL}:generateContent?key=${env.GEMINI_API_KEY}`;
33
+ const res = await fetch(url, {
34
+ method: 'POST',
35
+ headers: { 'content-type': 'application/json' },
36
+ body: JSON.stringify({ contents: [{ parts: [{ text: PROMPT }] }] }),
37
+ });
38
+ if (!res.ok) throw new Error(`Gemini HTTP ${res.status}`);
39
+ const data = (await res.json()) as {
40
+ candidates?: { content?: { parts?: { text?: string }[] } }[];
41
+ };
42
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text?.trim();
43
+ if (!text) throw new Error('Gemini returned no text');
44
+ return text;
45
+ }
46
+
47
+ function key(env: Env): string {
48
+ return `${env.GREENLIGHT_ENV}:latest`;
49
+ }
50
+
51
+ async function runOnce(env: Env): Promise<AgentRecord> {
52
+ const rec: AgentRecord = {
53
+ ok: true,
54
+ text: await callGemini(env),
55
+ lastRun: new Date().toISOString(),
56
+ model: env.MODEL,
57
+ };
58
+ await env.STATE.put(key(env), JSON.stringify(rec));
59
+ return rec;
60
+ }
61
+
62
+ async function latest(env: Env): Promise<AgentRecord | null> {
63
+ const raw = await env.STATE.get(key(env));
64
+ return raw ? (JSON.parse(raw) as AgentRecord) : null;
65
+ }
66
+
67
+ export default {
68
+ async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> {
69
+ ctx.waitUntil(runOnce(env));
70
+ },
71
+
72
+ async fetch(req: Request, env: Env): Promise<Response> {
73
+ const { pathname } = new URL(req.url);
74
+
75
+ if (req.method === 'POST' && pathname === '/run') {
76
+ const auth = req.headers.get('authorization');
77
+ if (!env.RUN_TOKEN || auth !== `Bearer ${env.RUN_TOKEN}`) {
78
+ return new Response('unauthorized', { status: 401 });
79
+ }
80
+ return Response.json(await runOnce(env));
81
+ }
82
+
83
+ if (pathname === '/status') {
84
+ const rec = await latest(env);
85
+ return Response.json({
86
+ ok: Boolean(rec),
87
+ lastRun: rec?.lastRun ?? null,
88
+ model: env.MODEL,
89
+ preview: rec ? rec.text.slice(0, 80) : null,
90
+ });
91
+ }
92
+
93
+ const rec = await latest(env);
94
+ if (!rec) return new Response('No run yet — POST /run to seed.\n', { status: 200 });
95
+ return new Response(`${rec.text}\n`, {
96
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
97
+ });
98
+ },
99
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2022",
4
+ "module": "es2022",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["es2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["src"]
13
+ }
@@ -0,0 +1,8 @@
1
+ // The agent exposes GET /status with last-run metadata. The deploy step seeds a run (POST /run)
2
+ // before verify, so /status reports ok:true — assert that. settle absorbs Cloudflare propagation.
3
+ export default {
4
+ mode: 'api',
5
+ checks: [{ path: '/status', status: 200, contains: '"ok":true' }],
6
+ settleRetries: 6,
7
+ settleMs: 5000,
8
+ };
@@ -0,0 +1,49 @@
1
+ # Agent Worker. `greenlight add` rewrites `name` + the route domain + the account_id, and emits a
2
+ # .github/workflows/deploy-<name>.yml that (on push to main) creates the KV namespace, deploys, sets
3
+ # the GEMINI_API_KEY + RUN_TOKEN Worker secrets from GitHub secrets, seeds, and verifies. So you only
4
+ # add those two GitHub secrets — no manual wrangler.
5
+ name = "agent-tool"
6
+ # Non-secret account id (committed config). `greenlight add` resolves + fills this from your domain's
7
+ # zone; without it wrangler calls /memberships, which a scoped API token can't do.
8
+ account_id = "REPLACE_WITH_CLOUDFLARE_ACCOUNT_ID"
9
+ main = "src/index.ts"
10
+ compatibility_date = "2025-06-01"
11
+
12
+ # Daily 13:00 UTC — far under the Gemini free-tier quota (~1500 req/day). Edit to taste.
13
+ [triggers]
14
+ crons = ["0 13 * * *"]
15
+
16
+ [vars]
17
+ MODEL = "gemini-2.5-flash"
18
+ GREENLIGHT_ENV = "prod"
19
+
20
+ # Output store. `wrangler kv namespace create STATE` → paste the id here (and per-env below).
21
+ [[kv_namespaces]]
22
+ binding = "STATE"
23
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"
24
+
25
+ # Named environments (the deploy uses `wrangler deploy --env prod|beta`). Wrangler envs do NOT
26
+ # inherit top-level config, so vars + kv + routes are repeated per env.
27
+ [env.prod]
28
+ name = "agent-tool"
29
+ routes = [{ pattern = "agent-tool.example.dev", custom_domain = true }]
30
+
31
+ [env.prod.vars]
32
+ MODEL = "gemini-2.5-flash"
33
+ GREENLIGHT_ENV = "prod"
34
+
35
+ [[env.prod.kv_namespaces]]
36
+ binding = "STATE"
37
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"
38
+
39
+ [env.beta]
40
+ name = "agent-tool-beta"
41
+ routes = [{ pattern = "beta.agent-tool.example.dev", custom_domain = true }]
42
+
43
+ [env.beta.vars]
44
+ MODEL = "gemini-2.5-flash"
45
+ GREENLIGHT_ENV = "beta"
46
+
47
+ [[env.beta.kv_namespaces]]
48
+ binding = "STATE"
49
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"