@opennous/cli 0.7.5 → 0.9.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 +2 -1
- package/src/index.js +193 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opennous/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"homepage": "https://opennous.cloud",
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"chokidar": "^3.6.0",
|
|
34
35
|
"commander": "^12.1.0"
|
|
35
36
|
}
|
|
36
37
|
}
|
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
|
|
|
@@ -837,18 +837,25 @@ program
|
|
|
837
837
|
// freshly minted API key is saved to ~/.nous/config.json. No copy-paste. Shared
|
|
838
838
|
// by `nous login` and `nous init`.
|
|
839
839
|
// ---------------------------------------------------------------------------
|
|
840
|
-
async function browserLogin(url) {
|
|
840
|
+
async function browserLogin(url, opts = {}) {
|
|
841
841
|
const apiUrl = url || process.env.NOUS_API_URL || readConfig().apiUrl || "https://api.opennous.cloud";
|
|
842
|
+
// Default = plain browser sign-in. With { startPath, startBody } it redeems a team
|
|
843
|
+
// join token instead (nous join): same device flow, different start endpoint.
|
|
844
|
+
const startPath = opts.startPath || "/api/cli/auth/start";
|
|
845
|
+
const startBody = opts.startBody ? JSON.stringify(opts.startBody) : "{}";
|
|
842
846
|
|
|
843
847
|
let start;
|
|
844
848
|
try {
|
|
845
|
-
const r = await fetch(`${apiUrl}
|
|
846
|
-
method: "POST", headers: { "Content-Type": "application/json" }, body:
|
|
849
|
+
const r = await fetch(`${apiUrl}${startPath}`, {
|
|
850
|
+
method: "POST", headers: { "Content-Type": "application/json" }, body: startBody,
|
|
847
851
|
});
|
|
848
|
-
if (!r.ok)
|
|
852
|
+
if (!r.ok) {
|
|
853
|
+
const j = await r.json().catch(() => ({}));
|
|
854
|
+
throw new Error(j.error ? j.error.replace(/_/g, " ") : `server returned ${r.status}`);
|
|
855
|
+
}
|
|
849
856
|
start = await r.json();
|
|
850
857
|
} catch (e) {
|
|
851
|
-
console.error(`Couldn't start
|
|
858
|
+
console.error(`Couldn't start: ${e.message}`);
|
|
852
859
|
process.exit(1);
|
|
853
860
|
}
|
|
854
861
|
|
|
@@ -895,6 +902,16 @@ program
|
|
|
895
902
|
console.log("Your Nous tools are ready — try `get_workspace_status` in your agent.");
|
|
896
903
|
});
|
|
897
904
|
|
|
905
|
+
program
|
|
906
|
+
.command("join <token>")
|
|
907
|
+
.description("Join a team with an invite/join token and set up your own scoped workspace")
|
|
908
|
+
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
909
|
+
.action(async (token, { url }) => {
|
|
910
|
+
await browserLogin(url, { startPath: "/api/cli/auth/join/start", startBody: { token } });
|
|
911
|
+
console.log("You're in — your agent now has its own member-scoped Nous workspace.");
|
|
912
|
+
console.log("Try `get_workspace_status` in your agent.");
|
|
913
|
+
});
|
|
914
|
+
|
|
898
915
|
// ---------------------------------------------------------------------------
|
|
899
916
|
// MCP registration — connect Nous to whatever agent is on this machine.
|
|
900
917
|
//
|
|
@@ -1051,8 +1068,8 @@ program
|
|
|
1051
1068
|
.option("--force", "Overwrite files that already exist", false)
|
|
1052
1069
|
.action(async ({ dir, force }) => {
|
|
1053
1070
|
const api = apiClient();
|
|
1054
|
-
const {
|
|
1055
|
-
const docs = (
|
|
1071
|
+
const { foundations } = await api.get("/v2/foundations");
|
|
1072
|
+
const docs = (foundations || []).filter((p) => PULL_KINDS[p.kind] && (p.body_md || "").trim());
|
|
1056
1073
|
if (!docs.length) {
|
|
1057
1074
|
console.log("Nothing to pull — your Vault has no context docs yet. Set one up in the app first.");
|
|
1058
1075
|
return;
|
|
@@ -1075,14 +1092,14 @@ program
|
|
|
1075
1092
|
console.log(` write ${rel}`);
|
|
1076
1093
|
// Re-sync WITH the path so the graph now knows the repo is the author.
|
|
1077
1094
|
try {
|
|
1078
|
-
await api.post(`/v2/
|
|
1095
|
+
await api.post(`/v2/foundations/${p.kind}`, { body_md: p.body_md, file_path: rel });
|
|
1079
1096
|
synced++;
|
|
1080
1097
|
} catch { /* the file is written; a failed authorship flip is non-fatal, re-run pull */ }
|
|
1081
1098
|
}
|
|
1082
1099
|
|
|
1083
1100
|
console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
|
|
1084
1101
|
if (synced < written) console.log(dim(" (some didn't sync back — re-run `nous pull` to finish)"));
|
|
1085
|
-
console.log(dim("Edit these files and your agent syncs them with
|
|
1102
|
+
console.log(dim("Edit these files and your agent syncs them with sync_foundation. Nous mirrors them."));
|
|
1086
1103
|
});
|
|
1087
1104
|
|
|
1088
1105
|
// nous logout — top-level alias of `auth logout`. Removes the saved API key.
|
|
@@ -1098,4 +1115,167 @@ program
|
|
|
1098
1115
|
});
|
|
1099
1116
|
|
|
1100
1117
|
// ---------------------------------------------------------------------------
|
|
1101
|
-
|
|
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
|
+
program.name("nous").version("0.8.0").parse();
|