@opennous/cli 0.10.1 → 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 +126 -977
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.10.1",
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
@@ -451,389 +181,49 @@ program
451
181
  );
452
182
 
453
183
  // ---------------------------------------------------------------------------
454
- // nous context <focus> — engineered, intent-shaped context for a task
455
- // ---------------------------------------------------------------------------
456
- program
457
- .command("context <focus>")
458
- .description("Engineered context for a task about one person or company")
459
- .option(
460
- "--intent <intent>",
461
- "draft_email | follow_up | meeting_prep | call_prep | account_review",
462
- "account_review"
463
- )
464
- .option("--budget <tokens>", "Token budget for the assembled context")
465
- .option("--json", "Print the raw JSON response")
466
- .action(async (focus, { intent, budget, json }) => {
467
- const api = apiClient();
468
- const ctx = await api.post("/v2/context", {
469
- focus,
470
- intent,
471
- budget_tokens: budget ? Number(budget) : undefined,
472
- });
473
- if (handleAmbiguous(ctx)) return;
474
- if (json) {
475
- console.log(JSON.stringify(ctx, null, 2));
476
- return;
477
- }
478
- const lines = [`${ctx.entity.type} ${ctx.entity.id} · intent: ${ctx.intent}`];
479
- if (ctx.summary) lines.push(`\n${ctx.summary}`);
480
- if (ctx.claims?.length) {
481
- lines.push(`\nClaims:`);
482
- ctx.claims.forEach(c => {
483
- const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
484
- lines.push(
485
- ` ${c.property} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
486
- `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
487
- );
488
- });
489
- }
490
- if (ctx.timeline?.length) {
491
- lines.push(`\nTimeline:`);
492
- ctx.timeline.slice(0, 8).forEach(t => {
493
- const when = t.when ? new Date(t.when).toLocaleDateString() : "";
494
- const detail = t.summary ? `: ${t.summary}` : t.count ? ` ×${t.count}` : "";
495
- lines.push(` ${when} — ${t.type}${detail}`);
496
- });
497
- }
498
- if (ctx.stakeholders?.length) {
499
- lines.push(`\nStakeholders:`);
500
- ctx.stakeholders.forEach(s =>
501
- lines.push(` ${s.name || s.entity_id}${s.role ? ` — ${s.role}` : ""}`)
502
- );
503
- }
504
- lines.push(`\n~${ctx.meta?.token_estimate ?? "?"} tokens`);
505
- console.log(lines.join("\n"));
506
- });
507
-
508
- // ---------------------------------------------------------------------------
509
- // nous account <focus> — the full record: every claim + its epistemics
510
- // ---------------------------------------------------------------------------
511
- program
512
- .command("account <focus>")
513
- .description("The full account record — every claim with its epistemics + timeline")
514
- .option("--json", "Print the raw JSON response")
515
- .action(async (focus, { json }) => {
516
- const api = apiClient();
517
- const rec = await api.get(`/v2/accounts/${encodeURIComponent(focus)}`);
518
- if (handleAmbiguous(rec)) return;
519
- if (json) {
520
- console.log(JSON.stringify(rec, null, 2));
521
- return;
522
- }
523
- const lines = [`${rec.type} ${rec.entity_id}`];
524
- const claims = Object.entries(rec.claims ?? {});
525
- if (claims.length) {
526
- lines.push(`\nClaims:`);
527
- claims.forEach(([prop, c]) => {
528
- const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
529
- lines.push(
530
- ` ${prop} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
531
- `conf ${Math.round((c.confidence ?? 0) * 100)}%)`
532
- );
533
- });
534
- }
535
- const obs = rec.recent_observations ?? [];
536
- if (obs.length) {
537
- lines.push(`\nRecent observations:`);
538
- obs.slice(0, 10).forEach(o => {
539
- const when = o.observed_at ? new Date(o.observed_at).toLocaleDateString() : "";
540
- lines.push(` ${when} — [${o.kind}] ${o.property} (${o.source})`);
541
- });
542
- }
543
- console.log(lines.join("\n"));
544
- });
545
-
546
- // ---------------------------------------------------------------------------
547
- // nous record <focus> — observe what happened; Nous derives the facts
548
- // ---------------------------------------------------------------------------
549
- program
550
- .command("record <focus>")
551
- .description("Record an observation. You observe — Nous derives the claims.")
552
- .requiredOption("--kind <kind>", "event | state")
553
- .requiredOption("--property <property>", "e.g. interaction.email_sent or job_title")
554
- .option("--value <value>", "The event detail or fact value (JSON or string)")
555
- .option("--source <source>", "Where this came from", "cli")
556
- .option("--method <method>", "How it was observed")
557
- .option("--observed-at <iso>", "When it was observed (ISO 8601)")
558
- .action(async (focus, opts) => {
559
- const api = apiClient();
560
- const observation = {
561
- kind: opts.kind,
562
- property: opts.property,
563
- value: parseValue(opts.value),
564
- source: opts.source,
565
- method: opts.method,
566
- observed_at: opts.observedAt,
567
- };
568
- const result = await api.post("/v2/observations", { focus, observations: [observation] });
569
- if (handleAmbiguous(result)) return;
570
- const recomputed = result.claims_recomputed ?? [];
571
- console.log(
572
- `✓ Recorded ${result.recorded} observation(s) for ${result.entity_id}` +
573
- (recomputed.length ? `\n Claims recomputed: ${recomputed.join(", ")}` : "")
574
- );
575
- });
576
-
577
- // ---------------------------------------------------------------------------
578
- // nous query — retrieve and summarise a corpus of activity across many people
579
- // ---------------------------------------------------------------------------
580
- program
581
- .command("query")
582
- .description("Retrieve and summarise activity across many people")
583
- .option("--kind <kind>", "event | state")
584
- .option("--property <prefix>", "Property prefix — e.g. interaction.linkedin")
585
- .option("--source <source>", "Filter by source")
586
- .option("--entity <id>", "Restrict to one entity")
587
- .option("--since <days>", "Look back this many days")
588
- .option("--limit <n>", "Max results", "20")
589
- .option("--question <text>", "A question — switches to semantic retrieval")
590
- .option("--json", "Print the raw JSON response")
591
- .action(async opts => {
592
- const api = apiClient();
593
- const scope = {
594
- kind: opts.kind,
595
- property: opts.property,
596
- source: opts.source,
597
- entity_id: opts.entity,
598
- since_days: opts.since ? Number(opts.since) : undefined,
599
- limit: opts.limit ? Number(opts.limit) : undefined,
600
- };
601
- const data = await api.post("/v2/query", { scope, question: opts.question });
602
- if (opts.json) {
603
- console.log(JSON.stringify(data, null, 2));
604
- return;
605
- }
606
- const items = data.items ?? [];
607
- if (!items.length) {
608
- console.log("No matching activity.");
609
- return;
610
- }
611
- items.forEach(it => {
612
- const when = it.when ? new Date(it.when).toLocaleDateString() : "";
613
- const who = it.entity_name || it.entity_id;
614
- const sim = it.similarity != null ? ` ${Math.round(it.similarity * 100)}%` : "";
615
- console.log(` ${when} — ${who} — ${it.type}${sim}${it.summary ? `: ${it.summary}` : ""}`);
616
- });
617
- console.log(
618
- `\n${data.returned} of ${data.matched} (${data.mode})` +
619
- (data.sampled ? " — sampled" : "")
620
- );
621
- });
622
-
623
- // ---------------------------------------------------------------------------
624
- // nous attention — what needs attention across the workspace
625
- // ---------------------------------------------------------------------------
626
- program
627
- .command("attention")
628
- .description("Accounts gone quiet, facts decayed — ranked, with suggested actions")
629
- .option("--limit <n>", "Max results", "20")
630
- .option("--json", "Print the raw JSON response")
631
- .action(async ({ limit, json }) => {
632
- const api = apiClient();
633
- const data = await api.get("/v2/attention", { limit });
634
- if (json) {
635
- console.log(JSON.stringify(data, null, 2));
636
- return;
637
- }
638
- const items = data.items ?? [];
639
- if (!items.length) {
640
- console.log("Nothing needs attention. ✓");
641
- return;
642
- }
643
- items.forEach(it => {
644
- const who = it.entity_name || it.entity_id;
645
- console.log(` [${it.kind}] ${who} — ${it.what} (${it.age_days}d)`);
646
- console.log(` → ${it.suggested_action}`);
647
- });
648
- });
649
-
650
- // ---------------------------------------------------------------------------
651
- // nous audit — is this graph actually sound?
652
- //
653
- // The command that answers the only question a technical team has before trusting
654
- // data they didn't collect themselves. It is deliberately a COMMAND and not a
655
- // dashboard: a dashboard makes a claim about our own product and asks you to
656
- // believe it; a command runs against your data, in your terminal, and returns an
657
- // exit code. One of those can fail a build.
184
+ // nous connect <provider> — connect Gmail / Google Calendar via Composio
658
185
  // ---------------------------------------------------------------------------
186
+ // You're already signed in (the saved key identifies you), so there's no device-code
187
+ // dance — we start a Composio consent, open it in your browser, and poll until the
188
+ // account goes ACTIVE. Composio holds the OAuth token; Nous never sees it.
189
+ const CONNECT_ALIASES = {
190
+ gmail: "gmail_oauth",
191
+ gmail_oauth: "gmail_oauth",
192
+ calendar: "google_calendar",
193
+ gcal: "google_calendar",
194
+ "google-calendar": "google_calendar",
195
+ google_calendar: "google_calendar",
196
+ };
659
197
  program
660
- .command("audit")
661
- .description("Audit your GTM data — what's broken, what's unsure, and what to do about it")
662
- .option("--check <name>", "One check only: arriving, resolved, evidenced, current, reachable")
663
- .option("--list", "List the records behind each finding")
664
- .option("--fail-under <n>", "Exit 1 if health is below this percentage — for CI")
665
- .option("--fix", "Show the repairs that can be made safely, without making them")
666
- .option("--apply", "Actually make them (use with --fix)")
667
- .option("--json", "Print the raw JSON response")
668
- .action(async ({ check, list, failUnder, fix, apply, json }) => {
669
- const api = apiClient();
670
-
671
- // ── nous audit --fix ──
672
- //
673
- // Only the repairs that need no judgment: records sharing an email or a
674
- // LinkedIn profile are the same human by definition. Everything else the audit
675
- // finds needs a decision, a browser, or you — and a --fix that quietly did
676
- // those too would be one nobody could trust.
677
- if (fix) {
678
- const out = await api.post("/v2/audit/fix", { apply: !!apply });
679
- if (json) { console.log(JSON.stringify(out, null, 2)); return; }
680
-
681
- if (out.dry_run) {
682
- if (!out.would_merge) {
683
- console.log("\n Nothing to repair automatically.\n");
684
- return;
685
- }
686
- console.log(`\n ${out.would_merge} ${out.would_merge === 1 ? "merge" : "merges"} can be made safely:\n`);
687
- for (const m of out.merges) {
688
- console.log(` · ${m.who ?? m.keep} ${dim("— " + m.why)}`);
689
- }
690
- console.log(dim("\n Nothing changed. Re-run with --fix --apply to make them.\n"));
691
- return;
692
- }
693
-
694
- console.log(`\n Merged ${out.merged}.${out.failed ? ` ${out.failed} failed.` : ""}\n`);
695
- for (const f of out.failures ?? []) console.error(` ✗ ${f.who ?? f.keep}: ${f.error}`);
696
- if (out.failed) process.exit(1);
697
- return;
698
- }
699
-
700
- const data = await api.get("/v2/audit", check ? { check } : {});
701
-
702
- if (json) {
703
- console.log(JSON.stringify(data, null, 2));
704
- } else {
705
- const checks = check ? [data.check] : data.checks;
706
- const findings = data.findings ?? [];
707
-
708
- console.log("");
709
- for (const c of checks) {
710
- const pct = String(c.pct).padStart(4);
711
- const mark = c.pct === 100 ? "\u2713" : "\u2717";
712
- console.log(` ${pct}% ${c.label.toUpperCase().padEnd(11)} ${c.summary} ${mark}`);
713
- // What we deliberately did not count. An audit cannot itself be a black box.
714
- if (c.note) console.log(` ${dim(c.note)}`);
715
- }
716
-
717
- if (!findings.length) {
718
- console.log("\n Nothing to fix.\n");
719
- } else {
720
- console.log("");
721
- for (const f of findings) {
722
- console.log(` [${f.severity.toUpperCase()}] ${f.title}`);
723
- console.log(` ${dim(f.detail)}`);
724
- // Who can fix it, said plainly. Pretending we can re-authorise someone's
725
- // Gmail is how a tool loses trust the first time it fails to.
726
- const by = f.fixable_by === "human"
727
- ? "needs you"
728
- : f.fixable_by === "agent_with_confirmation"
729
- ? "the agent, once you confirm"
730
- : "the agent";
731
- console.log(` \u2192 ${f.fix} (${by})`);
732
-
733
- if (list && f.subjects?.length) {
734
- for (const sub of f.subjects.slice(0, 20)) {
735
- const line = sub.who
736
- ? (Array.isArray(sub.who) ? sub.who.join(" \u2194 ") : sub.who)
737
- : Object.entries(sub).map(([k, v]) => `${k}=${v}`).join(" ");
738
- console.log(` \u00b7 ${line}${sub.why ? dim(" — " + sub.why) : ""}`);
739
- }
740
- if (f.subjects.length > 20) {
741
- console.log(` \u00b7 ${dim(`and ${f.subjects.length - 20} more`)}`);
742
- }
743
- }
744
- console.log("");
745
- }
746
- if (!list) console.log(dim(" Add --list to see the records behind each finding.\n"));
747
- }
748
- }
749
-
750
- // The reason this is a command. An exit code can stop a deploy; a dashboard
751
- // cannot. You would have known about a dead connector on day one, not day 48.
752
- const health = check ? data.check.pct : data.health;
753
- if (failUnder !== undefined) {
754
- const floor = Number(failUnder);
755
- if (health < floor) {
756
- console.error(`\n FAIL: health ${health}% is below the threshold of ${floor}%\n`);
757
- process.exit(1);
758
- }
198
+ .command("connect <provider>")
199
+ .description("Connect a source: gmail | calendar (via Composio)")
200
+ .action(async (provider) => {
201
+ const name = CONNECT_ALIASES[String(provider).toLowerCase()];
202
+ if (!name) {
203
+ console.error(`Unknown source "${provider}". Try: nous connect gmail | nous connect calendar`);
204
+ process.exit(1);
759
205
  }
760
- });
761
-
762
- // ---------------------------------------------------------------------------
763
- // nous verify <focus> <property> — re-check a claim before acting on it
764
- // ---------------------------------------------------------------------------
765
- program
766
- .command("verify <focus> <property>")
767
- .description("Re-check a claim before acting on it — the calibration check")
768
- .action(async (focus, property) => {
769
206
  const api = apiClient();
770
- const r = await api.post("/v2/verify", { focus, property });
771
- if (handleAmbiguous(r)) return;
772
- const fmt = c =>
773
- c
774
- ? `${typeof c.value === "object" ? JSON.stringify(c.value) : c.value} ` +
775
- `(${c.freshness}, conf ${Math.round((c.confidence ?? 0) * 100)}%)`
776
- : "—";
777
- console.log(`${r.property}`);
778
- console.log(` before: ${fmt(r.before)}`);
779
- console.log(` after: ${fmt(r.after)}`);
780
- if (r.note) console.log(` ${r.note}`);
781
- });
782
-
783
- // ---------------------------------------------------------------------------
784
- // nous classify — pre-flight cold-outbound dedup
785
- // ---------------------------------------------------------------------------
786
- program
787
- .command("classify")
788
- .description("Pre-flight dedup a list of emails and/or LinkedIn URLs against the workspace's engagement history")
789
- .option("--emails <list>", "Comma-separated emails")
790
- .option("--linkedin <list>", "Comma-separated LinkedIn URLs")
791
- .option("--file <path>", "Path to any text/CSV — both emails and LinkedIn URLs are auto-extracted")
792
- .option("--json", "Print the raw JSON response")
793
- .action(async ({ emails, linkedin, file, json }) => {
794
- const api = apiClient();
795
- const EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi;
796
- const LINKEDIN_RE = /https?:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9_\-%]+/gi;
797
-
798
- let emailList = [];
799
- let linkedinList = [];
800
-
801
- if (file) {
802
- const { readFileSync } = await import("fs");
803
- const text = readFileSync(file, "utf8");
804
- emailList = text.match(EMAIL_RE) || [];
805
- linkedinList = text.match(LINKEDIN_RE) || [];
806
- }
807
- if (emails) emailList = emailList.concat(emails.split(/[\s,]+/).filter(Boolean));
808
- if (linkedin) linkedinList = linkedinList.concat(linkedin.split(/[\s,]+/).filter(Boolean));
809
-
810
- emailList = [...new Set(emailList.map(e => e.toLowerCase().trim()))].filter(Boolean);
811
- linkedinList = [...new Set(linkedinList.map(u => u.trim()))].filter(Boolean);
812
-
813
- if (!emailList.length && !linkedinList.length) {
814
- console.error("Pass at least one of --emails, --linkedin, or --file");
207
+ const start = await api.post("/api/connect/composio/start", { provider: name });
208
+ if (!start?.redirect_url) {
209
+ console.error("Couldn't start the connection. Ask an admin to set up the Integration Engine (Composio) in Settings first.");
815
210
  process.exit(1);
816
211
  }
212
+ console.log(`\nOpening your browser to authorize ${name === "gmail_oauth" ? "Gmail" : "Google Calendar"}…`);
213
+ console.log(dim(start.redirect_url) + "\n");
214
+ openBrowser(start.redirect_url);
817
215
 
818
- const body = {};
819
- if (emailList.length) body.emails = emailList;
820
- if (linkedinList.length) body.linkedin_urls = linkedinList;
821
-
822
- const data = await api.post("/v2/dedup", body);
823
- if (json) { console.log(JSON.stringify(data, null, 2)); return; }
824
- const s = data.summary || {};
825
- const pad = (n, w = 6) => String(n).padStart(w);
826
- console.log(`\n ${pad(s.total)} total (${emailList.length} emails, ${linkedinList.length} linkedin)`);
827
- console.log(` ${pad(s.net_new)} net_new ← safe to send / safe to buy`);
828
- console.log(` ${pad(s.engaged)} engaged skip (in an active convo)`);
829
- console.log(` ${pad(s.recent)} recent defer (contacted in last 30d)`);
830
- console.log(` ${pad(s.bounced)} bounced skip`);
831
- console.log(` ${pad(s.unsubscribed)} unsubscribed skip`);
832
- console.log(` ${pad(s.suppressed)} suppressed skip (workspace policy)\n`);
216
+ process.stdout.write("Waiting for you to finish in the browser");
217
+ for (let i = 0; i < 150; i++) { // ~5 min at 2s
218
+ await sleep(2000);
219
+ process.stdout.write(".");
220
+ const s = await api.get("/api/connect/composio/status", { provider: name, connection_request_id: start.connection_request_id });
221
+ if (s?.status === "connected") { console.log("\n✓ Connected. Your history is backfilling now and new events will flow in automatically."); return; }
222
+ if (s?.status === "failed") { console.log("\n✗ Google didn't approve the connection. Run the command again to retry."); process.exit(1); }
223
+ }
224
+ console.log("\nStill pending. If you finished in the browser, run the command again — it will pick up the active connection.");
833
225
  });
834
226
 
835
- // ---------------------------------------------------------------------------
836
- // Browser sign-in (device authorization). Opens the browser, you approve, and a
837
227
  // freshly minted API key is saved to ~/.nous/config.json. No copy-paste. Shared
838
228
  // by `nous login` and `nous init`.
839
229
  // ---------------------------------------------------------------------------
@@ -883,7 +273,9 @@ async function browserLogin(url, opts = {}) {
883
273
  cfg.apiKey = j.api_key;
884
274
  cfg.apiUrl = apiUrl;
885
275
  writeConfig(cfg);
886
- 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`);
887
279
  return true;
888
280
  }
889
281
  if (j.status === "denied") { console.log("\nAuthorization was denied."); process.exit(1); }
@@ -899,7 +291,7 @@ program
899
291
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
900
292
  .action(async ({ url }) => {
901
293
  await browserLogin(url);
902
- console.log("Your Nous tools are ready — try `get_workspace_status` in your agent.");
294
+ printNextSteps();
903
295
  });
904
296
 
905
297
  program
@@ -908,18 +300,20 @@ program
908
300
  .option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
909
301
  .action(async (token, { url }) => {
910
302
  await browserLogin(url, { startPath: "/api/cli/auth/join/start", startBody: { token } });
911
- console.log("You're in — your agent now has its own member-scoped Nous workspace.");
912
- 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();
913
305
  });
914
306
 
915
307
  // ---------------------------------------------------------------------------
916
- // MCP registration — connect Nous to whatever agent is on this machine.
308
+ // Connect OpenNous to whatever agent is on this machine — host-aware.
917
309
  //
918
- // There is deliberately no Claude Code plugin/marketplace. A plugin would be Claude-Code-only
919
- // and put a marketplace between a new user and the product — too much for a first run, and it
920
- // wouldn't work for Cursor, Codex, or any other host. The MCP server is the whole integration,
921
- // so registration is one cross-platform step: `claude mcp add` if the Claude CLI is here, else
922
- // a `.mcp.json` any MCP host reads.
310
+ // The MCP server (7 primitives) is the universal capability: any MCP host runs `npx @opennous/mcp`.
311
+ // But the SKILLS + routing are the product surface, and those are delivered per host:
312
+ // • Claude Code → the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, one install).
313
+ // • Any other host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP via a `.mcp.json`;
314
+ // each host's skills pack ships as we light it up.
315
+ // So `init` installs the plugin on Claude Code, and writes the MCP config everywhere else — one
316
+ // signup, the right shell per host, the same 7-primitive surface (NOUS_SURFACE=plugin) throughout.
923
317
  //
924
318
  // The MCP resolves its key from ~/.nous/config.json on every call, so we never bake the
925
319
  // key into any of these configs — sign-in already wrote it there.
@@ -943,11 +337,15 @@ function writeJsonMcp(path, apiUrl) {
943
337
  try { cfg = JSON.parse(readFileSync(path, "utf8")); } catch { cfg = {}; }
944
338
  }
945
339
  cfg.mcpServers = cfg.mcpServers || {};
946
- cfg.mcpServers.nous = {
340
+ cfg.mcpServers.opennous = {
947
341
  command: MCP_CMD,
948
342
  args: MCP_ARGS,
949
- // Only pin a non-default API URL (self-host). Cloud reads the key from the config file.
950
- ...(apiUrl && apiUrl !== "https://api.opennous.cloud" ? { env: { NOUS_API_URL: apiUrl } } : {}),
343
+ // NOUS_SURFACE=plugin trims to the 7 revenue primitives (never the full internal toolset).
344
+ // Only pin a non-default API URL (self-host); cloud reads the key from ~/.nous/config.json.
345
+ env: {
346
+ NOUS_SURFACE: "plugin",
347
+ ...(apiUrl && apiUrl !== "https://api.opennous.cloud" ? { NOUS_API_URL: apiUrl } : {}),
348
+ },
951
349
  };
952
350
  const dir = dirname(path);
953
351
  if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -963,19 +361,26 @@ function writeJsonMcp(path, apiUrl) {
963
361
  * 2. Write ./.mcp.json in the current project (Claude Code and most clients read it).
964
362
  * We never fail the flow on this — a printed fallback is better than an abort.
965
363
  */
966
- function registerMcp(apiUrl) {
967
- if (hasClaudeCli()) {
968
- const args = ["mcp", "add", "nous", "--scope", "user"];
969
- if (apiUrl && apiUrl !== "https://api.opennous.cloud") args.push("-e", `NOUS_API_URL=${apiUrl}`);
970
- args.push("--", MCP_CMD, ...MCP_ARGS);
971
- const r = spawnSync("claude", args, { stdio: "ignore" });
972
- if (r.status === 0) return "Registered Nous with Claude Code (claude mcp add).";
973
- // add fails if a server named nous already exists — that's a success for our purposes.
974
- return "Nous is already registered with Claude Code.";
975
- }
364
+ const PLUGIN_MARKETPLACE = "NousC/nous-plugin";
365
+ const PLUGIN_ID = "opennous@opennous";
366
+
367
+ // Claude Code → install the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, in one).
368
+ // We try the plugin CLI; if this Claude build has no `plugin` subcommand it returns null and the
369
+ // caller prints the two slash-commands instead. We deliberately do NOT fall back to a bare
370
+ // `claude mcp add` — a standalone MCP server would duplicate the plugin's and drop the skills.
371
+ function installClaudePlugin() {
372
+ const add = spawnSync("claude", ["plugin", "marketplace", "add", PLUGIN_MARKETPLACE], { stdio: "ignore" });
373
+ if (add.status !== 0 && add.status !== null) { /* non-zero: try install anyway, marketplace may already be added */ }
374
+ const inst = spawnSync("claude", ["plugin", "install", PLUGIN_ID], { stdio: "ignore" });
375
+ return inst.status === 0;
376
+ }
377
+
378
+ // Any other MCP host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP server via a .mcp.json
379
+ // any MCP host reads. Skills for those hosts arrive as their own packs (roadmap).
380
+ function registerMcpServer(apiUrl) {
976
381
  const path = join(process.cwd(), ".mcp.json");
977
382
  writeJsonMcp(path, apiUrl);
978
- return `Wrote ${path} — your agent will pick up Nous from it.`;
383
+ return `Wrote ${path} — your agent will pick up OpenNous (7 primitives) from it.`;
979
384
  }
980
385
 
981
386
  // Install the ICP auto-sync hook into the project's Claude Code config. Once in place,
@@ -1026,12 +431,25 @@ program
1026
431
 
1027
432
  const apiUrl = url || readConfig().apiUrl || "https://api.opennous.cloud";
1028
433
  if (register !== false) {
1029
- try {
1030
- console.log(`\n${registerMcp(apiUrl)}`);
1031
- } catch (e) {
1032
- // Never let registration take down sign-in — print the manual line instead.
1033
- console.log("\nCouldn't register the MCP automatically. Add it with:");
1034
- console.log(` claude mcp add nous -- ${MCP_CMD} ${MCP_ARGS.join(" ")}`);
434
+ // Claude Code → the plugin (skills + hooks + trimmed MCP). Any other host → the MCP config.
435
+ if (hasClaudeCli()) {
436
+ let installed = false;
437
+ try { installed = installClaudePlugin(); } catch { /* fall through to instructions */ }
438
+ if (installed) {
439
+ console.log("\n✓ Installed the OpenNous plugin into Claude Code — skills, routing, and the 7 primitives.");
440
+ console.log(" If Claude Code is already running, apply it with /reload-plugins.");
441
+ } else {
442
+ console.log("\nDetected Claude Code. Install the OpenNous plugin (skills + hooks + tools):");
443
+ console.log(" /plugin marketplace add NousC/nous-plugin");
444
+ console.log(" /plugin install opennous@opennous");
445
+ }
446
+ } else {
447
+ try {
448
+ console.log(`\n${registerMcpServer(apiUrl)}`);
449
+ } catch {
450
+ console.log("\nCouldn't write .mcp.json automatically. Add OpenNous to your host's MCP config:");
451
+ console.log(` command: ${MCP_CMD} args: ${MCP_ARGS.join(" ")} env: NOUS_SURFACE=plugin`);
452
+ }
1035
453
  }
1036
454
 
1037
455
  // Auto-sync the ICP: a Claude Code hook so editing your ICP file reminds the
@@ -1059,7 +477,7 @@ program
1059
477
  //
1060
478
  // It only touches slots that exist, and by default won't clobber a file that's already there
1061
479
  // (the repo, once it's the author, wins) unless you pass --force.
1062
- const PULL_KINDS = { icp: "icp.md", positioning: "positioning.md", voice: "voice.md", outreach: "messaging.md" };
480
+ const PULL_KINDS = { icp: "icp.md" };
1063
481
 
1064
482
  program
1065
483
  .command("pull")
@@ -1115,273 +533,4 @@ program
1115
533
  });
1116
534
 
1117
535
  // ---------------------------------------------------------------------------
1118
- // nous link <dir> — pair a local folder with your personal vault and keep them in
1119
- // sync, both ways. Your laptop and Nous become two views of the same files: edit in
1120
- // Obsidian/Finder and it flows up; the agent files a brief (once you approve it) and
1121
- // it flows down. This is the "local desk" — the same vault the GitHub sync fills,
1122
- // only the transport is your filesystem instead of a repo. `nous link` is idempotent;
1123
- // run it once and leave it watching, or pass --once for a single reconcile.
1124
- // ---------------------------------------------------------------------------
1125
-
1126
- // Default vault structure (2026-08-21). New folders scaffold with these 7. The legacy
1127
- // folders are still walked/watched/searched so existing files keep syncing.
1128
- const DEFAULT_FOLDERS = ["inbox", "projects", "decisions", "accounts", "people", "resources", "archive"];
1129
- const VAULT_FOLDERS = [...DEFAULT_FOLDERS, "thoughts", "briefs", "content", "company"];
1130
-
1131
- // A resilient API caller for the daemon — unlike apiClient(), a failed poll or push
1132
- // must NOT exit the process; the loop should keep running and retry on the next tick.
1133
- function softClient() {
1134
- const cfg = readConfig();
1135
- const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
1136
- const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
1137
- if (!apiKey) { console.error("No API key. Run: nous login"); process.exit(1); }
1138
- return async (method, path, { body, query } = {}) => {
1139
- const url = new URL(path, apiUrl);
1140
- if (query) for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
1141
- try {
1142
- const res = await fetch(url.toString(), {
1143
- method,
1144
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
1145
- body: body ? JSON.stringify(body) : undefined,
1146
- });
1147
- if (!res.ok) return { ok: false, status: res.status };
1148
- return { ok: true, data: res.status === 204 ? {} : await res.json() };
1149
- } catch (e) {
1150
- return { ok: false, error: e.message };
1151
- }
1152
- };
1153
- }
1154
-
1155
- // Every .md under the foundation folders of `root`, as repo-style relative paths.
1156
- function walkVaultMd(root) {
1157
- const out = [];
1158
- const rec = (dir, prefix) => {
1159
- let entries = [];
1160
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
1161
- for (const e of entries) {
1162
- if (e.name.startsWith(".")) continue;
1163
- const abs = join(dir, e.name);
1164
- const rel = prefix ? `${prefix}/${e.name}` : e.name;
1165
- if (e.isDirectory()) rec(abs, rel);
1166
- else if (e.name.endsWith(".md")) out.push({ rel, abs });
1167
- }
1168
- };
1169
- for (const f of VAULT_FOLDERS) rec(join(root, f), f);
1170
- return out;
1171
- }
1172
-
1173
- const safeRead = (abs) => { try { return readFileSync(abs, "utf8"); } catch { return null; } };
1174
- const saveCursor = (cursor) => { const c = readConfig(); c.link = { ...(c.link || {}), cursor }; writeConfig(c); };
1175
-
1176
- // Write incoming files to disk where they differ. Records what we wrote so the watcher
1177
- // can ignore the change event it will fire, and so we never push a file straight back.
1178
- async function pullOnce(call, root, recentlyWritten, quiet) {
1179
- const since = readConfig().link?.cursor || new Date(0).toISOString();
1180
- const r = await call("GET", "/v2/personal/local/pull", { query: { since } });
1181
- if (!r.ok) return;
1182
- let wrote = 0;
1183
- for (const f of r.data.files || []) {
1184
- const abs = join(root, f.path.split("/").join(sep));
1185
- if (safeRead(abs) === f.content) continue; // already identical — no write, no echo
1186
- mkdirSync(dirname(abs), { recursive: true });
1187
- writeFileSync(abs, f.content);
1188
- recentlyWritten.set(f.path, Date.now());
1189
- if (!quiet) process.stdout.write(` ↓ ${f.path}\n`);
1190
- wrote++;
1191
- }
1192
- if (r.data.cursor) saveCursor(r.data.cursor);
1193
- return wrote;
1194
- }
1195
-
1196
- program
1197
- .command("link <dir>")
1198
- .description("Pair a local folder with your personal vault and keep them in sync")
1199
- .option("--once", "Reconcile once and exit instead of watching", false)
1200
- .option("--interval <sec>", "How often to pull incoming changes while watching", "15")
1201
- .action(async (dir, { once, interval }) => {
1202
- const call = softClient();
1203
- const root = resolve(dir);
1204
- // Scaffold the folder so the desk has the same shape as the vault.
1205
- if (!existsSync(root)) mkdirSync(root, { recursive: true });
1206
- for (const f of DEFAULT_FOLDERS) { const d = join(root, f); if (!existsSync(d)) mkdirSync(d, { recursive: true }); }
1207
-
1208
- const label = hostname() || "my machine";
1209
- const linked = await call("POST", "/v2/personal/local/link", { body: { label } });
1210
- if (!linked.ok) { console.error("Couldn't link this folder — check your API key with `nous auth status`."); process.exit(1); }
1211
-
1212
- const cfg = readConfig();
1213
- cfg.link = { dir: root, cursor: new Date(0).toISOString() };
1214
- writeConfig(cfg);
1215
- console.log(`Linked ${root} ↔ your Nous vault (${label}).\n`);
1216
-
1217
- const recentlyWritten = new Map();
1218
- const wasJustWritten = (rel) => { const t = recentlyWritten.get(rel); return t && Date.now() - t < 4000; };
1219
-
1220
- // ── Initial reconcile: pull server state, then push anything local that differs. ──
1221
- const serverState = await call("GET", "/v2/personal/local/pull", { query: { since: new Date(0).toISOString() } });
1222
- const serverMap = new Map((serverState.ok ? serverState.data.files : []).map((f) => [f.path, f.content]));
1223
- // Write server files that are missing/different on disk.
1224
- for (const [path, content] of serverMap) {
1225
- const abs = join(root, path.split("/").join(sep));
1226
- if (safeRead(abs) === content) continue;
1227
- mkdirSync(dirname(abs), { recursive: true });
1228
- writeFileSync(abs, content);
1229
- recentlyWritten.set(path, Date.now());
1230
- console.log(` ↓ ${path}`);
1231
- }
1232
- // Push local files the server doesn't have or that differ.
1233
- const toPush = walkVaultMd(root).filter((f) => serverMap.get(f.rel) !== safeRead(f.abs));
1234
- if (toPush.length) {
1235
- const files = toPush.map((f) => ({ path: f.rel, content: safeRead(f.abs) ?? "" }));
1236
- await call("POST", "/v2/personal/local/push", { body: { files } });
1237
- for (const f of toPush) console.log(` ↑ ${f.rel}`);
1238
- }
1239
- saveCursor(new Date().toISOString());
1240
- console.log(`\n✓ In sync — ${serverMap.size} in Nous, ${walkVaultMd(root).length} on disk.`);
1241
-
1242
- if (once) return;
1243
-
1244
- // ── Watch: push local edits up; poll: pull remote edits down. ──
1245
- let chokidar;
1246
- try { chokidar = (await import("chokidar")).default; }
1247
- catch { console.error("\nWatching needs chokidar. Re-run with --once, or `npm i -g @opennous/cli` to get it."); process.exit(1); }
1248
-
1249
- const pushPath = async (abs, deleted) => {
1250
- const rel = relative(root, abs).split(sep).join("/");
1251
- if (!rel.endsWith(".md")) return;
1252
- if (wasJustWritten(rel)) return; // this change is one we just pulled — don't echo it back
1253
- const content = deleted ? "" : (safeRead(abs) ?? "");
1254
- const r = await call("POST", "/v2/personal/local/push", { body: { files: [{ path: rel, content, deleted: !!deleted }] } });
1255
- if (r.ok) { saveCursor(new Date().toISOString()); process.stdout.write(deleted ? ` ↑ removed ${rel}\n` : ` ↑ ${rel}\n`); }
1256
- };
1257
-
1258
- const watcher = chokidar.watch(VAULT_FOLDERS.map((f) => join(root, f)), {
1259
- ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
1260
- });
1261
- watcher.on("add", (p) => pushPath(p, false)).on("change", (p) => pushPath(p, false)).on("unlink", (p) => pushPath(p, true));
1262
-
1263
- const secs = Math.max(5, Number(interval) || 15);
1264
- setInterval(() => { pullOnce(call, root, recentlyWritten).catch(() => {}); }, secs * 1000);
1265
-
1266
- console.log(`\nWatching for changes. Pulling every ${secs}s. Press Ctrl-C to stop.`);
1267
- process.stdin.resume(); // keep the process alive
1268
- });
1269
-
1270
- // nous unlink — detach the local folder. Files stay in the vault; only the pairing ends.
1271
- program
1272
- .command("unlink")
1273
- .description("Stop syncing your linked local folder (files stay in Nous)")
1274
- .action(async () => {
1275
- const call = softClient();
1276
- await call("POST", "/v2/personal/local/unlink", {});
1277
- const cfg = readConfig();
1278
- delete cfg.link;
1279
- writeConfig(cfg);
1280
- console.log("✓ Unlinked. Your files are still in Nous; this folder just stopped syncing.");
1281
- });
1282
-
1283
- // ---------------------------------------------------------------------------
1284
- // nous vault — read + search the LINKED local vault ON-DEVICE. When an agent runs
1285
- // where the vault is linked (`nous link`), it should read these files directly
1286
- // instead of calling the search_my_vault MCP tool: fresher (no sync lag), fuller
1287
- // (whole files, not snippets), faster (no network), and private (the reads never
1288
- // leave the machine). This is the native personal-vault retrieval path.
1289
- // ---------------------------------------------------------------------------
1290
- function linkedVaultRoot() {
1291
- const dir = readConfig().link?.dir;
1292
- if (!dir || !existsSync(dir)) return null;
1293
- return dir;
1294
- }
1295
- function requireVaultRoot() {
1296
- const root = linkedVaultRoot();
1297
- if (!root) { console.error("No local vault linked. Run `nous link <dir>` first."); process.exit(1); }
1298
- return root;
1299
- }
1300
- // Resolve a 'folder/[subfolder/]name' argument to an absolute .md path under the vault.
1301
- function resolveVaultRel(root, p) {
1302
- const rel = String(p).replace(/^\/+/, "").endsWith(".md") ? String(p).replace(/^\/+/, "") : `${String(p).replace(/^\/+/, "")}.md`;
1303
- return { rel, abs: join(root, rel.split("/").join(sep)) };
1304
- }
1305
-
1306
- const vaultCmd = program
1307
- .command("vault")
1308
- .description("Read and search your linked local vault — on-device, no server round-trip");
1309
-
1310
- vaultCmd
1311
- .command("path")
1312
- .description("Print the linked vault folder (point your own Read/Grep at it)")
1313
- .action(() => { console.log(requireVaultRoot()); });
1314
-
1315
- vaultCmd
1316
- .command("list [folder]")
1317
- .description("List vault files, optionally within one folder")
1318
- .option("--json", "Output JSON", false)
1319
- .action((folder, { json }) => {
1320
- const root = requireVaultRoot();
1321
- let files = walkVaultMd(root).map((f) => f.rel);
1322
- if (folder) files = files.filter((r) => r.split("/")[0] === folder);
1323
- files.sort();
1324
- if (json) { console.log(JSON.stringify(files)); return; }
1325
- if (!files.length) { console.log(folder ? `No files in ${folder}.` : "Vault is empty."); return; }
1326
- for (const r of files) console.log(` ${r}`);
1327
- });
1328
-
1329
- vaultCmd
1330
- .command("read <path>")
1331
- .description("Print a vault file's full content (e.g. decisions/pricing)")
1332
- .action((p) => {
1333
- const root = requireVaultRoot();
1334
- const { rel, abs } = resolveVaultRel(root, p);
1335
- const content = safeRead(abs);
1336
- if (content == null) { console.error(`Not found: ${rel}`); process.exit(1); }
1337
- process.stdout.write(content.endsWith("\n") ? content : content + "\n");
1338
- });
1339
-
1340
- vaultCmd
1341
- .command("search <query...>")
1342
- .description("Search the linked vault locally (on-device — never leaves your machine)")
1343
- .option("--folder <folder>", "Restrict to one vault folder")
1344
- .option("--limit <n>", "Max results", "6")
1345
- .option("--json", "Output JSON", false)
1346
- .action((queryParts, { folder, limit, json }) => {
1347
- const root = requireVaultRoot();
1348
- const query = queryParts.join(" ");
1349
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1350
- if (!terms.length) { console.error("Pass a query, e.g. `nous vault search pricing objections`."); process.exit(1); }
1351
-
1352
- let files = walkVaultMd(root);
1353
- if (folder) files = files.filter((f) => f.rel.split("/")[0] === folder);
1354
-
1355
- const hits = [];
1356
- for (const f of files) {
1357
- const content = safeRead(f.abs) ?? "";
1358
- const hay = content.toLowerCase();
1359
- const name = f.rel.toLowerCase();
1360
- let score = 0;
1361
- for (const t of terms) {
1362
- if (name.includes(t)) score += 5; // a filename hit is a strong signal
1363
- score += Math.min(hay.split(t).length - 1, 5); // content occurrences, capped
1364
- }
1365
- if (score <= 0) continue;
1366
- // Snippet around the first matching term.
1367
- let snippet = "";
1368
- for (const t of terms) {
1369
- const i = hay.indexOf(t);
1370
- if (i >= 0) { snippet = content.slice(Math.max(0, i - 60), i + 140).replace(/\s+/g, " ").trim(); break; }
1371
- }
1372
- hits.push({ path: f.rel, score, snippet });
1373
- }
1374
- hits.sort((a, b) => b.score - a.score);
1375
- const top = hits.slice(0, Math.min(Math.max(1, Number(limit) || 6), 30));
1376
-
1377
- if (json) { console.log(JSON.stringify(top)); return; }
1378
- if (!top.length) { console.log(`No local vault notes matched "${query}".`); return; }
1379
- console.log(`Local vault notes matching "${query}":\n`);
1380
- for (const h of top) {
1381
- console.log(` ${h.path}`);
1382
- if (h.snippet) console.log(` ${h.snippet}`);
1383
- }
1384
- });
1385
-
1386
- // ---------------------------------------------------------------------------
1387
- program.name("nous").version("0.8.0").parse();
536
+ program.name("nous").version("0.12.0").parse();