@getnoan/wizard 0.1.0
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/LICENSE +21 -0
- package/README.md +85 -0
- package/bin/wizard.mjs +9 -0
- package/package.json +39 -0
- package/src/agents.mjs +98 -0
- package/src/args.mjs +59 -0
- package/src/clients.mjs +119 -0
- package/src/env-file.mjs +36 -0
- package/src/noan.mjs +34 -0
- package/src/report.mjs +21 -0
- package/src/run.mjs +149 -0
- package/src/skill.mjs +80 -0
- package/src/telemetry.mjs +17 -0
- package/src/ui.mjs +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NOAN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# NOAN wizard
|
|
2
|
+
|
|
3
|
+
One command from nothing to a grounded agent.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx -y @getnoan/wizard@latest
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
It takes your NOAN API key, points your coding assistants at the NOAN MCP server, installs the
|
|
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. Then it tells you what it did and what to do next.
|
|
12
|
+
Run it again any time; every write is a merge, and nothing you have is overwritten.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
1. **Your key.** Create one at app.getnoan.com → Settings → API → New key and paste it in (it is
|
|
17
|
+
not echoed). The wizard checks it with `GET /me`, writes it to `.env` as `NOAN_API_KEY` and
|
|
18
|
+
`NOAN_PERSONAL_API_KEY`, and makes sure `.env` is gitignored. Already have `NOAN_API_KEY` in
|
|
19
|
+
your environment? It uses that and never asks.
|
|
20
|
+
2. **Your coding assistants.** Whatever is installed gets the MCP address `https://mcp.getnoan.com/mcp`
|
|
21
|
+
merged into its config: Claude Code (`.mcp.json`), Cursor (`.cursor/mcp.json`), VS Code
|
|
22
|
+
(`.vscode/mcp.json`), Gemini CLI, Windsurf, and Codex (`~/.codex/config.toml`, which reads the
|
|
23
|
+
key from your environment by name; no secret is written into any config file). Clients that
|
|
24
|
+
sign you in themselves get the address only. Claude and ChatGPT have no file to write; the
|
|
25
|
+
report tells you what to click.
|
|
26
|
+
3. **The skill.** The `noan-fact-layer` and `noan-fact-candidate-capture` skills from
|
|
27
|
+
[getnoan/skills](https://github.com/getnoan/skills) are copied into `.claude/skills/` (and
|
|
28
|
+
`~/.codex/skills/` if Codex is installed), and a short pointer is appended to `CLAUDE.md` and
|
|
29
|
+
`AGENTS.md` so an assistant knows where the key and the skill are.
|
|
30
|
+
4. **Your fact layer.** If the workspace holds fewer than ten facts, the wizard does not guess
|
|
31
|
+
facts for you. It hands the seeding to your assistant, which has the procedure from the skill:
|
|
32
|
+
read your website, repo and docs, propose a structure, and write only after you say yes.
|
|
33
|
+
5. **The agent pack** (optional, `--agents`). Forks [getnoan/agent-pack](https://github.com/getnoan/agent-pack)
|
|
34
|
+
to your account, verifies and stores your Anthropic and Resend keys (and optionally a Postgres URL
|
|
35
|
+
and a Firecrawl key) as repository secrets, runs the pack's seed scripts so each agent's starting
|
|
36
|
+
instructions are in your workspace, records the block slugs as repository variables, and triggers
|
|
37
|
+
one dry run. Safe mode stays on: the wizard never sets `DRY_RUN` to 0. That switch is yours.
|
|
38
|
+
6. **The report.** What happened, and the one thing to do next.
|
|
39
|
+
|
|
40
|
+
## For a coding assistant
|
|
41
|
+
|
|
42
|
+
The wizard is meant to be run by an agent mid-session, so every prompt has a flag:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
NOAN_API_KEY=npak_… npx -y @getnoan/wizard@latest --yes --json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`--yes` takes every default and never prompts; `--json` prints the report as one object on stdout
|
|
49
|
+
(progress goes to stderr) with a `next` list an agent can act on, including the first-connect
|
|
50
|
+
hand-off when the workspace is empty. `--agents` adds the pack step; it reads `ANTHROPIC_API_KEY`,
|
|
51
|
+
`RESEND_API_KEY`, `DATABASE_URL`, `FIRECRAWL_API_KEY`, `MAIL_FROM`, `REPLY_TO` and `ESCALATE_TO`
|
|
52
|
+
from the environment and skips what is missing, saying so.
|
|
53
|
+
|
|
54
|
+
Exit codes: 0 done, 2 the key did not work, 3 cancelled, 1 anything else.
|
|
55
|
+
|
|
56
|
+
## Options
|
|
57
|
+
|
|
58
|
+
| Flag | Effect |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `-y`, `--yes` | non-interactive |
|
|
61
|
+
| `--json` | machine-readable report (implies `--yes`) |
|
|
62
|
+
| `--api-key <key>` | the NOAN key (else `NOAN_API_KEY`, else a prompt) |
|
|
63
|
+
| `--global` | user-scope client config instead of project scope |
|
|
64
|
+
| `--dir <path>` | the project directory (default: current) |
|
|
65
|
+
| `--agents` / `--no-agents` | include or skip the agent pack step |
|
|
66
|
+
| `--no-mcp`, `--no-skill` | skip a step |
|
|
67
|
+
| `--dry-run` | show every write without making it |
|
|
68
|
+
| `--no-telemetry` | send nothing about this run (also `NOAN_WIZARD_NO_TELEMETRY=1`) |
|
|
69
|
+
|
|
70
|
+
## Telemetry
|
|
71
|
+
|
|
72
|
+
Three counts and nothing else: runs started, completed, cancelled, with the Node version and
|
|
73
|
+
platform. Never a key, never a path, never a workspace name. Off with `--no-telemetry`.
|
|
74
|
+
|
|
75
|
+
## Why a wizard and not a prompt
|
|
76
|
+
|
|
77
|
+
NOAN's published API description is wrong in a few places that a spec-generated client gets
|
|
78
|
+
confidently wrong (fields the spec marks nullable that the API rejects, create responses that nest
|
|
79
|
+
under the resource name, limits that reject rather than truncate). A short deterministic program
|
|
80
|
+
that makes the right calls beats asking a model to work it out from the spec. The wizard makes the
|
|
81
|
+
few calls it needs correctly and hands everything that needs judgement to your assistant.
|
|
82
|
+
|
|
83
|
+
## Licence
|
|
84
|
+
|
|
85
|
+
MIT.
|
package/bin/wizard.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { run } from "../src/run.mjs";
|
|
3
|
+
import { parseArgs, HELP } from "../src/args.mjs";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
const args = parseArgs(process.argv.slice(2));
|
|
7
|
+
if (args.help) { process.stdout.write(HELP); process.exit(0); }
|
|
8
|
+
if (args.version) { process.stdout.write(createRequire(import.meta.url)("../package.json").version + "\n"); process.exit(0); }
|
|
9
|
+
run(args).then(code => process.exit(code)).catch(e => { console.error("wizard: " + (e?.message || e)); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getnoan/wizard",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"noan-wizard": "bin/wizard.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/getnoan/wizard.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://getnoan.com/mcp",
|
|
27
|
+
"keywords": [
|
|
28
|
+
"noan",
|
|
29
|
+
"mcp",
|
|
30
|
+
"agent",
|
|
31
|
+
"skill",
|
|
32
|
+
"wizard",
|
|
33
|
+
"setup",
|
|
34
|
+
"fact-layer"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/agents.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent pack on the user's own GitHub account: fork, secrets, variables, seeds, one dry run.
|
|
3
|
+
*
|
|
4
|
+
* Everything here goes through the GitHub CLI so no token is ever handled by this code. The
|
|
5
|
+
* seed scripts are the pack's own (run from the clone, output parsed); the variable names are
|
|
6
|
+
* the pack's documented ones. Safe mode stays on: the wizard never sets DRY_RUN to 0.
|
|
7
|
+
*/
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { writeEnv } from "./env-file.mjs";
|
|
13
|
+
|
|
14
|
+
export const PACK_REPO = "getnoan/agent-pack";
|
|
15
|
+
|
|
16
|
+
export function gh(args, opts = {}) {
|
|
17
|
+
const r = spawnSync("gh", args, { encoding: "utf8", ...opts });
|
|
18
|
+
return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim(), status: r.status };
|
|
19
|
+
}
|
|
20
|
+
export function ghReady() {
|
|
21
|
+
const v = gh(["--version"]); if (!v.ok) return { ok: false, reason: "the GitHub CLI (gh) is not installed — https://cli.github.com" };
|
|
22
|
+
const a = gh(["auth", "status"]); if (!a.ok) return { ok: false, reason: "gh is not signed in — run: gh auth login" };
|
|
23
|
+
return { ok: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Parse what a seed script prints: the two .env lines per agent. */
|
|
27
|
+
export function parseSeedOutput(text) {
|
|
28
|
+
const vars = {};
|
|
29
|
+
for (const m of String(text || "").matchAll(/^\s*([A-Z][A-Z0-9_]*_BLOCK_SLUG)=(\S+)\s*$/gm)) vars[m[1]] = m[2];
|
|
30
|
+
return vars;
|
|
31
|
+
}
|
|
32
|
+
|
|
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"];
|
|
36
|
+
|
|
37
|
+
export async function verifyAnthropic(key) {
|
|
38
|
+
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; }
|
|
39
|
+
}
|
|
40
|
+
export async function verifyResend(key) {
|
|
41
|
+
try { const r = await fetch("https://api.resend.com/domains", { headers: { Authorization: `Bearer ${key}` } }); return r.status === 200; } catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
export async function verifyFirecrawl(key) {
|
|
44
|
+
try { const r = await fetch("https://api.firecrawl.dev/v1/team/credit-usage", { headers: { Authorization: `Bearer ${key}` } }); return r.status === 200; } catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Fork (or reuse) and clone the pack into dir/noan-agent-pack. */
|
|
48
|
+
export function forkAndClone(dir, { dryRun = false } = {}) {
|
|
49
|
+
const dest = path.join(dir, "noan-agent-pack");
|
|
50
|
+
if (existsSync(path.join(dest, ".git"))) return { dest, action: "reused existing clone" };
|
|
51
|
+
if (dryRun) return { dest, action: "would fork and clone" };
|
|
52
|
+
const r = gh(["repo", "fork", PACK_REPO, "--clone", "--remote=false", dest]);
|
|
53
|
+
if (!r.ok) return { dest, action: "fork failed", error: r.err.split("\n")[0] };
|
|
54
|
+
return { dest, action: "forked and cloned" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function repoSlug(dest) {
|
|
58
|
+
const r = gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], { cwd: dest });
|
|
59
|
+
return r.ok ? r.out : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function setSecret(dest, name, value, dryRun) {
|
|
63
|
+
if (dryRun) return { name, action: "would set" };
|
|
64
|
+
const r = spawnSync("gh", ["secret", "set", name], { cwd: dest, input: value, encoding: "utf8" });
|
|
65
|
+
return { name, action: r.status === 0 ? "set" : "failed", error: r.status === 0 ? undefined : (r.stderr || "").trim().split("\n")[0] };
|
|
66
|
+
}
|
|
67
|
+
export function setVariable(dest, name, value, dryRun) {
|
|
68
|
+
if (dryRun) return { name, action: "would set" };
|
|
69
|
+
const r = spawnSync("gh", ["variable", "set", name, "--body", value], { cwd: dest, encoding: "utf8" });
|
|
70
|
+
return { name, action: r.status === 0 ? "set" : "failed", error: r.status === 0 ? undefined : (r.stderr || "").trim().split("\n")[0] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Run every seed script the pack ships; collect the block slugs they print. */
|
|
74
|
+
export function runSeeds(dest, env, { dryRun = false } = {}) {
|
|
75
|
+
const seeds = ["seed-weekly-activity-report.mjs", "seed-fact-alignment.mjs", "seed-market-research-refresh.mjs", "seed-customer-support.mjs", "seed-deck.mjs"];
|
|
76
|
+
const slugs = {}; const rows = [];
|
|
77
|
+
for (const s of seeds) {
|
|
78
|
+
const p = path.join(dest, "agents", s);
|
|
79
|
+
if (!existsSync(p)) { rows.push({ seed: s, action: "missing" }); continue; }
|
|
80
|
+
if (dryRun) { rows.push({ seed: s, action: "would run" }); continue; }
|
|
81
|
+
const r = spawnSync(process.execPath, [p], { cwd: path.join(dest, "agents"), encoding: "utf8", env: { ...process.env, ...env } });
|
|
82
|
+
const found = parseSeedOutput((r.stdout || "") + "\n" + (r.stderr || ""));
|
|
83
|
+
Object.assign(slugs, found);
|
|
84
|
+
rows.push({ seed: s, action: r.status === 0 ? "ran" : "failed", slugs: Object.keys(found).length, error: r.status === 0 ? undefined : (r.stderr || r.stdout || "").trim().split("\n").slice(-1)[0] });
|
|
85
|
+
}
|
|
86
|
+
return { slugs, rows };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function dispatchDryRun(dest, workflow, dryRun) {
|
|
90
|
+
if (dryRun) return { workflow, action: "would dispatch" };
|
|
91
|
+
const r = gh(["workflow", "run", workflow, "-f", "dry_run=1"], { cwd: dest });
|
|
92
|
+
return { workflow, action: r.ok ? "dispatched" : "failed", error: r.ok ? undefined : r.err.split("\n")[0] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const randomSecret = () => randomBytes(24).toString("hex");
|
|
96
|
+
|
|
97
|
+
/** Write the pack's .env in the clone so the seeds (and a local run) have what they need. */
|
|
98
|
+
export function writePackEnv(dest, pairs, dryRun) { return writeEnv(dest, pairs, { dryRun }); }
|
package/src/args.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Flags. A coding assistant runs this too, so every prompt has a flag that answers it. */
|
|
2
|
+
export const HELP = `noan-wizard — one command from nothing to a grounded agent
|
|
3
|
+
|
|
4
|
+
npx -y @getnoan/wizard@latest [options]
|
|
5
|
+
|
|
6
|
+
What it does, in order:
|
|
7
|
+
1. Auth takes your NOAN API key (or reads NOAN_API_KEY), checks it with GET /me,
|
|
8
|
+
writes it to .env and makes sure .env is gitignored
|
|
9
|
+
2. Clients finds your coding assistants and points each at the NOAN MCP server
|
|
10
|
+
3. Skill installs the NOAN skill and leaves a pointer in CLAUDE.md / AGENTS.md
|
|
11
|
+
4. Workspace checks whether your fact layer has anything in it; if not, hands the
|
|
12
|
+
seeding to your assistant (it has the procedure) rather than guessing
|
|
13
|
+
5. Agents optionally sets up the open-source agent pack on your GitHub account
|
|
14
|
+
6. Report says what it did, and what to do next
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
-y, --yes non-interactive: take every default, never prompt (for an agent mid-session)
|
|
18
|
+
--json machine-readable report on stdout (implies --yes)
|
|
19
|
+
--api-key <key> the NOAN key (else NOAN_API_KEY / NOAN_PERSONAL_API_KEY, else a prompt)
|
|
20
|
+
--global write user-scope client config (~/.claude.json, ~/.cursor/mcp.json, ...)
|
|
21
|
+
instead of project scope (.mcp.json, .cursor/mcp.json, .vscode/mcp.json)
|
|
22
|
+
--dir <path> the project directory (default: the current directory)
|
|
23
|
+
--agents also set up the agent pack (default: ask; with --yes: skip unless given)
|
|
24
|
+
--no-mcp skip the client wiring
|
|
25
|
+
--no-skill skip the skill install
|
|
26
|
+
--dry-run show every write without making it
|
|
27
|
+
--no-telemetry send nothing about this run (also: NOAN_WIZARD_NO_TELEMETRY=1)
|
|
28
|
+
-h, --help this text
|
|
29
|
+
-v, --version the version
|
|
30
|
+
|
|
31
|
+
Exit codes: 0 done · 2 the key did not work · 3 cancelled · 1 anything else
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export function parseArgs(argv) {
|
|
35
|
+
const a = { yes: false, json: false, apiKey: null, global: false, dir: process.cwd(), agents: null,
|
|
36
|
+
mcp: true, skill: true, dryRun: false, telemetry: true, help: false, version: false, unknown: [] };
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const t = argv[i];
|
|
39
|
+
const next = () => { const v = argv[++i]; if (v === undefined) throw new Error(`${t} needs a value`); return v; };
|
|
40
|
+
if (t === "-y" || t === "--yes") a.yes = true;
|
|
41
|
+
else if (t === "--json") { a.json = true; a.yes = true; }
|
|
42
|
+
else if (t === "--api-key") a.apiKey = next();
|
|
43
|
+
else if (t.startsWith("--api-key=")) a.apiKey = t.slice(10);
|
|
44
|
+
else if (t === "--global") a.global = true;
|
|
45
|
+
else if (t === "--dir") a.dir = next();
|
|
46
|
+
else if (t.startsWith("--dir=")) a.dir = t.slice(6);
|
|
47
|
+
else if (t === "--agents") a.agents = true;
|
|
48
|
+
else if (t === "--no-agents") a.agents = false;
|
|
49
|
+
else if (t === "--no-mcp") a.mcp = false;
|
|
50
|
+
else if (t === "--no-skill") a.skill = false;
|
|
51
|
+
else if (t === "--dry-run") a.dryRun = true;
|
|
52
|
+
else if (t === "--no-telemetry") a.telemetry = false;
|
|
53
|
+
else if (t === "-h" || t === "--help") a.help = true;
|
|
54
|
+
else if (t === "-v" || t === "--version") a.version = true;
|
|
55
|
+
else a.unknown.push(t);
|
|
56
|
+
}
|
|
57
|
+
if (a.unknown.length) throw new Error(`unknown option: ${a.unknown.join(" ")}\n\n${HELP}`);
|
|
58
|
+
return a;
|
|
59
|
+
}
|
package/src/clients.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coding-assistant clients: detect what is installed and point each at the NOAN MCP server.
|
|
3
|
+
*
|
|
4
|
+
* Every write is an idempotent merge: an existing `noan` entry is updated in place, other
|
|
5
|
+
* servers are untouched, unknown files are created. OAuth-capable clients get the URL only —
|
|
6
|
+
* they sign the user in themselves. Codex reads the key from the environment by name, so no
|
|
7
|
+
* secret is ever written into a config file. Claude Desktop and ChatGPT have no config file to
|
|
8
|
+
* write; they get instructions in the report.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { MCP_URL } from "./noan.mjs";
|
|
14
|
+
|
|
15
|
+
export function readJson(p) { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } }
|
|
16
|
+
function writeJson(p, obj, dryRun) { if (dryRun) return; mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(obj, null, 2) + "\n"); }
|
|
17
|
+
|
|
18
|
+
/** Merge a `noan` server entry into a JSON config at `container` (e.g. "mcpServers"). */
|
|
19
|
+
export function mergeJsonServer(existing, container, entry) {
|
|
20
|
+
const obj = existing && typeof existing === "object" ? existing : {};
|
|
21
|
+
const servers = obj[container] && typeof obj[container] === "object" ? obj[container] : {};
|
|
22
|
+
const before = JSON.stringify(servers.noan ?? null);
|
|
23
|
+
servers.noan = { ...(servers.noan || {}), ...entry };
|
|
24
|
+
obj[container] = servers;
|
|
25
|
+
return { obj, action: before === "null" ? "added" : before === JSON.stringify(servers.noan) ? "unchanged" : "updated" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Codex's config is TOML; add or replace the [mcp_servers.noan] table without touching the rest. */
|
|
29
|
+
export function mergeToml(text, table, lines) {
|
|
30
|
+
const block = `[${table}]\n${lines.join("\n")}\n`;
|
|
31
|
+
const src = text || "";
|
|
32
|
+
const rx = new RegExp(`^\\[${table.replace(/\./g, "\\.")}\\]\\n(?:(?!\\[)[^\\n]*\\n?)*`, "m");
|
|
33
|
+
if (rx.test(src)) {
|
|
34
|
+
const cur = src.match(rx)[0];
|
|
35
|
+
if (cur.trim() === block.trim()) return { text: src, action: "unchanged" };
|
|
36
|
+
return { text: src.replace(rx, block), action: "updated" };
|
|
37
|
+
}
|
|
38
|
+
return { text: (src && !src.endsWith("\n") ? src + "\n" : src) + (src ? "\n" : "") + block, action: "added" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const HOME = os.homedir();
|
|
42
|
+
const onPath = (cmd) => (process.env.PATH || "").split(path.delimiter).some(d => d && existsSync(path.join(d, cmd)));
|
|
43
|
+
|
|
44
|
+
/** The clients, with how to detect them and where their config lives at each scope. */
|
|
45
|
+
export function clientCatalog({ dir, home = HOME }) {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
id: "claude-code", label: "Claude Code",
|
|
49
|
+
detect: () => existsSync(path.join(home, ".claude.json")) || existsSync(path.join(home, ".claude")) || onPath("claude"),
|
|
50
|
+
project: { file: path.join(dir, ".mcp.json"), kind: "json", container: "mcpServers", entry: { type: "http", url: MCP_URL } },
|
|
51
|
+
global: { file: path.join(home, ".claude.json"), kind: "json", container: "mcpServers", entry: { type: "http", url: MCP_URL } },
|
|
52
|
+
after: "In a session, type /mcp and pick NOAN to sign in.",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: "cursor", label: "Cursor",
|
|
56
|
+
detect: () => existsSync(path.join(home, ".cursor")),
|
|
57
|
+
project: { file: path.join(dir, ".cursor", "mcp.json"), kind: "json", container: "mcpServers", entry: { url: MCP_URL } },
|
|
58
|
+
global: { file: path.join(home, ".cursor", "mcp.json"), kind: "json", container: "mcpServers", entry: { url: MCP_URL } },
|
|
59
|
+
after: "Cursor asks you to sign in to NOAN once.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "codex", label: "OpenAI Codex",
|
|
63
|
+
detect: () => existsSync(path.join(home, ".codex")) || onPath("codex"),
|
|
64
|
+
project: null, // Codex config is user-level only
|
|
65
|
+
global: { file: path.join(home, ".codex", "config.toml"), kind: "toml", table: "mcp_servers.noan",
|
|
66
|
+
lines: [`url = "${MCP_URL}"`, `bearer_token_env_var = "NOAN_API_KEY"`] },
|
|
67
|
+
after: "Codex sends NOAN_API_KEY from your environment as the bearer token.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "vscode", label: "VS Code",
|
|
71
|
+
detect: () => existsSync(path.join(dir, ".vscode")) || onPath("code"),
|
|
72
|
+
project: { file: path.join(dir, ".vscode", "mcp.json"), kind: "json", container: "servers", entry: { type: "http", url: MCP_URL } },
|
|
73
|
+
global: null, // user-level MCP in VS Code lives in the profile UI, not a file we should edit
|
|
74
|
+
after: "Open the MCP view and start the NOAN server; VS Code signs you in.",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: "windsurf", label: "Windsurf",
|
|
78
|
+
detect: () => existsSync(path.join(home, ".codeium", "windsurf")),
|
|
79
|
+
project: null,
|
|
80
|
+
global: { file: path.join(home, ".codeium", "windsurf", "mcp_config.json"), kind: "json", container: "mcpServers", entry: { serverUrl: MCP_URL } },
|
|
81
|
+
after: "Refresh the MCP list in Windsurf.",
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "gemini", label: "Gemini CLI",
|
|
85
|
+
detect: () => existsSync(path.join(home, ".gemini")),
|
|
86
|
+
project: { file: path.join(dir, ".gemini", "settings.json"), kind: "json", container: "mcpServers", entry: { httpUrl: MCP_URL } },
|
|
87
|
+
global: { file: path.join(home, ".gemini", "settings.json"), kind: "json", container: "mcpServers", entry: { httpUrl: MCP_URL } },
|
|
88
|
+
after: "Run /mcp in Gemini CLI to check the server is up.",
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Clients with no file to write — the report tells the user what to click. */
|
|
94
|
+
export const MANUAL_CLIENTS = [
|
|
95
|
+
{ id: "claude-desktop", label: "Claude (desktop and web)", how: `Settings → Connectors → Add custom connector → name it NOAN, address ${MCP_URL}, then Connect and sign in.` },
|
|
96
|
+
{ id: "chatgpt", label: "ChatGPT", how: `Settings → Connectors (developer mode on) → Create → name it NOAN, address ${MCP_URL}, sign in with your NOAN account, then enable it in a chat.` },
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
/** Wire every detected client at the chosen scope. Returns one row per client. */
|
|
100
|
+
export function wireClients({ dir, global = false, dryRun = false, home = HOME, force = [] }) {
|
|
101
|
+
const rows = [];
|
|
102
|
+
for (const c of clientCatalog({ dir, home })) {
|
|
103
|
+
const detected = c.detect() || force.includes(c.id);
|
|
104
|
+
const target = global ? (c.global || c.project) : (c.project || c.global);
|
|
105
|
+
if (!detected) { rows.push({ id: c.id, label: c.label, detected: false }); continue; }
|
|
106
|
+
if (!target) { rows.push({ id: c.id, label: c.label, detected: true, action: "no config file for this scope" }); continue; }
|
|
107
|
+
let action;
|
|
108
|
+
if (target.kind === "json") {
|
|
109
|
+
const { obj, action: a } = mergeJsonServer(readJson(target.file), target.container, target.entry);
|
|
110
|
+
action = a; if (a !== "unchanged") writeJson(target.file, obj, dryRun);
|
|
111
|
+
} else {
|
|
112
|
+
const cur = existsSync(target.file) ? readFileSync(target.file, "utf8") : "";
|
|
113
|
+
const { text, action: a } = mergeToml(cur, target.table, target.lines);
|
|
114
|
+
action = a; if (a !== "unchanged" && !dryRun) { mkdirSync(path.dirname(target.file), { recursive: true }); writeFileSync(target.file, text); }
|
|
115
|
+
}
|
|
116
|
+
rows.push({ id: c.id, label: c.label, detected: true, file: target.file, scope: target === c.global ? "user" : "project", action, after: c.after });
|
|
117
|
+
}
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
package/src/env-file.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** .env handling: set keys without disturbing the rest, keep the file private, keep it out of git. */
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync, appendFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Returns the new file text with `pairs` set (replacing existing lines for those keys). */
|
|
6
|
+
export function upsertEnv(text, pairs) {
|
|
7
|
+
const lines = (text || "").split("\n");
|
|
8
|
+
const done = new Set();
|
|
9
|
+
const out = lines.map(l => {
|
|
10
|
+
const m = l.match(/^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=/);
|
|
11
|
+
if (m && m[1] in pairs) { done.add(m[1]); return `${m[1]}=${pairs[m[1]]}`; }
|
|
12
|
+
return l;
|
|
13
|
+
});
|
|
14
|
+
while (out.length && out[out.length - 1] === "") out.pop();
|
|
15
|
+
for (const [k, v] of Object.entries(pairs)) if (!done.has(k)) out.push(`${k}=${v}`);
|
|
16
|
+
return out.join("\n") + "\n";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function writeEnv(dir, pairs, { dryRun = false } = {}) {
|
|
20
|
+
const p = path.join(dir, ".env");
|
|
21
|
+
const before = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
22
|
+
const after = upsertEnv(before, pairs);
|
|
23
|
+
if (!dryRun) { writeFileSync(p, after); try { chmodSync(p, 0o600); } catch {} }
|
|
24
|
+
return { path: p, created: !before, changed: before !== after };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** .gitignore must cover .env; add the line if the directory is a git checkout (or already has a .gitignore). */
|
|
28
|
+
export function ensureGitignored(dir, { dryRun = false } = {}) {
|
|
29
|
+
const gi = path.join(dir, ".gitignore");
|
|
30
|
+
const isRepo = existsSync(path.join(dir, ".git"));
|
|
31
|
+
if (!isRepo && !existsSync(gi)) return { path: gi, action: "not a git repo" };
|
|
32
|
+
const text = existsSync(gi) ? readFileSync(gi, "utf8") : "";
|
|
33
|
+
if (text.split("\n").some(l => /^\s*\.env(\s*$|\s+#)/.test(l) || l.trim() === ".env*" || l.trim() === "*.env")) return { path: gi, action: "already ignored" };
|
|
34
|
+
if (!dryRun) appendFileSync(gi, (text && !text.endsWith("\n") ? "\n" : "") + ".env\n");
|
|
35
|
+
return { path: gi, action: "added .env" };
|
|
36
|
+
}
|
package/src/noan.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** The few NOAN calls the wizard makes. Plain fetch; the key travels only in the header. */
|
|
2
|
+
export const API = "https://api.getnoan.com/v1";
|
|
3
|
+
export const MCP_URL = "https://mcp.getnoan.com/mcp";
|
|
4
|
+
export const APP_URL = "https://app.getnoan.com";
|
|
5
|
+
export const KEY_PAGE_HINT = "app.getnoan.com → Settings → API → New key";
|
|
6
|
+
|
|
7
|
+
export function looksLikeKey(k) { return typeof k === "string" && /^npak_[A-Za-z0-9_-]{16,}$/.test(k.trim()); }
|
|
8
|
+
|
|
9
|
+
async function get(path, key) {
|
|
10
|
+
const r = await fetch(API + path, { headers: { Authorization: `Bearer ${key}` } });
|
|
11
|
+
let body = null; try { body = await r.json(); } catch {}
|
|
12
|
+
return { status: r.status, body };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** GET /me: who the key is, and which workspace. */
|
|
16
|
+
export async function whoAmI(key) {
|
|
17
|
+
const { status, body } = await get("/me", key);
|
|
18
|
+
if (status === 401 || status === 403) return { ok: false, reason: "the key was not accepted" };
|
|
19
|
+
if (status !== 200 || !body?.project) return { ok: false, reason: `unexpected answer from NOAN (${status})` };
|
|
20
|
+
return { ok: true, project: body.project, identity: body.identity };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** How many facts the workspace holds — the signal for "empty, hand the seeding over". */
|
|
24
|
+
export async function factCount(key) {
|
|
25
|
+
const { status, body } = await get("/facts?per_page=1", key);
|
|
26
|
+
if (status !== 200) return null;
|
|
27
|
+
return body?.meta?.totalItems ?? (Array.isArray(body?.items) ? body.items.length : null);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const EMPTY_THRESHOLD = 10;
|
|
31
|
+
export function classifyWorkspace(count) {
|
|
32
|
+
if (count == null) return { state: "unknown", empty: false };
|
|
33
|
+
return { state: count < EMPTY_THRESHOLD ? "empty" : "populated", empty: count < EMPTY_THRESHOLD, count };
|
|
34
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** The last thing the wizard prints: what it did and what to do next. Text for people, JSON for agents. */
|
|
2
|
+
import { MCP_URL, APP_URL } from "./noan.mjs";
|
|
3
|
+
|
|
4
|
+
export function renderReport(report, { json = false } = {}) {
|
|
5
|
+
if (json) return JSON.stringify(report, null, 2) + "\n";
|
|
6
|
+
const s = report.steps; const L = [];
|
|
7
|
+
L.push("", "──────── what happened ────────");
|
|
8
|
+
if (s.auth?.ok) L.push(`key: ${s.auth.email} · workspace "${s.auth.workspace}" · .env ${s.auth.envAction} · .gitignore ${s.auth.gitignore}`);
|
|
9
|
+
else L.push(`key: not accepted${s.auth?.reason ? ` (${s.auth.reason})` : ""}`);
|
|
10
|
+
if (s.clients?.rows) {
|
|
11
|
+
const wired = s.clients.rows.filter(r => r.detected);
|
|
12
|
+
L.push(wired.length ? `clients: ${wired.map(r => `${r.label} ${r.action}`).join(" · ")}` : `clients: none found (MCP address ${MCP_URL})`);
|
|
13
|
+
for (const r of wired) if (r.after) L.push(` ${r.label}: ${r.after}`);
|
|
14
|
+
}
|
|
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
|
+
if (s.workspace) L.push(`facts: ${s.workspace.count ?? "?"} (${s.workspace.state})`);
|
|
17
|
+
if (s.agents && !s.agents.skipped) L.push(s.agents.ok ? `agents: ${s.agents.repo || s.agents.dest} — ${s.agents.secrets.length} secrets, ${s.agents.variables.length} variables, safe mode on` : `agents: not set up (${s.agents.reason})`);
|
|
18
|
+
if (report.next?.length) { L.push("", "──────── next ────────"); for (const n of report.next) L.push(`• ${n.say}${n.why ? ` (${n.why})` : ""}`); }
|
|
19
|
+
L.push("");
|
|
20
|
+
return L.join("\n");
|
|
21
|
+
}
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/** The wizard, step by step. Each step reports a row; the report at the end is built from the rows. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { makeUI } from "./ui.mjs";
|
|
5
|
+
import { whoAmI, factCount, classifyWorkspace, looksLikeKey, APP_URL, KEY_PAGE_HINT, MCP_URL } from "./noan.mjs";
|
|
6
|
+
import { writeEnv, ensureGitignored } from "./env-file.mjs";
|
|
7
|
+
import { wireClients, MANUAL_CLIENTS } from "./clients.mjs";
|
|
8
|
+
import { fetchSkillFiles, skillTargets, installSkill, writePointers } from "./skill.mjs";
|
|
9
|
+
import * as pack from "./agents.mjs";
|
|
10
|
+
import { capture } from "./telemetry.mjs";
|
|
11
|
+
import { renderReport } from "./report.mjs";
|
|
12
|
+
|
|
13
|
+
export async function run(args) {
|
|
14
|
+
const ui = makeUI(args);
|
|
15
|
+
const dir = path.resolve(args.dir);
|
|
16
|
+
const report = { version: 1, dir, dryRun: args.dryRun, steps: {}, next: [] };
|
|
17
|
+
const t0 = Date.now();
|
|
18
|
+
await capture(args, "started");
|
|
19
|
+
const finish = async (code, event = code === 0 ? "completed" : "cancelled") => {
|
|
20
|
+
await capture(args, event, { exit: code, ms: Date.now() - t0, empty: report.steps.workspace?.empty });
|
|
21
|
+
report.exit = code;
|
|
22
|
+
process.stdout.write(renderReport(report, { json: args.json }));
|
|
23
|
+
return code;
|
|
24
|
+
};
|
|
25
|
+
if (!existsSync(dir)) { ui.warn(`no such directory: ${dir}`); return finish(1, "cancelled"); }
|
|
26
|
+
ui.say(`NOAN wizard${args.dryRun ? " (dry run: nothing is written)" : ""} — ${dir}`);
|
|
27
|
+
|
|
28
|
+
/* 1. auth */
|
|
29
|
+
ui.step(1, "Your NOAN key");
|
|
30
|
+
let key = (args.apiKey || process.env.NOAN_API_KEY || process.env.NOAN_PERSONAL_API_KEY || "").trim();
|
|
31
|
+
if (key) ui.info("using the key from " + (args.apiKey ? "--api-key" : "the environment"));
|
|
32
|
+
if (!key) {
|
|
33
|
+
if (!ui.interactive) { ui.warn("no key: set NOAN_API_KEY or pass --api-key"); report.steps.auth = { ok: false, reason: "no key" }; return finish(2, "cancelled"); }
|
|
34
|
+
ui.info(`Create one at ${KEY_PAGE_HINT} (opening ${APP_URL} is up to you), then paste it here. It is not echoed.`);
|
|
35
|
+
key = await ask(ui, "NOAN API key", { secret: true, required: true });
|
|
36
|
+
}
|
|
37
|
+
if (!looksLikeKey(key)) ui.warn("that does not look like a NOAN key (they start with npak_) — checking anyway");
|
|
38
|
+
const me = await whoAmI(key);
|
|
39
|
+
if (!me.ok) { ui.warn(me.reason); report.steps.auth = { ok: false, reason: me.reason }; return finish(2, "cancelled"); }
|
|
40
|
+
ui.ok(`key accepted: ${me.identity?.email || "?"} in workspace "${me.project?.name || "?"}"`);
|
|
41
|
+
const env = writeEnv(dir, { NOAN_API_KEY: key, NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
42
|
+
const gi = ensureGitignored(dir, { dryRun: args.dryRun });
|
|
43
|
+
ui.ok(`${env.created ? "wrote" : env.changed ? "updated" : "kept"} ${env.path} (NOAN_API_KEY, NOAN_PERSONAL_API_KEY); .gitignore: ${gi.action}`);
|
|
44
|
+
report.steps.auth = { ok: true, email: me.identity?.email, workspace: me.project?.name, env: env.path, envAction: env.created ? "created" : env.changed ? "updated" : "unchanged", gitignore: gi.action };
|
|
45
|
+
|
|
46
|
+
/* 2. clients */
|
|
47
|
+
ui.step(2, "Your coding assistants");
|
|
48
|
+
if (args.mcp) {
|
|
49
|
+
const rows = wireClients({ dir, global: args.global, dryRun: args.dryRun });
|
|
50
|
+
for (const r of rows) {
|
|
51
|
+
if (!r.detected) continue;
|
|
52
|
+
ui.ok(`${r.label}: ${r.action}${r.file ? ` (${r.scope} scope, ${r.file})` : ""}`);
|
|
53
|
+
}
|
|
54
|
+
if (!rows.some(r => r.detected)) ui.info("no coding assistant found on this machine; the MCP address is " + MCP_URL);
|
|
55
|
+
report.steps.clients = { rows, manual: MANUAL_CLIENTS };
|
|
56
|
+
for (const m of MANUAL_CLIENTS) ui.info(`${m.label}: ${m.how}`);
|
|
57
|
+
} else { ui.info("skipped (--no-mcp)"); report.steps.clients = { skipped: true }; }
|
|
58
|
+
|
|
59
|
+
/* 3. skill */
|
|
60
|
+
ui.step(3, "The NOAN skill");
|
|
61
|
+
if (args.skill) {
|
|
62
|
+
try {
|
|
63
|
+
const { files, missing } = await fetchSkillFiles();
|
|
64
|
+
const rows = installSkill(files, skillTargets({ dir, global: args.global }), { dryRun: args.dryRun });
|
|
65
|
+
for (const m of missing) ui.info(`${m} is not published yet; skipped`);
|
|
66
|
+
for (const r of rows) ui.ok(`${r.id}: ${r.written} file(s) ${args.dryRun ? "to write" : "written"}, ${r.unchanged} unchanged → ${r.root}`);
|
|
67
|
+
const pointers = writePointers(dir, { dryRun: args.dryRun });
|
|
68
|
+
for (const p of pointers) ui.ok(`${path.basename(p.file)}: ${p.action}`);
|
|
69
|
+
report.steps.skill = { rows, pointers, missing };
|
|
70
|
+
} catch (e) { ui.warn(`skill install failed: ${e.message}`); report.steps.skill = { error: e.message }; }
|
|
71
|
+
} else { ui.info("skipped (--no-skill)"); report.steps.skill = { skipped: true }; }
|
|
72
|
+
|
|
73
|
+
/* 4. workspace */
|
|
74
|
+
ui.step(4, "Your fact layer");
|
|
75
|
+
const count = await factCount(key);
|
|
76
|
+
const ws = classifyWorkspace(count);
|
|
77
|
+
report.steps.workspace = ws;
|
|
78
|
+
if (ws.state === "empty") {
|
|
79
|
+
ui.warn(`${count} fact(s): the workspace is empty or nearly so. The wizard does not guess facts.`);
|
|
80
|
+
ui.info("Ask your assistant to set up the workspace: it has the first-connect procedure from the skill and will propose a structure from your website, repo or docs before writing anything.");
|
|
81
|
+
report.next.push({ id: "first-connect", say: "Set up my NOAN workspace from our website and this repo. Propose the structure first and wait for my yes before writing.", why: "the fact layer is empty" });
|
|
82
|
+
} else if (ws.state === "populated") ui.ok(`${count} facts in the workspace`);
|
|
83
|
+
else ui.warn("could not count the facts (the key may be read-scoped to a subset); carrying on");
|
|
84
|
+
|
|
85
|
+
/* 5. agents */
|
|
86
|
+
ui.step(5, "The agent pack");
|
|
87
|
+
let wantAgents = args.agents;
|
|
88
|
+
if (wantAgents == null) wantAgents = ui.interactive ? await ui.confirm("Set up the six open-source agents on your GitHub account too?", false) : false;
|
|
89
|
+
if (wantAgents) report.steps.agents = await setupAgents({ args, ui, dir, key, me });
|
|
90
|
+
else { ui.info("skipped" + (args.yes && args.agents == null ? " (pass --agents to include it)" : "")); report.steps.agents = { skipped: true }; }
|
|
91
|
+
|
|
92
|
+
/* 6. report */
|
|
93
|
+
report.next.push({ id: "open", say: `Open ${APP_URL} to see the workspace your assistant is grounded in.` });
|
|
94
|
+
return finish(0);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function ask(ui, q, o) { return ui.ask(q, o); }
|
|
98
|
+
|
|
99
|
+
async function setupAgents({ args, ui, dir, key, me }) {
|
|
100
|
+
const ready = pack.ghReady();
|
|
101
|
+
if (!ready.ok) { ui.warn(ready.reason); return { ok: false, reason: ready.reason }; }
|
|
102
|
+
const fork = pack.forkAndClone(dir, { dryRun: args.dryRun });
|
|
103
|
+
ui.ok(`${fork.action}: ${fork.dest}${fork.error ? ` — ${fork.error}` : ""}`);
|
|
104
|
+
if (fork.error) return { ok: false, reason: fork.error };
|
|
105
|
+
const out = { ok: true, dest: fork.dest, secrets: [], variables: [], seeds: [], skipped: [] };
|
|
106
|
+
|
|
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();
|
|
110
|
+
if (!v && ui.interactive) v = await ui.ask(prompt, { secret: true });
|
|
111
|
+
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; }
|
|
113
|
+
return v;
|
|
114
|
+
};
|
|
115
|
+
const anthropic = await need("ANTHROPIC_API_KEY", pack.verifyAnthropic, "Anthropic API key (the agents' model)");
|
|
116
|
+
const resend = await need("RESEND_API_KEY", pack.verifyResend, "Resend API key (the agents' email)");
|
|
117
|
+
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
|
+
const firecrawl = await need("FIRECRAWL_API_KEY", pack.verifyFirecrawl, "Firecrawl API key (market research; blank to skip)", { optional: true });
|
|
119
|
+
const mailFrom = process.env.MAIL_FROM || (ui.interactive ? await ui.ask("Sender for the agents' mail, e.g. Agent <agent@yourdomain.com> (a domain verified in Resend)") : "");
|
|
120
|
+
const replyTo = process.env.REPLY_TO || (ui.interactive ? await ui.ask("Reply-to address (a mailbox someone reads)", { dflt: mailFrom.replace(/^.*<|>.*$/g, "") }) : "");
|
|
121
|
+
const escalateTo = process.env.ESCALATE_TO || (ui.interactive ? await ui.ask("Where the support agent escalates when it cannot answer") : "");
|
|
122
|
+
|
|
123
|
+
const secrets = { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }),
|
|
124
|
+
...(db && { DATABASE_URL: db }), ...(firecrawl && { FIRECRAWL_API_KEY: firecrawl }), NEWSLETTER_UNSUB_SECRET: pack.randomSecret() };
|
|
125
|
+
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}` : ""}`); }
|
|
126
|
+
const vars = { DRY_RUN: "1", STATE_BACKEND: db ? "postgres" : "local", ...(mailFrom && { MAIL_FROM: mailFrom }), ...(replyTo && { REPLY_TO: replyTo }), ...(escalateTo && { ESCALATE_TO: escalateTo }),
|
|
127
|
+
COMPANY_NAME: me.project?.name || "", ...(me.identity?.id && { AGENT_IDENTITY_IDS: me.identity.id }), ...(me.identity?.email && { COMMANDERS: me.identity.email }) };
|
|
128
|
+
// The seeds need the key in the clone's .env; the pack's own .env.example documents the rest.
|
|
129
|
+
pack.writePackEnv(fork.dest, { NOAN_PERSONAL_API_KEY: key, ...(anthropic && { ANTHROPIC_API_KEY: anthropic }), ...(resend && { RESEND_API_KEY: resend }) }, args.dryRun);
|
|
130
|
+
const seeds = pack.runSeeds(fork.dest, { NOAN_PERSONAL_API_KEY: key }, { dryRun: args.dryRun });
|
|
131
|
+
out.seeds = seeds.rows;
|
|
132
|
+
for (const r of seeds.rows) ui.ok(`${r.seed}: ${r.action}${r.slugs != null ? ` (${r.slugs} slug(s))` : ""}${r.error ? ` — ${r.error}` : ""}`);
|
|
133
|
+
Object.assign(vars, seeds.slugs);
|
|
134
|
+
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); }
|
|
135
|
+
ui.ok(`${out.variables.length} repository variable(s) ${args.dryRun ? "to set" : "set"} (DRY_RUN=1: every agent stays in safe mode)`);
|
|
136
|
+
// No point dispatching a run that will only fail for a missing key: say what is missing instead.
|
|
137
|
+
if (!anthropic || !resend) {
|
|
138
|
+
const missing = [!anthropic && "ANTHROPIC_API_KEY", !resend && "RESEND_API_KEY"].filter(Boolean);
|
|
139
|
+
out.incomplete = missing;
|
|
140
|
+
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.`);
|
|
141
|
+
} else {
|
|
142
|
+
const wf = firecrawl ? "market-research-refresh.yml" : "weekly-activity-report.yml";
|
|
143
|
+
const d = pack.dispatchDryRun(fork.dest, wf, args.dryRun);
|
|
144
|
+
out.dryRunDispatch = d;
|
|
145
|
+
ui.ok(`${wf}: ${d.action}${d.error ? ` — ${d.error}` : ""} — read it under Actions, then set DRY_RUN=0 for the agents you trust`);
|
|
146
|
+
}
|
|
147
|
+
out.repo = pack.repoSlug(fork.dest);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
package/src/skill.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NOAN skill: fetched from the public repo and placed where each assistant reads skills.
|
|
3
|
+
* Files are copied, not symlinked, so the install survives a moved checkout; re-running
|
|
4
|
+
* overwrites with the current published version.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import os from "node:os";
|
|
9
|
+
|
|
10
|
+
export const SKILLS_REPO = "getnoan/skills";
|
|
11
|
+
const RAW = `https://raw.githubusercontent.com/${SKILLS_REPO}/main/skills`;
|
|
12
|
+
export const SKILL_FILES = [
|
|
13
|
+
"noan-fact-layer/SKILL.md",
|
|
14
|
+
"noan-fact-layer/references/first-connect.md",
|
|
15
|
+
"noan-fact-layer/references/interview.md",
|
|
16
|
+
"noan-fact-layer/references/writing-facts.md",
|
|
17
|
+
];
|
|
18
|
+
/** Published later than the core skill; installed when present, skipped with a note when not. */
|
|
19
|
+
export const OPTIONAL_SKILL_FILES = ["noan-fact-candidate-capture/SKILL.md"];
|
|
20
|
+
|
|
21
|
+
export async function fetchSkillFiles(fetchImpl = fetch) {
|
|
22
|
+
const out = {}; const missing = [];
|
|
23
|
+
for (const f of [...SKILL_FILES, ...OPTIONAL_SKILL_FILES]) {
|
|
24
|
+
const r = await fetchImpl(`${RAW}/${f}`);
|
|
25
|
+
if (!r.ok) {
|
|
26
|
+
if (OPTIONAL_SKILL_FILES.includes(f) && r.status === 404) { missing.push(f); continue; }
|
|
27
|
+
throw new Error(`could not fetch ${f} from ${SKILLS_REPO} (${r.status})`);
|
|
28
|
+
}
|
|
29
|
+
out[f] = await r.text();
|
|
30
|
+
}
|
|
31
|
+
return { files: out, missing };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Where skills go: Claude Code reads .claude/skills (project) or ~/.claude/skills (user); Codex reads ~/.codex/skills. */
|
|
35
|
+
export function skillTargets({ dir, global = false, home = os.homedir() }) {
|
|
36
|
+
const t = [{ id: "claude-code", root: global ? path.join(home, ".claude", "skills") : path.join(dir, ".claude", "skills") }];
|
|
37
|
+
if (existsSync(path.join(home, ".codex"))) t.push({ id: "codex", root: path.join(home, ".codex", "skills") });
|
|
38
|
+
return t;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function installSkill(files, targets, { dryRun = false } = {}) {
|
|
42
|
+
const rows = [];
|
|
43
|
+
for (const t of targets) {
|
|
44
|
+
let written = 0, unchanged = 0;
|
|
45
|
+
for (const [rel, text] of Object.entries(files)) {
|
|
46
|
+
const p = path.join(t.root, rel);
|
|
47
|
+
const cur = existsSync(p) ? readFileSync(p, "utf8") : null;
|
|
48
|
+
if (cur === text) { unchanged++; continue; }
|
|
49
|
+
if (!dryRun) { mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, text); }
|
|
50
|
+
written++;
|
|
51
|
+
}
|
|
52
|
+
rows.push({ id: t.id, root: t.root, written, unchanged });
|
|
53
|
+
}
|
|
54
|
+
return rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const POINTER_MARK = "<!-- noan-wizard -->";
|
|
58
|
+
export function pointerText() {
|
|
59
|
+
return `${POINTER_MARK}
|
|
60
|
+
## NOAN
|
|
61
|
+
|
|
62
|
+
This project is grounded in the NOAN fact layer. The key is \`NOAN_API_KEY\` in \`.env\`; the MCP server is
|
|
63
|
+
configured for this assistant; the \`noan-fact-layer\` skill (installed under the skills folder) says how to read
|
|
64
|
+
and write facts. Read facts before answering questions about the business. If the workspace is empty, follow the
|
|
65
|
+
skill's first-connect procedure to seed it, and confirm the plan with the user before writing.
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Append the pointer to CLAUDE.md and AGENTS.md (creating them if absent); idempotent on the marker. */
|
|
70
|
+
export function writePointers(dir, { dryRun = false } = {}) {
|
|
71
|
+
const rows = [];
|
|
72
|
+
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
73
|
+
const p = path.join(dir, name);
|
|
74
|
+
const cur = existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
75
|
+
if (cur.includes(POINTER_MARK)) { rows.push({ file: p, action: "already present" }); continue; }
|
|
76
|
+
if (!dryRun) appendFileSync(p, (cur && !cur.endsWith("\n") ? "\n" : "") + (cur ? "\n" : "") + pointerText());
|
|
77
|
+
rows.push({ file: p, action: cur ? "appended" : "created" });
|
|
78
|
+
}
|
|
79
|
+
return rows;
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Runs, completions and cancels — the three counts that say whether the wizard works for strangers.
|
|
2
|
+
* Off until a public project token is set here; off whenever the user says so. Never a key, never a path. */
|
|
3
|
+
export const POSTHOG_TOKEN = ""; // public (write-only) PostHog project token; empty = telemetry off
|
|
4
|
+
const HOST = "https://us.i.posthog.com";
|
|
5
|
+
|
|
6
|
+
export function telemetryEnabled(args) {
|
|
7
|
+
return !!POSTHOG_TOKEN && args.telemetry !== false && process.env.NOAN_WIZARD_NO_TELEMETRY !== "1";
|
|
8
|
+
}
|
|
9
|
+
export async function capture(args, event, props = {}) {
|
|
10
|
+
if (!telemetryEnabled(args)) return false;
|
|
11
|
+
try {
|
|
12
|
+
await fetch(`${HOST}/capture/`, { method: "POST", headers: { "Content-Type": "application/json" },
|
|
13
|
+
body: JSON.stringify({ api_key: POSTHOG_TOKEN, event: `wizard_${event}`, distinct_id: "anonymous",
|
|
14
|
+
properties: { $process_person_profile: false, node: process.version, platform: process.platform, yes: !!args.yes, json: !!args.json, ...props } }) });
|
|
15
|
+
return true;
|
|
16
|
+
} catch { return false; }
|
|
17
|
+
}
|
package/src/ui.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Prompts. Every one has a non-interactive answer so an agent can run the wizard with --yes. */
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
|
|
4
|
+
export function makeUI({ yes = false, json = false } = {}) {
|
|
5
|
+
const out = json ? () => {} : (s = "") => process.stderr.write(s + "\n"); // --json keeps stdout for the report
|
|
6
|
+
const say = out;
|
|
7
|
+
const step = (n, title) => out(`\n${n}. ${title}`);
|
|
8
|
+
const ok = (s) => out(` ✓ ${s}`);
|
|
9
|
+
const warn = (s) => out(` ! ${s}`);
|
|
10
|
+
const info = (s) => out(` ${s}`);
|
|
11
|
+
|
|
12
|
+
async function ask(question, { dflt = "", secret = false, required = false } = {}) {
|
|
13
|
+
if (yes) return dflt;
|
|
14
|
+
if (!process.stdin.isTTY) return dflt;
|
|
15
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
16
|
+
const label = dflt && !secret ? `${question} [${dflt}]: ` : `${question}: `;
|
|
17
|
+
if (secret) {
|
|
18
|
+
// Mute the echo: the key never appears on screen, in a scrollback, or in a recording.
|
|
19
|
+
rl._writeToOutput = function (s) { if (s.includes(label)) rl.output.write(label); };
|
|
20
|
+
}
|
|
21
|
+
const answer = await new Promise(res => rl.question(label, res));
|
|
22
|
+
rl.close();
|
|
23
|
+
if (secret) process.stderr.write("\n");
|
|
24
|
+
const v = String(answer || "").trim() || dflt;
|
|
25
|
+
if (required && !v) return ask(question, { dflt, secret, required });
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
async function confirm(question, dflt = true) {
|
|
29
|
+
if (yes || !process.stdin.isTTY) return dflt;
|
|
30
|
+
const v = (await ask(`${question} (${dflt ? "Y/n" : "y/N"})`)).toLowerCase();
|
|
31
|
+
if (!v) return dflt;
|
|
32
|
+
return v.startsWith("y");
|
|
33
|
+
}
|
|
34
|
+
return { say, step, ok, warn, info, ask, confirm, interactive: !yes && !!process.stdin.isTTY };
|
|
35
|
+
}
|