@getnoan/wizard 0.1.6 → 0.1.8
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 +22 -6
- package/package.json +1 -1
- package/src/agents.mjs +42 -2
- package/src/args.mjs +4 -1
- package/src/clients.mjs +4 -1
- package/src/env-file.mjs +8 -0
- package/src/noan.mjs +68 -0
- package/src/run.mjs +56 -7
- package/src/web.mjs +1 -1
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
|
|
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,12 +31,26 @@ 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`).
|
|
35
|
-
|
|
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,
|
|
39
|
-
and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
|
|
49
|
+
and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0, and a re-run keeps the fork's `DRY_RUN`, and its `STATE_BACKEND` unless you give a Postgres URL. That switch is yours.
|
|
50
|
+
|
|
51
|
+
The newsletter signs unsubscribe links with `NEWSLETTER_UNSUB_SECRET`, and your site has to check
|
|
52
|
+
them with the same value. The wizard creates it once, stores it as a repository secret and in
|
|
53
|
+
`noan-agent-pack/.env` so you can copy it to your site, and keeps it on every re-run.
|
|
40
54
|
|
|
41
55
|
The model does not have to be Anthropic's. Set `ANTHROPIC_BASE_URL` in the environment before
|
|
42
56
|
running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
|
|
@@ -54,7 +68,8 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
54
68
|
`INSTALL.md` and a one-click Render deploy link.
|
|
55
69
|
|
|
56
70
|
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
|
|
71
|
+
internet, so each gets a NOAN key made for it alone, minted under your NOAN agent
|
|
72
|
+
(Settings → API → Agent API Keys). The report says so.
|
|
58
73
|
7. **The report.** What happened, and the one thing to do next.
|
|
59
74
|
|
|
60
75
|
## For a coding assistant
|
|
@@ -82,6 +97,7 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
|
82
97
|
| `-y`, `--yes` | non-interactive |
|
|
83
98
|
| `--json` | machine-readable report (implies `--yes`) |
|
|
84
99
|
| `--api-key <key>` | the NOAN key (else `NOAN_API_KEY`, else a prompt) |
|
|
100
|
+
| `--agent-key <key>` | an agent-owned NOAN key the agent pack runs on (else `NOAN_AGENT_API_KEY`, else a prompt) |
|
|
85
101
|
| `--global` | user-scope client config instead of project scope |
|
|
86
102
|
| `--dir <path>` | the project directory (default: current) |
|
|
87
103
|
| `--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.
|
|
3
|
+
"version": "0.1.8",
|
|
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
|
@@ -9,7 +9,7 @@ import { spawnSync } from "node:child_process";
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
-
import { writeEnv } from "./env-file.mjs";
|
|
12
|
+
import { writeEnv, readEnvValue } from "./env-file.mjs";
|
|
13
13
|
|
|
14
14
|
export const PACK_REPO = "getnoan/agent-pack";
|
|
15
15
|
|
|
@@ -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,30 @@ 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
|
+
}
|
|
133
|
+
/** The variables a re-run must not undo. DRY_RUN=1 only on a fork that has none, since it's the
|
|
134
|
+
* user's switch for taking agents live. STATE_BACKEND follows a Postgres URL given on this run,
|
|
135
|
+
* else keeps the fork's, so leaving the prompt blank doesn't drop the agents' state ledger. */
|
|
136
|
+
export function forkStateVars(have, { db = "" } = {}) {
|
|
137
|
+
const set = {}, kept = [];
|
|
138
|
+
if (have.has("DRY_RUN")) kept.push("DRY_RUN"); else set.DRY_RUN = "1";
|
|
139
|
+
if (db) set.STATE_BACKEND = "postgres";
|
|
140
|
+
else if (have.has("STATE_BACKEND")) kept.push("STATE_BACKEND");
|
|
141
|
+
else set.STATE_BACKEND = "local";
|
|
142
|
+
return { set, kept };
|
|
143
|
+
}
|
|
144
|
+
/** The repository secrets already set on the fork, by name. Same rules as existingVariables. */
|
|
145
|
+
export function existingSecrets(dest, dryRun) {
|
|
146
|
+
if (dryRun) return new Set();
|
|
147
|
+
const r = spawnSync("gh", ["secret", "list", "--json", "name", "-q", ".[].name"], { cwd: dest, encoding: "utf8" });
|
|
148
|
+
return new Set(r.status === 0 ? r.stdout.split("\n").map(s => s.trim()).filter(Boolean) : []);
|
|
149
|
+
}
|
|
125
150
|
export function setVariable(dest, name, value, dryRun) {
|
|
126
151
|
if (dryRun) return { name, action: "would set" };
|
|
127
152
|
const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
|
|
@@ -188,5 +213,20 @@ export function dispatchDryRun(dest, workflow, dryRun) {
|
|
|
188
213
|
|
|
189
214
|
export const randomSecret = () => randomBytes(24).toString("hex");
|
|
190
215
|
|
|
216
|
+
export const UNSUB_SECRET = "NEWSLETTER_UNSUB_SECRET";
|
|
217
|
+
|
|
218
|
+
/** The newsletter signs every unsubscribe link with this secret and the user's own site checks it
|
|
219
|
+
* with the same value. A GitHub secret can't be read back, so the clone's .env holds the copy the
|
|
220
|
+
* user reads. An existing value is kept: a new one breaks every unsubscribe link already sent. */
|
|
221
|
+
export function unsubSecret(dest, forkSecrets, { dryRun = false } = {}) {
|
|
222
|
+
const onFork = forkSecrets.has(UNSUB_SECRET);
|
|
223
|
+
const local = readEnvValue(dest, UNSUB_SECRET);
|
|
224
|
+
if (local) return { value: local, setOnFork: !onFork, action: onFork ? "kept" : "set from .env" };
|
|
225
|
+
if (onFork) return { value: null, setOnFork: false, action: "kept, not in .env" };
|
|
226
|
+
const value = randomSecret();
|
|
227
|
+
writeEnv(dest, { [UNSUB_SECRET]: value }, { dryRun });
|
|
228
|
+
return { value, setOnFork: true, action: "created" };
|
|
229
|
+
}
|
|
230
|
+
|
|
191
231
|
/** Write the pack's .env in the clone so the seeds (and a local run) have what they need. */
|
|
192
232
|
export function writePackEnv(dest, pairs, dryRun) { return writeEnv(dest, pairs, { dryRun }); }
|
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
|
-
|
|
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/env-file.mjs
CHANGED
|
@@ -16,6 +16,14 @@ export function upsertEnv(text, pairs) {
|
|
|
16
16
|
return out.join("\n") + "\n";
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** One key's value from dir/.env, "" when the file or the key is missing. */
|
|
20
|
+
export function readEnvValue(dir, key) {
|
|
21
|
+
const p = path.join(dir, ".env");
|
|
22
|
+
if (!existsSync(p)) return "";
|
|
23
|
+
const m = readFileSync(p, "utf8").match(new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(\\S+)`, "m"));
|
|
24
|
+
return m ? m[1].replace(/^["']|["']$/g, "") : "";
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
export function writeEnv(dir, pairs, { dryRun = false } = {}) {
|
|
20
28
|
const p = path.join(dir, ".env");
|
|
21
29
|
const before = existsSync(p) ? readFileSync(p, "utf8") : "";
|
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
|
-
|
|
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,15 +178,49 @@ 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
|
-
|
|
179
|
-
|
|
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 }),
|
|
207
|
+
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }) };
|
|
208
|
+
const unsub = pack.unsubSecret(fork.dest, pack.existingSecrets(fork.dest, args.dryRun), { dryRun: args.dryRun });
|
|
209
|
+
if (unsub.setOnFork) secrets[pack.UNSUB_SECRET] = unsub.value;
|
|
180
210
|
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}` : ""}`); }
|
|
211
|
+
if (!unsub.setOnFork) { out.secrets.push({ name: pack.UNSUB_SECRET, action: unsub.action }); ui.ok(`secret ${pack.UNSUB_SECRET}: ${unsub.action}`); }
|
|
212
|
+
if (unsub.action === "kept, not in .env") {
|
|
213
|
+
ui.warn(`${pack.UNSUB_SECRET} is on the fork but not in ${fork.dest}/.env, so your site can't check newsletter unsubscribe links with it`);
|
|
214
|
+
report.next.push({ id: "unsub-secret", say: `Before sending a newsletter, pick a new ${pack.UNSUB_SECRET} and set it in ${fork.dest}/.env, as the fork's secret, and on your site.`, why: "the site checks unsubscribe links with the same value the agents sign them with" });
|
|
215
|
+
}
|
|
181
216
|
// The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
|
|
182
217
|
// and still calls the default vendor, which is the same "documented is not delivered" trap the
|
|
183
218
|
// pack's own workflows had.
|
|
184
|
-
const
|
|
219
|
+
const have = pack.existingVariables(fork.dest, args.dryRun);
|
|
220
|
+
const forkState = pack.forkStateVars(have, { db });
|
|
221
|
+
const vars = { ...forkState.set, ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
|
|
185
222
|
...identity,
|
|
186
|
-
COMPANY_NAME: me.project?.name || "", ...(
|
|
223
|
+
COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
|
|
187
224
|
// The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
|
|
188
225
|
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
226
|
const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
@@ -201,8 +238,20 @@ async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
|
201
238
|
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
239
|
} else ui.ok("every block the agents read holds a fact");
|
|
203
240
|
}
|
|
241
|
+
// Who gets work handed back (agent role only, see agentRoles). An env value wins; one already
|
|
242
|
+
// set on the fork is kept, since it is a list a person may have extended with teammates.
|
|
243
|
+
const handBackKept = [], handBackToYou = [];
|
|
244
|
+
for (const [n, v] of Object.entries(roles.handBack)) {
|
|
245
|
+
const given = (process.env[n] || "").trim();
|
|
246
|
+
if (given) vars[n] = given;
|
|
247
|
+
else if (have.has(n)) handBackKept.push(n);
|
|
248
|
+
else { vars[n] = v; handBackToYou.push(n); }
|
|
249
|
+
}
|
|
250
|
+
out.handBack = { toYou: handBackToYou, kept: handBackKept };
|
|
204
251
|
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); }
|
|
205
|
-
ui.ok(
|
|
252
|
+
if (handBackToYou.length) ui.ok(`work the agents hand back goes to ${me.identity?.email || "you"} (fact review, parked tasks, support follow-ups)`);
|
|
253
|
+
if (handBackKept.length) ui.ok(`kept the fork's own ${handBackKept.join(", ")}`);
|
|
254
|
+
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"}${forkState.set.DRY_RUN ? " (DRY_RUN=1: every agent stays in safe mode)" : ""}${forkState.kept.length ? `; kept the fork's own ${forkState.kept.join(", ")}` : ""}`);
|
|
206
255
|
// No point dispatching a run that will only fail for a missing key: say what is missing instead.
|
|
207
256
|
if (!anthropic || !resend) {
|
|
208
257
|
const missing = [!anthropic && mName, !resend && "RESEND_API_KEY"].filter(Boolean);
|
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
|
-
+ `
|
|
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
|
};
|