useathena 0.2.0 → 0.3.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 CHANGED
@@ -107,11 +107,23 @@ Implemented:
107
107
  (`athena serve install`, done by setup) so capture survives reboots.
108
108
  - Chrome extension v0.1 for LinkedIn and Gmail draft-edit capture.
109
109
  - `athena export`: one JSON bundle of everything learned (`--redact` for sharing).
110
+ - Knowledge connectors — Notion, Slack, and Google (Drive + Gmail):
111
+ `athena connect <provider>` is a guided flow, also offered during onboarding.
112
+ Notion uses an internal integration token (the pages you share are the
113
+ privacy boundary); Slack uses an app created from a printed manifest
114
+ (read-only scopes, public channels you are a member of); Google is a
115
+ gcloud-style browser sign-in (loopback + PKCE, read-only Drive docs and sent
116
+ mail — expect the "unverified app" interstitial during friend testing).
117
+ Synced content becomes cited, searchable sources that feed briefs; re-sync
118
+ runs every few hours inside the serve service, or on demand via `athena sync`.
110
119
  - One-command onboarding: `npx useathena onboard --yes` (npm package `useathena`,
111
120
  binary `athena`; GitHub installs work too).
112
121
 
113
122
  Still rough or planned:
114
123
 
124
+ - Fact extraction from connected sources with stated-vs-observed provenance.
125
+ - Google consent-screen verification (today: unverified-app interstitial,
126
+ 100-user cap — fine for friend testing).
115
127
  - Chrome Web Store extension (today: load-unpacked from the installed package).
116
128
  - Chrome extension selector hardening and browser automation smoke tests.
117
129
  - Embeddings or semantic search, if lexical search stops being enough.
@@ -161,9 +173,15 @@ Onboarding does everything: creates the local store, detects which model provide
161
173
  you have (claude CLI, codex CLI, Anthropic or OpenAI API keys, a running Ollama),
162
174
  verifies the one you pick with a real inference call, installs itself globally so
163
175
  hooks survive the npx cache, wires up Claude Code (sensor hook + MCP registration),
164
- installs the browser-sensor API as a login service, and prints the extension setup
165
- with its token. `--yes` takes the first detected provider and says yes to all of it;
166
- opt out per-piece with `--no-hook`, `--no-service`, `--skip-test`, or `--model <spec>`.
176
+ installs the browser-sensor API as a login service, offers to connect a knowledge
177
+ source (Notion so briefs have real facts on day one), and prints the extension
178
+ setup with its token. `--yes` takes the first detected provider and says yes to all
179
+ of it (knowledge sources need a token paste, so non-interactive runs print the
180
+ `athena connect` pointer instead); opt out per-piece with `--no-hook`,
181
+ `--no-service`, `--no-connect`, `--skip-test`, or `--model <spec>`.
182
+
183
+ Already installed? `athena update` pulls the latest published version and
184
+ restarts the background serve service on the new code.
167
185
 
168
186
  Model providers are spec strings, set once by `setup` (or per-run via `ATHENA_MODEL`):
169
187
 
