noslopui 0.0.1 → 0.1.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 +76 -2
- package/SKILL.md +249 -0
- package/dist/adapters/claude-code.js +146 -0
- package/dist/adapters/index.js +19 -0
- package/dist/adapters/types.js +8 -0
- package/dist/commands/doctor.js +118 -0
- package/dist/commands/init.js +129 -0
- package/dist/commands/login.js +26 -0
- package/dist/commands/remove.js +39 -0
- package/dist/commands/update.js +42 -0
- package/dist/index.js +115 -0
- package/dist/lib/api.js +164 -0
- package/dist/lib/json-file.js +98 -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,129 @@
|
|
|
1
|
+
// `noslopui init` — the whole setup, in one command.
|
|
2
|
+
//
|
|
3
|
+
// detect the agent → sign in in the browser → write the MCP entry →
|
|
4
|
+
// install the skill → say what to do next
|
|
5
|
+
//
|
|
6
|
+
// Nothing here is silent: every file that gets written is named on screen,
|
|
7
|
+
// with its backup, because this command edits configuration the user didn't
|
|
8
|
+
// ask us to know about.
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { ApiError, mcpUrl, origin, startSignIn, waitForApproval, fetchSkill } from '../lib/api.js';
|
|
11
|
+
import { adapterById, detectAdapters } from '../adapters/index.js';
|
|
12
|
+
import { color, confirm, hint, interactive, machineLabel, maskKey, ok, say, step, warn } from '../lib/ui.js';
|
|
13
|
+
import { ConfigUnreadableError } from '../lib/json-file.js';
|
|
14
|
+
/** Best-effort: if it doesn't open, the URL is on screen anyway. */
|
|
15
|
+
function openBrowser(url) {
|
|
16
|
+
const [command, args] = process.platform === 'win32'
|
|
17
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
18
|
+
: process.platform === 'darwin'
|
|
19
|
+
? ['open', [url]]
|
|
20
|
+
: ['xdg-open', [url]];
|
|
21
|
+
try {
|
|
22
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
23
|
+
child.on('error', () => { });
|
|
24
|
+
child.unref();
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Headless, locked down, or no desktop — the printed URL covers it.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function chooseAdapters(options) {
|
|
31
|
+
if (options.agents?.length) {
|
|
32
|
+
const chosen = [];
|
|
33
|
+
for (const id of options.agents) {
|
|
34
|
+
const adapter = adapterById(id);
|
|
35
|
+
if (!adapter)
|
|
36
|
+
throw new Error(`Unknown agent "${id}". Supported: ${(await detectAdapters()).map((a) => a.id).join(', ') || 'claude-code'}.`);
|
|
37
|
+
chosen.push(adapter);
|
|
38
|
+
}
|
|
39
|
+
return chosen;
|
|
40
|
+
}
|
|
41
|
+
const detected = await detectAdapters();
|
|
42
|
+
if (!detected.length) {
|
|
43
|
+
throw new Error('No supported agent found on this machine. noslopUI sets up Claude Code today; install it first, or add the server by hand from https://noslopui.com/mcp.');
|
|
44
|
+
}
|
|
45
|
+
const chosen = [];
|
|
46
|
+
for (const adapter of detected) {
|
|
47
|
+
if (options.yes || !interactive()) {
|
|
48
|
+
chosen.push(adapter);
|
|
49
|
+
}
|
|
50
|
+
else if (await confirm(`Set up ${color.bold(adapter.label)}?`, true)) {
|
|
51
|
+
chosen.push(adapter);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!chosen.length)
|
|
55
|
+
throw new Error('Nothing selected, so nothing was changed.');
|
|
56
|
+
return chosen;
|
|
57
|
+
}
|
|
58
|
+
export async function init(options) {
|
|
59
|
+
say();
|
|
60
|
+
say(`${color.bold('noslopUI')} ${color.dim('— hand-crafted components and design systems for your agent')}`);
|
|
61
|
+
say();
|
|
62
|
+
const adapters = await chooseAdapters(options);
|
|
63
|
+
for (const adapter of adapters)
|
|
64
|
+
ok(`Found ${adapter.label}`);
|
|
65
|
+
// Either a key the user already has, or one issued through the browser.
|
|
66
|
+
let keys;
|
|
67
|
+
if (options.key) {
|
|
68
|
+
keys = adapters.map((a) => ({ agent: a.id, label: a.label, name: 'provided key', key: options.key }));
|
|
69
|
+
step('Using the key you passed with --key.');
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
const machine = machineLabel();
|
|
73
|
+
const started = await startSignIn(machine, adapters.map((a) => a.id));
|
|
74
|
+
say();
|
|
75
|
+
step(`Approve this in your browser, signed in to noslopUI:`);
|
|
76
|
+
say();
|
|
77
|
+
say(` ${color.bold(color.cyan(started.verificationUriComplete))}`);
|
|
78
|
+
say();
|
|
79
|
+
say(` code ${color.bold(started.userCode)} · this machine is shown as "${machine}" · expires in ${Math.round(started.expiresIn / 60)} minutes`);
|
|
80
|
+
say();
|
|
81
|
+
if (options.browser !== false)
|
|
82
|
+
openBrowser(started.verificationUriComplete);
|
|
83
|
+
step('Waiting for approval…');
|
|
84
|
+
keys = await waitForApproval(started);
|
|
85
|
+
ok(`Approved. ${keys.length === 1 ? 'One key issued' : `${keys.length} keys issued`}.`);
|
|
86
|
+
}
|
|
87
|
+
say();
|
|
88
|
+
const restartNeeded = [];
|
|
89
|
+
for (const adapter of adapters) {
|
|
90
|
+
const issued = keys.find((k) => k.agent === adapter.id) ?? keys[0];
|
|
91
|
+
if (!issued)
|
|
92
|
+
throw new Error(`No key was issued for ${adapter.label}.`);
|
|
93
|
+
const result = await adapter.install({ url: mcpUrl(), key: issued.key });
|
|
94
|
+
ok(`${adapter.label}: ${result.replacedExisting ? 'updated' : 'added'} the noslopui server`);
|
|
95
|
+
hint(`config ${result.configPath}`);
|
|
96
|
+
if (result.backupPath)
|
|
97
|
+
hint(`backup ${result.backupPath}`);
|
|
98
|
+
hint(`key ${maskKey(issued.key)} — named "${issued.name}" on your account`);
|
|
99
|
+
restartNeeded.push(adapter.label);
|
|
100
|
+
if (options.skill) {
|
|
101
|
+
const download = await fetchSkill();
|
|
102
|
+
const skill = await adapter.installSkill(download.markdown);
|
|
103
|
+
ok(`${adapter.label}: ${skill.changed ? 'installed' : 'already had'} the noslopUI skill`);
|
|
104
|
+
hint(`skill ${skill.path}`);
|
|
105
|
+
if (download.source === 'bundled') {
|
|
106
|
+
warn('That skill came from this package, not the site — it may be behind. Run "npx noslopui update" when you are back online.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
say();
|
|
111
|
+
say(color.bold('Done. Two things:'));
|
|
112
|
+
say(` 1. Restart ${restartNeeded.join(' and ')} so it picks up the new server.`);
|
|
113
|
+
say(` 2. Try it: ${color.cyan('"build me a landing page for a coffee roastery"')}`);
|
|
114
|
+
say();
|
|
115
|
+
hint(`Revoke a key any time at ${origin()}/account?tab=mcp · check the setup with "npx noslopui doctor"`);
|
|
116
|
+
say();
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
/** Turns the errors this command can hit into something worth reading. */
|
|
120
|
+
export function explain(err) {
|
|
121
|
+
if (err instanceof ConfigUnreadableError) {
|
|
122
|
+
return `${err.message}\n Fix or move that file, then run this again. Nothing was written.`;
|
|
123
|
+
}
|
|
124
|
+
if (err instanceof ApiError)
|
|
125
|
+
return err.message;
|
|
126
|
+
if (err instanceof Error)
|
|
127
|
+
return err.message;
|
|
128
|
+
return String(err);
|
|
129
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// `noslopui login` — a fresh key for an agent that's already set up.
|
|
2
|
+
//
|
|
3
|
+
// The case this exists for: the key was revoked, or it belongs to the wrong
|
|
4
|
+
// account. It swaps the key in place and leaves everything else — the skill,
|
|
5
|
+
// the server entry's other fields, every other configured server — alone.
|
|
6
|
+
import { init } from './init.js';
|
|
7
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
8
|
+
import { say, warn, hint } from '../lib/ui.js';
|
|
9
|
+
export async function login(options) {
|
|
10
|
+
const configured = [];
|
|
11
|
+
for (const adapter of ADAPTERS) {
|
|
12
|
+
if (!(await adapter.detect()))
|
|
13
|
+
continue;
|
|
14
|
+
if ((await adapter.status()).installed)
|
|
15
|
+
configured.push(adapter.id);
|
|
16
|
+
}
|
|
17
|
+
if (!configured.length) {
|
|
18
|
+
say();
|
|
19
|
+
warn('No agent here has noslopUI set up yet.');
|
|
20
|
+
hint('Run "npx noslopui init" — it does the sign-in and the setup together.');
|
|
21
|
+
say();
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
// Same path as init, minus the skill: this is only about the key.
|
|
25
|
+
return init({ ...options, agents: options.agents?.length ? options.agents : configured, skill: false });
|
|
26
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `noslopui remove` — take our entry back out, leave everything else alone.
|
|
2
|
+
//
|
|
3
|
+
// Removing the config entry does NOT revoke the key: the key belongs to the
|
|
4
|
+
// account, not to this machine, and someone removing a local config hasn't
|
|
5
|
+
// asked us to change their account. We say where to revoke it instead.
|
|
6
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
7
|
+
import { origin } from '../lib/api.js';
|
|
8
|
+
import { color, confirm, hint, ok, say, warn } from '../lib/ui.js';
|
|
9
|
+
export async function remove(options) {
|
|
10
|
+
say();
|
|
11
|
+
let removedAnything = false;
|
|
12
|
+
for (const adapter of ADAPTERS) {
|
|
13
|
+
if (!(await adapter.detect()))
|
|
14
|
+
continue;
|
|
15
|
+
const status = await adapter.status();
|
|
16
|
+
if (!status.installed) {
|
|
17
|
+
say(`${color.dim('–')} ${adapter.label}: nothing to remove`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (!options.yes && !(await confirm(`Remove the noslopui server from ${color.bold(adapter.label)}?`, true))) {
|
|
21
|
+
say(`${color.dim('–')} ${adapter.label}: left alone`);
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (await adapter.uninstall()) {
|
|
25
|
+
ok(`${adapter.label}: removed the noslopui server (other servers untouched)`);
|
|
26
|
+
removedAnything = true;
|
|
27
|
+
}
|
|
28
|
+
if (options.skill && (await adapter.removeSkill())) {
|
|
29
|
+
ok(`${adapter.label}: removed the skill`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
say();
|
|
33
|
+
if (removedAnything) {
|
|
34
|
+
warn('Your API key still exists and still works.');
|
|
35
|
+
hint(`Revoke it at ${origin()}/account?tab=mcp if this machine should no longer have access.`);
|
|
36
|
+
}
|
|
37
|
+
say();
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `noslopui update` — refresh what's already installed, without touching auth.
|
|
2
|
+
//
|
|
3
|
+
// In practice that means the skill: it's served from noslopui.com and changes
|
|
4
|
+
// as the workflow improves, while the config entry only changes if the server
|
|
5
|
+
// URL does. Nothing here needs a sign-in, so an expired trial or a revoked key
|
|
6
|
+
// doesn't block it.
|
|
7
|
+
import { fetchSkill, mcpUrl } from '../lib/api.js';
|
|
8
|
+
import { ADAPTERS } from '../adapters/index.js';
|
|
9
|
+
import { color, hint, ok, say, warn } from '../lib/ui.js';
|
|
10
|
+
export async function update() {
|
|
11
|
+
say();
|
|
12
|
+
let touched = false;
|
|
13
|
+
let markdown = null;
|
|
14
|
+
for (const adapter of ADAPTERS) {
|
|
15
|
+
if (!(await adapter.detect()))
|
|
16
|
+
continue;
|
|
17
|
+
const status = await adapter.status();
|
|
18
|
+
if (!status.installed) {
|
|
19
|
+
say(`${color.dim('–')} ${adapter.label}: not set up, nothing to update`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (status.url !== mcpUrl()) {
|
|
23
|
+
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.');
|
|
25
|
+
}
|
|
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;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
ok(`${adapter.label}: skill already current`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
say();
|
|
38
|
+
if (touched)
|
|
39
|
+
say(`Restart your agent to pick up the new workflow.`);
|
|
40
|
+
say();
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// noslopui — connect a coding agent to noslopUI.
|
|
3
|
+
//
|
|
4
|
+
// Argument parsing is by hand and stays that way: a tool people run with
|
|
5
|
+
// `npx` should download in one tick, and a CLI-argument library is a
|
|
6
|
+
// dependency you wait for before anything happens.
|
|
7
|
+
import { init, explain } from './commands/init.js';
|
|
8
|
+
import { doctor } from './commands/doctor.js';
|
|
9
|
+
import { remove } from './commands/remove.js';
|
|
10
|
+
import { update } from './commands/update.js';
|
|
11
|
+
import { login } from './commands/login.js';
|
|
12
|
+
import { color, fail, say } from './lib/ui.js';
|
|
13
|
+
import { origin } from './lib/api.js';
|
|
14
|
+
import { CLI_VERSION as VERSION } from './lib/version.js';
|
|
15
|
+
function usage() {
|
|
16
|
+
say(`
|
|
17
|
+
${color.bold('noslopui')} — hand-crafted UI components and design systems, in your agent.
|
|
18
|
+
|
|
19
|
+
${color.bold('Usage')}
|
|
20
|
+
npx noslopui <command> [options]
|
|
21
|
+
|
|
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)
|
|
28
|
+
|
|
29
|
+
${color.bold('Options')}
|
|
30
|
+
--agent <id> Set up a specific agent instead of detecting (claude-code)
|
|
31
|
+
--no-skill Do not install the skill (init)
|
|
32
|
+
--no-browser Do not open a browser; print the URL instead (init, login)
|
|
33
|
+
--skill Also remove the skill (remove)
|
|
34
|
+
--key <key> Use a key you already have instead of signing in (init, login)
|
|
35
|
+
-y, --yes Take the default answer for every question
|
|
36
|
+
-h, --help Show this
|
|
37
|
+
-v, --version Show the version
|
|
38
|
+
|
|
39
|
+
${color.dim(`Server: ${origin()} · set NOSLOPUI_ORIGIN to point somewhere else.`)}
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
function parse(argv) {
|
|
43
|
+
const parsed = { command: '', agents: [], skill: null, yes: false, browser: true, help: false, version: false };
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const arg = argv[i];
|
|
46
|
+
if (arg === '--help' || arg === '-h')
|
|
47
|
+
parsed.help = true;
|
|
48
|
+
else if (arg === '--version' || arg === '-v')
|
|
49
|
+
parsed.version = true;
|
|
50
|
+
else if (arg === '--yes' || arg === '-y')
|
|
51
|
+
parsed.yes = true;
|
|
52
|
+
else if (arg === '--no-skill')
|
|
53
|
+
parsed.skill = false;
|
|
54
|
+
else if (arg === '--no-browser')
|
|
55
|
+
parsed.browser = false;
|
|
56
|
+
else if (arg === '--skill')
|
|
57
|
+
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;
|
|
68
|
+
else
|
|
69
|
+
throw new Error(`Unknown option "${arg}". Run "npx noslopui --help".`);
|
|
70
|
+
}
|
|
71
|
+
parsed.agents = parsed.agents.filter(Boolean);
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
async function main() {
|
|
75
|
+
const args = parse(process.argv.slice(2));
|
|
76
|
+
if (args.version) {
|
|
77
|
+
say(VERSION);
|
|
78
|
+
return 0;
|
|
79
|
+
}
|
|
80
|
+
if (args.help || !args.command) {
|
|
81
|
+
usage();
|
|
82
|
+
return args.command ? 0 : 1;
|
|
83
|
+
}
|
|
84
|
+
switch (args.command) {
|
|
85
|
+
case 'init':
|
|
86
|
+
return init({ agents: args.agents, skill: args.skill !== false, yes: args.yes, browser: args.browser, ...(args.key ? { key: args.key } : {}) });
|
|
87
|
+
case 'doctor':
|
|
88
|
+
return doctor();
|
|
89
|
+
case 'login':
|
|
90
|
+
return login({ agents: args.agents, yes: args.yes, browser: args.browser, ...(args.key ? { key: args.key } : {}) });
|
|
91
|
+
case 'update':
|
|
92
|
+
return update();
|
|
93
|
+
case 'remove':
|
|
94
|
+
return remove({ yes: args.yes, skill: args.skill === true });
|
|
95
|
+
default:
|
|
96
|
+
fail(`Unknown command "${args.command}".`);
|
|
97
|
+
usage();
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// `process.exitCode`, never `process.exit()`. Forcing an exit while a fetch
|
|
102
|
+
// socket is still closing trips a libuv assertion on Windows
|
|
103
|
+
// ("Assertion failed: !(handle->flags & UV_HANDLE_CLOSING)"), which prints an
|
|
104
|
+
// alarming crash after a run that actually succeeded. Setting the code and
|
|
105
|
+
// letting the loop drain is both correct and quiet.
|
|
106
|
+
main()
|
|
107
|
+
.then((code) => {
|
|
108
|
+
process.exitCode = code;
|
|
109
|
+
})
|
|
110
|
+
.catch((err) => {
|
|
111
|
+
say();
|
|
112
|
+
fail(explain(err));
|
|
113
|
+
say();
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
});
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
/**
|
|
80
|
+
* `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.
|
|
83
|
+
*/
|
|
84
|
+
export async function checkServer(key) {
|
|
85
|
+
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
|
+
};
|
|
92
|
+
if (key)
|
|
93
|
+
headers.authorization = `Bearer ${key}`;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetch(mcpUrl(), {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers,
|
|
98
|
+
body: JSON.stringify({
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: 1,
|
|
101
|
+
method: key ? 'tools/call' : 'tools/list',
|
|
102
|
+
params: key ? { name: 'list_collections', arguments: {} } : {},
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return { reachable: false, detail: `Could not reach ${mcpUrl()}.` };
|
|
108
|
+
}
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
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;
|
|
114
|
+
try {
|
|
115
|
+
json = JSON.parse(line ? line.slice(6) : text);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { reachable: false, detail: 'The server answered with something unreadable.' };
|
|
119
|
+
}
|
|
120
|
+
if (!key)
|
|
121
|
+
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) {
|
|
125
|
+
// The server writes these for an agent to relay; they're already the right
|
|
126
|
+
// words for a person, so pass them through rather than inventing our own.
|
|
127
|
+
return { reachable: true, detail: 'Server reachable.', keyState: 'rejected', keyDetail: body.split('. ')[0] };
|
|
128
|
+
}
|
|
129
|
+
return { reachable: true, detail: 'Server reachable.', keyState: 'ok', keyDetail: 'Key accepted.' };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The workflow file. Fetched from the site, because the site is where it's
|
|
133
|
+
* assembled — the same text the MCP server sends as its instructions, so the
|
|
134
|
+
* two can never disagree.
|
|
135
|
+
*
|
|
136
|
+
* The copy shipped in this package is a fallback for an unreachable site, not
|
|
137
|
+
* an equal: it's only as fresh as the last publish, so the caller says so.
|
|
138
|
+
*/
|
|
139
|
+
export async function fetchSkill() {
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`${origin()}/skill/SKILL.md`, { headers: { 'user-agent': USER_AGENT } });
|
|
142
|
+
if (res.ok) {
|
|
143
|
+
const text = await res.text();
|
|
144
|
+
if (text.startsWith('---'))
|
|
145
|
+
return { markdown: text, source: 'server' };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
// Fall through to the bundled copy.
|
|
150
|
+
}
|
|
151
|
+
const bundled = bundledSkill();
|
|
152
|
+
if (bundled)
|
|
153
|
+
return { markdown: bundled, source: 'bundled' };
|
|
154
|
+
throw new ApiError(`Could not download the skill from ${origin()}, and this package has no copy to fall back on.`);
|
|
155
|
+
}
|
|
156
|
+
function bundledSkill() {
|
|
157
|
+
try {
|
|
158
|
+
// dist/lib/api.js → the package root.
|
|
159
|
+
return readFileSync(fileURLToPath(new URL('../../SKILL.md', import.meta.url)), 'utf8');
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
super(`${path} is not valid JSON, so it was left untouched.`);
|
|
22
|
+
this.path = path;
|
|
23
|
+
this.cause_ = cause_;
|
|
24
|
+
this.name = 'ConfigUnreadableError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Reads a JSON object, or `null` when the file simply isn't there yet. */
|
|
28
|
+
export function readJsonFile(path) {
|
|
29
|
+
if (!existsSync(path))
|
|
30
|
+
return null;
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(path, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
throw new ConfigUnreadableError(path, err);
|
|
37
|
+
}
|
|
38
|
+
if (!raw.trim())
|
|
39
|
+
return {};
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(raw);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new ConfigUnreadableError(path, err);
|
|
46
|
+
}
|
|
47
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
48
|
+
throw new ConfigUnreadableError(path, new Error('top level is not an object'));
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* A timestamped copy beside the original: `.claude.json.noslopui-backup-<ts>`.
|
|
54
|
+
* Beside it rather than in a temp directory so the user can find it without
|
|
55
|
+
* being told twice, and timestamped so a second run can't clobber the first
|
|
56
|
+
* backup — which would defeat the point of having one.
|
|
57
|
+
*/
|
|
58
|
+
export function backupFile(path) {
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
return null;
|
|
61
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
62
|
+
const backup = `${path}.noslopui-backup-${stamp}`;
|
|
63
|
+
copyFileSync(path, backup);
|
|
64
|
+
return backup;
|
|
65
|
+
}
|
|
66
|
+
/** Write via a temp file in the same directory, then rename over the target. */
|
|
67
|
+
export function writeJsonFile(path, value, { indent = 2 } = {}) {
|
|
68
|
+
const dir = dirname(path);
|
|
69
|
+
mkdirSync(dir, { recursive: true });
|
|
70
|
+
const temp = join(dir, `.noslopui-write-${process.pid}-${Date.now()}.tmp`);
|
|
71
|
+
try {
|
|
72
|
+
writeFileSync(temp, `${JSON.stringify(value, null, indent)}\n`, 'utf8');
|
|
73
|
+
renameSync(temp, path);
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync(temp))
|
|
78
|
+
unlinkSync(temp);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// The rename is what matters; a stray temp file is not worth a second error.
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Indentation of an existing file, so we write it back the way we found it. */
|
|
87
|
+
export function detectIndent(path) {
|
|
88
|
+
try {
|
|
89
|
+
const raw = readFileSync(path, 'utf8');
|
|
90
|
+
const match = raw.match(/\n(\s+)"/);
|
|
91
|
+
if (match?.[1] && !match[1].includes('\t'))
|
|
92
|
+
return match[1].length;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Not worth failing a write over; fall through to the default.
|
|
96
|
+
}
|
|
97
|
+
return 2;
|
|
98
|
+
}
|