@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.
- package/assets/skills/provider-gemini/SKILL.md +70 -0
- package/dist/bin.js +96 -10
- 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
- package/templates/_template-next/gitignore +5 -0
- package/templates/_template-next/vercel.json +4 -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";
|
|
@@ -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.
|
|
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
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"
|