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.
- package/LICENSE +21 -0
- package/README.md +116 -2
- package/SKILL.md +245 -0
- package/dist/adapters/claude-code.js +46 -0
- package/dist/adapters/codex.js +196 -0
- package/dist/adapters/index.js +30 -0
- package/dist/adapters/json-adapter.js +135 -0
- package/dist/adapters/json-agents.js +88 -0
- package/dist/adapters/types.js +8 -0
- package/dist/commands/account.js +49 -0
- package/dist/commands/catalog.js +191 -0
- package/dist/commands/doctor.js +129 -0
- package/dist/commands/init.js +163 -0
- package/dist/commands/login.js +36 -0
- package/dist/commands/remove.js +74 -0
- package/dist/commands/skill.js +42 -0
- package/dist/commands/update.js +48 -0
- package/dist/index.js +192 -0
- package/dist/lib/api.js +241 -0
- package/dist/lib/json-file.js +103 -0
- 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/ui.js +75 -0
- package/dist/lib/version.js +3 -0
- package/package.json +38 -15
- package/bin/noslopui.js +0 -3
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// The shared shape of every agent whose MCP config is a JSON file.
|
|
2
|
+
//
|
|
3
|
+
// Seven of the eight agents keep their servers in a JSON object keyed by
|
|
4
|
+
// server name; they differ only in where the file is, what the object is
|
|
5
|
+
// called (`mcpServers`, `servers`, `mcp`), and which fields a remote server
|
|
6
|
+
// takes (`url` or `serverUrl`, plus `type: "http"`, `transport: "http"` or
|
|
7
|
+
// `type: "remote"`). Each adapter states those facts and nothing else; the
|
|
8
|
+
// rules for editing a file we don't own live here once, in `lib/json-file.ts`
|
|
9
|
+
// underneath:
|
|
10
|
+
//
|
|
11
|
+
// · back up before the first write, merge, never touch another entry
|
|
12
|
+
// · keep fields on our own entry that we didn't put there
|
|
13
|
+
// · write atomically, and stop cold on a file that doesn't parse
|
|
14
|
+
// · read the entry back — several of these files have another writer
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import { backupFile, detectIndent, readJsonFile, writeJsonFile } from '../lib/json-file.js';
|
|
17
|
+
import { installSkillAt, removeSkillAt } from '../lib/skill-file.js';
|
|
18
|
+
import { onPath } from '../lib/paths.js';
|
|
19
|
+
/** The name the server appears under in every agent. */
|
|
20
|
+
export const SERVER_NAME = 'noslopui';
|
|
21
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
22
|
+
export class JsonConfigAdapter {
|
|
23
|
+
spec;
|
|
24
|
+
id;
|
|
25
|
+
label;
|
|
26
|
+
constructor(spec) {
|
|
27
|
+
this.spec = spec;
|
|
28
|
+
this.id = spec.id;
|
|
29
|
+
this.label = spec.label;
|
|
30
|
+
}
|
|
31
|
+
configPath() {
|
|
32
|
+
return this.spec.configPath();
|
|
33
|
+
}
|
|
34
|
+
skillPath() {
|
|
35
|
+
return this.spec.skillPath();
|
|
36
|
+
}
|
|
37
|
+
async detect() {
|
|
38
|
+
try {
|
|
39
|
+
if ((this.spec.detectBinaries ?? []).some(onPath))
|
|
40
|
+
return true;
|
|
41
|
+
return this.spec.detectPaths().some((p) => existsSync(p));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
async install(entry) {
|
|
48
|
+
const path = this.configPath();
|
|
49
|
+
const config = readJsonFile(path) ?? {};
|
|
50
|
+
const backupPath = backupFile(path);
|
|
51
|
+
const servers = isObject(config[this.spec.container]) ? config[this.spec.container] : {};
|
|
52
|
+
const replacedExisting = SERVER_NAME in servers;
|
|
53
|
+
// Spread the existing entry so anything the agent (or the user) added to
|
|
54
|
+
// it that we don't know about survives the update.
|
|
55
|
+
const existing = isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : {};
|
|
56
|
+
const existingHeaders = isObject(existing.headers) ? existing.headers : {};
|
|
57
|
+
// The other spelling of the url field would be read instead of ours by an
|
|
58
|
+
// agent that accepts both, so it goes.
|
|
59
|
+
const { url: _url, serverUrl: _serverUrl, ...rest } = existing;
|
|
60
|
+
servers[SERVER_NAME] = {
|
|
61
|
+
...rest,
|
|
62
|
+
...(this.spec.fixed ?? {}),
|
|
63
|
+
[this.spec.urlField]: entry.url,
|
|
64
|
+
headers: { ...existingHeaders, Authorization: `Bearer ${entry.key}` },
|
|
65
|
+
};
|
|
66
|
+
config[this.spec.container] = servers;
|
|
67
|
+
writeJsonFile(path, config, { indent: existsSync(path) ? detectIndent(path) : 2 });
|
|
68
|
+
// Read back: a successful write is not the same as a config that says
|
|
69
|
+
// what we think it says, when something else may rewrite the file.
|
|
70
|
+
const after = await this.status();
|
|
71
|
+
if (!after.installed || after.url !== entry.url || !after.hasKey) {
|
|
72
|
+
throw new Error(`The entry was written to ${path} but reading it back didn't show it.${this.spec.readBackHint ? ` ${this.spec.readBackHint}` : ''}`);
|
|
73
|
+
}
|
|
74
|
+
return { configPath: path, backupPath, method: 'config-file', replacedExisting };
|
|
75
|
+
}
|
|
76
|
+
async status() {
|
|
77
|
+
const path = this.configPath();
|
|
78
|
+
const config = readJsonFile(path);
|
|
79
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
80
|
+
const entry = servers && isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : null;
|
|
81
|
+
if (!entry)
|
|
82
|
+
return { installed: false, configPath: path, url: null, hasKey: false };
|
|
83
|
+
const raw = entry[this.spec.urlField];
|
|
84
|
+
const url = typeof raw === 'string' ? raw : null;
|
|
85
|
+
const headers = isObject(entry.headers) ? entry.headers : {};
|
|
86
|
+
const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
|
|
87
|
+
const hasKey = /^Bearer\s+\S+/i.test(auth);
|
|
88
|
+
let problem;
|
|
89
|
+
if (!url)
|
|
90
|
+
problem = `The entry has no "${this.spec.urlField}".`;
|
|
91
|
+
else if (!hasKey)
|
|
92
|
+
problem = 'The entry has no Authorization header, so the agent will be asked to sign in.';
|
|
93
|
+
else {
|
|
94
|
+
for (const [field, value] of Object.entries(this.spec.fixed ?? {})) {
|
|
95
|
+
if (entry[field] !== value) {
|
|
96
|
+
problem = `The entry's ${field} is "${String(entry[field])}", expected "${String(value)}".`;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { installed: true, configPath: path, url, hasKey, ...(problem ? { problem } : {}) };
|
|
102
|
+
}
|
|
103
|
+
async configuredKey() {
|
|
104
|
+
try {
|
|
105
|
+
const config = readJsonFile(this.configPath());
|
|
106
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
107
|
+
const entry = servers && isObject(servers[SERVER_NAME]) ? servers[SERVER_NAME] : null;
|
|
108
|
+
const headers = entry && isObject(entry.headers) ? entry.headers : {};
|
|
109
|
+
const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
|
|
110
|
+
return auth.match(/^Bearer\s+(\S+)$/i)?.[1] ?? null;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async uninstall() {
|
|
117
|
+
const path = this.configPath();
|
|
118
|
+
const config = readJsonFile(path);
|
|
119
|
+
const servers = config && isObject(config[this.spec.container]) ? config[this.spec.container] : null;
|
|
120
|
+
if (!config || !servers || !(SERVER_NAME in servers))
|
|
121
|
+
return false;
|
|
122
|
+
backupFile(path);
|
|
123
|
+
delete servers[SERVER_NAME];
|
|
124
|
+
writeJsonFile(path, config, { indent: detectIndent(path) });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
async installSkill(markdown) {
|
|
128
|
+
const path = this.skillPath();
|
|
129
|
+
return path ? installSkillAt(path, markdown) : null;
|
|
130
|
+
}
|
|
131
|
+
async removeSkill() {
|
|
132
|
+
const path = this.skillPath();
|
|
133
|
+
return path ? removeSkillAt(path) : false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Every agent whose MCP config is a JSON file, apart from Claude Code (which
|
|
2
|
+
// has its own module for its history). Each entry is only facts, and each fact
|
|
3
|
+
// is from that vendor's own documentation, checked 2026-09-21:
|
|
4
|
+
//
|
|
5
|
+
// Cursor cursor.com/docs/context/mcp, …/context/skills
|
|
6
|
+
// VS Code code.visualstudio.com/docs/copilot/customization/mcp-servers, …/agent-skills
|
|
7
|
+
// Devin docs.devin.ai/cli/extensibility/mcp/configuration, …/skills
|
|
8
|
+
// Windsurf docs.devin.ai/desktop/cascade/mcp (the legacy Cascade agent)
|
|
9
|
+
// OpenCode opencode.ai/docs/config, …/mcp-servers, …/skills
|
|
10
|
+
// Antigravity antigravity.google/docs/mcp
|
|
11
|
+
//
|
|
12
|
+
// Windsurf shipped as Devin Desktop in June 2026 and now has two agents with
|
|
13
|
+
// two config files: Devin Local (the default for new tabs, and the Devin CLI)
|
|
14
|
+
// reads the Devin CLI's config; the legacy Cascade agent still reads
|
|
15
|
+
// ~/.codeium/windsurf/mcp_config.json. They're two adapters, so each is
|
|
16
|
+
// detected, written and checked on its own. Both read skills from
|
|
17
|
+
// ~/.codeium/windsurf/skills, so they share one copy.
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { JsonConfigAdapter } from './json-adapter.js';
|
|
20
|
+
import { appData, home, sharedSkillPath, vscodeUserDir, xdgConfig } from '../lib/paths.js';
|
|
21
|
+
const devinConfigDir = () => (process.platform === 'win32' ? join(appData(), 'devin') : join(xdgConfig(), 'devin'));
|
|
22
|
+
const windsurfSkillPath = () => join(home(), '.codeium', 'windsurf', 'skills', 'noslopui', 'SKILL.md');
|
|
23
|
+
export const cursor = () => new JsonConfigAdapter({
|
|
24
|
+
id: 'cursor',
|
|
25
|
+
label: 'Cursor',
|
|
26
|
+
configPath: () => join(home(), '.cursor', 'mcp.json'),
|
|
27
|
+
container: 'mcpServers',
|
|
28
|
+
urlField: 'url',
|
|
29
|
+
detectPaths: () => [join(home(), '.cursor')],
|
|
30
|
+
detectBinaries: ['cursor'],
|
|
31
|
+
skillPath: sharedSkillPath,
|
|
32
|
+
});
|
|
33
|
+
export const vscode = () => new JsonConfigAdapter({
|
|
34
|
+
id: 'vscode',
|
|
35
|
+
label: 'VS Code',
|
|
36
|
+
configPath: () => join(vscodeUserDir(), 'mcp.json'),
|
|
37
|
+
container: 'servers',
|
|
38
|
+
urlField: 'url',
|
|
39
|
+
fixed: { type: 'http' },
|
|
40
|
+
detectPaths: () => [vscodeUserDir()],
|
|
41
|
+
detectBinaries: ['code'],
|
|
42
|
+
skillPath: sharedSkillPath,
|
|
43
|
+
});
|
|
44
|
+
export const devin = () => new JsonConfigAdapter({
|
|
45
|
+
id: 'devin',
|
|
46
|
+
label: 'Devin Desktop',
|
|
47
|
+
configPath: () => join(devinConfigDir(), 'mcp_config.json'),
|
|
48
|
+
container: 'mcpServers',
|
|
49
|
+
urlField: 'url',
|
|
50
|
+
fixed: { transport: 'http' },
|
|
51
|
+
detectPaths: () => [devinConfigDir()],
|
|
52
|
+
detectBinaries: ['devin'],
|
|
53
|
+
skillPath: windsurfSkillPath,
|
|
54
|
+
});
|
|
55
|
+
export const windsurf = () => new JsonConfigAdapter({
|
|
56
|
+
id: 'windsurf',
|
|
57
|
+
label: 'Windsurf (Cascade)',
|
|
58
|
+
configPath: () => join(home(), '.codeium', 'windsurf', 'mcp_config.json'),
|
|
59
|
+
container: 'mcpServers',
|
|
60
|
+
urlField: 'serverUrl',
|
|
61
|
+
detectPaths: () => [join(home(), '.codeium', 'windsurf')],
|
|
62
|
+
detectBinaries: ['windsurf'],
|
|
63
|
+
skillPath: windsurfSkillPath,
|
|
64
|
+
});
|
|
65
|
+
export const opencode = () => new JsonConfigAdapter({
|
|
66
|
+
id: 'opencode',
|
|
67
|
+
label: 'OpenCode',
|
|
68
|
+
// OpenCode also reads opencode.jsonc. We only ever write the .json file:
|
|
69
|
+
// a .jsonc may carry comments that a JSON round trip would delete, and
|
|
70
|
+
// the reader refuses a file it can't parse rather than rewrite it.
|
|
71
|
+
configPath: () => join(xdgConfig(), 'opencode', 'opencode.json'),
|
|
72
|
+
container: 'mcp',
|
|
73
|
+
urlField: 'url',
|
|
74
|
+
fixed: { type: 'remote', enabled: true },
|
|
75
|
+
detectPaths: () => [join(xdgConfig(), 'opencode')],
|
|
76
|
+
detectBinaries: ['opencode'],
|
|
77
|
+
skillPath: sharedSkillPath,
|
|
78
|
+
});
|
|
79
|
+
export const antigravity = () => new JsonConfigAdapter({
|
|
80
|
+
id: 'antigravity',
|
|
81
|
+
label: 'Antigravity',
|
|
82
|
+
configPath: () => join(home(), '.gemini', 'config', 'mcp_config.json'),
|
|
83
|
+
container: 'mcpServers',
|
|
84
|
+
urlField: 'serverUrl',
|
|
85
|
+
detectPaths: () => [join(home(), '.gemini', 'config'), join(home(), '.gemini', 'antigravity')],
|
|
86
|
+
detectBinaries: ['antigravity'],
|
|
87
|
+
skillPath: () => join(home(), '.gemini', 'config', 'skills', 'noslopui', 'SKILL.md'),
|
|
88
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// The shape every agent adapter fills in.
|
|
2
|
+
//
|
|
3
|
+
// Adding an agent is meant to be this file's worth of work and nothing else:
|
|
4
|
+
// the facts about its config (most agents are one entry in json-agents.ts),
|
|
5
|
+
// one entry in the registry, the same id in the server's CLI_AGENTS, and a
|
|
6
|
+
// fixture test. The interesting part of supporting a new agent is finding out
|
|
7
|
+
// from its own docs where it keeps things, not writing code.
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `noslopui whoami` and `noslopui logout` — the CLI's own session.
|
|
2
|
+
import { origin, whoami as fetchAccount } from '../lib/api.js';
|
|
3
|
+
import { clearSession, readSession, sessionPath } from '../lib/session.js';
|
|
4
|
+
import { color, hint, ok, say, warn } from '../lib/ui.js';
|
|
5
|
+
export async function whoami(options) {
|
|
6
|
+
const session = readSession();
|
|
7
|
+
if (!session) {
|
|
8
|
+
if (options.json)
|
|
9
|
+
say(JSON.stringify({ signedIn: false }));
|
|
10
|
+
else {
|
|
11
|
+
say();
|
|
12
|
+
warn('Not signed in.');
|
|
13
|
+
hint('Run "npx noslopui login".');
|
|
14
|
+
say();
|
|
15
|
+
}
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const account = await fetchAccount(session.key);
|
|
19
|
+
if (options.json) {
|
|
20
|
+
say(JSON.stringify({ signedIn: true, ...account }, null, 2));
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const plan = account.plan === 'paid'
|
|
24
|
+
? 'paid plan'
|
|
25
|
+
: account.plan === 'trial'
|
|
26
|
+
? account.trialEndsAt
|
|
27
|
+
? `free trial, ends ${new Date(account.trialEndsAt).toLocaleDateString()}`
|
|
28
|
+
: 'free trial, not started yet — the first unlock starts it'
|
|
29
|
+
: 'free — search and collections; code and DESIGN.md files need a plan';
|
|
30
|
+
say();
|
|
31
|
+
ok(`${color.bold(account.email ?? 'Signed in')} · ${plan}`);
|
|
32
|
+
hint(session.savedAt === 'env' ? 'key from NOSLOPUI_API_KEY' : `key saved in ${sessionPath()}`);
|
|
33
|
+
say();
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
export async function logout() {
|
|
37
|
+
say();
|
|
38
|
+
if (clearSession()) {
|
|
39
|
+
ok('Signed out on this machine.');
|
|
40
|
+
hint(`The key still exists on your account — revoke it at ${origin()}/account?tab=mcp if this machine shouldn't have it.`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
warn('Not signed in, so nothing to do.');
|
|
44
|
+
}
|
|
45
|
+
if (process.env.NOSLOPUI_API_KEY)
|
|
46
|
+
hint('NOSLOPUI_API_KEY is still set in this shell.');
|
|
47
|
+
say();
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The catalog from the terminal: search, get, prompt, design-system,
|
|
2
|
+
// design-systems, collections, collection.
|
|
3
|
+
//
|
|
4
|
+
// Every command is one call to the MCP server's own tools with the CLI's key,
|
|
5
|
+
// so the terminal and an agent always see the same thing, and every gate —
|
|
6
|
+
// plan, trial, fair use — stays on the server where it belongs.
|
|
7
|
+
//
|
|
8
|
+
// Output rule: the thing asked for goes to stdout, everything about it goes to
|
|
9
|
+
// stderr. `npx noslopui get hero-aurora > Hero.tsx` has to produce a file with
|
|
10
|
+
// nothing in it but the component.
|
|
11
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { dirname, resolve } from 'node:path';
|
|
13
|
+
import { callTool } from '../lib/api.js';
|
|
14
|
+
import { readSession } from '../lib/session.js';
|
|
15
|
+
import { color } from '../lib/ui.js';
|
|
16
|
+
const out = (s = '') => process.stdout.write(`${s}\n`);
|
|
17
|
+
const note = (s = '') => process.stderr.write(`${s}\n`);
|
|
18
|
+
function requireKey() {
|
|
19
|
+
const session = readSession();
|
|
20
|
+
if (!session)
|
|
21
|
+
throw new Error('Not signed in. Run "npx noslopui login" first — it takes one approval in the browser.');
|
|
22
|
+
return session.key;
|
|
23
|
+
}
|
|
24
|
+
function requireArg(options, what) {
|
|
25
|
+
const value = options.args.join(' ').trim();
|
|
26
|
+
if (!value)
|
|
27
|
+
throw new Error(`Say which ${what}.`);
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
/** A tool error is the server telling the user something; print it as-is and fail. */
|
|
31
|
+
function check(result) {
|
|
32
|
+
if (result.isError)
|
|
33
|
+
throw new Error(result.text);
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
/** Notices the server attaches to a result (the trial just started, and so on). */
|
|
37
|
+
function notices(data) {
|
|
38
|
+
if (typeof data?.trial === 'string')
|
|
39
|
+
note(color.yellow(`! ${data.trial}`));
|
|
40
|
+
}
|
|
41
|
+
function writeOut(path, text, force) {
|
|
42
|
+
const target = resolve(path);
|
|
43
|
+
if (existsSync(target) && !force)
|
|
44
|
+
throw new Error(`${target} already exists. Pass --force to overwrite it.`);
|
|
45
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
46
|
+
writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`, 'utf8');
|
|
47
|
+
note(`${color.green('✔')} wrote ${target}`);
|
|
48
|
+
}
|
|
49
|
+
const truncate = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s ?? '');
|
|
50
|
+
export async function search(options) {
|
|
51
|
+
const key = requireKey();
|
|
52
|
+
const r = check(await callTool(key, 'search_components', {
|
|
53
|
+
...(options.args.length ? { query: options.args.join(' ') } : {}),
|
|
54
|
+
...(options.tags.length ? { tags: options.tags } : {}),
|
|
55
|
+
...(options.framework ? { framework: options.framework } : {}),
|
|
56
|
+
...(options.limit ? { limit: options.limit } : {}),
|
|
57
|
+
}));
|
|
58
|
+
if (options.json)
|
|
59
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
60
|
+
const results = r.data?.results ?? [];
|
|
61
|
+
if (!results.length) {
|
|
62
|
+
note('Nothing matched. Try fewer words, or no tags.');
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
for (const item of results) {
|
|
66
|
+
out(`${color.bold(item.name)} ${color.dim(item.id)}`);
|
|
67
|
+
if (item.description)
|
|
68
|
+
out(` ${truncate(item.description, 110)}`);
|
|
69
|
+
out(` ${color.cyan(item.url)}`);
|
|
70
|
+
out();
|
|
71
|
+
}
|
|
72
|
+
note(color.dim(`Get one with "npx noslopui get <id>".`));
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
export async function designSystems(options) {
|
|
76
|
+
const key = requireKey();
|
|
77
|
+
const r = check(await callTool(key, 'search_design_systems', {
|
|
78
|
+
...(options.args.length ? { query: options.args.join(' ') } : {}),
|
|
79
|
+
...(options.tags.length ? { tags: options.tags } : {}),
|
|
80
|
+
...(options.limit ? { limit: options.limit } : {}),
|
|
81
|
+
}));
|
|
82
|
+
if (options.json)
|
|
83
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
84
|
+
const results = r.data?.results ?? [];
|
|
85
|
+
if (!results.length) {
|
|
86
|
+
note('Nothing matched.');
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
for (const ds of results) {
|
|
90
|
+
const inspired = ds.inspiredBy ? color.dim(` · inspired by ${ds.inspiredBy}`) : '';
|
|
91
|
+
out(`${color.bold(ds.name)} ${color.dim(ds.id)}${inspired}`);
|
|
92
|
+
if (ds.description)
|
|
93
|
+
out(` ${truncate(ds.description, 110)}`);
|
|
94
|
+
if (ds.tags?.length)
|
|
95
|
+
out(` ${color.dim(ds.tags.join(' · '))}`);
|
|
96
|
+
out(` ${color.cyan(ds.previewUrl)}`);
|
|
97
|
+
out();
|
|
98
|
+
}
|
|
99
|
+
note(color.dim(`Get its DESIGN.md with "npx noslopui design-system <id> --write DESIGN.md".`));
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
export async function get(options) {
|
|
103
|
+
const key = requireKey();
|
|
104
|
+
const id = requireArg(options, 'component (its id, from "npx noslopui search")');
|
|
105
|
+
const r = check(await callTool(key, 'get_component_code', { id }));
|
|
106
|
+
notices(r.data);
|
|
107
|
+
if (options.json)
|
|
108
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
109
|
+
const snippets = r.data?.snippets ?? [];
|
|
110
|
+
if (!snippets.length)
|
|
111
|
+
throw new Error('This item has no published code.');
|
|
112
|
+
const chosen = options.format ? snippets.filter((s) => s.language === options.format) : snippets;
|
|
113
|
+
if (!chosen.length) {
|
|
114
|
+
throw new Error(`No ${options.format} version. Available: ${snippets.map((s) => s.language).join(', ')}.`);
|
|
115
|
+
}
|
|
116
|
+
if (options.write) {
|
|
117
|
+
if (chosen.length > 1)
|
|
118
|
+
throw new Error(`Several formats (${chosen.map((s) => s.language).join(', ')}) — pick one with --format.`);
|
|
119
|
+
writeOut(options.write, chosen[0].code, options.force);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (chosen.length === 1) {
|
|
123
|
+
out(chosen[0].code);
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
for (const s of chosen) {
|
|
127
|
+
note(color.dim(`── ${s.language} ──`));
|
|
128
|
+
out(s.code);
|
|
129
|
+
}
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
export async function prompt(options) {
|
|
133
|
+
const key = requireKey();
|
|
134
|
+
const id = requireArg(options, 'component');
|
|
135
|
+
const r = check(await callTool(key, 'get_component_prompt', { id }));
|
|
136
|
+
notices(r.data);
|
|
137
|
+
if (options.json)
|
|
138
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
139
|
+
if (options.write)
|
|
140
|
+
writeOut(options.write, r.data?.promptText ?? '', options.force);
|
|
141
|
+
else
|
|
142
|
+
out(r.data?.promptText ?? '');
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
export async function designSystem(options) {
|
|
146
|
+
const key = requireKey();
|
|
147
|
+
const id = requireArg(options, 'design system (its id, from "npx noslopui design-systems")');
|
|
148
|
+
const r = check(await callTool(key, 'get_design_system_file', { id }));
|
|
149
|
+
notices(r.data);
|
|
150
|
+
if (options.json)
|
|
151
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
152
|
+
if (options.write)
|
|
153
|
+
writeOut(options.write, r.data?.designMd ?? '', options.force);
|
|
154
|
+
else
|
|
155
|
+
out(r.data?.designMd ?? '');
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
export async function collections(options) {
|
|
159
|
+
const key = requireKey();
|
|
160
|
+
const r = check(await callTool(key, 'list_collections'));
|
|
161
|
+
if (options.json)
|
|
162
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
163
|
+
const list = r.data?.collections ?? [];
|
|
164
|
+
if (!list.length) {
|
|
165
|
+
note('No collections yet. Save items on noslopui.com to make one.');
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
for (const c of list)
|
|
169
|
+
out(`${color.bold(c.name)} ${color.dim(`${c.itemCount} ${c.itemCount === 1 ? 'item' : 'items'} · ${c.id}`)}`);
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
export async function collection(options) {
|
|
173
|
+
const key = requireKey();
|
|
174
|
+
const which = requireArg(options, 'collection (a name or an id)');
|
|
175
|
+
// Ids are opaque; try the name first, since that's what people type.
|
|
176
|
+
let r = await callTool(key, 'get_collection', { name: which });
|
|
177
|
+
if (r.isError)
|
|
178
|
+
r = await callTool(key, 'get_collection', { listId: which });
|
|
179
|
+
check(r);
|
|
180
|
+
if (options.json)
|
|
181
|
+
return out(JSON.stringify(r.data, null, 2)), 0;
|
|
182
|
+
out(color.bold(r.data?.collection ?? which));
|
|
183
|
+
const systems = r.data?.designSystems ?? [];
|
|
184
|
+
if (systems.length)
|
|
185
|
+
out(`${color.dim('design system:')} ${systems.map((d) => `${d.name} (${d.id})`).join(', ')}`);
|
|
186
|
+
out();
|
|
187
|
+
for (const item of r.data?.items ?? []) {
|
|
188
|
+
out(`${item.name} ${color.dim(`${item.kind === 'design_systems' ? 'design system' : 'block'} · ${item.id}`)}`);
|
|
189
|
+
}
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// `noslopui doctor` — what's set up, what's wrong, and what to do about it.
|
|
2
|
+
//
|
|
3
|
+
// The rule for every line: if something is broken, say the one thing that
|
|
4
|
+
// fixes it. A diagnostic that only reports a state leaves the user exactly
|
|
5
|
+
// where they started.
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { checkServer, mcpUrl, origin, whoami } from '../lib/api.js';
|
|
8
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
9
|
+
import { ConfigUnreadableError } from '../lib/json-file.js';
|
|
10
|
+
import { readSession } from '../lib/session.js';
|
|
11
|
+
import { color, fail, hint, ok, say, warn } from '../lib/ui.js';
|
|
12
|
+
export async function doctor() {
|
|
13
|
+
let problems = 0;
|
|
14
|
+
say();
|
|
15
|
+
say(`${color.bold('noslopUI doctor')} ${color.dim(`— ${origin()}`)}`);
|
|
16
|
+
say();
|
|
17
|
+
say(color.bold('Server'));
|
|
18
|
+
const reach = await checkServer();
|
|
19
|
+
if (reach.reachable)
|
|
20
|
+
ok(`${mcpUrl()} — ${reach.detail}`);
|
|
21
|
+
else {
|
|
22
|
+
fail(reach.detail);
|
|
23
|
+
hint('If you are behind a proxy or VPN, that is the usual cause.');
|
|
24
|
+
problems++;
|
|
25
|
+
}
|
|
26
|
+
say();
|
|
27
|
+
say(color.bold('This CLI'));
|
|
28
|
+
const session = readSession();
|
|
29
|
+
if (!session) {
|
|
30
|
+
say(`${color.dim('–')} ${color.dim('not signed in (only needed for "noslopui search" and "get"; run "npx noslopui login")')}`);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
try {
|
|
34
|
+
const account = await whoami(session.key);
|
|
35
|
+
ok(`Signed in as ${account.email ?? 'your account'} (${account.plan})`);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
fail(err.message);
|
|
39
|
+
hint('Run "npx noslopui login" to sign in again.');
|
|
40
|
+
problems++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let anyAgent = false;
|
|
44
|
+
for (const adapter of ADAPTERS) {
|
|
45
|
+
if (!(await adapter.detect()))
|
|
46
|
+
continue;
|
|
47
|
+
anyAgent = true;
|
|
48
|
+
say();
|
|
49
|
+
say(color.bold(adapter.label));
|
|
50
|
+
let status;
|
|
51
|
+
try {
|
|
52
|
+
status = await adapter.status();
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof ConfigUnreadableError) {
|
|
56
|
+
fail(err.message);
|
|
57
|
+
hint('Fix that file by hand — this tool will not overwrite a config it cannot read.');
|
|
58
|
+
problems++;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
if (!status.installed) {
|
|
64
|
+
warn('noslopUI is not set up here.');
|
|
65
|
+
hint(`Run "npx noslopui init --agent ${adapter.id}".`);
|
|
66
|
+
problems++;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
ok(`Configured in ${status.configPath}`);
|
|
70
|
+
if (status.url !== mcpUrl()) {
|
|
71
|
+
warn(`The configured url is ${status.url}, but this CLI expects ${mcpUrl()}.`);
|
|
72
|
+
hint(`Run "npx noslopui init --agent ${adapter.id}" to update it.`);
|
|
73
|
+
problems++;
|
|
74
|
+
}
|
|
75
|
+
if (status.problem) {
|
|
76
|
+
warn(status.problem);
|
|
77
|
+
hint(`Run "npx noslopui init --agent ${adapter.id}" to repair the entry.`);
|
|
78
|
+
problems++;
|
|
79
|
+
}
|
|
80
|
+
const key = await adapter.configuredKey();
|
|
81
|
+
if (key) {
|
|
82
|
+
const probe = await checkServer(key);
|
|
83
|
+
if (probe.keyState === 'ok') {
|
|
84
|
+
ok('The configured key works.');
|
|
85
|
+
}
|
|
86
|
+
else if (probe.keyState === 'rejected') {
|
|
87
|
+
fail(probe.keyDetail ?? 'The configured key was refused.');
|
|
88
|
+
hint(`Run "npx noslopui login --agent ${adapter.id}" for a fresh key.`);
|
|
89
|
+
problems++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const skillPath = adapter.skillPath();
|
|
94
|
+
if (!skillPath) {
|
|
95
|
+
say(`${color.dim('–')} ${color.dim('no skills folder; the rules reach it through the server')}`);
|
|
96
|
+
}
|
|
97
|
+
else if (existsSync(skillPath)) {
|
|
98
|
+
let current = '';
|
|
99
|
+
try {
|
|
100
|
+
current = readFileSync(skillPath, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
current = '';
|
|
104
|
+
}
|
|
105
|
+
if (current.includes('noslopui'))
|
|
106
|
+
ok(`Skill installed at ${skillPath}`);
|
|
107
|
+
else
|
|
108
|
+
warn(`${skillPath} exists but doesn't look like the noslopUI skill.`);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
warn('The skill is not installed.');
|
|
112
|
+
hint('Optional, but it is what makes "build me a website" use noslopUI at all. Run "npx noslopui update".');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!anyAgent) {
|
|
116
|
+
say();
|
|
117
|
+
warn(`None of the agents this CLI sets up were found (${ADAPTERS.map((a) => a.label).join(', ')}).`);
|
|
118
|
+
hint(`Any other MCP client can be connected by hand: ${origin()}/mcp`);
|
|
119
|
+
}
|
|
120
|
+
say();
|
|
121
|
+
if (problems === 0) {
|
|
122
|
+
ok('Everything checks out.');
|
|
123
|
+
say();
|
|
124
|
+
return 0;
|
|
125
|
+
}
|
|
126
|
+
fail(`${problems} ${problems === 1 ? 'problem' : 'problems'} found.`);
|
|
127
|
+
say();
|
|
128
|
+
return 1;
|
|
129
|
+
}
|