@@ -40,6 +40,11 @@ export function cmdStatus(store) {
40
40
  if (counts.facts > 0) {
41
41
  lines.splice(3, 0, ` ${bold(String(counts.facts))} fact${counts.facts === 1 ? "" : "s"} about ${bold(String(store.listObjects().length))} entities`);
42
42
  }
43
+ const connections = store.listConnections();
44
+ if (counts.sources > 0 || connections.length > 0) {
45
+ const from = connections.length > 0 ? ` from ${connections.map((c) => c.provider).join(", ")}` : "";
46
+ lines.push(` ${bold(String(counts.sources))} source${counts.sources === 1 ? "" : "s"} synced${from} ${dim("(athena connect to add more)")}`);
47
+ }
43
48
  if (counts.unmatchedDrafts > 0) {
44
49
  lines.push(` ${bold(String(counts.unmatchedDrafts))} agent draft${counts.unmatchedDrafts === 1 ? "" : "s"} awaiting an observed outcome`);
45
50
  }
@@ -0,0 +1,120 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { newId } from "../core/ids.js";
3
+ import { connectors, syncConnection } from "../connect/sync.js";
4
+ import { deleteSecret, saveSecret } from "../connect/secrets.js";
5
+ import { bold, dim, green, red, yellow } from "./format.js";
6
+ /** `athena connect [provider]` — guided token-paste connect; no args lists connections. */
7
+ export async function runConnect(store, args) {
8
+ const provider = args.find((a) => !a.startsWith("--"));
9
+ if (!provider) {
10
+ console.log(renderConnections(store));
11
+ return;
12
+ }
13
+ if (!connectors[provider]) {
14
+ throw new Error(`unknown provider: ${provider} — available: ${Object.keys(connectors).join(", ")}`);
15
+ }
16
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
17
+ try {
18
+ await connectProvider(store, provider, rl);
19
+ }
20
+ finally {
21
+ rl.close();
22
+ }
23
+ }
24
+ /**
25
+ * The guided connect flow, on a caller-owned readline so the setup wizard can
26
+ * embed it (two open readline interfaces on one stdin double-echo keystrokes).
27
+ * A failed validation re-prompts — a typo'd paste is common; with `allowSkip`,
28
+ * an empty paste backs out instead of erroring.
29
+ */
30
+ export async function connectProvider(store, provider, rl, { allowSkip = false } = {}) {
31
+ const connector = connectors[provider];
32
+ console.log(`${bold(`Connect ${connector.label}`)}\n`);
33
+ connector.instructions.forEach((step, i) => console.log(` ${i + 1}. ${step}`));
34
+ if (connector.extra) {
35
+ console.log(`\n${connector.extra.split("\n").map((line) => ` ${line}`).join("\n")}`);
36
+ }
37
+ const acquired = connector.acquire
38
+ ? await connector.acquire(rl, (line) => console.log(dim(` ${line}`)))
39
+ : await pasteToken(connector, provider, rl, allowSkip);
40
+ if (!acquired)
41
+ return false;
42
+ const existing = store.listConnections(provider)[0];
43
+ const connection = existing
44
+ ? { ...existing, label: acquired.label }
45
+ : { id: newId("con"), provider, label: acquired.label, createdAt: new Date().toISOString() };
46
+ saveSecret(connection.id, acquired.secret);
47
+ store.saveConnection(connection);
48
+ console.log(dim("first sync…"));
49
+ const result = await syncConnection(store, connection, (line) => console.log(dim(` ${line}`)));
50
+ console.log(`${green("✓")} connected ${bold(acquired.label)} — ${bold(String(result.added))} sources synced` +
51
+ (result.updated || result.unchanged ? dim(` (${result.updated} updated, ${result.unchanged} unchanged)`) : ""));
52
+ console.log(dim(`new and changed content arrives on the next sync (athena sync ${provider})`));
53
+ return true;
54
+ }
55
+ async function pasteToken(connector, provider, rl, allowSkip) {
56
+ while (true) {
57
+ const skipHint = allowSkip ? dim(", Enter to skip") : "";
58
+ const token = (await rl.question(`\nPaste the token (${dim(connector.tokenHint ?? "")}${skipHint}): `)).trim();
59
+ if (!token) {
60
+ if (!allowSkip)
61
+ throw new Error("no token given — nothing connected");
62
+ console.log(dim(`skipped — connect later with: athena connect ${provider}`));
63
+ return undefined;
64
+ }
65
+ process.stdout.write(dim("checking the token… "));
66
+ try {
67
+ const label = await connector.validate(token);
68
+ console.log(`${green("✓")} ${label}`);
69
+ return { secret: token, label };
70
+ }
71
+ catch (error) {
72
+ console.log(red(`✗ ${error instanceof Error ? error.message : String(error)}`));
73
+ }
74
+ }
75
+ }
76
+ export async function runDisconnect(store, args) {
77
+ const provider = args.find((a) => !a.startsWith("--"));
78
+ if (!provider)
79
+ throw new Error("usage: athena disconnect <provider>");
80
+ const connection = store.listConnections(provider)[0];
81
+ if (!connection) {
82
+ console.log(yellow(`no ${provider} connection found`));
83
+ return;
84
+ }
85
+ deleteSecret(connection.id);
86
+ store.deleteConnection(connection.id);
87
+ console.log(`${green("✓")} disconnected ${connection.label} — synced sources stay (and remain searchable)`);
88
+ }
89
+ export async function runSync(store, args) {
90
+ const provider = args.find((a) => !a.startsWith("--"));
91
+ const targets = provider ? store.listConnections(provider) : store.listConnections();
92
+ if (targets.length === 0) {
93
+ console.log(yellow(provider ? `no ${provider} connection — run: athena connect ${provider}` : "no connections yet — run: athena connect notion"));
94
+ return;
95
+ }
96
+ for (const connection of targets) {
97
+ console.log(`${bold(connection.provider)} ${dim(connection.label)} …`);
98
+ try {
99
+ const result = await syncConnection(store, connection, (line) => console.log(dim(` ${line}`)));
100
+ console.log(`${green("✓")} +${result.added} new, ${result.updated} updated, ${result.unchanged} unchanged`);
101
+ }
102
+ catch (error) {
103
+ console.log(red(`✗ ${error instanceof Error ? error.message : String(error)}`));
104
+ process.exitCode = 1;
105
+ }
106
+ }
107
+ }
108
+ export function renderConnections(store) {
109
+ const connections = store.listConnections();
110
+ if (connections.length === 0) {
111
+ return `no connections yet — available: ${Object.keys(connectors).join(", ")}\nconnect one with: ${bold("athena connect notion")}`;
112
+ }
113
+ return connections
114
+ .map((c) => {
115
+ const synced = c.lastSyncedAt ? `last sync ${c.lastSyncedAt.slice(0, 16).replace("T", " ")}` : "never synced";
116
+ const error = c.lastError ? ` ${red(`✗ ${c.lastError.slice(0, 80)}`)}` : "";
117
+ return `${bold(c.provider)} ${c.label} ${dim(synced)}${error}`;
118
+ })
119
+ .join("\n");
120
+ }
@@ -57,6 +57,30 @@ export function hitRate(upheld, overridden) {
57
57
  const text = `${upheld}/${total} upheld`;
58
58
  return rate >= 0.7 ? green(text) : rate >= 0.4 ? yellow(text) : red(text);
59
59
  }
60
+ /** The wizard's opening: a banner (figlet "ANSI Shadow"), the version, and the one-line promise. */
61
+ export function banner(version) {
62
+ const art = [
63
+ " █████╗ ████████╗██╗ ██╗███████╗███╗ ██╗ █████╗ ",
64
+ "██╔══██╗╚══██╔══╝██║ ██║██╔════╝████╗ ██║██╔══██╗",
65
+ "███████║ ██║ ███████║█████╗ ██╔██╗ ██║███████║",
66
+ "██╔══██║ ██║ ██╔══██║██╔══╝ ██║╚██╗██║██╔══██║",
67
+ "██║ ██║ ██║ ██║ ██║███████╗██║ ╚████║██║ ██║",
68
+ "╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝",
69
+ ];
70
+ return [
71
+ "",
72
+ ...art.map((line) => ` ${cyan(line)}`),
73
+ "",
74
+ ` agents that learn the tacit knowledge behind how you work ${dim(`v${version}`)}`,
75
+ "",
76
+ ].join("\n");
77
+ }
78
+ /** A section divider: `── title · 2/4 ─────…` — structure without a TUI dependency. */
79
+ export function section(title, hint = "", step = "") {
80
+ const label = `── ${title} ${step ? `· ${step} ` : ""}`;
81
+ const rule = "─".repeat(Math.max(0, 56 - label.length));
82
+ return `\n${dim("──")} ${bold(title)}${step ? dim(` · ${step}`) : ""} ${dim(rule)}${hint ? `\n${dim(` ${hint}`)}` : ""}`;
83
+ }
60
84
  export function wrap(text, indent, width = 88) {
61
85
  const words = text.split(/\s+/);
62
86
  const lines = [];
@@ -43,6 +43,13 @@ Restart=always
43
43
  WantedBy=default.target
44
44
  `;
45
45
  }
46
+ export function serviceInstalled(platform = process.platform) {
47
+ if (platform === "darwin")
48
+ return existsSync(join(homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`));
49
+ if (platform === "linux")
50
+ return existsSync(join(homedir(), ".config", "systemd", "user", SYSTEMD_UNIT));
51
+ return false;
52
+ }
46
53
  export function installService(athenaBin, platform = process.platform) {
47
54
  const logPath = join(dirname(dbPath()), "serve.log");
48
55
  if (platform === "darwin") {
package/dist/cli/setup.js CHANGED
@@ -6,22 +6,27 @@ import { createInterface } from "node:readline/promises";
6
6
  import { configPath, dbPath, loadConfig, saveConfig } from "../config.js";
7
7
  import { loadOrCreateServeToken } from "../api/server.js";
8
8
  import { DEFAULT_ANTHROPIC_MODEL, modelClientFromSpec, SUPPORTED_SPECS } from "../model/registry.js";
9
+ import { connectors } from "../connect/sync.js";
9
10
  import { installService } from "./service.js";
10
- import { bold, cyan, dim, green, red, yellow } from "./format.js";
11
+ import { connectProvider } from "./connect.js";
12
+ import { readPackageInfo } from "./update.js";
13
+ import { banner, bold, cyan, dim, green, red, section, yellow } from "./format.js";
11
14
  /**
12
15
  * First-run wizard (aliases: setup, onboard): pick a model provider, prove it
13
- * answers, wire up the sensors. `npx useathena onboard --yes` is the one-command
14
- * path: it accepts every default, including a global self-install when running
15
- * from the npx cache (hooks and MCP registrations must outlive cache pruning).
16
- * Granular escape hatches: --model <spec>, --hook/--no-hook, --no-service, --skip-test.
16
+ * answers, wire up the sensors, offer a knowledge source. `npx useathena onboard
17
+ * --yes` is the one-command path: it accepts every default, including a global
18
+ * self-install when running from the npx cache (hooks and MCP registrations must
19
+ * outlive cache pruning). Granular escape hatches: --model <spec>, --hook/--no-hook,
20
+ * --no-service, --no-connect, --skip-test.
17
21
  */
18
22
  const OLLAMA_TAGS_URL = "http://127.0.0.1:11434/api/tags";
19
- export async function runSetup(args, packageRoot) {
23
+ export async function runSetup(args, packageRoot, store) {
20
24
  const interactive = process.stdin.isTTY === true && !args.includes("--yes");
21
25
  const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : null;
22
26
  try {
23
- console.log(`${bold("athena setup")}\n`);
27
+ console.log(banner(readPackageInfo(packageRoot).version));
24
28
  console.log(`${green("✓")} store ready at ${bold(dbPath())}`);
29
+ console.log(section("model", "powers rule inference — capture works without it", "1/4"));
25
30
  const config = loadConfig();
26
31
  const spec = await chooseModelSpec(args, config, rl);
27
32
  config.model = spec;
@@ -31,14 +36,16 @@ export async function runSetup(args, packageRoot) {
31
36
  await testModel(spec, config);
32
37
  }
33
38
  const home = await ensureDurableInstall(rl, packageRoot);
34
- const mcpRegistered = await offerClaudeCode(args, rl, home.bin);
35
- if (!mcpRegistered) {
36
- console.log(`\n${bold("connect your agent (MCP):")}`);
39
+ console.log(section("your agent", "how agents read athena's briefs and record what happens", "2/4"));
40
+ const claude = await offerClaudeCode(args, rl, home.bin);
41
+ if (!claude.mcp) {
42
+ console.log(`${bold("connect your agent (MCP):")}`);
37
43
  console.log(` claude mcp add --scope user athena -- ${home.bin} mcp`);
38
44
  console.log(dim(` (other MCP clients: stdio command "${home.bin} mcp")`));
39
45
  }
46
+ console.log(section("background capture", "sensors watch for judgment moments while you work", "3/4"));
40
47
  const serviceRunning = await offerServeService(args, rl, home.bin);
41
- console.log(`\n${bold("browser sensor")} ${dim("(LinkedIn and Gmail draft edits)")}:`);
48
+ console.log(`${bold("browser sensor")} ${dim("(LinkedIn and Gmail draft edits)")}:`);
42
49
  console.log(` 1. chrome://extensions → Developer mode → Load unpacked → ${join(home.root, "apps", "chrome-extension")}`);
43
50
  if (serviceRunning) {
44
51
  console.log(` 2. extension options page → token ${bold(loadOrCreateServeToken())}`);
@@ -46,11 +53,22 @@ export async function runSetup(args, packageRoot) {
46
53
  else {
47
54
  console.log(` 2. run ${bold("athena serve")} and paste the printed token into the extension options page`);
48
55
  }
49
- console.log(`\n${bold("the loop:")} capture runs in the background — then`);
50
- console.log(` ${cyan("athena learn")} infer tacit rules from captured evidence`);
51
- console.log(` ${cyan("athena rules")} see what athena believes (and how confidently)`);
52
- console.log(` ${cyan('athena brief "task"')} the judgment an agent gets before acting`);
53
- console.log(dim(`\nrules serve themselves once replay-validated; they graduate to confident\nthrough upheld outcomes — or faster via: athena review`));
56
+ const knowledge = await offerConnectors(args, rl, store);
57
+ const recap = (ok, label, detail) => ` ${ok ? green("✓") : yellow("○")} ${label.padEnd(13)} ${ok ? detail : dim(detail)}`;
58
+ console.log(`\n${dim("".repeat(56))}`);
59
+ console.log(`${green("")} ${bold("athena is set up")}\n`);
60
+ console.log(recap(true, "model", spec));
61
+ console.log(recap(claude.hook || claude.mcp, "Claude Code", claude.mcp ? "sensor hook + MCP registered" : claude.hook ? "hook installed — MCP command above" : "athena hook install"));
62
+ console.log(recap(serviceRunning, "background", serviceRunning ? "serve service runs at login" : "athena serve install"));
63
+ console.log(recap(false, "browser", "load the Chrome extension — steps above (optional)"));
64
+ console.log(recap(knowledge.connected.length > 0, "knowledge", knowledge.connected.length > 0
65
+ ? `${knowledge.connected.join(", ")} connected` +
66
+ (knowledge.missing.length > 0 ? dim(` (more: athena connect <${knowledge.missing.join("|")}>)`) : "")
67
+ : `athena connect <${knowledge.missing.join("|")}>`));
68
+ console.log(`\n${bold("try it:")}`);
69
+ console.log(` ${cyan('athena brief "draft the weekly update"')} ${dim("what an agent gets before acting")}`);
70
+ console.log(` ${cyan("athena status")} ${dim("what athena has captured and learned")}`);
71
+ console.log(dim("\ncapture runs in the background; rules appear after real work and\ngraduate through upheld outcomes — or faster via: athena review"));
54
72
  }
55
73
  finally {
56
74
  rl?.close();
@@ -70,7 +88,6 @@ async function chooseModelSpec(args, config, rl) {
70
88
  }
71
89
  return auto.auto;
72
90
  }
73
- console.log(`\n${bold("model provider")} ${dim("— powers rule inference; capture works without it")}`);
74
91
  options.forEach((option, index) => {
75
92
  console.log(` ${bold(String(index + 1))}. ${option.name} ${dim(option.hint)}`);
76
93
  });
@@ -92,6 +109,17 @@ async function chooseModelSpec(args, config, rl) {
92
109
  }
93
110
  async function buildProviderOptions(config) {
94
111
  const options = [];
112
+ // Returning users keep what they have by pressing Enter — re-running setup
113
+ // must never feel like reconfiguring from scratch.
114
+ if (config.model) {
115
+ const current = config.model;
116
+ options.push({
117
+ name: `keep ${current}`,
118
+ hint: "your current model — Enter keeps it",
119
+ auto: current,
120
+ resolve: async () => current,
121
+ });
122
+ }
95
123
  if (onPath("claude")) {
96
124
  options.push({
97
125
  name: "cli:claude",
@@ -229,7 +257,7 @@ async function offerServeService(args, rl, athenaBin) {
229
257
  return false;
230
258
  let wanted = args.includes("--yes");
231
259
  if (!wanted && rl) {
232
- const answer = (await rl.question(`\nRun the browser-sensor API at login? ${dim("(background service for athena serve)")} [Y/n] `))
260
+ const answer = (await rl.question(`Run the browser-sensor API at login? ${dim("(background service for athena serve)")} [Y/n] `))
233
261
  .trim()
234
262
  .toLowerCase();
235
263
  wanted = answer === "" || answer === "y" || answer === "yes";
@@ -240,40 +268,84 @@ async function offerServeService(args, rl, athenaBin) {
240
268
  console.log(result.installed ? `${green("✓")} ${result.message}` : yellow(` ${result.message}`));
241
269
  return result.installed;
242
270
  }
271
+ /**
272
+ * Knowledge sources fix the day-zero brief: without them athena knows nothing
273
+ * until the tacit loop warms up. Never blocking, never silent — declining (or
274
+ * running non-interactively, where a token paste is impossible) prints exactly
275
+ * how to connect later, and a re-run shows what is already connected.
276
+ */
277
+ async function offerConnectors(args, rl, store) {
278
+ const state = () => {
279
+ const connected = [...new Set(store.listConnections().map((c) => c.provider))];
280
+ return { connected, missing: Object.keys(connectors).filter((p) => !connected.includes(p)) };
281
+ };
282
+ if (args.includes("--no-connect"))
283
+ return state();
284
+ console.log(section("knowledge sources", "your docs and channels give briefs real facts on day one", "4/4"));
285
+ const existing = store.listConnections();
286
+ for (const connection of existing) {
287
+ console.log(`${green("✓")} ${connection.provider} connected — ${connection.label}`);
288
+ }
289
+ const { missing } = state();
290
+ if (missing.length === 0)
291
+ return state();
292
+ if (!rl) {
293
+ console.log(` connect later with ${bold(`athena connect <${missing.join("|")}>`)} ${dim("(guided, ~2 minutes each)")}`);
294
+ return state();
295
+ }
296
+ for (const provider of missing) {
297
+ const answer = (await rl.question(`Connect ${connectors[provider].label} now? ${dim(`(${connectors[provider].connectHint})`)} [Y/n] `))
298
+ .trim()
299
+ .toLowerCase();
300
+ if (answer !== "" && answer !== "y" && answer !== "yes") {
301
+ console.log(dim(` later: athena connect ${provider}`));
302
+ continue;
303
+ }
304
+ console.log("");
305
+ try {
306
+ await connectProvider(store, provider, rl, { allowSkip: true });
307
+ }
308
+ catch (error) {
309
+ console.log(red(`✗ connect failed: ${String(error instanceof Error ? error.message : error).slice(0, 200)}`));
310
+ console.log(dim(` retry later with: athena connect ${provider}`));
311
+ }
312
+ }
313
+ return state();
314
+ }
243
315
  /** Wire up Claude Code end to end: sensor hook + MCP registration. */
244
316
  async function offerClaudeCode(args, rl, athenaBin) {
245
317
  if (args.includes("--no-hook"))
246
- return false;
318
+ return { hook: false, mcp: false };
247
319
  const hasClaudeDir = existsSync(join(homedir(), ".claude"));
248
320
  let wanted = args.includes("--hook") || (args.includes("--yes") && hasClaudeDir);
249
321
  if (!wanted && rl && hasClaudeDir) {
250
- const answer = (await rl.question(`\nWire up Claude Code? ${dim("(sensor hook + MCP registration)")} [Y/n] `))
322
+ const answer = (await rl.question(`Wire up Claude Code? ${dim("(sensor hook + MCP registration)")} [Y/n] `))
251
323
  .trim()
252
324
  .toLowerCase();
253
325
  wanted = answer === "" || answer === "y" || answer === "yes";
254
326
  }
255
327
  if (!wanted)
256
- return false;
328
+ return { hook: false, mcp: false };
257
329
  const { settingsPath, changed } = installClaudeCodeHook(athenaBin);
258
330
  console.log(changed
259
331
  ? `${green("✓")} hook installed in ${settingsPath} ${dim("(restart Claude Code sessions to pick it up)")}`
260
332
  : `${green("✓")} hook already installed in ${settingsPath}`);
261
333
  if (!onPath("claude"))
262
- return false;
334
+ return { hook: true, mcp: false };
263
335
  const existing = spawnSync("claude", ["mcp", "get", "athena"], { stdio: "ignore" });
264
336
  if (existing.status === 0) {
265
337
  console.log(`${green("✓")} MCP server already registered with Claude Code`);
266
- return true;
338
+ return { hook: true, mcp: true };
267
339
  }
268
340
  const added = spawnSync("claude", ["mcp", "add", "--scope", "user", "athena", "--", athenaBin, "mcp"], {
269
341
  encoding: "utf8",
270
342
  });
271
343
  if (added.status === 0) {
272
344
  console.log(`${green("✓")} MCP server registered with Claude Code (scope: user)`);
273
- return true;
345
+ return { hook: true, mcp: true };
274
346
  }
275
347
  console.log(yellow(`could not register MCP automatically: ${(added.stderr ?? "").trim().slice(-200)}`));
276
- return false;
348
+ return { hook: true, mcp: false };
277
349
  }
278
350
  /**
279
351
  * Merge the UserPromptSubmit hook into ~/.claude/settings.json. Any previous
@@ -0,0 +1,81 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { installService, serviceInstalled } from "./service.js";
5
+ import { bold, dim, green, red, yellow } from "./format.js";
6
+ export function compareVersions(a, b) {
7
+ const left = a.split(".").map(Number);
8
+ const right = b.split(".").map(Number);
9
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
10
+ const diff = (left[i] ?? 0) - (right[i] ?? 0);
11
+ if (diff !== 0)
12
+ return Math.sign(diff);
13
+ }
14
+ return 0;
15
+ }
16
+ export function planUpdate(opts) {
17
+ if (opts.devCheckout)
18
+ return "dev-checkout";
19
+ return compareVersions(opts.current, opts.latest) < 0 ? "install" : "up-to-date";
20
+ }
21
+ export function readPackageInfo(packageRoot) {
22
+ const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
23
+ return { name: pkg.name, version: pkg.version };
24
+ }
25
+ export async function fetchLatestVersion(name, timeoutMs = 10_000) {
26
+ const response = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(timeoutMs) });
27
+ if (!response.ok)
28
+ throw new Error(`npm registry lookup failed: HTTP ${response.status}`);
29
+ const body = (await response.json());
30
+ if (!body.version)
31
+ throw new Error("npm registry reply had no version");
32
+ return body.version;
33
+ }
34
+ export function nudgeLine(current, latest) {
35
+ if (compareVersions(current, latest) >= 0)
36
+ return undefined;
37
+ return `\n${yellow("↑")} ${latest} is out (you run ${current}) — update with ${bold("athena update")}`;
38
+ }
39
+ /** One quiet line at the end of `athena status` when the registry is ahead; silent offline. */
40
+ export async function updateNudge(packageRoot) {
41
+ try {
42
+ const { name, version } = readPackageInfo(packageRoot);
43
+ return nudgeLine(version, await fetchLatestVersion(name, 1_500));
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ function globalAthenaBin() {
50
+ const prefix = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8" }).stdout?.trim();
51
+ if (!prefix)
52
+ return undefined;
53
+ const bin = join(prefix, "bin", "athena");
54
+ return existsSync(bin) ? bin : undefined;
55
+ }
56
+ export async function runUpdate(packageRoot, athenaBin) {
57
+ const { name, version } = readPackageInfo(packageRoot);
58
+ const latest = await fetchLatestVersion(name);
59
+ const plan = planUpdate({ current: version, latest, devCheckout: existsSync(join(packageRoot, ".git")) });
60
+ if (plan === "dev-checkout") {
61
+ console.log(`${yellow("dev checkout")} at ${packageRoot} — update with ${bold("git pull")} (running ${version}, registry has ${latest})`);
62
+ return;
63
+ }
64
+ if (plan === "up-to-date") {
65
+ console.log(`${green("✓")} athena ${version} is up to date`);
66
+ return;
67
+ }
68
+ console.log(dim(`updating ${name} ${version} → ${latest} (npm install -g)…`));
69
+ const install = spawnSync("npm", ["install", "-g", `${name}@latest`], { encoding: "utf8" });
70
+ if (install.status !== 0) {
71
+ console.log(red(`✗ update failed: ${(install.stderr ?? "").trim().slice(-300)}`));
72
+ console.log(yellow(` retry manually: npm install -g ${name}@latest`));
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ console.log(`${green("✓")} athena ${latest} installed`);
77
+ if (serviceInstalled()) {
78
+ const result = installService(globalAthenaBin() ?? athenaBin);
79
+ console.log(result.installed ? `${green("✓")} serve service restarted on ${latest}` : yellow(result.message));
80
+ }
81
+ }
package/dist/cli.js CHANGED
@@ -15,6 +15,9 @@ import { maybeAutoLearn } from "./serve/auto-learn.js";
15
15
  import { recordOutcome } from "./serve/outcome.js";
16
16
  import { loadConfig } from "./config.js";
17
17
  import { installClaudeCodeHook, runSetup } from "./cli/setup.js";
18
+ import { readPackageInfo, runUpdate, updateNudge } from "./cli/update.js";
19
+ import { runConnect, runDisconnect, runSync } from "./cli/connect.js";
20
+ import { syncDueConnections } from "./connect/sync.js";
18
21
  import { bold, dim, green, red, yellow } from "./cli/format.js";
19
22
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
20
23
  const athenaBin = join(packageRoot, "bin", "athena");
@@ -23,7 +26,7 @@ const HELP = `${bold("athena")} — agents that learn the tacit knowledge behind
23
26
  usage: athena <command> [args]
24
27
 
25
28
  ${bold("setup")} [--model spec] [--yes]
26
- first-run wizard: store, model provider, sensors, MCP
29
+ first-run wizard: store, model, sensors, MCP, knowledge sources
27
30
  (alias: onboard — npx useathena onboard --yes)
28
31
  ${bold("init")} create the local store and print MCP setup
29
32
  ${bold("status")} what athena has captured, learned, and served
@@ -39,10 +42,14 @@ usage: athena <command> [args]
39
42
  ${bold("record")} outcome <briefId> <uncorrected|corrected|abandoned>
40
43
  ${bold("record")} output <briefId> <text|@file> register an agent draft for automatic outcomes
41
44
  ${bold("export")} [--out f] [--redact] one JSON bundle of everything learned (for analysis)
45
+ ${bold("connect")} [provider] connect a knowledge source, guided (no args: list connections)
46
+ ${bold("disconnect")} <provider> remove a connection (already-synced sources stay)
47
+ ${bold("sync")} [provider] pull new and changed content from connected sources now
42
48
  ${bold("open")} <athena://ref | id> inspect any entity
43
49
  ${bold("hook")} install [--print] install the Claude Code sensor hook into ~/.claude/settings.json
44
50
  ${bold("serve")} [--port n] local API for browser sensors (default 127.0.0.1:4517)
45
51
  ${bold("serve")} install|uninstall run the API at login (launchd/systemd user service)
52
+ ${bold("update")} update to the latest published version (restarts serve)
46
53
  ${bold("mcp")} run the MCP stdio server (claude mcp add athena -- athena mcp)
47
54
 
48
55
  store: ${dbPath()} ${dim("(override with ATHENA_DB)")}
@@ -57,6 +64,10 @@ async function main() {
57
64
  console.log(HELP);
58
65
  return;
59
66
  }
67
+ if (command === "version" || command === "--version") {
68
+ console.log(readPackageInfo(packageRoot).version);
69
+ return;
70
+ }
60
71
  if (command === "hook") {
61
72
  await runHook(args);
62
73
  return;
@@ -66,7 +77,7 @@ async function main() {
66
77
  switch (command) {
67
78
  case "setup":
68
79
  case "onboard": {
69
- await runSetup(args, packageRoot);
80
+ await runSetup(args, packageRoot, store);
70
81
  break;
71
82
  }
72
83
  case "init": {
@@ -76,9 +87,13 @@ async function main() {
76
87
  console.log(dim(`\nfor the guided version (model provider, sensors): athena setup`));
77
88
  break;
78
89
  }
79
- case "status":
90
+ case "status": {
80
91
  console.log(cmdStatus(store));
92
+ const nudge = await updateNudge(packageRoot);
93
+ if (nudge)
94
+ console.log(nudge);
81
95
  break;
96
+ }
82
97
  case "rules": {
83
98
  const domain = flag(args, "domain");
84
99
  console.log(cmdRules(store, {
@@ -194,18 +209,41 @@ async function main() {
194
209
  console.log(`${green("●")} athena API listening on ${bold(`http://127.0.0.1:${actualPort}`)}`);
195
210
  console.log(` token: ${bold(token)} ${dim("(paste into the extension options page)")}`);
196
211
  console.log(dim(" ctrl-c to stop"));
212
+ // Connected sources ride the same long-lived process: check every 30
213
+ // minutes, sync whatever is due (the runner enforces its own 4h cadence).
214
+ const syncLog = (line) => console.log(dim(` sync: ${line}`));
215
+ void syncDueConnections(store, syncLog);
216
+ const syncTimer = setInterval(() => void syncDueConnections(store, syncLog), 30 * 60 * 1000);
197
217
  await new Promise((resolve) => {
198
218
  process.on("SIGINT", () => {
219
+ clearInterval(syncTimer);
199
220
  server.close();
200
221
  resolve();
201
222
  });
202
223
  process.on("SIGTERM", () => {
224
+ clearInterval(syncTimer);
203
225
  server.close();
204
226
  resolve();
205
227
  });
206
228
  });
207
229
  break;
208
230
  }
231
+ case "update": {
232
+ await runUpdate(packageRoot, athenaBin);
233
+ break;
234
+ }
235
+ case "connect": {
236
+ await runConnect(store, args);
237
+ break;
238
+ }
239
+ case "disconnect": {
240
+ await runDisconnect(store, args);
241
+ break;
242
+ }
243
+ case "sync": {
244
+ await runSync(store, args);
245
+ break;
246
+ }
209
247
  case "mcp": {
210
248
  // stdio transport: stdout belongs to the protocol, so print nothing.
211
249
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
@@ -321,4 +359,11 @@ async function reviewLoop(store) {
321
359
  readline.close();
322
360
  }
323
361
  }
324
- await main();
362
+ try {
363
+ await main();
364
+ }
365
+ catch (error) {
366
+ // One clean line for expected operational failures; stack traces are for bugs.
367
+ console.error(red(`✗ ${error instanceof Error ? error.message : String(error)}`));
368
+ process.exit(1);
369
+ }