@opennous/cli 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +1 -1
  3. package/src/index.js +97 -293
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # @opennous/cli changelog
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Changed — new default vault folder structure
6
+
7
+ `nous link` now scaffolds the vault with **inbox · projects · decisions · accounts ·
8
+ people · resources · archive**. `accounts/` and `people/` are where Nous files the raw
9
+ of each touch (email, meeting) by resolved account/person. Backward-compatible: the
10
+ legacy folders (thoughts/briefs/content/company) are still walked, watched, and searched,
11
+ so any files already in them keep syncing — only the folders created on a fresh link
12
+ changed.
13
+
3
14
  ## 0.10.0
4
15
 
5
16
  ### Added — `nous vault`: on-device retrieval over your linked local vault
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.10.0",
3
+ "version": "0.11.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
@@ -450,6 +450,50 @@ program
450
450
  })
451
451
  );
452
452
 
453
+ // ---------------------------------------------------------------------------
454
+ // nous connect <provider> — connect Gmail / Google Calendar via Composio
455
+ // ---------------------------------------------------------------------------
456
+ // You're already signed in (the saved key identifies you), so there's no device-code
457
+ // dance — we start a Composio consent, open it in your browser, and poll until the
458
+ // account goes ACTIVE. Composio holds the OAuth token; Nous never sees it.
459
+ const CONNECT_ALIASES = {
460
+ gmail: "gmail_oauth",
461
+ gmail_oauth: "gmail_oauth",
462
+ calendar: "google_calendar",
463
+ gcal: "google_calendar",
464
+ "google-calendar": "google_calendar",
465
+ google_calendar: "google_calendar",
466
+ };
467
+ program
468
+ .command("connect <provider>")
469
+ .description("Connect a source: gmail | calendar (via Composio)")
470
+ .action(async (provider) => {
471
+ const name = CONNECT_ALIASES[String(provider).toLowerCase()];
472
+ if (!name) {
473
+ console.error(`Unknown source "${provider}". Try: nous connect gmail | nous connect calendar`);
474
+ process.exit(1);
475
+ }
476
+ const api = apiClient();
477
+ const start = await api.post("/api/connect/composio/start", { provider: name });
478
+ if (!start?.redirect_url) {
479
+ console.error("Couldn't start the connection. Ask an admin to set up the Integration Engine (Composio) in Settings first.");
480
+ process.exit(1);
481
+ }
482
+ console.log(`\nOpening your browser to authorize ${name === "gmail_oauth" ? "Gmail" : "Google Calendar"}…`);
483
+ console.log(dim(start.redirect_url) + "\n");
484
+ openBrowser(start.redirect_url);
485
+
486
+ process.stdout.write("Waiting for you to finish in the browser");
487
+ for (let i = 0; i < 150; i++) { // ~5 min at 2s
488
+ await sleep(2000);
489
+ process.stdout.write(".");
490
+ const s = await api.get("/api/connect/composio/status", { provider: name, connection_request_id: start.connection_request_id });
491
+ if (s?.status === "connected") { console.log("\n✓ Connected. Your history is backfilling now and new events will flow in automatically."); return; }
492
+ if (s?.status === "failed") { console.log("\n✗ Google didn't approve the connection. Run the command again to retry."); process.exit(1); }
493
+ }
494
+ console.log("\nStill pending. If you finished in the browser, run the command again — it will pick up the active connection.");
495
+ });
496
+
453
497
  // ---------------------------------------------------------------------------
454
498
  // nous context <focus> — engineered, intent-shaped context for a task
455
499
  // ---------------------------------------------------------------------------
@@ -913,13 +957,15 @@ program
913
957
  });
914
958
 
915
959
  // ---------------------------------------------------------------------------
