@opennous/cli 0.5.0 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +15 -6
  2. package/package.json +2 -2
  3. package/src/index.js +305 -14
package/README.md CHANGED
@@ -1,21 +1,30 @@
1
1
  # @opennous/cli
2
2
 
3
- Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API.
3
+ Nous CLI — set Nous up, connect it to your coding agent, and read the Context API from your terminal.
4
4
 
5
- ## Quick start
5
+ ## Get started
6
6
 
7
7
  ```bash
8
- # in your project root
9
- npx -y @opennous/cli@latest install
8
+ curl -fsSL https://get.opennous.cloud | sh
10
9
  ```
11
10
 
12
- `nous install` detects your stack (Next.js, Node, Python), writes a small `nous.js` (or `nous.py`) module with three helpers, and updates your `.env`:
11
+ That installs the CLI and runs `nous init`, which signs you in (creating your account if you don't have one), registers the Nous MCP server with the agent on your machine, and hands off. Then tell your agent *"set up my Nous workspace"* — it reads your project, finds your ICP (or drafts one), and syncs it.
12
+
13
+ Prefer to run it yourself:
14
+
15
+ ```bash
16
+ npx -y @opennous/cli@latest init
17
+ ```
18
+
19
+ ## Event tracking
20
+
21
+ `nous track init` detects your stack (Next.js, Node, Python), writes a small `nous.js` (or `nous.py`) module with three helpers, and updates your `.env`:
13
22
 
14
23
  - **`trackSignup({email, ...})`** — fires `interaction.signed_up` + initial `state.stage`
15
24
  - **`handleStripeEvent(event, {customerEmail})`** — fires `interaction.subscription_started / updated / canceled` and flips `state.stage` (Customer / Churned)
16
25
  - **`track(focus, property, value)`** — escape hatch for any other event
17
26
 
18
- The generated module is plain code you own. Read it, edit it, or delete it.
27
+ The generated module is plain code you own. Read it, edit it, or delete it. (This was `nous install`; that name still works but is deprecated.)
19
28
 
20
29
  ## Other commands
21
30
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.5.0",
4
- "description": "Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API",
3
+ "version": "0.7.0",
4
+ "description": "Nous CLI — set up Nous, connect it to your coding agent, and read the Context API from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "nous": "src/index.js"
package/src/index.js CHANGED
@@ -10,10 +10,16 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } fr
10
10
  import { homedir } from "os";
11
11
  import { join, resolve } from "path";
12
12
  import { createInterface } from "readline";
13
- import { spawn } from "child_process";
13
+ import { spawn, spawnSync } from "child_process";
14
14
 
15
15
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
16
16
 
17
+ // Recede, don't decorate. Secondary text in a terminal should be readable and
18
+ // quiet — and it must degrade to plain text when the output is piped, because a
19
+ // CI log full of escape codes is worse than no colour at all.
20
+ const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const dim = (t) => (COLOR ? `\x1b[2m${t}\x1b[0m` : t);
22
+
17
23
  // Best-effort open the user's browser; if it fails they have the printed URL.
