@opennous/cli 0.11.0 → 0.12.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +39 -691
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
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": {
package/src/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Nous CLI — a thin client of the v2 Context API.
3
+ // OpenNous CLI — the setup + auth utility. NOT a data surface.
4
4
  //
5
- // You read engineered, epistemics-tagged context and write observations.
6
- // You never overwrite — Nous derives the facts.
5
+ // It signs you in (browser device flow → key in ~/.nous/config.json) and installs the
6
+ // opennous plugin into your agent (`init`). All data work happens in the agent through
7
+ // the 7 MCP primitives — the CLI never duplicates them.
7
8
 
8
9
  import { program, Command } from "commander";
9
10
  import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "fs";
@@ -19,6 +20,34 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
20
  // CI log full of escape codes is worse than no colour at all.
20
21
  const COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
21
22
  const dim = (t) => (COLOR ? `\x1b[2m${t}\x1b[0m` : t);
23
+ const bold = (t) => (COLOR ? `\x1b[1m${t}\x1b[0m` : t);
24
+ const green = (t) => (COLOR ? `\x1b[32m${t}\x1b[0m` : t);
25
+
26
+ // Fetch the acting identity for a key — the same whoami the agent gets — so sign-in can
27
+ // confirm WHO you are, not just that a key was written. Best-effort; never throws.
28
+ async function fetchWhoami(apiUrl, key) {
29
+ try {
30
+ const r = await fetch(`${apiUrl}/v2/workspace/whoami`, { headers: { Authorization: `Bearer ${key}` } });
31
+ if (!r.ok) return null;
32
+ return await r.json();
33
+ } catch { return null; }
34
+ }
35
+ // "Bennet Glinder · WindSeeker AI · admin · AE"
36
+ function identityLine(who) {
37
+ const y = who?.you || {};
38
+ return [
39
+ y.name || (y.user_id ? "member" : "shared workspace key"),
40
+ who?.workspace?.name,
41
+ y.scope,
42
+ (Array.isArray(y.roles) && y.roles.length) ? y.roles.join(", ") : null,
43
+ ].filter(Boolean).join(dim(" · "));
44
+ }
45
+ // The one thing left after signing in: install the plugin in the agent.
46
+ function printNextSteps() {
47
+ console.log(` ${dim("Next — install the plugin in Claude Code:")}`);
48
+ console.log(` /plugin marketplace add NousC/nous-plugin`);
49
+ console.log(` /plugin install opennous@opennous\n`);
50
+ }
22
51
 
23
52
  // Best-effort open the user's browser; if it fails they have the printed URL.
24
53
  //
@@ -105,305 +134,6 @@ function apiClient() {
105
134
  };
106
135
  }
107
136
 