916
- // MCP registration — connect Nous to whatever agent is on this machine.
960
+ // Connect OpenNous to whatever agent is on this machine — host-aware.
917
961
  //
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.
962
+ // The MCP server (7 primitives) is the universal capability: any MCP host runs `npx @opennous/mcp`.
963
+ // But the SKILLS + routing are the product surface, and those are delivered per host:
964
+ // • Claude Code → the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, one install).
965
+ // • Any other host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP via a `.mcp.json`;
966
+ // each host's skills pack ships as we light it up.
967
+ // So `init` installs the plugin on Claude Code, and writes the MCP config everywhere else — one
968
+ // signup, the right shell per host, the same 7-primitive surface (NOUS_SURFACE=plugin) throughout.
923
969
  //
924
970
  // The MCP resolves its key from ~/.nous/config.json on every call, so we never bake the
925
971
  // key into any of these configs — sign-in already wrote it there.
@@ -943,11 +989,15 @@ function writeJsonMcp(path, apiUrl) {
943
989
  try { cfg = JSON.parse(readFileSync(path, "utf8")); } catch { cfg = {}; }
944
990
  }
945
991
  cfg.mcpServers = cfg.mcpServers || {};
946
- cfg.mcpServers.nous = {
992
+ cfg.mcpServers.opennous = {
947
993
  command: MCP_CMD,
948
994
  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 } } : {}),
995
+ // NOUS_SURFACE=plugin trims to the 7 revenue primitives (never the full internal toolset).
996
+ // Only pin a non-default API URL (self-host); cloud reads the key from ~/.nous/config.json.
997
+ env: {
998
+ NOUS_SURFACE: "plugin",
999
+ ...(apiUrl && apiUrl !== "https://api.opennous.cloud" ? { NOUS_API_URL: apiUrl } : {}),
1000
+ },
951
1001
  };
952
1002
  const dir = dirname(path);
953
1003
  if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
@@ -963,19 +1013,26 @@ function writeJsonMcp(path, apiUrl) {
963
1013
  * 2. Write ./.mcp.json in the current project (Claude Code and most clients read it).
964
1014
  * We never fail the flow on this — a printed fallback is better than an abort.
965
1015
  */
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
- }
1016
+ const PLUGIN_MARKETPLACE = "NousC/nous-plugin";
1017
+ const PLUGIN_ID = "opennous@opennous";
1018
+
1019
+ // Claude Code → install the OpenNous PLUGIN (skills + routing hooks + the trimmed MCP, in one).
1020
+ // We try the plugin CLI; if this Claude build has no `plugin` subcommand it returns null and the
1021
+ // caller prints the two slash-commands instead. We deliberately do NOT fall back to a bare
1022
+ // `claude mcp add` — a standalone MCP server would duplicate the plugin's and drop the skills.
1023
+ function installClaudePlugin() {
1024
+ const add = spawnSync("claude", ["plugin", "marketplace", "add", PLUGIN_MARKETPLACE], { stdio: "ignore" });
1025
+ if (add.status !== 0 && add.status !== null) { /* non-zero: try install anyway, marketplace may already be added */ }
1026
+ const inst = spawnSync("claude", ["plugin", "install", PLUGIN_ID], { stdio: "ignore" });
1027
+ return inst.status === 0;
1028
+ }
1029
+
1030
+ // Any other MCP host (Cursor, Codex, Claude Desktop, …) → the trimmed MCP server via a .mcp.json
1031
+ // any MCP host reads. Skills for those hosts arrive as their own packs (roadmap).
1032
+ function registerMcpServer(apiUrl) {
976
1033
  const path = join(process.cwd(), ".mcp.json");
977
1034
  writeJsonMcp(path, apiUrl);
978
- return `Wrote ${path} — your agent will pick up Nous from it.`;
1035
+ return `Wrote ${path} — your agent will pick up OpenNous (7 primitives) from it.`;
979
1036
  }
980
1037
 
981
1038
  // Install the ICP auto-sync hook into the project's Claude Code config. Once in place,
@@ -1026,12 +1083,25 @@ program
1026
1083
 
1027
1084
  const apiUrl = url || readConfig().apiUrl || "https://api.opennous.cloud";
