@meffecta/agent 1.0.0 → 1.0.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.
- package/README.md +34 -4
- package/bin/meffecta-agent.js +26 -8
- package/engine.json +2 -2
- package/lib/commands.js +80 -21
- package/lib/doctor.js +452 -0
- package/lib/flags.js +82 -0
- package/lib/integrations.js +212 -0
- package/package.json +3 -3
- package/scripts/lib/read-json.mjs +68 -0
- package/scripts/setup-scheduler.sh +6 -33
- package/scripts/update-tooling.sh +6 -5
- package/ARCHITECTURE.md +0 -197
- package/IMPLEMENTATION.md +0 -292
package/README.md
CHANGED
|
@@ -29,9 +29,18 @@ npx @meffecta/agent deploy # roll out the engine
|
|
|
29
29
|
npx @meffecta/agent setup-scheduler # give it its triggers
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
Giving it access to something — Gmail, GA4, Grafana, PostHog, Cloudflare, an inbox
|
|
33
|
+
trigger — is ongoing work rather than set-up, and each has a walkthrough:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx @meffecta/agent connect # what can be connected
|
|
37
|
+
npx @meffecta/agent connect gmail # what to do, in order, with the commands
|
|
38
|
+
```
|
|
39
|
+
|
|
32
40
|
Then, day to day:
|
|
33
41
|
|
|
34
42
|
```bash
|
|
43
|
+
npx @meffecta/agent doctor # is anything quietly broken?
|
|
35
44
|
npx @meffecta/agent status # revision, shape, triggers, queue
|
|
36
45
|
npx @meffecta/agent jobs # what is registered, and what triggers it
|
|
37
46
|
npx @meffecta/agent env # every setting (secret values never printed)
|
|
@@ -42,6 +51,15 @@ npx @meffecta/agent run morning-brief
|
|
|
42
51
|
npx @meffecta/agent ask "how did search do last week"
|
|
43
52
|
```
|
|
44
53
|
|
|
54
|
+
`doctor` is the one to run when something feels wrong. It judges rather than describes,
|
|
55
|
+
and it is aimed at the failures that produce no error anywhere: a scaled-to-zero service
|
|
56
|
+
with no triggers runs nothing while looking perfectly healthy, an always-on one with
|
|
57
|
+
triggers runs every cron twice, a sweep whose cadence no longer matches the inbox poll
|
|
58
|
+
interval checks mail on yesterday's schedule for ever, and a registry that expired the
|
|
59
|
+
image your revision pins will fail at the next cold start rather than the next deploy.
|
|
60
|
+
Every finding carries the command that fixes it, and it exits non-zero on a real problem
|
|
61
|
+
so CI can run it.
|
|
62
|
+
|
|
45
63
|
## Two things worth knowing
|
|
46
64
|
|
|
47
65
|
**The version you install is the engine you deploy.** Each release records the engine build
|
|
@@ -55,10 +73,22 @@ value from a pipe, a file, or a hidden prompt — never from your shell history.
|
|
|
55
73
|
|
|
56
74
|
## Requirements
|
|
57
75
|
|
|
58
|
-
`gcloud` (authenticated), `docker` (to mirror engine images into your registry), `git`,
|
|
59
|
-
|
|
76
|
+
`gcloud` (authenticated), `docker` (to mirror engine images into your registry), `git`, and
|
|
77
|
+
Node 20+ — Node 24+ if you also run the `claude` CLI to mint the token. macOS or Linux.
|
|
78
|
+
|
|
79
|
+
## Developing it
|
|
80
|
+
|
|
81
|
+
From a checkout of the engine repo:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
cd cli && npm link # `meffecta-agent` now runs the working tree
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The scripts and docs are vendored into this package only at pack time, so a linked
|
|
88
|
+
checkout falls back to the engine repo's own copies — edit either half and the next command
|
|
89
|
+
picks it up. `npm rm -g @meffecta/agent` undoes it.
|
|
60
90
|
|
|
61
91
|
## Documentation
|
|
62
92
|
|
|
63
|
-
`
|
|
64
|
-
|
|
93
|
+
`npx @meffecta/agent help` lists every command; `npx @meffecta/agent steps` is the whole
|
|
94
|
+
set-up in order, with the command for each step. Every command takes `--help`.
|
package/bin/meffecta-agent.js
CHANGED
|
@@ -14,18 +14,32 @@ import { loadDeployment, UserError } from "../lib/config.js";
|
|
|
14
14
|
* a tool that holds someone's cloud credentials is a poor place for a supply chain.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
// Tools first: every command below needs gcloud, and the very first one already does. The
|
|
18
|
+
// claude CLI is the odd one out — it is used once, to mint the token, and never again.
|
|
17
19
|
const STEPS = [
|
|
18
|
-
[
|
|
20
|
+
[
|
|
21
|
+
"",
|
|
22
|
+
"Install gcloud, docker, node 24+ and git. Also the claude CLI — it is\n used once, for the token two steps down, and not after that.",
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
"create-project",
|
|
26
|
+
"A new GCP project. This and the next step are per GCP account, not\n per deployment — skip both if you are adding a second agent to a project you\n already have.",
|
|
27
|
+
],
|
|
19
28
|
["link-billing", "Attach a billing account, or nothing can run."],
|
|
20
|
-
[
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
[
|
|
30
|
+
"mint-gmail",
|
|
31
|
+
"Credentials: `claude setup-token`, a GitHub token that can clone your repos,\n and — only if this agent reads or sends mail — one token per mailbox.",
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"init",
|
|
35
|
+
"Create your content repo — jobs/, worlds/, SYSTEM.md — and a deployment.env in it.\n Run everything from there; it is what says which deployment you mean.",
|
|
36
|
+
],
|
|
23
37
|
["setup-infra", "Provision GCP and the Cloud Run service shell."],
|
|
24
38
|
["set-secret", "AGENT_WEBHOOK_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN."],
|
|
25
39
|
["set-env", "GIT_REPO_URL=… — the service will not boot without it."],
|
|
26
40
|
["deploy", "Roll out the engine image."],
|
|
27
41
|
["setup-scheduler", "Give it its triggers. Scaled to zero, it fires nothing without them."],
|
|
28
|
-
["
|
|
42
|
+
["doctor", 'Check it. Then: jobs, run <job>, ask "…".'],
|
|
29
43
|
];
|
|
30
44
|
|
|
31
45
|
function help() {
|
|
@@ -40,6 +54,9 @@ function help() {
|
|
|
40
54
|
for (const [name, summary] of group.commands) {
|
|
41
55
|
console.log(` ${name.padEnd(width)} ${summary}`);
|
|
42
56
|
}
|
|
57
|
+
for (const line of group.note ?? []) {
|
|
58
|
+
console.log(` ${line}`);
|
|
59
|
+
}
|
|
43
60
|
console.log("");
|
|
44
61
|
}
|
|
45
62
|
console.log(" steps The whole set-up, in order, with the command for each");
|
|
@@ -50,7 +67,7 @@ function help() {
|
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
function steps() {
|
|
53
|
-
console.log("Setting up a deployment, in order
|
|
70
|
+
console.log("Setting up a deployment, in order.\n");
|
|
54
71
|
STEPS.forEach(([command, note], index) => {
|
|
55
72
|
const label = `${String(index + 1).padStart(2)}.`;
|
|
56
73
|
console.log(`${label} ${note}`);
|
|
@@ -59,8 +76,8 @@ function steps() {
|
|
|
59
76
|
}
|
|
60
77
|
console.log("");
|
|
61
78
|
});
|
|
62
|
-
console.log("Each step is checkable: `meffecta-agent
|
|
63
|
-
console.log("
|
|
79
|
+
console.log("Each step is checkable: `meffecta-agent doctor` after the last one should come");
|
|
80
|
+
console.log("back clean, and `meffecta-agent jobs` should list your jobs.");
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
function version() {
|
|
@@ -107,6 +124,7 @@ try {
|
|
|
107
124
|
if (name === "deploy" && !args.includes("--tag") && !args.includes("--image")) {
|
|
108
125
|
const { tag, pinned } = engineTag();
|
|
109
126
|
if (pinned) {
|
|
127
|
+
console.log(`Engine ${tag}, the build this CLI was released with. --tag overrides it.\n`);
|
|
110
128
|
args.push("--tag", tag);
|
|
111
129
|
}
|
|
112
130
|
}
|
package/engine.json
CHANGED
package/lib/commands.js
CHANGED
|
@@ -2,7 +2,10 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { requireDeployment, UserError } from "./config.js";
|
|
5
|
+
import { runDoctor } from "./doctor.js";
|
|
6
|
+
import { parseFlags } from "./flags.js";
|
|
5
7
|
import { api, requireCommand, stream } from "./gcloud.js";
|
|
8
|
+
import { connect } from "./integrations.js";
|
|
6
9
|
|
|
7
10
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
11
|
|
|
@@ -28,8 +31,15 @@ export function engineTag() {
|
|
|
28
31
|
return { tag: "latest", pinned: false };
|
|
29
32
|
}
|
|
30
33
|
try {
|
|
31
|
-
const { engineTag: tag } = JSON.parse(readFileSync(file, "utf8"));
|
|
32
|
-
|
|
34
|
+
const { engineTag: tag, builtFrom } = JSON.parse(readFileSync(file, "utf8"));
|
|
35
|
+
// Only a release is pinned. `npm pack` run by hand writes this file into the working
|
|
36
|
+
// tree with builtFrom "local", where it would otherwise survive and pin `deploy` to a
|
|
37
|
+
// version that was never published — the image is not there and the mirror fails with
|
|
38
|
+
// nothing to say why that tag was chosen.
|
|
39
|
+
if (!tag || builtFrom === "local" || existsSync(resolve(packageRoot, "..", "src", "index.ts"))) {
|
|
40
|
+
return { tag: "latest", pinned: false };
|
|
41
|
+
}
|
|
42
|
+
return { tag, pinned: true };
|
|
33
43
|
} catch {
|
|
34
44
|
return { tag: "latest", pinned: false };
|
|
35
45
|
}
|
|
@@ -137,11 +147,20 @@ async function status() {
|
|
|
137
147
|
return 0;
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/**
|
|
151
|
+
* What the *service* has registered — not the files here, and not Cloud Scheduler.
|
|
152
|
+
*
|
|
153
|
+
* The engine reads jobs/ from a clone taken at its boot, so this is the boot-time picture.
|
|
154
|
+
* A job pushed since is not in it until the service restarts, and a Cloud Scheduler trigger
|
|
155
|
+
* can outlive the job that created it. `doctor` compares all three and is the place to look
|
|
156
|
+
* when they disagree.
|
|
157
|
+
*/
|
|
140
158
|
async function jobs() {
|
|
141
159
|
const d = requireDeployment();
|
|
142
160
|
const list = JSON.parse(await api(d, "/jobs"));
|
|
143
161
|
if (list.length === 0) {
|
|
144
|
-
console.log("No jobs. The content repo's jobs/ directory is empty,
|
|
162
|
+
console.log("No jobs registered. The content repo's jobs/ directory is empty, GIT_REPO_URL is");
|
|
163
|
+
console.log("wrong, or GITHUB_TOKEN cannot clone it.");
|
|
145
164
|
return 0;
|
|
146
165
|
}
|
|
147
166
|
const width = Math.max(...list.map((j) => j.name.length));
|
|
@@ -149,22 +168,27 @@ async function jobs() {
|
|
|
149
168
|
const triggers = [
|
|
150
169
|
job.cron && `cron ${job.cron}`,
|
|
151
170
|
job.webhook && `webhook /${job.webhook}`,
|
|
152
|
-
job.inbox && `inbox ${job.inbox}`,
|
|
171
|
+
job.inbox && `inbox ${job.inbox} (from ${job.allowFrom ?? "NOBODY — no allowFrom:"})`,
|
|
153
172
|
]
|
|
154
173
|
.filter(Boolean)
|
|
155
174
|
.join(", ");
|
|
156
175
|
console.log(`${job.name.padEnd(width)} ${triggers || "manual only"}${job.disabled ? " [disabled]" : ""}`);
|
|
157
176
|
}
|
|
177
|
+
console.log(
|
|
178
|
+
`\n${list.length} registered by ${d.SERVICE}, from the content repo as it stood at that service's` +
|
|
179
|
+
"\nlast boot — not the files here. `meffecta-agent doctor` checks them against both.",
|
|
180
|
+
);
|
|
158
181
|
return 0;
|
|
159
182
|
}
|
|
160
183
|
|
|
161
184
|
async function runJob(args) {
|
|
162
|
-
const
|
|
185
|
+
const { flags, positional } = parseFlags(args, { in: { type: "int", min: 1, max: 3600 } });
|
|
186
|
+
const name = positional[0];
|
|
163
187
|
if (!name) {
|
|
164
188
|
throw new UserError("Which job? Try: meffecta-agent jobs");
|
|
165
189
|
}
|
|
166
190
|
const d = requireDeployment();
|
|
167
|
-
const delay =
|
|
191
|
+
const delay = flags.in ?? 0;
|
|
168
192
|
const query = delay > 0 ? `?delaySeconds=${delay}` : "";
|
|
169
193
|
const body = JSON.parse(await api(d, `/jobs/${encodeURIComponent(name)}/run${query}`, { method: "POST" }));
|
|
170
194
|
console.log(
|
|
@@ -176,18 +200,22 @@ async function runJob(args) {
|
|
|
176
200
|
}
|
|
177
201
|
|
|
178
202
|
async function ask(args) {
|
|
179
|
-
|
|
203
|
+
// model and effort are deliberately not validated here. The engine owns that list, this
|
|
204
|
+
// CLI may be deploying an engine older or newer than itself, and /test already answers
|
|
205
|
+
// with the valid values — so the authority stays in one place and the error stays good.
|
|
206
|
+
const { flags, positional } = parseFlags(args, {
|
|
207
|
+
model: {},
|
|
208
|
+
effort: {},
|
|
209
|
+
timeoutSeconds: { type: "int", min: 1, max: 3600 },
|
|
210
|
+
});
|
|
211
|
+
const prompt = positional.join(" ");
|
|
180
212
|
if (!prompt) {
|
|
181
213
|
throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
|
|
182
214
|
}
|
|
183
215
|
const d = requireDeployment();
|
|
184
|
-
const flag = (name) => (args.includes(name) ? args[args.indexOf(name) + 1] : undefined);
|
|
185
216
|
const params = new URLSearchParams({ prompt });
|
|
186
|
-
for (const name of
|
|
187
|
-
|
|
188
|
-
if (value) {
|
|
189
|
-
params.set(name, value);
|
|
190
|
-
}
|
|
217
|
+
for (const [name, value] of Object.entries(flags)) {
|
|
218
|
+
params.set(name, String(value));
|
|
191
219
|
}
|
|
192
220
|
console.error("Running on the deployment — this takes as long as the run does.");
|
|
193
221
|
process.stdout.write(await api(d, `/test?${params}`, { accept: "text/markdown" }));
|
|
@@ -321,8 +349,9 @@ async function triggers() {
|
|
|
321
349
|
/** Recent service logs, without making anyone remember the filter syntax. */
|
|
322
350
|
async function logs(args) {
|
|
323
351
|
requireCommand("gcloud", "logs come from Cloud Logging");
|
|
352
|
+
const { flags } = parseFlags(args, { limit: { type: "int", min: 1, max: 1000 } });
|
|
324
353
|
const d = requireDeployment();
|
|
325
|
-
const limit =
|
|
354
|
+
const limit = String(flags.limit ?? 40);
|
|
326
355
|
const { code } = await stream("gcloud", [
|
|
327
356
|
"logging",
|
|
328
357
|
"read",
|
|
@@ -360,28 +389,58 @@ async function init(args) {
|
|
|
360
389
|
|
|
361
390
|
export const GROUPS = [
|
|
362
391
|
{
|
|
363
|
-
|
|
392
|
+
// The only two commands that run before a deployment exists: no deployment.env, nothing
|
|
393
|
+
// to stand in, and once done you never touch them again. Everything in the next group
|
|
394
|
+
// acts on *a* deployment and reads deployment.env to know which one you mean.
|
|
395
|
+
title: "Before you have a deployment (once per GCP account)",
|
|
396
|
+
commands: [
|
|
397
|
+
["create-project", "Create the GCP project a deployment will live in", script("create-project.sh")],
|
|
398
|
+
["link-billing", "Attach a billing account — nothing runs without one", script("link-billing.sh")],
|
|
399
|
+
],
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
// The shortest path to a running agent, and nothing else. Three secrets and one
|
|
403
|
+
// setting are all the engine actually needs to boot; everything optional lives in the
|
|
404
|
+
// next group so a first deployment is not a shopping list.
|
|
405
|
+
title: "Stand one up (once, in order)",
|
|
364
406
|
commands: [
|
|
365
407
|
["init", "Start a deployment.env here, from the template", init],
|
|
366
|
-
["create-project", "Create the GCP project this deployment lives in", script("create-project.sh")],
|
|
367
|
-
["link-billing", "Attach a billing account to it", script("link-billing.sh")],
|
|
368
408
|
[
|
|
369
409
|
"setup-infra",
|
|
370
410
|
"Provision GCP: registry, buckets, service account, service shell",
|
|
371
411
|
script("setup-infrastructure.sh"),
|
|
372
412
|
],
|
|
373
|
-
["
|
|
374
|
-
["
|
|
375
|
-
["set-secret", "Store a secret and bind it to the service (--random to generate)", script("set-secret.sh")],
|
|
376
|
-
["set-env", "Set non-secret service settings (NAME=VALUE …)", script("set-env.sh")],
|
|
413
|
+
["set-secret", "AGENT_WEBHOOK_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN", script("set-secret.sh")],
|
|
414
|
+
["set-env", "GIT_REPO_URL — the one setting the service will not boot without", script("set-env.sh")],
|
|
377
415
|
["deploy", "Roll out an engine image and assert the runtime shape", script("deploy.sh")],
|
|
378
416
|
["setup-scheduler", "Create the external triggers — Cloud Scheduler + Cloud Tasks", script("setup-scheduler.sh")],
|
|
417
|
+
],
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
// What an agent can do is credentials plus jobs, and both are added for as long as the
|
|
421
|
+
// deployment lives. Worth its own group so it does not read like set-up you skipped.
|
|
422
|
+
title: "Give it more to do (any time after)",
|
|
423
|
+
commands: [
|
|
424
|
+
["connect", "How to wire up gmail, ga4, grafana, posthog … — `connect` lists them", connect],
|
|
425
|
+
[
|
|
426
|
+
"mint-gmail",
|
|
427
|
+
"A Gmail/Calendar refresh token, per account it should read or send as",
|
|
428
|
+
script("mint-gmail-token.mjs"),
|
|
429
|
+
],
|
|
430
|
+
["mint-graph", "A Microsoft Graph refresh token, per mailbox", script("mint-graph-token.mjs")],
|
|
379
431
|
["artifact-cleanup", "Expire mirrored images in the deployment's registry", script("set-artifact-cleanup.sh")],
|
|
380
432
|
],
|
|
433
|
+
note: [
|
|
434
|
+
"A new credential is `set-secret` again, plus `set-env` for whatever names the",
|
|
435
|
+
"project it points at. A new job is a push to your content repo — there is no",
|
|
436
|
+
"command for it, and its prompt is live on the next run. Only its *trigger* needs",
|
|
437
|
+
"`deploy`, which restarts the service so the new cron: is registered.",
|
|
438
|
+
],
|
|
381
439
|
},
|
|
382
440
|
{
|
|
383
441
|
title: "Look at what is there",
|
|
384
442
|
commands: [
|
|
443
|
+
["doctor", "Check the deployment for the things that silently break it", runDoctor],
|
|
385
444
|
["status", "What the deployment looks like right now", status],
|
|
386
445
|
["jobs", "The jobs it has registered, and what triggers them", jobs],
|
|
387
446
|
["env", "Every setting on the service (secret values never printed)", envList],
|