@noeis/noeis-cli 0.1.1

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 ADDED
@@ -0,0 +1,85 @@
1
+ # @noeis/cli
2
+
3
+ Command-line client for scripting a Noeis wiki without an MCP-speaking agent.
4
+
5
+ ## Install
6
+
7
+ Current internal build:
8
+
9
+ ```bash
10
+ cd ~/Documents/GitHub/note-taker-3-1
11
+ npm i -g ./packages/cli
12
+ ```
13
+
14
+ Public package status: `@noeis/cli` is not published on npm yet. After publish, this becomes `npm i -g @noeis/cli`.
15
+
16
+ ## Connect an agent
17
+
18
+ The normal setup path opens Noeis in your browser, asks you to approve the local agent, writes the CLI token, writes the runtime MCP config, and runs an access check:
19
+
20
+ ```bash
21
+ noeis connect hermes
22
+ # or
23
+ noeis connect openclaw
24
+ # or
25
+ noeis connect codex
26
+ ```
27
+
28
+ Supported runtime names: `claude-code`, `codex`, `hermes`, `openclaw`, and `opencode`.
29
+
30
+ The generated runtime MCP config calls `noeis mcp`. The raw token stays in one place: the Noeis CLI config, normally `~/.config/noeis/config.json`. Generated MCP configs should not copy `NOEIS_TOKEN`.
31
+
32
+ For local/self-hosted API targets, pass both URLs:
33
+
34
+ ```bash
35
+ noeis connect hermes --api-url http://localhost:5500 --app-url http://localhost:3000
36
+ ```
37
+
38
+ If the browser cannot open automatically:
39
+
40
+ ```bash
41
+ noeis connect hermes --no-browser
42
+ ```
43
+
44
+ ## Agent launch links
45
+
46
+ Noeis can create browser links that feed a task to a connected runtime:
47
+
48
+ ```text
49
+ https://www.noeis.io/a/run/at_...
50
+ ```
51
+
52
+ Open the link, review the task, then dispatch it to OpenClaw, Hermes, Codex, or another connected runtime. If the runtime is not connected yet, Noeis shows the exact connect command to run and preserves the task link.
53
+
54
+ ## Manual auth
55
+
56
+ You can still create a Connected agents token in Noeis Settings and paste it manually:
57
+
58
+ ```bash
59
+ noeis login --token ntk_at_... --api-url http://localhost:5500
60
+ ```
61
+
62
+ You can also skip stored config and use environment variables:
63
+
64
+ ```bash
65
+ NOEIS_TOKEN=ntk_at_... NOEIS_API_URL=https://api.noeis.io noeis pages list
66
+ ```
67
+
68
+ Manual environment variables are useful for scripts. Runtime MCP configs should prefer `noeis mcp` so secrets remain centralized.
69
+
70
+ ## Commands
71
+
72
+ ```bash
73
+ noeis pages list
74
+ noeis mcp --help
75
+ noeis pages get <id> --json
76
+ noeis ingest https://example.com/research
77
+ noeis ingest ./source.txt --title "Source title"
78
+ noeis draft <pageId>
79
+ noeis ask <pageId> "What changed?"
80
+ noeis schema show
81
+ noeis schema edit
82
+ noeis log --since 1d
83
+ ```
84
+
85
+ Write commands require a Connected agents token with `agent-write`.
package/bin/noeis ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runCli } from '../src/cli.js';
3
+
4
+ runCli(process.argv.slice(2)).catch((error) => {
5
+ process.stderr.write(`${error?.message || error}\n`);
6
+ process.exit(error?.exitCode || 1);
7
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@noeis/noeis-cli",
3
+ "version": "0.1.1",
4
+ "description": "Command-line client for scripting a Noeis wiki.",
5
+ "type": "module",
6
+ "bin": {
7
+ "noeis": "bin/noeis"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "test": "node test/cli.test.js",
16
+ "smoke": "node bin/noeis --help"
17
+ },
18
+ "keywords": [
19
+ "noeis",
20
+ "wiki",
21
+ "cli",
22
+ "agent"
23
+ ],
24
+ "license": "ISC",
25
+ "dependencies": {
26
+ "@noeis/wiki-mcp": "^0.1.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=18.17"
30
+ }
31
+ }
package/src/cli.js ADDED
@@ -0,0 +1,442 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { stdin as input, stdout as output } from 'node:process';
7
+
8
+ import { NoeisCliClient, NoeisCliError } from './client.js';
9
+ import { DEFAULT_API_URL, DEFAULT_APP_URL, readConfig, resolveAuth, writeConfig } from './config.js';
10
+
11
+ const HELP = `Noeis CLI
12
+
13
+ Usage:
14
+ noeis connect [claude-code|codex|hermes|openclaw|opencode] [--label name] [--no-browser]
15
+ noeis mcp [--help]
16
+ noeis login [--token ntk_at_...] [--api-url https://api.noeis.io]
17
+ noeis pages list [--query text] [--status draft|published|archived] [--page-type type] [--limit n] [--json]
18
+ noeis pages get <id> [--json]
19
+ noeis ingest <url|file> [--title title] [--json]
20
+ noeis draft <pageId> [--json]
21
+ noeis ask <pageId> "question" [--json]
22
+ noeis schema show
23
+ noeis schema edit
24
+ noeis log [--since 1d] [--limit n] [--json]
25
+
26
+ Environment:
27
+ NOEIS_TOKEN, NOEIS_API_URL, NOEIS_APP_URL, NOEIS_CONFIG_DIR
28
+ `;
29
+
30
+ const optionValue = (args, name, fallback = '') => {
31
+ const index = args.indexOf(name);
32
+ if (index === -1) return fallback;
33
+ return args[index + 1] || '';
34
+ };
35
+
36
+ const hasFlag = (args, name) => args.includes(name);
37
+
38
+ const compactArgs = (args) => args.filter((arg, index) => {
39
+ if (arg.startsWith('-')) return false;
40
+ const previous = args[index - 1];
41
+ if (previous?.startsWith('-') && !['--json'].includes(previous)) return false;
42
+ return true;
43
+ });
44
+
45
+ const sinceToIso = (value = '') => {
46
+ const trimmed = String(value || '').trim();
47
+ if (!trimmed) return '';
48
+ if (/^\d+[hdwm]$/.test(trimmed)) {
49
+ const amount = Number(trimmed.slice(0, -1));
50
+ const unit = trimmed.slice(-1);
51
+ const multipliers = {
52
+ h: 60 * 60 * 1000,
53
+ d: 24 * 60 * 60 * 1000,
54
+ w: 7 * 24 * 60 * 60 * 1000,
55
+ m: 30 * 24 * 60 * 60 * 1000
56
+ };
57
+ return new Date(Date.now() - amount * multipliers[unit]).toISOString();
58
+ }
59
+ const parsed = new Date(trimmed);
60
+ return Number.isNaN(parsed.getTime()) ? trimmed : parsed.toISOString();
61
+ };
62
+
63
+ const openBrowser = (url, { platform = process.platform } = {}) => {
64
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
65
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
66
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
67
+ child.unref?.();
68
+ };
69
+
70
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
71
+
72
+ const RUNTIME_ALIASES = {
73
+ claude: 'claude-code',
74
+ 'claude-code': 'claude-code',
75
+ codex: 'codex',
76
+ hermes: 'hermes',
77
+ openclaw: 'openclaw',
78
+ opencode: 'opencode'
79
+ };
80
+
81
+ const RUNTIME_LABELS = {
82
+ 'claude-code': 'Claude Code',
83
+ codex: 'Codex',
84
+ hermes: 'Hermes',
85
+ openclaw: 'OpenClaw',
86
+ opencode: 'OpenCode'
87
+ };
88
+
89
+ const normalizeRuntime = (value = '') => {
90
+ const runtime = String(value || '').trim().toLowerCase();
91
+ return RUNTIME_ALIASES[runtime] || 'agent';
92
+ };
93
+
94
+ const runtimeLabel = (runtime = 'agent') => RUNTIME_LABELS[runtime] || 'Noeis agent';
95
+
96
+ const safeReadJson = (filePath) => {
97
+ try {
98
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
99
+ } catch (error) {
100
+ if (error.code === 'ENOENT') return {};
101
+ throw error;
102
+ }
103
+ };
104
+
105
+ const ensurePrivateDir = (dirPath) => {
106
+ fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 });
107
+ try {
108
+ fs.chmodSync(dirPath, 0o700);
109
+ } catch {
110
+ // Best effort on filesystems that do not support chmod.
111
+ }
112
+ };
113
+
114
+ const writeJsonFile = (filePath, value) => {
115
+ ensurePrivateDir(path.dirname(filePath));
116
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
117
+ };
118
+
119
+ const mcpServerConfig = ({ configDir, apiUrl }) => ({
120
+ command: 'noeis',
121
+ args: ['mcp'],
122
+ env: {
123
+ ...(configDir ? { NOEIS_CONFIG_DIR: configDir } : {}),
124
+ ...(apiUrl && apiUrl !== DEFAULT_API_URL ? { NOEIS_API_URL: apiUrl } : {})
125
+ }
126
+ });
127
+
128
+ const writeTomlMcpConfig = (filePath, { configDir, apiUrl }) => {
129
+ ensurePrivateDir(path.dirname(filePath));
130
+ let current = '';
131
+ try {
132
+ current = fs.readFileSync(filePath, 'utf8');
133
+ } catch (error) {
134
+ if (error.code !== 'ENOENT') throw error;
135
+ }
136
+ const envParts = [];
137
+ if (configDir) envParts.push(`NOEIS_CONFIG_DIR = ${JSON.stringify(configDir)}`);
138
+ if (apiUrl && apiUrl !== DEFAULT_API_URL) envParts.push(`NOEIS_API_URL = ${JSON.stringify(apiUrl)}`);
139
+ const envLine = envParts.length ? `\nenv = { ${envParts.join(', ')} }` : '';
140
+ const block = `[mcp_servers.noeis-wiki]
141
+ command = "noeis"
142
+ args = ["mcp"]${envLine}
143
+ `;
144
+ const next = /\[mcp_servers\.noeis-wiki\][\s\S]*?(?=\n\[|\s*$)/m.test(current)
145
+ ? current.replace(/\[mcp_servers\.noeis-wiki\][\s\S]*?(?=\n\[|\s*$)/m, block.trimEnd())
146
+ : `${current.trimEnd()}${current.trim() ? '\n\n' : ''}${block}`;
147
+ fs.writeFileSync(filePath, `${next.trimEnd()}\n`, { mode: 0o600 });
148
+ };
149
+
150
+ const writeRuntimeMcpConfig = ({ runtime, apiUrl, configDir, env = process.env } = {}) => {
151
+ const home = os.homedir();
152
+ const server = mcpServerConfig({ configDir, apiUrl });
153
+ if (runtime === 'codex') {
154
+ const filePath = path.join(home, '.codex', 'config.toml');
155
+ writeTomlMcpConfig(filePath, { configDir, apiUrl });
156
+ return filePath;
157
+ }
158
+
159
+ const targets = {
160
+ 'claude-code': path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'claude-code', 'mcp.json'),
161
+ hermes: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'hermes', 'mcp.json'),
162
+ openclaw: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'openclaw', 'mcp.json'),
163
+ opencode: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode', 'opencode.json'),
164
+ agent: path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'noeis', 'mcp.json')
165
+ };
166
+ const filePath = targets[runtime] || targets.agent;
167
+ const config = safeReadJson(filePath);
168
+ if (runtime === 'opencode') {
169
+ config.mcp = { ...(config.mcp || {}), 'noeis-wiki': server };
170
+ } else {
171
+ config.servers = { ...(config.servers || {}), 'noeis-wiki': { transport: 'stdio', ...server } };
172
+ delete config['noeis-wiki'];
173
+ }
174
+ writeJsonFile(filePath, config);
175
+ return filePath;
176
+ };
177
+
178
+ const requestJson = async (url, { method = 'GET', body, fetchImpl = global.fetch } = {}) => {
179
+ const response = await fetchImpl(url, {
180
+ method,
181
+ headers: {
182
+ Accept: 'application/json',
183
+ ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
184
+ },
185
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {})
186
+ });
187
+ const contentType = response.headers?.get?.('content-type') || '';
188
+ const payload = contentType.includes('application/json') ? await response.json() : await response.text();
189
+ if (!response.ok) {
190
+ const message = typeof payload === 'object' && payload?.error
191
+ ? payload.error
192
+ : `Noeis connection request failed with ${response.status}`;
193
+ throw new NoeisCliError(message, { status: response.status });
194
+ }
195
+ return payload;
196
+ };
197
+
198
+ const readTokenFromPrompt = async ({ inputStream = input, outputStream = output } = {}) => {
199
+ const rl = readline.createInterface({ input: inputStream, output: outputStream });
200
+ try {
201
+ return (await rl.question('Paste your Connected agents token: ')).trim();
202
+ } finally {
203
+ rl.close();
204
+ }
205
+ };
206
+
207
+ const printJson = (value, io) => {
208
+ io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
209
+ };
210
+
211
+ const printRows = (rows = [], io) => {
212
+ if (!rows.length) {
213
+ io.stdout.write('No rows.\n');
214
+ return;
215
+ }
216
+ rows.forEach((row) => {
217
+ io.stdout.write(`${row.id || row._id || ''}\t${row.title || row.action || row.type || 'Untitled'}\t${row.pageType || row.status || ''}\n`);
218
+ });
219
+ };
220
+
221
+ const printResult = (value, { json = false, io }) => {
222
+ if (json || typeof value !== 'object' || value === null) {
223
+ printJson(value, io);
224
+ return;
225
+ }
226
+ if (Array.isArray(value)) {
227
+ printRows(value, io);
228
+ return;
229
+ }
230
+ if (Array.isArray(value.pages)) printRows(value.pages, io);
231
+ else if (Array.isArray(value.events)) printRows(value.events, io);
232
+ else printJson(value, io);
233
+ };
234
+
235
+ const sourceFromInput = (value, title = '') => {
236
+ if (/^https?:\/\//i.test(value)) return { type: 'url', url: value, title: title || undefined };
237
+ const absolute = path.resolve(value);
238
+ const text = fs.readFileSync(absolute, 'utf8');
239
+ return { type: 'text', text, title: title || path.basename(absolute) };
240
+ };
241
+
242
+ const runLogin = async (args, context) => {
243
+ const auth = resolveAuth(context);
244
+ const appUrl = optionValue(args, '--app-url', auth.appUrl || DEFAULT_APP_URL);
245
+ const apiUrl = optionValue(args, '--api-url', auth.apiUrl || DEFAULT_API_URL);
246
+ const tokenArg = optionValue(args, '--token');
247
+ if (!tokenArg && !hasFlag(args, '--no-browser')) {
248
+ openBrowser(`${appUrl}/settings`, context);
249
+ }
250
+ const token = tokenArg || await readTokenFromPrompt(context);
251
+ if (!token) throw new NoeisCliError('Token is required.');
252
+ const configPath = writeConfig({ ...readConfig(context), token, apiUrl, appUrl }, context);
253
+ context.io.stdout.write(`Saved Noeis CLI config to ${configPath}\n`);
254
+ };
255
+
256
+ const runConnect = async (args, context) => {
257
+ const auth = resolveAuth(context);
258
+ const positional = compactArgs(args);
259
+ const runtime = normalizeRuntime(positional[0] || optionValue(args, '--runtime'));
260
+ const appUrl = optionValue(args, '--app-url', auth.appUrl || DEFAULT_APP_URL);
261
+ const apiUrl = optionValue(args, '--api-url', auth.apiUrl || DEFAULT_API_URL);
262
+ const label = optionValue(args, '--label', `${runtimeLabel(runtime)} local`);
263
+ const timeoutSec = Math.max(15, Math.min(Number(optionValue(args, '--timeout', '300')) || 300, 1800));
264
+ const fetchImpl = context.fetchImpl || global.fetch;
265
+ const io = context.io || { stdout: process.stdout, stderr: process.stderr };
266
+ const browserOpener = context.openBrowser || openBrowser;
267
+ const pause = context.sleep || sleep;
268
+
269
+ const created = await requestJson(`${apiUrl.replace(/\/+$/g, '')}/api/agent-connect/sessions`, {
270
+ method: 'POST',
271
+ fetchImpl,
272
+ body: {
273
+ runtime,
274
+ label,
275
+ appUrl,
276
+ apiUrl,
277
+ scopes: ['read', 'agent-write']
278
+ }
279
+ });
280
+ const session = created.session || {};
281
+ const authorizeUrl = created.authorizeUrl;
282
+ if (!authorizeUrl || !created.pollSecret || !session.sessionId) {
283
+ throw new NoeisCliError('Noeis did not return a usable connection session.');
284
+ }
285
+
286
+ io.stdout.write(`Approve ${runtimeLabel(runtime)} in your browser.\n`);
287
+ io.stdout.write(`Device code: ${session.deviceCode || 'unknown'}\n`);
288
+ io.stdout.write(`${authorizeUrl}\n`);
289
+ if (!hasFlag(args, '--no-browser')) browserOpener(authorizeUrl, context);
290
+
291
+ const deadline = Date.now() + timeoutSec * 1000;
292
+ let approved = null;
293
+ while (Date.now() < deadline) {
294
+ const polled = await requestJson(`${apiUrl.replace(/\/+$/g, '')}/api/agent-connect/sessions/${encodeURIComponent(session.sessionId)}/poll`, {
295
+ method: 'POST',
296
+ fetchImpl,
297
+ body: { pollSecret: created.pollSecret }
298
+ });
299
+ if (polled.session?.status === 'approved') {
300
+ approved = polled;
301
+ break;
302
+ }
303
+ if (['expired', 'cancelled'].includes(polled.session?.status)) {
304
+ throw new NoeisCliError(`Connection session ${polled.session.status}. Run \`noeis connect ${runtime}\` again.`);
305
+ }
306
+ await pause(Math.max(1, Number(polled.pollIntervalSec || created.pollIntervalSec || 2)) * 1000);
307
+ }
308
+ if (!approved?.secret) throw new NoeisCliError('Timed out waiting for browser approval.');
309
+
310
+ const configPath = writeConfig({ ...readConfig(context), token: approved.secret, apiUrl, appUrl }, context);
311
+ let runtimeConfigPath = '';
312
+ if (!hasFlag(args, '--no-config')) {
313
+ runtimeConfigPath = writeRuntimeMcpConfig({
314
+ runtime,
315
+ apiUrl,
316
+ configDir: path.dirname(configPath),
317
+ env: context.env || process.env
318
+ });
319
+ }
320
+
321
+ try {
322
+ const client = new NoeisCliClient({ token: approved.secret, apiUrl, fetchImpl, env: { ...(context.env || process.env), NOEIS_TOKEN: approved.secret, NOEIS_API_URL: apiUrl } });
323
+ await client.listPages({ limit: 1 });
324
+ io.stdout.write(`Connected ${runtimeLabel(runtime)} with read/write Noeis access.\n`);
325
+ } catch (error) {
326
+ io.stderr.write(`Connected, but the access check failed: ${error.message || error}\n`);
327
+ }
328
+ io.stdout.write(`Saved Noeis CLI config to ${configPath}\n`);
329
+ if (runtimeConfigPath) io.stdout.write(`Updated ${runtimeLabel(runtime)} MCP config at ${runtimeConfigPath}\n`);
330
+ if (runtimeConfigPath) io.stdout.write(`Runtime config reads the token from ${configPath}; no raw token was copied into MCP config.\n`);
331
+ if (hasFlag(args, '--print-token')) io.stdout.write(`${approved.secret}\n`);
332
+ };
333
+
334
+ const runMcp = async (args, context) => {
335
+ if (args.includes('--help') || args.includes('-h')) {
336
+ context.io.stdout.write(`Noeis MCP bridge\n\nUsage: noeis mcp\n\nReads token/API settings from NOEIS_CONFIG_DIR or ~/.config/noeis/config.json.\n`);
337
+ return;
338
+ }
339
+ const auth = resolveAuth(context);
340
+ if (!auth.token) throw new NoeisCliError('Noeis token is missing. Run `noeis connect <runtime>` first.');
341
+ process.env.NOEIS_TOKEN = auth.token;
342
+ process.env.NOEIS_API_URL = auth.apiUrl;
343
+ try {
344
+ let mod;
345
+ try {
346
+ mod = await import('@noeis/wiki-mcp');
347
+ } catch {
348
+ const localMcpPath = new URL('../../wiki-mcp/src/server.js', import.meta.url);
349
+ mod = await import(localMcpPath.href);
350
+ }
351
+ await mod.main([]);
352
+ } catch (error) {
353
+ throw new NoeisCliError(`Unable to start Noeis MCP bridge. Install the CLI with its MCP dependency or publish/install @noeis/wiki-mcp. ${error.message || error}`);
354
+ }
355
+ };
356
+
357
+ const editSchema = async (client, context) => {
358
+ const current = await client.getSchema();
359
+ const content = String(current.content || '');
360
+ const filePath = path.join(os.tmpdir(), `noeis-schema-${Date.now()}.md`);
361
+ fs.writeFileSync(filePath, content);
362
+ const editor = context.env.EDITOR || context.env.VISUAL || 'vi';
363
+ const result = spawnSync(editor, [filePath], { stdio: 'inherit' });
364
+ if (result.status !== 0) throw new NoeisCliError(`Editor exited with ${result.status}.`);
365
+ const next = fs.readFileSync(filePath, 'utf8');
366
+ if (next === content) {
367
+ context.io.stdout.write('Schema unchanged.\n');
368
+ return;
369
+ }
370
+ await client.updateSchema(next);
371
+ context.io.stdout.write('Schema updated.\n');
372
+ };
373
+
374
+ export const runCli = async (argv = [], context = {}) => {
375
+ const io = context.io || { stdout: process.stdout, stderr: process.stderr };
376
+ const env = context.env || process.env;
377
+ const args = [...argv];
378
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
379
+ io.stdout.write(HELP);
380
+ return;
381
+ }
382
+
383
+ const command = args[0];
384
+ if (command === 'connect') {
385
+ await runConnect(args.slice(1), { ...context, env, io });
386
+ return;
387
+ }
388
+ if (command === 'mcp') {
389
+ await runMcp(args.slice(1), { ...context, env, io });
390
+ return;
391
+ }
392
+ if (command === 'login') {
393
+ await runLogin(args.slice(1), { ...context, env, io });
394
+ return;
395
+ }
396
+
397
+ const client = context.client || new NoeisCliClient({
398
+ env,
399
+ fetchImpl: context.fetchImpl || global.fetch
400
+ });
401
+ const json = hasFlag(args, '--json');
402
+ const positional = compactArgs(args);
403
+ let result;
404
+
405
+ if (command === 'pages' && positional[1] === 'list') {
406
+ result = await client.listPages({
407
+ q: optionValue(args, '--query') || optionValue(args, '-q'),
408
+ status: optionValue(args, '--status'),
409
+ pageType: optionValue(args, '--page-type'),
410
+ visibility: optionValue(args, '--visibility'),
411
+ limit: optionValue(args, '--limit', '100')
412
+ });
413
+ } else if (command === 'pages' && positional[1] === 'get') {
414
+ if (!positional[2]) throw new NoeisCliError('Usage: noeis pages get <id>');
415
+ result = await client.getPage(positional[2]);
416
+ } else if (command === 'ingest') {
417
+ if (!positional[1]) throw new NoeisCliError('Usage: noeis ingest <url|file>');
418
+ result = await client.ingestSource(sourceFromInput(positional[1], optionValue(args, '--title')));
419
+ } else if (command === 'draft') {
420
+ if (!positional[1]) throw new NoeisCliError('Usage: noeis draft <pageId>');
421
+ result = await client.draftPage(positional[1]);
422
+ } else if (command === 'ask') {
423
+ if (!positional[1] || !positional[2]) throw new NoeisCliError('Usage: noeis ask <pageId> "question"');
424
+ result = await client.askPage(positional[1], positional.slice(2).join(' '));
425
+ } else if (command === 'schema' && positional[1] === 'show') {
426
+ const schema = await client.getSchema();
427
+ io.stdout.write(`${schema.content || ''}\n`);
428
+ return;
429
+ } else if (command === 'schema' && positional[1] === 'edit') {
430
+ await editSchema(client, { ...context, env, io });
431
+ return;
432
+ } else if (command === 'log') {
433
+ result = await client.listActivity({
434
+ since: sinceToIso(optionValue(args, '--since')),
435
+ limit: optionValue(args, '--limit', '50')
436
+ });
437
+ } else {
438
+ throw new NoeisCliError(`Unknown command. Run \`noeis --help\`.`);
439
+ }
440
+
441
+ printResult(result, { json, io });
442
+ };
package/src/client.js ADDED
@@ -0,0 +1,116 @@
1
+ import { resolveAuth } from './config.js';
2
+
3
+ export class NoeisCliError extends Error {
4
+ constructor(message, { status = 0, exitCode = 1 } = {}) {
5
+ super(message);
6
+ this.name = 'NoeisCliError';
7
+ this.status = status;
8
+ this.exitCode = exitCode;
9
+ }
10
+ }
11
+
12
+ const normalizeArrayPayload = (payload, key) => {
13
+ if (Array.isArray(payload)) return payload;
14
+ if (Array.isArray(payload?.[key])) return payload[key];
15
+ return [];
16
+ };
17
+
18
+ const toDoc = (body) => {
19
+ if (body === undefined || body === null || body === '') return undefined;
20
+ if (typeof body === 'object' && !Array.isArray(body)) return body;
21
+ const text = String(body || '').trim();
22
+ return {
23
+ type: 'doc',
24
+ content: text ? [{ type: 'paragraph', content: [{ type: 'text', text }] }] : []
25
+ };
26
+ };
27
+
28
+ export class NoeisCliClient {
29
+ constructor({ token, apiUrl, fetchImpl = global.fetch, env = process.env } = {}) {
30
+ const auth = resolveAuth({ env });
31
+ this.token = String(token || auth.token || '').trim();
32
+ this.apiUrl = String(apiUrl || auth.apiUrl || '').replace(/\/+$/g, '');
33
+ this.fetch = fetchImpl;
34
+ if (typeof this.fetch !== 'function') throw new NoeisCliError('Node 18+ is required because fetch is not available.');
35
+ }
36
+
37
+ requireToken() {
38
+ if (!this.token) {
39
+ throw new NoeisCliError('No Noeis token found. Run `noeis login --token ntk_at_...` or set NOEIS_TOKEN.');
40
+ }
41
+ }
42
+
43
+ buildUrl(path, query = {}) {
44
+ const url = new URL(path, `${this.apiUrl}/`);
45
+ Object.entries(query || {}).forEach(([key, value]) => {
46
+ if (value === undefined || value === null || value === '') return;
47
+ url.searchParams.set(key, String(value));
48
+ });
49
+ return url;
50
+ }
51
+
52
+ async request(path, { method = 'GET', query = {}, body, expectText = false } = {}) {
53
+ this.requireToken();
54
+ const headers = {
55
+ Authorization: `Bearer ${this.token}`,
56
+ Accept: expectText ? 'text/markdown, text/plain;q=0.9, application/json;q=0.8' : 'application/json'
57
+ };
58
+ const init = { method, headers };
59
+ if (body !== undefined) {
60
+ headers['Content-Type'] = 'application/json';
61
+ init.body = JSON.stringify(body);
62
+ }
63
+ const response = await this.fetch(this.buildUrl(path, query), init);
64
+ const contentType = response.headers?.get?.('content-type') || '';
65
+ const payload = expectText
66
+ ? await response.text()
67
+ : (contentType.includes('application/json') ? await response.json() : await response.text());
68
+ if (!response.ok) {
69
+ const message = typeof payload === 'object' && payload?.error
70
+ ? payload.error
71
+ : `Noeis API request failed with ${response.status}`;
72
+ throw new NoeisCliError(message, { status: response.status });
73
+ }
74
+ return payload;
75
+ }
76
+
77
+ listPages({ q, status, pageType, visibility, limit = 100 } = {}) {
78
+ return this.request('/api/wiki/pages', { query: { q, status, pageType, visibility, limit } })
79
+ .then(payload => normalizeArrayPayload(payload, 'pages'));
80
+ }
81
+
82
+ getPage(pageId) {
83
+ return this.request(`/api/wiki/pages/${encodeURIComponent(pageId)}`);
84
+ }
85
+
86
+ ingestSource(source) {
87
+ return this.request('/api/wiki/ingest', { method: 'POST', body: { source } });
88
+ }
89
+
90
+ draftPage(pageId) {
91
+ return this.request(`/api/wiki/pages/${encodeURIComponent(pageId)}/ai/draft`, { method: 'POST', body: {} });
92
+ }
93
+
94
+ askPage(pageId, question) {
95
+ return this.request(`/api/wiki/pages/${encodeURIComponent(pageId)}/ask`, { method: 'POST', body: { question } });
96
+ }
97
+
98
+ getSchema() {
99
+ return this.request('/api/wiki/schema');
100
+ }
101
+
102
+ updateSchema(content) {
103
+ return this.request('/api/wiki/schema', { method: 'PUT', body: { content } });
104
+ }
105
+
106
+ listActivity({ limit = 50, since } = {}) {
107
+ return this.request('/api/wiki/activity', { query: { limit, since } });
108
+ }
109
+
110
+ createPage({ title, pageType, body, sourceScope } = {}) {
111
+ return this.request('/api/wiki/pages', {
112
+ method: 'POST',
113
+ body: { title, pageType, body: toDoc(body), sourceScope }
114
+ });
115
+ }
116
+ }
package/src/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ export const DEFAULT_API_URL = 'https://api.noeis.io';
6
+ export const DEFAULT_APP_URL = 'https://www.noeis.io';
7
+
8
+ export const resolveConfigDir = ({ env = process.env } = {}) => (
9
+ env.NOEIS_CONFIG_DIR ||
10
+ path.join(env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'noeis')
11
+ );
12
+
13
+ export const resolveConfigPath = (options = {}) => path.join(resolveConfigDir(options), 'config.json');
14
+
15
+ export const readConfig = ({ env = process.env } = {}) => {
16
+ const configPath = resolveConfigPath({ env });
17
+ try {
18
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
19
+ } catch (error) {
20
+ if (error.code === 'ENOENT') return {};
21
+ throw error;
22
+ }
23
+ };
24
+
25
+ export const writeConfig = (config = {}, { env = process.env } = {}) => {
26
+ const configDir = resolveConfigDir({ env });
27
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
28
+ try {
29
+ fs.chmodSync(configDir, 0o700);
30
+ } catch {
31
+ // Best effort on filesystems that do not support chmod.
32
+ }
33
+ const configPath = resolveConfigPath({ env });
34
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
35
+ return configPath;
36
+ };
37
+
38
+ export const resolveAuth = ({ env = process.env } = {}) => {
39
+ const config = readConfig({ env });
40
+ return {
41
+ token: String(env.NOEIS_TOKEN || config.token || '').trim(),
42
+ apiUrl: String(env.NOEIS_API_URL || config.apiUrl || DEFAULT_API_URL).replace(/\/+$/g, ''),
43
+ appUrl: String(env.NOEIS_APP_URL || config.appUrl || DEFAULT_APP_URL).replace(/\/+$/g, '')
44
+ };
45
+ };