noslopui 0.1.0 → 0.3.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,42 @@
1
+ // `noslopui skill` — install only the skill, for someone who connected an
2
+ // agent by hand (a connector sign-in, or a config they wrote). It never
3
+ // touches an agent's MCP config, so a sign-in stays a sign-in.
4
+ import { fetchSkill } from '../lib/api.js';
5
+ import { ADAPTERS, adapterById, detectAdapters } from '../adapters/index.js';
6
+ import { hint, ok, say, warn } from '../lib/ui.js';
7
+ export async function skill(options) {
8
+ say();
9
+ let targets;
10
+ if (options.agents?.length) {
11
+ targets = options.agents.map((id) => {
12
+ const a = adapterById(id);
13
+ if (!a)
14
+ throw new Error(`Unknown agent "${id}". Supported: ${ADAPTERS.map((x) => x.id).join(', ')}.`);
15
+ return a;
16
+ });
17
+ }
18
+ else {
19
+ targets = await detectAdapters();
20
+ if (!targets.length)
21
+ throw new Error(`No supported agent found. Pass one with --agent: ${ADAPTERS.map((a) => a.id).join(', ')}.`);
22
+ }
23
+ const download = await fetchSkill();
24
+ if (download.source === 'bundled')
25
+ warn('Using the copy in this package — the site was unreachable, so it may be behind.');
26
+ const written = new Map();
27
+ for (const adapter of targets) {
28
+ const path = adapter.skillPath();
29
+ if (!path) {
30
+ ok(`${adapter.label}: no skills folder; the rules reach it through the server`);
31
+ continue;
32
+ }
33
+ if (!written.has(path))
34
+ written.set(path, (await adapter.installSkill(download.markdown))?.changed ?? false);
35
+ ok(`${adapter.label}: skill ${written.get(path) ? 'installed' : 'already current'}`);
36
+ hint(path);
37
+ }
38
+ say();
39
+ say('Restart your agent to pick it up.');
40
+ say();
41
+ return 0;
42
+ }
@@ -3,7 +3,8 @@
3
3
  // In practice that means the skill: it's served from noslopui.com and changes
4
4
  // as the workflow improves, while the config entry only changes if the server
5
5
  // URL does. Nothing here needs a sign-in, so an expired trial or a revoked key
6
- // doesn't block it.
6
+ // doesn't block it. Several agents share one skills folder, so each folder is
7
+ // written once.
7
8
  import { fetchSkill, mcpUrl } from '../lib/api.js';
8
9
  import { ADAPTERS } from '../adapters/index.js';
9
10
  import { color, hint, ok, say, warn } from '../lib/ui.js';
