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/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
- * Uses list_collections because it's the one gated tool that costs the account
82
- * nothing — no trial clock, no fair-use count.
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
- const text = await res.text();
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 = JSON.parse(line ? line.slice(6) : text);
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
- const isError = json?.result?.isError === true;
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
@@ -18,7 +18,8 @@ export class ConfigUnreadableError extends Error {
18
18
  path;
19
19
  cause_;
20
20
  constructor(path, cause_) {
21
- super(`${path} is not valid JSON, so it was left untouched.`);
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, `${JSON.stringify(value, null, indent)}\n`, 'utf8');
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
+ }
@@ -1,3 +1,3 @@
1
1
  // One place for the version, so package.json, --version and the User-Agent
2
2
  // can't disagree. Checked against package.json by a test.
3
- export const CLI_VERSION = '0.1.0';
3
+ export const CLI_VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noslopui",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect your coding agent to noslopUI \u2014 hand-crafted UI components and portable design systems, over MCP.",
5
5
  "keywords": [
6
6
  "mcp",