@opennous/cli 0.2.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/README.md +44 -0
- package/package.json +25 -0
- package/src/index.js +651 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @opennous/cli
|
|
2
|
+
|
|
3
|
+
Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# in your project root
|
|
9
|
+
npx -y @opennous/cli@latest install
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`nous install` detects your stack (Next.js, Node, Python), writes a small `nous.js` (or `nous.py`) module with three helpers, and updates your `.env`:
|
|
13
|
+
|
|
14
|
+
- **`trackSignup({email, ...})`** — fires `interaction.signed_up` + initial `state.stage`
|
|
15
|
+
- **`handleStripeEvent(event, {customerEmail})`** — fires `interaction.subscription_started / updated / canceled` and flips `state.stage` (Customer / Churned)
|
|
16
|
+
- **`track(focus, property, value)`** — escape hatch for any other event
|
|
17
|
+
|
|
18
|
+
The generated module is plain code you own. Read it, edit it, or delete it.
|
|
19
|
+
|
|
20
|
+
## Other commands
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
nous auth login --key <your-key> # save your API key
|
|
24
|
+
nous context <email> # engineered context for a person
|
|
25
|
+
nous account <email> # full record with epistemics + timeline
|
|
26
|
+
nous record <email> --property X --value Y
|
|
27
|
+
nous query --property stage --value Customer
|
|
28
|
+
nous attention # surface what changed recently
|
|
29
|
+
nous verify <email> <property> # confirm a claim is current
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Run `nous --help` for the full list.
|
|
33
|
+
|
|
34
|
+
## Auth
|
|
35
|
+
|
|
36
|
+
`NOUS_API_KEY` env var, or `nous auth login --key <key>`. Mint a key at [app.opennous.cloud](https://app.opennous.cloud) → Settings → API Keys.
|
|
37
|
+
|
|
38
|
+
## Self-hosting
|
|
39
|
+
|
|
40
|
+
Set `NOUS_API_URL` to your own deployment.
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
AGPL-3.0
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opennous/cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Nous CLI — install Nous event tracking into your app, plus terminal access to the Context API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"nous": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["src", "README.md"],
|
|
10
|
+
"scripts": {
|
|
11
|
+
"dev": "node src/index.js",
|
|
12
|
+
"start": "node src/index.js"
|
|
13
|
+
},
|
|
14
|
+
"keywords": ["nous", "crm", "cli", "agents", "memory", "observability", "stripe"],
|
|
15
|
+
"license": "AGPL-3.0",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/bennetglinder1/nous.git",
|
|
19
|
+
"directory": "apps/cli"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://opennous.cloud",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"commander": "^12.1.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Nous CLI — a thin client of the v2 Context API.
|
|
4
|
+
//
|
|
5
|
+
// You read engineered, epistemics-tagged context and write observations.
|
|
6
|
+
// You never overwrite — Nous derives the facts.
|
|
7
|
+
|
|
8
|
+
import { program, Command } from "commander";
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { join, resolve } from "path";
|
|
12
|
+
import { createInterface } from "readline";
|
|
13
|
+
|
|
14
|
+
const CONFIG_PATH = join(homedir(), ".nous", "config.json");
|
|
15
|
+
|
|
16
|
+
function readConfig() {
|
|
17
|
+
if (!existsSync(CONFIG_PATH)) return {};
|
|
18
|
+
return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeConfig(cfg) {
|
|
22
|
+
const dir = join(homedir(), ".nous");
|
|
23
|
+
if (!existsSync(dir)) {
|
|
24
|
+
mkdirSync(dir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function apiClient() {
|
|
30
|
+
const cfg = readConfig();
|
|
31
|
+
const apiKey = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
32
|
+
const apiUrl = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
33
|
+
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
console.error("No API key found. Run: nous auth login --key <your-key>");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function request(method, path, { body, query } = {}) {
|
|
40
|
+
const url = new URL(path, apiUrl);
|
|
41
|
+
if (query) {
|
|
42
|
+
for (const [k, v] of Object.entries(query)) {
|
|
43
|
+
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const res = await fetch(url.toString(), {
|
|
47
|
+
method,
|
|
48
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
49
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const err = await res.json().catch(() => ({ message: res.statusText }));
|
|
53
|
+
console.error(`Error: ${err.error || err.message}`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
if (res.status === 204) return {};
|
|
57
|
+
return res.json();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
get: (path, query) => request("GET", path, { query }),
|
|
62
|
+
post: (path, body) => request("POST", path, { body }),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// A name that matches several entities comes back as { status: 'ambiguous' }.
|
|
67
|
+
// Print the candidates and stop — the caller re-runs with a precise focus.
|
|
68
|
+
function handleAmbiguous(r) {
|
|
69
|
+
if (r && r.status === "ambiguous") {
|
|
70
|
+
console.log("Several entities match that focus — re-run with one of:");
|
|
71
|
+
for (const c of r.candidates ?? []) {
|
|
72
|
+
const detail = c.detail ? ` — ${c.detail}` : "";
|
|
73
|
+
console.log(` ${c.name || "(unnamed)"}${detail} [${c.entity_id}]`);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --value accepts JSON ('{"description":"…"}') or a bare string.
|
|
81
|
+
function parseValue(raw) {
|
|
82
|
+
if (raw === undefined) return undefined;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// nous install — drop a starter Nous integration into the current project
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// Detects the framework, asks for the API key, writes a single module with the
|
|
94
|
+
// helpers wired (signup tracking + Stripe webhook handler + generic track()),
|
|
95
|
+
// and prints exactly where the user has to hook it up.
|
|
96
|
+
//
|
|
97
|
+
// No magic — everything is plain code the user can read and edit. The CLI's job
|
|
98
|
+
// is to save them the 30 minutes of writing it from scratch and to make sure
|
|
99
|
+
// the event names match what the Nous UI expects.
|
|
100
|
+
|
|
101
|
+
function detectStack(root) {
|
|
102
|
+
// Returns one of: "next", "node-esm", "node-cjs", "python", "unknown"
|
|
103
|
+
const pkgPath = join(root, "package.json");
|
|
104
|
+
if (existsSync(pkgPath)) {
|
|
105
|
+
try {
|
|
106
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
107
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
108
|
+
if (deps.next) return "next";
|
|
109
|
+
if (pkg.type === "module") return "node-esm";
|
|
110
|
+
return "node-cjs";
|
|
111
|
+
} catch { /* fall through */ }
|
|
112
|
+
}
|
|
113
|
+
if (existsSync(join(root, "pyproject.toml")) || existsSync(join(root, "requirements.txt"))) {
|
|
114
|
+
return "python";
|
|
115
|
+
}
|
|
116
|
+
return "unknown";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function prompt(question, fallback) {
|
|
120
|
+
return new Promise(resolve => {
|
|
121
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
122
|
+
rl.question(question + (fallback ? ` [${fallback}] ` : " "), answer => {
|
|
123
|
+
rl.close();
|
|
124
|
+
resolve(answer.trim() || fallback || "");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const TEMPLATES = {
|
|
130
|
+
// Node ESM template — works for raw Express, Fastify, Hono, anything that
|
|
131
|
+
// accepts async functions with the request body. The user wires the import
|
|
132
|
+
// into whichever route handles signups + Stripe.
|
|
133
|
+
"node-esm": `// Nous event tracking. Generated by \`nous install\`.
|
|
134
|
+
// Edit freely — this is just a starting point.
|
|
135
|
+
|
|
136
|
+
const NOUS_API_URL = process.env.NOUS_API_URL || "https://api.opennous.cloud";
|
|
137
|
+
const NOUS_API_KEY = process.env.NOUS_API_KEY;
|
|
138
|
+
|
|
139
|
+
async function postObservations(focus, observations) {
|
|
140
|
+
if (!NOUS_API_KEY) { console.warn("[NOUS] NOUS_API_KEY not set, skipping"); return; }
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(\`\${NOUS_API_URL}/v2/observations\`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: { "Content-Type": "application/json", Authorization: \`Bearer \${NOUS_API_KEY}\` },
|
|
145
|
+
body: JSON.stringify({ focus, observations }),
|
|
146
|
+
});
|
|
147
|
+
if (!res.ok) console.error("[NOUS] observation failed:", res.status, await res.text().catch(() => ""));
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error("[NOUS] observation error:", err.message);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Call this right after a new user account is created in your auth flow.
|
|
154
|
+
export async function trackSignup({ email, first_name = null, last_name = null, company = null, plan = "free", source }) {
|
|
155
|
+
if (!email) return;
|
|
156
|
+
await postObservations(email, [
|
|
157
|
+
{ kind: "event", property: "interaction.signed_up",
|
|
158
|
+
value: { plan, source: source || "your-app", first_name, last_name, company, at: new Date().toISOString() } },
|
|
159
|
+
{ kind: "state", property: "stage", value: plan === "free" ? "Free User" : "Trial" },
|
|
160
|
+
...(company ? [{ kind: "state", property: "company", value: company }] : []),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mount this on your Stripe webhook handler. Expects a Stripe Event object.
|
|
165
|
+
export async function handleStripeEvent(event, { customerEmail }) {
|
|
166
|
+
if (!customerEmail) return;
|
|
167
|
+
const sub = event.data?.object;
|
|
168
|
+
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
|
|
169
|
+
if (sub?.status !== "active") return;
|
|
170
|
+
const price = sub.items?.data?.[0]?.price;
|
|
171
|
+
await postObservations(customerEmail, [
|
|
172
|
+
{ kind: "event",
|
|
173
|
+
property: event.type === "customer.subscription.created"
|
|
174
|
+
? "interaction.subscription_started"
|
|
175
|
+
: "interaction.subscription_updated",
|
|
176
|
+
value: {
|
|
177
|
+
source: "stripe", plan: sub.metadata?.plan_id || price?.lookup_key || null, status: sub.status,
|
|
178
|
+
amount: price?.unit_amount ? price.unit_amount / 100 : null, currency: sub.currency || null,
|
|
179
|
+
stripe_subscription_id: sub.id, at: new Date().toISOString(),
|
|
180
|
+
} },
|
|
181
|
+
{ kind: "state", property: "stage", value: "Customer" },
|
|
182
|
+
{ kind: "state", property: "plan", value: sub.metadata?.plan_id || price?.lookup_key || null },
|
|
183
|
+
]);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (event.type === "customer.subscription.deleted") {
|
|
187
|
+
await postObservations(customerEmail, [
|
|
188
|
+
{ kind: "event", property: "interaction.subscription_canceled",
|
|
189
|
+
value: { source: "stripe", stripe_subscription_id: sub?.id, at: new Date().toISOString() } },
|
|
190
|
+
{ kind: "state", property: "stage", value: "Churned" },
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Escape hatch for any other event you want to land on the timeline.
|
|
196
|
+
export async function track(focus, property, value = {}) {
|
|
197
|
+
await postObservations(focus, [{ kind: "event", property, value: { ...value, at: new Date().toISOString() } }]);
|
|
198
|
+
}
|
|
199
|
+
`,
|
|
200
|
+
|
|
201
|
+
python: `# Nous event tracking. Generated by \`nous install\`.
|
|
202
|
+
# Edit freely — this is just a starting point.
|
|
203
|
+
import os
|
|
204
|
+
import urllib.request
|
|
205
|
+
import json
|
|
206
|
+
from datetime import datetime, timezone
|
|
207
|
+
|
|
208
|
+
NOUS_API_URL = os.getenv("NOUS_API_URL", "https://api.opennous.cloud")
|
|
209
|
+
NOUS_API_KEY = os.getenv("NOUS_API_KEY")
|
|
210
|
+
|
|
211
|
+
def _post(focus, observations):
|
|
212
|
+
if not NOUS_API_KEY:
|
|
213
|
+
print("[NOUS] NOUS_API_KEY not set, skipping")
|
|
214
|
+
return
|
|
215
|
+
body = json.dumps({"focus": focus, "observations": observations}).encode()
|
|
216
|
+
req = urllib.request.Request(
|
|
217
|
+
f"{NOUS_API_URL}/v2/observations", data=body, method="POST",
|
|
218
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {NOUS_API_KEY}"},
|
|
219
|
+
)
|
|
220
|
+
try:
|
|
221
|
+
with urllib.request.urlopen(req, timeout=5) as r:
|
|
222
|
+
if r.status >= 400: print("[NOUS] observation failed:", r.status)
|
|
223
|
+
except Exception as e:
|
|
224
|
+
print("[NOUS] observation error:", e)
|
|
225
|
+
|
|
226
|
+
def track_signup(email, *, first_name=None, last_name=None, company=None, plan="free", source="your-app"):
|
|
227
|
+
if not email: return
|
|
228
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
229
|
+
_post(email, [
|
|
230
|
+
{"kind":"event","property":"interaction.signed_up",
|
|
231
|
+
"value":{"plan":plan,"source":source,"first_name":first_name,"last_name":last_name,"company":company,"at":now}},
|
|
232
|
+
{"kind":"state","property":"stage","value":"Free User" if plan=="free" else "Trial"},
|
|
233
|
+
*([{"kind":"state","property":"company","value":company}] if company else []),
|
|
234
|
+
])
|
|
235
|
+
|
|
236
|
+
def handle_stripe_event(event, customer_email):
|
|
237
|
+
if not customer_email: return
|
|
238
|
+
sub = event.get("data", {}).get("object", {})
|
|
239
|
+
t = event.get("type")
|
|
240
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
241
|
+
if t in ("customer.subscription.created", "customer.subscription.updated"):
|
|
242
|
+
if sub.get("status") != "active": return
|
|
243
|
+
price = (sub.get("items", {}).get("data") or [{}])[0].get("price", {}) or {}
|
|
244
|
+
_post(customer_email, [
|
|
245
|
+
{"kind":"event",
|
|
246
|
+
"property":"interaction.subscription_started" if t.endswith("created") else "interaction.subscription_updated",
|
|
247
|
+
"value":{"source":"stripe","plan":sub.get("metadata",{}).get("plan_id"),"status":sub.get("status"),
|
|
248
|
+
"amount":(price.get("unit_amount") or 0)/100 or None,"currency":sub.get("currency"),
|
|
249
|
+
"stripe_subscription_id":sub.get("id"),"at":now}},
|
|
250
|
+
{"kind":"state","property":"stage","value":"Customer"},
|
|
251
|
+
])
|
|
252
|
+
elif t == "customer.subscription.deleted":
|
|
253
|
+
_post(customer_email, [
|
|
254
|
+
{"kind":"event","property":"interaction.subscription_canceled",
|
|
255
|
+
"value":{"source":"stripe","stripe_subscription_id":sub.get("id"),"at":now}},
|
|
256
|
+
{"kind":"state","property":"stage","value":"Churned"},
|
|
257
|
+
])
|
|
258
|
+
|
|
259
|
+
def track(focus, property_, value=None):
|
|
260
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
261
|
+
_post(focus, [{"kind":"event","property":property_,"value":{**(value or {}),"at":now}}])
|
|
262
|
+
`,
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const NEXT_STEPS = {
|
|
266
|
+
"node-esm": `Next steps:
|
|
267
|
+
1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
|
|
268
|
+
2. Import { trackSignup } from "./nous.js" — call it right after you persist a new user.
|
|
269
|
+
3. Import { handleStripeEvent } in your Stripe webhook route — call it after verifying the signature.
|
|
270
|
+
4. Use track(email, "interaction.<your_event>", {...}) for anything else.`,
|
|
271
|
+
"node-cjs": `Next steps:
|
|
272
|
+
1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
|
|
273
|
+
2. const { trackSignup, handleStripeEvent, track } = require("./nous.js");
|
|
274
|
+
3. Call trackSignup() after persisting a new user; handleStripeEvent() inside your Stripe webhook.`,
|
|
275
|
+
next: `Next steps:
|
|
276
|
+
1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
|
|
277
|
+
2. Inside your signup API route, await trackSignup({ email, ... }) after the user row is written.
|
|
278
|
+
3. Inside app/api/webhooks/stripe/route.ts (or pages/api/webhooks/stripe.ts), await handleStripeEvent(event, { customerEmail }) after Stripe signature verification.`,
|
|
279
|
+
python: `Next steps:
|
|
280
|
+
1. Set NOUS_API_KEY in your .env (see https://app.opennous.cloud/integrations).
|
|
281
|
+
2. from nous import track_signup, handle_stripe_event, track — call track_signup() after persisting a new user.
|
|
282
|
+
3. Call handle_stripe_event(event, customer_email) inside your Stripe webhook view.`,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
program
|
|
286
|
+
.command("install")
|
|
287
|
+
.description("Drop Nous event tracking (signups + Stripe lifecycle) into the current project")
|
|
288
|
+
.option("--dir <path>", "Target project directory (default: cwd)", process.cwd())
|
|
289
|
+
.option("--lang <stack>", "Force language/stack: next | node-esm | node-cjs | python")
|
|
290
|
+
.option("--key <key>", "API key (otherwise prompted)")
|
|
291
|
+
.option("--yes", "Skip prompts and use defaults", false)
|
|
292
|
+
.action(async ({ dir, lang, key, yes }) => {
|
|
293
|
+
const root = resolve(dir);
|
|
294
|
+
if (!existsSync(root)) {
|
|
295
|
+
console.error(`Directory not found: ${root}`);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const detected = lang || detectStack(root);
|
|
300
|
+
const stack = (detected === "unknown") ? "node-esm" : detected;
|
|
301
|
+
const templateKey = stack === "next" ? "node-esm" : (stack === "node-cjs" ? "node-esm" : stack);
|
|
302
|
+
const template = TEMPLATES[templateKey];
|
|
303
|
+
if (!template) {
|
|
304
|
+
console.error(`No template for stack: ${stack}. Use --lang next|node-esm|node-cjs|python.`);
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const outFile = stack === "python" ? "nous.py" : "nous.js";
|
|
309
|
+
const outPath = join(root, outFile);
|
|
310
|
+
|
|
311
|
+
if (existsSync(outPath) && !yes) {
|
|
312
|
+
const answer = await prompt(`${outFile} already exists. Overwrite? (y/N)`, "N");
|
|
313
|
+
if (!answer.toLowerCase().startsWith("y")) {
|
|
314
|
+
console.log("Aborted.");
|
|
315
|
+
process.exit(0);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let apiKey = key || process.env.NOUS_API_KEY || readConfig().apiKey;
|
|
320
|
+
if (!apiKey && !yes) {
|
|
321
|
+
apiKey = await prompt("Paste your Nous API key (or press Enter to skip and set NOUS_API_KEY later):", "");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
writeFileSync(outPath, template);
|
|
325
|
+
|
|
326
|
+
// Append NOUS_API_KEY to .env if not already there.
|
|
327
|
+
const envPath = join(root, ".env");
|
|
328
|
+
let envTouched = false;
|
|
329
|
+
if (existsSync(envPath)) {
|
|
330
|
+
const env = readFileSync(envPath, "utf8");
|
|
331
|
+
if (!/^NOUS_API_KEY\s*=/m.test(env)) {
|
|
332
|
+
appendFileSync(envPath, `\n# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey || ""}\n`);
|
|
333
|
+
envTouched = true;
|
|
334
|
+
}
|
|
335
|
+
} else if (apiKey) {
|
|
336
|
+
writeFileSync(envPath, `# Nous event tracking (https://app.opennous.cloud)\nNOUS_API_KEY=${apiKey}\n`);
|
|
337
|
+
envTouched = true;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log(`\n✓ Wrote ${outFile}`);
|
|
341
|
+
if (envTouched) console.log(`✓ Updated .env`);
|
|
342
|
+
console.log(`\nDetected stack: ${stack}`);
|
|
343
|
+
console.log(`\n${NEXT_STEPS[stack] || NEXT_STEPS["node-esm"]}\n`);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// nous auth
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
program
|
|
350
|
+
.command("auth")
|
|
351
|
+
.description("Manage authentication")
|
|
352
|
+
.addCommand(
|
|
353
|
+
new Command("login")
|
|
354
|
+
.description("Save your API key")
|
|
355
|
+
.requiredOption("--key <key>", "Your Nous API key")
|
|
356
|
+
.option("--url <url>", "API base URL (default: https://api.opennous.cloud)")
|
|
357
|
+
.action(({ key, url }) => {
|
|
358
|
+
const cfg = readConfig();
|
|
359
|
+
cfg.apiKey = key;
|
|
360
|
+
if (url) cfg.apiUrl = url;
|
|
361
|
+
writeConfig(cfg);
|
|
362
|
+
console.log("✓ Authenticated. Run `nous attention` to verify.");
|
|
363
|
+
})
|
|
364
|
+
)
|
|
365
|
+
.addCommand(
|
|
366
|
+
new Command("status")
|
|
367
|
+
.description("Show current auth status")
|
|
368
|
+
.action(() => {
|
|
369
|
+
const cfg = readConfig();
|
|
370
|
+
const key = process.env.NOUS_API_KEY || cfg.apiKey;
|
|
371
|
+
const url = process.env.NOUS_API_URL || cfg.apiUrl || "https://api.opennous.cloud";
|
|
372
|
+
if (key) {
|
|
373
|
+
console.log(`Logged in\nAPI URL: ${url}\nKey: ${key.slice(0, 8)}...`);
|
|
374
|
+
} else {
|
|
375
|
+
console.log("Not logged in. Run: nous auth login --key <your-key>");
|
|
376
|
+
}
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// nous context <focus> — engineered, intent-shaped context for a task
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
program
|
|
384
|
+
.command("context <focus>")
|
|
385
|
+
.description("Engineered context for a task about one person or company")
|
|
386
|
+
.option(
|
|
387
|
+
"--intent <intent>",
|
|
388
|
+
"draft_email | follow_up | meeting_prep | call_prep | account_review",
|
|
389
|
+
"account_review"
|
|
390
|
+
)
|
|
391
|
+
.option("--budget <tokens>", "Token budget for the assembled context")
|
|
392
|
+
.option("--json", "Print the raw JSON response")
|
|
393
|
+
.action(async (focus, { intent, budget, json }) => {
|
|
394
|
+
const api = apiClient();
|
|
395
|
+
const ctx = await api.post("/v2/context", {
|
|
396
|
+
focus,
|
|
397
|
+
intent,
|
|
398
|
+
budget_tokens: budget ? Number(budget) : undefined,
|
|
399
|
+
});
|
|
400
|
+
if (handleAmbiguous(ctx)) return;
|
|
401
|
+
if (json) {
|
|
402
|
+
console.log(JSON.stringify(ctx, null, 2));
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const lines = [`${ctx.entity.type} ${ctx.entity.id} · intent: ${ctx.intent}`];
|
|
406
|
+
if (ctx.summary) lines.push(`\n${ctx.summary}`);
|
|
407
|
+
if (ctx.claims?.length) {
|
|
408
|
+
lines.push(`\nClaims:`);
|
|
409
|
+
ctx.claims.forEach(c => {
|
|
410
|
+
const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
|
|
411
|
+
lines.push(
|
|
412
|
+
` ${c.property} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
|
|
413
|
+
`conf ${Math.round((c.confidence ?? 0) * 100)}%)`
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (ctx.timeline?.length) {
|
|
418
|
+
lines.push(`\nTimeline:`);
|
|
419
|
+
ctx.timeline.slice(0, 8).forEach(t => {
|
|
420
|
+
const when = t.when ? new Date(t.when).toLocaleDateString() : "";
|
|
421
|
+
const detail = t.summary ? `: ${t.summary}` : t.count ? ` ×${t.count}` : "";
|
|
422
|
+
lines.push(` ${when} — ${t.type}${detail}`);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
if (ctx.stakeholders?.length) {
|
|
426
|
+
lines.push(`\nStakeholders:`);
|
|
427
|
+
ctx.stakeholders.forEach(s =>
|
|
428
|
+
lines.push(` ${s.name || s.entity_id}${s.role ? ` — ${s.role}` : ""}`)
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
lines.push(`\n~${ctx.meta?.token_estimate ?? "?"} tokens`);
|
|
432
|
+
console.log(lines.join("\n"));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// nous account <focus> — the full record: every claim + its epistemics
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
program
|
|
439
|
+
.command("account <focus>")
|
|
440
|
+
.description("The full account record — every claim with its epistemics + timeline")
|
|
441
|
+
.option("--json", "Print the raw JSON response")
|
|
442
|
+
.action(async (focus, { json }) => {
|
|
443
|
+
const api = apiClient();
|
|
444
|
+
const rec = await api.get(`/v2/accounts/${encodeURIComponent(focus)}`);
|
|
445
|
+
if (handleAmbiguous(rec)) return;
|
|
446
|
+
if (json) {
|
|
447
|
+
console.log(JSON.stringify(rec, null, 2));
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const lines = [`${rec.type} ${rec.entity_id}`];
|
|
451
|
+
const claims = Object.entries(rec.claims ?? {});
|
|
452
|
+
if (claims.length) {
|
|
453
|
+
lines.push(`\nClaims:`);
|
|
454
|
+
claims.forEach(([prop, c]) => {
|
|
455
|
+
const val = typeof c.value === "object" ? JSON.stringify(c.value) : c.value;
|
|
456
|
+
lines.push(
|
|
457
|
+
` ${prop} = ${val} (${c.epistemic_class}, ${c.freshness}, ` +
|
|
458
|
+
`conf ${Math.round((c.confidence ?? 0) * 100)}%)`
|
|
459
|
+
);
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
const obs = rec.recent_observations ?? [];
|
|
463
|
+
if (obs.length) {
|
|
464
|
+
lines.push(`\nRecent observations:`);
|
|
465
|
+
obs.slice(0, 10).forEach(o => {
|
|
466
|
+
const when = o.observed_at ? new Date(o.observed_at).toLocaleDateString() : "";
|
|
467
|
+
lines.push(` ${when} — [${o.kind}] ${o.property} (${o.source})`);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
console.log(lines.join("\n"));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// nous record <focus> — observe what happened; Nous derives the facts
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
program
|
|
477
|
+
.command("record <focus>")
|
|
478
|
+
.description("Record an observation. You observe — Nous derives the claims.")
|
|
479
|
+
.requiredOption("--kind <kind>", "event | state")
|
|
480
|
+
.requiredOption("--property <property>", "e.g. interaction.email_sent or job_title")
|
|
481
|
+
.option("--value <value>", "The event detail or fact value (JSON or string)")
|
|
482
|
+
.option("--source <source>", "Where this came from", "cli")
|
|
483
|
+
.option("--method <method>", "How it was observed")
|
|
484
|
+
.option("--observed-at <iso>", "When it was observed (ISO 8601)")
|
|
485
|
+
.action(async (focus, opts) => {
|
|
486
|
+
const api = apiClient();
|
|
487
|
+
const observation = {
|
|
488
|
+
kind: opts.kind,
|
|
489
|
+
property: opts.property,
|
|
490
|
+
value: parseValue(opts.value),
|
|
491
|
+
source: opts.source,
|
|
492
|
+
method: opts.method,
|
|
493
|
+
observed_at: opts.observedAt,
|
|
494
|
+
};
|
|
495
|
+
const result = await api.post("/v2/observations", { focus, observations: [observation] });
|
|
496
|
+
if (handleAmbiguous(result)) return;
|
|
497
|
+
const recomputed = result.claims_recomputed ?? [];
|
|
498
|
+
console.log(
|
|
499
|
+
`✓ Recorded ${result.recorded} observation(s) for ${result.entity_id}` +
|
|
500
|
+
(recomputed.length ? `\n Claims recomputed: ${recomputed.join(", ")}` : "")
|
|
501
|
+
);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
// ---------------------------------------------------------------------------
|
|
505
|
+
// nous query — retrieve and summarise a corpus of activity across many people
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
program
|
|
508
|
+
.command("query")
|
|
509
|
+
.description("Retrieve and summarise activity across many people")
|
|
510
|
+
.option("--kind <kind>", "event | state")
|
|
511
|
+
.option("--property <prefix>", "Property prefix — e.g. interaction.linkedin")
|
|
512
|
+
.option("--source <source>", "Filter by source")
|
|
513
|
+
.option("--entity <id>", "Restrict to one entity")
|
|
514
|
+
.option("--since <days>", "Look back this many days")
|
|
515
|
+
.option("--limit <n>", "Max results", "20")
|
|
516
|
+
.option("--question <text>", "A question — switches to semantic retrieval")
|
|
517
|
+
.option("--json", "Print the raw JSON response")
|
|
518
|
+
.action(async opts => {
|
|
519
|
+
const api = apiClient();
|
|
520
|
+
const scope = {
|
|
521
|
+
kind: opts.kind,
|
|
522
|
+
property: opts.property,
|
|
523
|
+
source: opts.source,
|
|
524
|
+
entity_id: opts.entity,
|
|
525
|
+
since_days: opts.since ? Number(opts.since) : undefined,
|
|
526
|
+
limit: opts.limit ? Number(opts.limit) : undefined,
|
|
527
|
+
};
|
|
528
|
+
const data = await api.post("/v2/query", { scope, question: opts.question });
|
|
529
|
+
if (opts.json) {
|
|
530
|
+
console.log(JSON.stringify(data, null, 2));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const items = data.items ?? [];
|
|
534
|
+
if (!items.length) {
|
|
535
|
+
console.log("No matching activity.");
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
items.forEach(it => {
|
|
539
|
+
const when = it.when ? new Date(it.when).toLocaleDateString() : "";
|
|
540
|
+
const who = it.entity_name || it.entity_id;
|
|
541
|
+
const sim = it.similarity != null ? ` ${Math.round(it.similarity * 100)}%` : "";
|
|
542
|
+
console.log(` ${when} — ${who} — ${it.type}${sim}${it.summary ? `: ${it.summary}` : ""}`);
|
|
543
|
+
});
|
|
544
|
+
console.log(
|
|
545
|
+
`\n${data.returned} of ${data.matched} (${data.mode})` +
|
|
546
|
+
(data.sampled ? " — sampled" : "")
|
|
547
|
+
);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// nous attention — what needs attention across the workspace
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
program
|
|
554
|
+
.command("attention")
|
|
555
|
+
.description("Accounts gone quiet, facts decayed — ranked, with suggested actions")
|
|
556
|
+
.option("--limit <n>", "Max results", "20")
|
|
557
|
+
.option("--json", "Print the raw JSON response")
|
|
558
|
+
.action(async ({ limit, json }) => {
|
|
559
|
+
const api = apiClient();
|
|
560
|
+
const data = await api.get("/v2/attention", { limit });
|
|
561
|
+
if (json) {
|
|
562
|
+
console.log(JSON.stringify(data, null, 2));
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
const items = data.items ?? [];
|
|
566
|
+
if (!items.length) {
|
|
567
|
+
console.log("Nothing needs attention. ✓");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
items.forEach(it => {
|
|
571
|
+
const who = it.entity_name || it.entity_id;
|
|
572
|
+
console.log(` [${it.kind}] ${who} — ${it.what} (${it.age_days}d)`);
|
|
573
|
+
console.log(` → ${it.suggested_action}`);
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
// nous verify <focus> <property> — re-check a claim before acting on it
|
|
579
|
+
// ---------------------------------------------------------------------------
|
|
580
|
+
program
|
|
581
|
+
.command("verify <focus> <property>")
|
|
582
|
+
.description("Re-check a claim before acting on it — the calibration check")
|
|
583
|
+
.action(async (focus, property) => {
|
|
584
|
+
const api = apiClient();
|
|
585
|
+
const r = await api.post("/v2/verify", { focus, property });
|
|
586
|
+
if (handleAmbiguous(r)) return;
|
|
587
|
+
const fmt = c =>
|
|
588
|
+
c
|
|
589
|
+
? `${typeof c.value === "object" ? JSON.stringify(c.value) : c.value} ` +
|
|
590
|
+
`(${c.freshness}, conf ${Math.round((c.confidence ?? 0) * 100)}%)`
|
|
591
|
+
: "—";
|
|
592
|
+
console.log(`${r.property}`);
|
|
593
|
+
console.log(` before: ${fmt(r.before)}`);
|
|
594
|
+
console.log(` after: ${fmt(r.after)}`);
|
|
595
|
+
if (r.note) console.log(` ${r.note}`);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ---------------------------------------------------------------------------
|
|
599
|
+
// nous classify — pre-flight cold-outbound dedup
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
program
|
|
602
|
+
.command("classify")
|
|
603
|
+
.description("Pre-flight dedup a list of emails and/or LinkedIn URLs against the workspace's engagement history")
|
|
604
|
+
.option("--emails <list>", "Comma-separated emails")
|
|
605
|
+
.option("--linkedin <list>", "Comma-separated LinkedIn URLs")
|
|
606
|
+
.option("--file <path>", "Path to any text/CSV — both emails and LinkedIn URLs are auto-extracted")
|
|
607
|
+
.option("--json", "Print the raw JSON response")
|
|
608
|
+
.action(async ({ emails, linkedin, file, json }) => {
|
|
609
|
+
const api = apiClient();
|
|
610
|
+
const EMAIL_RE = /[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}/gi;
|
|
611
|
+
const LINKEDIN_RE = /https?:\/\/(?:www\.)?linkedin\.com\/in\/[a-zA-Z0-9_\-%]+/gi;
|
|
612
|
+
|
|
613
|
+
let emailList = [];
|
|
614
|
+
let linkedinList = [];
|
|
615
|
+
|
|
616
|
+
if (file) {
|
|
617
|
+
const { readFileSync } = await import("fs");
|
|
618
|
+
const text = readFileSync(file, "utf8");
|
|
619
|
+
emailList = text.match(EMAIL_RE) || [];
|
|
620
|
+
linkedinList = text.match(LINKEDIN_RE) || [];
|
|
621
|
+
}
|
|
622
|
+
if (emails) emailList = emailList.concat(emails.split(/[\s,]+/).filter(Boolean));
|
|
623
|
+
if (linkedin) linkedinList = linkedinList.concat(linkedin.split(/[\s,]+/).filter(Boolean));
|
|
624
|
+
|
|
625
|
+
emailList = [...new Set(emailList.map(e => e.toLowerCase().trim()))].filter(Boolean);
|
|
626
|
+
linkedinList = [...new Set(linkedinList.map(u => u.trim()))].filter(Boolean);
|
|
627
|
+
|
|
628
|
+
if (!emailList.length && !linkedinList.length) {
|
|
629
|
+
console.error("Pass at least one of --emails, --linkedin, or --file");
|
|
630
|
+
process.exit(1);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const body = {};
|
|
634
|
+
if (emailList.length) body.emails = emailList;
|
|
635
|
+
if (linkedinList.length) body.linkedin_urls = linkedinList;
|
|
636
|
+
|
|
637
|
+
const data = await api.post("/v2/dedup", body);
|
|
638
|
+
if (json) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
639
|
+
const s = data.summary || {};
|
|
640
|
+
const pad = (n, w = 6) => String(n).padStart(w);
|
|
641
|
+
console.log(`\n ${pad(s.total)} total (${emailList.length} emails, ${linkedinList.length} linkedin)`);
|
|
642
|
+
console.log(` ${pad(s.net_new)} net_new ← safe to send / safe to buy`);
|
|
643
|
+
console.log(` ${pad(s.engaged)} engaged skip (in an active convo)`);
|
|
644
|
+
console.log(` ${pad(s.recent)} recent defer (contacted in last 30d)`);
|
|
645
|
+
console.log(` ${pad(s.bounced)} bounced skip`);
|
|
646
|
+
console.log(` ${pad(s.unsubscribed)} unsubscribed skip`);
|
|
647
|
+
console.log(` ${pad(s.suppressed)} suppressed skip (workspace policy)\n`);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
// ---------------------------------------------------------------------------
|
|
651
|
+
program.name("nous").version("0.3.0").parse();
|