@getnoan/wizard 0.1.3 → 0.1.4

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
@@ -32,10 +32,15 @@ Run it again any time; every write is a merge, and nothing you have is overwritt
32
32
  read your website, repo and docs, propose a structure, and write only after you say yes.
33
33
  5. **The agent pack** (optional, `--agents`). Asks what to call your agent — Verity by default, the
34
34
  name she answers to in NOAN — then forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
35
- to your account, verifies and stores your Anthropic and Resend keys (and optionally a Postgres URL
35
+ to your account, verifies and stores your model and Resend keys (and optionally a Postgres URL
36
36
  and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
37
37
  instructions are in your workspace, records the name and the block slugs as repository variables,
38
38
  and triggers one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
39
+
40
+ The model does not have to be Anthropic's. Set `ANTHROPIC_BASE_URL` in the environment before
41
+ running — `https://openrouter.ai/api`, say, or your own gateway — and the wizard verifies your key
42
+ against *that* endpoint, stores it as `LLM_API_KEY`, and records the endpoint as a repository
43
+ variable so the fork actually uses it. Unset, nothing changes: it asks for an Anthropic key.
39
44
  6. **The report.** What happened, and the one thing to do next.
40
45
 
41
46
  ## For a coding assistant
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getnoan/wizard",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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
@@ -31,8 +31,40 @@ export function parseSeedOutput(text) {
31
31
  }
32
32
 
33
33
  /** The secrets and variables the pack documents, and which are required for which agent. */
34
- export const PACK_SECRETS = ["NOAN_PERSONAL_API_KEY", "RESEND_API_KEY", "ANTHROPIC_API_KEY", "DATABASE_URL", "FIRECRAWL_API_KEY", "NEWSLETTER_UNSUB_SECRET"];
35
- export const PACK_VARS = ["MAIL_FROM", "REPLY_TO", "ESCALATE_TO", "STATE_BACKEND", "AGENT_NAME", "COMPANY_NAME", "AGENT_IDENTITY_IDS", "COMMANDERS", "REPORT_RECIPIENT_TAG"];
34
+ /** LLM_API_KEY is an ALTERNATIVE to ANTHROPIC_API_KEY, not an addition: the pack accepts either
35
+ * name for the model key and one of the two is required. It is listed so a caller enumerating
36
+ * the pack's secrets knows the name exists, not so anyone sets both. */
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"];
39
+
40
+ /** The pack's model endpoint, from the same variable the pack itself reads. Bare base, no
41
+ * trailing slash; empty string means the default vendor.
42
+ *
43
+ * A bad value THROWS rather than falling back, and both rules are the pack's own
44
+ * (resolveBaseUrl in agents/anthropic.mjs), deliberately down to the wording.
45
+ *
46
+ * This returned "" on anything URL() rejected until review caught it. "" means "default
47
+ * vendor", so `ANTHROPIC_BASE_URL=openrouter.ai/api` — a missing scheme, the likeliest typo
48
+ * there is — silently became "no endpoint": the key went in under the vendor's name, was
49
+ * verified against the vendor's host, came back 401 and was DISCARDED. That is precisely the
50
+ * sequence this whole change exists to stop, reached by a typo instead of by design.
51
+ *
52
+ * The scheme rule matters for the opposite reason: URL() happily parses `ftp://host` and
53
+ * `javascript:...`, and accepting one writes a repository variable the agents refuse at
54
+ * runtime — on a schedule, where nobody is watching. Better to refuse it here, in front of
55
+ * someone who can retype it. */
56
+ export function modelBase(env = process.env) {
57
+ const raw = (env.ANTHROPIC_BASE_URL || "").trim().replace(/\/+$/, "");
58
+ if (!raw) return "";
59
+ let url;
60
+ try { url = new URL(raw); } catch { throw new Error(`ANTHROPIC_BASE_URL is not a valid URL: ${raw}`); }
61
+ if (!/^https?:$/.test(url.protocol)) throw new Error(`ANTHROPIC_BASE_URL must be http(s): ${raw}`);
62
+ return raw;
63
+ }
64
+ export const DEFAULT_MODEL_BASE = "https://api.anthropic.com";
65
+ /** Which secret name the model key is STORED under: the neutral one once an endpoint is set,
66
+ * because "ANTHROPIC_API_KEY" holding an OpenRouter key is a lie the next reader has to unpick. */
67
+ export const modelKeyName = (env = process.env) => (modelBase(env) ? "LLM_API_KEY" : "ANTHROPIC_API_KEY");
36
68
 
37
69
  /** What the agents call themselves, and how their own prose refers to them.
38
70
  * Verity is the default because that is the agent people meet in NOAN; anyone who
@@ -46,9 +78,23 @@ export function agentIdentity({ name, pronouns } = {}) {
46
78
  return { AGENT_NAME: n, ...(p && { AGENT_PRONOUNS: p }) };
47
79
  }
48
80
 
49
- export async function verifyAnthropic(key) {
50
- try { const r = await fetch("https://api.anthropic.com/v1/models", { headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } }); return r.status === 200; } catch { return false; }
81
+ /** Verify the model key against the endpoint that will actually be called.
82
+ *
83
+ * Against the default vendor this is a straight yes/no. Against a configured endpoint it is
84
+ * advisory: `/v1/models` is not part of the Messages contract, so a gateway may not implement
85
+ * it, and a 404 there says nothing about the key. Returning "unknown" rather than false is the
86
+ * point — the previous behaviour verified every key against the vendor's own host, so a valid
87
+ * gateway key came back 401 and was DISCARDED with "not accepted by its service". */
88
+ export async function verifyModelKey(key, base = "") {
89
+ const root = base || DEFAULT_MODEL_BASE;
90
+ try {
91
+ const r = await fetch(`${root}/v1/models`, { headers: { "x-api-key": key, "anthropic-version": "2023-06-01" } });
92
+ if (r.status === 200) return true;
93
+ // Only the vendor's own host is trusted to mean "this key is bad".
94
+ return base ? "unknown" : false;
95
+ } catch { return base ? "unknown" : false; }
51
96
  }
97
+ export const verifyAnthropic = (key) => verifyModelKey(key, "");
52
98
  export async function verifyResend(key) {
53
99
  try { const r = await fetch("https://api.resend.com/domains", { headers: { Authorization: `Bearer ${key}` } }); return r.status === 200; } catch { return false; }
54
100
  }
package/src/run.mjs CHANGED
@@ -105,14 +105,44 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
105
105
  const out = { ok: true, dest: fork.dest, secrets: [], variables: [], seeds: [], skipped: [] };
106
106
 
107
107
  // Keys: from the environment (an agent's run) or a prompt (a person's). Verified before they are stored.
108
- const need = async (name, verify, prompt, { optional = false } = {}) => {
109
- let v = (process.env[name] || "").trim();
108
+ const need = async (name, verify, prompt, { optional = false, from = [] } = {}) => {
109
+ // `from` is other env names that may carry the same value. The model key has two accepted
110
+ // spellings and a user who set the one we are not storing under has still configured it.
111
+ let v = "";
112
+ for (const n of [name, ...from]) { v = (process.env[n] || "").trim(); if (v) break; }
110
113
  if (!v && ui.interactive) v = await ui.ask(prompt, { secret: true });
111
114
  if (!v) { out.skipped.push({ name, why: optional ? "not provided" : "required, not provided" }); return null; }
112
- if (verify && !(await verify(v))) { ui.warn(`${name} was not accepted by its service; not stored`); out.skipped.push({ name, why: "rejected" }); return null; }
115
+ if (verify) {
116
+ // A verdict of "unknown" means the endpoint could not answer, not that the key is bad —
117
+ // see verifyModelKey. Discarding on that would throw away a working key.
118
+ const verdict = await verify(v);
119
+ if (verdict === "unknown") ui.warn(`${name} could not be confirmed by ${prompt.replace(/^.*for /, "").replace(/ \(.*$/, "")}; storing it anyway`);
120
+ else if (!verdict) { ui.warn(`${name} was not accepted by its service; not stored`); out.skipped.push({ name, why: "rejected" }); return null; }
121
+ }
113
122
  return v;
114
123
  };
115
- const anthropic = await need("ANTHROPIC_API_KEY", pack.verifyAnthropic, "Anthropic API key (the agents' model)");
124
+ /* The model endpoint is configuration in the pack (ANTHROPIC_BASE_URL), so it is here too.
125
+ * With one set, the key is verified against THAT endpoint and stored under the neutral name —
126
+ * verifying a gateway key against the vendor's own host returns 401, and the wizard used to
127
+ * discard a perfectly good key on the strength of it. Either spelling is accepted as a source. */
128
+ /* A bad ANTHROPIC_BASE_URL stops the agents step and says why, rather than being treated as
129
+ * "unset". Falling back would put the key in under the vendor's name and verify it against the
130
+ * vendor's host — the discarded-key failure this change exists to fix, reached by a typo.
131
+ *
132
+ * The fork and clone have already happened by here, so the message says what is and is not
133
+ * true: no secret, variable or .env has been written, and re-running is safe because
134
+ * forkAndClone reuses an existing clone. */
135
+ let mBase;
136
+ try { mBase = pack.modelBase(); }
137
+ catch (e) {
138
+ ui.warn(`${e.message}\n Fix it (or unset it to use the default) and run again — no secret or variable was written, and the clone is reused.`);
139
+ return { ok: false, reason: e.message };
140
+ }
141
+ const mName = pack.modelKeyName();
142
+ const mHost = mBase ? new URL(mBase).host : "Anthropic";
143
+ const anthropic = await need(mName, (k) => pack.verifyModelKey(k, mBase),
144
+ `API key for ${mHost} (the agents' model)`,
145
+ { from: [mName === "LLM_API_KEY" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] });
116
146
  const resend = await need("RESEND_API_KEY", pack.verifyResend, "Resend API key (the agents' email)");
117
147
  const db = await need("DATABASE_URL", null, "Postgres URL for the agents' state ledger (blank to skip; then only market research runs)", { optional: true });
118
148
  const firecrawl = await need("FIRECRAWL_API_KEY", pack.verifyFirecrawl, "Firecrawl API key (market research; blank to skip)", { optional: true });
@@ -125,14 +155,17 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
125
155
  ? await ui.ask(`Pronouns for ${askedName}, in ${askedName}'s own prose (blank for they/them)`)
126
156
  : "") });
