@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.
- package/package.json +1 -1
- package/src/index.js +126 -977
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
//
|
|
3
|
+
// OpenNous CLI — the setup + auth utility. NOT a data surface.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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("
|
|
661
|
-
.description("
|
|
662
|
-
.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
771
|
-
if (
|
|
772
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
console.log(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
912
|
-
|
|
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
|
-
//
|
|
308
|
+
// Connect OpenNous to whatever agent is on this machine — host-aware.
|
|
917
309
|
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
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.
|
|
340
|
+
cfg.mcpServers.opennous = {
|
|
947
341
|
command: MCP_CMD,
|
|
948
342
|
args: MCP_ARGS,
|
|
949
|
-
//
|
|
950
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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"
|
|
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
|
-
|
|
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();
|