@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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/index.js +193 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opennous/cli",
3
- "version": "0.7.5",
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}/api/cli/auth/start`, {
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) throw new Error(`server returned ${r.status}`);
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 sign-in: ${e.message}`);
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 { playbooks } = await api.get("/v2/playbooks");
1055
- const docs = (playbooks || []).filter((p) => PULL_KINDS[p.kind] && (p.body_md || "").trim());
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/playbooks/${p.kind}`, { body_md: p.body_md, file_path: rel });
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 sync_playbook. Nous mirrors them."));
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
- program.name("nous").version("0.7.0").parse();
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();