@skyelight/mcp 0.4.1 → 0.5.1

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/src/cli.js CHANGED
@@ -11,6 +11,11 @@
11
11
  * It shows the change and waits, like `@skyelight/build init` does. Editing
12
12
  * a config somebody else wrote is the kind of help that has to ask first.
13
13
  * `--yes` exists for people who have already read it once.
14
+ *
15
+ * Several agents used to be a dead end: it printed the list and exited 1, as
16
+ * though having two editors were an error. Most developers have more than
17
+ * one, so the commonest case on the first command anybody runs was a refusal.
18
+ * It asks now, and `--client` still answers in advance for a script.
14
19
  */
15
20
 
16
21
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
@@ -24,18 +29,26 @@ import {
24
29
  findClient,
25
30
  addToJson,
26
31
  addToToml,
32
+ removeFromJson,
33
+ removeFromToml,
27
34
  } from "./install.js";
28
-
29
- const bold = (s) => `\x1b[1m${s}\x1b[0m`;
30
- const dim = (s) => `\x1b[2m${s}\x1b[0m`;
31
- const green = (s) => `\x1b[32m${s}\x1b[0m`;
32
- const amber = (s) => `\x1b[33m${s}\x1b[0m`;
35
+ import {
36
+ bold,
37
+ dim,
38
+ green,
39
+ amber,
40
+ brand,
41
+ header,
42
+ heading,
43
+ pad,
44
+ MARK,
45
+ } from "./theme.js";
33
46
 
34
47
  /**
35
48
  * Which deployment to register. Production unless told otherwise.
36
49
  */
37
50
  const ORIGIN = (
38
- process.env.SKYELIGHT_URL ?? "https://mellow-zebra-383.convex.site"
51
+ process.env.SKYELIGHT_URL ?? "https://app.skyelight.ai"
39
52
  ).replace(/\/+$/, "");
40
53
 
41
54
  /**
@@ -64,85 +77,102 @@ async function fetchConfig() {
64
77
  return body;
65
78
  }
66
79
 
67
- async function confirm(question) {
68
- if (!process.stdin.isTTY) return false;
80
+ /** The host being configured, for the header. Not the whole URL — it is the */
81
+ /** part that tells you whether you are pointed somewhere unexpected. */
82
+ function originLabel() {
83
+ try {
84
+ return new URL(ORIGIN).host;
85
+ } catch {
86
+ return ORIGIN;
87
+ }
88
+ }
89
+
90
+ async function ask(question) {
69
91
  const rl = createInterface({ input: process.stdin, output: process.stdout });
70
92
  try {
71
- return /^y(es)?$/i.test((await rl.question(`${question} [y/N] `)).trim());
93
+ return (await rl.question(` ${question} ${brand(MARK.arrow)} `)).trim();
72
94
  } finally {
73
95
  rl.close();
74
96
  }
75
97
  }
76
98
 
