@rtrentjones/greenlight 0.2.29 → 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.
- package/assets/skills/provider-gemini/SKILL.md +70 -0
- package/dist/bin.js +52 -9
- package/dist/{chunk-HMU7D7R2.js → chunk-P6FRYOOV.js} +3 -2
- package/dist/index.js +1 -1
- package/package.json +5 -5
- package/templates/_template-agent/README.md +33 -0
- package/templates/_template-agent/gitignore +5 -0
- package/templates/_template-agent/package.json +14 -0
- package/templates/_template-agent/src/index.ts +99 -0
- package/templates/_template-agent/tsconfig.json +13 -0
- package/templates/_template-agent/verify.config.ts +8 -0
- package/templates/_template-agent/wrangler.toml +47 -0
|
@@ -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-
|
|
9
|
+
} from "./chunk-P6FRYOOV.js";
|
|
10
10
|
import "./chunk-HX7VA25D.js";
|
|
11
11
|
import "./chunk-N3IKUCSF.js";
|
|
12
12
|
import "./chunk-KP3Y6WRU.js";
|
|
@@ -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.
|
|
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}`);
|
|
@@ -1194,7 +1225,7 @@ async function addCommand(args) {
|
|
|
1194
1225
|
const entry = next.tools.find((t) => t.name === name);
|
|
1195
1226
|
const data = entry?.data ?? "none";
|
|
1196
1227
|
const envs = entry?.envs ?? ["beta", "prod"];
|
|
1197
|
-
const toolInfo = { target, data };
|
|
1228
|
+
const toolInfo = { lane, target, data };
|
|
1198
1229
|
const dest = resolve5(process.cwd(), "tools", name);
|
|
1199
1230
|
if (existsSync5(dest)) throw new Error(`tools/${name} already exists`);
|
|
1200
1231
|
const src = templateDir(lane, target);
|
|
@@ -1209,6 +1240,11 @@ async function addCommand(args) {
|
|
|
1209
1240
|
}
|
|
1210
1241
|
const shippedGitignore = join(dest, "gitignore");
|
|
1211
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
|
+
}
|
|
1212
1248
|
console.log(`\u2714 copied ${src} \u2192 tools/${name}`);
|
|
1213
1249
|
if (existsSync5(pkgPath)) registerWorkspaceMember(process.cwd(), `tools/${name}`);
|
|
1214
1250
|
} else {
|
|
@@ -1838,11 +1874,11 @@ async function adoptWrapper(ctx) {
|
|
|
1838
1874
|
`verify/${name}.config.ts`
|
|
1839
1875
|
);
|
|
1840
1876
|
}
|
|
1841
|
-
const providers = providersForTool({ target, data });
|
|
1877
|
+
const providers = providersForTool({ lane, target, data });
|
|
1842
1878
|
if (existsSync6(join2(cwd, "infra/main.tf")) && providers.some((p) => p !== "cloudflare" && p !== "github")) {
|
|
1843
1879
|
console.log(`\xB7 ensure infra/main.tf declares provider(s): ${providers.join(", ")}`);
|
|
1844
1880
|
}
|
|
1845
|
-
materializeAgentKit(dest, { target, data });
|
|
1881
|
+
materializeAgentKit(dest, { lane, target, data });
|
|
1846
1882
|
addGreenlightScript(dest);
|
|
1847
1883
|
if (target === "oci") {
|
|
1848
1884
|
const wrapperSlug = parseRepo(safeGit(cwd, ["remote", "get-url", "origin"])) ?? "OWNER/REPO";
|
|
@@ -1940,7 +1976,7 @@ async function adoptStandalone(ctx) {
|
|
|
1940
1976
|
".github/workflows/greenlight-promote.yml"
|
|
1941
1977
|
);
|
|
1942
1978
|
writeIfAbsent(join2(repo, "verify.config.ts"), starterVerifyConfig(lane), "verify.config.ts");
|
|
1943
|
-
materializeAgentKit(repo, { target, data });
|
|
1979
|
+
materializeAgentKit(repo, { lane, target, data });
|
|
1944
1980
|
writeIfAbsent(join2(repo, "mise.toml"), MISE_TOML, "mise.toml");
|
|
1945
1981
|
writeIfAbsent(join2(repo, ".node-version"), "24\n", ".node-version");
|
|
1946
1982
|
const nextReg = addTool(reg, {
|
|
@@ -2674,6 +2710,13 @@ function defaultSpec(lane) {
|
|
|
2674
2710
|
return { mode: "api", checks: [{ path: "/", status: 200 }] };
|
|
2675
2711
|
case "mcp":
|
|
2676
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
|
+
};
|
|
2677
2720
|
}
|
|
2678
2721
|
}
|
|
2679
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rtrentjones/greenlight",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
35
|
-
"@rtrentjones/greenlight-loop": "0.
|
|
36
|
-
"@rtrentjones/greenlight-verify": "0.
|
|
37
|
-
"@rtrentjones/greenlight-shared": "0.
|
|
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,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"
|