@getnoan/wizard 0.1.6 → 0.1.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.
package/README.md CHANGED
@@ -14,8 +14,8 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
14
14
 
15
15
  ## What it does
16
16
 
17
- 1. **Your key.** Create one at app.getnoan.com → Settings → API → New key and paste it in (it is
18
- not echoed). The wizard checks it with `GET /me`, writes it to `.env` as `NOAN_API_KEY` and
17
+ 1. **Your key.** Create your own at app.getnoan.com → Settings → API → New key and paste it in (it is
18
+ not echoed). This is *your* key, for you and your coding assistant. The wizard checks it with `GET /me`, writes it to `.env` as `NOAN_API_KEY` and
19
19
  `NOAN_PERSONAL_API_KEY`, and makes sure `.env` is gitignored. Already have `NOAN_API_KEY` in
20
20
  your environment? It uses that and never asks.
21
21
  2. **Your coding assistants.** Whatever is installed gets the MCP address `https://mcp.getnoan.com/mcp`
@@ -31,8 +31,18 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
31
31
  4. **Your fact layer.** If the workspace holds fewer than ten facts, the wizard does not guess
32
32
  facts for you. It hands the seeding to your assistant, which has the procedure from the skill:
33
33
  read your website, repo and docs, propose a structure, and write only after you say yes.
34
- 5. **The agent pack** (optional, `--agents`). Asks what to call your agent — Verity by default, the
35
- name she answers to in NOAN — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
34
+ 5. **The agent pack** (optional, `--agents`). The agents should run as **NOAN's agent identity**,
35
+ not as you: an Owner creates it once under Settings → Team → Agent, and mints its keys under
36
+ Settings → API → Agent API Keys. The wizard asks for that agent key (or takes `--agent-key` /
37
+ `NOAN_AGENT_API_KEY`) and checks it is the agent's. The agents then run on it, answer to the
38
+ agent's id (`AGENT_IDENTITY_IDS`), and take direction from you (`COMMANDERS`). Work they hand
39
+ back to a person (the weekly fact review, parked tasks, support follow-ups) goes to you: the
40
+ `PARK_ASSIGNEES_*`, `FACT_ALIGNMENT_REVIEW_ASSIGNEES`, `REPLY_HUMAN_ASSIGNEES` and
41
+ `HUMAN_IDENTITIES` variables, which a re-run keeps if you have since changed them. Your own key stays
42
+ for seeding and your assistant. Skip it and the agents run as you; the wizard warns that your own
43
+ task comments will then read as the agent's, so steer them by email.
44
+
45
+ It asks what to call your agent — Verity by default — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
36
46
  to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
37
47
  and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
38
48
  instructions are in your workspace, records the name and the block slugs as repository variables,
@@ -54,7 +64,8 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
54
64
  `INSTALL.md` and a one-click Render deploy link.
55
65
 
56
66
  Your personal NOAN key is **not** written into either service's `.env`: these answer the open
57
- internet, so each gets a NOAN key made for it alone. The report says so.
67
+ internet, so each gets a NOAN key made for it alone, minted under your NOAN agent
68
+ (Settings → API → Agent API Keys). The report says so.
58
69
  7. **The report.** What happened, and the one thing to do next.
59
70
 
60
71
  ## For a coding assistant
@@ -82,6 +93,7 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
82
93
  | `-y`, `--yes` | non-interactive |
83
94
  | `--json` | machine-readable report (implies `--yes`) |
84
95
  | `--api-key <key>` | the NOAN key (else `NOAN_API_KEY`, else a prompt) |
96
+ | `--agent-key <key>` | an agent-owned NOAN key the agent pack runs on (else `NOAN_AGENT_API_KEY`, else a prompt) |
85
97
  | `--global` | user-scope client config instead of project scope |
86
98
  | `--dir <path>` | the project directory (default: current) |
87
99
  | `--agents` / `--no-agents` | include or skip the agent pack step |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getnoan/wizard",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "One command from nothing to a grounded agent: sign in to NOAN, wire your coding assistant, install the skill, and set up the agent pack.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agents.mjs CHANGED
@@ -35,7 +35,8 @@ export function parseSeedOutput(text) {
35
35
  * name for the model key and one of the two is required. It is listed so a caller enumerating
36
36
  * the pack's secrets knows the name exists, not so anyone sets both. */
37
37
  export const PACK_SECRETS = ["NOAN_PERSONAL_API_KEY", "RESEND_API_KEY", "ANTHROPIC_API_KEY", "LLM_API_KEY", "DATABASE_URL", "FIRECRAWL_API_KEY", "NEWSLETTER_UNSUB_SECRET"];
38
- export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "ANTHROPIC_BASE_URL", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG"];
38
+ export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "ANTHROPIC_BASE_URL", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG",
39
+ "FACT_ALIGNMENT_REVIEW_ASSIGNEES", "PARK_ASSIGNEES_CS", "PARK_ASSIGNEES_SALES", "PARK_ASSIGNEES_ENG", "REPLY_HUMAN_ASSIGNEES", "HUMAN_IDENTITIES"];
39
40
 
