noslopui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -18
- package/SKILL.md +9 -13
- package/dist/adapters/claude-code.js +21 -121
- package/dist/adapters/codex.js +196 -0
- package/dist/adapters/index.js +16 -5
- package/dist/adapters/json-adapter.js +135 -0
- package/dist/adapters/json-agents.js +88 -0
- package/dist/adapters/types.js +4 -4
- package/dist/commands/account.js +49 -0
- package/dist/commands/catalog.js +191 -0
- package/dist/commands/doctor.js +39 -28
- package/dist/commands/init.js +69 -35
- package/dist/commands/login.js +31 -21
- package/dist/commands/remove.js +40 -5
- package/dist/commands/skill.js +42 -0
- package/dist/commands/update.js +17 -11
- package/dist/index.js +106 -29
- package/dist/lib/api.js +92 -15
- package/dist/lib/json-file.js +7 -2
- package/dist/lib/paths.js +41 -0
- package/dist/lib/session.js +49 -0
- package/dist/lib/skill-file.js +26 -0
- package/dist/lib/version.js +1 -1
- package/package.json +1 -1
package/dist/lib/api.js
CHANGED
|
@@ -76,19 +76,28 @@ export async function waitForApproval(started, onTick) {
|
|
|
76
76
|
}
|
|
77
77
|
throw new ApiError('The code expired before it was approved. Run the command again to get a new one.');
|
|
78
78
|
}
|
|
79
|
+
const MCP_HEADERS = {
|
|
80
|
+
'content-type': 'application/json',
|
|
81
|
+
accept: 'application/json, text/event-stream',
|
|
82
|
+
'mcp-protocol-version': '2025-06-18',
|
|
83
|
+
'user-agent': USER_AGENT,
|
|
84
|
+
};
|
|
85
|
+
/** The last JSON-RPC message in a reply, whether it came as plain JSON or SSE-framed. */
|
|
86
|
+
function rpcBody(text) {
|
|
87
|
+
const line = text.split(/\r?\n/).filter((l) => l.startsWith('data: ')).pop();
|
|
88
|
+
return JSON.parse(line ? line.slice(6) : text);
|
|
89
|
+
}
|
|
79
90
|
/**
|
|
80
91
|
* `doctor`'s probe: can we reach the MCP server, and does this key still work?
|
|
81
|
-
*
|
|
82
|
-
*
|
|
92
|
+
*
|
|
93
|
+
* Every MCP call needs a key or a sign-in, so without one the right answer is
|
|
94
|
+
* our own 401 pointing at the OAuth metadata — that's "reachable", and a
|
|
95
|
+
* challenge page or a proxy error is not. With a key it calls list_collections,
|
|
96
|
+
* the one tool that costs the account nothing: no trial clock, no fair-use count.
|
|
83
97
|
*/
|
|
84
98
|
export async function checkServer(key) {
|
|
85
99
|
let res;
|
|
86
|
-
const headers = {
|
|
87
|
-
'content-type': 'application/json',
|
|
88
|
-
accept: 'application/json, text/event-stream',
|
|
89
|
-
'mcp-protocol-version': '2025-06-18',
|
|
90
|
-
'user-agent': USER_AGENT,
|
|
91
|
-
};
|
|
100
|
+
const headers = { ...MCP_HEADERS };
|
|
92
101
|
if (key)
|
|
93
102
|
headers.authorization = `Bearer ${key}`;
|
|
94
103
|
try {
|
|
@@ -106,28 +115,96 @@ export async function checkServer(key) {
|
|
|
106
115
|
catch {
|
|
107
116
|
return { reachable: false, detail: `Could not reach ${mcpUrl()}.` };
|
|
108
117
|
}
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
if (res.status === 401) {
|
|
120
|
+
const ours = /resource_metadata=/.test(res.headers.get('www-authenticate') ?? '');
|
|
121
|
+
if (!ours)
|
|
122
|
+
return { reachable: false, detail: `${mcpUrl()} answered 401, but not in the way noslopUI does — something in between is answering.` };
|
|
123
|
+
if (!key)
|
|
124
|
+
return { reachable: true, detail: 'Server reachable, and asking for sign-in as it should.' };
|
|
125
|
+
let message = 'The configured key was refused.';
|
|
126
|
+
try {
|
|
127
|
+
message = rpcBody(text)?.error?.message ?? message;
|
|
128
|
+
}
|
|
129
|
+
catch { }
|
|
130
|
+
return { reachable: true, detail: 'Server reachable.', keyState: 'rejected', keyDetail: message.split('. ')[0] };
|
|
131
|
+
}
|
|
109
132
|
if (!res.ok)
|
|
110
133
|
return { reachable: false, detail: `${mcpUrl()} answered HTTP ${res.status}.` };
|
|
111
|
-
|
|
112
|
-
const line = text.split(/\r?\n/).filter((l) => l.startsWith('data: ')).pop();
|
|
113
|
-
let json = null;
|
|
134
|
+
let json;
|
|
114
135
|
try {
|
|
115
|
-
json =
|
|
136
|
+
json = rpcBody(text);
|
|
116
137
|
}
|
|
117
138
|
catch {
|
|
118
139
|
return { reachable: false, detail: 'The server answered with something unreadable.' };
|
|
119
140
|
}
|
|
120
141
|
if (!key)
|
|
121
142
|
return { reachable: true, detail: `${(json?.result?.tools ?? []).length} tools available.` };
|
|
122
|
-
|
|
123
|
-
const body = json?.result?.content?.[0]?.text ?? '';
|
|
124
|
-
if (isError) {
|
|
143
|
+
if (json?.result?.isError === true) {
|
|
125
144
|
// The server writes these for an agent to relay; they're already the right
|
|
126
145
|
// words for a person, so pass them through rather than inventing our own.
|
|
146
|
+
const body = json?.result?.content?.[0]?.text ?? '';
|
|
127
147
|
return { reachable: true, detail: 'Server reachable.', keyState: 'rejected', keyDetail: body.split('. ')[0] };
|
|
128
148
|
}
|
|
129
149
|
return { reachable: true, detail: 'Server reachable.', keyState: 'ok', keyDetail: 'Key accepted.' };
|
|
130
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* One MCP tool call — what the terminal commands are built on, so the CLI and
|
|
153
|
+
* an agent can never see different things, and every gate (plan, trial,
|
|
154
|
+
* fair use) is the server's.
|
|
155
|
+
*/
|
|
156
|
+
export async function callTool(key, name, args = {}) {
|
|
157
|
+
let res;
|
|
158
|
+
try {
|
|
159
|
+
res = await fetch(mcpUrl(), {
|
|
160
|
+
method: 'POST',
|
|
161
|
+
headers: { ...MCP_HEADERS, authorization: `Bearer ${key}` },
|
|
162
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name, arguments: args } }),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new ApiError(`Could not reach ${origin()}. Check your connection, then run the command again.`);
|
|
167
|
+
}
|
|
168
|
+
const text = await res.text();
|
|
169
|
+
let json;
|
|
170
|
+
try {
|
|
171
|
+
json = rpcBody(text);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
throw new ApiError(`${origin()} returned something that isn't JSON (HTTP ${res.status}).`, res.status);
|
|
175
|
+
}
|
|
176
|
+
if (res.status === 401) {
|
|
177
|
+
throw new ApiError(`${json?.error?.message ?? 'Not signed in.'}\n Run "npx noslopui login" to sign in again.`, 401);
|
|
178
|
+
}
|
|
179
|
+
if (json?.error)
|
|
180
|
+
throw new ApiError(json.error.message ?? `The server refused the call (${json.error.code}).`);
|
|
181
|
+
const out = json?.result?.content?.[0]?.text ?? '';
|
|
182
|
+
let data = null;
|
|
183
|
+
try {
|
|
184
|
+
data = JSON.parse(out);
|
|
185
|
+
}
|
|
186
|
+
catch { }
|
|
187
|
+
return { isError: json?.result?.isError === true, text: out, data };
|
|
188
|
+
}
|
|
189
|
+
export async function whoami(key) {
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = await fetch(`${origin()}/api/cli/me`, { headers: { authorization: `Bearer ${key}`, 'user-agent': USER_AGENT } });
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new ApiError(`Could not reach ${origin()}. Check your connection, then run the command again.`);
|
|
196
|
+
}
|
|
197
|
+
let json = null;
|
|
198
|
+
try {
|
|
199
|
+
json = await res.json();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new ApiError(`${origin()} returned something that isn't JSON (HTTP ${res.status}).`, res.status);
|
|
203
|
+
}
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
throw new ApiError(json?.message ?? `HTTP ${res.status}`, res.status);
|
|
206
|
+
return json;
|
|
207
|
+
}
|
|
131
208
|
/**
|
|
132
209
|
* The workflow file. Fetched from the site, because the site is where it's
|
|
133
210
|
* assembled — the same text the MCP server sends as its instructions, so the
|
package/dist/lib/json-file.js
CHANGED
|
@@ -18,7 +18,8 @@ export class ConfigUnreadableError extends Error {
|
|
|
18
18
|
path;
|
|
19
19
|
cause_;
|
|
20
20
|
constructor(path, cause_) {
|
|
21
|
-
|
|
21
|
+
const why = cause_ instanceof Error && cause_.message ? ` (${cause_.message})` : '';
|
|
22
|
+
super(`${path} couldn't be read safely${why}, so it was left untouched.`);
|
|
22
23
|
this.path = path;
|
|
23
24
|
this.cause_ = cause_;
|
|
24
25
|
this.name = 'ConfigUnreadableError';
|
|
@@ -65,11 +66,15 @@ export function backupFile(path) {
|
|
|
65
66
|
}
|
|
66
67
|
/** Write via a temp file in the same directory, then rename over the target. */
|
|
67
68
|
export function writeJsonFile(path, value, { indent = 2 } = {}) {
|
|
69
|
+
writeTextFile(path, `${JSON.stringify(value, null, indent)}\n`);
|
|
70
|
+
}
|
|
71
|
+
/** The same atomic write for a file that isn't JSON (Codex's config.toml). */
|
|
72
|
+
export function writeTextFile(path, text) {
|
|
68
73
|
const dir = dirname(path);
|
|
69
74
|
mkdirSync(dir, { recursive: true });
|
|
70
75
|
const temp = join(dir, `.noslopui-write-${process.pid}-${Date.now()}.tmp`);
|
|
71
76
|
try {
|
|
72
|
-
writeFileSync(temp,
|
|
77
|
+
writeFileSync(temp, text, 'utf8');
|
|
73
78
|
renameSync(temp, path);
|
|
74
79
|
}
|
|
75
80
|
catch (err) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Where agents keep their files. Every path starts from the home directory or
|
|
2
|
+
// an environment variable the OS sets for it, never a hardcoded user folder, so
|
|
3
|
+
// the tests can point all of them at a throwaway directory.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { delimiter, join } from 'node:path';
|
|
7
|
+
export const home = () => homedir();
|
|
8
|
+
/** `%APPDATA%` on Windows (Roaming), where Electron apps and Devin keep user config. */
|
|
9
|
+
export function appData() {
|
|
10
|
+
return process.env.APPDATA || join(homedir(), 'AppData', 'Roaming');
|
|
11
|
+
}
|
|
12
|
+
/** `$XDG_CONFIG_HOME`, or `~/.config` — the Linux/macOS home for CLI config. */
|
|
13
|
+
export function xdgConfig() {
|
|
14
|
+
return process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
15
|
+
}
|
|
16
|
+
/** Where a VS Code-family app keeps its user settings, per OS. */
|
|
17
|
+
export function vscodeUserDir(product = 'Code') {
|
|
18
|
+
if (process.platform === 'win32')
|
|
19
|
+
return join(appData(), product, 'User');
|
|
20
|
+
if (process.platform === 'darwin')
|
|
21
|
+
return join(homedir(), 'Library', 'Application Support', product, 'User');
|
|
22
|
+
return join(xdgConfig(), product, 'User');
|
|
23
|
+
}
|
|
24
|
+
/** The skills folder several agents share: Codex, Cursor, OpenCode, VS Code. */
|
|
25
|
+
export const sharedSkillPath = () => join(homedir(), '.agents', 'skills', 'noslopui', 'SKILL.md');
|
|
26
|
+
export function onPath(binary) {
|
|
27
|
+
const paths = (process.env.PATH ?? '').split(delimiter).filter(Boolean);
|
|
28
|
+
const suffixes = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
29
|
+
for (const dir of paths) {
|
|
30
|
+
for (const suffix of suffixes) {
|
|
31
|
+
try {
|
|
32
|
+
if (existsSync(join(dir, binary + suffix)))
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// An unreadable PATH entry is not our problem; keep looking.
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The CLI's own sign-in — what `noslopui search`, `get` and `whoami` use.
|
|
2
|
+
//
|
|
3
|
+
// It's an ordinary noslopUI API key, issued for the agent id `cli` by the same
|
|
4
|
+
// browser approval `init` uses, and named after this machine on the account
|
|
5
|
+
// page, where it can be revoked like any other. It's stored in the user's
|
|
6
|
+
// config folder with owner-only permissions, and never printed in full.
|
|
7
|
+
//
|
|
8
|
+
// Agent keys stay in each agent's own config; this file is only the CLI's.
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { appData, xdgConfig } from './paths.js';
|
|
12
|
+
export function sessionPath() {
|
|
13
|
+
const dir = process.platform === 'win32' ? join(appData(), 'noslopui') : join(xdgConfig(), 'noslopui');
|
|
14
|
+
return join(dir, 'credentials.json');
|
|
15
|
+
}
|
|
16
|
+
export function readSession() {
|
|
17
|
+
if (process.env.NOSLOPUI_API_KEY)
|
|
18
|
+
return { key: process.env.NOSLOPUI_API_KEY, savedAt: 'env' };
|
|
19
|
+
const path = sessionPath();
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
return typeof parsed?.key === 'string' && parsed.key ? parsed : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function saveSession(key) {
|
|
31
|
+
const path = sessionPath();
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
writeFileSync(path, `${JSON.stringify({ key, savedAt: new Date().toISOString() }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
34
|
+
try {
|
|
35
|
+
// mode only applies when the file is created; tighten an existing one too.
|
|
36
|
+
chmodSync(path, 0o600);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// Windows ignores POSIX modes; the file is under the user's own profile.
|
|
40
|
+
}
|
|
41
|
+
return path;
|
|
42
|
+
}
|
|
43
|
+
export function clearSession() {
|
|
44
|
+
const path = sessionPath();
|
|
45
|
+
if (!existsSync(path))
|
|
46
|
+
return false;
|
|
47
|
+
rmSync(path, { force: true });
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Writing and removing the skill file. Shared by every adapter, because several
|
|
2
|
+
// agents read the same folder (`~/.agents/skills/` is read by Codex, Cursor,
|
|
3
|
+
// OpenCode and VS Code alike) — one copy there serves all of them.
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname } from 'node:path';
|
|
6
|
+
export function installSkillAt(path, markdown) {
|
|
7
|
+
if (existsSync(path)) {
|
|
8
|
+
try {
|
|
9
|
+
if (readFileSync(path, 'utf8') === markdown)
|
|
10
|
+
return { path, changed: false };
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
// Unreadable but present: fall through and replace it.
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
17
|
+
writeFileSync(path, markdown, 'utf8');
|
|
18
|
+
return { path, changed: true };
|
|
19
|
+
}
|
|
20
|
+
export function removeSkillAt(path) {
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return false;
|
|
23
|
+
// Only our own folder (…/skills/noslopui/), and only because we created it.
|
|
24
|
+
rmSync(dirname(path), { recursive: true, force: true });
|
|
25
|
+
return true;
|
|
26
|
+
}
|
package/dist/lib/version.js
CHANGED