noslopui 0.0.1 → 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.
@@ -0,0 +1,241 @@
1
+ // Talking to noslopui.com: the device-code sign-in, and a health probe for
2
+ // `doctor`. Everything here is plain fetch against documented endpoints.
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CLI_VERSION } from './version.js';
6
+ export const DEFAULT_ORIGIN = 'https://noslopui.com';
7
+ /**
8
+ * Identify ourselves on every request. A tool that talks to a server should
9
+ * say what it is: it makes our own logs readable, and it gives anything
10
+ * sitting in front of the server (a CDN, a bot filter) something honest to
11
+ * recognise instead of a bare runtime default.
12
+ */
13
+ export const USER_AGENT = `noslopui-cli/${CLI_VERSION} (+https://github.com/noslopUI/cli)`;
14
+ /** Overridable so the flow can be exercised against a local server in tests. */
15
+ export const origin = () => (process.env.NOSLOPUI_ORIGIN || DEFAULT_ORIGIN).replace(/\/$/, '');
16
+ export const mcpUrl = () => `${origin()}/api/mcp`;
17
+ export class ApiError extends Error {
18
+ status;
19
+ constructor(message, status) {
20
+ super(message);
21
+ this.status = status;
22
+ this.name = 'ApiError';
23
+ }
24
+ }
25
+ async function post(path, body) {
26
+ let res;
27
+ try {
28
+ res = await fetch(`${origin()}${path}`, {
29
+ method: 'POST',
30
+ headers: { 'content-type': 'application/json', 'user-agent': USER_AGENT },
31
+ body: JSON.stringify(body),
32
+ });
33
+ }
34
+ catch (err) {
35
+ throw new ApiError(`Could not reach ${origin()}. Check your connection, then run the command again.`);
36
+ }
37
+ const text = await res.text();
38
+ let json = null;
39
+ try {
40
+ json = JSON.parse(text);
41
+ }
42
+ catch {
43
+ // A proxy or captive portal answering instead of us.
44
+ throw new ApiError(`${origin()} returned something that isn't JSON (HTTP ${res.status}).`, res.status);
45
+ }
46
+ return { status: res.status, json };
47
+ }
48
+ export async function startSignIn(clientName, agents) {
49
+ const { status, json } = await post('/api/cli/auth/start', { clientName, agents });
50
+ if (status !== 201)
51
+ throw new ApiError(json?.message ?? `Sign-in could not be started (HTTP ${status}).`, status);
52
+ return json;
53
+ }
54
+ /** One poll. Everything that isn't "keep waiting" is thrown, with the server's own wording. */
55
+ export async function pollSignIn(deviceCode) {
56
+ const { status, json } = await post('/api/cli/auth/poll', { deviceCode });
57
+ if (status === 200 && json?.status === 'approved')
58
+ return { done: true, keys: json.keys };
59
+ if (status === 200)
60
+ return { done: false }; // pending, or told to slow down
61
+ throw new ApiError(json?.message ?? `Sign-in failed (HTTP ${status}).`, status);
62
+ }
63
+ /**
64
+ * Waits for the browser half to finish. Polls on the interval the server asked
65
+ * for, and gives up when the code the user is looking at has expired.
66
+ */
67
+ export async function waitForApproval(started, onTick) {
68
+ const deadline = Date.now() + started.expiresIn * 1000;
69
+ const intervalMs = Math.max(1, started.interval) * 1000;
70
+ while (Date.now() < deadline) {
71
+ await new Promise((r) => setTimeout(r, intervalMs));
72
+ const outcome = await pollSignIn(started.deviceCode);
73
+ if (outcome.done)
74
+ return outcome.keys;
75
+ onTick?.(Math.max(0, Math.round((deadline - Date.now()) / 1000)));
76
+ }
77
+ throw new ApiError('The code expired before it was approved. Run the command again to get a new one.');
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
+ }
90
+ /**
91
+ * `doctor`'s probe: can we reach the MCP server, and does this key still work?
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.
97
+ */
98
+ export async function checkServer(key) {
99
+ let res;
100
+ const headers = { ...MCP_HEADERS };
101
+ if (key)
102
+ headers.authorization = `Bearer ${key}`;
103
+ try {
104
+ res = await fetch(mcpUrl(), {
105
+ method: 'POST',
106
+ headers,
107
+ body: JSON.stringify({
108
+ jsonrpc: '2.0',
109
+ id: 1,
110
+ method: key ? 'tools/call' : 'tools/list',
111
+ params: key ? { name: 'list_collections', arguments: {} } : {},
112
+ }),
113
+ });
114
+ }
115
+ catch {
116
+ return { reachable: false, detail: `Could not reach ${mcpUrl()}.` };
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
+ }
132
+ if (!res.ok)
133
+ return { reachable: false, detail: `${mcpUrl()} answered HTTP ${res.status}.` };
134
+ let json;
135
+ try {
136
+ json = rpcBody(text);
137
+ }
138
+ catch {
139
+ return { reachable: false, detail: 'The server answered with something unreadable.' };
140
+ }
141
+ if (!key)
142
+ return { reachable: true, detail: `${(json?.result?.tools ?? []).length} tools available.` };
143
+ if (json?.result?.isError === true) {
144
+ // The server writes these for an agent to relay; they're already the right
145
+ // words for a person, so pass them through rather than inventing our own.
146
+ const body = json?.result?.content?.[0]?.text ?? '';
147
+ return { reachable: true, detail: 'Server reachable.', keyState: 'rejected', keyDetail: body.split('. ')[0] };
148
+ }
149
+ return { reachable: true, detail: 'Server reachable.', keyState: 'ok', keyDetail: 'Key accepted.' };
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
+ }
208
+ /**
209
+ * The workflow file. Fetched from the site, because the site is where it's
210
+ * assembled — the same text the MCP server sends as its instructions, so the
211
+ * two can never disagree.
212
+ *
213
+ * The copy shipped in this package is a fallback for an unreachable site, not
214
+ * an equal: it's only as fresh as the last publish, so the caller says so.
215
+ */
216
+ export async function fetchSkill() {
217
+ try {
218
+ const res = await fetch(`${origin()}/skill/SKILL.md`, { headers: { 'user-agent': USER_AGENT } });
219
+ if (res.ok) {
220
+ const text = await res.text();
221
+ if (text.startsWith('---'))
222
+ return { markdown: text, source: 'server' };
223
+ }
224
+ }
225
+ catch {
226
+ // Fall through to the bundled copy.
227
+ }
228
+ const bundled = bundledSkill();
229
+ if (bundled)
230
+ return { markdown: bundled, source: 'bundled' };
231
+ throw new ApiError(`Could not download the skill from ${origin()}, and this package has no copy to fall back on.`);
232
+ }
233
+ function bundledSkill() {
234
+ try {
235
+ // dist/lib/api.js → the package root.
236
+ return readFileSync(fileURLToPath(new URL('../../SKILL.md', import.meta.url)), 'utf8');
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
@@ -0,0 +1,103 @@
1
+ // Reading and writing a config file that belongs to somebody else.
2
+ //
3
+ // The rules this file exists to enforce, in order of how badly they'd hurt:
4
+ //
5
+ // 1. If the file doesn't parse, STOP. Never overwrite a config we couldn't
6
+ // read — `~/.claude.json` carries onboarding state, project history and
7
+ // every other MCP server the user has set up. Rewriting it from scratch
8
+ // because we couldn't parse it would be the worst thing this tool could
9
+ // possibly do.
10
+ // 2. Back up before the first write, and tell the user where the backup is.
11
+ // 3. Merge. Only our own key changes; every sibling is written back exactly
12
+ // as it was read.
13
+ // 4. Write atomically — a temp file in the same directory, then rename — so
14
+ // a crash or a full disk can't leave a half-written config behind.
15
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ export class ConfigUnreadableError extends Error {
18
+ path;
19
+ cause_;
20
+ constructor(path, cause_) {
21
+ const why = cause_ instanceof Error && cause_.message ? ` (${cause_.message})` : '';
22
+ super(`${path} couldn't be read safely${why}, so it was left untouched.`);
23
+ this.path = path;
24
+ this.cause_ = cause_;
25
+ this.name = 'ConfigUnreadableError';
26
+ }
27
+ }
28
+ /** Reads a JSON object, or `null` when the file simply isn't there yet. */
29
+ export function readJsonFile(path) {
30
+ if (!existsSync(path))
31
+ return null;
32
+ let raw;
33
+ try {
34
+ raw = readFileSync(path, 'utf8');
35
+ }
36
+ catch (err) {
37
+ throw new ConfigUnreadableError(path, err);
38
+ }
39
+ if (!raw.trim())
40
+ return {};
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(raw);
44
+ }
45
+ catch (err) {
46
+ throw new ConfigUnreadableError(path, err);
47
+ }
48
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
49
+ throw new ConfigUnreadableError(path, new Error('top level is not an object'));
50
+ }
51
+ return parsed;
52
+ }
53
+ /**
54
+ * A timestamped copy beside the original: `.claude.json.noslopui-backup-<ts>`.
55
+ * Beside it rather than in a temp directory so the user can find it without
56
+ * being told twice, and timestamped so a second run can't clobber the first
57
+ * backup — which would defeat the point of having one.
58
+ */
59
+ export function backupFile(path) {
60
+ if (!existsSync(path))
61
+ return null;
62
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
63
+ const backup = `${path}.noslopui-backup-${stamp}`;
64
+ copyFileSync(path, backup);
65
+ return backup;
66
+ }
67
+ /** Write via a temp file in the same directory, then rename over the target. */
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) {
73
+ const dir = dirname(path);
74
+ mkdirSync(dir, { recursive: true });
75
+ const temp = join(dir, `.noslopui-write-${process.pid}-${Date.now()}.tmp`);
76
+ try {
77
+ writeFileSync(temp, text, 'utf8');
78
+ renameSync(temp, path);
79
+ }
80
+ catch (err) {
81
+ try {
82
+ if (existsSync(temp))
83
+ unlinkSync(temp);
84
+ }
85
+ catch {
86
+ // The rename is what matters; a stray temp file is not worth a second error.
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+ /** Indentation of an existing file, so we write it back the way we found it. */
92
+ export function detectIndent(path) {
93
+ try {
94
+ const raw = readFileSync(path, 'utf8');
95
+ const match = raw.match(/\n(\s+)"/);
96
+ if (match?.[1] && !match[1].includes('\t'))
97
+ return match[1].length;
98
+ }
99
+ catch {
100
+ // Not worth failing a write over; fall through to the default.
101
+ }
102
+ return 2;
103
+ }
@@ -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/ui.js ADDED
@@ -0,0 +1,75 @@
1
+ // Terminal output and prompts. No dependencies on purpose: this package is run
2
+ // with `npx`, so every dependency is something the user waits to download
3
+ // before anything happens.
4
+ import { createInterface } from 'node:readline/promises';
5
+ import { hostname } from 'node:os';
6
+ import { stdin, stdout } from 'node:process';
7
+ const useColor = stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== 'dumb';
8
+ const wrap = (open, close) => (s) => (useColor ? `${open}${s}${close}` : s);
9
+ export const color = {
10
+ bold: wrap('\u001b[1m', '\u001b[22m'),
11
+ dim: wrap('\u001b[2m', '\u001b[22m'),
12
+ green: wrap('\u001b[32m', '\u001b[39m'),
13
+ yellow: wrap('\u001b[33m', '\u001b[39m'),
14
+ red: wrap('\u001b[31m', '\u001b[39m'),
15
+ cyan: wrap('\u001b[36m', '\u001b[39m'),
16
+ };
17
+ export const say = (line = '') => stdout.write(`${line}\n`);
18
+ export const step = (line) => say(`${color.cyan('›')} ${line}`);
19
+ export const ok = (line) => say(`${color.green('✔')} ${line}`);
20
+ export const warn = (line) => say(`${color.yellow('!')} ${line}`);
21
+ export const fail = (line) => say(`${color.red('✘')} ${line}`);
22
+ export const hint = (line) => say(` ${color.dim(line)}`);
23
+ /** True when there's a human to answer a prompt. */
24
+ export const interactive = () => Boolean(stdin.isTTY && stdout.isTTY);
25
+ /**
26
+ * A key is only ever shown like this. `npx noslopui init` runs in a terminal
27
+ * people screenshot and paste into issues, and the key is the account.
28
+ */
29
+ export function maskKey(key) {
30
+ return key.length <= 11 ? '…' : `${key.slice(0, 11)}…`;
31
+ }
32
+ export async function confirm(question, fallback) {
33
+ if (!interactive())
34
+ return fallback;
35
+ const rl = createInterface({ input: stdin, output: stdout });
36
+ try {
37
+ const suffix = fallback ? 'Y/n' : 'y/N';
38
+ const answer = (await rl.question(`${color.cyan('?')} ${question} ${color.dim(`(${suffix})`)} `)).trim().toLowerCase();
39
+ if (!answer)
40
+ return fallback;
41
+ return answer === 'y' || answer === 'yes';
42
+ }
43
+ finally {
44
+ rl.close();
45
+ }
46
+ }
47
+ /** Wait for Enter, or return immediately when nobody's watching. */
48
+ export async function pressEnter(question) {
49
+ if (!interactive())
50
+ return;
51
+ const rl = createInterface({ input: stdin, output: stdout });
52
+ try {
53
+ await rl.question(`${color.cyan('?')} ${question} `);
54
+ }
55
+ finally {
56
+ rl.close();
57
+ }
58
+ }
59
+ /**
60
+ * A name for this machine, used to label the key on the account page and on
61
+ * the approval screen. The hostname only — never a username, path or anything
62
+ * else that would say more about the person than "which laptop is this".
63
+ * `os.hostname()` rather than $HOSTNAME, which isn't exported on every shell.
64
+ */
65
+ export function machineLabel() {
66
+ let raw = '';
67
+ try {
68
+ raw = hostname();
69
+ }
70
+ catch {
71
+ raw = '';
72
+ }
73
+ const cleaned = (raw.trim().split('.')[0] ?? '').replace(/[^\w .-]/g, '');
74
+ return cleaned.slice(0, 40) || 'this machine';
75
+ }
@@ -0,0 +1,3 @@
1
+ // One place for the version, so package.json, --version and the User-Agent
2
+ // can't disagree. Checked against package.json by a test.
3
+ export const CLI_VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,25 +1,48 @@
1
1
  {
2
2
  "name": "noslopui",
3
- "version": "0.0.1",
4
- "description": "Connect your coding agent to noslopUI's hand-crafted UI catalog. Installer coming soon.",
5
- "homepage": "https://noslopui.com/mcp",
3
+ "version": "0.2.0",
4
+ "description": "Connect your coding agent to noslopUI \u2014 hand-crafted UI components and portable design systems, over MCP.",
5
+ "keywords": [
6
+ "mcp",
7
+ "claude-code",
8
+ "ui",
9
+ "design-system",
10
+ "components",
11
+ "cli"
12
+ ],
13
+ "homepage": "https://noslopui.com",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/noslopUI/cli.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/noslopUI/cli/issues"
20
+ },
6
21
  "license": "MIT",
22
+ "type": "module",
7
23
  "bin": {
8
- "noslopui": "bin/noslopui.js"
24
+ "noslopui": "dist/index.js"
9
25
  },
10
26
  "files": [
11
- "bin"
27
+ "dist",
28
+ "SKILL.md",
29
+ "README.md",
30
+ "LICENSE"
12
31
  ],
13
32
  "engines": {
14
- "node": ">=18"
33
+ "node": ">=20.10"
15
34
  },
16
- "keywords": [
17
- "noslopui",
18
- "mcp",
19
- "ui",
20
- "design-system",
21
- "claude-code",
22
- "cursor",
23
- "codex"
24
- ]
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.json",
37
+ "build:test": "tsc -p tsconfig.test.json",
38
+ "test": "npm run build:test && node scripts/run-tests.mjs",
39
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
40
+ "sync-skill": "node scripts/sync-skill.mjs",
41
+ "check-skill": "node scripts/sync-skill.mjs --check",
42
+ "prepack": "npm run build"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^24.3.0",
46
+ "typescript": "^5.9.2"
47
+ }
25
48
  }
package/bin/noslopui.js DELETED
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- console.log('The noslopUI installer is coming soon.');
3
- console.log('Until then, set up by hand: https://noslopui.com/mcp');