@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.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- 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
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
|
-
//
|
|
960
|
+
// Connect OpenNous to whatever agent is on this machine — host-aware.
|
|
917
961
|
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
//
|
|
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.
|
|
992
|
+
cfg.mcpServers.opennous = {
|
|
947
993
|
command: MCP_CMD,
|
|
948
994
|
args: MCP_ARGS,
|
|
949
|
-
//
|
|
950
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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
|
|
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
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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"
|
|
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();
|