@opennous/cli 0.7.5 → 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 +2 -1
- package/src/index.js +171 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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": {
|
|
@@ -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
|
|
|
@@ -1051,8 +1051,8 @@ program
|
|
|
1051
1051
|
.option("--force", "Overwrite files that already exist", false)
|
|
1052
1052
|
.action(async ({ dir, force }) => {
|
|
1053
1053
|
const api = apiClient();
|
|
1054
|
-
const {
|
|
1055
|
-
const docs = (
|
|
1054
|
+
const { foundations } = await api.get("/v2/foundations");
|
|
1055
|
+
const docs = (foundations || []).filter((p) => PULL_KINDS[p.kind] && (p.body_md || "").trim());
|
|
1056
1056
|
if (!docs.length) {
|
|
1057
1057
|
console.log("Nothing to pull — your Vault has no context docs yet. Set one up in the app first.");
|
|
1058
1058
|
return;
|
|
@@ -1075,14 +1075,14 @@ program
|
|
|
1075
1075
|
console.log(` write ${rel}`);
|
|
1076
1076
|
// Re-sync WITH the path so the graph now knows the repo is the author.
|
|
1077
1077
|
try {
|
|
1078
|
-
await api.post(`/v2/
|
|
1078
|
+
await api.post(`/v2/foundations/${p.kind}`, { body_md: p.body_md, file_path: rel });
|
|
1079
1079
|
synced++;
|
|
1080
1080
|
} catch { /* the file is written; a failed authorship flip is non-fatal, re-run pull */ }
|
|
1081
1081
|
}
|
|
1082
1082
|
|
|
1083
1083
|
console.log(`\n✓ ${written} written${skipped ? `, ${skipped} skipped` : ""}. Your repo now owns your context.`);
|
|
1084
1084
|
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
|
|
1085
|
+
console.log(dim("Edit these files and your agent syncs them with sync_foundation. Nous mirrors them."));
|
|
1086
1086
|
});
|
|
1087
1087
|
|
|
1088
1088
|
// nous logout — top-level alias of `auth logout`. Removes the saved API key.
|
|
@@ -1098,4 +1098,167 @@ program
|
|
|
1098
1098
|
});
|
|
1099
1099
|
|
|
1100
1100
|
// ---------------------------------------------------------------------------
|
|
1101
|
-
|
|
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();
|