@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 +85 -0
- package/bin/noeis +7 -0
- package/package.json +31 -0
- package/src/cli.js +442 -0
- package/src/client.js +116 -0
- package/src/config.js +45 -0
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
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
|
+
};
|