@@ -11,6 +12,7 @@ export async function update() {
11
12
  say();
12
13
  let touched = false;
13
14
  let markdown = null;
15
+ const written = new Map();
14
16
  for (const adapter of ADAPTERS) {
15
17
  if (!(await adapter.detect()))
16
18
  continue;
@@ -21,22 +23,26 @@ export async function update() {
21
23
  }
22
24
  if (status.url !== mcpUrl()) {
23
25
  warn(`${adapter.label}: configured for ${status.url}, not ${mcpUrl()}.`);
24
- hint('Run "npx noslopui init" to move it — that needs a key, so it is not done here.');
26
+ hint(`Run "npx noslopui init --agent ${adapter.id}" to move it — that needs a key, so it is not done here.`);
25
27
  }
26
- markdown ??= (await fetchSkill()).markdown;
27
- const skill = await adapter.installSkill(markdown);
28
- if (skill.changed) {
29
- ok(`${adapter.label}: skill updated`);
30
- hint(skill.path);
31
- touched = true;
28
+ const path = adapter.skillPath();
29
+ if (!path) {
30
+ ok(`${adapter.label}: no skills folder; the rules reach it through the server`);
31
+ continue;
32
32
  }
33
- else {
34
- ok(`${adapter.label}: skill already current`);
33
+ if (!written.has(path)) {
34
+ markdown ??= (await fetchSkill()).markdown;
35
+ const skill = await adapter.installSkill(markdown);
36
+ written.set(path, skill?.changed ?? false);
37
+ if (skill?.changed)
38
+ touched = true;
35
39
  }
40
+ ok(`${adapter.label}: skill ${written.get(path) ? 'updated' : 'already current'}`);
41
+ hint(path);
36
42
  }
37
43
  say();
38
44
  if (touched)
39
- say(`Restart your agent to pick up the new workflow.`);
45
+ say('Restart your agents to pick up the new workflow.');
40
46
  say();
41
47
  return 0;
42
48
  }
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- // noslopui — connect a coding agent to noslopUI.
2
+ // noslopui — connect a coding agent to noslopUI, and use the catalog from the
3
+ // terminal.
3
4
  //
4
5
  // Argument parsing is by hand and stays that way: a tool people run with
5
6
  // `npx` should download in one tick, and a CLI-argument library is a
@@ -9,6 +10,10 @@ import { doctor } from './commands/doctor.js';
9
10
  import { remove } from './commands/remove.js';
10
11
  import { update } from './commands/update.js';
11
12
  import { login } from './commands/login.js';
13
+ import { logout, whoami } from './commands/account.js';
14
+ import { skill } from './commands/skill.js';
15
+ import * as catalog from './commands/catalog.js';
16
+ import { ADAPTERS } from './adapters/index.js';
12
17
  import { color, fail, say } from './lib/ui.js';
13
18
  import { origin } from './lib/api.js';
14
19
  import { CLI_VERSION as VERSION } from './lib/version.js';
@@ -19,52 +24,96 @@ ${color.bold('noslopui')} — hand-crafted UI components and design systems, in
19
24
  ${color.bold('Usage')}
20
25
  npx noslopui <command> [options]
21
26
 
22
- ${color.bold('Commands')}
23
- init Set up an agent: sign in, add the MCP server, install the skill
24
- doctor Check what is set up and what is wrong with it
25
- login Get a fresh key for an agent that is already set up
26
- update Re-download the skill for agents that are set up
27
- remove Take the noslopUI server back out (never touches other servers)
27
+ ${color.bold('Set up')}
28
+ init Set up your agents: one sign-in, then the MCP server and the skill
29
+ doctor Check what is set up and what is wrong with it
30
+ update Re-download the skill for agents that are set up
31
+ skill Install only the skill (for an agent you connected by hand)
32
+ remove Take the noslopUI server back out (never touches other servers)
33
+
34
+ ${color.bold('Account')}
35
+ login Sign this CLI in (for search and get); --agent <id> re-keys an agent
36
+ whoami Show the account and plan this CLI is signed in to
37
+ logout Forget this CLI's sign-in
38
+
39
+ ${color.bold('Catalog')}
40
+ search <query> Search blocks and effects --section ui-blocks|motion-lab, --tag <facet:value>, --limit
41
+ get <id> Print a component's code --format tsx|html|vue, --write <file>
42
+ prompt <id> Print a component's AI-prompt version
43
+ design-systems [query] Search design systems --tag theme:dark …
44
+ design-system <id> Print a design system's DESIGN.md --write DESIGN.md
45
+ collections List your saved collections
46
+ collection <name|id> Show one collection
28
47
 
29
48
  ${color.bold('Options')}
30
- --agent <id> Set up a specific agent instead of detecting (claude-code)
49
+ --agent <id> Only this agent (repeatable): ${ADAPTERS.map((a) => a.id).join(', ')}
31
50
  --no-skill Do not install the skill (init)
32
51
  --no-browser Do not open a browser; print the URL instead (init, login)
33
52
  --skill Also remove the skill (remove)
34
53
  --key <key> Use a key you already have instead of signing in (init, login)
54
+ --json Machine-readable output (catalog commands, whoami)
55
+ --force Overwrite the file --write points at
35
56
  -y, --yes Take the default answer for every question
36
57
  -h, --help Show this
37
58
  -v, --version Show the version
38
59
 
39
- ${color.dim(`Server: ${origin()} · set NOSLOPUI_ORIGIN to point somewhere else.`)}
60
+ ${color.dim(`Server: ${origin()} · NOSLOPUI_ORIGIN points somewhere else · NOSLOPUI_API_KEY signs in without a saved login.`)}
40
61
  `);
41
62
  }
42
63
  function parse(argv) {
43
- const parsed = { command: '', agents: [], skill: null, yes: false, browser: true, help: false, version: false };
64
+ const parsed = { command: '', args: [], agents: [], tags: [], skill: null, yes: false, browser: true, help: false, version: false, json: false, force: false };
65
+ const value = (i, name) => {
66
+ const v = argv[i];
67
+ if (v === undefined || v.startsWith('-'))
68
+ throw new Error(`${name} needs a value.`);
69
+ return v;
70
+ };
44
71
  for (let i = 0; i < argv.length; i++) {
45
72
  const arg = argv[i];
46
- if (arg === '--help' || arg === '-h')
73
+ const [flag, inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg, undefined];
74
+ const take = () => inline ?? value(++i, flag);
75
+ if (flag === '--help' || flag === '-h')
47
76
  parsed.help = true;
48
- else if (arg === '--version' || arg === '-v')
77
+ else if (flag === '--version' || flag === '-v')
49
78
  parsed.version = true;
50
- else if (arg === '--yes' || arg === '-y')
79
+ else if (flag === '--yes' || flag === '-y')
51
80
  parsed.yes = true;
52
- else if (arg === '--no-skill')
81
+ else if (flag === '--no-skill')
53
82
  parsed.skill = false;
54
- else if (arg === '--no-browser')
83
+ else if (flag === '--no-browser')
55
84
  parsed.browser = false;
56
- else if (arg === '--skill')
85
+ else if (flag === '--skill')
57
86
  parsed.skill = true;
58
- else if (arg === '--agent')
59
- parsed.agents.push(String(argv[++i] ?? ''));
60
- else if (arg.startsWith('--agent='))
61
- parsed.agents.push(arg.slice('--agent='.length));
62
- else if (arg === '--key')
63
- parsed.key = String(argv[++i] ?? '');
64
- else if (arg.startsWith('--key='))
65
- parsed.key = arg.slice('--key='.length);
66
- else if (!arg.startsWith('-') && !parsed.command)
67
- parsed.command = arg;
87
+ else if (flag === '--json')
88
+ parsed.json = true;
89
+ else if (flag === '--force')
90
+ parsed.force = true;
91
+ else if (flag === '--agent')
92
+ parsed.agents.push(take());
93
+ else if (flag === '--tag')
94
+ parsed.tags.push(take());
95
+ else if (flag === '--key')
96
+ parsed.key = take();
97
+ else if (flag === '--format')
98
+ parsed.format = take();
99
+ else if (flag === '--framework')
100
+ parsed.framework = take();
101
+ else if (flag === '--section')
102
+ parsed.section = take();
103
+ else if (flag === '--write' || flag === '-o')
104
+ parsed.write = take();
105
+ else if (flag === '--limit') {
106
+ const n = Number(take());
107
+ if (!Number.isInteger(n) || n < 1)
108
+ throw new Error('--limit needs a whole number.');
109
+ parsed.limit = n;
110
+ }
111
+ else if (!arg.startsWith('-')) {
112
+ if (!parsed.command)
113
+ parsed.command = arg;
114
+ else
115
+ parsed.args.push(arg);
116
+ }
68
117
  else
69
118
  throw new Error(`Unknown option "${arg}". Run "npx noslopui --help".`);
70
119
  }
@@ -81,6 +130,17 @@ async function main() {
81
130
  usage();
82
131
  return args.command ? 0 : 1;
83
132
  }
133
+ const catalogOptions = {
134
+ args: args.args,
135
+ json: args.json,
136
+ tags: args.tags,
137
+ force: args.force,
138
+ ...(args.limit ? { limit: args.limit } : {}),
139
+ ...(args.format ? { format: args.format } : {}),
140
+ ...(args.framework ? { framework: args.framework } : {}),
141
+ ...(args.section ? { section: args.section } : {}),
142
+ ...(args.write ? { write: args.write } : {}),
143
+ };
84
144
  switch (args.command) {
85
145
  case 'init':
86
146
  return init({ agents: args.agents, skill: args.skill !== false, yes: args.yes, browser: args.browser, ...(args.key ? { key: args.key } : {}) });
@@ -88,10 +148,30 @@ async function main() {
88
148
  return doctor();
89
149
  case 'login':
90
150
  return login({ agents: args.agents, yes: args.yes, browser: args.browser, ...(args.key ? { key: args.key } : {}) });
151
+ case 'logout':
152
+ return logout();
153
+ case 'whoami':
154
+ return whoami({ json: args.json });
91
155
  case 'update':
92
156
  return update();
157
+ case 'skill':
158
+ return skill({ agents: args.agents });
93
159
  case 'remove':
94
- return remove({ yes: args.yes, skill: args.skill === true });
160
+ return remove({ yes: args.yes, skill: args.skill === true, agents: args.agents });
161
+ case 'search':
162
+ return catalog.search(catalogOptions);
163
+ case 'get':
164
+ return catalog.get(catalogOptions);
165
+ case 'prompt':
166
+ return catalog.prompt(catalogOptions);
167
+ case 'design-systems':
168
+ return catalog.designSystems(catalogOptions);
169
+ case 'design-system':
170
+ return catalog.designSystem(catalogOptions);
171
+ case 'collections':
172
+ return catalog.collections(catalogOptions);
173
+ case 'collection':
174
+ return catalog.collection(catalogOptions);
95
175
  default:
96
176
  fail(`Unknown command "${args.command}".`);
97
177
  usage();
@@ -108,8 +188,8 @@ main()
108
188
  process.exitCode = code;
109
189
  })
110
190
  .catch((err) => {
111
- say();
191
+ process.stderr.write('\n');
112
192
  fail(explain(err));
113
- say();
193
+ process.stderr.write('\n');
114
194
  process.exitCode = 1;
115
195
  });
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.3.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "noslopui",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",