@opennous/cli 0.7.4 → 0.8.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 +15 -3
- package/src/hooks/icp-sync-reminder.mjs +33 -0
- package/src/index.js +215 -8
package/package.json
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennous/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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": {
|
|
7
7
|
"nous": "src/index.js"
|
|
8
8
|
},
|
|
9
|
-
"files": [
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
10
13
|
"scripts": {
|
|
11
14
|
"dev": "node src/index.js",
|
|
12
15
|
"start": "node src/index.js"
|
|
13
16
|
},
|
|
14
|
-
"keywords": [
|
|
17
|
+
"keywords": [
|
|
18
|
+
"nous",
|
|
19
|
+
"crm",
|
|
20
|
+
"cli",
|
|
21
|
+
"agents",
|
|
22
|
+
"memory",
|
|
23
|
+
"observability",
|
|
24
|
+
"stripe"
|
|
25
|
+
],
|
|
15
26
|
"license": "Apache-2.0",
|
|
16
27
|
"repository": {
|
|
17
28
|
"type": "git",
|
|
@@ -20,6 +31,7 @@
|
|
|
20
31
|
},
|
|
21
32
|
"homepage": "https://opennous.cloud",
|
|
22
33
|
"dependencies": {
|
|
34
|
+
"chokidar": "^3.6.0",
|
|
23
35
|
"commander": "^12.1.0"
|
|
24
36
|
}
|
|
25
37
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Nous ICP auto-sync reminder (installed by `npx @opennous/cli init`).
|
|
3
|
+
//
|
|
4
|
+
// A Claude Code PostToolUse hook. After the agent edits a Nous context file, it
|
|
5
|
+
// reminds the agent that the file is INERT until synced: the app, the ICP tab, and
|
|
6
|
+
// the ICP scoring model read the Nous GRAPH, not the repo file, so an edit does
|
|
7
|
+
// nothing until mcp__nous__sync_icp runs. Standalone Node (no bash/jq) so it behaves
|
|
8
|
+
// identically on Windows, macOS, and Linux.
|
|
9
|
+
|
|
10
|
+
let raw = "";
|
|
11
|
+
process.stdin.on("data", (c) => { raw += c; });
|
|
12
|
+
process.stdin.on("end", () => {
|
|
13
|
+
try {
|
|
14
|
+
const j = JSON.parse(raw || "{}");
|
|
15
|
+
const f = (j && j.tool_input && j.tool_input.file_path) || (j && j.tool_response && j.tool_response.filePath) || "";
|
|
16
|
+
const norm = String(f).replace(/\\/g, "/"); // normalize Windows separators
|
|
17
|
+
if (!/\.md$/i.test(norm) || !/\/context\//i.test(norm)) return;
|
|
18
|
+
|
|
19
|
+
// Report the path relative to the project so the agent passes the right source_path.
|
|
20
|
+
const proj = (process.env.CLAUDE_PROJECT_DIR || "").replace(/\\/g, "/");
|
|
21
|
+
let rel = norm;
|
|
22
|
+
if (proj && norm.toLowerCase().startsWith(proj.toLowerCase())) {
|
|
23
|
+
rel = norm.slice(proj.length).replace(/^\/+/, "");
|
|
24
|
+
}
|
|
25
|
+
const base = norm.split("/").pop().toLowerCase();
|
|
26
|
+
|
|
27
|
+
const ctx = base === "icp.md"
|
|
28
|
+
? `You just edited ${rel}, your Nous ICP file. It is INERT until synced: the app, the ICP tab, and the ICP scoring model read the Nous graph, not this file. Call the mcp__nous__sync_icp tool THIS TURN with section "ICP" and source_path "${rel}", passing the file body as the section content, so the graph and the scoring model rebuild from your edit. Do not end the turn with the ICP file edited but unsynced.`
|
|
29
|
+
: `You edited a Nous context file (${rel}). It stays inert until synced into the Nous graph. If it is an ICP, positioning, pricing, competitors, or market file, call mcp__nous__sync_icp this turn with the matching section and its source_path so the graph updates.`;
|
|
30
|
+
|
|
31
|
+
process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: ctx } }));
|
|
32
|
+
} catch { /* never block a tool call */ }
|
|
33
|
+
});
|
package/src/index.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
// You never overwrite — Nous derives the facts.
|
|
7
7
|
|
|
8
8
|
import { program, Command } from "commander";
|
|
9
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "fs";
|
|
10
|
-
import { homedir } from "os";
|
|
11
|
-
import { join, resolve, dirname } from "path";
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "fs";
|
|
10
|
+
import { homedir, hostname } from "os";
|
|
11
|
+
import { join, resolve, dirname, relative, sep } from "path";
|
|
12
12
|
import { createInterface } from "readline";
|
|
13
13
|
import { spawn, spawnSync } from "child_process";
|
|
14
14
|
|
|
@@ -961,6 +961,42 @@ function registerMcp(apiUrl) {
|
|
|
961
961
|
return `Wrote ${path} — your agent will pick up Nous from it.`;
|
|
962
962
|
}
|
|
963
963
|
|
|
964
|
+
// Install the ICP auto-sync hook into the project's Claude Code config. Once in place,
|
|
965
|
+
// editing a Nous context file (context/**/icp.md, etc.) reminds the agent to call
|
|
966
|
+
// sync_icp that turn — because an edited ICP file is INERT until synced into the graph.
|
|
967
|
+
// Copies our packaged hook script into .claude/hooks/ and merges the PostToolUse entry
|
|
968
|
+
// into .claude/settings.json WITHOUT clobbering any existing hooks or settings.
|
|
969
|
+
// Returns true if it was already installed. Uses the exec (args) form so there's no
|
|
970
|
+
// shell/quoting difference between Windows PowerShell and macOS/Linux bash.
|
|
971
|
+
function installIcpSyncHook() {
|
|
972
|
+
const claudeDir = join(process.cwd(), ".claude");
|
|
973
|
+
const hooksDir = join(claudeDir, "hooks");
|
|
974
|
+
const scriptPath = join(hooksDir, "icp-sync-reminder.mjs");
|
|
975
|
+
const settingsPath = join(claudeDir, "settings.json");
|
|
976
|
+
|
|
977
|
+
const src = readFileSync(new URL("./hooks/icp-sync-reminder.mjs", import.meta.url), "utf8");
|
|
978
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
979
|
+
writeFileSync(scriptPath, src);
|
|
980
|
+
|
|
981
|
+
let settings = {};
|
|
982
|
+
if (existsSync(settingsPath)) {
|
|
983
|
+
try { settings = JSON.parse(readFileSync(settingsPath, "utf8")) || {}; } catch { settings = {}; }
|
|
984
|
+
}
|
|
985
|
+
settings.hooks = settings.hooks || {};
|
|
986
|
+
const post = settings.hooks.PostToolUse = settings.hooks.PostToolUse || [];
|
|
987
|
+
const already = post.some((e) => (e.hooks || []).some((h) =>
|
|
988
|
+
(Array.isArray(h.args) && h.args.some((a) => String(a).includes("icp-sync-reminder"))) ||
|
|
989
|
+
String(h.command || "").includes("icp-sync-reminder")));
|
|
990
|
+
if (!already) {
|
|
991
|
+
post.push({
|
|
992
|
+
matcher: "Write|Edit|MultiEdit",
|
|
993
|
+
hooks: [{ type: "command", command: "node", args: [scriptPath], timeout: 10 }],
|
|
994
|
+
});
|
|
995
|
+
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
996
|
+
}
|
|
997
|
+
return already;
|
|
998
|
+
}
|
|
999
|
+
|
|
964
1000
|
// nous init — the one-line "get started": sign in, register the MCP, hand off to the agent.
|
|
965
1001
|
// This is the command the install script and the marketing site point to.
|
|
966
1002
|
program
|
|
@@ -980,6 +1016,14 @@ program
|
|
|
980
1016
|
console.log("\nCouldn't register the MCP automatically. Add it with:");
|
|
981
1017
|
console.log(` claude mcp add nous -- ${MCP_CMD} ${MCP_ARGS.join(" ")}`);
|
|
982
1018
|
}
|
|
1019
|
+
|
|
1020
|
+
// Auto-sync the ICP: a Claude Code hook so editing your ICP file reminds the
|
|
1021
|
+
// agent to sync it into Nous (the file is inert until synced). Best-effort —
|
|
1022
|
+
// a convenience, never a reason to fail setup.
|
|
1023
|
+
try {
|
|
1024
|
+
const already = installIcpSyncHook();
|
|
1025
|
+
if (!already) console.log("Installed the ICP auto-sync hook — edits to your ICP file now sync to Nous automatically.");
|
|
1026
|
+
} catch { /* non-fatal */ }
|
|
983
1027
|
}
|
|
984
1028
|
|
|
985
1029
|
console.log("\nYou're set. Tell your agent:\n");
|
|
@@ -1007,8 +1051,8 @@ program
|
|
|
1007
1051
|
.option("--force", "Overwrite files that already exist", false)
|
|
1008
1052
|
.action(async ({ dir, force }) => {
|
|
1009
1053
|
const api = apiClient();
|
|
1010
|
-
const {
|
|
1011
|
-
const docs = (
|
|
1054
|
+
const { foundations } = await api.get("/v2/foundations");
|
|
1055
|
+
const docs = (foundations || []).filter((p) => PULL_KINDS[p.kind] && (p.body_md || "").trim());
|
|
1012
1056
|
if (!docs.length) {
|
|
1013
1057
|
console.log("Nothing to pull — your Vault has no context docs yet. Set one up in the app first.");
|
|
1014
1058
|
return;
|
|
@@ -1031,14 +1075,14 @@ program
|
|
|
1031
1075
|
console.log(` write ${rel}`);
|
|
1032
1076
|
// Re-sync WITH the path so the graph now knows the repo is the author.
|
|
1033
1077
|
try {
|
|
1034
|
-
await api.post(`/v2/
|
|
1078
|
+
await api.post(`/v2/foundations/${p.kind}`, { body_md: p.body_md, file_path: rel });
|
|
1035
1079
|
synced++;
|
|
1036
1080
|
} catch { /* the file is written; a failed authorship flip is non-fatal, re-run pull */ }
|
|
1037
1081
|
}
|
|
1038
1082
|
|
|
1039
1083
|
console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
|
|
1040
1084
|
if (synced < written) console.log(dim(" (some didn't sync back — re-run `nous pull` to finish)"));
|
|
1041
|
-
console.log(dim("Edit these files and your agent syncs them with
|
|
1085
|
+
console.log(dim("Edit these files and your agent syncs them with sync_foundation. Nous mirrors them."));
|
|
1042
1086
|
});
|
|
1043
1087
|
|
|
1044
1088
|
// nous logout — top-level alias of `auth logout`. Removes the saved API key.
|
|
@@ -1054,4 +1098,167 @@ program
|
|
|
1054
1098
|
});
|
|
1055
1099
|
|
|
1056
1100
|
// ---------------------------------------------------------------------------
|
|
1057
|
-
|
|
1101
|
+
// nous link <dir> — pair a local folder with your personal vault and keep them in
|
|
1102
|
+
// sync, both ways. Your laptop and Nous become two views of the same files: edit in
|
|
1103
|
+
// Obsidian/Finder and it flows up; the agent files a brief (once you approve it) and
|
|
1104
|
+
// it flows down. This is the "local desk" — the same vault the GitHub sync fills,
|
|
1105
|
+
// only the transport is your filesystem instead of a repo. `nous link` is idempotent;
|
|
1106
|
+
// run it once and leave it watching, or pass --once for a single reconcile.
|
|
1107
|
+
// ---------------------------------------------------------------------------
|
|
1108
|
+
|
|
1109
|
+
const VAULT_FOLDERS = ["inbox", "thoughts", "decisions", "projects", "briefs", "content", "company"];
|
|
1110
|
+
|
|
1111
|
+
// A resilient API caller for the daemon — unlike apiClient(), a failed poll or push
|
|
1112
|
+
// must NOT exit the process; the loop should keep running and retry on the next tick.
|
|
1113
|
+
function softClient() {
|
|
1114
|
+
const cfg = readConfig();
|
|
1115
|
+
const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
1116
|
+
const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
1117
|
+
if (!apiKey) { console.error("No API key. Run: nous login"); process.exit(1); }
|
|
1118
|
+
return async (method, path, { body, query } = {}) => {
|
|
1119
|
+
const url = new URL(path, apiUrl);
|
|
1120
|
+
if (query) for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
|
|
1121
|
+
try {
|
|
1122
|
+
const res = await fetch(url.toString(), {
|
|
1123
|
+
method,
|
|
1124
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
1125
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
1126
|
+
});
|
|
1127
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
1128
|
+
return { ok: true, data: res.status === 204 ? {} : await res.json() };
|
|
1129
|
+
} catch (e) {
|
|
1130
|
+
return { ok: false, error: e.message };
|
|
1131
|
+
}
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Every .md under the foundation folders of `root`, as repo-style relative paths.
|
|
1136
|
+
function walkVaultMd(root) {
|
|
1137
|
+
const out = [];
|
|
1138
|
+
const rec = (dir, prefix) => {
|
|
1139
|
+
let entries = [];
|
|
1140
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
1141
|
+
for (const e of entries) {
|
|
1142
|
+
if (e.name.startsWith(".")) continue;
|
|
1143
|
+
const abs = join(dir, e.name);
|
|
1144
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
1145
|
+
if (e.isDirectory()) rec(abs, rel);
|
|
1146
|
+
else if (e.name.endsWith(".md")) out.push({ rel, abs });
|
|
1147
|
+
}
|
|
1148
|
+
};
|
|
1149
|
+
for (const f of VAULT_FOLDERS) rec(join(root, f), f);
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const safeRead = (abs) => { try { return readFileSync(abs, "utf8"); } catch { return null; } };
|
|
1154
|
+
const saveCursor = (cursor) => { const c = readConfig(); c.link = { ...(c.link || {}), cursor }; writeConfig(c); };
|
|
1155
|
+
|
|
1156
|
+
// Write incoming files to disk where they differ. Records what we wrote so the watcher
|
|
1157
|
+
// can ignore the change event it will fire, and so we never push a file straight back.
|
|
1158
|
+
async function pullOnce(call, root, recentlyWritten, quiet) {
|
|
1159
|
+
const since = readConfig().link?.cursor || new Date(0).toISOString();
|
|
1160
|
+
const r = await call("GET", "/v2/personal/local/pull", { query: { since } });
|
|
1161
|
+
if (!r.ok) return;
|
|
1162
|
+
let wrote = 0;
|
|
1163
|
+
for (const f of r.data.files || []) {
|
|
1164
|
+
const abs = join(root, f.path.split("/").join(sep));
|
|
1165
|
+
if (safeRead(abs) === f.content) continue; // already identical — no write, no echo
|
|
1166
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
1167
|
+
writeFileSync(abs, f.content);
|
|
1168
|
+
recentlyWritten.set(f.path, Date.now());
|
|
1169
|
+
if (!quiet) process.stdout.write(` ↓ ${f.path}\n`);
|
|
1170
|
+
wrote++;
|
|
1171
|
+
}
|
|
1172
|
+
if (r.data.cursor) saveCursor(r.data.cursor);
|
|
1173
|
+
return wrote;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
program
|
|
1177
|
+
.command("link <dir>")
|
|
1178
|
+
.description("Pair a local folder with your personal vault and keep them in sync")
|
|
1179
|
+
.option("--once", "Reconcile once and exit instead of watching", false)
|
|
1180
|
+
.option("--interval <sec>", "How often to pull incoming changes while watching", "15")
|
|
1181
|
+
.action(async (dir, { once, interval }) => {
|
|
1182
|
+
const call = softClient();
|
|
1183
|
+
const root = resolve(dir);
|
|
1184
|
+
// Scaffold the folder so the desk has the same shape as the vault.
|
|
1185
|
+
if (!existsSync(root)) mkdirSync(root, { recursive: true });
|
|
1186
|
+
for (const f of VAULT_FOLDERS) { const d = join(root, f); if (!existsSync(d)) mkdirSync(d, { recursive: true }); }
|
|
1187
|
+
|
|
1188
|
+
const label = hostname() || "my machine";
|
|
1189
|
+
const linked = await call("POST", "/v2/personal/local/link", { body: { label } });
|
|
1190
|
+
if (!linked.ok) { console.error("Couldn't link this folder — check your API key with `nous auth status`."); process.exit(1); }
|
|
1191
|
+
|
|
1192
|
+
const cfg = readConfig();
|
|
1193
|
+
cfg.link = { dir: root, cursor: new Date(0).toISOString() };
|
|
1194
|
+
writeConfig(cfg);
|
|
1195
|
+
console.log(`Linked ${root} ↔ your Nous vault (${label}).\n`);
|
|
1196
|
+
|
|
1197
|
+
const recentlyWritten = new Map();
|
|
1198
|
+
const wasJustWritten = (rel) => { const t = recentlyWritten.get(rel); return t && Date.now() - t < 4000; };
|
|
1199
|
+
|
|
1200
|
+
// ── Initial reconcile: pull server state, then push anything local that differs. ──
|
|
1201
|
+
const serverState = await call("GET", "/v2/personal/local/pull", { query: { since: new Date(0).toISOString() } });
|
|
1202
|
+
const serverMap = new Map((serverState.ok ? serverState.data.files : []).map((f) => [f.path, f.content]));
|
|
1203
|
+
// Write server files that are missing/different on disk.
|
|
1204
|
+
for (const [path, content] of serverMap) {
|
|
1205
|
+
const abs = join(root, path.split("/").join(sep));
|
|
1206
|
+
if (safeRead(abs) === content) continue;
|
|
1207
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
1208
|
+
writeFileSync(abs, content);
|
|
1209
|
+
recentlyWritten.set(path, Date.now());
|
|
1210
|
+
console.log(` ↓ ${path}`);
|
|
1211
|
+
}
|
|
1212
|
+
// Push local files the server doesn't have or that differ.
|
|
1213
|
+
const toPush = walkVaultMd(root).filter((f) => serverMap.get(f.rel) !== safeRead(f.abs));
|
|
1214
|
+
if (toPush.length) {
|
|
1215
|
+
const files = toPush.map((f) => ({ path: f.rel, content: safeRead(f.abs) ?? "" }));
|
|
1216
|
+
await call("POST", "/v2/personal/local/push", { body: { files } });
|
|
1217
|
+
for (const f of toPush) console.log(` ↑ ${f.rel}`);
|
|
1218
|
+
}
|
|
1219
|
+
saveCursor(new Date().toISOString());
|
|
1220
|
+
console.log(`\n✓ In sync — ${serverMap.size} in Nous, ${walkVaultMd(root).length} on disk.`);
|
|
1221
|
+
|
|
1222
|
+
if (once) return;
|
|
1223
|
+
|
|
1224
|
+
// ── Watch: push local edits up; poll: pull remote edits down. ──
|
|
1225
|
+
let chokidar;
|
|
1226
|
+
try { chokidar = (await import("chokidar")).default; }
|
|
1227
|
+
catch { console.error("\nWatching needs chokidar. Re-run with --once, or `npm i -g @opennous/cli` to get it."); process.exit(1); }
|
|
1228
|
+
|
|
1229
|
+
const pushPath = async (abs, deleted) => {
|
|
1230
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
1231
|
+
if (!rel.endsWith(".md")) return;
|
|
1232
|
+
if (wasJustWritten(rel)) return; // this change is one we just pulled — don't echo it back
|
|
1233
|
+
const content = deleted ? "" : (safeRead(abs) ?? "");
|
|
1234
|
+
const r = await call("POST", "/v2/personal/local/push", { body: { files: [{ path: rel, content, deleted: !!deleted }] } });
|
|
1235
|
+
if (r.ok) { saveCursor(new Date().toISOString()); process.stdout.write(deleted ? ` ↑ removed ${rel}\n` : ` ↑ ${rel}\n`); }
|
|
1236
|
+
};
|
|
1237
|
+
|
|
1238
|
+
const watcher = chokidar.watch(VAULT_FOLDERS.map((f) => join(root, f)), {
|
|
1239
|
+
ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: 400, pollInterval: 100 },
|
|
1240
|
+
});
|
|
1241
|
+
watcher.on("add", (p) => pushPath(p, false)).on("change", (p) => pushPath(p, false)).on("unlink", (p) => pushPath(p, true));
|
|
1242
|
+
|
|
1243
|
+
const secs = Math.max(5, Number(interval) || 15);
|
|
1244
|
+
setInterval(() => { pullOnce(call, root, recentlyWritten).catch(() => {}); }, secs * 1000);
|
|
1245
|
+
|
|
1246
|
+
console.log(`\nWatching for changes. Pulling every ${secs}s. Press Ctrl-C to stop.`);
|
|
1247
|
+
process.stdin.resume(); // keep the process alive
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
// nous unlink — detach the local folder. Files stay in the vault; only the pairing ends.
|
|
1251
|
+
program
|
|
1252
|
+
.command("unlink")
|
|
1253
|
+
.description("Stop syncing your linked local folder (files stay in Nous)")
|
|
1254
|
+
.action(async () => {
|
|
1255
|
+
const call = softClient();
|
|
1256
|
+
await call("POST", "/v2/personal/local/unlink", {});
|
|
1257
|
+
const cfg = readConfig();
|
|
1258
|
+
delete cfg.link;
|
|
1259
|
+
writeConfig(cfg);
|
|
1260
|
+
console.log("✓ Unlinked. Your files are still in Nous; this folder just stopped syncing.");
|
|
1261
|
+
});
|
|
1262
|
+
|
|
1263
|
+
// ---------------------------------------------------------------------------
|
|
1264
|
+
program.name("nous").version("0.8.0").parse();
|