127
157
 
128
- const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
158
+ const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
129
159
  ...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
130
160
  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}` : ""}`); }
131
- const vars = { DRY_RUN: "1", STATE_BACKEND: db ? "postgres" : "local", ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
161
+ // The endpoint is a repository VARIABLE, not a secret: without it the fork holds a gateway key
162
+ // and still calls the default vendor, which is the same "documented is not delivered" trap the
163
+ // pack's own workflows had.
164
+ 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 }),
132
165
  ...identity,
133
166
  COMPANY_NAME: me.project?.name || "", ...(me.identity?.id && { AGENT_IDENTITY_IDS: me.identity.id }), ...(me.identity?.email && { COMMANDERS: me.identity.email }) };
134
167
  // The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
135
- pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
168
+ pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { [mName]: anthropic }), ...(mBase && { ANTHROPIC_BASE_URL: mBase }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
136
169
  const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
137
170
  out.seeds = seeds.rows;
138
171
  for (const r of seeds.rows) ui.ok(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
@@ -151,7 +184,7 @@ async function setupAgents({ args, ui, dir, key, me, report }) {
151
184
  ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
152
185
  // No point dispatching a run that will only fail for a missing key: say what is missing instead.
153
186
  if (!anthropic || !resend) {
154
- const missing = [!anthropic && "ANTHROPIC_API_KEY", !resend && "RESEND_API_KEY"].filter(Boolean);
187
+ const missing = [!anthropic && mName, !resend && "RESEND_API_KEY"].filter(Boolean);
155
188
  out.incomplete = missing;
156
189
  ui.warn(`not dispatching a run: ${missing.join(" and ")} still missing. Add them as repository secrets (or re-run with them in the environment), then run any agent workflow with dry_run=1.`);
157
190
  } else {