108
- // A name that matches several entities comes back as { status: 'ambiguous' }.
109
- // Print the candidates and stop — the caller re-runs with a precise focus.
110
- function handleAmbiguous(r) {
111
- if (r && r.status === "ambiguous") {
112
- console.log("Several entities match that focus — re-run with one of:");
113
- for (const c of r.candidates ?? []) {
114
- const detail = c.detail ? ` — ${c.detail}` : "";
115
- console.log(` ${c.name || "(unnamed)"}${detail} [${c.entity_id}]`);
116
- }
117
- return true;
118
- }
119
- return false;
120
- }
121
-
122
- // --value accepts JSON ('{"description":"…"}') or a bare string.
123
- function parseValue(raw) {
124
- if (raw === undefined) return undefined;
125
- try {
126
- return JSON.parse(raw);
127
- } catch {
128
- return raw;
129
- }
130
- }
131
-
132
- // ---------------------------------------------------------------------------
133
- // nous install — drop a starter Nous integration into the current project
134
- // ---------------------------------------------------------------------------
135
- // Detects the framework, asks for the API key, writes a single module with the
136
- // helpers wired (signup tracking + Stripe webhook handler + generic track()),
137
- // and prints exactly where the user has to hook it up.
138
- //
139
- // No magic — everything is plain code the user can read and edit. The CLI's job
140
- // is to save them the 30 minutes of writing it from scratch and to make sure
141
- // the event names match what the Nous UI expects.
142
-
143
- function detectStack(root) {
144
- // Returns one of: "next", "node-esm", "node-cjs", "python", "unknown"
145
- const pkgPath = join(root, "package.json");
146
- if (existsSync(pkgPath)) {
147
- try {
148
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
149
- const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
150
- if (deps.next) return "next";
151
- if (pkg.type === "module") return "node-esm";
152
- return "node-cjs";
153
- } catch { /* fall through */ }
154
- }
155
- if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
156
- return "python";
157
- }
158
- return "unknown";
159
- }
160
-
161
- function prompt(question, fallback) {
162
- return new Promise(resolve => {
163
- const rl = createInterface({ input: process.stdin, output: process.stdout });
164
- rl.question(question + (fallback ? ` [${fallback}] ` : " "), answer => {
165
- rl.close();
166
- resolve(answer.trim() || fallback || "");
167
- });
168
- });
169
- }
170
-
171
- const TEMPLATES = {
172
- // Node ESM template — works for raw Express, Fastify, Hono, anything that
173
- // accepts async functions with the request body. The user wires the import
174
- // into whichever route handles signups + Stripe.
175
- "node-esm": `// Nous event tracking. Generated by \`nous install\`.
176
- // Edit freely — this is just a starting point.
177
-
178
- const NOUS_API_URL = process.env.NOUS_API_URL || "https://api.opennous.cloud";
179
- const NOUS_API_KEY = process.env.NOUS_API_KEY;
180
-
181
- async function postObservations(focus, observations) {
182
- if (!NOUS_API_KEY) { console.warn("[NOUS] NOUS_API_KEY not set, skipping"); return; }
183
- try {
184
- const res = await fetch(\`\${NOUS_API_URL}/v2/observations\`, {
185
- method: "POST",
186
- headers: { "Content-Type": "application/json", Authorization: \`Bearer \${NOUS_API_KEY}\` },
187
- body: JSON.stringify({ focus, observations }),
188
- });
189
- if (!res.ok) console.error("[NOUS] observation failed:", res.status, await res.text().catch(() => ""));
190
- } catch (err) {
191
- console.error("[NOUS] observation error:", err.message);
192
- }
193
- }
194
-
195
- // Call this right after a new user account is created in your auth flow.
196
- export async function trackSignup({ email, first_name = null, last_name = null, company = null, plan = "free", source }) {
197
- if (!email) return;
198
- await postObservations(email, [
199
- { kind: "event", property: "interaction.signed_up",
200
- value: { plan, source: source || "your-app", first_name, last_name, company, at: new Date().toISOString() } },
201
- { kind: "state", property: "stage", value: plan === "free" ? "Free User" : "Trial" },
202
- ...(company ? [{ kind: "state", property: "company", value: company }] : []),
203
- ]);
204
- }
205
-
206
- // Mount this on your Stripe webhook handler. Expects a Stripe Event object.
207
- export async function handleStripeEvent(event, { customerEmail }) {
208
- if (!customerEmail) return;
209
- const sub = event.data?.object;
210
- if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
211
- if (sub?.status !== "active") return;
212
- const price = sub.items?.data?.[0]?.price;
213
- await postObservations(customerEmail, [
214
- { kind: "event",
215
- property: event.type === "customer.subscription.created"
216
- ? "interaction.subscription_started"
217
- : "interaction.subscription_updated",
218
- value: {
219
- source: "stripe", plan: sub.metadata?.plan_id || price?.lookup_key || null, status: sub.status,
220
- amount: price?.unit_amount ? price.unit_amount / 100 : null, currency: sub.currency || null,
221
- stripe_subscription_id: sub.id, at: new Date().toISOString(),
222
- } },
223
- { kind: "state", property: "stage", value: "Customer" },
224
- { kind: "state", property: "plan", value: sub.metadata?.plan_id || price?.lookup_key || null },
225
- ]);
226
- return;
227
- }
228
- if (event.type === "customer.subscription.deleted") {
229
- await postObservations(customerEmail, [
230
- { kind: "event", property: "interaction.subscription_canceled",
231
- value: { source: "stripe", stripe_subscription_id: sub?.id, at: new Date().toISOString() } },
232
- { kind: "state", property: "stage", value: "Churned" },
233
- ]);
234
- }
235
- }
236
-
237
- // Escape hatch for any other event you want to land on the timeline.
238
- export async function track(focus, property, value = {}) {
239
- await postObservations(focus, [{ kind: "event", property, value: { ...value, at: new Date().toISOString() } }]);
240
- }
241
- `,
242
-
243
- python: `# Nous event tracking. Generated by \`nous install\`.
244
- # Edit freely — this is just a starting point.
245
- import os
246
- import urllib.request
247
- import json
248
- from datetime import datetime, timezone
249
-
250
- NOUS_API_URL = os.getenv("NOUS_API_URL", "https://api.opennous.cloud")
251
- NOUS_API_KEY = os.getenv("NOUS_API_KEY")
252
-
253
- def _post(focus, observations):
254
- if not NOUS_API_KEY:
255
- print("[NOUS] NOUS_API_KEY not set, skipping")
256
- return
257
- body = json.dumps({"focus": focus, "observations": observations}).encode()
258
- req = urllib.request.Request(
259
- f"{NOUS_API_URL}/v2/observations", data=body, method="POST",
260
- headers={"Content-Type": "application/json", "Authorization": f"Bearer {NOUS_API_KEY}"},
261
- )
262
- try:
263
- with urllib.request.urlopen(req, timeout=5) as r:
264
- if r.status >= 400: print("[NOUS] observation failed:", r.status)
265
- except Exception as e:
266
- print("[NOUS] observation error:", e)
267
-
268
- def track_signup(email, *, first_name=None, last_name=None, company=None, plan="free", source="your-app"):
269
- if not email: return
270
- now = datetime.now(timezone.utc).isoformat()
271
- _post(email, [
272
- {"kind":"event","property":"interaction.signed_up",
273
- "value":{"plan":plan,"source":source,"first_name":first_name,"last_name":last_name,"company":company,"at":now}},
274
- {"kind":"state","property":"stage","value":"Free User" if plan=="free" else "Trial"},
275
- *([{"kind":"state","property":"company","value":company}] if company else []),
276
- ])
277
-
278
- def handle_stripe_event(event, customer_email):
279
- if not customer_email: return
280
- sub = event.get("data", {}).get("object", {})
281
- t = event.get("type")
282
- now = datetime.now(timezone.utc).isoformat()
283
- if t in ("customer.subscription.created", "customer.subscription.updated"):
284
- if sub.get("status") != "active": return
285
- price = (sub.get("items", {}).get("data") or [{}])[0].get("price", {}) or {}
286
- _post(customer_email, [
287
- {"kind":"event",
288
- "property":"interaction.subscription_started" if t.endswith("created") else "interaction.subscription_updated",
289
- "value":{"source":"stripe","plan":sub.get("metadata",{}).get("plan_id"),"status":sub.get("status"),
290
- "amount":(price.get("unit_amount") or 0)/100 or None,"currency":sub.get("currency"),
291
- "stripe_subscription_id":sub.get("id"),"at":now}},
292
- {"kind":"state","property":"stage","value":"Customer"},
293
- ])
294
- elif t == "customer.subscription.deleted":
295
- _post(customer_email, [
296
- {"kind":"event","property":"interaction.subscription_canceled",
297
- "value":{"source":"stripe","stripe_subscription_id":sub.get("id"),"at":now}},
298
- {"kind":"state","property":"stage","value":"Churned"},
299
- ])
300
-
301
- def track(focus, property_, value=None):
302
- now = datetime.now(timezone.utc).isoformat()
303
- _post(focus, [{"kind":"event","property":property_,"value":{**(value or {}),"at":now}}])
304
- `,
305
- };
306
-
307
- const NEXT_STEPS = {
308
- "node-esm": `Next steps:
309
- 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
310
- 2. Import { trackSignup } from "./nous.js" — call it right after you persist a new user.
311
- 3. Import { handleStripeEvent } in your Stripe webhook route — call it after verifying the signature.
312
- 4. Use track(email, "interaction.<your_event>", {...}) for anything else.`,
313
- "node-cjs": `Next steps:
314
- 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
315
- 2. const { trackSignup, handleStripeEvent, track } = require("./nous.js");
316
- 3. Call trackSignup() after persisting a new user; handleStripeEvent() inside your Stripe webhook.`,
317
- next: `Next steps:
318
- 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
319
- 2. Inside your signup API route, await trackSignup({ email, ... }) after the user row is written.
320
- 3. Inside app/api/webhooks/stripe/route.ts (or pages/api/webhooks/stripe.ts), await handleStripeEvent(event, { customerEmail }) after Stripe signature verification.`,
321
- python: `Next steps:
322
- 1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
323
- 2. from nous import track_signup, handle_stripe_event, track — call track_signup() after persisting a new user.
324
- 3. Call handle_stripe_event(event, customer_email) inside your Stripe webhook view.`,
325
- };
326
-
327
- // `nous track init` — drop event tracking into the user's app. This used to be
328
- // `nous install`, which collided head-on with the front-door meaning of "install":
329
- // people typed `nous install` expecting to set Nous up and got an app-instrumentation
330
- // scaffolder instead. The front door is now `nous init`. `install` stays as a hidden
331
- // deprecated alias so nobody's script breaks, and it prints where the command went.
332
- const trackCmd = program
333
- .command("track")
334
- .description("Instrument your app to send events to Nous");
335
-
336
- function defineTrackInit(cmd) {
337
- return cmd
338
- .description("Drop Nous event tracking (signups + Stripe lifecycle) into the current project")
339
- .option("--dir <path>", "Target project directory (default: cwd)", process.cwd())
340
- .option("--lang <stack>", "Force language/stack: next | node-esm | node-cjs | python")
341
- .option("--key <key>", "API key (otherwise prompted)")
342
- .option("--yes", "Skip prompts and use defaults", false)
343
- .action(async ({ dir, lang, key, yes }) => {
344
- const root = resolve(dir);
345
- if (!existsSync(root)) {
346
- console.error(`Directory not found: ${root}`);
347
- process.exit(1);
348
- }
349
-
350
- const detected = lang || detectStack(root);
351
- const stack = (detected === "unknown") ? "node-esm" : detected;
352
- const templateKey = stack === "next" ? "node-esm" : (stack === "node-cjs" ? "node-esm" : stack);
353
- const template = TEMPLATES[templateKey];
354
- if (!template) {
355
- console.error(`No template for stack: ${stack}. Use --lang next|node-esm|node-cjs|python.`);
356
- process.exit(1);
357
- }
358
-
359
- const outFile = stack === "python" ? "nous.py" : "nous.js";
360
- const outPath = join(root, outFile);
361
-
362
- if (existsSync(outPath) && !yes) {
363
- const answer = await prompt(`${outFile} already exists. Overwrite? (y/N)`, "N");
364
- if (!answer.toLowerCase().startsWith("y")) {
365
- console.log("Aborted.");
366
- process.exit(0);
367
- }
368
- }
369
-
370
- let apiKey = key || process.env.NOUS_API_KEY || readConfig().apiKey;
371
- if (!apiKey && !yes) {
372
- apiKey = await prompt("Paste your Nous API key (or press Enter to skip and set NOUS_API_KEY later):", "");
373
- }
374
-
375
- writeFileSync(outPath, template);
376
-
377
- // Append NOUS_API_KEY to .env if not already there.
378
- const envPath = join(root, ".env");
379
- let envTouched = false;
380
- if (existsSync(envPath)) {
381
- const env = readFileSync(envPath, "utf8");
382
- if (!/^NOUS_API_KEY\s*=/m.test(env)) {
383
- appendFileSync(envPath, `\n# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey || ""}\n`);
384
- envTouched = true;
385
- }
386
- } else if (apiKey) {
387
- writeFileSync(envPath, `# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey}\n`);
388
- envTouched = true;
389
- }
390
-
391
- console.log(`\n✓ Wrote ${outFile}`);
392
- if (envTouched) console.log(`✓ Updated .env`);
393
- console.log(`\nDetected stack: ${stack}`);
394
- console.log(`\n${NEXT_STEPS[stack] || NEXT_STEPS["node-esm"]}\n`);
395
- });
396
- }
397
-
398
- // The real command: `nous track init`.
399
- defineTrackInit(trackCmd.command("init"));
400
-
401
- // Deprecated alias: `nous install`. Kept so existing scripts don't break; it does the
402
- // same thing but says where it moved, because "install" is now `nous init`.
403
- defineTrackInit(program.command("install"))
404
- .hook("preAction", () => {
405
- console.error(dim("note: `nous install` is now `nous track init`. Run `nous init` to set up Nous itself.\n"));
406
- });
407
137
 
