neagen 0.1.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/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ Neagen CLI — Proprietary License
2
+ Copyright (c) 2026 Neagen. All rights reserved.
3
+
4
+ This software and its source code are proprietary and confidential.
5
+
6
+ You are permitted to download, install, and run the official `neagen` package
7
+ from the public npm registry for the sole purpose of accessing the Neagen
8
+ service.
9
+
10
+ You are NOT permitted to copy, reproduce, modify, adapt, translate, reverse
11
+ engineer, decompile, merge, publish, redistribute, sublicense, sell, or create
12
+ derivative works of this software or any part of its source code, in whole or
13
+ in part, except with the prior express written permission of the copyright
14
+ holder.
15
+
16
+ No rights or licenses are granted except as expressly stated above. All other
17
+ rights are reserved by the copyright holder.
18
+
19
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # neagen
2
+
3
+ Your personal **networking agent**, in the terminal.
4
+
5
+ State a need, and your Neagen remembers who you know, finds the right person
6
+ across your network, and prepares a warm introduction — you review and approve
7
+ every outbound. The CLI is a thin, secure client for your hosted Neagen.
8
+
9
+ ## Install & run
10
+
11
+ ```bash
12
+ npx neagen
13
+ ```
14
+
15
+ …or install it once and run `neagen` anytime:
16
+
17
+ ```bash
18
+ npm install -g neagen
19
+ neagen
20
+ ```
21
+
22
+ Requires **Node.js 24+**.
23
+
24
+ ## First time
25
+
26
+ 1. Create your account on the web at **https://neagen.xyz** (passkey — no
27
+ password).
28
+ 2. Run `neagen`. It opens your browser to approve the terminal with your
29
+ passkey, then drops you into your agent.
30
+
31
+ The CLI can only sign in to an **existing** account — it can never create one.
32
+ Your passkey stays the root of your identity.
33
+
34
+ ## Commands
35
+
36
+ | Command | What it does |
37
+ | --- | --- |
38
+ | `neagen` | Open your agent (logs you in on first run) |
39
+ | `neagen login` | Log in / refresh your session, then exit |
40
+ | `neagen logout` | Forget the saved session on this machine |
41
+ | `neagen doctor` | Check connectivity + login status |
42
+ | `neagen --version` | Print the version |
43
+ | `neagen --help` | Show help |
44
+
45
+ ## Environment
46
+
47
+ | Variable | Default | Purpose |
48
+ | --- | --- | --- |
49
+ | `NEAGEN_HOST` | `https://neagen.xyz` | Server to connect to (local dev: `http://localhost:3000`) |
50
+ | `NEAGEN_PROFILE` | _(main)_ | Named credential slot, e.g. for multiple accounts |
51
+
52
+ ## Where your session is stored
53
+
54
+ A normal session token is cached at `~/.neagen/credentials.json` with
55
+ owner-only permissions (`0600`). Remove it anytime with `neagen logout`.
56
+
57
+ ## License
58
+
59
+ Proprietary — © 2026 Neagen, all rights reserved. You may install and run this
60
+ package to access the Neagen service; you may not copy, modify, or redistribute
61
+ it. See [LICENSE](./LICENSE).
package/lib/api.mjs ADDED
@@ -0,0 +1,56 @@
1
+ const DEFAULT_TIMEOUT_MS = 8_000;
2
+
3
+ export async function probeHost(host) {
4
+ const res = await fetch(`${host}/eve/v1/health`, {
5
+ signal: AbortSignal.timeout(5_000),
6
+ });
7
+ if (!res.ok) throw new Error(`health ${res.status}`);
8
+ return true;
9
+ }
10
+
11
+ export async function apiFetch(host, path, { token, method = "GET", body } = {}) {
12
+ const res = await fetch(`${host}${path}`, {
13
+ method,
14
+ headers: {
15
+ ...(token ? { authorization: `Bearer ${token}` } : {}),
16
+ ...(body ? { "content-type": "application/json" } : {}),
17
+ },
18
+ body: body ? JSON.stringify(body) : undefined,
19
+ signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
20
+ });
21
+ const data = await res.json().catch(() => null);
22
+ return { res, data };
23
+ }
24
+
25
+ /** Resolve owner identity — /api/me when deployed, else Ably token clientId. */
26
+ export async function resolveIdentity(host, token) {
27
+ const me = await apiFetch(host, "/api/me", { token });
28
+ if (me.res.ok && me.data?.ownerId) {
29
+ return {
30
+ ownerId: me.data.ownerId,
31
+ displayName: me.data.displayName ?? "you",
32
+ neagenName: me.data.neagenName ?? "Neagen",
33
+ };
34
+ }
35
+ if (me.res.status !== 404 && me.res.status !== 401) {
36
+ const msg = me.data?.error?.message ?? `HTTP ${me.res.status}`;
37
+ throw new Error(msg);
38
+ }
39
+ if (me.res.status === 401) return null;
40
+
41
+ const ably = await apiFetch(host, "/api/ably-token", { token });
42
+ if (ably.res.ok && ably.data?.clientId) {
43
+ return {
44
+ ownerId: ably.data.clientId,
45
+ displayName: "you",
46
+ neagenName: "Neagen",
47
+ };
48
+ }
49
+ if (ably.res.status === 401) return null;
50
+
51
+ throw new Error(
52
+ me.res.status === 404
53
+ ? "Server is missing /api/me — deploy latest Neagen, or check ABLY_API_KEY on the server."
54
+ : (ably.data?.error?.message ?? `identity check failed (${ably.res.status})`),
55
+ );
56
+ }
package/lib/creds.mjs ADDED
@@ -0,0 +1,133 @@
1
+ // Credential + host resolution for the published `neagen` CLI.
2
+ //
3
+ // The CLI is a REMOTE client: it never boots a server, it talks to a deployed
4
+ // Neagen over HTTPS. By default that is production (neagen.xyz). A session token
5
+ // (a normal Better Auth session, minted by the RFC 8628 device grant) is cached
6
+ // per profile under ~/.neagen with owner-only permissions, and is BOUND to the
7
+ // host it was minted for — it is never replayed to a different origin.
8
+
9
+ import {
10
+ chmodSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ renameSync,
14
+ unlinkSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ const CRED_DIR = join(homedir(), ".neagen");
21
+ const DEFAULT_HOST = "https://neagen.xyz";
22
+
23
+ export function credPath(profile) {
24
+ const name = profile?.trim() ? `credentials-${profile.trim()}.json` : "credentials.json";
25
+ return join(CRED_DIR, name);
26
+ }
27
+
28
+ /**
29
+ * Host selection (published CLI):
30
+ * 1. NEAGEN_HOST (explicit — used for local dev against http://localhost:3000
31
+ * and for self-hosters)
32
+ * 2. https://neagen.xyz (production default)
33
+ */
34
+ export function resolveHost(overrideHost) {
35
+ const explicit = overrideHost?.trim() || process.env.NEAGEN_HOST?.trim();
36
+ if (explicit) return explicit;
37
+ return DEFAULT_HOST;
38
+ }
39
+
40
+ function hostnameOf(host) {
41
+ try {
42
+ return new URL(host).hostname.replace(/^\[/, "").replace(/\]$/, "");
43
+ } catch {
44
+ return "";
45
+ }
46
+ }
47
+
48
+ export function isLoopbackHost(host) {
49
+ const h = hostnameOf(host);
50
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
51
+ }
52
+
53
+ /**
54
+ * Fail closed before any token leaves this machine.
55
+ *
56
+ * The CLI sends a bearer session token to the resolved host, so refuse to send
57
+ * it (a) to a malformed/non-http(s) host, or (b) over plaintext http:// to a
58
+ * NON-loopback host (which would expose the token to the network). http:// is
59
+ * allowed only for localhost/127.0.0.1/::1 (local dev).
60
+ */
61
+ export function assertTransportSafe(host) {
62
+ let url;
63
+ try {
64
+ url = new URL(host);
65
+ } catch {
66
+ throw new Error(`NEAGEN_HOST is not a valid URL: ${host}`);
67
+ }
68
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
69
+ throw new Error(`NEAGEN_HOST must be http(s): got ${url.protocol}`);
70
+ }
71
+ if (url.protocol === "http:" && !isLoopbackHost(host)) {
72
+ throw new Error(
73
+ `Refusing to send your login token over plain http:// to a non-local host (${host}).\n` +
74
+ ` Use https://, or http:// only for localhost.`,
75
+ );
76
+ }
77
+ }
78
+
79
+ export function readCreds(profile) {
80
+ try {
81
+ return JSON.parse(readFileSync(credPath(profile), "utf8"));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ export function saveCreds(profile, { host, accessToken }) {
88
+ mkdirSync(CRED_DIR, { recursive: true, mode: 0o700 });
89
+ // Enforce owner-only perms even if ~/.neagen already existed with a looser
90
+ // mode (the mkdir `mode` is honored only on creation).
91
+ try {
92
+ chmodSync(CRED_DIR, 0o700);
93
+ } catch {
94
+ /* best-effort */
95
+ }
96
+ const target = credPath(profile);
97
+ const tmp = `${target}.${process.pid}.tmp`;
98
+ // Atomic write: a 0600 temp file then rename over the target, so a concurrent
99
+ // reader never sees a partial token and a pre-existing loose-perms file can't
100
+ // linger readable through an in-place rewrite.
101
+ writeFileSync(
102
+ tmp,
103
+ JSON.stringify({ host: host.trim(), access_token: accessToken }, null, 2),
104
+ { mode: 0o600 },
105
+ );
106
+ try {
107
+ chmodSync(tmp, 0o600);
108
+ } catch {
109
+ /* best-effort */
110
+ }
111
+ renameSync(tmp, target);
112
+ }
113
+
114
+ /**
115
+ * Return the cached token ONLY if it was minted for the host we're about to talk
116
+ * to. A token saved for neagen.xyz is never handed to a different NEAGEN_HOST —
117
+ * this is what stops a poisoned env var (`NEAGEN_HOST=https://evil neagen`) from
118
+ * exfiltrating a production session token. A mismatch returns null → re-login.
119
+ */
120
+ export function loadToken(profile, host = resolveHost()) {
121
+ const creds = readCreds(profile);
122
+ if (!creds?.access_token) return null;
123
+ if (creds.host && creds.host.trim() !== host.trim()) return null;
124
+ return creds.access_token;
125
+ }
126
+
127
+ export function clearCreds(profile) {
128
+ try {
129
+ unlinkSync(credPath(profile));
130
+ } catch {
131
+ /* already gone */
132
+ }
133
+ }
@@ -0,0 +1,136 @@
1
+ // Better Auth device grant (RFC 8628) — passwordless CLI login.
2
+ //
3
+ // The CLI requests a user_code, opens the browser to the passkey-authenticated
4
+ // approval page (${host}/device), and polls ${host}/api/auth/device/token until
5
+ // the Owner approves. The grant only ever mints a session for an ALREADY-EXISTING
6
+ // account — it can never create one — so a brand-new user must first create their
7
+ // account on the web (the /device page bounces them to sign-up). Passkey stays the
8
+ // identity root.
9
+
10
+ import { execFile } from "node:child_process";
11
+ import { saveCreds } from "./creds.mjs";
12
+ import { panel } from "./flow.mjs";
13
+
14
+ function openBrowser(url) {
15
+ // Validate the scheme before handing the URL to an OS opener — never launch a
16
+ // javascript:/file:/custom-scheme URL, and only ever an http(s) link. The code
17
+ // is also printed to stderr, so a refused auto-open never blocks login.
18
+ let parsed;
19
+ try {
20
+ parsed = new URL(url);
21
+ } catch {
22
+ return;
23
+ }
24
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
25
+
26
+ const platform = process.platform;
27
+ if (platform === "darwin") execFile("open", [url], () => {});
28
+ // Windows: rundll32's URL handler opens the link with NO shell/cmd parsing, so
29
+ // a server-controlled URL can't inject into cmd.exe (the `cmd /c start` opener
30
+ // does parse `&`, `^`, etc.).
31
+ else if (platform === "win32")
32
+ execFile("rundll32", ["url.dll,FileProtocolHandler", url], () => {});
33
+ else execFile("xdg-open", [url], () => {});
34
+ }
35
+
36
+ /**
37
+ * A console-backed stand-in for the Eve TerminalRenderer panel, so `neagen login`
38
+ * and `neagen doctor` can run the grant without constructing the full TUI.
39
+ */
40
+ function consoleUi() {
41
+ return {
42
+ begin(title) {
43
+ if (title) process.stderr.write(`\n ${title}\n`);
44
+ },
45
+ renderLine(message) {
46
+ process.stderr.write(` ${message}\n`);
47
+ },
48
+ setStatus() {},
49
+ end() {},
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Run the RFC 8628 device grant. Pass an Eve `renderer` to draw inside its panel
55
+ * (the TUI path), or omit it for the plain console output (`neagen login`).
56
+ * @returns {Promise<string>} access token
57
+ */
58
+ export async function runDeviceGrantLogin({ host, profile, renderer }) {
59
+ const ui = renderer ? panel(renderer) : consoleUi();
60
+ ui.begin("Log in to Neagen", "pulse");
61
+ let preserveDiagnostics = true;
62
+ try {
63
+ const codeRes = await fetch(`${host}/api/auth/device/code`, {
64
+ method: "POST",
65
+ headers: { "content-type": "application/json" },
66
+ body: JSON.stringify({ client_id: "neagen-cli" }),
67
+ });
68
+ if (!codeRes.ok) {
69
+ const body = await codeRes.text();
70
+ ui.renderLine(`Could not start login (${codeRes.status}).`, "error");
71
+ if (codeRes.status === 404) {
72
+ ui.renderLine(
73
+ "CLI login is not available on this server yet.",
74
+ "warning",
75
+ );
76
+ console.error(
77
+ "\n CLI device login returned 404 on " +
78
+ host +
79
+ ".\n The server is reachable but does not expose the device-login endpoints.\n (Local dev: run `npm run dev`, then `NEAGEN_HOST=http://localhost:3000 neagen`.)\n",
80
+ );
81
+ }
82
+ throw new Error(body || `device/code ${codeRes.status}`);
83
+ }
84
+ const code = await codeRes.json();
85
+
86
+ ui.renderLine(`Your code: ${code.user_code}`, "info");
87
+ ui.renderLine(code.verification_uri_complete, "info");
88
+ ui.renderLine("Opening your browser — approve with your passkey…", "info");
89
+ // Mirror to stderr so login is never silent if the panel hasn't painted yet.
90
+ console.error(
91
+ `\n Your code: ${code.user_code}\n Approve at: ${code.verification_uri_complete}\n`,
92
+ );
93
+ openBrowser(code.verification_uri_complete);
94
+
95
+ const intervalMs = (code.interval ?? 5) * 1000;
96
+ const deadline = Date.now() + (code.expires_in ?? 600) * 1000;
97
+
98
+ while (Date.now() < deadline) {
99
+ ui.setStatus("Waiting for passkey approval…");
100
+ await new Promise((r) => setTimeout(r, intervalMs));
101
+
102
+ const tokRes = await fetch(`${host}/api/auth/device/token`, {
103
+ method: "POST",
104
+ headers: { "content-type": "application/json" },
105
+ body: JSON.stringify({
106
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
107
+ device_code: code.device_code,
108
+ client_id: "neagen-cli",
109
+ }),
110
+ });
111
+
112
+ if (tokRes.ok) {
113
+ const tok = await tokRes.json();
114
+ saveCreds(profile, { host, accessToken: tok.access_token });
115
+ ui.renderLine("Logged in.", "success");
116
+ preserveDiagnostics = false;
117
+ return tok.access_token;
118
+ }
119
+
120
+ const err = await tokRes.json().catch(() => ({}));
121
+ if (err.error === "authorization_pending" || err.error === "slow_down") continue;
122
+
123
+ ui.renderLine(
124
+ `Login failed: ${err.error_description ?? err.error ?? tokRes.status}`,
125
+ "error",
126
+ );
127
+ throw new Error(err.error_description ?? err.error ?? "device/token failed");
128
+ }
129
+
130
+ ui.renderLine("The code expired before approval. Run neagen again.", "error");
131
+ throw new Error("device code expired");
132
+ } finally {
133
+ ui.setStatus(undefined);
134
+ ui.end({ preserveDiagnostics });
135
+ }
136
+ }
package/lib/flow.mjs ADDED
@@ -0,0 +1,4 @@
1
+ /** Eve TerminalRenderer panel API (begin/renderLine/setStatus/end live on setupFlow). */
2
+ export function panel(renderer) {
3
+ return renderer.setupFlow;
4
+ }
package/lib/tui.mjs ADDED
@@ -0,0 +1,173 @@
1
+ // neagen TUI — chat, agent-mail, files against the deployed agent.
2
+ // Login happens here (device-grant panel) when no valid session exists.
3
+ //
4
+ // Resolves Eve's TUI internals from the INSTALLED `eve` package (via
5
+ // require.resolve), so it works no matter where npm puts the dependency — global
6
+ // install, npx cache, or this repo's node_modules. `eve` is a hard dependency of
7
+ // this package (see ../package.json), pinned to an exact version because these
8
+ // dist paths are internal to that version.
9
+
10
+ import { createRequire } from "node:module";
11
+ import { dirname, join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { Client } from "eve/client";
14
+ import { assertTransportSafe, clearCreds, loadToken, resolveHost } from "./creds.mjs";
15
+ import { probeHost, resolveIdentity } from "./api.mjs";
16
+ import { runDeviceGrantLogin } from "./device-login.mjs";
17
+ import { panel } from "./flow.mjs";
18
+ import { subscribeInboxWakeAbly } from "./wake.mjs";
19
+
20
+ const require = createRequire(import.meta.url);
21
+ // `eve` exposes "./package.json" in its exports map, so this resolves the
22
+ // installed package root regardless of hoisting.
23
+ const EVE_DIST = join(dirname(require.resolve("eve/package.json")), "dist", "src");
24
+ const EVE_TUI = join(EVE_DIST, "cli", "dev", "tui");
25
+
26
+ const profile = process.env.NEAGEN_PROFILE?.trim() || undefined;
27
+ const host = resolveHost();
28
+
29
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
30
+ console.error("neagen requires an interactive terminal.");
31
+ process.exit(1);
32
+ }
33
+
34
+ try {
35
+ assertTransportSafe(host);
36
+ } catch (e) {
37
+ console.error(`\n ${e.message}\n`);
38
+ process.exit(1);
39
+ }
40
+
41
+ async function importEve(relPath) {
42
+ return import(pathToFileURL(join(EVE_DIST, relPath)).href);
43
+ }
44
+
45
+ const [
46
+ { EveTUIRunner },
47
+ { createPromptCommandHandler },
48
+ { promptCommandsFor },
49
+ { formatRemoteAuthChallengeMessage },
50
+ { toErrorMessage },
51
+ { isVercelAuthChallenge },
52
+ { TerminalRenderer },
53
+ ] = await Promise.all([
54
+ import(pathToFileURL(join(EVE_TUI, "runner.js")).href),
55
+ import(pathToFileURL(join(EVE_TUI, "prompt-command-handler.js")).href),
56
+ import(pathToFileURL(join(EVE_TUI, "prompt-commands.js")).href),
57
+ import(pathToFileURL(join(EVE_TUI, "remote-auth-result.js")).href),
58
+ importEve("shared/errors.js"),
59
+ importEve("services/dev-client/vercel-auth-error.js"),
60
+ import(pathToFileURL(join(EVE_TUI, "terminal-renderer.js")).href),
61
+ ]);
62
+
63
+ const target = {
64
+ kind: "remote",
65
+ serverUrl: host,
66
+ workspaceRoot: process.cwd(),
67
+ };
68
+
69
+ const renderer = new TerminalRenderer({ availablePromptCommands: promptCommandsFor("remote") });
70
+
71
+ function fail(message) {
72
+ try {
73
+ panel(renderer).renderLine(message, "error");
74
+ } catch {
75
+ console.error(`\n ${message}\n`);
76
+ }
77
+ process.exit(1);
78
+ }
79
+
80
+ async function ensureReachable() {
81
+ const ui = panel(renderer);
82
+ ui.begin("neagen", "pulse");
83
+ ui.renderLine(`Connecting to ${host}…`, "info");
84
+ try {
85
+ await probeHost(host);
86
+ ui.renderLine("Agent online.", "success");
87
+ } catch (e) {
88
+ ui.renderLine(`Can't reach ${host} (${e.message}).`, "error");
89
+ ui.renderLine("Check your connection, or set NEAGEN_HOST to your server.", "info");
90
+ ui.end({ preserveDiagnostics: true });
91
+ process.exit(1);
92
+ }
93
+ ui.end({ preserveDiagnostics: false });
94
+ }
95
+
96
+ async function ensureAuthenticated() {
97
+ let token = loadToken(profile);
98
+ if (token) {
99
+ const identity = await resolveIdentity(host, token);
100
+ if (identity) return { token, me: identity };
101
+ clearCreds(profile);
102
+ token = null;
103
+ }
104
+ token = await runDeviceGrantLogin({ host, profile, renderer });
105
+ const me = await resolveIdentity(host, token);
106
+ if (!me) {
107
+ clearCreds(profile);
108
+ fail("Session could not be verified after login.");
109
+ }
110
+ return { token, me };
111
+ }
112
+
113
+ await ensureReachable();
114
+
115
+ let session;
116
+ try {
117
+ session = await ensureAuthenticated();
118
+ } catch (e) {
119
+ fail(e.message ?? "Login failed.");
120
+ }
121
+
122
+ const { me } = session;
123
+ const label = me.displayName ?? "you";
124
+ const ownerId = me.ownerId;
125
+
126
+ const client = new Client({
127
+ host,
128
+ auth: { bearer: () => loadToken(profile) ?? "" },
129
+ preserveCompletedSessions: true,
130
+ });
131
+
132
+ try {
133
+ await client.health();
134
+ } catch (e) {
135
+ fail(`Agent unreachable at ${host} (${e.message}).`);
136
+ }
137
+
138
+ let wake;
139
+ try {
140
+ const token = loadToken(profile);
141
+ wake = await subscribeInboxWakeAbly(ownerId, (ev) => {
142
+ const what = ev.kind === "message" ? "a message" : `a file (${ev.filename})`;
143
+ renderer.renderNotice(
144
+ `📬 New mail — ${what} arrived. Ask me about it (e.g. "what's new?") to fetch it.`,
145
+ );
146
+ }, { host, bearerToken: token });
147
+ } catch {
148
+ /* wake is best-effort */
149
+ }
150
+
151
+ const stopWake = async () => {
152
+ try {
153
+ await wake?.close();
154
+ } catch {
155
+ /* best-effort */
156
+ }
157
+ };
158
+ process.on("SIGINT", stopWake);
159
+ process.on("exit", () => void wake?.close());
160
+
161
+ await new EveTUIRunner({
162
+ name: `neagen (${label})`,
163
+ session: client.session(),
164
+ client,
165
+ serverUrl: host,
166
+ renderer,
167
+ promptCommandHandler: createPromptCommandHandler({ target }),
168
+ availablePromptCommands: promptCommandsFor("remote"),
169
+ formatTransportError: (error) =>
170
+ isVercelAuthChallenge(error) ? formatRemoteAuthChallengeMessage(host) : toErrorMessage(error),
171
+ }).run();
172
+
173
+ await stopWake();
package/lib/wake.mjs ADDED
@@ -0,0 +1,312 @@
1
+ // Plain-ESM mailbox watcher / wake subscriber for the node scripts (CLI / TUI /
2
+ // live demo). Canonical home for the script-side wake client.
3
+ //
4
+ // This is the script-side twin of src/lib/agentmail/wake.ts (the TS module used
5
+ // by the app + vitest). The node entrypoints are .mjs and can't import the TS
6
+ // path-aliased module, so the same ~40 lines live here. Keep the two in sync.
7
+ //
8
+ // The published `neagen` CLI uses ONLY subscribeInboxWakeAbly (token auth against
9
+ // /api/ably-token). The fs/ws transports below are for local dev / the two-agent
10
+ // harness and are inert in the remote CLI.
11
+ //
12
+ // The wake is non-polling: fs.watch is OS-level (FSEvents / inotify), event-
13
+ // driven. "Mail = the file system" — a wake is just an envelope appearing in the
14
+ // recipient's transit mailbox.
15
+
16
+ import { watch } from "node:fs";
17
+ import { mkdir, readdir, readFile } from "node:fs/promises";
18
+ import path from "node:path";
19
+
20
+ function transitRoot() {
21
+ return path.resolve(process.env.AGENT_MAIL_TRANSIT_DIR || ".agent-mail-transit");
22
+ }
23
+
24
+ export function ownerMailboxDir(ownerId) {
25
+ return path.join(transitRoot(), ownerId);
26
+ }
27
+
28
+ async function listEnvelopes(ownerId) {
29
+ const dir = ownerMailboxDir(ownerId);
30
+ let names;
31
+ try {
32
+ names = (await readdir(dir)).filter((n) => n.endsWith(".json")).sort();
33
+ } catch {
34
+ return [];
35
+ }
36
+ const out = [];
37
+ for (const name of names) {
38
+ try {
39
+ out.push(JSON.parse(await readFile(path.join(dir, name), "utf8")));
40
+ } catch {
41
+ // skip a partial/corrupt envelope until it is complete
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /**
48
+ * Watch an owner's transit mailbox; call onWake once per NEW envelope.
49
+ * Returns { close() }. Event-driven (fs.watch), de-duped by envelope id, drains
50
+ * pre-existing mail on subscribe unless drainExisting === false.
51
+ */
52
+ export async function subscribeInboxWake(ownerId, onWake, { drainExisting = true } = {}) {
53
+ const dir = ownerMailboxDir(ownerId);
54
+ await mkdir(dir, { recursive: true });
55
+
56
+ const seen = new Set();
57
+ let closed = false;
58
+ let draining = false;
59
+ let rerun = false;
60
+ let firstDrain = true;
61
+
62
+ const drainOnce = async () => {
63
+ if (closed) return;
64
+ const suppress = firstDrain && !drainExisting;
65
+ firstDrain = false;
66
+ const pending = await listEnvelopes(ownerId);
67
+ for (const env of pending) {
68
+ if (seen.has(env.id)) continue;
69
+ seen.add(env.id);
70
+ if (suppress) continue;
71
+ try {
72
+ await onWake({
73
+ toOwnerId: env.toOwnerId,
74
+ fromOwnerId: env.fromOwnerId,
75
+ kind: env.kind ?? "file",
76
+ filename: env.filename,
77
+ envelopeId: env.id,
78
+ });
79
+ } catch {
80
+ // a handler error must not kill the watcher
81
+ }
82
+ }
83
+ };
84
+
85
+ const scheduleDrain = () => {
86
+ if (closed) return;
87
+ if (draining) {
88
+ rerun = true;
89
+ return;
90
+ }
91
+ draining = true;
92
+ drainOnce().finally(() => {
93
+ draining = false;
94
+ if (rerun && !closed) {
95
+ rerun = false;
96
+ scheduleDrain();
97
+ }
98
+ });
99
+ };
100
+
101
+ await drainOnce();
102
+
103
+ let watcher;
104
+ try {
105
+ watcher = watch(dir, { persistent: true }, () => scheduleDrain());
106
+ watcher.on("error", () => {});
107
+ } catch {
108
+ // inert subscription if the OS can't watch; the initial drain still ran
109
+ }
110
+
111
+ return {
112
+ async close() {
113
+ closed = true;
114
+ watcher?.close();
115
+ },
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // WS transport — the held-connection wake (production shape).
121
+ //
122
+ // Plain-ESM twin of src/lib/agentmail/wake-ws.ts. SAME (ownerId, onWake) ->
123
+ // { close() } contract as subscribeInboxWake above, so the CLI/TUI handlers are
124
+ // unchanged. Holds ONE WebSocket to the eve wake channel; the server pushes each
125
+ // wake down it (no polling). Reconnect = offline catch-up (the server re-drains
126
+ // transit on connect). Owner identity rides the upgrade as the x-neagen-owner
127
+ // header — Node's native WebSocket forwards { headers } on the handshake.
128
+
129
+ function wakeWsUrl(host) {
130
+ return host.replace(/\/+$/, "").replace(/^http/, "ws") + "/eve/v1/wake";
131
+ }
132
+
133
+ export async function subscribeInboxWakeWS(
134
+ ownerId,
135
+ onWake,
136
+ { host, reconnectMs = 1000, maxReconnectMs = 30000 } = {},
137
+ ) {
138
+ const base = host || process.env.EVE_HOST || "http://127.0.0.1:4010";
139
+ const url = wakeWsUrl(base);
140
+
141
+ let closed = false;
142
+ let socket;
143
+ let reconnectTimer;
144
+ let backoffMs = reconnectMs;
145
+
146
+ const connect = () => {
147
+ if (closed) return;
148
+ socket = new WebSocket(url, { headers: { "x-neagen-owner": ownerId } });
149
+ socket.onopen = () => {
150
+ backoffMs = reconnectMs; // a live connection resets the backoff
151
+ };
152
+ socket.onmessage = (ev) => {
153
+ let msg;
154
+ try {
155
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
156
+ } catch {
157
+ return;
158
+ }
159
+ if (msg?.type === "wake" && msg.event) {
160
+ try {
161
+ const r = onWake(msg.event);
162
+ if (r && typeof r.then === "function") r.catch(() => {});
163
+ } catch {
164
+ // a handler error must not kill the subscription
165
+ }
166
+ }
167
+ };
168
+ socket.onclose = () => {
169
+ if (closed) return;
170
+ // Exponential backoff (NOT interval polling): one-shot timer that doubles
171
+ // up to the cap and resets on the next successful open.
172
+ reconnectTimer = setTimeout(connect, backoffMs);
173
+ backoffMs = Math.min(backoffMs * 2, maxReconnectMs);
174
+ };
175
+ socket.onerror = () => {
176
+ // onclose follows; reconnect handled there
177
+ };
178
+ };
179
+
180
+ connect();
181
+
182
+ await new Promise((resolve) => {
183
+ if (!socket || socket.readyState === WebSocket.OPEN) return resolve();
184
+ socket.addEventListener("open", () => resolve(), { once: true });
185
+ setTimeout(resolve, 1500);
186
+ });
187
+
188
+ return {
189
+ async close() {
190
+ closed = true;
191
+ if (reconnectTimer) clearTimeout(reconnectTimer);
192
+ try {
193
+ socket?.close();
194
+ } catch {
195
+ // already closing
196
+ }
197
+ },
198
+ };
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Ably transport — the managed-pub/sub wake (production, no box of our own).
203
+ //
204
+ // Plain-ESM twin of src/lib/agentmail/wake-ably.ts. SAME (ownerId, onWake) ->
205
+ // { close() } contract as the two transports above, so the CLI/TUI handlers are
206
+ // unchanged. The Ably SDK holds ONE socket to Ably's managed service and pushes
207
+ // each wake down it (no polling). For the CLI/dev path (x-neagen-owner header,
208
+ // no Better Auth session) the raw ABLY_API_KEY is fine; a deployed/browser
209
+ // client uses token auth via /api/ably-token instead.
210
+
211
+ function wakeChannelName(ownerId) {
212
+ return `wake:${ownerId}`;
213
+ }
214
+
215
+ export async function subscribeInboxWakeAbly(ownerId, onWake, { key, authUrl, host, bearerToken } = {}) {
216
+ const { Realtime } = await import("ably");
217
+ const apiKey = key || process.env.ABLY_API_KEY;
218
+ const tokenUrl = host && bearerToken ? `${host.replace(/\/$/, "")}/api/ably-token` : undefined;
219
+ if (!tokenUrl && !authUrl && !apiKey) {
220
+ throw new Error("Ably wake: set ABLY_API_KEY, pass authUrl, or pass host + bearerToken.");
221
+ }
222
+
223
+ /** @type {import("ably").ClientOptions} */
224
+ let clientOptions;
225
+ if (tokenUrl) {
226
+ clientOptions = {
227
+ clientId: ownerId,
228
+ authCallback: (_tokenParams, callback) => {
229
+ fetch(tokenUrl, { headers: { authorization: `Bearer ${bearerToken}` } })
230
+ .then(async (res) => {
231
+ if (!res.ok) throw new Error(`ably-token ${res.status}`);
232
+ return res.json();
233
+ })
234
+ .then((tokenRequest) => callback(null, tokenRequest))
235
+ .catch((err) => callback(err?.message ?? String(err), null));
236
+ },
237
+ };
238
+ } else if (authUrl) {
239
+ clientOptions = { authUrl, clientId: ownerId };
240
+ } else {
241
+ clientOptions = { key: apiKey, clientId: ownerId };
242
+ }
243
+ const client = new Realtime(clientOptions);
244
+ const channel = client.channels.get(wakeChannelName(ownerId));
245
+
246
+ const listener = (msg) => {
247
+ try {
248
+ const r = onWake(msg.data);
249
+ if (r && typeof r.then === "function") r.catch(() => {});
250
+ } catch {
251
+ // a handler error must not kill the subscription
252
+ }
253
+ };
254
+ // Don't await subscribe: it rejects on a bad key and stays pending (hangs) if
255
+ // Ably is unreachable. The listener registers synchronously; the SDK attaches +
256
+ // reconnects in the background (event-driven, not polling).
257
+ void channel.subscribe("wake", listener).catch(() => {});
258
+
259
+ // Resolve once connected, but never hang if Ably is unreachable (the SDK
260
+ // reconnects on its own — no polling).
261
+ await Promise.race([
262
+ client.connection.once("connected").then(() => {}),
263
+ new Promise((resolve) => setTimeout(resolve, 1500)),
264
+ ]);
265
+
266
+ return {
267
+ async close() {
268
+ channel.unsubscribe("wake", listener);
269
+ client.close();
270
+ },
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Pick the wake transport by env.
276
+ * - NEAGEN_WAKE_TRANSPORT wins when set: "fs" → fs.watch (pure-local, needs a
277
+ * shared filesystem), "ws" → our held WebSocket to the eve wake channel,
278
+ * "ably" → Ably managed pub/sub.
279
+ * - Otherwise, if ABLY_API_KEY is set → Ably (the production default: no
280
+ * always-on box of our own, Ably holds the socket).
281
+ * - Otherwise → the held WebSocket.
282
+ * Same (ownerId, onWake) -> { close() } contract for every transport, so callers
283
+ * don't branch.
284
+ */
285
+ export async function subscribeInboxWakeAuto(ownerId, onWake, options = {}) {
286
+ const transport = process.env.NEAGEN_WAKE_TRANSPORT;
287
+ if (transport === "fs") return subscribeInboxWake(ownerId, onWake, options);
288
+ if (transport === "ws") return subscribeInboxWakeWS(ownerId, onWake, options);
289
+ if (transport === "ably") return subscribeInboxWakeAbly(ownerId, onWake, options);
290
+ if (process.env.ABLY_API_KEY) return subscribeInboxWakeAbly(ownerId, onWake, options);
291
+ return subscribeInboxWakeWS(ownerId, onWake, options);
292
+ }
293
+
294
+ // CLI mode: `node cli/lib/wake.mjs <ownerId>` — subscribe and print one
295
+ // `WOKE <json>` line per wake event (used by the cross-process wake check).
296
+ if (import.meta.url === `file://${process.argv[1]}`) {
297
+ const ownerId = process.argv[2];
298
+ if (!ownerId) {
299
+ console.error("usage: node cli/lib/wake.mjs <ownerId>");
300
+ process.exit(2);
301
+ }
302
+ const sub = await subscribeInboxWake(ownerId, (ev) => {
303
+ process.stdout.write(`WOKE ${JSON.stringify(ev)}\n`);
304
+ });
305
+ process.stdout.write("READY\n");
306
+ const stop = async () => {
307
+ await sub.close();
308
+ process.exit(0);
309
+ };
310
+ process.on("SIGTERM", stop);
311
+ process.on("SIGINT", stop);
312
+ }
package/neagen.mjs ADDED
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // neagen — your personal networking agent, in the terminal.
3
+ //
4
+ // `neagen` open the TUI (logs you in on first run)
5
+ // `neagen login` log in / refresh your session, then exit
6
+ // `neagen logout` forget the saved session on this machine
7
+ // `neagen doctor` check connectivity + login status (no terminal UI needed)
8
+ // `neagen --version` print the version
9
+ //
10
+ // The CLI is a remote client for a deployed Neagen (https://neagen.xyz by
11
+ // default; set NEAGEN_HOST to point elsewhere). Login is the OAuth 2.0 Device
12
+ // Authorization Grant (RFC 8628): you approve with your passkey in the browser.
13
+ // It can only sign in to an EXISTING account — create yours on the web first.
14
+
15
+ import { createRequire } from "node:module";
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const pkg = require("./package.json");
19
+
20
+ function parseArgs(argv) {
21
+ let profile = process.env.NEAGEN_PROFILE?.trim() || undefined;
22
+ let command;
23
+ for (let i = 2; i < argv.length; i++) {
24
+ const arg = argv[i];
25
+ if (arg === "--profile" && argv[i + 1]) {
26
+ profile = argv[++i].trim() || undefined;
27
+ continue;
28
+ }
29
+ if (arg === "--help" || arg === "-h") return { command: "help", profile };
30
+ if (arg === "--version" || arg === "-v") return { command: "version", profile };
31
+ if (!arg.startsWith("-") && !command) {
32
+ command = arg;
33
+ continue;
34
+ }
35
+ console.error(`unknown argument: ${arg} (try: neagen --help)\n`);
36
+ process.exit(1);
37
+ }
38
+ return { command: command ?? "tui", profile };
39
+ }
40
+
41
+ function printHelp() {
42
+ process.stdout.write(
43
+ `neagen ${pkg.version} — your personal networking agent\n\n` +
44
+ `Usage:\n` +
45
+ ` neagen [--profile NAME] Open the agent (logs you in on first run)\n` +
46
+ ` neagen login Log in / refresh your session\n` +
47
+ ` neagen logout Forget the saved session on this machine\n` +
48
+ ` neagen doctor Check connectivity + login status\n` +
49
+ ` neagen --version Print the version\n` +
50
+ ` neagen --help Show this help\n\n` +
51
+ `Environment:\n` +
52
+ ` NEAGEN_HOST Server to connect to (default https://neagen.xyz)\n` +
53
+ ` NEAGEN_PROFILE Named credential slot (default: the main one)\n\n` +
54
+ `First time? Create your account at https://neagen.xyz, then run \`neagen\`.\n`,
55
+ );
56
+ }
57
+
58
+ async function runTui(profile) {
59
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
60
+ console.error(
61
+ "neagen needs an interactive terminal. Try `neagen doctor` to check your setup.\n",
62
+ );
63
+ process.exit(1);
64
+ }
65
+ if (profile) process.env.NEAGEN_PROFILE = profile;
66
+ await import("./lib/tui.mjs");
67
+ }
68
+
69
+ async function runLogin(profile) {
70
+ const { resolveHost, assertTransportSafe, loadToken, clearCreds } = await import("./lib/creds.mjs");
71
+ const { resolveIdentity } = await import("./lib/api.mjs");
72
+ const { runDeviceGrantLogin } = await import("./lib/device-login.mjs");
73
+ const host = resolveHost();
74
+ assertTransportSafe(host);
75
+
76
+ const existing = loadToken(profile);
77
+ if (existing) {
78
+ const me = await resolveIdentity(host, existing).catch(() => null);
79
+ if (me) {
80
+ process.stdout.write(`Already logged in to ${host} as ${me.displayName}.\n`);
81
+ return;
82
+ }
83
+ clearCreds(profile);
84
+ }
85
+
86
+ const token = await runDeviceGrantLogin({ host, profile });
87
+ const me = await resolveIdentity(host, token).catch(() => null);
88
+ if (!me) {
89
+ clearCreds(profile);
90
+ console.error("Session could not be verified after login.\n");
91
+ process.exit(1);
92
+ }
93
+ process.stdout.write(`Logged in to ${host} as ${me.displayName}.\n`);
94
+ }
95
+
96
+ async function runLogout(profile) {
97
+ const { clearCreds, credPath } = await import("./lib/creds.mjs");
98
+ clearCreds(profile);
99
+ process.stdout.write(`Logged out. Removed ${credPath(profile)}.\n`);
100
+ }
101
+
102
+ async function runDoctor(profile) {
103
+ const { resolveHost, assertTransportSafe, loadToken } = await import("./lib/creds.mjs");
104
+ const { probeHost, resolveIdentity } = await import("./lib/api.mjs");
105
+ const host = resolveHost();
106
+ process.stdout.write(`neagen ${pkg.version}\n`);
107
+ process.stdout.write(`host: ${host}\n`);
108
+ try {
109
+ assertTransportSafe(host);
110
+ } catch (e) {
111
+ process.stdout.write(`host: UNSAFE — ${e.message.split("\n")[0]}\n`);
112
+ process.exit(2);
113
+ }
114
+
115
+ let reachable = false;
116
+ try {
117
+ await probeHost(host);
118
+ reachable = true;
119
+ process.stdout.write(`server: reachable\n`);
120
+ } catch (e) {
121
+ process.stdout.write(`server: UNREACHABLE (${e.message})\n`);
122
+ }
123
+
124
+ const token = loadToken(profile);
125
+ if (!token) {
126
+ process.stdout.write(`login: not logged in — run \`neagen login\`\n`);
127
+ process.exit(reachable ? 0 : 2);
128
+ }
129
+ if (!reachable) {
130
+ process.stdout.write(`login: a session is saved, but the server is unreachable\n`);
131
+ process.exit(2);
132
+ }
133
+ try {
134
+ const me = await resolveIdentity(host, token);
135
+ if (me) process.stdout.write(`login: signed in as ${me.displayName}\n`);
136
+ else process.stdout.write(`login: saved session is invalid — run \`neagen login\`\n`);
137
+ process.exit(0);
138
+ } catch (e) {
139
+ process.stdout.write(`login: could not verify session (${e.message})\n`);
140
+ process.exit(2);
141
+ }
142
+ }
143
+
144
+ const { command, profile } = parseArgs(process.argv);
145
+
146
+ try {
147
+ switch (command) {
148
+ case "help":
149
+ printHelp();
150
+ break;
151
+ case "version":
152
+ process.stdout.write(`${pkg.version}\n`);
153
+ break;
154
+ case "login":
155
+ await runLogin(profile);
156
+ break;
157
+ case "logout":
158
+ await runLogout(profile);
159
+ break;
160
+ case "doctor":
161
+ await runDoctor(profile);
162
+ break;
163
+ case "tui":
164
+ await runTui(profile);
165
+ break;
166
+ default:
167
+ console.error(`unknown command: ${command} (try: neagen --help)\n`);
168
+ process.exit(1);
169
+ }
170
+ } catch (error) {
171
+ console.error(`\n ${error?.message ?? error}\n`);
172
+ process.exit(1);
173
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "neagen",
3
+ "version": "0.1.0",
4
+ "description": "Your personal networking agent, in the terminal. Chat, agent-mail, and files against your Neagen — passkey login, no passwords.",
5
+ "type": "module",
6
+ "bin": {
7
+ "neagen": "neagen.mjs"
8
+ },
9
+ "files": [
10
+ "neagen.mjs",
11
+ "lib",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "engines": {
16
+ "node": ">=24"
17
+ },
18
+ "dependencies": {
19
+ "ably": "^2.23.0",
20
+ "eve": "0.14.0"
21
+ },
22
+ "license": "SEE LICENSE IN LICENSE",
23
+ "homepage": "https://neagen.xyz",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/Remedy92/neagen.git",
27
+ "directory": "cli"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/Remedy92/neagen/issues"
31
+ },
32
+ "keywords": [
33
+ "neagen",
34
+ "networking",
35
+ "agent",
36
+ "cli",
37
+ "tui",
38
+ "passkey"
39
+ ],
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }