@meffecta/agent 1.0.2 → 1.0.7

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,344 @@
1
+ // `create-job "<what you want it to do>"` — a job definition written by the agent itself.
2
+ //
3
+ // A job file is small but unforgiving: five cron fields in the deployment's timezone,
4
+ // frontmatter the engine parses strictly, an `inbox:` that fails closed without
5
+ // `allowFrom:`, and a prompt that has to name the world and skills this deployment
6
+ // actually has. Someone who has just installed the CLI knows none of that, and the failure
7
+ // is quiet — a job that never registers looks exactly like a job that has not run yet.
8
+ //
9
+ // WHERE THE MODEL RUNS: on the deployment, not here. It already holds the Claude token, so
10
+ // nothing new is needed on the operator's machine — but the real reason is context. A run
11
+ // has the content repo cloned and the engine's default skills mounted, so it can read the
12
+ // existing jobs for house style, the worlds, and the systems/ register for what this
13
+ // deployment can actually reach. A `claude` on the laptop would see the content repo and
14
+ // none of the skills, and would invent capabilities that are not there.
15
+ //
16
+ // WHAT COMES BACK IS NOT TRUSTED. The file is parsed with the engine's own frontmatter
17
+ // rules and checked before it is written: a valid cron, a name that is a name, `allowFrom:`
18
+ // whenever `inbox:` appears, an effort the engine accepts. A model that returns something
19
+ // plausible-but-wrong is the expected case, not the exceptional one — this command's value
20
+ // is the checking, not the generating.
21
+ //
22
+ // It writes a file and stops. No commit, no push, no deploy: the operator reads the prompt
23
+ // their agent will run before anyone else does.
24
+
25
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
26
+ import { resolve } from "node:path";
27
+ import { requireDeployment, UserError } from "./config.js";
28
+ import { parseFlags } from "./flags.js";
29
+ import { api } from "./gcloud.js";
30
+
31
+ /** The engine's list, and it rejects anything else (src/jobs.ts). */
32
+ const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
33
+
34
+ /** Sentinels rather than code fences: a job prompt may itself contain fenced blocks. */
35
+ const BEGIN = "JOB_FILE_BEGIN";
36
+ const END = "JOB_FILE_END";
37
+
38
+ /**
39
+ * A cron field, in the five-field form Cloud Scheduler and croner both accept. Deliberately
40
+ * strict: a field the engine would take but Cloud Scheduler would reject creates a job that
41
+ * registers locally and silently never fires on a scaled-to-zero deployment.
42
+ */
43
+ function validCronField(field, min, max, names = []) {
44
+ return field.split(",").every((part) => {
45
+ const [range, step] = part.split("/");
46
+ if (step !== undefined && !/^\d+$/.test(step)) {
47
+ return false;
48
+ }
49
+ if (range === "*") {
50
+ return true;
51
+ }
52
+ const value = (token) => {
53
+ const index = names.indexOf(token.toLowerCase());
54
+ return index >= 0 ? index : Number(token);
55
+ };
56
+ const bounds = range.split("-");
57
+ if (bounds.length > 2) {
58
+ return false;
59
+ }
60
+ return bounds.every((token) => {
61
+ const n = value(token);
62
+ return Number.isInteger(n) && n >= min && n <= max;
63
+ });
64
+ });
65
+ }
66
+
67
+ const MONTHS = ["", "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
68
+ const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
69
+
70
+ export function validCron(expression) {
71
+ const fields = (expression ?? "").trim().split(/\s+/);
72
+ if (fields.length !== 5) {
73
+ return false;
74
+ }
75
+ const [minute, hour, dom, month, dow] = fields;
76
+ return (
77
+ validCronField(minute, 0, 59) &&
78
+ validCronField(hour, 0, 23) &&
79
+ validCronField(dom, 1, 31) &&
80
+ validCronField(month, 1, 12, MONTHS) &&
81
+ validCronField(dow, 0, 6, DAYS)
82
+ );
83
+ }
84
+
85
+ /** A job name is a filename, a Cloud Scheduler job id, and a memory directory. */
86
+ export function validName(name) {
87
+ return /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(name ?? "");
88
+ }
89
+
90
+ /**
91
+ * The engine's frontmatter rules, exactly (src/jobs.ts): a leading `---` block of
92
+ * `key: value` lines. Reimplemented rather than imported because the CLI ships without the
93
+ * engine — but it must not drift, so a test compares the two on the same input.
94
+ */
95
+ export function parseFrontmatter(content) {
96
+ const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
97
+ if (!match) {
98
+ return { meta: {}, body: content.trim() };
99
+ }
100
+ const meta = {};
101
+ for (const line of match[1].split("\n")) {
102
+ const [key, ...rest] = line.split(": ");
103
+ if (key && rest.length) {
104
+ meta[key.trim()] = rest.join(": ").replace(/^["']|["']$/g, "");
105
+ }
106
+ }
107
+ return { meta, body: match[2].trim() };
108
+ }
109
+
110
+ /** Pull the file out of the model's answer. Returns null when it did not follow the shape. */
111
+ export function extractJobFile(answer) {
112
+ const start = answer.indexOf(BEGIN);
113
+ const stop = answer.indexOf(END);
114
+ if (start < 0 || stop < 0 || stop < start) {
115
+ return null;
116
+ }
117
+ const file = answer.slice(start + BEGIN.length, stop).trim();
118
+ const named = answer.match(/JOB_NAME:\s*([^\n]+)/);
119
+ const notes = answer.match(/JOB_NOTES:\s*([^\n]+)/);
120
+ return {
121
+ name: named?.[1].trim().replace(/\.md$/, ""),
122
+ file: `${file}\n`,
123
+ notes: notes?.[1].trim(),
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Everything that would make the engine, or Cloud Scheduler, quietly not run this. Returns
129
+ * a list of human problems — empty means it is safe to write.
130
+ */
131
+ export function validateJob({ name, file, existing = [] }) {
132
+ const problems = [];
133
+ if (!validName(name)) {
134
+ problems.push(
135
+ `"${name}" is not usable as a job name. It becomes a filename, a Cloud Scheduler job id and a memory directory: lower-case letters, digits and hyphens.`,
136
+ );
137
+ }
138
+ if (existing.includes(name)) {
139
+ problems.push(`A job called "${name}" already exists. Pass --name to call this one something else.`);
140
+ }
141
+ const { meta, body } = parseFrontmatter(file);
142
+ if (!file.startsWith("---\n")) {
143
+ problems.push("No frontmatter block — the engine would read the whole file as the prompt.");
144
+ }
145
+ if (body.length < 20) {
146
+ problems.push("The prompt body is empty or too short to be a job.");
147
+ }
148
+ if (meta.cron !== undefined && !validCron(meta.cron)) {
149
+ problems.push(`cron: "${meta.cron}" is not a valid five-field expression, so no trigger would be created.`);
150
+ }
151
+ // The engine enforces this and fails closed; catching it here explains why.
152
+ if (meta.inbox && !meta.allowFrom) {
153
+ problems.push(
154
+ `inbox: ${meta.inbox} without allowFrom: — the engine refuses to register a mail-triggered job with no sender policy, deliberately. Add allowFrom: with the addresses or domains allowed to trigger it.`,
155
+ );
156
+ }
157
+ if (meta.effort && !EFFORT_LEVELS.includes(meta.effort)) {
158
+ problems.push(`effort: ${meta.effort} is not one of ${EFFORT_LEVELS.join(", ")} — the engine would ignore it.`);
159
+ }
160
+ if (meta.timeoutSeconds !== undefined) {
161
+ const seconds = Number(meta.timeoutSeconds);
162
+ if (!Number.isFinite(seconds) || seconds <= 0) {
163
+ problems.push(`timeoutSeconds: ${meta.timeoutSeconds} is not a positive number — the engine would ignore it.`);
164
+ }
165
+ }
166
+ if (!meta.cron && !meta.webhook && !meta.inbox) {
167
+ // Not an error: a job you trigger by hand is a real thing, and worth saying out loud
168
+ // rather than leaving someone waiting for a schedule that was never asked for.
169
+ problems.push(
170
+ "__note__No cron:, webhook: or inbox: — this job will only run when you ask it to, with `meffecta-agent run`.",
171
+ );
172
+ }
173
+ return problems;
174
+ }
175
+
176
+ /** What the deployment is asked. The response contract is the load-bearing part. */
177
+ function buildPrompt(description, { timezone, existing }) {
178
+ return [
179
+ "You are writing ONE job definition file for the Meffecta Agent deployment you are running in.",
180
+ "",
181
+ "Before writing anything, read what this deployment actually is:",
182
+ " - jobs/*.md in your working directory — the house style, and what already exists",
183
+ " - systems/ (or ENVIRONMENT.md) — what this deployment can reach, and the variables for each",
184
+ " - worlds/ if present — the projects/products a job can be pointed at",
185
+ " - your own available skills — use only skills that exist; never invent a capability",
186
+ "",
187
+ "The operator asked for:",
188
+ ` ${JSON.stringify(description)}`,
189
+ "",
190
+ "Rules:",
191
+ ` - cron: is five fields, interpreted in ${timezone}. "every Wednesday at 2pm" is "0 14 * * 3".`,
192
+ " - Use cron: only if a schedule was actually asked for. Otherwise omit it.",
193
+ " - If the job is triggered by mail, inbox: REQUIRES allowFrom: — the engine refuses",
194
+ " to register one without it. Never emit inbox: without allowFrom:.",
195
+ " - effort: must be one of low, medium, high, xhigh, max, or omitted.",
196
+ " - Do not invent env vars, worlds, repos or skills. If something is needed that this",
197
+ " deployment does not have, say so in JOB_NOTES rather than pretending it exists.",
198
+ " - The prompt body should say what to produce and where it goes (email? a file? a PR?),",
199
+ " and be specific about the data source, matching how the existing jobs are written.",
200
+ existing.length ? ` - These names are taken: ${existing.join(", ")}` : "",
201
+ "",
202
+ "Answer in EXACTLY this shape and nothing else — no preamble, no code fences:",
203
+ "",
204
+ "JOB_NAME: <short-kebab-case-name>",
205
+ `${BEGIN}`,
206
+ "---",
207
+ "<frontmatter lines>",
208
+ "---",
209
+ "<the prompt body>",
210
+ `${END}`,
211
+ "JOB_NOTES: <one line: anything the operator must set up or decide, or 'none'>",
212
+ ]
213
+ .filter(Boolean)
214
+ .join("\n");
215
+ }
216
+
217
+ export async function createJob(args) {
218
+ const { flags, positional } = parseFlags(args, { name: {}, model: {}, "dry-run": { type: "boolean" } });
219
+ const description = positional.join(" ").trim();
220
+ if (!description) {
221
+ throw new UserError(
222
+ 'What should it do? e.g.\n\n meffecta-agent create-job "a marketing report every Wednesday at 2pm"',
223
+ );
224
+ }
225
+ const d = requireDeployment();
226
+ const jobsDir = resolve(process.cwd(), "jobs");
227
+ const existing = existsSync(jobsDir)
228
+ ? readdirSync(jobsDir)
229
+ .filter((f) => f.endsWith(".md"))
230
+ .map((f) => f.replace(/\.md$/, ""))
231
+ : [];
232
+
233
+ const timezone = d.TIMEZONE ?? "Europe/Stockholm";
234
+ console.error(`Asking ${d.SERVICE} to write it — it can see your jobs, worlds and skills. This takes a minute.`);
235
+ const params = new URLSearchParams({ prompt: buildPrompt(description, { timezone, existing }) });
236
+ if (flags.model) {
237
+ params.set("model", String(flags.model));
238
+ }
239
+ const answer = await api(d, `/test?${params}`, { accept: "text/markdown" });
240
+
241
+ const extracted = extractJobFile(answer);
242
+ if (!extracted?.file) {
243
+ throw new UserError(
244
+ `The deployment did not answer in the expected shape, so nothing was written.\n\n${answer.slice(0, 600)}`,
245
+ );
246
+ }
247
+ const name = String(flags.name ?? extracted.name ?? "").trim();
248
+ const problems = validateJob({ name, file: extracted.file, existing });
249
+ const notes = problems.filter((p) => p.startsWith("__note__")).map((p) => p.slice("__note__".length));
250
+ const errors = problems.filter((p) => !p.startsWith("__note__"));
251
+
252
+ console.log("");
253
+ console.log(`── jobs/${name || "?"}.md ${"─".repeat(Math.max(0, 60 - name.length))}`);
254
+ console.log(extracted.file.trimEnd());
255
+ console.log("─".repeat(72));
256
+
257
+ if (extracted.notes && extracted.notes.toLowerCase() !== "none") {
258
+ console.log(`\nIt says: ${extracted.notes}`);
259
+ }
260
+ for (const note of notes) {
261
+ console.log(`\nℹ ${note}`);
262
+ }
263
+ if (errors.length) {
264
+ console.log("");
265
+ for (const problem of errors) {
266
+ console.log(`✖ ${problem}`);
267
+ }
268
+ throw new UserError("\nNot written. Re-run to try again, or fix it by hand from the text above.");
269
+ }
270
+
271
+ const { meta } = parseFrontmatter(extracted.file);
272
+ if (flags["dry-run"]) {
273
+ console.log("\n(dry run — nothing written)");
274
+ return 0;
275
+ }
276
+ const target = resolve(jobsDir, `${name}.md`);
277
+ mkdirSync(jobsDir, { recursive: true });
278
+ writeFileSync(target, extracted.file);
279
+ console.log(`\n✔ Written to jobs/${name}.md — read it before you commit it. It is a prompt your agent will run.`);
280
+ console.log("");
281
+ console.log(" git add jobs/ && git commit && git push the prompt is live on the next run");
282
+ if (meta.cron) {
283
+ console.log(` meffecta-agent deploy registers the schedule (${meta.cron}, ${timezone})`);
284
+ }
285
+ console.log(
286
+ ` meffecta-agent run ${name}${" ".repeat(Math.max(1, 24 - name.length))}try it now, before any schedule does`,
287
+ );
288
+ return 0;
289
+ }
290
+
291
+ /**
292
+ * `check-jobs` — the same validation, on files that already exist.
293
+ *
294
+ * `create-job` writes a file and stops so the operator reads it first, which means the
295
+ * next thing that happens is a hand-edit. Without this, editing the cron by hand puts you
296
+ * straight back into the silent failure the checking existed to prevent — and the jobs
297
+ * written before this command existed have never been checked at all.
298
+ */
299
+ export async function checkJobs() {
300
+ const jobsDir = resolve(process.cwd(), "jobs");
301
+ if (!existsSync(jobsDir)) {
302
+ throw new UserError(`No jobs/ directory in ${process.cwd()}. Run this from your content repo.`);
303
+ }
304
+ const files = readdirSync(jobsDir).filter((f) => f.endsWith(".md"));
305
+ if (files.length === 0) {
306
+ console.log('No job files yet. Write one: meffecta-agent create-job "..."');
307
+ return 0;
308
+ }
309
+ const names = files.map((f) => f.replace(/\.md$/, ""));
310
+ let failed = 0;
311
+ for (const file of files.sort()) {
312
+ const name = file.replace(/\.md$/, "");
313
+ // `existing` excludes this file, or every job would collide with itself.
314
+ const problems = validateJob({
315
+ name,
316
+ file: readFileSync(resolve(jobsDir, file), "utf8"),
317
+ existing: names.filter((other) => other !== name),
318
+ });
319
+ const errors = problems.filter((p) => !p.startsWith("__note__"));
320
+ const notes = problems.filter((p) => p.startsWith("__note__"));
321
+ if (errors.length === 0 && notes.length === 0) {
322
+ console.log(`✔ ${name}`);
323
+ continue;
324
+ }
325
+ console.log(`${errors.length ? "✖" : "·"} ${name}`);
326
+ for (const problem of errors) {
327
+ console.log(` ${problem}`);
328
+ }
329
+ for (const note of notes) {
330
+ console.log(` ${note.slice("__note__".length)}`);
331
+ }
332
+ failed += errors.length ? 1 : 0;
333
+ }
334
+ console.log("");
335
+ console.log(
336
+ failed
337
+ ? `${failed} of ${files.length} would not register or run as written.`
338
+ : `${files.length} job file(s), all valid. Triggers still need a restart: meffecta-agent deploy`,
339
+ );
340
+ return failed ? 1 : 0;
341
+ }
342
+
343
+ /** Exported for the test that keeps this parser in step with the engine's. */
344
+ export const __internals = { buildPrompt, BEGIN, END };
package/lib/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { requireDeployment, UserError } from "./config.js";
3
3
  import { api, capture, gcloudArgs, requireCommand } from "./gcloud.js";
4
+ import { cliVersion, isNewer, latestPublished } from "./version.js";
4
5
 
5
6
  /**
6
7
  * Judge a deployment, rather than describe it.
@@ -39,7 +40,7 @@ class Report {
39
40
 
40
41
  const MARK = { [OK]: "✓", [WARN]: "!", [FAIL]: "✗" };
41
42
 
42
- /** Cron minutes the sweep should run at, given the poll interval. Mirrors setup-scheduler.sh. */
43
+ /** Cron minutes the sweep should run at, given the poll interval. Mirrors sync-triggers.sh. */
43
44
 
44
45
  /**
45
46
  * The jobs the content repo declares on disk, parsed the way the engine parses them.
@@ -83,7 +84,7 @@ export function readLocalJobs(dir) {
83
84
 
84
85
  export function expectedSweepSchedule(pollSeconds) {
85
86
  // Unset means the engine's own default; 0 must clamp to one minute rather than fall
86
- // back to it, which is what setup-scheduler.sh does and what doctor has to agree with.
87
+ // back to it, which is what sync-triggers.sh does and what doctor has to agree with.
87
88
  const raw = pollSeconds === undefined || pollSeconds === null || pollSeconds === "" ? 300 : Number(pollSeconds);
88
89
  const seconds = Number.isFinite(raw) ? raw : 300;
89
90
  const minutes = Math.max(1, Math.ceil(seconds / 60));
@@ -105,7 +106,7 @@ export function judgeTriggering({ minScale, external, throttled }) {
105
106
  level: FAIL,
106
107
  title: "Scaled to zero with no triggers",
107
108
  detail: "Nothing in the service fires on its own, so no job will ever run. It will look healthy.",
108
- fix: "meffecta-agent setup-scheduler",
109
+ fix: "meffecta-agent sync-triggers",
109
110
  });
110
111
  } else if (!scaledToZero && external) {
111
112
  findings.push({
@@ -182,13 +183,40 @@ export async function doctor() {
182
183
  );
183
184
  }
184
185
 
186
+ // Being several releases behind is a health fact, and nothing else in the CLI mentions it
187
+ // unprompted. Non-fatal when the registry is unreachable: offline is not a broken
188
+ // deployment, and a doctor that fails on a network hiccup stops being run.
189
+ const latest = await latestPublished();
190
+ if (latest && isNewer(latest, cliVersion())) {
191
+ r.warn(
192
+ `A newer @meffecta/agent is published (${cliVersion()} → ${latest})`,
193
+ "Each release pins the engine image published beside it, so upgrading the tooling is how a deployment moves engine version.",
194
+ "meffecta-agent upgrade",
195
+ );
196
+ }
197
+
185
198
  const secrets = new Set(
186
199
  capture("gcloud", gcloudArgs(d, ["secrets", "list", "--format=value(name)"]))
187
200
  .split("\n")
188
201
  .filter(Boolean)
189
202
  .map((n) => n.split("/").pop()),
190
203
  );
191
- for (const name of ["AGENT_WEBHOOK_SECRET", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN"]) {
204
+ // AGENT_WEBHOOK_SECRET is the old name for AGENT_API_SECRET and the engine still reads
205
+ // it, so a deployment on the old one is working, not broken — check the pair, not the
206
+ // preferred name, or doctor fails a healthy service.
207
+ const apiSecretName = env.AGENT_API_SECRET
208
+ ? "AGENT_API_SECRET"
209
+ : env.AGENT_WEBHOOK_SECRET
210
+ ? "AGENT_WEBHOOK_SECRET"
211
+ : "AGENT_API_SECRET";
212
+ if (apiSecretName === "AGENT_WEBHOOK_SECRET") {
213
+ r.warn(
214
+ "AGENT_WEBHOOK_SECRET is the old name",
215
+ "It guards the whole API, not just webhooks, and is now AGENT_API_SECRET. The engine reads both.",
216
+ "meffecta-agent set-secret AGENT_API_SECRET (paste the same value, deploy, then delete the old one)",
217
+ );
218
+ }
219
+ for (const name of [apiSecretName, "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN"]) {
192
220
  const ref = env[name]?.valueFrom?.secretKeyRef?.name;
193
221
  if (!env[name]) {
194
222
  r.fail(`${name} not set`, "Required by the engine.", `meffecta-agent set-secret ${name}`);
@@ -277,7 +305,7 @@ export async function doctor() {
277
305
  drifted.length && `${drifted.length} schedule(s) out of date: ${drifted.map((j) => j.name).join(", ")}`,
278
306
  stale.length && `${stale.length} trigger(s) for jobs that no longer exist: ${stale.join(", ")}`,
279
307
  ].filter(Boolean);
280
- r.fail("Triggers do not match the content repo", parts.join("; "), "meffecta-agent setup-scheduler");
308
+ r.fail("Triggers do not match the content repo", parts.join("; "), "meffecta-agent sync-triggers");
281
309
  } else if (wanted.length) {
282
310
  r.ok("Cron triggers", `${wanted.length} in place, all matching the content repo`);
283
311
  }
@@ -306,7 +334,7 @@ export async function doctor() {
306
334
  r.fail(
307
335
  "No sweep",
308
336
  "Due follow-ups, runs left behind by a dead process, and watched inboxes are all handled by the sweep. Without it they never happen.",
309
- "meffecta-agent setup-scheduler",
337
+ "meffecta-agent sync-triggers",
310
338
  );
311
339
  } else {
312
340
  const watchesInbox = jobs.some((j) => j.inbox);
@@ -319,7 +347,7 @@ export async function doctor() {
319
347
  ? `AGENT_INBOX_POLL_SECONDS=${value("AGENT_INBOX_POLL_SECONDS") ?? 300} wants "${expected}"`
320
348
  : `no job watches an inbox, so "${expected}" is enough`
321
349
  }.`,
322
- "meffecta-agent setup-scheduler",
350
+ "meffecta-agent sync-triggers",
323
351
  );
324
352
  } else {
325
353
  r.ok("Sweep", `${sweep.schedule}${watchesInbox ? " — matching the inbox poll interval" : ""}`);
@@ -351,7 +379,7 @@ export async function doctor() {
351
379
  r.fail(
352
380
  "No task queue",
353
381
  "External triggers are configured but the queue is missing.",
354
- "meffecta-agent setup-scheduler",
382
+ "meffecta-agent sync-triggers",
355
383
  );
356
384
  }
357
385
  }
package/lib/gcloud.js CHANGED
@@ -38,6 +38,31 @@ export function capture(command, args) {
38
38
  return result.stdout.trim();
39
39
  }
40
40
 
41
+ /**
42
+ * Run a command and resolve with its stdout, or null when it fails.
43
+ *
44
+ * `capture` throws, which is right for a command acting on one thing. It is wrong for
45
+ * taking an inventory: a half-finished set-up is exactly what someone is looking at when
46
+ * they ask what exists, and stopping at the first absent resource would hide the rest.
47
+ * Here a missing thing is an answer.
48
+ *
49
+ * stdout only, deliberately — gcloud narrates on stderr ("Encryption: Google-managed key")
50
+ * even when asked for JSON. It is still drained, or a large one would fill the pipe and
51
+ * hang the read.
52
+ */
53
+ export function probe(command, args) {
54
+ return new Promise((resolveRun) => {
55
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
56
+ let out = "";
57
+ child.stdout.on("data", (chunk) => {
58
+ out += chunk;
59
+ });
60
+ child.stderr.on("data", () => {});
61
+ child.on("error", () => resolveRun(null));
62
+ child.on("close", (code) => resolveRun(code === 0 ? out.trim() : null));
63
+ });
64
+ }
65
+
41
66
  export function requireCommand(name, why) {
42
67
  const found = spawnSync(process.platform === "win32" ? "where" : "which", [name], { encoding: "utf8" });
43
68
  if (found.status !== 0) {
@@ -69,27 +94,31 @@ export function serviceUrl(deployment) {
69
94
  }
70
95
 
71
96
  /**
72
- * The deployment's webhook secret, straight from Secret Manager into memory. It is never
97
+ * The deployment's API secret, straight from Secret Manager into memory. It is never
73
98
  * printed, never passed as a process argument, and only ever leaves here as a header.
99
+ *
100
+ * AGENT_WEBHOOK_SECRET is the old name — it guarded webhooks once, then everything else —
101
+ * and the engine still reads it, so this tries both rather than telling a deployment that
102
+ * works fine that its secret is missing.
74
103
  */
75
- export function webhookSecret(deployment) {
76
- try {
77
- return capture(
78
- "gcloud",
79
- gcloudArgs(deployment, ["secrets", "versions", "access", "latest", "--secret=AGENT_WEBHOOK_SECRET"]),
80
- );
81
- } catch {
82
- throw new UserError(
83
- "Could not read AGENT_WEBHOOK_SECRET from Secret Manager.\n" +
84
- "Create it first: meffecta-agent set-secret AGENT_WEBHOOK_SECRET --random",
85
- );
104
+ export function apiSecret(deployment) {
105
+ for (const name of ["AGENT_API_SECRET", "AGENT_WEBHOOK_SECRET"]) {
106
+ try {
107
+ return capture("gcloud", gcloudArgs(deployment, ["secrets", "versions", "access", "latest", `--secret=${name}`]));
108
+ } catch {
109
+ // Try the other name before deciding there is no secret at all.
110
+ }
86
111
  }
112
+ throw new UserError(
113
+ "Could not read AGENT_API_SECRET from Secret Manager (nor the old AGENT_WEBHOOK_SECRET).\n" +
114
+ "Create it first: meffecta-agent set-secret AGENT_API_SECRET --random",
115
+ );
87
116
  }
88
117
 
89
118
  /** An authenticated request to the deployment's own API. */
90
119
  export async function api(deployment, path, { method = "GET", body, accept } = {}) {
91
120
  const url = `${serviceUrl(deployment)}${path}`;
92
- const headers = { Authorization: `Bearer ${webhookSecret(deployment)}` };
121
+ const headers = { Authorization: `Bearer ${apiSecret(deployment)}` };
93
122
  if (accept) {
94
123
  headers.Accept = accept;
95
124
  }
@@ -132,6 +132,57 @@ const INTEGRATIONS = {
132
132
  ],
133
133
  },
134
134
 
135
+ hubspot: {
136
+ title: "HubSpot CRM",
137
+ gives: "query-hubspot — pipelines, contacts, deals, activity. manage-hubspot writes, and only when a job says to",
138
+ needs: [
139
+ "A private app in the HubSpot portal: Settings → Integrations → Private Apps → Create.",
140
+ "Grant only the scopes the jobs need — crm.objects.*.read for reporting; add the",
141
+ "matching .write scopes only if a job is meant to change records.",
142
+ ],
143
+ steps: [["meffecta-agent set-secret HUBSPOT_TOKEN", "the private app's access token"]],
144
+ more: [
145
+ "A second portal is HUBSPOT_<NAME>_TOKEN, and the job says which portal it may touch.",
146
+ "Reading and writing are two skills on purpose: a reporting job loads query-hubspot and",
147
+ "then cannot alter the CRM at all, because the ability to is not in its context.",
148
+ "Rotate the token from the same screen; HubSpot can expire the old one after 7 days.",
149
+ ],
150
+ },
151
+
152
+ kleer: {
153
+ title: "Kleer accounting and payroll",
154
+ gives:
155
+ "query-kleer — invoices out and in, payment status, vouchers, the SIE4E export, payroll runs, bank transactions. Read-only",
156
+ needs: [
157
+ "A Kleer API token, requested through your accounting consultant — Kleer issues it by",
158
+ "SMS, there is no self-service. It inherits the permissions of the Kleer user it is",
159
+ "bound to, so ask for it on a READ-ONLY user: that, not the skill, is what stops a",
160
+ "write. (Kleer was PE Accounting before the rebrand; the docs still say PE in places.)",
161
+ ],
162
+ steps: [["meffecta-agent set-secret KLEER_API_TOKEN", "the token Kleer sent"]],
163
+ more: [
164
+ "Company ids are discovered at run time from the token, so nothing else needs setting.",
165
+ "There is a test environment on the same token, mirroring production a day behind —",
166
+ "worth pointing a new job at first, since live books are not a place to explore.",
167
+ ],
168
+ },
169
+
170
+ mongodb: {
171
+ title: "A MongoDB database",
172
+ gives: "query-mongodb — counts, filters and aggregations over a product database. Read-only",
173
+ needs: [
174
+ "A MongoDB user with the `read` role on the database and nothing more, and a connection",
175
+ "URI for it. The role is the real boundary — the skill refuses to write, but a",
176
+ "read-only user makes a mistake impossible rather than merely forbidden.",
177
+ ],
178
+ steps: [["meffecta-agent set-secret ACME_MONGODB_URI_READONLY", "one per database, named for the project"]],
179
+ more: [
180
+ "Connection strings are per-database, so there is no single shared variable: name each",
181
+ "one for its project and give it a file in the deployment's systems/ register, which is where",
182
+ "the skill looks for the name. The driver already ships in the engine image.",
183
+ ],
184
+ },
185
+
135
186
  cloudflare: {
136
187
  title: "Cloudflare",
137
188
  gives: "cloudflare — DNS, cache purge, Pages deployments, certificate checks",
@@ -157,7 +208,7 @@ const INTEGRATIONS = {
157
208
  ],
158
209
  ["meffecta-agent set-env AGENT_INBOX_ACCOUNT=SALES", "OAuth path only: which GMAIL_<NAME>_* account to read"],
159
210
  ["meffecta-agent set-env AGENT_INBOX_POLL_SECONDS=300", "how often; the sweep inherits this cadence"],
160
- ["meffecta-agent setup-scheduler", "re-syncs the sweep to that interval"],
211
+ ["meffecta-agent sync-triggers", "re-syncs the sweep to that interval"],
161
212
  ],
162
213
  more: [
163
214
  "Then, in the job's frontmatter:",
@@ -171,7 +222,7 @@ const INTEGRATIONS = {
171
222
  "before the run starts, so it never reaches the model. Leave it out and the job",
172
223
  "refuses every message — an omission is not read as consent.",
173
224
  "",
174
- " marcus@acme.com one address @acme.com that domain (or *@acme.com)",
225
+ " you@acme.com one address @acme.com that domain (or *@acme.com)",
175
226
  " *@*.acme.com any subdomain * anyone at all",
176
227
  "",
177
228
  "A subdomain rule does not cover the bare domain; list both if you want both.",