408
138
  // ---------------------------------------------------------------------------
409
139
  // nous auth
@@ -494,390 +224,6 @@ program
494
224
  console.log("\nStill pending. If you finished in the browser, run the command again — it will pick up the active connection.");
495
225
  });
496
226
 
497
- // ---------------------------------------------------------------------------
498
- // nous context <focus> — engineered, intent-shaped context for a task
499
- // ---------------------------------------------------------------------------
500
- program
501
- .command("context <focus>")
502
- .description("Engineered context for a task about one person or company")
503
- .option(
504
- "--intent <intent>",
505
- "draft_email | follow_up | meeting_prep | call_prep | account_review",
506
- "account_review"
507
- )
508
- .option("--budget <tokens>", "Token budget for the assembled context")
509
- .option("--json", "Print the raw JSON response")
510
- .action(async (focus, { intent, budget, json }) => {
511
- const api = apiClient();
512
- const ctx = await api.post("/v2/context", {
513
- focus,
514
- intent,
515
- budget_tokens: budget ? Number(budget) : undefined,
516
- });
517
- if (handleAmbiguous(ctx)) return;
518
- if (json) {
519
- console.log(JSON.stringify(ctx, null, 2));
520
- return;
521
- }
522
- const lines = [`${ctx.entity.type} ${ctx.entity.id} · intent: ${ctx.intent}`];
523
- if (ctx.summary) lines.push(`\n${ctx.summary}`);
524
- if (ctx.claims?.length) {
525
- lines.push(`\nClaims:`);
526
- ctx.claims.forEach(c => {
527
- const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
528
- lines.push(
529
- ` ${c.property} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
530
- `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
531
- );
532
- });
533
- }
534
- if (ctx.timeline?.length) {
535
- lines.push(`\nTimeline:`);
536
- ctx.timeline.slice(0, 8).forEach(t => {
537
- const when = t.when ? new Date(t.when).toLocaleDateString() : "";
538
- const detail = t.summary ? `: ${t.summary}` : t.count ? ` ×${t.count}` : "";
539
- lines.push(` ${when} — ${t.type}${detail}`);
540
- });
541
- }
542
- if (ctx.stakeholders?.length) {
543
- lines.push(`\nStakeholders:`);
544
- ctx.stakeholders.forEach(s =>
545
- lines.push(` ${s.name || s.entity_id}${s.role ? ` — ${s.role}` : ""}`)
546
- );
547
- }
548
- lines.push(`\n~${ctx.meta?.token_estimate ?? "?"} tokens`);
549
- console.log(lines.join("\n"));
550
- });
551
-
552
- // ---------------------------------------------------------------------------
553
- // nous account <focus> — the full record: every claim + its epistemics
554
- // ---------------------------------------------------------------------------
555
- program
556
- .command("account <focus>")
557
- .description("The full account record — every claim with its epistemics + timeline")
558
- .option("--json", "Print the raw JSON response")
559
- .action(async (focus, { json }) => {
560
- const api = apiClient();
561
- const rec = await api.get(`/v2/accounts/${encodeURIComponent(focus)}`);
562
- if (handleAmbiguous(rec)) return;
563
- if (json) {
564
- console.log(JSON.stringify(rec, null, 2));
565
- return;
566
- }
567
- const lines = [`${rec.type} ${rec.entity_id}`];
568
- const claims = Object.entries(rec.claims ?? {});
569
- if (claims.length) {
570
- lines.push(`\nClaims:`);
571
- claims.forEach(([prop, c]) => {
572
- const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
573
- lines.push(
574
- ` ${prop} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
575
- `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
576
- );
577
- });
578
- }
579
- const obs = rec.recent_observations ?? [];
580
- if (obs.length) {
581
- lines.push(`\nRecent observations:`);
582
- obs.slice(0, 10).forEach(o => {
583
- const when = o.observed_at ? new Date(o.observed_at).toLocaleDateString() : "";
584
- lines.push(` ${when} — [${o.kind}] ${o.property} (${o.source})`);
585
- });
586
- }
587
- console.log(lines.join("\n"));
588
- });
589
-
590
- // ---------------------------------------------------------------------------
591
- // nous record <focus> — observe what happened; Nous derives the facts
592
- // ---------------------------------------------------------------------------
593
- program
594
- .command("record <focus>")
595
- .description("Record an observation. You observe — Nous derives the claims.")
596
- .requiredOption("--kind <kind>", "event | state")
597
- .requiredOption("--property <property>", "e.g. interaction.email_sent or job_title")
598
- .option("--value <value>", "The event detail or fact value (JSON or string)")
599
- .option("--source <source>", "Where this came from", "cli")
600
- .option("--method <method>", "How it was observed")
601
- .option("--observed-at <iso>", "When it was observed (ISO 8601)")
602
- .action(async (focus, opts) => {
603
- const api = apiClient();
604
- const observation = {
605
- kind: opts.kind,
606
- property: opts.property,
607
- value: parseValue(opts.value),
608
- source: opts.source,
609
- method: opts.method,
610
- observed_at: opts.observedAt,
611
- };
612
- const result = await api.post("/v2/observations", { focus, observations: [observation] });
613
- if (handleAmbiguous(result)) return;
614
- const recomputed = result.claims_recomputed ?? [];
615
- console.log(
616
- `✓ Recorded ${result.recorded} observation(s) for ${result.entity_id}` +
617
- (recomputed.length ? `\n Claims recomputed: ${recomputed.join(", ")}` : "")
618
- );
619
- });
620
-
621
- // ---------------------------------------------------------------------------
622
- // nous query — retrieve and summarise a corpus of activity across many people
623
- // ---------------------------------------------------------------------------
624
- program
625
- .command("query")
626
- .description("Retrieve and summarise activity across many people")
627
- .option("--kind <kind>", "event | state")
628
- .option("--property <prefix>", "Property prefix — e.g. interaction.linkedin")
629
- .option("--source <source>", "Filter by source")
630
- .option("--entity <id>", "Restrict to one entity")
631
- .option("--since <days>", "Look back this many days")
632
- .option("--limit <n>", "Max results", "20")
633
- .option("--question <text>", "A question — switches to semantic retrieval")
634
- .option("--json", "Print the raw JSON response")
635
- .action(async opts => {
636
- const api = apiClient();
637
- const scope = {
638
- kind: opts.kind,
639
- property: opts.property,
640
- source: opts.source,
641
- entity_id: opts.entity,
642
- since_days: opts.since ? Number(opts.since) : undefined,
643
- limit: opts.limit ? Number(opts.limit) : undefined,
644
- };
645
- const data = await api.post("/v2/query", { scope, question: opts.question });
646
- if (opts.json) {
647
- console.log(JSON.stringify(data, null, 2));
648
- return;
649
- }
650
- const items = data.items ?? [];
651
- if (!items.length) {
652
- console.log("No matching activity.");
653
- return;
654
- }
655
- items.forEach(it => {
656
- const when = it.when ? new Date(it.when).toLocaleDateString() : "";
657
- const who = it.entity_name || it.entity_id;
658
- const sim = it.similarity != null ? ` ${Math.round(it.similarity * 100)}%` : "";
659
- console.log(` ${when} — ${who} — ${it.type}${sim}${it.summary ? `: ${it.summary}` : ""}`);
660
- });
661
- console.log(
662
- `\n${data.returned} of ${data.matched} (${data.mode})` +
663
- (data.sampled ? " — sampled" : "")
664
- );
665
- });
666
-
667
- // ---------------------------------------------------------------------------
668
- // nous attention — what needs attention across the workspace
669
- // ---------------------------------------------------------------------------
670
- program
671
- .command("attention")
672
- .description("Accounts gone quiet, facts decayed — ranked, with suggested actions")
673
- .option("--limit <n>", "Max results", "20")
674
- .option("--json", "Print the raw JSON response")
675
- .action(async ({ limit, json }) => {
676
- const api = apiClient();
677
- const data = await api.get("/v2/attention", { limit });
678
- if (json) {
679
- console.log(JSON.stringify(data, null, 2));
680
- return;
681
- }
682
- const items = data.items ?? [];
683
- if (!items.length) {
684
- console.log("Nothing needs attention. ✓");
685
- return;
686
- }
687
- items.forEach(it => {
688
- const who = it.entity_name || it.entity_id;
689
- console.log(` [${it.kind}] ${who} — ${it.what} (${it.age_days}d)`);
690
- console.log(` → ${it.suggested_action}`);
691
- });
692
- });
693
-
694
- // ---------------------------------------------------------------------------
695
- // nous audit — is this graph actually sound?
696
- //
697
- // The command that answers the only question a technical team has before trusting
698
- // data they didn't collect themselves. It is deliberately a COMMAND and not a
699
- // dashboard: a dashboard makes a claim about our own product and asks you to
700
- // believe it; a command runs against your data, in your terminal, and returns an
701
- // exit code. One of those can fail a build.
702
- // ---------------------------------------------------------------------------
703
- program
704
- .command("audit")
705
- .description("Audit your GTM data — what's broken, what's unsure, and what to do about it")
706
- .option("--check <name>", "One check only: arriving, resolved, evidenced, current, reachable")
707
- .option("--list", "List the records behind each finding")
708
- .option("--fail-under <n>", "Exit 1 if health is below this percentage — for CI")
709
- .option("--fix", "Show the repairs that can be made safely, without making them")
710
- .option("--apply", "Actually make them (use with --fix)")
711
- .option("--json", "Print the raw JSON response")
712
- .action(async ({ check, list, failUnder, fix, apply, json }) => {
713
- const api = apiClient();
714
-
715
- // ── nous audit --fix ──
716
- //
717
- // Only the repairs that need no judgment: records sharing an email or a
718
- // LinkedIn profile are the same human by definition. Everything else the audit
719
- // finds needs a decision, a browser, or you — and a --fix that quietly did
720
- // those too would be one nobody could trust.
721
- if (fix) {
722
- const out = await api.post("/v2/audit/fix", { apply: !!apply });
723
- if (json) { console.log(JSON.stringify(out, null, 2)); return; }
724
-
725
- if (out.dry_run) {
726
- if (!out.would_merge) {
727
- console.log("\n Nothing to repair automatically.\n");
728
- return;
729
- }
730
- console.log(`\n ${out.would_merge} ${out.would_merge === 1 ? "merge" : "merges"} can be made safely:\n`);
731
- for (const m of out.merges) {
732
- console.log(` · ${m.who ?? m.keep} ${dim("— " + m.why)}`);
733
- }
734
- console.log(dim("\n Nothing changed. Re-run with --fix --apply to make them.\n"));
735
- return;
736
- }
737
-
738
- console.log(`\n Merged ${out.merged}.${out.failed ? ` ${out.failed} failed.` : ""}\n`);
739
- for (const f of out.failures ?? []) console.error(` ✗ ${f.who ?? f.keep}: ${f.error}`);
740
- if (out.failed) process.exit(1);
741
- return;
742
- }
743
-
744
- const data = await api.get("/v2/audit", check ? { check } : {});
745
-
746
- if (json) {
747
- console.log(JSON.stringify(data, null, 2));
748
- } else {
749
- const checks = check ? [data.check] : data.checks;
750
- const findings = data.findings ?? [];
751
-
752
- console.log("");
753
- for (const c of checks) {
754
- const pct = String(c.pct).padStart(4);
755
- const mark = c.pct === 100 ? "\u2713" : "\u2717";
756
- console.log(` ${pct}% ${c.label.toUpperCase().padEnd(11)} ${c.summary} ${mark}`);
757
- // What we deliberately did not count. An audit cannot itself be a black box.
758
- if (c.note) console.log(` ${dim(c.note)}`);
759
- }
760
-
761
- if (!findings.length) {
762
- console.log("\n Nothing to fix.\n");
763
- } else {
764
- console.log("");
765
- for (const f of findings) {
766
- console.log(` [${f.severity.toUpperCase()}] ${f.title}`);
767
- console.log(` ${dim(f.detail)}`);
768
- // Who can fix it, said plainly. Pretending we can re-authorise someone's
769
- // Gmail is how a tool loses trust the first time it fails to.
770
- const by = f.fixable_by === "human"
771
- ? "needs you"
772
- : f.fixable_by === "agent_with_confirmation"
773
- ? "the agent, once you confirm"
774
- : "the agent";
775
- console.log(` \u2192 ${f.fix} (${by})`);
776
-
777
- if (list && f.subjects?.length) {
778
- for (const sub of f.subjects.slice(0, 20)) {
779
- const line = sub.who
780
- ? (Array.isArray(sub.who) ? sub.who.join(" \u2194 ") : sub.who)
781
- : Object.entries(sub).map(([k, v]) => `${k}=${v}`).join(" ");
782
- console.log(` \u00b7 ${line}${sub.why ? dim(" — " + sub.why) : ""}`);
783
- }
784
- if (f.subjects.length > 20) {
785
- console.log(` \u00b7 ${dim(`and ${f.subjects.length - 20} more`)}`);
786
- }
787
- }
788
- console.log("");
789
- }
790
- if (!list) console.log(dim(" Add --list to see the records behind each finding.\n"));
791
- }
792
- }
793
-
794
- // The reason this is a command. An exit code can stop a deploy; a dashboard
795
- // cannot. You would have known about a dead connector on day one, not day 48.
796
- const health = check ? data.check.pct : data.health;
797
- if (failUnder !== undefined) {
798
- const floor = Number(failUnder);
799
- if (health < floor) {
800
- console.error(`\n FAIL: health ${health}% is below the threshold of ${floor}%\n`);
801
- process.exit(1);
802
- }
803
- }
804
- });
805
-
806
- // ---------------------------------------------------------------------------
807
- // nous verify <focus> <property> — re-check a claim before acting on it
808
- // ---------------------------------------------------------------------------
809
- program
810
- .command("verify <focus> <property>")
811
- .description("Re-check a claim before acting on it — the calibration check")
812
- .action(async (focus, property) => {
813
- const api = apiClient();
814
- const r = await api.post("/v2/verify", { focus, property });
815
- if (handleAmbiguous(r)) return;
816
- const fmt = c =>
817
- c
818
- ? `${typeof c.value === "object" ? JSON.stringify(c.value) : c.value} ` +
819
- `(${c.freshness}, conf ${Math.round((c.confidence ?? 0) * 100)}%)`
820
- : "—";
821
- console.log(`${r.property}`);
822
- console.log(` before: ${fmt(r.before)}`);
823
- console.log(` after: ${fmt(r.after)}`);
824
- if (r.note) console.log(` ${r.note}`);
825
- });
826
-
827
- // ---------------------------------------------------------------------------
828
- // nous classify — pre-flight cold-outbound dedup
829
- // ---------------------------------------------------------------------------
830
- program
831
- .command("classify")
832
- .description("Pre-flight dedup a list of emails and/or LinkedIn URLs against the workspace's engagement history")
833
- .option("--emails <list>", "Comma-separated emails")
834
- .option("--linkedin <list>", "Comma-separated LinkedIn URLs")
835
- .option("--file <path>", "Path to any text/CSV — both emails and LinkedIn URLs are auto-extracted")
836
- .option("--json", "Print the raw JSON response")
837
- .action(async ({ emails, linkedin, file, json }) => {
838
- const api = apiClient();
839
- const EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi;
840
- const LINKEDIN_RE = /https?:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9_\-%]+/gi;
841
-
842
- let emailList = [];
843
- let linkedinList = [];
844
-
845
- if (file) {
846
- const { readFileSync } = await import("fs");
847
- const text = readFileSync(file, "utf8");
848
- emailList = text.match(EMAIL_RE) || [];
849
- linkedinList = text.match(LINKEDIN_RE) || [];
850
- }
851
- if (emails) emailList = emailList.concat(emails.split(/[\s,]+/).filter(Boolean));
852
- if (linkedin) linkedinList = linkedinList.concat(linkedin.split(/[\s,]+/).filter(Boolean));
853
-
854
- emailList = [...new Set(emailList.map(e => e.toLowerCase().trim()))].filter(Boolean);
855
- linkedinList = [...new Set(linkedinList.map(u => u.trim()))].filter(Boolean);
856
-
857
- if (!emailList.length && !linkedinList.length) {
858
- console.error("Pass at least one of --emails, --linkedin, or --file");
859
- process.exit(1);
860
- }
861
-
862
- const body = {};
863
- if (emailList.length) body.emails = emailList;
864
- if (linkedinList.length) body.linkedin_urls = linkedinList;
865
-
866
- const data = await api.post("/v2/dedup", body);
867
- if (json) { console.log(JSON.stringify(data, null, 2)); return; }
868
- const s = data.summary || {};
869
- const pad = (n, w = 6) => String(n).padStart(w);
870
- console.log(`\n ${pad(s.total)} total (${emailList.length} emails, ${linkedinList.length} linkedin)`);
871
- console.log(` ${pad(s.net_new)} net_new ← safe to send / safe to buy`);
872
- console.log(` ${pad(s.engaged)} engaged skip (in an active convo)`);
873
- console.log(` ${pad(s.recent)} recent defer (contacted in last 30d)`);
874
- console.log(` ${pad(s.bounced)} bounced skip`);
875
- console.log(` ${pad(s.unsubscribed)} unsubscribed skip`);
876
- console.log(` ${pad(s.suppressed)} suppressed skip (workspace policy)\n`);
877
- });
878
-
879
- // ---------------------------------------------------------------------------
880
- // Browser sign-in (device authorization). Opens the browser, you approve, and a
881
227
  // freshly minted API key is saved to ~/.nous/config.json. No copy-paste. Shared