40
41
  /** The pack's model endpoint, from the same variable the pack itself reads. Bare base, no
41
42
  * trailing slash; empty string means the default vendor.
@@ -122,6 +123,13 @@ export function setSecret(dest, name, value, dryRun) {
122
123
  const r = spawnSync("gh", ["secret", "set", name], { cwd: dest, input: value, encoding: "utf8" });
123
124
  return { name, action: r.status === 0 ? "set" : "failed", error: r.status === 0 ? undefined : (r.stderr || "").trim().split("\n")[0] };
124
125
  }
126
+ /** The repository variables already set on the fork, by name. A list that cannot be read is
127
+ * empty: the defaults are then written, and the gh failure shows on the set that follows. */
128
+ export function existingVariables(dest, dryRun) {
129
+ if (dryRun) return new Set();
130
+ const r = spawnSync("gh", ["variable", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
131
+ return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
132
+ }
125
133
  export function setVariable(dest, name, value, dryRun) {
126
134
  if (dryRun) return { name, action: "would set" };
127
135
  const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
package/src/args.mjs CHANGED
@@ -20,6 +20,7 @@ Options:
20
20
  -y, --yes non-interactive: take every default, never prompt (for an agent mid-session)
21
21
  --json machine-readable report on stdout (implies --yes)
22
22
  --api-key <key> the NOAN key (else NOAN_API_KEY / NOAN_PERSONAL_API_KEY, else a prompt)
23
+ --agent-key <key> an agent-owned NOAN key the agent pack runs on (else NOAN_AGENT_API_KEY, else a prompt)
23
24
  --global write user-scope client config (~/.claude.json, ~/.cursor/mcp.json, ...)
24
25
  instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
25
26
  --dir <path> the project directory (default: the current directory)
@@ -37,7 +38,7 @@ Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
37
38
  `;
38
39
 
39
40
  export function parseArgs(argv) {
40
- const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
41
+ const a = { yes: false, json: false, apiKey: null, agentKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
41
42
  mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
42
43
  for (let i = 0; i < argv.length; i++) {
43
44
  const t = argv[i];
@@ -46,6 +47,8 @@ export function parseArgs(argv) {
46
47
  else if (t === "--json") { a.json = true; a.yes = true; }
47
48
  else if (t === "--api-key") a.apiKey = next();
48
49
  else if (t.startsWith("--api-key=")) a.apiKey = t.slice(10);
50
+ else if (t === "--agent-key") a.agentKey = next();
51
+ else if (t.startsWith("--agent-key=")) a.agentKey = t.slice(12);
49
52
  else if (t === "--global") a.global = true;
50
53
  else if (t === "--dir") a.dir = next();
51
54
  else if (t.startsWith("--dir=")) a.dir = t.slice(6);
package/src/clients.mjs CHANGED
@@ -29,7 +29,10 @@ export function mergeJsonServer(existing, container, entry) {
29
29
  export function mergeToml(text, table, lines) {
30
30
  const block = `[${table}]\n${lines.join("\n")}\n`;
31
31
  const src = text || "";
32
- const rx = new RegExp(`^\\[${table.replace(/\./g, "\\.")}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m");
32
+ // Escape every regex metacharacter, not only the dot: the table name is ours today, but a
33
+ // partial escape is exactly what CodeQL js/incomplete-sanitization flags, and it is one line.
34
+ const esc = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ const rx = new RegExp(`^\\[${esc}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m");
33
36
  if (rx.test(src)) {
34
37
  const cur = src.match(rx)[0];
35
38
  if (cur.trim() === block.trim()) return { text: src, action: "unchanged" };
package/src/noan.mjs CHANGED
@@ -3,6 +3,8 @@ export const API = "https://api.getnoan.com/v1";
3
3
  export const MCP_URL = "https://mcp.getnoan.com/mcp";
4
4
  export const APP_URL = "https://app.getnoan.com";
5
5
  export const KEY_PAGE_HINT = "app.getnoan.com → Settings → API → New key";
6
+ /** Where an AGENT-owned key is minted: NOAN's agent identity (role bot), created by an Owner. */
7
+ export const AGENT_KEY_PAGE_HINT = "app.getnoan.com → Settings → API → Agent API Keys";
6
8
 
7
9
  export function looksLikeKey(k) { return typeof k === "string" && /^npak_[A-Za-z0-9_-]{16,}$/.test(k.trim()); }
8
10
 
@@ -20,6 +22,72 @@ export async function whoAmI(key) {
20
22
  return { ok: true, project: body.project, identity: body.identity };
21
23
  }
22
24
 
25
+ /** The pack's variables naming who gets work an agent hands back: identity ids, comma-separated.
26
+ * HUMAN_IDENTITIES (email=id pairs) is the sixth, set alongside these. */
27
+ export const HAND_BACK_ASSIGNEE_VARS = ["FACT_ALIGNMENT_REVIEW_ASSIGNEES", "PARK_ASSIGNEES_CS", "PARK_ASSIGNEES_SALES", "PARK_ASSIGNEES_ENG", "REPLY_HUMAN_ASSIGNEES"];
28
+
29
+ /** Is this /me identity NOAN's agent? Its role is "bot" (and the API gives it no email). */
30
+ export function isAgentIdentity(identity) {
31
+ return identity?.role === "bot";
32
+ }
33
+
34
+ /**
35
+ * Who the agents run as, and who steers them, from GET /me on the keys the user gave.
36
+ *
37
+ * NOAN has an agent role: one agent identity per workspace (role bot, no login), which an Owner
38
+ * creates under Settings → Team → Agent; its keys are minted under Settings → API → Agent API Keys. Its id is what the agents answer to
39
+ * (AGENT_IDENTITY_IDS: tasks assigned to it trigger them, comments by it are the agent's own).
40
+ * The people who steer and approve (COMMANDERS) are humans, never the agent.
41
+ *
42
+ * Deriving both from one key's /me was wrong either way: a person's key made the person "the
43
+ * agent", so every comment they wrote was classed as the agent's own and ignored; an agent key
44
+ * made the agent the only commander, and the agent has no email to be one.
45
+ *
46
+ * person GET /me of the user's own key (step 1), when it is a person
47
+ * agent GET /me of an agent-owned key, when one was given (or step 1's, if that was the agent)
48
+ * commanders extra addresses (COMMANDERS env or a prompt), comma-separated
49
+ *
50
+ * Returns the ids/addresses to configure, which key the agents should run on ("agent" or
51
+ * "person"), who gets the work the agents hand back (handBack), and the warnings to say out loud.
52
+ *
53
+ * handBack matters only under the agent role. On a person's key the pack falls back to that
54
+ * person (fact alignment's review) or to nobody (parks never read GET /me); on the agent's key
55
+ * every fallback is deliberately refused, because handing work to the key's owner would hand it
56
+ * to the agent. So with no hand-back settings an agent-key install files its weekly review, its
57
+ * parked tasks and its support follow-ups with no person on them, and nothing errors. The person
58
+ * who ran the wizard is the default owner of all of it. On a person's key handBack stays empty:
59
+ * that person IS the agent there (AGENT_IDENTITY_IDS), so assigning them would hand the work
60
+ * straight back to the agents.
61
+ */
62
+ export function agentRoles({ person = null, agent = null, commanders = "" } = {}) {
63
+ const warnings = [];
64
+ const agentId = agent && isAgentIdentity(agent) ? agent.id : null;
65
+ const humans = new Set(String(commanders || "").split(",").map(s => s.trim().toLowerCase()).filter(Boolean));
66
+ if (person && !isAgentIdentity(person) && person.email) humans.add(String(person.email).toLowerCase());
67
+ const out = { agentIds: agentId, commanders: [...humans].join(","), runAs: agentId ? "agent" : "person", warnings };
68
+ if (!agentId) {
69
+ // No agent identity: keep the old single-account setup, but say what it costs.
70
+ out.agentIds = person?.id || null;
71
+ warnings.push(
72
+ "No agent key, so the agents run as you and answer to your own account. Every comment you write on a task " +
73
+ "will read as the agent's own, so steer and approve by email, not task comments. To fix it, create the agent " +
74
+ `(${AGENT_KEY_PAGE_HINT}) and re-run with --agent-key.`);
75
+ }
76
+ if (!out.commanders) warnings.push("No commander: nobody can steer or approve the agents until COMMANDERS lists a person's email.");
77
+ out.handBack = {};
78
+ if (out.runAs === "agent") {
79
+ const human = person && !isAgentIdentity(person) && person.id ? person : null;
80
+ if (human) {
81
+ for (const n of HAND_BACK_ASSIGNEE_VARS) out.handBack[n] = human.id;
82
+ if (human.email) out.handBack.HUMAN_IDENTITIES = `${String(human.email).toLowerCase()}=${human.id}`;
83
+ } else warnings.push(
84
+ "No person's key, so the work the agents hand back (the weekly fact review, parked tasks, support follow-ups) " +
85
+ `has nobody to go to. Set ${HAND_BACK_ASSIGNEE_VARS.join(", ")} on the fork to a person's NOAN identity id ` +
86
+ "(GET /me with their key), or re-run with your own key first and the agent key as --agent-key.");
87
+ }
88
+ return out;
89
+ }
90
+
23
91
  /** How many facts the workspace holds — the signal for "empty, hand the seeding over". */
24
92
  export async function factCount(key) {
25
93
  const { status, body } = await get("/facts?per_page=1", key);
package/src/run.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { existsSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { makeUI } from "./ui.mjs";
5
- import { whoAmI, factCount, classifyWorkspace, looksLikeKey, APP_URL, KEY_PAGE_HINT, MCP_URL } from "./noan.mjs";
5
+ import { whoAmI, factCount, classifyWorkspace, looksLikeKey, APP_URL, KEY_PAGE_HINT, MCP_URL, isAgentIdentity, agentRoles, AGENT_KEY_PAGE_HINT } from "./noan.mjs";
6
6
  import { writeEnv, ensureGitignored } from "./env-file.mjs";
7
7
  import { wireClients, MANUAL_CLIENTS } from "./clients.mjs";
8
8
  import { fetchSkillFiles, skillTargets, installSkill, writePointers } from "./skill.mjs";
@@ -38,7 +38,10 @@ export async function run(args) {
38
38
  if (!looksLikeKey(key)) ui.warn("that does not look like a NOAN key (they start with npak_) — checking anyway");
39
39
  const me = await whoAmI(key);
40
40
  if (!me.ok) { ui.warn(me.reason); report.steps.auth = { ok: false, reason: me.reason }; return finish(2, "cancelled"); }
41
- ui.ok(`key accepted: ${me.identity?.email || "?"} in workspace "${me.project?.name || "?"}"`);
41
+ if (isAgentIdentity(me.identity)) {
42
+ ui.ok(`key accepted: your NOAN agent "${me.identity?.name || "?"}" (role bot) in workspace "${me.project?.name || "?"}"`);
43
+ ui.warn("this key belongs to the agent, not to you: your coding assistant will write as the agent too. For your own use, a personal key (" + KEY_PAGE_HINT + ") is the better fit.");
44
+ } else ui.ok(`key accepted: ${me.identity?.email || "?"} in workspace "${me.project?.name || "?"}"`);
42
45
  const env = writeEnv(dir, { NOAN_API_KEY: key, NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
43
46
  const gi = ensureGitignored(dir, { dryRun: args.dryRun });
44
47
  ui.ok(`${env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (NOAN_API_KEY, NOAN_PERSONAL_API_KEY); .gitignore: ${gi.action}`);
@@ -175,7 +178,32 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
175
178
  ? await ui.ask(`Pronouns for ${askedName}, in ${askedName}'s own prose (blank for they/them)`)
176
179
  : "") });
177
180
 
178
- const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
181
+ /* Who the agents run as, and who steers them (NOAN's agent role). The agents should run on an
182
+ * AGENT-owned key, so what they write is the agent's and its id is what they answer to; the
183
+ * people who steer them are humans. Step 1's key is normally the user's own, which stays for
184
+ * seeding and the coding assistant. See agentRoles in noan.mjs. */
185
+ let agentKey = null, agentMe = null;
186
+ if (isAgentIdentity(me.identity)) { agentKey = key; agentMe = me.identity; }
187
+ else {
188
+ const given = (args.agentKey || process.env.NOAN_AGENT_API_KEY || "").trim()
189
+ || (ui.interactive ? await ui.ask(`Agent key, so the agents run as your NOAN agent rather than as you (${AGENT_KEY_PAGE_HINT}; blank to skip)`, { secret: true }) : "");
190
+ if (given) {
191
+ const am = await whoAmI(given);
192
+ if (!am.ok) ui.warn(`agent key not accepted (${am.reason}); continuing without one`);
193
+ else if (am.project?.id && me.project?.id && am.project.id !== me.project.id) ui.warn(`that agent key is for workspace "${am.project?.name}", not "${me.project?.name}"; continuing without one`);
194
+ else if (!isAgentIdentity(am.identity)) ui.warn(`that key belongs to ${am.identity?.email || "a person"}, not the agent; continuing without one`);
195
+ else { agentKey = given; agentMe = am.identity; ui.ok(`agent key accepted: "${am.identity?.name || "agent"}" (role bot)`); }
196
+ }
197
+ }
198
+ const commandersIn = (process.env.COMMANDERS || "").trim()
199
+ || (isAgentIdentity(me.identity) && ui.interactive ? await ui.ask("Email(s) of the people who steer and approve the agents, comma-separated") : "");
200
+ const roles = agentRoles({ person: isAgentIdentity(me.identity) ? null : me.identity, agent: agentMe, commanders: commandersIn });
201
+ for (const w of roles.warnings) ui.warn(w);
202
+ out.identity = { runAs: roles.runAs, agentIds: roles.agentIds, commanders: roles.commanders };
203
+ // The pack reads its NOAN key from this secret name; under the agent role it holds the agent's key.
204
+ const runKey = roles.runAs === "agent" ? agentKey : key;
205
+
206
+ const secrets = { NOAN_PERSONAL_API_KEY: runKey, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
179
207
  ...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
180
208
  for (const [n, v] of Object.entries(secrets)) { const r = pack.setSecret(fork.dest, n, v, args.dryRun); out.secrets.push(r); ui.ok(`secret ${n}: ${r.action}${r.error ? ` — ${r.error}` : ""}`); }
181
209
  // The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
@@ -183,7 +211,7 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
183
211
  // pack's own workflows had.
184
212
  const vars = { DRY_RUN: "1", STATE_BACKEND: db ? "postgres" : "local", ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
185
213
  ...identity,
186
- COMPANY_NAME: me.project?.name || "", ...(me.identity?.id && { AGENT_IDENTITY_IDS: me.identity.id }), ...(me.identity?.email && { COMMANDERS: me.identity.email }) };
214
+ COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
187
215
  // The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
188
216
  pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
189
217
  const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
@@ -201,7 +229,20 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
201
229
  report.next.push({ id: "grounding", say: `Fill the ${grounding.gaps.length} block(s) the agents read (tasks are on your NOAN board): ${grounding.gaps.map(g => g.title).join(", ")}.`, why: "an agent grounded in an empty block fails quietly" });
202
230
  } else ui.ok("every block the agents read holds a fact");
203
231
  }
232
+ // Who gets work handed back (agent role only, see agentRoles). An env value wins; one already
233
+ // set on the fork is kept, since it is a list a person may have extended with teammates.
234
+ const have = pack.existingVariables(fork.dest, args.dryRun);
235
+ const handBackKept = [], handBackToYou = [];
236
+ for (const [n, v] of Object.entries(roles.handBack)) {
237
+ const given = (process.env[n] || "").trim();
238
+ if (given) vars[n] = given;
239
+ else if (have.has(n)) handBackKept.push(n);
240
+ else { vars[n] = v; handBackToYou.push(n); }
241
+ }
242
+ out.handBack = { toYou: handBackToYou, kept: handBackKept };
204
243
  for (const [n, v] of Object.entries(vars)) { if (!v) continue; const r = pack.setVariable(fork.dest, n, v, args.dryRun); out.variables.push(r); }
244
+ if (handBackToYou.length) ui.ok(`work the agents hand back goes to ${me.identity?.email || "you"} (fact review, parked tasks, support follow-ups)`);
245
+ if (handBackKept.length) ui.ok(`kept the fork's own ${handBackKept.join(", ")}`);
205
246
  ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
206
247
  // No point dispatching a run that will only fail for a missing key: say what is missing instead.
207
248
  if (!anthropic || !resend) {
package/src/web.mjs CHANGED
@@ -162,7 +162,7 @@ export function webNext(agent, { dest, deployUrl, missingModelKey = null }) {
162
162
  say: `Finish ${agent.label} (${agent.what}): in ${dest}, follow INSTALL.md ${agent.remaining}.`
163
163
  + (missingModelKey ? ` It needs ${missingModelKey} too — the wizard had none to write.` : "")
164
164
  + (deployUrl ? ` One-click deploy: ${deployUrl}.` : "")
165
- + ` Create a NOAN API key for this service alone and set it as NOAN_AGENT_API_KEY in the deploy env — not your personal key.`
165
+ + ` Mint a NOAN key for this service alone under your NOAN agent (Settings → API → Agent API Keys) and set it as NOAN_AGENT_API_KEY in the deploy env — not your personal key.`
166
166
  + ` ${agent.after}`,
167
167
  why: "the rest needs accounts only you can create; INSTALL.md marks those steps HUMAN",
168
168
  };