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,214 @@
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
+ // The catalog's two component sections, as search_components names them.
17
+ // Hyphens are accepted too, since that's how they read in URLs.
18
+ const SECTIONS = {
19
+ ui_blocks: 'ui_blocks',
20
+ 'ui-blocks': 'ui_blocks',
21
+ blocks: 'ui_blocks',
22
+ motion_lab: 'motion_lab',
23
+ 'motion-lab': 'motion_lab',
24
+ effects: 'motion_lab',
25
+ };
26
+ /** search_components arguments for these options; throws on a section it doesn't know. */
27
+ export function searchArguments(options) {
28
+ let section;
29
+ if (options.section !== undefined) {
30
+ section = SECTIONS[options.section.toLowerCase()];
31
+ if (!section)
32
+ throw new Error(`--section takes ui-blocks or motion-lab, not "${options.section}".`);
33
+ }
34
+ return {
35
+ ...(options.args.length ? { query: options.args.join(' ') } : {}),
36
+ ...(section ? { section } : {}),
37
+ ...(options.tags.length ? { tags: options.tags } : {}),
38
+ ...(options.framework ? { framework: options.framework } : {}),
39
+ ...(options.limit ? { limit: options.limit } : {}),
40
+ };
41
+ }
42
+ const out = (s = '') => process.stdout.write(`${s}\n`);
43
+ const note = (s = '') => process.stderr.write(`${s}\n`);
44
+ function requireKey() {
45
+ const session = readSession();
46
+ if (!session)
47
+ throw new Error('Not signed in. Run "npx noslopui login" first — it takes one approval in the browser.');
48
+ return session.key;
49
+ }
50
+ function requireArg(options, what) {
51
+ const value = options.args.join(' ').trim();
52
+ if (!value)
53
+ throw new Error(`Say which ${what}.`);
54
+ return value;
55
+ }
56
+ /** A tool error is the server telling the user something; print it as-is and fail. */
57
+ function check(result) {
58
+ if (result.isError)
59
+ throw new Error(result.text);
60
+ return result;
61
+ }
62
+ /** Notices the server attaches to a result (the trial just started, and so on). */
63
+ function notices(data) {
64
+ if (typeof data?.trial === 'string')
65
+ note(color.yellow(`! ${data.trial}`));
66
+ }
67
+ function writeOut(path, text, force) {
68
+ const target = resolve(path);
69
+ if (existsSync(target) && !force)
70
+ throw new Error(`${target} already exists. Pass --force to overwrite it.`);
71
+ mkdirSync(dirname(target), { recursive: true });
72
+ writeFileSync(target, text.endsWith('\n') ? text : `${text}\n`, 'utf8');
73
+ note(`${color.green('✔')} wrote ${target}`);
74
+ }
75
+ const truncate = (s, n) => (s && s.length > n ? `${s.slice(0, n - 1)}…` : s ?? '');
76
+ export async function search(options) {
77
+ const args = searchArguments(options);
78
+ const key = requireKey();
79
+ const r = check(await callTool(key, 'search_components', args));
80
+ if (options.json)
81
+ return out(JSON.stringify(r.data, null, 2)), 0;
82
+ const results = r.data?.results ?? [];
83
+ if (!results.length) {
84
+ note('Nothing matched. Try fewer words, or no tags.');
85
+ return 1;
86
+ }
87
+ for (const item of results) {
88
+ const kind = item.kind === 'motion_lab' ? ` ${color.cyan('Motion Lab')}` : '';
89
+ out(`${color.bold(item.name)} ${color.dim(item.id)}${kind}`);
90
+ if (item.description)
91
+ out(` ${truncate(item.description, 110)}`);
92
+ out(` ${color.cyan(item.url)}`);
93
+ out();
94
+ }
95
+ note(color.dim(`Get one with "npx noslopui get <id>".`));
96
+ return 0;
97
+ }
98
+ export async function designSystems(options) {
99
+ const key = requireKey();
100
+ const r = check(await callTool(key, 'search_design_systems', {
101
+ ...(options.args.length ? { query: options.args.join(' ') } : {}),
102
+ ...(options.tags.length ? { tags: options.tags } : {}),
103
+ ...(options.limit ? { limit: options.limit } : {}),
104
+ }));
105
+ if (options.json)
106
+ return out(JSON.stringify(r.data, null, 2)), 0;
107
+ const results = r.data?.results ?? [];
108
+ if (!results.length) {
109
+ note('Nothing matched.');
110
+ return 1;
111
+ }
112
+ for (const ds of results) {
113
+ const inspired = ds.inspiredBy ? color.dim(` · inspired by ${ds.inspiredBy}`) : '';
114
+ out(`${color.bold(ds.name)} ${color.dim(ds.id)}${inspired}`);
115
+ if (ds.description)
116
+ out(` ${truncate(ds.description, 110)}`);
117
+ if (ds.tags?.length)
118
+ out(` ${color.dim(ds.tags.join(' · '))}`);
119
+ out(` ${color.cyan(ds.previewUrl)}`);
120
+ out();
121
+ }
122
+ note(color.dim(`Get its DESIGN.md with "npx noslopui design-system <id> --write DESIGN.md".`));
123
+ return 0;
124
+ }
125
+ export async function get(options) {
126
+ const key = requireKey();
127
+ const id = requireArg(options, 'component (its id, from "npx noslopui search")');
128
+ const r = check(await callTool(key, 'get_component_code', { id }));
129
+ notices(r.data);
130
+ if (options.json)
131
+ return out(JSON.stringify(r.data, null, 2)), 0;
132
+ const snippets = r.data?.snippets ?? [];
133
+ if (!snippets.length)
134
+ throw new Error('This item has no published code.');
135
+ const chosen = options.format ? snippets.filter((s) => s.language === options.format) : snippets;
136
+ if (!chosen.length) {
137
+ throw new Error(`No ${options.format} version. Available: ${snippets.map((s) => s.language).join(', ')}.`);
138
+ }
139
+ if (options.write) {
140
+ if (chosen.length > 1)
141
+ throw new Error(`Several formats (${chosen.map((s) => s.language).join(', ')}) — pick one with --format.`);
142
+ writeOut(options.write, chosen[0].code, options.force);
143
+ return 0;
144
+ }
145
+ if (chosen.length === 1) {
146
+ out(chosen[0].code);
147
+ return 0;
148
+ }
149
+ for (const s of chosen) {
150
+ note(color.dim(`── ${s.language} ──`));
151
+ out(s.code);
152
+ }
153
+ return 0;
154
+ }
155
+ export async function prompt(options) {
156
+ const key = requireKey();
157
+ const id = requireArg(options, 'component');
158
+ const r = check(await callTool(key, 'get_component_prompt', { id }));
159
+ notices(r.data);
160
+ if (options.json)
161
+ return out(JSON.stringify(r.data, null, 2)), 0;
162
+ if (options.write)
163
+ writeOut(options.write, r.data?.promptText ?? '', options.force);
164
+ else
165
+ out(r.data?.promptText ?? '');
166
+ return 0;
167
+ }
168
+ export async function designSystem(options) {
169
+ const key = requireKey();
170
+ const id = requireArg(options, 'design system (its id, from "npx noslopui design-systems")');
171
+ const r = check(await callTool(key, 'get_design_system_file', { id }));
172
+ notices(r.data);
173
+ if (options.json)
174
+ return out(JSON.stringify(r.data, null, 2)), 0;
175
+ if (options.write)
176
+ writeOut(options.write, r.data?.designMd ?? '', options.force);
177
+ else
178
+ out(r.data?.designMd ?? '');
179
+ return 0;
180
+ }
181
+ export async function collections(options) {
182
+ const key = requireKey();
183
+ const r = check(await callTool(key, 'list_collections'));
184
+ if (options.json)
185
+ return out(JSON.stringify(r.data, null, 2)), 0;
186
+ const list = r.data?.collections ?? [];
187
+ if (!list.length) {
188
+ note('No collections yet. Save items on noslopui.com to make one.');
189
+ return 0;
190
+ }
191
+ for (const c of list)
192
+ out(`${color.bold(c.name)} ${color.dim(`${c.itemCount} ${c.itemCount === 1 ? 'item' : 'items'} · ${c.id}`)}`);
193
+ return 0;
194
+ }
195
+ export async function collection(options) {
196
+ const key = requireKey();
197
+ const which = requireArg(options, 'collection (a name or an id)');
198
+ // Ids are opaque; try the name first, since that's what people type.
199
+ let r = await callTool(key, 'get_collection', { name: which });
200
+ if (r.isError)
201
+ r = await callTool(key, 'get_collection', { listId: which });
202
+ check(r);
203
+ if (options.json)
204
+ return out(JSON.stringify(r.data, null, 2)), 0;
205
+ out(color.bold(r.data?.collection ?? which));
206
+ const systems = r.data?.designSystems ?? [];
207
+ if (systems.length)
208
+ out(`${color.dim('design system:')} ${systems.map((d) => `${d.name} (${d.id})`).join(', ')}`);
209
+ out();
210
+ for (const item of r.data?.items ?? []) {
211
+ out(`${item.name} ${color.dim(`${item.kind === 'design_systems' ? 'design system' : 'block'} · ${item.id}`)}`);
212
+ }
213
+ return 0;
214
+ }
@@ -4,25 +4,11 @@
4
4
  // fixes it. A diagnostic that only reports a state leaves the user exactly
