@getnoan/wizard 0.1.5 → 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 +37 -7
- package/package.json +1 -1
- package/src/agents.mjs +9 -1
- package/src/args.mjs +14 -2
- package/src/clients.mjs +4 -1
- package/src/noan.mjs +68 -0
- package/src/report.mjs +7 -0
- package/src/run.mjs +126 -7
- package/src/web.mjs +169 -0
package/README.md
CHANGED
|
@@ -8,13 +8,14 @@ npx -y @getnoan/wizard@latest
|
|
|
8
8
|
|
|
9
9
|
It takes your NOAN API key, points your coding assistants at the NOAN MCP server, installs the
|
|
10
10
|
NOAN skill, checks whether your fact layer has anything in it, and, if you want, sets up the six
|
|
11
|
-
open-source agents on your own GitHub account
|
|
11
|
+
open-source agents on your own GitHub account and gets the two web agents — booking pages and a
|
|
12
|
+
site chat — ready to deploy. Then it tells you what it did and what to do next.
|
|
12
13
|
Run it again any time; every write is a merge, and nothing you have is overwritten.
|
|
13
14
|
|
|
14
15
|
## What it does
|
|
15
16
|
|
|
16
|
-
1. **Your key.** Create
|
|
17
|
-
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
|
|
18
19
|
`NOAN_PERSONAL_API_KEY`, and makes sure `.env` is gitignored. Already have `NOAN_API_KEY` in
|
|
19
20
|
your environment? It uses that and never asks.
|
|
20
21
|
2. **Your coding assistants.** Whatever is installed gets the MCP address `https://mcp.getnoan.com/mcp`
|
|
@@ -30,8 +31,18 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
30
31
|
4. **Your fact layer.** If the workspace holds fewer than ten facts, the wizard does not guess
|
|
31
32
|
facts for you. It hands the seeding to your assistant, which has the procedure from the skill:
|
|
32
33
|
read your website, repo and docs, propose a structure, and write only after you say yes.
|
|
33
|
-
5. **The agent pack** (optional, `--agents`).
|
|
34
|
-
|
|
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)
|
|
35
46
|
to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
|
|
36
47
|
and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
|
|
37
48
|
instructions are in your workspace, records the name and the block slugs as repository variables,
|
|
@@ -41,7 +52,21 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
|
|
|
41
52
|
running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
|
|
42
53
|
against *that* endpoint, stores it as `LLM_API_KEY`, and records the endpoint as a repository
|
|
43
54
|
variable so the fork actually uses it. Unset, nothing changes: it asks for an Anthropic key.
|
|
44
|
-
6. **The
|
|
55
|
+
6. **The web agents** (optional, `--meetings`, `--chat`). Two agents run as always-on services on
|
|
56
|
+
your own domain rather than as scheduled jobs, so they live in their own repos:
|
|
57
|
+
[Verity Meetings](https://github.com/getnoan/verity-meetings) (booking pages) and
|
|
58
|
+
[Verity Chat](https://github.com/getnoan/verity-chat) (a chat widget for your website). For each
|
|
59
|
+
one you pick, the wizard forks the repo to your account, runs its seed scripts so its starting
|
|
60
|
+
instructions are in your workspace (in a stack of their own, apart from any other agent's), boots
|
|
61
|
+
it locally with no keys to prove it works, and writes a gitignored `.env` in the clone with the
|
|
62
|
+
block slugs. Deploying needs accounts only you can create — a host, a Supabase project, and for
|
|
63
|
+
Meetings, Google Calendar credentials — so the report hands your assistant the rest of the repo's
|
|
64
|
+
`INSTALL.md` and a one-click Render deploy link.
|
|
65
|
+
|
|
66
|
+
Your personal NOAN key is **not** written into either service's `.env`: these answer the open
|
|
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.
|
|
69
|
+
7. **The report.** What happened, and the one thing to do next.
|
|
45
70
|
|
|
46
71
|
## For a coding assistant
|
|
47
72
|
|
|
@@ -55,7 +80,9 @@ NOAN_API_KEY=npak_… npx -y @getnoan/wizard@latest --yes --json
|
|
|
55
80
|
(progress goes to stderr) with a `next` list an agent can act on, including the first-connect
|
|
56
81
|
hand-off when the workspace is empty. `--agents` adds the pack step; it reads `ANTHROPIC_API_KEY`,
|
|
57
82
|
`RESEND_API_KEY`, `DATABASE_URL`, `FIRECRAWL_API_KEY`, `MAIL_FROM`, `REPLY_TO` and `ESCALATE_TO`
|
|
58
|
-
from the environment and skips what is missing, saying so.
|
|
83
|
+
from the environment and skips what is missing, saying so. `--meetings` and `--chat` add the web
|
|
84
|
+
agents; the chat reads its model key from the same variables as the pack (or reuses the pack
|
|
85
|
+
step's in the same run), and a `next` item per service carries the deploy hand-off.
|
|
59
86
|
|
|
60
87
|
Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
61
88
|
|
|
@@ -66,9 +93,12 @@ Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
|
66
93
|
| `-y`, `--yes` | non-interactive |
|
|
67
94
|
| `--json` | machine-readable report (implies `--yes`) |
|
|
68
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) |
|
|
69
97
|
| `--global` | user-scope client config instead of project scope |
|
|
70
98
|
| `--dir <path>` | the project directory (default: current) |
|
|
71
99
|
| `--agents` / `--no-agents` | include or skip the agent pack step |
|
|
100
|
+
| `--meetings` / `--no-meetings` | include or skip Verity Meetings (booking pages) |
|
|
101
|
+
| `--chat` / `--no-chat` | include or skip Verity Chat (a chat widget for your site) |
|
|
72
102
|
| `--no-mcp`, `--no-skill` | skip a step |
|
|
73
103
|
| `--dry-run` | show every write without making it |
|
|
74
104
|
| `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY`, `DO_NOT_TRACK`, or any `CI`) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getnoan/wizard",
|
|
3
|
-
"version": "0.1.
|
|
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
|
@@ -11,16 +11,22 @@ What it does, in order:
|
|
|
11
11
|
4. Workspace checks whether your fact layer has anything in it; if not, hands the
|
|
12
12
|
seeding to your assistant (it has the procedure) rather than guessing
|
|
13
13
|
5. Agents optionally sets up the open-source agent pack on your GitHub account
|
|
14
|
-
6.
|
|
14
|
+
6. Web optionally forks Verity Meetings (booking pages) and Verity Chat (a chat
|
|
15
|
+
widget for your site), seeds their facts, proves each boots locally, and
|
|
16
|
+
hands the deploy to your assistant
|
|
17
|
+
7. Report says what it did, and what to do next
|
|
15
18
|
|
|
16
19
|
Options:
|
|
17
20
|
-y, --yes non-interactive: take every default, never prompt (for an agent mid-session)
|
|
18
21
|
--json machine-readable report on stdout (implies --yes)
|
|
19
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)
|
|
20
24
|
--global write user-scope client config (~/.claude.json, ~/.cursor/mcp.json, ...)
|
|
21
25
|
instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
|
|
22
26
|
--dir <path> the project directory (default: the current directory)
|
|
23
27
|
--agents also set up the agent pack (default: ask; with --yes: skip unless given)
|
|
28
|
+
--meetings also set up Verity Meetings, booking pages on your own domain (same default)
|
|
29
|
+
--chat also set up Verity Chat, a chat widget for your website (same default)
|
|
24
30
|
--no-mcp skip the client wiring
|
|
25
31
|
--no-skill skip the skill install
|
|
26
32
|
--dry-run show every write without making it
|
|
@@ -32,7 +38,7 @@ Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
|
|
|
32
38
|
`;
|
|
33
39
|
|
|
34
40
|
export function parseArgs(argv) {
|
|
35
|
-
const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null,
|
|
41
|
+
const a = { yes: false, json: false, apiKey: null, agentKey: null, global: false, dir: process.cwd(), agents: null, meetings: null, chat: null,
|
|
36
42
|
mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
|
|
37
43
|
for (let i = 0; i < argv.length; i++) {
|
|
38
44
|
const t = argv[i];
|
|
@@ -41,11 +47,17 @@ export function parseArgs(argv) {
|
|
|
41
47
|
else if (t === "--json") { a.json = true; a.yes = true; }
|
|
42
48
|
else if (t === "--api-key") a.apiKey = next();
|
|
43
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);
|
|
44
52
|
else if (t === "--global") a.global = true;
|
|
45
53
|
else if (t === "--dir") a.dir = next();
|
|
46
54
|
else if (t.startsWith("--dir=")) a.dir = t.slice(6);
|
|
47
55
|
else if (t === "--agents") a.agents = true;
|
|
48
56
|
else if (t === "--no-agents") a.agents = false;
|
|
57
|
+
else if (t === "--meetings") a.meetings = true;
|
|
58
|
+
else if (t === "--no-meetings") a.meetings = false;
|
|
59
|
+
else if (t === "--chat") a.chat = true;
|
|
60
|
+
else if (t === "--no-chat") a.chat = false;
|
|
49
61
|
else if (t === "--no-mcp") a.mcp = false;
|
|
50
62
|
else if (t === "--no-skill") a.skill = false;
|
|
51
63
|
else if (t === "--dry-run") a.dryRun = true;
|
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/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/report.mjs
CHANGED
|
@@ -15,6 +15,13 @@ export function renderReport(report, { json = false } = {}) {
|
|
|
15
15
|
if (s.skill?.rows) L.push(`skill: ${s.skill.rows.map(r => `${r.id} (${r.written} written)`).join(" · ")}; pointers: ${s.skill.pointers.map(p => p.action).join(", ")}`);
|
|
16
16
|
if (s.workspace) L.push(`facts: ${s.workspace.count ?? "?"} (${s.workspace.state})`);
|
|
17
17
|
if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.agentName || "your agent"}, ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
|
|
18
|
+
if (s.web && !s.web.skipped) for (const [id, w] of Object.entries(s.web)) {
|
|
19
|
+
if (!w.ok) { L.push(`${id}: not set up (${w.reason})`); continue; }
|
|
20
|
+
if (report.dryRun) { L.push(`${id}: would fork to ${w.dest}, run ${w.seeds.length} seed(s), boot it locally and write its .env${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}`); continue; }
|
|
21
|
+
const seeded = w.seeds.filter(r => r.action === "ran").length;
|
|
22
|
+
const local = w.local?.ok ? "boots locally" : w.local?.ok === false ? `local boot failed (${w.local.reason})` : "local boot not tried";
|
|
23
|
+
L.push(`${id}: ${w.repo || w.dest} — ${seeded}/${w.seeds.length} seeds ran, ${local}, .env written${w.missing?.length ? ` (still needs ${w.missing.join(", ")})` : ""}${w.deployUrl ? `, deploy: ${w.deployUrl}` : ""}`);
|
|
24
|
+
}
|
|
18
25
|
if (report.next?.length) { L.push("", "──────── next ────────"); for (const n of report.next) L.push(`• ${n.say}${n.why ? ` (${n.why})` : ""}`); }
|
|
19
26
|
L.push("");
|
|
20
27
|
return L.join("\n");
|
package/src/run.mjs
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
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";
|
|
9
9
|
import * as pack from "./agents.mjs";
|
|
10
|
+
import * as web from "./web.mjs";
|
|
10
11
|
import { capture } from "./telemetry.mjs";
|
|
11
12
|
import { renderReport } from "./report.mjs";
|
|
12
13
|
|
|
@@ -37,7 +38,10 @@ export async function run(args) {
|
|
|
37
38
|
if (!looksLikeKey(key)) ui.warn("that does not look like a NOAN key (they start with npak_) — checking anyway");
|
|
38
39
|
const me = await whoAmI(key);
|
|
39
40
|
if (!me.ok) { ui.warn(me.reason); report.steps.auth = { ok: false, reason: me.reason }; return finish(2, "cancelled"); }
|
|
40
|
-
|
|
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 || "?"}"`);
|
|
41
45
|
const env = writeEnv(dir, { NOAN_API_KEY: key, NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
42
46
|
const gi = ensureGitignored(dir, { dryRun: args.dryRun });
|
|
43
47
|
ui.ok(`${env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (NOAN_API_KEY, NOAN_PERSONAL_API_KEY); .gitignore: ${gi.action}`);
|
|
@@ -86,17 +90,36 @@ export async function run(args) {
|
|
|
86
90
|
ui.step(5, "The agent pack");
|
|
87
91
|
let wantAgents = args.agents;
|
|
88
92
|
if (wantAgents == null) wantAgents = ui.interactive ? await ui.confirm("Set up the six open-source agents on your GitHub account too?", false) : false;
|
|
89
|
-
|
|
93
|
+
// Carries the model key and the agent's name from the pack step to the web step, so a run that
|
|
94
|
+
// does both asks once. Never put in the report: the report is printed, and --json is parsed.
|
|
95
|
+
const shared = {};
|
|
96
|
+
if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me, report, shared });
|
|
90
97
|
else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
|
|
91
98
|
|
|
92
|
-
/* 6.
|
|
99
|
+
/* 6. web agents */
|
|
100
|
+
ui.step(6, "The web agents");
|
|
101
|
+
const wantWeb = [];
|
|
102
|
+
for (const [id, agent] of Object.entries(web.WEB_AGENTS)) {
|
|
103
|
+
let want = args[id];
|
|
104
|
+
if (want == null) want = ui.interactive ? await ui.confirm(`Set up ${agent.label} — ${agent.what}?`, false) : false;
|
|
105
|
+
if (want) wantWeb.push(id);
|
|
106
|
+
}
|
|
107
|
+
if (!wantWeb.length) {
|
|
108
|
+
ui.info("skipped" + (args.yes && args.meetings == null && args.chat == null ? " (pass --meetings and/or --chat to include them)" : ""));
|
|
109
|
+
report.steps.web = { skipped: true };
|
|
110
|
+
} else {
|
|
111
|
+
report.steps.web = {};
|
|
112
|
+
for (const id of wantWeb) report.steps.web[id] = await setupWeb({ id, args, ui, dir, key, me, report, shared });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* 7. report */
|
|
93
116
|
report.next.push({ id: "open", say: `Open ${APP_URL} to see the workspace your assistant is grounded in.` });
|
|
94
117
|
return finish(0);
|
|
95
118
|
}
|
|
96
119
|
|
|
97
120
|
async function ask(ui, q, o) { return ui.ask(q, o); }
|
|
98
121
|
|
|
99
|
-
async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
122
|
+
async function setupAgents({ args, ui, dir, key, me, report, shared = {} }) {
|
|
100
123
|
const ready = pack.ghReady();
|
|
101
124
|
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
102
125
|
const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
|
|
@@ -155,7 +178,32 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
|
155
178
|
? await ui.ask(`Pronouns for ${askedName}, in ${askedName}'s own prose (blank for they/them)`)
|
|
156
179
|
: "") });
|
|
157
180
|
|
|
158
|
-
|
|
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 }),
|
|
159
207
|
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
|
|
160
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}` : ""}`); }
|
|
161
209
|
// The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
|
|
@@ -163,7 +211,7 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
|
163
211
|
// pack's own workflows had.
|
|
164
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 }),
|
|
165
213
|
...identity,
|
|
166
|
-
COMPANY_NAME: me.project?.name || "", ...(
|
|
214
|
+
COMPANY_NAME: me.project?.name || "", ...(roles.agentIds && { AGENT_IDENTITY_IDS: roles.agentIds }), ...(roles.commanders && { COMMANDERS: roles.commanders }) };
|
|
167
215
|
// The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
|
|
168
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);
|
|
169
217
|
const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
@@ -181,7 +229,20 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
|
181
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" });
|
|
182
230
|
} else ui.ok("every block the agents read holds a fact");
|
|
183
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 };
|
|
184
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(", ")}`);
|
|
185
246
|
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
|
|
186
247
|
// No point dispatching a run that will only fail for a missing key: say what is missing instead.
|
|
187
248
|
if (!anthropic || !resend) {
|
|
@@ -196,5 +257,63 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
|
|
|
196
257
|
}
|
|
197
258
|
out.repo = pack.repoSlug(fork.dest);
|
|
198
259
|
out.agentName = identity.AGENT_NAME;
|
|
260
|
+
Object.assign(shared, { modelKey: anthropic, modelKeyName: mName, modelBase: mBase, agentName: identity.AGENT_NAME });
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** One web agent: fork, seed, prove it boots, write its .env, hand the deploy to the assistant.
|
|
265
|
+
* What it returns goes in the report, so it carries names and outcomes — never a key. */
|
|
266
|
+
async function setupWeb({ id, args, ui, dir, key, me, report, shared }) {
|
|
267
|
+
const agent = web.WEB_AGENTS[id];
|
|
268
|
+
ui.info(`${agent.label}:`);
|
|
269
|
+
const ready = pack.ghReady();
|
|
270
|
+
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
271
|
+
const fork = web.forkAndCloneWeb(dir, agent, { dryRun: args.dryRun });
|
|
272
|
+
ui.ok(`${fork.action}: ${fork.dest}${fork.error ? ` — ${fork.error}` : ""}`);
|
|
273
|
+
if (fork.error) return { ok: false, reason: fork.error };
|
|
274
|
+
const out = { ok: true, dest: fork.dest, seeds: [], skipped: [] };
|
|
275
|
+
|
|
276
|
+
// Seeds run under the person's own key: setting up their workspace is theirs to do.
|
|
277
|
+
const seeds = web.runWebSeeds(fork.dest, agent, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
278
|
+
out.seeds = seeds.rows;
|
|
279
|
+
for (const r of seeds.rows) (r.action === "failed" || r.action === "missing" ? ui.warn : ui.ok)(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
|
|
280
|
+
|
|
281
|
+
// INSTALL.md step 0, for them: the service boots with no keys at all.
|
|
282
|
+
if (args.dryRun) out.local = { ok: null, reason: "dry run" };
|
|
283
|
+
else if (existsSync(fork.dest)) {
|
|
284
|
+
out.local = await web.proveLocally(fork.dest, agent);
|
|
285
|
+
if (out.local.ok) ui.ok(`boots locally with no keys (${out.local.probes.join(", ")} answered)`);
|
|
286
|
+
else ui.warn(`did not boot locally: ${out.local.reason} — INSTALL.md step 0 runs the same thing by hand`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The chat needs a model key; reuse the pack step's if this run had one, else env, else ask.
|
|
290
|
+
let modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "";
|
|
291
|
+
if (agent.needsModelKey) {
|
|
292
|
+
if (shared.modelKey) ({ modelKey, modelKeyName, modelBase } = shared);
|
|
293
|
+
else {
|
|
294
|
+
try { modelBase = pack.modelBase(); } catch (e) { ui.warn(e.message); out.skipped.push({ name: "model key", why: e.message }); modelBase = null; }
|
|
295
|
+
if (modelBase !== null) {
|
|
296
|
+
modelKeyName = pack.modelKeyName();
|
|
297
|
+
modelKey = (process.env[modelKeyName] || process.env[modelKeyName === "LLM_API_KEY" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] || "").trim()
|
|
298
|
+
|| (ui.interactive ? await ui.ask(`API key for ${modelBase ? new URL(modelBase).host : "Anthropic"} (the chat's model; blank to add it later)`, { secret: true }) : "");
|
|
299
|
+
if (modelKey) {
|
|
300
|
+
const verdict = await pack.verifyModelKey(modelKey, modelBase);
|
|
301
|
+
if (verdict === false) { ui.warn(`${modelKeyName} was not accepted by its service; not written`); out.skipped.push({ name: modelKeyName, why: "rejected" }); modelKey = null; }
|
|
302
|
+
} else out.skipped.push({ name: modelKeyName, why: "not provided" });
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const agentName = shared.agentName || pack.agentIdentity({ name: process.env.AGENT_NAME }).AGENT_NAME;
|
|
308
|
+
const pairs = web.webEnv(agent, { slugs: seeds.slugs, company: me.project?.name || "", agentName, modelKey, modelKeyName, modelBase: modelBase || "" });
|
|
309
|
+
const env = web.writeWebEnv(fork.dest, pairs, { dryRun: args.dryRun });
|
|
310
|
+
ui.ok(`${args.dryRun ? "would write" : env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (${env.keys.join(", ") || "nothing new"}); .gitignore: ${env.gitignore}`);
|
|
311
|
+
out.env = { path: env.path, keys: env.keys, gitignore: env.gitignore };
|
|
312
|
+
const missingModelKey = agent.needsModelKey && !modelKey ? modelKeyName : null;
|
|
313
|
+
if (missingModelKey) { ui.warn(`no ${missingModelKey} written: the chat cannot answer until it has one`); out.missing = [missingModelKey]; }
|
|
314
|
+
|
|
315
|
+
out.repo = args.dryRun ? null : pack.repoSlug(fork.dest);
|
|
316
|
+
out.deployUrl = web.renderDeployUrl(out.repo);
|
|
317
|
+
report.next.push(web.webNext(agent, { dest: fork.dest, deployUrl: out.deployUrl, missingModelKey }));
|
|
199
318
|
return out;
|
|
200
319
|
}
|
package/src/web.mjs
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The web-facing agents: getnoan/verity-meetings (booking pages) and getnoan/verity-chat (a chat
|
|
3
|
+
* widget for your site). They are always-on services on the user's own domain, not scheduled
|
|
4
|
+
* jobs, so they live in their own repos and deploy to a host (Render by default), not to Actions.
|
|
5
|
+
*
|
|
6
|
+
* The wizard does the part that needs no account the user has not already given it:
|
|
7
|
+
* fork and clone → seed the service's facts (under the user's own key, a human action) →
|
|
8
|
+
* prove the service boots locally with NO keys → write a gitignored .env in the clone with what
|
|
9
|
+
* is known → hand the rest to the coding assistant, INSTALL.md's remaining steps plus a Render
|
|
10
|
+
* deploy link.
|
|
11
|
+
* The rest — a host account, a Supabase project, Google calendar credentials, DNS, the embed tag on
|
|
12
|
+
* their site — needs the person, and INSTALL.md marks each of those HUMAN.
|
|
13
|
+
*
|
|
14
|
+
* The personal NOAN key is NOT written into the service's .env. That key reaches the whole
|
|
15
|
+
* workspace, and these services answer the open internet; each gets a key made for it, and the
|
|
16
|
+
* hand-off says so. The seeds are different: seeding is a person setting up their own workspace.
|
|
17
|
+
*/
|
|
18
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { createServer } from "node:net";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { gh, parseSeedOutput, randomSecret } from "./agents.mjs";
|
|
23
|
+
import { writeEnv, ensureGitignored } from "./env-file.mjs";
|
|
24
|
+
|
|
25
|
+
/** One entry per web agent. `boot` is INSTALL.md step 0 — the no-keys local run — and `probe` is
|
|
26
|
+
* what proves it came up; both are the repos' own CI checks, so a green CI there means this works. */
|
|
27
|
+
export const WEB_AGENTS = {
|
|
28
|
+
meetings: {
|
|
29
|
+
label: "Verity Meetings",
|
|
30
|
+
repo: "getnoan/verity-meetings",
|
|
31
|
+
dirName: "verity-meetings",
|
|
32
|
+
what: "booking pages on your own domain",
|
|
33
|
+
seeds: ["agents/seed-scheduling.mjs", "agents/seed-booking.mjs"],
|
|
34
|
+
boot: (port) => ({
|
|
35
|
+
args: ["booking/server.mjs"],
|
|
36
|
+
env: { BOOKING_ENABLED: "1", BOOKING_STORE: "memory", BOOKING_DRY_RUN: "1",
|
|
37
|
+
BOOKING_HOSTS: "founder@example.com,teammate@example.com",
|
|
38
|
+
BOOKING_PUBLIC_URL: `http://127.0.0.1:${port}`, PORT: String(port) },
|
|
39
|
+
}),
|
|
40
|
+
probe: ["/healthz", "/alex"],
|
|
41
|
+
needsModelKey: false,
|
|
42
|
+
// INSTALL.md steps left after the wizard: it has seeded (step 1's second half).
|
|
43
|
+
remaining: "step 2 (Google calendar credentials), step 3 (Supabase + schema.sql), step 4 (deploy), step 5 (DNS)",
|
|
44
|
+
after: "Then edit the Booking Types fact in NOAN: the starters use placeholder hosts, and a type whose host is not in BOOKING_HOSTS is not served.",
|
|
45
|
+
},
|
|
46
|
+
chat: {
|
|
47
|
+
label: "Verity Chat",
|
|
48
|
+
repo: "getnoan/verity-chat",
|
|
49
|
+
dirName: "verity-chat",
|
|
50
|
+
what: "a grounded chat widget for your website",
|
|
51
|
+
seeds: ["agents/seed-site-chat.mjs"],
|
|
52
|
+
boot: (port) => ({
|
|
53
|
+
args: ["--input-type=module", "-e", `const { server } = await import("./site-web/server.mjs"); server.listen(${port}, "127.0.0.1");`],
|
|
54
|
+
env: { NODE_ENV: "test" },
|
|
55
|
+
}),
|
|
56
|
+
probe: ["/healthz", "/widget.js"],
|
|
57
|
+
needsModelKey: true,
|
|
58
|
+
remaining: "step 3 (Supabase + schema.sql — the daily budget refuses every chat until it can meter), step 4 (deploy), step 5 (the embed tag on your site)",
|
|
59
|
+
after: "Set SITE_ALLOWED_ORIGINS to your site's origins and SITE_CHAT_GROUNDING_SLUGS to the blocks it may answer from.",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Fork (or reuse) and clone a web agent's repo into dir/<dirName>. */
|
|
64
|
+
export function forkAndCloneWeb(dir, agent, { dryRun = false } = {}) {
|
|
65
|
+
const dest = path.join(dir, agent.dirName);
|
|
66
|
+
if (existsSync(path.join(dest, ".git"))) return { dest, action: "reused existing clone" };
|
|
67
|
+
if (dryRun) return { dest, action: "would fork and clone" };
|
|
68
|
+
const r = gh(["repo", "fork", agent.repo, "--clone", "--remote=false", dest]);
|
|
69
|
+
if (!r.ok) return { dest, action: "fork failed", error: r.err.split("\n")[0] };
|
|
70
|
+
return { dest, action: "forked and cloned" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run a web agent's seed scripts from the repo root; collect the block slugs they print. */
|
|
74
|
+
export function runWebSeeds(dest, agent, env, { dryRun = false } = {}) {
|
|
75
|
+
const slugs = {}; const rows = [];
|
|
76
|
+
for (const s of agent.seeds) {
|
|
77
|
+
const p = path.join(dest, s);
|
|
78
|
+
// A dry run has no clone yet, so "missing" there would be a false alarm.
|
|
79
|
+
if (dryRun && !existsSync(dest)) { rows.push({ seed: s, action: "would run" }); continue; }
|
|
80
|
+
if (!existsSync(p)) { rows.push({ seed: s, action: "missing" }); continue; }
|
|
81
|
+
if (dryRun) { rows.push({ seed: s, action: "would run" }); continue; }
|
|
82
|
+
const r = spawnSync(process.execPath, [p], { cwd: dest, encoding: "utf8", env: { ...process.env, ...env } });
|
|
83
|
+
const found = parseSeedOutput((r.stdout || "") + "\n" + (r.stderr || ""));
|
|
84
|
+
Object.assign(slugs, found);
|
|
85
|
+
rows.push({ seed: s, action: r.status === 0 ? "ran" : "failed", slugs: Object.keys(found).length,
|
|
86
|
+
error: r.status === 0 ? undefined : (r.stderr || r.stdout || "").trim().split("\n").slice(-1)[0] });
|
|
87
|
+
}
|
|
88
|
+
return { slugs, rows };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function freePort() {
|
|
92
|
+
return new Promise((res, rej) => {
|
|
93
|
+
const s = createServer(); s.unref(); s.on("error", rej);
|
|
94
|
+
s.listen(0, "127.0.0.1", () => { const { port } = s.address(); s.close(() => res(port)); });
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** INSTALL.md step 0, run for the user: boot the service with no keys, hit its probes, stop it.
|
|
99
|
+
* The child gets PATH and HOME only — never the wizard's environment — so no key it holds can
|
|
100
|
+
* reach the service, and a block slug in the shell cannot switch booking out of its local mode. */
|
|
101
|
+
export async function proveLocally(dest, agent, { timeoutMs = 15000, fetchImpl = fetch } = {}) {
|
|
102
|
+
const port = await freePort();
|
|
103
|
+
const { args, env } = agent.boot(port);
|
|
104
|
+
const child = spawn(process.execPath, args, { cwd: dest, stdio: ["ignore", "ignore", "pipe"],
|
|
105
|
+
env: { PATH: process.env.PATH || "", HOME: process.env.HOME || "", ...env } });
|
|
106
|
+
let stderr = ""; child.stderr.on("data", d => { stderr = (stderr + d).slice(-2000); });
|
|
107
|
+
const exited = new Promise(r => child.on("exit", code => r(code)));
|
|
108
|
+
const base = `http://127.0.0.1:${port}`;
|
|
109
|
+
try {
|
|
110
|
+
const deadline = Date.now() + timeoutMs;
|
|
111
|
+
let up = false;
|
|
112
|
+
while (Date.now() < deadline && !up) {
|
|
113
|
+
if (child.exitCode !== null) break;
|
|
114
|
+
try { up = (await fetchImpl(`${base}${agent.probe[0]}`, { signal: AbortSignal.timeout(1000) })).ok; } catch {}
|
|
115
|
+
if (!up) await new Promise(r => setTimeout(r, 300));
|
|
116
|
+
}
|
|
117
|
+
if (!up) return { ok: false, reason: child.exitCode !== null ? `exited (${child.exitCode}): ${stderr.trim().split("\n").slice(-1)[0] || "no output"}` : "did not answer in time" };
|
|
118
|
+
for (const p of agent.probe.slice(1)) {
|
|
119
|
+
const r = await fetchImpl(`${base}${p}`, { signal: AbortSignal.timeout(3000) }).catch(() => null);
|
|
120
|
+
if (!r?.ok) return { ok: false, reason: `${p} answered ${r ? r.status : "nothing"}` };
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, probes: agent.probe };
|
|
123
|
+
} finally {
|
|
124
|
+
if (child.exitCode === null) { child.kill(); await Promise.race([exited, new Promise(r => setTimeout(r, 2000))]); }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Render's one-click deploy for a repo that carries a render.yaml. */
|
|
129
|
+
export const renderDeployUrl = (slug) => (slug ? `https://render.com/deploy?repo=https://github.com/${slug}` : null);
|
|
130
|
+
|
|
131
|
+
/** What goes in the clone's .env. Never the personal NOAN key (see the header). The model key goes
|
|
132
|
+
* under the name the service reads for the chosen endpoint (the pack's rule: ANTHROPIC_API_KEY for
|
|
133
|
+
* the default vendor, LLM_API_KEY plus ANTHROPIC_BASE_URL for anything else). */
|
|
134
|
+
export function webEnv(agent, { slugs = {}, company = "", agentName = "", modelKey = null, modelKeyName = "ANTHROPIC_API_KEY", modelBase = "" } = {}) {
|
|
135
|
+
return {
|
|
136
|
+
...slugs,
|
|
137
|
+
...(company && { COMPANY_NAME: company }),
|
|
138
|
+
...(agentName && { AGENT_NAME: agentName }),
|
|
139
|
+
...(agent === WEB_AGENTS.chat && { SESSION_SECRET: randomSecret() }),
|
|
140
|
+
...(agent.needsModelKey && modelKey && { [modelKeyName]: modelKey }),
|
|
141
|
+
...(agent.needsModelKey && modelKey && modelBase && { ANTHROPIC_BASE_URL: modelBase }),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Write the clone's .env: gitignored FIRST (the exported repos ship no .gitignore, and this file
|
|
146
|
+
* can hold a model key), and a SESSION_SECRET that is already there is kept — rotating it would
|
|
147
|
+
* sign out every visitor of a chat already deployed from it. */
|
|
148
|
+
export function writeWebEnv(dest, pairs, { dryRun = false } = {}) {
|
|
149
|
+
const gitignore = ensureGitignored(dest, { dryRun });
|
|
150
|
+
const envPath = path.join(dest, ".env");
|
|
151
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
152
|
+
const keep = { ...pairs };
|
|
153
|
+
if (/^\s*(?:export\s+)?SESSION_SECRET\s*=\s*\S/m.test(existing)) delete keep.SESSION_SECRET;
|
|
154
|
+
const env = writeEnv(dest, keep, { dryRun });
|
|
155
|
+
return { ...env, gitignore: gitignore.action, keys: Object.keys(keep) };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The hand-off line for the report's `next` list: what is left, and where to read how. */
|
|
159
|
+
export function webNext(agent, { dest, deployUrl, missingModelKey = null }) {
|
|
160
|
+
return {
|
|
161
|
+
id: `web-${agent.dirName}`,
|
|
162
|
+
say: `Finish ${agent.label} (${agent.what}): in ${dest}, follow INSTALL.md ${agent.remaining}.`
|
|
163
|
+
+ (missingModelKey ? ` It needs ${missingModelKey} too — the wizard had none to write.` : "")
|
|
164
|
+
+ (deployUrl ? ` One-click deploy: ${deployUrl}.` : "")
|
|
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
|
+
+ ` ${agent.after}`,
|
|
167
|
+
why: "the rest needs accounts only you can create; INSTALL.md marks those steps HUMAN",
|
|
168
|
+
};
|
|
169
|
+
}
|