@rtrentjones/greenlight 0.2.28 → 0.3.0

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,70 @@
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 — wrangler (workers target)
33
+
34
+ Like the astro blog: cron + KV + secret + `custom_domain` in `wrangler.toml`; no Terraform.
35
+
36
+ ```toml
37
+ [triggers]
38
+ crons = ["0 13 * * *"] # daily; stays far under the free-tier quota
39
+ [[kv_namespaces]]
40
+ binding = "STATE"
41
+ [[routes]]
42
+ pattern = "<name>.<domain>"
43
+ custom_domain = true
44
+ ```
45
+
46
+ ## Surface
47
+
48
+ | route | purpose |
49
+ |---|---|
50
+ | `scheduled()` | the cron: prompt Gemini → `STATE.put(today, text + metadata)` |
51
+ | `GET /` | the latest output (public, read-only) |
52
+ | `GET /status` | `{ ok, lastRun, model, preview }` — the **api-mode verify** target |
53
+ | `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 |
54
+
55
+ ## Verify — `api` mode on `/status`
56
+
57
+ `verify.config.ts` hits `/status` and asserts `ok: true` + a recent run. (Output *quality* is a
58
+ future `eval` mode — LLM-judged.) Because the first cron may not have fired at deploy time, the
59
+ deploy step `POST /run`s once to seed, then verifies.
60
+
61
+ ## Safety envelope
62
+
63
+ - **Low-stakes / read-only** first agents (generate → store → serve; no destructive external actions).
64
+ - **Bearer on `/run`**; the cron frequency stays far under the free-tier daily limit.
65
+ - Key is **secret-only** (a Worker secret), never committed or echoed.
66
+
67
+ ## No keepalive
68
+
69
+ An agent needs no keepalive — the cron *is* its heartbeat and the Worker is always-available
70
+ (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,7 +15,7 @@ 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, writeFileSync as writeFileSync2 } from "fs";
18
+ import { cpSync as cpSync2, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync, writeFileSync as writeFileSync2 } from "fs";
19
19
  import { join, resolve as resolve5 } from "path";
20
20
 
21
21
  // src/asset-paths.ts
@@ -351,6 +351,29 @@ var PACKS = [
351
351
  skill: "provider-neon",
352
352
  tfModules: ["neon"]
353
353
  },
354
+ {
355
+ id: "gemini",
356
+ name: "Google Gemini (free tier)",
357
+ // The LLM behind the `agent` lane. (A future `llm` axis would generalize this beyond agents.)
358
+ appliesTo: (t) => t.lane === "agent",
359
+ guide: "docs/provider-tokens.md \u2014 GEMINI_API_KEY (Google AI Studio, free tier, no billing)",
360
+ setupUrl: "https://aistudio.google.com/apikey",
361
+ tokens: [
362
+ {
363
+ // The agent Worker's LLM credential. Free tier (no billing / no card); set as a Cloudflare
364
+ // Worker secret, never in the repo. One key serves every agent (shared, not per-tool).
365
+ envVar: "GEMINI_API_KEY",
366
+ label: "Google AI Studio API key (Gemini free tier)",
367
+ verify: async (t) => {
368
+ const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${t}`);
369
+ return { ok: okStatus(r), detail: `HTTP ${r.status}` };
370
+ }
371
+ }
372
+ ],
373
+ skill: "provider-gemini"
374
+ // No tfModules: the agent Worker (cron + KV + secret + custom_domain) deploys via wrangler,
375
+ // like the astro blog — KV/DNS are wrangler-managed for the workers target.
376
+ },
354
377
  {
355
378
  id: "hcp",
356
379
  name: "HCP Terraform (remote state)",
@@ -478,7 +501,7 @@ function tokensForTool(tool) {
478
501
  }
479
502
 
480
503
  // src/version.ts
481
- var MODULE_REF = "v0.2.28";
504
+ var MODULE_REF = "v0.3.0";
482
505
  var MODULE_SOURCE_BASE = "git::https://github.com/RTrentJones/greenlight.git//infra/modules";
483
506
  function moduleSource(module, ref = MODULE_REF) {
484
507
  return `${MODULE_SOURCE_BASE}/${module}?ref=${ref}`;
@@ -488,6 +511,14 @@ function moduleSource(module, ref = MODULE_REF) {
488
511
  var hcl = (s) => s.replace(/\n{3,}/g, "\n\n").trimEnd();
489
512
  function emitToolTf(opts) {
490
513
  const { name, domain, lane, target, data, envs, ref = MODULE_REF } = opts;
514
+ if (lane === "agent") {
515
+ const suffix = data && data !== "none" ? `/${data}` : "";
516
+ return `# ${name} \u2014 agent/${target}${suffix}, emitted by \`greenlight add\`.
517
+ # Wrangler-managed: the Worker (cron + KV + custom_domain route + GEMINI_API_KEY/RUN_TOKEN
518
+ # secrets) deploys via \`wrangler deploy\` from tools/${name}/. No Terraform here \u2014 see that
519
+ # wrangler.toml + the provider-gemini skill + docs/agents-plan.md.
520
+ `;
521
+ }
491
522
  const port = opts.port ?? 8e3;
492
523
  const slug = opts.slug ?? `OWNER/${name}`;
493
524
  const useSupabase = data === "supabase";
@@ -875,10 +906,10 @@ async function agentCommand(args) {
875
906
  const { config } = await loadManifest();
876
907
  const entry = resolveEntry(config, name);
877
908
  const dir = resolve3(process.cwd(), entry.dir ?? ".");
878
- materializeAgentKit(dir, { target: entry.target, data: entry.data });
909
+ materializeAgentKit(dir, { lane: entry.lane, target: entry.target, data: entry.data });
879
910
  console.log(
880
911
  `
881
- Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (target=${entry.target}, data=${entry.data}).`
912
+ Synced the kit for "${name}" \u2192 ${entry.dir ?? "."} (lane=${entry.lane}, target=${entry.target}, data=${entry.data}).`
882
913
  );
883
914
  return;
884
915
  }
@@ -1035,7 +1066,7 @@ function setGitHubSecret(repo, env, key, value) {
1035
1066
  async function gatherSecrets(name, repo, env, prefill) {
1036
1067
  const { config } = await loadManifest();
1037
1068
  const entry = resolveEntry(config, name);
1038
- const packs = packsForTool({ target: entry.target, data: entry.data });
1069
+ const packs = packsForTool({ lane: entry.lane, target: entry.target, data: entry.data });
1039
1070
  const dest = env ? `env "${env}" of ${repo}` : repo;
1040
1071
  const existing = listGitHubSecrets(repo, env);
1041
1072
  console.log(`Gathering secrets for "${name}" \u2192 GitHub ${dest}`);
@@ -1141,6 +1172,30 @@ function templateDir(lane, target) {
1141
1172
  const base = join(templatesRoot(), `_template-${lane}`);
1142
1173
  return lane === "mcp" ? join(base, target) : base;
1143
1174
  }
1175
+ function registerWorkspaceMember(cwd, member) {
1176
+ const wsPath = join(cwd, "pnpm-workspace.yaml");
1177
+ if (!existsSync5(wsPath)) {
1178
+ writeFileSync2(wsPath, `packages:
1179
+ - "${member}"
1180
+ `);
1181
+ console.log(`\u2714 created pnpm-workspace.yaml (member ${member})`);
1182
+ return;
1183
+ }
1184
+ const text = readFileSync3(wsPath, "utf8");
1185
+ if (text.includes(member) || /^\s*-\s*["']?tools\/\*/m.test(text)) return;
1186
+ const lines = text.split("\n");
1187
+ const pkgIdx = lines.findIndex((l) => /^packages\s*:/.test(l));
1188
+ if (pkgIdx === -1) {
1189
+ writeFileSync2(wsPath, `${text.replace(/\s*$/, "")}
1190
+ packages:
1191
+ - "${member}"
1192
+ `);
1193
+ } else {
1194
+ lines.splice(pkgIdx + 1, 0, ` - "${member}"`);
1195
+ writeFileSync2(wsPath, lines.join("\n"));
1196
+ }
1197
+ console.log(`\u2714 registered ${member} in pnpm-workspace.yaml`);
1198
+ }
1144
1199
  async function addCommand(args) {
1145
1200
  const name = args[0];
1146
1201
  if (!name || name.startsWith("-")) {
@@ -1170,7 +1225,7 @@ async function addCommand(args) {
1170
1225
  const entry = next.tools.find((t) => t.name === name);
1171
1226
  const data = entry?.data ?? "none";
1172
1227
  const envs = entry?.envs ?? ["beta", "prod"];
1173
- const toolInfo = { target, data };
1228
+ const toolInfo = { lane, target, data };
1174
1229
  const dest = resolve5(process.cwd(), "tools", name);
1175
1230
  if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
1176
1231
  const src = templateDir(lane, target);
@@ -1183,7 +1238,15 @@ async function addCommand(args) {
1183
1238
  writeFileSync2(pkgPath, `${JSON.stringify(pkg, null, 2)}
1184
1239
  `);
1185
1240
  }
1241
+ const shippedGitignore = join(dest, "gitignore");
1242
+ if (existsSync5(shippedGitignore)) renameSync(shippedGitignore, join(dest, ".gitignore"));
1243
+ const wranglerPath = join(dest, "wrangler.toml");
1244
+ if (existsSync5(wranglerPath)) {
1245
+ const wt = readFileSync3(wranglerPath, "utf8").replaceAll("agent-tool", name).replaceAll("example.dev", config.domain);
1246
+ writeFileSync2(wranglerPath, wt);
1247
+ }
1186
1248
  console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
1249
+ if (existsSync5(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
1187
1250
  } else {
1188
1251
  console.log(`! no template at ${src} \u2014 manifest entry added without scaffolding`);
1189
1252
  }
@@ -1811,11 +1874,11 @@ async function adoptWrapper(ctx) {
1811
1874
  `verify/${name}.config.ts`
1812
1875
  );
1813
1876
  }
1814
- const providers = providersForTool({ target, data });
1877
+ const providers = providersForTool({ lane, target, data });
1815
1878
  if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
1816
1879
  console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
1817
1880
  }
1818
- materializeAgentKit(dest, { target, data });
1881
+ materializeAgentKit(dest, { lane, target, data });
1819
1882
  addGreenlightScript(dest);
1820
1883
  if (target === "oci") {
1821
1884
  const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
@@ -1913,7 +1976,7 @@ async function adoptStandalone(ctx) {
1913
1976
  ".github/workflows/greenlight-promote.yml"
1914
1977
  );
1915
1978
  writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
1916
- materializeAgentKit(repo, { target, data });
1979
+ materializeAgentKit(repo, { lane, target, data });
1917
1980
  writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
1918
1981
  writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
1919
1982
  const nextReg = addTool(reg, {
@@ -2134,6 +2197,22 @@ function conformanceChecks(t, root) {
2134
2197
  detail: generic.length ? `not tool-scoped (should contain ${tag}): ${generic.join(", ")}` : `${declared.length} scoped secret name(s)`
2135
2198
  });
2136
2199
  }
2200
+ if (!t.external && t.lane === "next" && t.target === "vercel") {
2201
+ const wsPath = join4(root, "pnpm-workspace.yaml");
2202
+ const ws = existsSync7(wsPath) ? readFileSync5(wsPath, "utf8") : "";
2203
+ const member = ws.includes(toolDir) || /^\s*-\s*["']?tools\/\*/m.test(ws);
2204
+ out.push({
2205
+ name: `${t.name}: pnpm workspace member`,
2206
+ status: member ? "ok" : "warn",
2207
+ detail: member ? void 0 : `add "${toolDir}" to pnpm-workspace.yaml \u2014 else Vercel's root install skips its deps`
2208
+ });
2209
+ const hasVercelJson = existsSync7(join4(root, toolDir, "vercel.json"));
2210
+ out.push({
2211
+ name: `${t.name}: vercel.json framework`,
2212
+ status: hasVercelJson ? "ok" : "warn",
2213
+ detail: hasVercelJson ? void 0 : `no ${join4(toolDir, "vercel.json")} (framework: "nextjs") \u2014 Vercel may treat the build as static`
2214
+ });
2215
+ }
2137
2216
  return out;
2138
2217
  }
2139
2218
  function versionDriftCheck(root) {
@@ -2631,6 +2710,13 @@ function defaultSpec(lane) {
2631
2710
  return { mode: "api", checks: [{ path: "/", status: 200 }] };
2632
2711
  case "mcp":
2633
2712
  return { mode: "mcp", expectTools: [] };
2713
+ case "agent":
2714
+ return {
2715
+ mode: "api",
2716
+ checks: [{ path: "/status", status: 200 }],
2717
+ settleRetries: 6,
2718
+ settleMs: 5e3
2719
+ };
2634
2720
  }
2635
2721
  }
2636
2722
  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.28",
3
+ "version": "0.3.0",
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.28",
35
- "@rtrentjones/greenlight-loop": "0.2.28",
36
- "@rtrentjones/greenlight-verify": "0.2.28",
37
- "@rtrentjones/greenlight-shared": "0.2.28"
34
+ "@rtrentjones/greenlight-adapters": "0.3.0",
35
+ "@rtrentjones/greenlight-loop": "0.3.0",
36
+ "@rtrentjones/greenlight-verify": "0.3.0",
37
+ "@rtrentjones/greenlight-shared": "0.3.0"
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,47 @@
1
+ # Agent Worker. `greenlight add` rewrites `name` (agent-tool → your tool) + the route domain
2
+ # (example.dev → your domain). You still set the KV namespace id (create it once with
3
+ # `wrangler kv namespace create STATE`) and the secrets:
4
+ # wrangler secret put GEMINI_API_KEY --env prod (Google AI Studio key — free tier)
5
+ # wrangler secret put RUN_TOKEN --env prod (any random string; bearer for POST /run)
6
+ name = "agent-tool"
7
+ main = "src/index.ts"
8
+ compatibility_date = "2025-06-01"
9
+
10
+ # Daily 13:00 UTC — far under the Gemini free-tier quota (~1500 req/day). Edit to taste.
11
+ [triggers]
12
+ crons = ["0 13 * * *"]
13
+
14
+ [vars]
15
+ MODEL = "gemini-2.5-flash"
16
+ GREENLIGHT_ENV = "prod"
17
+
18
+ # Output store. `wrangler kv namespace create STATE` → paste the id here (and per-env below).
19
+ [[kv_namespaces]]
20
+ binding = "STATE"
21
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"
22
+
23
+ # Named environments (the deploy uses `wrangler deploy --env prod|beta`). Wrangler envs do NOT
24
+ # inherit top-level config, so vars + kv + routes are repeated per env.
25
+ [env.prod]
26
+ name = "agent-tool"
27
+ routes = [{ pattern = "agent-tool.example.dev", custom_domain = true }]
28
+
29
+ [env.prod.vars]
30
+ MODEL = "gemini-2.5-flash"
31
+ GREENLIGHT_ENV = "prod"
32
+
33
+ [[env.prod.kv_namespaces]]
34
+ binding = "STATE"
35
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"
36
+
37
+ [env.beta]
38
+ name = "agent-tool-beta"
39
+ routes = [{ pattern = "beta.agent-tool.example.dev", custom_domain = true }]
40
+
41
+ [env.beta.vars]
42
+ MODEL = "gemini-2.5-flash"
43
+ GREENLIGHT_ENV = "beta"
44
+
45
+ [[env.beta.kv_namespaces]]
46
+ binding = "STATE"
47
+ id = "REPLACE_WITH_KV_NAMESPACE_ID"
@@ -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. Next.js build output + generated types (regenerated on every build).
3
+ .next/
4
+ next-env.d.ts
5
+ node_modules/
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs"
4
+ }