5
5
  // where they started.
6
6
  import { existsSync, readFileSync } from 'node:fs';
7
- import { checkServer, mcpUrl, origin } from '../lib/api.js';
7
+ import { checkServer, mcpUrl, origin, whoami } from '../lib/api.js';
8
8
  import { ADAPTERS } from '../adapters/index.js';
9
- import { ConfigUnreadableError, readJsonFile } from '../lib/json-file.js';
10
- import { SERVER_NAME } from '../adapters/claude-code.js';
9
+ import { ConfigUnreadableError } from '../lib/json-file.js';
10
+ import { readSession } from '../lib/session.js';
11
11
  import { color, fail, hint, ok, say, warn } from '../lib/ui.js';
12
- /** Read back the configured key so we can ask the server whether it still works. */
13
- function configuredKey(configPath) {
14
- try {
15
- const config = readJsonFile(configPath);
16
- const entry = config?.mcpServers?.[SERVER_NAME];
17
- const headers = (entry?.headers ?? {});
18
- const auth = typeof headers.Authorization === 'string' ? headers.Authorization : '';
19
- const match = auth.match(/^Bearer\s+(\S+)$/i);
20
- return match?.[1] ?? null;
21
- }
22
- catch {
23
- return null;
24
- }
25
- }
26
12
  export async function doctor() {
27
13
  let problems = 0;
28
14
  say();
@@ -37,13 +23,30 @@ export async function doctor() {
37
23
  hint('If you are behind a proxy or VPN, that is the usual cause.');
38
24
  problems++;
39
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;
40
44
  for (const adapter of ADAPTERS) {
45
+ if (!(await adapter.detect()))
46
+ continue;
47
+ anyAgent = true;
41
48
  say();
42
49
  say(color.bold(adapter.label));
43
- if (!(await adapter.detect())) {
44
- say(`${color.dim('–')} ${color.dim('not installed on this machine')}`);
45
- continue;
46
- }
47
50
  let status;
48
51
  try {
49
52
  status = await adapter.status();
@@ -59,22 +62,22 @@ export async function doctor() {
59
62
  }
60
63
  if (!status.installed) {
61
64
  warn('noslopUI is not set up here.');
62
- hint('Run "npx noslopui init".');
65
+ hint(`Run "npx noslopui init --agent ${adapter.id}".`);
63
66
  problems++;
64
67
  }
65
68
  else {
66
69
  ok(`Configured in ${status.configPath}`);
67
70
  if (status.url !== mcpUrl()) {
68
71
  warn(`The configured url is ${status.url}, but this CLI expects ${mcpUrl()}.`);
69
- hint('Run "npx noslopui init" to update it.');
72
+ hint(`Run "npx noslopui init --agent ${adapter.id}" to update it.`);
70
73
  problems++;
71
74
  }
72
75
  if (status.problem) {
73
76
  warn(status.problem);
74
- hint('Run "npx noslopui init" to repair the entry.');
77
+ hint(`Run "npx noslopui init --agent ${adapter.id}" to repair the entry.`);
75
78
  problems++;
76
79
  }
77
- const key = configuredKey(status.configPath);
80
+ const key = await adapter.configuredKey();
78
81
  if (key) {
79
82
  const probe = await checkServer(key);
80
83
  if (probe.keyState === 'ok') {
@@ -82,13 +85,16 @@ export async function doctor() {
82
85
  }
83
86
  else if (probe.keyState === 'rejected') {
84
87
  fail(probe.keyDetail ?? 'The configured key was refused.');
85
- hint(`Create a new key at ${origin()}/account?tab=mcp, or run "npx noslopui login".`);
88
+ hint(`Run "npx noslopui login --agent ${adapter.id}" for a fresh key.`);
86
89
  problems++;
87
90
  }
88
91
  }
89
92
  }
90
93
  const skillPath = adapter.skillPath();
91
- if (existsSync(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)) {
92
98
  let current = '';
93
99
  try {
94
100
  current = readFileSync(skillPath, 'utf8');
@@ -103,9 +109,14 @@ export async function doctor() {
103
109
  }
104
110
  else {
105
111
  warn('The skill is not installed.');
106
- hint('Optional, but it is what makes "build me a website" use noslopUI at all. Run "npx noslopui init".');
112
+ hint('Optional, but it is what makes "build me a website" use noslopUI at all. Run "npx noslopui update".');
107
113
  }
108
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
+ }
109
120
  say();
110
121
  if (problems === 0) {
111
122
  ok('Everything checks out.');
@@ -1,18 +1,22 @@
1
1
  // `noslopui init` — the whole setup, in one command.
2
2
  //
3
- // detect the agent → sign in in the browser → write the MCP entry →
4
- // install the skill → say what to do next
3
+ // find the agents → one sign-in in the browser → write each agent's MCP
4
+ // entry → install the skill → say what to do next
5
5
  //
6
6
  // Nothing here is silent: every file that gets written is named on screen,
7
7
  // with its backup, because this command edits configuration the user didn't
8
8
  // ask us to know about.
9
+ //
10
+ // The same approval also gives the CLI itself a key (agent id `cli`), saved
11
+ // for `noslopui search`, `get` and `whoami` — unless it already has one.
9
12
  import { spawn } from 'node:child_process';
10
13
  import { ApiError, mcpUrl, origin, startSignIn, waitForApproval, fetchSkill } from '../lib/api.js';
11
- import { adapterById, detectAdapters } from '../adapters/index.js';
14
+ import { ADAPTERS, adapterById, detectAdapters } from '../adapters/index.js';
12
15
  import { color, confirm, hint, interactive, machineLabel, maskKey, ok, say, step, warn } from '../lib/ui.js';
13
16
  import { ConfigUnreadableError } from '../lib/json-file.js';
17
+ import { readSession, saveSession } from '../lib/session.js';
14
18
  /** Best-effort: if it doesn't open, the URL is on screen anyway. */
15
- function openBrowser(url) {
19
+ export function openBrowser(url) {
16
20
  const [command, args] = process.platform === 'win32'
17
21
  ? ['cmd', ['/c', 'start', '', url]]
18
22
  : process.platform === 'darwin'
@@ -27,29 +31,57 @@ function openBrowser(url) {
27
31
  // Headless, locked down, or no desktop — the printed URL covers it.
28
32
  }
29
33
  }
34
+ /**
35
+ * The browser half: open a request for these agent ids, show the code, wait
36
+ * for the user to approve it, and hand back one key per id.
37
+ */
38
+ export async function signIn(agentIds, browser) {
39
+ const machine = machineLabel();
40
+ const started = await startSignIn(machine, agentIds);
41
+ say();
42
+ step('Approve this in your browser, signed in to noslopUI:');
43
+ say();
44
+ say(` ${color.bold(color.cyan(started.verificationUriComplete))}`);
45
+ say();
46
+ say(` code ${color.bold(started.userCode)} · this machine is shown as "${machine}" · expires in ${Math.round(started.expiresIn / 60)} minutes`);
47
+ say();
48
+ if (browser)
49
+ openBrowser(started.verificationUriComplete);
50
+ step('Waiting for approval…');
51
+ const keys = await waitForApproval(started);
52
+ ok(`Approved. ${keys.length === 1 ? 'One key issued' : `${keys.length} keys issued`}.`);
53
+ return keys;
54
+ }
55
+ const supportedList = () => ADAPTERS.map((a) => a.id).join(', ');
30
56
  async function chooseAdapters(options) {
31
57
  if (options.agents?.length) {
32
58
  const chosen = [];
33
59
  for (const id of options.agents) {
34
60
  const adapter = adapterById(id);
35
61
  if (!adapter)
36
- throw new Error(`Unknown agent "${id}". Supported: ${(await detectAdapters()).map((a) => a.id).join(', ') || 'claude-code'}.`);
62
+ throw new Error(`Unknown agent "${id}". Supported: ${supportedList()}.`);
37
63
  chosen.push(adapter);
38
64
  }
39
65
  return chosen;
40
66
  }
41
67
  const detected = await detectAdapters();
42
68
  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.');
69
+ throw new Error(`No supported agent found on this machine. noslopUI sets up ${ADAPTERS.map((a) => a.label).join(', ')}.\n For anything else, add the server by hand: ${origin()}/mcp`);
44
70
  }
71
+ say(`Found ${detected.map((a) => color.bold(a.label)).join(', ')}.`);
72
+ if (options.yes || !interactive() || detected.length === 1) {
73
+ if (detected.length === 1 && interactive() && !options.yes) {
74
+ if (!(await confirm(`Set up ${color.bold(detected[0].label)}?`, true)))
75
+ throw new Error('Nothing selected, so nothing was changed.');
76
+ }
77
+ return detected;
78
+ }
79
+ if (await confirm(`Set up all ${detected.length}?`, true))
80
+ return detected;
45
81
  const chosen = [];
46
82
  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)) {
83
+ if (await confirm(`Set up ${color.bold(adapter.label)}?`, true))
51
84
  chosen.push(adapter);
52
- }
53
85
  }
54
86
  if (!chosen.length)
55
87
  throw new Error('Nothing selected, so nothing was changed.');
@@ -60,8 +92,6 @@ export async function init(options) {
60
92
  say(`${color.bold('noslopUI')} ${color.dim('— hand-crafted components and design systems for your agent')}`);
61
93
  say();
62
94
  const adapters = await chooseAdapters(options);
63
- for (const adapter of adapters)
64
- ok(`Found ${adapter.label}`);
65
95
  // Either a key the user already has, or one issued through the browser.
66
96
  let keys;
67
97
  if (options.key) {
@@ -69,25 +99,20 @@ export async function init(options) {
69
99
  step('Using the key you passed with --key.');
70
100
  }
71
101
  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`}.`);
102
+ const wantSession = !readSession();
103
+ keys = await signIn([...adapters.map((a) => a.id), ...(wantSession ? ['cli'] : [])], options.browser !== false);
104
+ const session = keys.find((k) => k.agent === 'cli');
105
+ if (session) {
106
+ const path = saveSession(session.key);
107
+ hint(`CLI signed in too, for "noslopui search" and "get" — ${path}`);
108
+ }
86
109
  }
87
110
  say();
88
111
  const restartNeeded = [];
112
+ const skillsWritten = new Map();
113
+ let skillText = null;
89
114
  for (const adapter of adapters) {
90
- const issued = keys.find((k) => k.agent === adapter.id) ?? keys[0];
115
+ const issued = keys.find((k) => k.agent === adapter.id);
91
116
  if (!issued)
92
117
  throw new Error(`No key was issued for ${adapter.label}.`);
93
118
  const result = await adapter.install({ url: mcpUrl(), key: issued.key });
@@ -97,19 +122,28 @@ export async function init(options) {
97
122
  hint(`backup ${result.backupPath}`);
98
123
  hint(`key ${maskKey(issued.key)} — named "${issued.name}" on your account`);
99
124
  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') {
125
+ if (!options.skill)
126
+ continue;
127
+ const path = adapter.skillPath();
128
+ if (!path) {
129
+ hint(`skill ${adapter.label} has no skills folder; the rules reach it through the server`);
130
+ continue;
131
+ }
132
+ // Several agents read one folder (~/.agents/skills); write it once.
133
+ if (!skillsWritten.has(path)) {
134
+ skillText ??= await fetchSkill();
135
+ const skill = await adapter.installSkill(skillText.markdown);
136
+ skillsWritten.set(path, skill?.changed ?? false);
137
+ if (skillText.source === 'bundled') {
106
138
  warn('That skill came from this package, not the site — it may be behind. Run "npx noslopui update" when you are back online.');
107
139
  }
108
140
  }
141
+ ok(`${adapter.label}: ${skillsWritten.get(path) ? 'installed' : 'already had'} the noslopUI skill`);
142
+ hint(`skill ${path}`);
109
143
  }
110
144
  say();
111
145
  say(color.bold('Done. Two things:'));
112
- say(` 1. Restart ${restartNeeded.join(' and ')} so it picks up the new server.`);
146
+ say(` 1. Restart ${restartNeeded.join(', ')} so ${restartNeeded.length === 1 ? 'it picks' : 'they pick'} up the new server.`);
113
147
  say(` 2. Try it: ${color.cyan('"build me a landing page for a coffee roastery"')}`);
114
148
  say();
115
149
  hint(`Revoke a key any time at ${origin()}/account?tab=mcp · check the setup with "npx noslopui doctor"`);
@@ -1,26 +1,36 @@
1
- // `noslopui login` — a fresh key for an agent that's already set up.
1
+ // `noslopui login` — sign the CLI itself in, like `21st login`.
2
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';
3
+ // Saves a key for the agent id `cli` to this machine's config folder, which is
4
+ // what `search`, `get`, `design-system`, `collections` and `whoami` use. It
5
+ // doesn't touch any agent's config: `init` does that.
6
+ //
7
+ // `login --agent <id>` is the other job: a fresh key for an agent that's
8
+ // already set up (its key was revoked, or belongs to the wrong account). It
9
+ // swaps the key in place and leaves the skill and every other server alone.
10
+ import { init, signIn } from './init.js';
11
+ import { whoami } from '../lib/api.js';
12
+ import { saveSession } from '../lib/session.js';
13
+ import { hint, ok, say } from '../lib/ui.js';
9
14
  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);
15
+ if (options.agents?.length) {
16
+ return init({ ...options, skill: false });
17
+ }
18
+ say();
19
+ let key;
20
+ if (options.key) {
21
+ key = options.key;
16
22
  }
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
+ else {
24
+ const keys = await signIn(['cli'], options.browser);
25
+ key = keys.find((k) => k.agent === 'cli')?.key ?? '';
26
+ if (!key)
27
+ throw new Error('No key was issued for the CLI.');
23
28
  }
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 });
29
+ const account = await whoami(key);
30
+ const path = saveSession(key);
31
+ ok(`Signed in as ${account.email ?? 'your account'}.`);
32
+ hint(`saved to ${path}`);
33
+ hint('Set up an agent with "npx noslopui init", or search with "npx noslopui search <query>".');
34
+ say();
35
+ return 0;
26
36
  }
@@ -3,14 +3,26 @@
3
3
  // Removing the config entry does NOT revoke the key: the key belongs to the
4
4
  // account, not to this machine, and someone removing a local config hasn't
5
5
  // asked us to change their account. We say where to revoke it instead.
6
- import { ADAPTERS } from '../adapters/index.js';
6
+ //
7
+ // Several agents share a skills folder (~/.agents/skills), so a skill is only
8
+ // removed once no agent that's still set up reads it.
9
+ import { ADAPTERS, adapterById } from '../adapters/index.js';
7
10
  import { origin } from '../lib/api.js';
8
11
  import { color, confirm, hint, ok, say, warn } from '../lib/ui.js';
9
12
  export async function remove(options) {
10
13
  say();
11
14
  let removedAnything = false;
12
- for (const adapter of ADAPTERS) {
13
- if (!(await adapter.detect()))
15
+ const targets = options.agents?.length
16
+ ? options.agents.map((id) => {
17
+ const a = adapterById(id);
18
+ if (!a)
19
+ throw new Error(`Unknown agent "${id}". Supported: ${ADAPTERS.map((x) => x.id).join(', ')}.`);
20
+ return a;
21
+ })
22
+ : ADAPTERS;
23
+ const removed = [];
24
+ for (const adapter of targets) {
25
+ if (!options.agents?.length && !(await adapter.detect()))
14
26
  continue;
15
27
  const status = await adapter.status();
16
28
  if (!status.installed) {
@@ -24,9 +36,32 @@ export async function remove(options) {
24
36
  if (await adapter.uninstall()) {
25
37
  ok(`${adapter.label}: removed the noslopui server (other servers untouched)`);
26
38
  removedAnything = true;
39
+ removed.push(adapter);
27
40
  }
28
- if (options.skill && (await adapter.removeSkill())) {
29
- ok(`${adapter.label}: removed the skill`);
41
+ }
42
+ if (options.skill) {
43
+ const stillUsing = async (path) => {
44
+ for (const other of ADAPTERS) {
45
+ if (removed.includes(other) || other.skillPath() !== path)
46
+ continue;
47
+ if ((await other.status().catch(() => ({ installed: false }))).installed)
48
+ return other;
49
+ }
50
+ return null;
51
+ };
52
+ const done = new Set();
53
+ for (const adapter of removed) {
54
+ const path = adapter.skillPath();
55
+ if (!path || done.has(path))
56
+ continue;
57
+ done.add(path);
58
+ const user = await stillUsing(path);
59
+ if (user) {
60
+ say(`${color.dim('–')} kept the skill at ${path}: ${user.label} still uses it`);
61
+ }
62
+ else if (await adapter.removeSkill()) {
63
+ ok(`removed the skill at ${path}`);
64
+ }
30
65
  }
31
66
  }
32
67
  say();