77
- function listClients() {
78
- console.log("Say which one with:\n");
79
- for (const c of CLIENTS) {
80
- console.log(` ${bold(`npx @skyelight/mcp init --client ${c.id}`)} ${dim(c.label)}`);
99
+ async function confirm(question) {
100
+ // No terminal, no question. A CI run should not hang on a prompt nobody is
101
+ // there to answer, and `--yes` is how it says so deliberately.
102
+ if (!process.stdin.isTTY) return false;
103
+ return /^y(es)?$/i.test(await ask(`${question} ${dim("[y/N]")}`));
104
+ }
105
+
106
+ /**
107
+ * Which agent, when several are on the machine.
108
+ *
109
+ * A numbered prompt rather than a list of commands to go and retype. The
110
+ * commands are still printed underneath, because somebody scripting this
111
+ * needs them and because the prompt is unavailable without a terminal.
112
+ */
113
+ async function choose(found) {
114
+ console.log(` Found ${bold(found.length)} agents.\n`);
115
+ found.forEach((c, i) => {
116
+ console.log(` ${brand(String(i + 1))} ${c.label}`);
117
+ });
118
+ console.log(` ${brand("a")} ${dim("All of them")}\n`);
119
+
120
+ if (!process.stdin.isTTY) {
121
+ console.log(dim(" Not a terminal, so nothing was chosen. Name one:\n"));
122
+ listClients();
123
+ return null;
81
124
  }
82
- console.log("");
125
+
126
+ for (let attempt = 0; attempt < 3; attempt += 1) {
127
+ const answer = (
128
+ await ask(`Which one? ${dim(`[1-${found.length}, a]`)}`)
129
+ ).toLowerCase();
130
+ if (answer === "a" || answer === "all") return found;
131
+ const n = Number(answer);
132
+ if (Number.isInteger(n) && n >= 1 && n <= found.length) {
133
+ return [found[n - 1]];
134
+ }
135
+ console.log(dim(` Not one of them.\n`));
136
+ }
137
+ return null;
83
138
  }
84
139
 
85
- async function init({ yes, clientFlag }) {
86
- console.log(`\n${bold("Skyelight MCP")}\n`);
140
+ /** The widest agent id, so the labels line up whatever is in the registry. */
141
+ const ID_WIDTH = Math.max(...CLIENTS.map((c) => c.id.length));
87
142
 
88
- let config;
89
- try {
90
- config = await fetchConfig();
91
- } catch (err) {
92
- console.log(amber(`Could not reach Skyelight: ${err.message}\n`));
143
+ function listClients() {
144
+ for (const c of CLIENTS) {
93
145
  console.log(
94
- dim(" Point at another deployment with SKYELIGHT_URL=https://…\n"),
146
+ ` ${dim("--client")} ${bold(pad(c.id, ID_WIDTH))} ${dim(c.label)}`,
95
147
  );
96
- process.exitCode = 1;
97
- return;
98
148
  }
99
- const { url: MCP_URL, clientId: CLIENT_ID, callbackPort } = config;
149
+ console.log("");
150
+ }
100
151
 
101
- let client;
102
- if (clientFlag) {
103
- client = findClient(clientFlag);
104
- if (!client) {
105
- console.log(amber(`Unknown agent "${clientFlag}".\n`));
106
- listClients();
107
- process.exitCode = 1;
108
- return;
109
- }
110
- } else {
111
- const found = detectClients();
112
- if (found.length === 0) {
113
- console.log(amber("No coding agent found on this machine.\n"));
114
- listClients();
115
- process.exitCode = 1;
116
- return;
117
- }
118
- if (found.length > 1) {
119
- console.log(
120
- `Found ${found.map((c) => bold(c.label)).join(", ")}.\n`,
121
- );
122
- listClients();
123
- process.exitCode = 1;
124
- return;
125
- }
126
- client = found[0];
127
- console.log(`Found ${bold(client.label)}.\n`);
128
- }
152
+ /**
153
+ * Register one agent. Returns whether anything changed, so the summary at
154
+ * the end can tell "added" from "already there" without guessing.
155
+ */
156
+ async function register(client, config, { yes, quiet }) {
157
+ const { url: MCP_URL, clientId: CLIENT_ID, callbackPort } = config;
129
158
 
130
159
  // Claude Code registers through its own CLI — see `install.js`.
131
160
  if (client.command) {
132
161
  const [bin, args] = client.command(MCP_URL, CLIENT_ID, callbackPort);
133
- console.log(dim(` ${bin} ${args.join(" ")}\n`));
162
+ console.log(` ${bold(client.label)}`);
163
+ console.log(dim(` ${bin} ${args.join(" ")}\n`));
134
164
  if (!yes && !(await confirm("Run it?"))) {
135
- console.log(dim("\nNothing changed.\n"));
136
- return;
165
+ console.log(dim("\n Nothing changed.\n"));
166
+ return "skipped";
137
167
  }
138
- const res = spawnSync(bin, args, { stdio: "inherit" });
168
+ const res = spawnSync(bin, args, { stdio: quiet ? "ignore" : "inherit" });
139
169
  if (res.status !== 0) {
140
- console.log(amber("\nThat command failed. Run it yourself to see why.\n"));
141
- process.exitCode = 1;
142
- return;
170
+ console.log(
171
+ amber(`\n That command failed. Run it yourself to see why.\n`),
172
+ );
173
+ return "failed";
143
174
  }
144
- done(client);
145
- return;
175
+ return "added";
146
176
  }
147
177
 
148
178
  const file = client.file();
@@ -153,48 +183,291 @@ async function init({ yes, clientFlag }) {
153
183
  : addToToml(before, client.entry(MCP_URL, CLIENT_ID));
154
184
 
155
185
  if (after === null) {
156
- console.log(green(`Already set up in ${file}\n`));
157
- return;
186
+ console.log(
187
+ ` ${green(MARK.tick)} ${client.label} ${dim("— already set up")}`,
188
+ );
189
+ return "already";
158
190
  }
159
191
 
160
- console.log(`${bold(file)}\n`);
192
+ console.log(` ${bold(client.label)}`);
193
+ console.log(dim(` ${file}`));
161
194
  console.log(
162
195
  dim(
163
196
  client.format === "json"
164
- ? ` mcpServers.${SERVER_NAME} → ${MCP_URL}`
165
- : ` [mcp_servers.${SERVER_NAME}] → ${MCP_URL}`,
197
+ ? ` mcpServers.${SERVER_NAME} → ${MCP_URL}\n`
198
+ : ` [mcp_servers.${SERVER_NAME}] → ${MCP_URL}\n`,
166
199
  ),
167
200
  );
168
- console.log("");
169
201
 
170
202
  if (!yes && !(await confirm("Write it?"))) {
171
- console.log(dim("\nNothing written.\n"));
172
- return;
203
+ console.log(dim("\n Nothing written.\n"));
204
+ return "skipped";
173
205
  }
174
206
 
175
207
  mkdirSync(dirname(file), { recursive: true });
176
208
  writeFileSync(file, after, "utf8");
177
- done(client);
209
+ return "added";
210
+ }
211
+
212
+ /**
213
+ * Take the server back out of one agent.
214
+ *
215
+ * Symmetrical with `register` on purpose, including the confirmation: the
216
+ * file belongs to whoever configured it, and a command that edits it without
217
+ * showing what it is about to do is the thing `init` deliberately is not.
218
+ */
219
+ async function unregister(client, { yes, quiet }) {
220
+ if (client.removeCommand) {
221
+ const [bin, args] = client.removeCommand();
222
+ console.log(` ${bold(client.label)}`);
223
+ console.log(dim(` ${bin} ${args.join(" ")}\n`));
224
+ if (!yes && !(await confirm("Run it?"))) {
225
+ console.log(dim("\n Nothing changed.\n"));
226
+ return "skipped";
227
+ }
228
+ const res = spawnSync(bin, args, { stdio: quiet ? "ignore" : "inherit" });
229
+ // A client that had no such server exits non-zero saying so, which is
230
+ // the answer rather than a failure — `remove` is meant to be safe to
231
+ // run twice.
232
+ return res.status === 0 ? "removed" : "absent";
233
+ }
234
+
235
+ const file = client.file();
236
+ if (!existsSync(file)) {
237
+ console.log(
238
+ ` ${dim(MARK.dot)} ${client.label} ${dim("— nothing to remove")}`,
239
+ );
240
+ return "absent";
241
+ }
242
+ const before = readFileSync(file, "utf8");
243
+ const after =
244
+ client.format === "json" ? removeFromJson(before) : removeFromToml(before);
245
+
246
+ if (after === null) {
247
+ console.log(
248
+ ` ${dim(MARK.dot)} ${client.label} ${dim("— nothing to remove")}`,
249
+ );
250
+ return "absent";
251
+ }
252
+
253
+ console.log(` ${bold(client.label)}`);
254
+ console.log(dim(` ${file}`));
255
+ console.log(
256
+ dim(
257
+ client.format === "json"
258
+ ? ` mcpServers.${SERVER_NAME} ${MARK.arrow} removed\n`
259
+ : ` [mcp_servers.${SERVER_NAME}] ${MARK.arrow} removed\n`,
260
+ ),
261
+ );
262
+
263
+ if (!yes && !(await confirm("Write it?"))) {
264
+ console.log(dim("\n Nothing written.\n"));
265
+ return "skipped";
266
+ }
267
+ writeFileSync(file, after, "utf8");
268
+ return "removed";
269
+ }
270
+
271
+ /**
272
+ * Which agents to act on. Shared by `init` and `remove` so the two cannot
273
+ * disagree about what `--client all` means or how a tie is broken.
274
+ *
275
+ * `known` is for removal: an agent whose config directory has since been
276
+ * deleted still fails detection, and somebody uninstalling wants every place
277
+ * we ever wrote considered rather than only the ones still standing.
278
+ */
279
+ async function pickTargets({ clientFlag, known = false }) {
280
+ if (clientFlag === "all") {
281
+ const all = known ? CLIENTS : detectClients();
282
+ if (all.length === 0) {
283
+ console.log(amber(" No coding agent found on this machine.\n"));
284
+ listClients();
285
+ return null;
286
+ }
287
+ return all;
288
+ }
289
+ if (clientFlag) {
290
+ const one = findClient(clientFlag);
291
+ if (!one) {
292
+ console.log(amber(` Unknown agent "${clientFlag}".\n`));
293
+ listClients();
294
+ return null;
295
+ }
296
+ return [one];
297
+ }
298
+ const found = detectClients();
299
+ if (found.length === 0) {
300
+ console.log(amber(" No coding agent found on this machine.\n"));
301
+ listClients();
302
+ return null;
303
+ }
304
+ if (found.length === 1) {
305
+ console.log(` Found ${bold(found[0].label)}.\n`);
306
+ return found;
307
+ }
308
+ const chosen = await choose(found);
309
+ if (chosen) console.log("");
310
+ return chosen;
311
+ }
312
+
313
+ async function remove({ yes, clientFlag }) {
314
+ console.log(header());
315
+
316
+ // Default to every agent we know, not every agent detected. "Uninstall
317
+ // Skyelight" means everywhere, and asking which one to forget is a
318
+ // question with no useful wrong answer.
319
+ const targets = await pickTargets({
320
+ clientFlag: clientFlag ?? "all",
321
+ known: true,
322
+ });
323
+ if (!targets) {
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+
328
+ const results = [];
329
+ for (const client of targets) {
330
+ results.push({
331
+ client,
332
+ outcome: await unregister(client, { yes, quiet: targets.length > 1 }),
333
+ });
334
+ }
335
+
336
+ const removed = results.filter((r) => r.outcome === "removed");
337
+ const skipped = results.filter((r) => r.outcome === "skipped");
338
+ if (removed.length === 0) {
339
+ // "Not registered anywhere" and "you declined every prompt" are
340
+ // different facts, and saying the first when the second happened tells
341
+ // somebody their agent is clean when it is not.
342
+ console.log(
343
+ skipped.length
344
+ ? `\n ${dim("Nothing was removed.")}\n`
345
+ : `\n ${dim("Skyelight was not registered anywhere.")}\n`,
346
+ );
347
+ return;
348
+ }
349
+ console.log("");
350
+ for (const r of removed) {
351
+ console.log(
352
+ ` ${green(MARK.tick)} ${bold(r.client.label)} ${dim("— removed")}`,
353
+ );
354
+ }
355
+ console.log(
356
+ `\n${heading("Note")}\n ${dim(MARK.dot)} Restart the agent for it to notice.`,
357
+ );
358
+ console.log(
359
+ ` ${dim(MARK.dot)} Your sign-in is still valid; revoke it under Account ${MARK.arrow} API Keys\n or in the agent's own OAuth settings.\n`,
360
+ );
361
+ }
362
+
363
+ async function init({ yes, clientFlag }) {
364
+ console.log(header(originLabel()));
365
+
366
+ let config;
367
+ try {
368
+ config = await fetchConfig();
369
+ } catch (err) {
370
+ console.log(amber(` Could not reach Skyelight: ${err.message}\n`));
371
+ console.log(
372
+ dim(" Point at another deployment with SKYELIGHT_URL=https://…\n"),
373
+ );
374
+ process.exitCode = 1;
375
+ return;
376
+ }
377
+
378
+ const targets = await pickTargets({ clientFlag });
379
+ if (!targets) {
380
+ process.exitCode = 1;
381
+ return;
382
+ }
383
+
384
+ const results = [];
385
+ for (const client of targets) {
386
+ const outcome = await register(client, config, {
387
+ yes,
388
+ quiet: targets.length > 1,
389
+ });
390
+ results.push({ client, outcome });
391
+ }
392
+
393
+ done(results);
394
+ }
395
+
396
+ function done(results) {
397
+ const added = results.filter((r) => r.outcome === "added");
398
+ const already = results.filter((r) => r.outcome === "already");
399
+ const failed = results.filter((r) => r.outcome === "failed");
400
+
401
+ if (added.length === 0 && already.length === 0) {
402
+ if (failed.length) process.exitCode = 1;
403
+ return;
404
+ }
405
+
406
+ console.log("");
407
+ for (const r of [...added, ...already]) {
408
+ console.log(` ${green(MARK.tick)} ${bold(r.client.label)}`);
409
+ }
410
+ for (const r of failed) {
411
+ console.log(
412
+ ` ${amber(MARK.bullet)} ${bold(r.client.label)} ${dim("— not changed")}`,
413
+ );
414
+ }
415
+
416
+ console.log(`\n${heading("Next")}`);
417
+ console.log(
418
+ ` ${dim(MARK.dot)} Restart ${added.length + already.length > 1 ? "them" : (added[0] ?? already[0]).client.label}, then ask for your Skyelight items.`,
419
+ );
420
+ console.log(
421
+ ` ${dim(MARK.dot)} A browser opens to sign you in the first time.\n`,
422
+ );
423
+
424
+ if (failed.length) process.exitCode = 1;
178
425
  }
179
426
 
180
- function done(client) {
181
- console.log(green(`\n✓ Skyelight added to ${client.label}\n`));
182
- console.log("Restart it, then ask it to list your Skyelight items.");
427
+ function help() {
428
+ console.log(header());
183
429
  console.log(
184
- dim("It will open a browser to sign you in the first time.\n"),
430
+ ` ${dim("MCP server — your feedback, in your coding agent.")}\n`,
185
431
  );
432
+ console.log(heading("Usage"));
433
+ const usage = [
434
+ ["npx @skyelight/mcp init", "set up your coding agent"],
435
+ ["npx @skyelight/mcp init --client <id>", "skip the question"],
436
+ ["npx @skyelight/mcp init --client all", "every agent on this machine"],
437
+ ["npx @skyelight/mcp init --yes", "don't ask before writing"],
438
+ ["npx @skyelight/mcp remove", "take it out of every agent"],
439
+ ];
440
+ const w = Math.max(...usage.map(([u]) => u.length));
441
+ for (const [u, what] of usage) {
442
+ console.log(` ${bold(pad(u, w))} ${dim(what)}`);
443
+ }
444
+ console.log("");
445
+ console.log(heading("Agents"));
446
+ listClients();
447
+ console.log(heading("Environment"));
448
+ const env = [
449
+ ["SKYELIGHT_URL", `which deployment to register (${originLabel()})`],
450
+ ["NO_COLOR", "plain output"],
451
+ ];
452
+ const ew = Math.max(...env.map(([n]) => n.length));
453
+ for (const [n, what] of env) {
454
+ console.log(` ${bold(pad(n, ew))} ${dim(what)}`);
455
+ }
456
+ console.log("");
457
+ console.log(dim(" Running with no command starts the stdio server.\n"));
186
458
  }
187
459
 
188
460
  const argv = process.argv.slice(2);
189
461
  const cmd = argv[0];
190
462
 
463
+ const yes = argv.includes("--yes") || argv.includes("-y");
464
+ const at = argv.indexOf("--client");
465
+ const clientFlag = at === -1 ? null : argv[at + 1];
466
+
191
467
  if (cmd === "init") {
192
- const yes = argv.includes("--yes") || argv.includes("-y");
193
- const at = argv.indexOf("--client");
194
- await init({ yes, clientFlag: at === -1 ? null : argv[at + 1] });
468
+ await init({ yes, clientFlag });
469
+ } else if (cmd === "remove" || cmd === "uninstall") {
470
+ await remove({ yes, clientFlag });
195
471
  } else {
196
- console.log(`\n${bold("@skyelight/mcp")}\n`);
197
- console.log(" npx @skyelight/mcp init set up your coding agent");
198
- console.log(" npx @skyelight/mcp init --client cursor\n");
199
- console.log(dim(" Running with no command starts the stdio server.\n"));
472
+ help();
200
473
  }
package/src/client.js CHANGED
@@ -101,5 +101,14 @@ export function createClient({ apiUrl, token, fetchImpl = fetch }) {
101
101
  postUpdate: (body) => post("/api/v1/items/update", body),
102
102
  createItem: (body) => post("/api/v1/items/create", body),
103
103
  setStatus: (body) => post("/api/v1/items/status", body),
104
+ listMembers: (params) => request("/api/v1/members", params),
105
+ assign: (body) => post("/api/v1/items/assign", body),
106
+ projectReview: (params) => request("/api/v1/review", params),
107
+ whatsNew: (params) => request("/api/v1/whats-new", params),
108
+ findBySource: (params) => request("/api/v1/source", params),
109
+ findSimilar: (body) => post("/api/v1/similar", body),
110
+ mergeItems: (body) => post("/api/v1/items/merge", body),
111
+ decisionLog: (params) => request("/api/v1/decisions", params),
112
+ saveRule: (body) => post("/api/v1/rules", body),
104
113
  };
105
114
  }
package/src/config.js CHANGED
@@ -11,11 +11,9 @@
11
11
  *
12
12
  * The project binding is separate and comes from `.skyelight.json` in the
13
13
  * working directory, so a repo can declare which Skyelight project it is
14
- * without every developer configuring it.
15
- *
16
- * A project-bound API KEY (SKY-269) needs none of this — the server already
17
- * knows its project. `.skyelight.json` is for workspace-wide keys used
18
- * against several repos.
14
+ * without every developer configuring it. A personal token reaches every
15
+ * project its owner can, so the binding is what saves passing a projectId on
16
+ * every call.
19
17
  */
20
18
 
21
19
  import { readFileSync, existsSync } from "node:fs";
@@ -105,8 +103,8 @@ export function resolveConfig({
105
103
  return {
106
104
  token,
107
105
  apiUrl: apiUrl.replace(/\/+$/, ""),
108
- // Named projectId to match the API. A bound key overrides this anyway —
109
- // the server ignores a projectId outside the key's binding.
106
+ // Named projectId to match the API. A projectId the token's owner cannot
107
+ // reach is refused by the server, whatever this says.
110
108
  projectId: project.projectId ?? null,
111
109
  projectName: project.projectName ?? null,
112
110
  };
package/src/index.js CHANGED
@@ -15,7 +15,17 @@
15
15
  // The static import below is hoisted, so `config.js` is evaluated either
16
16
  // way — but `resolveConfig()` is only *called* past this point, and calling
17
17
  // it is what throws when there are no credentials. The installer needs none.
18
- if (process.argv[2] === "init" || process.argv[2] === "--help") {
18
+ /**
19
+ * Every word that is a command rather than "be the server".
20
+ *
21
+ * Listed rather than tested for "not empty", because an unknown word has to
22
+ * reach the server path: that is where a client passes flags we have never
23
+ * heard of, and turning those into a help screen would break the client
24
+ * rather than the typo.
25
+ */
26
+ const COMMANDS = new Set(["init", "remove", "uninstall", "--help", "-h"]);
27
+
28
+ if (COMMANDS.has(process.argv[2])) {
19
29
  await import("./cli.js");
20
30
  process.exit(process.exitCode ?? 0);
21
31
  }
@@ -0,0 +1,15 @@
1
+ /** Types for insights.js; see tools.d.ts for why the package ships these. */
2
+
3
+ import type { ToolDefinition } from "./tools.js";
4
+
5
+ export declare function insightToolDefinitions(): ToolDefinition[];
6
+ export declare const INSIGHT_TOOLS: Set<string>;
7
+ export declare function renderInsight(
8
+ name: string,
9
+ data: unknown,
10
+ ): string | null;
11
+ export declare function callInsightTool(
12
+ name: string,
13
+ args: Record<string, any>,
14
+ opts: { client: any; projectId?: string },
15
+ ): Promise<unknown> | undefined;