18
24
  function openBrowser(url) {
19
25
  const cmd = process.platform === "darwin" ? "open"
@@ -293,8 +299,17 @@ const NEXT_STEPS = {
293
299
  3. Call handle_stripe_event(event, customer_email) inside your Stripe webhook view.`,
294
300
  };
295
301
 
296
- program
297
- .command("install")
302
+ // `nous track init` — drop event tracking into the user's app. This used to be
303
+ // `nous install`, which collided head-on with the front-door meaning of "install":
304
+ // people typed `nous install` expecting to set Nous up and got an app-instrumentation
305
+ // scaffolder instead. The front door is now `nous init`. `install` stays as a hidden
306
+ // deprecated alias so nobody's script breaks, and it prints where the command went.
307
+ const trackCmd = program
308
+ .command("track")
309
+ .description("Instrument your app to send events to Nous");
310
+
311
+ function defineTrackInit(cmd) {
312
+ return cmd
298
313
  .description("Drop Nous event tracking (signups + Stripe lifecycle) into the current project")
299
314
  .option("--dir <path>", "Target project directory (default: cwd)", process.cwd())
300
315
  .option("--lang <stack>", "Force language/stack: next | node-esm | node-cjs | python")
@@ -353,6 +368,17 @@ program
353
368
  console.log(`\nDetected stack: ${stack}`);
354
369
  console.log(`\n${NEXT_STEPS[stack] || NEXT_STEPS["node-esm"]}\n`);
355
370
  });
371
+ }
372
+
373
+ // The real command: `nous track init`.
374
+ defineTrackInit(trackCmd.command("init"));
375
+
376
+ // Deprecated alias: `nous install`. Kept so existing scripts don't break; it does the
377
+ // same thing but says where it moved, because "install" is now `nous init`.
378
+ defineTrackInit(program.command("install"))
379
+ .hook("preAction", () => {
380
+ console.error(dim("note: `nous install` is now `nous track init`. Run `nous init` to set up Nous itself.\n"));
381
+ });
356
382
 
357
383
  // ---------------------------------------------------------------------------
358
384
  // nous auth
@@ -383,9 +409,20 @@ program
383
409
  if (key) {
384
410
  console.log(`Logged in\nAPI URL: ${url}\nKey: ${key.slice(0, 8)}...`);
385
411
  } else {
386
- console.log("Not logged in. Run: nous auth login --key <your-key>");
412
+ console.log("Not logged in. Run: npx @opennous/cli login");
387
413
  }
388
414
  })
415
+ )
416
+ .addCommand(
417
+ new Command("logout")
418
+ .description("Sign out — remove the saved API key")
419
+ .action(() => {
420
+ const cfg = readConfig();
421
+ if (!cfg.apiKey) { console.log("Not signed in."); return; }
422
+ delete cfg.apiKey;
423
+ writeConfig(cfg);
424
+ console.log("✓ Signed out — removed the saved API key from ~/.nous/config.json");
425
+ })
389
426
  );
390
427
 
391
428
  // ---------------------------------------------------------------------------
@@ -585,6 +622,118 @@ program
585
622
  });
586
623
  });
587
624
 
625
+ // ---------------------------------------------------------------------------
626
+ // nous audit — is this graph actually sound?
627
+ //
628
+ // The command that answers the only question a technical team has before trusting
629
+ // data they didn't collect themselves. It is deliberately a COMMAND and not a
630
+ // dashboard: a dashboard makes a claim about our own product and asks you to
631
+ // believe it; a command runs against your data, in your terminal, and returns an
632
+ // exit code. One of those can fail a build.
633
+ // ---------------------------------------------------------------------------
634
+ program
635
+ .command("audit")
636
+ .description("Audit your GTM data — what's broken, what's unsure, and what to do about it")
637
+ .option("--check <name>", "One check only: arriving, resolved, evidenced, current, reachable")
638
+ .option("--list", "List the records behind each finding")
639
+ .option("--fail-under <n>", "Exit 1 if health is below this percentage — for CI")
640
+ .option("--fix", "Show the repairs that can be made safely, without making them")
641
+ .option("--apply", "Actually make them (use with --fix)")
642
+ .option("--json", "Print the raw JSON response")
643
+ .action(async ({ check, list, failUnder, fix, apply, json }) => {
644
+ const api = apiClient();
645
+
646
+ // ── nous audit --fix ──
647
+ //
648
+ // Only the repairs that need no judgment: records sharing an email or a
649
+ // LinkedIn profile are the same human by definition. Everything else the audit
650
+ // finds needs a decision, a browser, or you — and a --fix that quietly did
651
+ // those too would be one nobody could trust.
652
+ if (fix) {
653
+ const out = await api.post("/v2/audit/fix", { apply: !!apply });
654
+ if (json) { console.log(JSON.stringify(out, null, 2)); return; }
655
+
656
+ if (out.dry_run) {
657
+ if (!out.would_merge) {
658
+ console.log("\n Nothing to repair automatically.\n");
659
+ return;
660
+ }
661
+ console.log(`\n ${out.would_merge} ${out.would_merge === 1 ? "merge" : "merges"} can be made safely:\n`);
662
+ for (const m of out.merges) {
663
+ console.log(` · ${m.who ?? m.keep} ${dim("— " + m.why)}`);
664
+ }
665
+ console.log(dim("\n Nothing changed. Re-run with --fix --apply to make them.\n"));
666
+ return;
667
+ }
668
+
669
+ console.log(`\n Merged ${out.merged}.${out.failed ? ` ${out.failed} failed.` : ""}\n`);
670
+ for (const f of out.failures ?? []) console.error(` ✗ ${f.who ?? f.keep}: ${f.error}`);
671
+ if (out.failed) process.exit(1);
672
+ return;
673
+ }
674
+
675
+ const data = await api.get("/v2/audit", check ? { check } : {});
676
+
677
+ if (json) {
678
+ console.log(JSON.stringify(data, null, 2));
679
+ } else {
680
+ const checks = check ? [data.check] : data.checks;
681
+ const findings = data.findings ?? [];
682
+
683
+ console.log("");
684
+ for (const c of checks) {
685
+ const pct = String(c.pct).padStart(4);
686
+ const mark = c.pct === 100 ? "\u2713" : "\u2717";
687
+ console.log(` ${pct}% ${c.label.toUpperCase().padEnd(11)} ${c.summary} ${mark}`);
688
+ // What we deliberately did not count. An audit cannot itself be a black box.
689
+ if (c.note) console.log(` ${dim(c.note)}`);
690
+ }
691
+
692
+ if (!findings.length) {
693
+ console.log("\n Nothing to fix.\n");
694
+ } else {
695
+ console.log("");
696
+ for (const f of findings) {
697
+ console.log(` [${f.severity.toUpperCase()}] ${f.title}`);
698
+ console.log(` ${dim(f.detail)}`);
699
+ // Who can fix it, said plainly. Pretending we can re-authorise someone's
700
+ // Gmail is how a tool loses trust the first time it fails to.
701
+ const by = f.fixable_by === "human"
702
+ ? "needs you"
703
+ : f.fixable_by === "agent_with_confirmation"
704
+ ? "the agent, once you confirm"
705
+ : "the agent";
706
+ console.log(` \u2192 ${f.fix} (${by})`);
707
+
708
+ if (list && f.subjects?.length) {
709
+ for (const sub of f.subjects.slice(0, 20)) {
710
+ const line = sub.who
711
+ ? (Array.isArray(sub.who) ? sub.who.join(" \u2194 ") : sub.who)
712
+ : Object.entries(sub).map(([k, v]) => `${k}=${v}`).join(" ");
713
+ console.log(` \u00b7 ${line}${sub.why ? dim(" — " + sub.why) : ""}`);
714
+ }
715
+ if (f.subjects.length > 20) {
716
+ console.log(` \u00b7 ${dim(`and ${f.subjects.length - 20} more`)}`);
717
+ }
718
+ }
719
+ console.log("");
720
+ }
721
+ if (!list) console.log(dim(" Add --list to see the records behind each finding.\n"));
722
+ }
723
+ }
724
+
725
+ // The reason this is a command. An exit code can stop a deploy; a dashboard
726
+ // cannot. You would have known about a dead connector on day one, not day 48.
727
+ const health = check ? data.check.pct : data.health;
728
+ if (failUnder !== undefined) {
729
+ const floor = Number(failUnder);
730
+ if (health < floor) {
731
+ console.error(`\n FAIL: health ${health}% is below the threshold of ${floor}%\n`);
732
+ process.exit(1);
733
+ }
734
+ }
735
+ });
736
+
588
737
  // ---------------------------------------------------------------------------
589
738
  // nous verify <focus> <property> — re-check a claim before acting on it
590
739
  // ---------------------------------------------------------------------------
@@ -721,20 +870,162 @@ program
721
870
  console.log("Your Nous tools are ready — try `get_workspace_status` in your agent.");
722
871
  });
723
872
 
724
- // nous init — the one-line "get started": sign in, then connect to your agent.
725
- // This is the command the marketing site points to (`npx @opennous/cli init`).
873
+ // ---------------------------------------------------------------------------
874
+ // MCP registration — connect Nous to whatever agent is on this machine.
875
+ //
876
+ // We deliberately DO NOT use the Claude Code plugin/marketplace here. Two commands
877
+ // (`/plugin marketplace add`, `/plugin install`), Claude-Code-only, with a marketplace
878
+ // standing between a new user and the product — it's too much for a first run. The MCP
879
+ // server is the whole integration, and `claude mcp add` registers it in one line.
880
+ //
881
+ // The MCP resolves its key from ~/.nous/config.json on every call, so we never bake the
882
+ // key into any of these configs — sign-in already wrote it there.
883
+ // ---------------------------------------------------------------------------
884
+
885
+ const MCP_CMD = "npx";
886
+ const MCP_ARGS = ["-y", "@opennous/mcp"];
887
+
888
+ /** Is the `claude` CLI on PATH? Then registration is one command and needs no file. */
889
+ function hasClaudeCli() {
890
+ try {
891
+ const r = spawnSync("claude", ["--version"], { stdio: "ignore" });
892
+ return r.status === 0;
893
+ } catch { return false; }
894
+ }
895
+
896
+ /** Merge a `nous` server into a JSON MCP config file (Claude Code / Cursor shape). */
897
+ function writeJsonMcp(path, apiUrl) {
898
+ let cfg = {};
899
+ if (existsSync(path)) {
900
+ try { cfg = JSON.parse(readFileSync(path, "utf8")); } catch { cfg = {}; }
901
+ }
902
+ cfg.mcpServers = cfg.mcpServers || {};
903
+ cfg.mcpServers.nous = {
904
+ command: MCP_CMD,
905
+ args: MCP_ARGS,
906
+ // Only pin a non-default API URL (self-host). Cloud reads the key from the config file.
907
+ ...(apiUrl && apiUrl !== "https://api.opennous.cloud" ? { env: { NOUS_API_URL: apiUrl } } : {}),
908
+ };
909
+ const dir = path.slice(0, path.lastIndexOf("/"));
910
+ if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
911
+ writeFileSync(path, JSON.stringify(cfg, null, 2) + "\n");
912
+ }
913
+
914
+ /**
915
+ * Register the MCP server with the agent on this machine. Returns a short line describing
916
+ * what happened, for the caller to print.
917
+ *
918
+ * Order of preference:
919
+ * 1. `claude mcp add` if the Claude CLI is here — one command, no file to manage.
920
+ * 2. Write ./.mcp.json in the current project (Claude Code and most clients read it).
921
+ * We never fail the flow on this — a printed fallback is better than an abort.
922
+ */
923
+ function registerMcp(apiUrl) {
924
+ if (hasClaudeCli()) {
925
+ const args = ["mcp", "add", "nous", "--scope", "user"];
926
+ if (apiUrl && apiUrl !== "https://api.opennous.cloud") args.push("-e", `NOUS_API_URL=${apiUrl}`);
927
+ args.push("--", MCP_CMD, ...MCP_ARGS);
928
+ const r = spawnSync("claude", args, { stdio: "ignore" });
929
+ if (r.status === 0) return "Registered Nous with Claude Code (claude mcp add).";
930
+ // add fails if a server named nous already exists — that's a success for our purposes.
931
+ return "Nous is already registered with Claude Code.";
932
+ }
933
+ const path = join(process.cwd(), ".mcp.json");
934
+ writeJsonMcp(path, apiUrl);
935
+ return `Wrote ${path} — your agent will pick up Nous from it.`;
936
+ }
937
+
938
+ // nous init — the one-line "get started": sign in, register the MCP, hand off to the agent.
939
+ // This is the command the install script and the marketing site point to.
726
940
  program
727
941
  .command("init")
728
- .description("Get started — sign in, then connect Nous to your agent")
942
+ .description("Get started — sign in and connect Nous to your agent")
729
943
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
730
- .action(async ({ url }) => {
944
+ .option("--no-register", "Skip registering the MCP server (just sign in)")
945
+ .action(async ({ url, register }) => {
731
946
  await browserLogin(url);
732
- console.log("\nNow add Nous to Claude Code:\n");
733
- console.log(" /plugin marketplace add NousC/nous");
734
- console.log(" /plugin install nous@nous-plugins\n");
735
- console.log('Then tell your agent: "set me up" — it onboards your workspace and builds your playbook.');
736
- console.log("\nDocs: https://docs.opennous.cloud/mcp/introduction");
947
+
948
+ const apiUrl = url || readConfig().apiUrl || "https://api.opennous.cloud";
949
+ if (register !== false) {
950
+ try {
951
+ console.log(`\n${registerMcp(apiUrl)}`);
952
+ } catch (e) {
953
+ // Never let registration take down sign-in — print the manual line instead.
954
+ console.log("\nCouldn't register the MCP automatically. Add it with:");
955
+ console.log(` claude mcp add nous -- ${MCP_CMD} ${MCP_ARGS.join(" ")}`);
956
+ }
957
+ }
958
+
959
+ console.log('\nYou\'re set. Tell your agent: "set up my Nous workspace" — it reads your');
960
+ console.log("project, finds your ICP (or drafts one), and syncs it. That's onboarding.\n");
961
+ console.log(dim("Docs: https://docs.opennous.cloud/mcp/introduction"));
962
+ });
963
+
964
+ // nous pull — write the Vault into ./context/*.md and hand authorship to the repo.
965
+ //
966
+ // The bridge for the Google road. A user who set up in the browser has a Vault whose author
967
+ // is Nous (source 'nous', no file). The day they open a repo and connect an agent, that
968
+ // arrangement is backwards: they now live in files, and the Vault should mirror the files,
969
+ // not the other way round. `pull` flips it — it writes each context doc to disk and re-syncs
970
+ // it WITH its path, which sets source='claude_code'. From then on their repo is the author,
971
+ // exactly as if they'd started on the agent road.
972
+ //
973
+ // It only touches slots that exist, and by default won't clobber a file that's already there
974
+ // (the repo, once it's the author, wins) unless you pass --force.
975
+ const PULL_KINDS = { icp: "icp.md", positioning: "positioning.md", voice: "voice.md", outreach: "messaging.md" };
976
+
977
+ program
978
+ .command("pull")
979
+ .description("Write your Nous context docs into ./context and let your repo own them")
980
+ .option("--dir <path>", "Where to write the context/ folder (default: cwd)", process.cwd())
981
+ .option("--force", "Overwrite files that already exist", false)
982
+ .action(async ({ dir, force }) => {
983
+ const api = apiClient();
984
+ const { playbooks } = await api.get("/v2/playbooks");
985
+ const docs = (playbooks || []).filter((p) => PULL_KINDS[p.kind] && (p.body_md || "").trim());
986
+ if (!docs.length) {
987
+ console.log("Nothing to pull — your Vault has no context docs yet. Set one up in the app first.");
988
+ return;
989
+ }
990
+
991
+ const ctxDir = join(resolve(dir), "context");
992
+ if (!existsSync(ctxDir)) mkdirSync(ctxDir, { recursive: true });
993
+
994
+ let written = 0, skipped = 0, synced = 0;
995
+ for (const p of docs) {
996
+ const rel = join("context", PULL_KINDS[p.kind]);
997
+ const abs = join(resolve(dir), rel);
998
+ if (existsSync(abs) && !force) {
999
+ console.log(dim(` skip ${rel} (exists — pass --force to overwrite)`));
1000
+ skipped++;
1001
+ continue;
1002
+ }
1003
+ writeFileSync(abs, p.body_md.trim() + "\n");
1004
+ written++;
1005
+ console.log(` write ${rel}`);
1006
+ // Re-sync WITH the path so the graph now knows the repo is the author.
1007
+ try {
1008
+ await api.post(`/v2/playbooks/${p.kind}`, { body_md: p.body_md, file_path: rel });
1009
+ synced++;
1010
+ } catch { /* the file is written; a failed authorship flip is non-fatal, re-run pull */ }
1011
+ }
1012
+
1013
+ console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
1014
+ if (synced < written) console.log(dim(" (some didn't sync back — re-run `nous pull` to finish)"));
1015
+ console.log(dim("Edit these files and your agent syncs them with sync_playbook. Nous mirrors them."));
1016
+ });
1017
+
1018
+ // nous logout — top-level alias of `auth logout`. Removes the saved API key.
1019
+ program
1020
+ .command("logout")
1021
+ .description("Sign out — remove the saved API key")
1022
+ .action(() => {
1023
+ const cfg = readConfig();
1024
+ if (!cfg.apiKey) { console.log("Not signed in."); return; }
1025
+ delete cfg.apiKey;
1026
+ writeConfig(cfg);
1027
+ console.log("✓ Signed out — removed the saved API key from ~/.nous/config.json");
737
1028
  });
738
1029
 
739
1030
  // ---------------------------------------------------------------------------
740
- program.name("nous").version("0.5.0").parse();
1031
+ program.name("nous").version("0.7.0").parse();