noeta-cli 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.
Files changed (3) hide show
  1. package/README.md +91 -0
  2. package/cli.mjs +290 -0
  3. package/package.json +24 -0
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # noeta-cli
2
+
3
+ Installs the `noeta` command (`npm i -g noeta-cli` for the short form; `npx noeta-cli …` works with no install).
4
+
5
+ CLI portal to a [Noeta Cloud](https://noeta.cloud) workspace — for agents and
6
+ the humans who authorize them.
7
+
8
+ ```bash
9
+ npx noeta-cli login
10
+ ```
11
+
12
+ That's the whole setup: your browser opens, you sign in, pick an access level
13
+ (full / editor / commenter / read-only), and click **Approve**. The command
14
+ finishes on the click — it never asks for terminal input — and saves the token
15
+ locally. From then on the CLI *is* the integration; no MCP client
16
+ configuration required:
17
+
18
+ ```bash
19
+ npx noeta-cli whoami # who am I, which workspace, what cap
20
+ npx noeta-cli tools # list the workspace's tools
21
+ npx noeta-cli call get_doc '{"docId":"<id>"}' # read a document
22
+ npx noeta-cli call insert_blocks '{"docId":"<id>","blocks":[{"type":"paragraph","content":"hi"}]}'
23
+ npx noeta-cli logout # revoke the token server-side + forget it
24
+ ```
25
+
26
+ `login` defaults to `https://noeta.cloud`; pass your deployment's URL for
27
+ per-org instances: `npx noeta-cli login https://your-org.noeta.cloud`. Credentials
28
+ live in `~/.config/noeta/credentials.json` (0600), one entry per deployment;
29
+ the last login is the default target and `--url` / `NOETA_URL` switch between
30
+ them. `NOETA_TOKEN` overrides the store entirely (CI).
31
+
32
+ ## What the identity is
33
+
34
+ Approving mints a **delegated agent identity**: it acts on *your* behalf,
35
+ capped at the level you approved — it can do at most what you can do, never
36
+ more, and everything it writes is attributed to you *via* the agent. Revoke
37
+ any time with `npx noeta-cli logout` (or `DELETE /api/agents/<id>` as your
38
+ signed-in user).
39
+
40
+ ## For agents driving this programmatically
41
+
42
+ No command reads stdin, and `--json` makes stdout a single machine-readable
43
+ value (progress and errors go to stderr):
44
+
45
+ ```bash
46
+ npx noeta-cli login --json --no-open # stderr: the approval link to hand to the user
47
+ # stdout: {"token":…,"agentId":…,"mcpUrl":…} on approval
48
+ npx noeta-cli whoami --json
49
+ npx noeta-cli tools --json
50
+ npx noeta-cli call get_doc '{"docId":"<id>"}' # stdout: the tool result JSON
51
+ ```
52
+
53
+ Exit codes: `0` ok · `1` usage/network/server error · `2` login denied,
54
+ expired, or timed out · `3` the tool call returned an error (reason on stderr).
55
+
56
+ Prefer a native MCP connection? `login` also prints the one-liner:
57
+
58
+ ```bash
59
+ claude mcp add --transport http noeta https://your-org.noeta.cloud/mcp \
60
+ --header "Authorization: Bearer noeta_…"
61
+ ```
62
+
63
+ ## The raw HTTP contract (no node required)
64
+
65
+ The CLI is a convenience over plain HTTP — any language can do this
66
+ (OAuth-device-grant shaped):
67
+
68
+ ```bash
69
+ # 1. start (no auth): returns userCode (for the browser), deviceCode (for you), verifyUrl
70
+ curl -X POST https://your-org.noeta.cloud/api/agent-auth/start \
71
+ -H 'content-type: application/json' -d '{"clientName":"My Agent"}'
72
+
73
+ # 2. send the user to verifyUrl (…/?agent-auth=<userCode>) and let them approve
74
+
75
+ # 3. poll (no auth) every ~2s until "approved"; the token is returned exactly ONCE
76
+ curl -X POST https://your-org.noeta.cloud/api/agent-auth/poll \
77
+ -H 'content-type: application/json' -d '{"deviceCode":"<deviceCode>"}'
78
+ ```
79
+
80
+ Then, with `Authorization: Bearer <token>`:
81
+
82
+ - `POST /mcp` — MCP over Streamable HTTP (JSON-RPC 2.0): `tools/list`, `tools/call`
83
+ - `GET /agent/me` — the authenticated identity
84
+ - `POST /agent/revoke` — self-revoke (what `logout` calls)
85
+
86
+ Requests expire after 10 minutes; the `userCode` in the URL can't poll the
87
+ token; the server stores only a hash of the token.
88
+
89
+ ## Requirements
90
+
91
+ Node ≥ 18 (global `fetch`). Zero dependencies.
package/cli.mjs ADDED
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env node
2
+ // noeta — CLI portal to a Noeta Cloud workspace (BUILD_PLAN §8.7).
3
+ //
4
+ // npx noeta-cli login browser approval → token saved locally
5
+ // npx noeta-cli tools list the workspace's MCP tools
6
+ // npx noeta-cli call get_doc '{"docId":"…"}'
7
+ // npx noeta-cli whoami · logout
8
+ //
9
+ // Built for agents: no command ever reads stdin. `login` opens a browser (or prints
10
+ // the link with --no-open) and finishes the moment the user clicks Approve; every
11
+ // other command runs with the stored token, so an agent with shell access can use
12
+ // the whole workspace with NO MCP client configuration. `--json` makes stdout a
13
+ // single machine-readable value (progress goes to stderr).
14
+ //
15
+ // Credentials: ~/.config/noeta/credentials.json (0600; override the directory with
16
+ // NOETA_CONFIG_DIR). Precedence for authed commands: --url flag > NOETA_URL > the
17
+ // last login; token: NOETA_TOKEN > the stored token for that URL.
18
+ // Exit codes: 0 ok · 1 usage/network/server error · 2 login denied/expired/timed
19
+ // out · 3 the tool call itself returned an error.
20
+
21
+ import { spawn } from "node:child_process";
22
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import process from "node:process";
26
+
27
+ const DEFAULT_URL = "https://noeta.cloud";
28
+
29
+ const USAGE = `noeta — CLI portal to a Noeta Cloud workspace
30
+
31
+ Usage: noeta <command> [args] [options]
32
+
33
+ Commands:
34
+ login [url] Authorize this machine via a browser approval (no prompts;
35
+ finishes when the user clicks Approve). Saves the token.
36
+ url defaults to ${DEFAULT_URL}; if repeated, the last wins.
37
+ whoami Show the authenticated agent identity + workspace
38
+ tools List the workspace's MCP tools
39
+ call <tool> [json] Call an MCP tool; args as a JSON object (or via --args)
40
+ logout Revoke this machine's token server-side and forget it
41
+ help Show this help
42
+
43
+ Options:
44
+ --url <url> Target a specific saved deployment (default: last login)
45
+ --json Machine-readable stdout (progress/errors → stderr)
46
+ --name <name> [login] Display name on the approval screen (default "MCP agent")
47
+ --no-open [login] Don't launch a browser; just print the approval link
48
+ --timeout <sec> [login] Max seconds to wait (default: the server's window)
49
+ --args <json> [call] Tool arguments as a JSON object
50
+
51
+ Environment: NOETA_URL, NOETA_TOKEN (override the store), NOETA_CONFIG_DIR.
52
+ Exit codes: 0 ok · 1 error · 2 login denied/expired/timed out · 3 tool-call error.
53
+
54
+ The same access over raw HTTP (any language): POST /api/agent-auth/start → browser
55
+ approves at verifyUrl → POST /api/agent-auth/poll → Bearer token for POST /mcp.`;
56
+
57
+ // ── arg parsing (no deps) ─────────────────────────────────────────────────────
58
+
59
+ const argv = process.argv.slice(2);
60
+ const opts = { json: false, open: true, name: "MCP agent", url: null, timeout: null, args: null };
61
+ const positionals = [];
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === "-h" || a === "--help") {
65
+ console.log(USAGE);
66
+ process.exit(0);
67
+ } else if (a === "--json") opts.json = true;
68
+ else if (a === "--no-open") opts.open = false;
69
+ else if (a === "--name") opts.name = argv[++i] ?? opts.name;
70
+ else if (a === "--url") opts.url = argv[++i] ?? null;
71
+ else if (a === "--timeout") opts.timeout = Number(argv[++i]);
72
+ else if (a === "--args") opts.args = argv[++i] ?? null;
73
+ else if (a.startsWith("-")) die(`Unknown option: ${a}\n\n${USAGE}`);
74
+ else positionals.push(a);
75
+ }
76
+ const command = positionals.shift() ?? "help";
77
+
78
+ // In --json mode stdout is reserved for the final value; everything else → stderr.
79
+ const log = (msg) => (opts.json ? process.stderr : process.stdout).write(msg);
80
+ const emit = (human, jsonValue) =>
81
+ opts.json ? process.stdout.write(`${JSON.stringify(jsonValue)}\n`) : console.log(human);
82
+
83
+ function die(msg, code = 1) {
84
+ process.stderr.write(`✖ ${msg}\n`);
85
+ process.exit(code);
86
+ }
87
+
88
+ // ── credential store ──────────────────────────────────────────────────────────
89
+
90
+ const configDir = () => process.env.NOETA_CONFIG_DIR ?? join(homedir(), ".config", "noeta");
91
+ const credsPath = () => join(configDir(), "credentials.json");
92
+
93
+ function readCreds() {
94
+ try {
95
+ return JSON.parse(readFileSync(credsPath(), "utf8"));
96
+ } catch {
97
+ return { default: null, origins: {} };
98
+ }
99
+ }
100
+
101
+ function writeCreds(creds) {
102
+ mkdirSync(configDir(), { recursive: true });
103
+ writeFileSync(credsPath(), `${JSON.stringify(creds, null, 2)}\n`, { mode: 0o600 });
104
+ }
105
+
106
+ /** Resolve the deployment + token an authed command should use. */
107
+ function resolveSession() {
108
+ const creds = readCreds();
109
+ const url = (opts.url ?? process.env.NOETA_URL ?? creds.default ?? "").replace(/\/$/, "");
110
+ if (!url) die(`no deployment selected — run \`npx noeta-cli login\` first (or pass --url / set NOETA_URL)`);
111
+ const token = process.env.NOETA_TOKEN ?? creds.origins?.[url]?.token;
112
+ if (!token) die(`no token for ${url} — run \`npx noeta-cli login ${url}\` (or set NOETA_TOKEN)`);
113
+ return { url, token, creds };
114
+ }
115
+
116
+ // ── HTTP helpers ──────────────────────────────────────────────────────────────
117
+
118
+ async function post(baseUrl, path, body, headers = {}) {
119
+ try {
120
+ return await fetch(`${baseUrl}${path}`, {
121
+ method: "POST",
122
+ headers: { "Content-Type": "application/json", ...headers },
123
+ body: JSON.stringify(body),
124
+ });
125
+ } catch (err) {
126
+ die(`could not reach ${baseUrl} (${err.message}). Is the URL right and the server up?`);
127
+ }
128
+ }
129
+
130
+ let rpcId = 0;
131
+ async function mcp(session, method, params) {
132
+ const res = await post(session.url, "/mcp", { jsonrpc: "2.0", id: ++rpcId, method, params }, {
133
+ Authorization: `Bearer ${session.token}`,
134
+ });
135
+ if (res.status === 401) die(`unauthorized — the token for ${session.url} was revoked or expired. Run \`npx noeta-cli login ${session.url}\`.`);
136
+ if (!res.ok) die(`${method} failed: HTTP ${res.status} ${await res.text()}`);
137
+ const body = await res.json();
138
+ if (body.error) die(`${method} failed: ${body.error.message ?? JSON.stringify(body.error)}`);
139
+ return body.result;
140
+ }
141
+
142
+ /** Best-effort browser open; the printed URL is always the fallback (headless/SSH). */
143
+ function openBrowser(url) {
144
+ const [cmd, args] =
145
+ process.platform === "darwin"
146
+ ? ["open", [url]]
147
+ : process.platform === "win32"
148
+ ? ["cmd", ["/c", "start", "", url]]
149
+ : ["xdg-open", [url]];
150
+ try {
151
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
152
+ } catch {
153
+ /* fall back to the printed URL */
154
+ }
155
+ }
156
+
157
+ // ── commands ──────────────────────────────────────────────────────────────────
158
+
159
+ async function cmdLogin() {
160
+ // Last positional wins so npm scripts can bake in a default the caller overrides.
161
+ const baseUrl = (positionals.pop() ?? DEFAULT_URL).replace(/\/$/, "");
162
+
163
+ const startRes = await post(baseUrl, "/api/agent-auth/start", { clientName: opts.name });
164
+ if (!startRes.ok) die(`start failed: HTTP ${startRes.status} ${await startRes.text()}`);
165
+ const { deviceCode, verifyUrl, expiresIn, interval } = await startRes.json();
166
+
167
+ const windowSec = Number.isFinite(opts.timeout) && opts.timeout > 0 ? opts.timeout : expiresIn;
168
+ log(`\nAuthorize ${opts.name} in your browser (link expires in ${Math.round(expiresIn / 60)} min):\n`);
169
+ log(`\n ${verifyUrl}\n\n`);
170
+ if (opts.open) openBrowser(verifyUrl);
171
+ log("Waiting for approval…");
172
+
173
+ const deadline = Date.now() + windowSec * 1000;
174
+ let approval = null;
175
+ while (Date.now() < deadline) {
176
+ await new Promise((r) => setTimeout(r, (interval ?? 2) * 1000));
177
+ const res = await post(baseUrl, "/api/agent-auth/poll", { deviceCode });
178
+ if (res.status === 404) die("the request expired or was denied. Re-run to try again.", 2);
179
+ if (!res.ok) die(`poll failed: HTTP ${res.status}`);
180
+ const body = await res.json();
181
+ if (body.status === "approved") {
182
+ approval = body;
183
+ break;
184
+ }
185
+ log(".");
186
+ }
187
+ if (!approval) die("timed out waiting for approval. Re-run to try again.", 2);
188
+ log(" approved ✔\n");
189
+
190
+ const { token, agentId, agentName, workspaceId } = approval;
191
+ const creds = readCreds();
192
+ creds.origins = { ...creds.origins, [baseUrl]: { token, agentId, agentName, workspaceId } };
193
+ creds.default = baseUrl;
194
+ writeCreds(creds);
195
+
196
+ const mcpUrl = `${baseUrl}/mcp`;
197
+ if (opts.json) {
198
+ emit(null, { token, agentId, agentName, workspaceId, mcpUrl, baseUrl });
199
+ return;
200
+ }
201
+ console.log(`\nConnected as ${agentName} (${agentId}) — workspace ${workspaceId}.`);
202
+ console.log(`Token saved to ${credsPath()} (shown below once; the server stores only a hash).\n`);
203
+ console.log(`Use it straight from this CLI — no MCP configuration needed:`);
204
+ console.log(` npx noeta-cli tools`);
205
+ console.log(` npx noeta-cli call get_doc '{"docId":"<id>"}'\n`);
206
+ console.log(`Or wire up an MCP client:`);
207
+ console.log(` claude mcp add --transport http noeta ${mcpUrl} --header "Authorization: Bearer ${token}"\n`);
208
+ console.log(`Disconnect any time with: npx noeta-cli logout`);
209
+ }
210
+
211
+ async function cmdWhoami() {
212
+ const session = resolveSession();
213
+ const res = await fetch(`${session.url}/agent/me`, { headers: { Authorization: `Bearer ${session.token}` } });
214
+ if (res.status === 401) die(`unauthorized — run \`npx noeta-cli login ${session.url}\``);
215
+ if (!res.ok) die(`whoami failed: HTTP ${res.status}`);
216
+ const me = await res.json();
217
+ emit(
218
+ `${me.name} (${me.agentId})\n` +
219
+ ` deployment: ${session.url}\n` +
220
+ ` workspace: ${me.workspaceId ?? "—"}\n` +
221
+ ` acting for: ${me.delegatedUserId ?? "— (standalone service account)"}\n` +
222
+ ` access cap: ${me.roleCap ?? "none (the user's full role)"}`,
223
+ { ...me, baseUrl: session.url },
224
+ );
225
+ }
226
+
227
+ async function cmdTools() {
228
+ const session = resolveSession();
229
+ const result = await mcp(session, "tools/list", {});
230
+ const tools = result.tools ?? [];
231
+ if (opts.json) return emit(null, tools);
232
+ for (const t of tools) {
233
+ const first = (t.description ?? "").split(". ")[0];
234
+ console.log(`${t.name.padEnd(16)} ${first}`);
235
+ }
236
+ console.log(`\n${tools.length} tools. Call one with: npx noeta-cli call <tool> '<json-args>'`);
237
+ }
238
+
239
+ async function cmdCall() {
240
+ const name = positionals.shift();
241
+ if (!name) die(`usage: noeta call <tool> ['{"json":"args"}']`);
242
+ const raw = opts.args ?? positionals.shift() ?? "{}";
243
+ let args;
244
+ try {
245
+ args = JSON.parse(raw);
246
+ } catch {
247
+ die(`tool arguments must be a JSON object, got: ${raw}`);
248
+ }
249
+
250
+ const session = resolveSession();
251
+ const result = await mcp(session, "tools/call", { name, arguments: args });
252
+ const text = (result.content ?? [])
253
+ .filter((c) => c.type === "text")
254
+ .map((c) => c.text)
255
+ .join("\n");
256
+ if (result.isError) {
257
+ process.stderr.write(`✖ ${text}\n`);
258
+ process.exit(3);
259
+ }
260
+ // Tool results are already JSON text — print raw so output is pipeable either way.
261
+ process.stdout.write(`${text}\n`);
262
+ }
263
+
264
+ async function cmdLogout() {
265
+ const session = resolveSession();
266
+ // Revoke server-side first (best effort — the local forget still happens).
267
+ const res = await fetch(`${session.url}/agent/revoke`, {
268
+ method: "POST",
269
+ headers: { Authorization: `Bearer ${session.token}` },
270
+ });
271
+ const revoked = res.ok;
272
+ if (!revoked) process.stderr.write(`⚠ server-side revoke failed (HTTP ${res.status}) — token forgotten locally only\n`);
273
+
274
+ const creds = session.creds;
275
+ delete creds.origins?.[session.url];
276
+ if (creds.default === session.url) creds.default = Object.keys(creds.origins ?? {})[0] ?? null;
277
+ writeCreds(creds);
278
+ emit(`Disconnected from ${session.url}${revoked ? " (token revoked)" : ""}.`, { loggedOut: session.url, revoked });
279
+ }
280
+
281
+ // ── dispatch ──────────────────────────────────────────────────────────────────
282
+
283
+ const commands = { login: cmdLogin, whoami: cmdWhoami, tools: cmdTools, call: cmdCall, logout: cmdLogout };
284
+ if (command === "help") {
285
+ console.log(USAGE);
286
+ } else if (commands[command]) {
287
+ await commands[command]();
288
+ } else {
289
+ die(`unknown command: ${command}\n\n${USAGE}`);
290
+ }
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "noeta-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI portal to Noeta Cloud for agents & humans: `npx noeta-cli login` (browser approval, no prompts), then call the workspace API/MCP tools directly — no MCP client configuration required.",
5
+ "type": "module",
6
+ "bin": {
7
+ "noeta": "./cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "agent",
19
+ "cli",
20
+ "oauth-device-flow",
21
+ "noeta"
22
+ ],
23
+ "license": "MIT"
24
+ }