882
228
  // by `nous login` and `nous init`.
883
229
  // ---------------------------------------------------------------------------
@@ -927,7 +273,9 @@ async function browserLogin(url, opts = {}) {
927
273
  cfg.apiKey = j.api_key;
928
274
  cfg.apiUrl = apiUrl;
929
275
  writeConfig(cfg);
930
- console.log(`\n\n✓ Signed in. Key saved to ~/.nous/config.json`);
276
+ const who = await fetchWhoami(apiUrl, j.api_key);
277
+ console.log(`\n\n ${green("✓")} ${bold("Signed in")}${who ? ` as ${identityLine(who)}` : ""}`);
278
+ console.log(` ${dim("Key saved to ~/.nous/config.json")}\n`);
931
279
  return true;
932
280
  }
933
281
  if (j.status === "denied") { console.log("\nAuthorization was denied."); process.exit(1); }
@@ -943,7 +291,7 @@ program
943
291
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
944
292
  .action(async ({ url }) => {
945
293
  await browserLogin(url);
946
- console.log("Your Nous tools are ready — try `get_workspace_status` in your agent.");
294
+ printNextSteps();
947
295
  });
948
296
 
949
297
  program
@@ -952,8 +300,8 @@ program
952
300
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
953
301
  .action(async (token, { url }) => {
954
302
  await browserLogin(url, { startPath: "/api/cli/auth/join/start", startBody: { token } });
955
- console.log("You're in — your agent now has its own member-scoped Nous workspace.");
956
- console.log("Try `get_workspace_status` in your agent.");
303
+ console.log(` ${dim("You're in — your agent now has its own member-scoped workspace.")}\n`);
304
+ printNextSteps();
957
305
  });
958
306
 
959
307
  // ---------------------------------------------------------------------------
@@ -1185,4 +533,4 @@ program
1185
533
  });
1186
534
 
1187
535
  // ---------------------------------------------------------------------------
1188
- program.name("nous").version("0.8.0").parse();
536
+ program.name("nous").version("0.12.0").parse();