1028
1085
  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(" ")}`);
1086
+ // Claude Code → the plugin (skills + hooks + trimmed MCP). Any other host → the MCP config.
1087
+ if (hasClaudeCli()) {
1088
+ let installed = false;
1089
+ try { installed = installClaudePlugin(); } catch { /* fall through to instructions */ }
1090
+ if (installed) {
1091
+ console.log("\n✓ Installed the OpenNous plugin into Claude Code — skills, routing, and the 7 primitives.");
1092
+ console.log(" If Claude Code is already running, apply it with /reload-plugins.");
1093
+ } else {
1094
+ console.log("\nDetected Claude Code. Install the OpenNous plugin (skills + hooks + tools):");
1095
+ console.log(" /plugin marketplace add NousC/nous-plugin");
1096
+ console.log(" /plugin install opennous@opennous");
1097
+ }
1098
+ } else {
1099
+ try {
1100
+ console.log(`\n${registerMcpServer(apiUrl)}`);
1101
+ } catch {
1102
+ console.log("\nCouldn't write .mcp.json automatically. Add OpenNous to your host's MCP config:");
1103
+ console.log(` command: ${MCP_CMD} args: ${MCP_ARGS.join(" ")} env: NOUS_SURFACE=plugin`);
1104
+ }
1035
1105
  }
1036
1106
 
1037
1107
  // Auto-sync the ICP: a Claude Code hook so editing your ICP file reminds the
@@ -1059,7 +1129,7 @@ program
1059
1129
  //
1060
1130
  // It only touches slots that exist, and by default won't clobber a file that's already there
1061
1131
  // (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" };
1132
+ const PULL_KINDS = { icp: "icp.md" };
1063
1133
 
1064
1134
  program
1065
1135
  .command("pull")
@@ -1114,271 +1184,5 @@ program
1114
1184
  console.log("✓ Signed out — removed the saved API key from ~/.nous/config.json");
1115
1185
  });
1116
1186
 
1117
- // ---------------------------------------------------------------------------
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
- const VAULT_FOLDERS = ["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"];
1127
-
1128
- // A resilient API caller for the daemon — unlike apiClient(), a failed poll or push
1129
- // must NOT exit the process; the loop should keep running and retry on the next tick.
1130
- function softClient() {
1131
- const cfg = readConfig();
1132
- const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
1133
- const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
1134
- if (!apiKey) { console.error("No API key. Run: nous login"); process.exit(1); }
1135
- return async (method, path, { body, query } = {}) => {
1136
- const url = new URL(path, apiUrl);
1137
- if (query) for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
1138
- try {
1139
- const res = await fetch(url.toString(), {
1140
- method,
1141
- headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
1142
- body: body ? JSON.stringify(body) : undefined,
1143
- });
1144
- if (!res.ok) return { ok: false, status: res.status };
1145
- return { ok: true, data: res.status === 204 ? {} : await res.json() };
1146
- } catch (e) {
1147
- return { ok: false, error: e.message };
1148
- }
1149
- };
1150
- }
1151
-
1152
- // Every .md under the foundation folders of `root`, as repo-style relative paths.
1153
- function walkVaultMd(root) {
1154
- const out = [];
1155
- const rec = (dir, prefix) => {
1156
- let entries = [];
1157
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
1158
- for (const e of entries) {
1159
- if (e.name.startsWith(".")) continue;
1160
- const abs = join(dir, e.name);
1161
- const rel = prefix ? `${prefix}/${e.name}` : e.name;
1162
- if (e.isDirectory()) rec(abs, rel);
1163
- else if (e.name.endsWith(".md")) out.push({ rel, abs });
1164
- }
1165
- };
1166
- for (const f of VAULT_FOLDERS) rec(join(root, f), f);
1167
- return out;
1168
- }
1169
-
1170
- const safeRead = (abs) => { try { return readFileSync(abs, "utf8"); } catch { return null; } };
1171
- const saveCursor = (cursor) => { const c = readConfig(); c.link = { ...(c.link || {}), cursor }; writeConfig(c); };
1172
-
1173
- // Write incoming files to disk where they differ. Records what we wrote so the watcher
1174
- // can ignore the change event it will fire, and so we never push a file straight back.
1175
- async function pullOnce(call, root, recentlyWritten, quiet) {
1176
- const since = readConfig().link?.cursor || new Date(0).toISOString();
1177
- const r = await call("GET", "/v2/personal/local/pull", { query: { since } });
1178
- if (!r.ok) return;
1179
- let wrote = 0;
1180
- for (const f of r.data.files || []) {
1181
- const abs = join(root, f.path.split("/").join(sep));
1182
- if (safeRead(abs) === f.content) continue; // already identical — no write, no echo
1183
- mkdirSync(dirname(abs), { recursive: true });
1184
- writeFileSync(abs, f.content);
1185
- recentlyWritten.set(f.path, Date.now());
1186
- if (!quiet) process.stdout.write(` ↓ ${f.path}\n`);
1187
- wrote++;
1188
- }
1189
- if (r.data.cursor) saveCursor(r.data.cursor);
1190
- return wrote;
1191
- }
1192
-
1193
- program
1194
- .command("link <dir>")
1195
- .description("Pair a local folder with your personal vault and keep them in sync")
1196
- .option("--once", "Reconcile once and exit instead of watching", false)
1197
- .option("--interval <sec>", "How often to pull incoming changes while watching", "15")
1198
- .action(async (dir, { once, interval }) => {
1199
- const call = softClient();
1200
- const root = resolve(dir);
1201
- // Scaffold the folder so the desk has the same shape as the vault.
1202
- if (!existsSync(root)) mkdirSync(root, { recursive: true });
1203
- for (const f of VAULT_FOLDERS) { const d = join(root, f); if (!existsSync(d)) mkdirSync(d, { recursive: true }); }
1204
-
1205
- const label = hostname() || "my machine";
1206
- const linked = await call("POST", "/v2/personal/local/link", { body: { label } });
1207
- if (!linked.ok) { console.error("Couldn't link this folder — check your API key with `nous auth status`."); process.exit(1); }
1208
-
1209
- const cfg = readConfig();
1210
- cfg.link = { dir: root, cursor: new Date(0).toISOString() };
1211
- writeConfig(cfg);
1212
- console.log(`Linked ${root} ↔ your Nous vault (${label}).\n`);
1213
-
1214
- const recentlyWritten = new Map();
1215
- const wasJustWritten = (rel) => { const t = recentlyWritten.get(rel); return t && Date.now() - t < 4000; };
1216
-
1217
- // ── Initial reconcile: pull server state, then push anything local that differs. ──
1218
- const serverState = await call("GET", "/v2/personal/local/pull", { query: { since: new Date(0).toISOString() } });
1219
- const serverMap = new Map((serverState.ok ? serverState.data.files : []).map((f) => [f.path, f.content]));
1220
- // Write server files that are missing/different on disk.
1221
- for (const [path, content] of serverMap) {
1222
- const abs = join(root, path.split("/").join(sep));
1223
- if (safeRead(abs) === content) continue;
1224
- mkdirSync(dirname(abs), { recursive: true });
1225
- writeFileSync(abs, content);
1226
- recentlyWritten.set(path, Date.now());
1227
- console.log(` ↓ ${path}`);
1228
- }
1229
- // Push local files the server doesn't have or that differ.
1230
- const toPush = walkVaultMd(root).filter((f) => serverMap.get(f.rel) !== safeRead(f.abs));
1231
- if (toPush.length) {
1232
- const files = toPush.map((f) => ({ path: f.rel, content: safeRead(f.abs) ?? "" }));
1233
- await call("POST", "/v2/personal/local/push", { body: { files } });
1234
- for (const f of toPush) console.log(` ↑ ${f.rel}`);
1235
- }
1236
- saveCursor(new Date().toISOString());
1237
- console.log(`\n✓ In sync — ${serverMap.size} in Nous, ${walkVaultMd(root).length} on disk.`);
1238
-
1239
- if (once) return;
1240
-
1241
- // ── Watch: push local edits up; poll: pull remote edits down. ──
1242
- let chokidar;
1243
- try { chokidar = (await import("chokidar")).default; }
1244
- catch { console.error("\nWatching needs chokidar. Re-run with --once, or `npm i -g @opennous/cli` to get it."); process.exit(1); }
1245
-
1246
- const pushPath = async (abs, deleted) => {
1247
- const rel = relative(root, abs).split(sep).join("/");
1248
- if (!rel.endsWith(".md")) return;
1249
- if (wasJustWritten(rel)) return; // this change is one we just pulled — don't echo it back
1250
- const content = deleted ? "" : (safeRead(abs) ?? "");
1251
- const r = await call("POST", "/v2/personal/local/push", { body: { files: [{ path: rel, content, deleted: !!deleted }] } });
1252
- if (r.ok) { saveCursor(new Date().toISOString()); process.stdout.write(deleted ? ` ↑ removed ${rel}\n` : ` ↑ ${rel}\n`); }
1253
- };
1254
-
1255
- const watcher = chokidar.watch(VAULT_FOLDERS.map((f) => join(root, f)), {
1256
- ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
1257
- });
1258
- watcher.on("add", (p) => pushPath(p, false)).on("change", (p) => pushPath(p, false)).on("unlink", (p) => pushPath(p, true));
1259
-
1260
- const secs = Math.max(5, Number(interval) || 15);
1261
- setInterval(() => { pullOnce(call, root, recentlyWritten).catch(() => {}); }, secs * 1000);
1262
-
1263
- console.log(`\nWatching for changes. Pulling every ${secs}s. Press Ctrl-C to stop.`);
1264
- process.stdin.resume(); // keep the process alive
1265
- });
1266
-
1267
- // nous unlink — detach the local folder. Files stay in the vault; only the pairing ends.
1268
- program
1269
- .command("unlink")
1270
- .description("Stop syncing your linked local folder (files stay in Nous)")
1271
- .action(async () => {
1272
- const call = softClient();
1273
- await call("POST", "/v2/personal/local/unlink", {});
1274
- const cfg = readConfig();
1275
- delete cfg.link;
1276
- writeConfig(cfg);
1277
- console.log("✓ Unlinked. Your files are still in Nous; this folder just stopped syncing.");
1278
- });
1279
-
1280
- // ---------------------------------------------------------------------------
1281
- // nous vault — read + search the LINKED local vault ON-DEVICE. When an agent runs
1282
- // where the vault is linked (`nous link`), it should read these files directly
1283
- // instead of calling the search_my_vault MCP tool: fresher (no sync lag), fuller
1284
- // (whole files, not snippets), faster (no network), and private (the reads never
1285
- // leave the machine). This is the native personal-vault retrieval path.
1286
- // ---------------------------------------------------------------------------
1287
- function linkedVaultRoot() {
1288
- const dir = readConfig().link?.dir;
1289
- if (!dir || !existsSync(dir)) return null;
1290
- return dir;
1291
- }
1292
- function requireVaultRoot() {
1293
- const root = linkedVaultRoot();
1294
- if (!root) { console.error("No local vault linked. Run `nous link <dir>` first."); process.exit(1); }
1295
- return root;
1296
- }
1297
- // Resolve a 'folder/[subfolder/]name' argument to an absolute .md path under the vault.
1298
- function resolveVaultRel(root, p) {
1299
- const rel = String(p).replace(/^\/+/, "").endsWith(".md") ? String(p).replace(/^\/+/, "") : `${String(p).replace(/^\/+/, "")}.md`;
1300
- return { rel, abs: join(root, rel.split("/").join(sep)) };
1301
- }
1302
-
1303
- const vaultCmd = program
1304
- .command("vault")
1305
- .description("Read and search your linked local vault — on-device, no server round-trip");
1306
-
1307
- vaultCmd
1308
- .command("path")
1309
- .description("Print the linked vault folder (point your own Read/Grep at it)")
1310
- .action(() => { console.log(requireVaultRoot()); });
1311
-
1312
- vaultCmd
1313
- .command("list [folder]")
1314
- .description("List vault files, optionally within one folder")
1315
- .option("--json", "Output JSON", false)
1316
- .action((folder, { json }) => {
1317
- const root = requireVaultRoot();
1318
- let files = walkVaultMd(root).map((f) => f.rel);
1319
- if (folder) files = files.filter((r) => r.split("/")[0] === folder);
1320
- files.sort();
1321
- if (json) { console.log(JSON.stringify(files)); return; }
1322
- if (!files.length) { console.log(folder ? `No files in ${folder}.` : "Vault is empty."); return; }
1323
- for (const r of files) console.log(` ${r}`);
1324
- });
1325
-
1326
- vaultCmd
1327
- .command("read <path>")
1328
- .description("Print a vault file's full content (e.g. decisions/pricing)")
1329
- .action((p) => {
1330
- const root = requireVaultRoot();
1331
- const { rel, abs } = resolveVaultRel(root, p);
1332
- const content = safeRead(abs);
1333
- if (content == null) { console.error(`Not found: ${rel}`); process.exit(1); }
1334
- process.stdout.write(content.endsWith("\n") ? content : content + "\n");
1335
- });
1336
-
1337
- vaultCmd
1338
- .command("search <query...>")
1339
- .description("Search the linked vault locally (on-device — never leaves your machine)")
1340
- .option("--folder <folder>", "Restrict to one vault folder")
1341
- .option("--limit <n>", "Max results", "6")
1342
- .option("--json", "Output JSON", false)
1343
- .action((queryParts, { folder, limit, json }) => {
1344
- const root = requireVaultRoot();
1345
- const query = queryParts.join(" ");
1346
- const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
1347
- if (!terms.length) { console.error("Pass a query, e.g. `nous vault search pricing objections`."); process.exit(1); }
1348
-
1349
- let files = walkVaultMd(root);
1350
- if (folder) files = files.filter((f) => f.rel.split("/")[0] === folder);
1351
-
1352
- const hits = [];
1353
- for (const f of files) {
1354
- const content = safeRead(f.abs) ?? "";
1355
- const hay = content.toLowerCase();
1356
- const name = f.rel.toLowerCase();
1357
- let score = 0;
1358
- for (const t of terms) {
1359
- if (name.includes(t)) score += 5; // a filename hit is a strong signal
1360
- score += Math.min(hay.split(t).length - 1, 5); // content occurrences, capped
1361
- }
1362
- if (score <= 0) continue;
1363
- // Snippet around the first matching term.
1364
- let snippet = "";
1365
- for (const t of terms) {
1366
- const i = hay.indexOf(t);
1367
- if (i >= 0) { snippet = content.slice(Math.max(0, i - 60), i + 140).replace(/\s+/g, " ").trim(); break; }
1368
- }
1369
- hits.push({ path: f.rel, score, snippet });
1370
- }
1371
- hits.sort((a, b) => b.score - a.score);
1372
- const top = hits.slice(0, Math.min(Math.max(1, Number(limit) || 6), 30));
1373
-
1374
- if (json) { console.log(JSON.stringify(top)); return; }
1375
- if (!top.length) { console.log(`No local vault notes matched "${query}".`); return; }
1376
- console.log(`Local vault notes matching "${query}":\n`);
1377
- for (const h of top) {
1378
- console.log(` ${h.path}`);
1379
- if (h.snippet) console.log(` ${h.snippet}`);
1380
- }
1381
- });
1382
-
1383
1187
  // ---------------------------------------------------------------------------
1384
1188
  program.name("nous").version("0.8.0").parse();