@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 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
- and Node 20+. macOS or Linux.
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
- `IMPLEMENTATION.md` and `ARCHITECTURE.md` ship inside this package the full set-up, and
64
- how the pieces fit.
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`.
@@ -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
- ["create-project", "A new GCP project."],
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
- ["", "Create your content repo — jobs/, worlds/, SYSTEM.md, deployment.env."],
21
- ["", "Install gcloud, docker, git, node 24+, and the claude CLI."],
22
- ["mint-gmail", "Credentials: claude setup-token, then a token per mail account."],
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
- ["status", 'Check it. Then: jobs, run <job>, ask "…".'],
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. The full text is IMPLEMENTATION.md.\n");
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 status` after the last one should show");
63
- console.log("your triggers, and `meffecta-agent jobs` your jobs.");
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
@@ -1,4 +1,4 @@
1
1
  {
2
- "engineTag": "1.0.0",
3
- "builtFrom": "9bffef350f00af96654b7b9f97f2cb075b70bbad"
2
+ "engineTag": "1.0.1",
3
+ "builtFrom": "e86fcdf452f5403ce1c90805c904048dacc0c6ae"
4
4
  }
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
- return tag ? { tag, pinned: true } : { tag: "latest", pinned: false };
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, or GIT_REPO_URL is wrong.");
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 name = args[0];
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 = args.includes("--in") ? Number(args[args.indexOf("--in") + 1]) : 0;
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
- const prompt = args.filter((a) => !a.startsWith("--")).join(" ");
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 ["model", "effort", "timeoutSeconds"]) {
187
- const value = flag(`--${name}`);
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 = args.includes("--limit") ? args[args.indexOf("--limit") + 1] : "40";
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
- title: "Set up a deployment, in order",
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
- ["mint-gmail", "Mint a Gmail/Calendar refresh token for one account", script("mint-gmail-token.mjs")],
374
- ["mint-graph", "Mint a Microsoft Graph refresh token for one mailbox", script("mint-graph-token.mjs")],
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],