@stigmer/cli 3.18.1-dev.20260919070736 → 3.19.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.
Files changed (39) hide show
  1. package/commands/apply.js +3 -1
  2. package/commands/apply.js.map +1 -1
  3. package/commands/install.js +2 -1
  4. package/commands/install.js.map +1 -1
  5. package/commands/marketplace.js +3 -3
  6. package/commands/marketplace.js.map +1 -1
  7. package/commands/push.js +3 -1
  8. package/commands/push.js.map +1 -1
  9. package/marketplace/config.d.ts.map +1 -1
  10. package/marketplace/config.js +13 -9
  11. package/marketplace/config.js.map +1 -1
  12. package/marketplace/render.d.ts.map +1 -1
  13. package/marketplace/render.js +1 -7
  14. package/marketplace/render.js.map +1 -1
  15. package/package.json +7 -7
  16. package/resources/connect/oauth.d.ts.map +1 -1
  17. package/resources/connect/oauth.js +4 -1
  18. package/resources/connect/oauth.js.map +1 -1
  19. package/resources/plugin.d.ts +27 -0
  20. package/resources/plugin.d.ts.map +1 -1
  21. package/resources/plugin.js +113 -8
  22. package/resources/plugin.js.map +1 -1
  23. package/src/commands/apply.ts +6 -1
  24. package/src/commands/install.test.ts +20 -21
  25. package/src/commands/install.ts +2 -1
  26. package/src/commands/marketplace.test.ts +10 -19
  27. package/src/commands/marketplace.ts +3 -3
  28. package/src/commands/push.ts +6 -1
  29. package/src/local/plugins/defaults.test.ts +28 -28
  30. package/src/marketplace/__fixtures__/cursor-marketplace.ts +16 -12
  31. package/src/marketplace/config.test.ts +12 -28
  32. package/src/marketplace/config.ts +13 -9
  33. package/src/marketplace/install.test.ts +19 -19
  34. package/src/marketplace/read.test.ts +6 -6
  35. package/src/marketplace/render.ts +1 -10
  36. package/src/resources/connect/oauth.test.ts +2 -2
  37. package/src/resources/connect/oauth.ts +4 -1
  38. package/src/resources/plugin.test.ts +119 -2
  39. package/src/resources/plugin.ts +140 -8
@@ -4,7 +4,11 @@
4
4
  // is missing, so every suite sees an offered list and a dropped entry. It
5
5
  // names itself `acme-plugins`: the vendors' own names are built into every
6
6
  // client, so a fixture under one of them would meet the reserved-name
7
- // refusal instead of being added.
7
+ // refusal instead of being added. Its plugins carry names no real catalogue
8
+ // offers (`warmer`, `codeforge`): a bare-name install searches the official
9
+ // catalogue too, which in a checkout is the live `plugins/` tree, and a
10
+ // fixture named after a real plugin would collide with it as the catalogue
11
+ // grows.
8
12
 
9
13
  import { mkdirSync, writeFileSync } from "node:fs";
10
14
  import { dirname, join } from "node:path";
@@ -24,41 +28,41 @@ export function writeCursorMarketplace(root: string): void {
24
28
  metadata: { description: "A test catalogue" },
25
29
  plugins: [
26
30
  {
27
- name: "thermos",
28
- source: "thermos",
31
+ name: "warmer",
32
+ source: "warmer",
29
33
  description: "Keeps things warm.",
30
34
  },
31
- { name: "github", source: "third_party/github" },
35
+ { name: "codeforge", source: "third_party/codeforge" },
32
36
  { name: "ghost", source: "ghost", description: "Listed, not present." },
33
37
  ],
34
38
  }),
35
39
  );
36
40
  write(
37
- "thermos/.cursor-plugin/plugin.json",
41
+ "warmer/.cursor-plugin/plugin.json",
38
42
  JSON.stringify({
39
- name: "thermos",
43
+ name: "warmer",
40
44
  version: "1.0.0",
41
- description: "The thermos plugin.",
45
+ description: "The warmer plugin.",
42
46
  skills: "./skills/",
43
47
  }),
44
48
  );
45
49
  write(
46
- "thermos/skills/warm/SKILL.md",
50
+ "warmer/skills/warm/SKILL.md",
47
51
  "---\nname: warm\ndescription: Warms.\n---\nKeep it warm.\n",
48
52
  );
49
53
  write(
50
- "third_party/github/.cursor-plugin/plugin.json",
54
+ "third_party/codeforge/.cursor-plugin/plugin.json",
51
55
  JSON.stringify({
52
- name: "github",
56
+ name: "codeforge",
53
57
  version: "2.1.0",
54
58
  mcpServers: "./mcp.json",
55
59
  }),
56
60
  );
57
61
  write(
58
- "third_party/github/mcp.json",
62
+ "third_party/codeforge/mcp.json",
59
63
  JSON.stringify({
60
64
  mcpServers: {
61
- github: { type: "http", url: "https://api.githubcopilot.com/mcp/" },
65
+ codeforge: { type: "http", url: "https://api.githubcopilot.com/mcp/" },
62
66
  },
63
67
  }),
64
68
  );
@@ -48,26 +48,18 @@ function writeConfig(yaml: string): void {
48
48
  writeFileSync(configPath(), yaml);
49
49
  }
50
50
 
51
- const BUILT_IN_NAMES = [
52
- OFFICIAL_MARKETPLACE_NAME,
53
- "cursor-plugins",
54
- "claude-code-plugins",
55
- "codex-plugins",
56
- ];
51
+ const BUILT_IN_NAMES = [OFFICIAL_MARKETPLACE_NAME];
57
52
 
58
53
  describe("listMarketplaces", () => {
59
- it("is the built-in sources alone on a fresh machine, the official one first", () => {
54
+ it("is the built-in source alone on a fresh machine, the official catalogue", () => {
60
55
  const listing = listMarketplaces();
61
56
  expect(listing.known.map((m) => m.name)).toEqual(BUILT_IN_NAMES);
62
57
  expect(listing.known[0]?.source).toEqual({ type: "official" });
63
- expect(listing.known[1]?.source).toEqual({
64
- type: "github",
65
- repo: "cursor/plugins",
66
- });
58
+ expect(listing.known).toHaveLength(1);
67
59
  expect(listing.unreadable).toEqual([]);
68
60
  });
69
61
 
70
- it("lists configured entries after the built-ins, in file order, names the unreadable ones, and lets a built-in win over a same-named entry", () => {
62
+ it("lists configured entries after the built-in, in file order, names the unreadable ones, and lets the built-in win over a same-named entry", () => {
71
63
  writeConfig(
72
64
  [
73
65
  "backend:",
@@ -76,8 +68,8 @@ describe("listMarketplaces", () => {
76
68
  " acme:",
77
69
  " type: github",
78
70
  " repo: acme/plugins",
79
- // Written before the vendors were built in: the built-in wins, the entry stays in the file.
80
- " cursor-plugins:",
71
+ // A hand edit under the built-in's name: the built-in wins, the entry stays in the file.
72
+ " stigmer:",
81
73
  " type: github",
82
74
  " repo: someone/fork",
83
75
  " pinned:",
@@ -102,23 +94,20 @@ describe("listMarketplaces", () => {
102
94
  "pinned",
103
95
  "team",
104
96
  ]);
105
- expect(listing.known[4]?.source).toEqual({
97
+ expect(listing.known[0]?.source).toEqual({ type: "official" });
98
+ expect(listing.known[1]?.source).toEqual({
106
99
  type: "github",
107
100
  repo: "acme/plugins",
108
101
  });
109
- expect(listing.known[5]?.source).toEqual({
102
+ expect(listing.known[2]?.source).toEqual({
110
103
  type: "github",
111
104
  repo: "anthropics/claude-code",
112
105
  ref: "v1.2.3",
113
106
  });
114
- expect(listing.known[6]?.source).toEqual({
107
+ expect(listing.known[3]?.source).toEqual({
115
108
  type: "local",
116
109
  path: "/srv/plugins",
117
110
  });
118
- expect(listing.known[1]?.source).toEqual({
119
- type: "github",
120
- repo: "cursor/plugins",
121
- });
122
111
  expect(listing.unreadable).toEqual([
123
112
  {
124
113
  name: "broken",
@@ -173,13 +162,11 @@ describe("addMarketplace and removeMarketplace", () => {
173
162
  });
174
163
  });
175
164
 
176
- it("refuses every built-in name, a duplicate, an unknown removal, and a built-in removal", () => {
165
+ it("refuses the built-in name, a duplicate, an unknown removal, and the built-in's removal; a vendor's name is free", () => {
177
166
  expect(() =>
178
167
  addMarketplace("stigmer", { type: "local", path: "/x" }),
179
168
  ).toThrow(/'stigmer' is a built-in source and cannot be added or replaced/);
180
- expect(() =>
181
- addMarketplace("cursor-plugins", { type: "local", path: "/x" }),
182
- ).toThrow(/'cursor-plugins' is a built-in source/);
169
+ addMarketplace("cursor-plugins", { type: "github", repo: "cursor/plugins" });
183
170
  addMarketplace("team", { type: "local", path: "/srv/plugins" });
184
171
  expect(() =>
185
172
  addMarketplace("team", { type: "local", path: "/other" }),
@@ -190,9 +177,6 @@ describe("addMarketplace and removeMarketplace", () => {
190
177
  expect(() => removeMarketplace("stigmer")).toThrow(
191
178
  /'stigmer' is a built-in source and cannot be removed/,
192
179
  );
193
- expect(() => removeMarketplace("codex-plugins")).toThrow(
194
- /'codex-plugins' is a built-in source and cannot be removed/,
195
- );
196
180
  });
197
181
 
198
182
  it("refuses under --standalone instead of writing a default file over the user's", () => {
@@ -1,14 +1,17 @@
1
- // The sources a CLI knows: the built-in ones (the official catalogue and the
2
- // three vendors' public ones, the same list the console ships), and the ones
3
- // a user added under the `marketplaces` key of ~/.stigmer/config.yaml.
1
+ // The sources a CLI knows: the built-in one (the official catalogue, the same
2
+ // list the console ships) and the ones a user added under the `marketplaces`
3
+ // key of ~/.stigmer/config.yaml.
4
4
  //
5
5
  // A source is client-side configuration, never a server resource: the
6
6
  // server only ever sees the plugin archive a client pushes. That is why the
7
7
  // list lives beside the named backends and the context, and why `stigmer
8
- // marketplace` is a noun group like `config` and `auth`. The built-ins are
9
- // code, not config: they cannot be added over or removed, so `stigmer
10
- // install cursor-plugins/thermos` works on a fresh machine and the CLI's
11
- // list never drifts from the console's.
8
+ // marketplace` is a noun group like `config` and `auth`. The built-in is
9
+ // code, not config: it cannot be added over or removed, so `stigmer install
10
+ // assistant` works on a fresh machine and the CLI's list never drifts from
11
+ // the console's. A vendor's public repository is a source the user adds by
12
+ // name (`stigmer marketplace add cursor/plugins`), never one on offer by
13
+ // default: the official catalogue already carries what Stigmer can offer of
14
+ // it, curated (`plugins/README.md`).
12
15
  //
13
16
  // The config module keeps the on-disk entry loose (`MarketplaceEntryConfig`,
14
17
  // every field optional) so a hand-edited entry this CLI cannot read survives
@@ -82,8 +85,9 @@ export function listMarketplaces(
82
85
  const known: KnownMarketplace[] = [...BUILT_IN_SOURCES];
83
86
  const unreadable: UnreadableMarketplace[] = [];
84
87
  for (const [name, entry] of Object.entries(config.marketplaces ?? {})) {
85
- // A config written before the vendors were built in may name one; the
86
- // built-in wins and the entry is left in the file untouched.
88
+ // A config may name the built-in (a hand edit, or a file written by a
89
+ // CLI whose built-ins differed); the built-in wins and the entry is left
90
+ // in the file untouched.
87
91
  if (isBuiltInMarketplaceName(name)) continue;
88
92
  const narrowed = narrowEntry(entry);
89
93
  if (narrowed.ok) known.push({ name, source: narrowed.source });
@@ -33,9 +33,9 @@ afterAll(() => {
33
33
  describe("prepareEntry", () => {
34
34
  it("prepares an offered entry exactly as push plugin would prepare its directory", async () => {
35
35
  const tree = readMarketplaceTree(root);
36
- const viaMarketplace = await prepareEntry(tree, "github");
37
- const viaFolder = await preparePluginPush(join(root, "third_party", "github"));
38
- expect(viaMarketplace.plugin.name).toBe("github");
36
+ const viaMarketplace = await prepareEntry(tree, "codeforge");
37
+ const viaFolder = await preparePluginPush(join(root, "third_party", "codeforge"));
38
+ expect(viaMarketplace.plugin.name).toBe("codeforge");
39
39
  expect(viaMarketplace.digest).toBe(viaFolder.digest);
40
40
  expect(
41
41
  Buffer.from(viaMarketplace.archive).equals(
@@ -57,15 +57,15 @@ describe("prepareEntry", () => {
57
57
 
58
58
  describe("assertVersion", () => {
59
59
  it("passes without a pin or with the offered version, refuses another naming the offered one", async () => {
60
- const prepared = await prepareEntry(readMarketplaceTree(root), "thermos");
61
- expect(() => assertVersion(prepared, undefined, "thermos")).not.toThrow();
60
+ const prepared = await prepareEntry(readMarketplaceTree(root), "warmer");
61
+ expect(() => assertVersion(prepared, undefined, "warmer")).not.toThrow();
62
62
  expect(() =>
63
- assertVersion(prepared, "1.0.0", "thermos@1.0.0"),
63
+ assertVersion(prepared, "1.0.0", "warmer@1.0.0"),
64
64
  ).not.toThrow();
65
65
  expect(() =>
66
- assertVersion(prepared, "9.9.9", "acme-plugins/thermos@9.9.9"),
66
+ assertVersion(prepared, "9.9.9", "acme-plugins/warmer@9.9.9"),
67
67
  ).toThrow(
68
- /pins version 9\.9\.9, but the marketplace offers 'thermos' at 1\.0\.0\n\nInstall it as 'acme-plugins\/thermos@1\.0\.0'/,
68
+ /pins version 9\.9\.9, but the marketplace offers 'warmer' at 1\.0\.0\n\nInstall it as 'acme-plugins\/warmer@1\.0\.0'/,
69
69
  );
70
70
  });
71
71
  });
@@ -104,10 +104,10 @@ describe("locateEntry", () => {
104
104
  const remoteFiles = {
105
105
  "marketplace.json": JSON.stringify({
106
106
  name: "remote",
107
- plugins: [{ name: "thermos", source: "./thermos" }],
107
+ plugins: [{ name: "warmer", source: "./warmer" }],
108
108
  }),
109
- "thermos/plugin.json": JSON.stringify({
110
- name: "thermos",
109
+ "warmer/plugin.json": JSON.stringify({
110
+ name: "warmer",
111
111
  version: "3.0.0",
112
112
  }),
113
113
  };
@@ -122,7 +122,7 @@ describe("locateEntry", () => {
122
122
  source: { type: "local", path: root } as const,
123
123
  };
124
124
  const located = await locateEntry(
125
- parseInstallRef("acme-plugins/thermos"),
125
+ parseInstallRef("acme-plugins/warmer"),
126
126
  listing(local),
127
127
  );
128
128
  try {
@@ -132,7 +132,7 @@ describe("locateEntry", () => {
132
132
  located.open.dispose();
133
133
  }
134
134
  await expect(
135
- locateEntry(parseInstallRef("nowhere/thermos"), listing(local)),
135
+ locateEntry(parseInstallRef("nowhere/warmer"), listing(local)),
136
136
  ).rejects.toThrow(/no marketplace named 'nowhere' is configured/);
137
137
  });
138
138
 
@@ -142,7 +142,7 @@ describe("locateEntry", () => {
142
142
  source: { type: "local", path: root } as const,
143
143
  };
144
144
  const located = await locateEntry(
145
- parseInstallRef("github"),
145
+ parseInstallRef("codeforge"),
146
146
  listing(local),
147
147
  );
148
148
  try {
@@ -178,11 +178,11 @@ describe("locateEntry", () => {
178
178
  };
179
179
  const before = countTempTrees();
180
180
  await expect(
181
- locateEntry(parseInstallRef("thermos"), listing(local, remote), {
181
+ locateEntry(parseInstallRef("warmer"), listing(local, remote), {
182
182
  fetchImpl: servingZipOf(remoteFiles),
183
183
  }),
184
184
  ).rejects.toThrow(
185
- /offered by more than one marketplace: acme-plugins, remote\n\nName the one you mean: acme-plugins\/thermos or remote\/thermos/,
185
+ /offered by more than one marketplace: acme-plugins, remote\n\nName the one you mean: acme-plugins\/warmer or remote\/warmer/,
186
186
  );
187
187
  expect(countTempTrees()).toBe(before);
188
188
  });
@@ -197,8 +197,8 @@ describe("locateEntry", () => {
197
197
  source: { type: "github", repo: "a/b" } as const,
198
198
  };
199
199
  const requests: string[] = [];
200
- // `github` is offered by the local fixture alone: the remote is peeked, not downloaded.
201
- const located = await locateEntry(parseInstallRef("github"), listing(local, remote), {
200
+ // `codeforge` is offered by the local fixture alone: the remote is peeked, not downloaded.
201
+ const located = await locateEntry(parseInstallRef("codeforge"), listing(local, remote), {
202
202
  fetchImpl: servingZipOf(remoteFiles, "r-HEAD", requests),
203
203
  });
204
204
  located.open.dispose();
@@ -241,7 +241,7 @@ describe("locateEntry", () => {
241
241
  source: { type: "github", repo: "a/b" } as const,
242
242
  };
243
243
  const located = await locateEntry(
244
- parseInstallRef("remote/thermos"),
244
+ parseInstallRef("remote/warmer"),
245
245
  listing(remote),
246
246
  {
247
247
  fetchImpl: servingZipOf(remoteFiles),
@@ -27,8 +27,8 @@ describe("readMarketplaceTree", () => {
27
27
  expect(tree.marketplace.name).toBe("acme-plugins");
28
28
  expect(tree.marketplace.dialect).toBe("cursor");
29
29
  expect(tree.marketplace.plugins.map((entry) => entry.name)).toEqual([
30
- "thermos",
31
- "github",
30
+ "warmer",
31
+ "codeforge",
32
32
  ]);
33
33
  expect(tree.warnings).toHaveLength(1);
34
34
  expect(tree.warnings[0]?.kind).toBe("entry-directory-missing");
@@ -71,10 +71,10 @@ describe("readMarketplaceTree", () => {
71
71
  describe("findEntry and entryDirectory", () => {
72
72
  it("resolves an offered entry to its absolute directory and an unknown name to undefined", () => {
73
73
  const tree = readMarketplaceTree(root);
74
- const github = findEntry(tree.marketplace, "github");
75
- expect(github?.dir).toBe("third_party/github");
76
- expect(entryDirectory(tree, github!)).toBe(
77
- join(root, "third_party", "github"),
74
+ const codeforge = findEntry(tree.marketplace, "codeforge");
75
+ expect(codeforge?.dir).toBe("third_party/codeforge");
76
+ expect(entryDirectory(tree, codeforge!)).toBe(
77
+ join(root, "third_party", "codeforge"),
78
78
  );
79
79
  expect(findEntry(tree.marketplace, "ghost")).toBeUndefined();
80
80
  });
@@ -5,7 +5,6 @@
5
5
  // same kind of thing: client-side configuration, not a server collection.
6
6
 
7
7
  import { readPluginPackage } from "@stigmer/plugin-package";
8
- import { isBuiltInMarketplaceName } from "@stigmer/plugin-package/client";
9
8
  import { CommandResult } from "../output/index.js";
10
9
  import { count, readPluginDirectory } from "../resources/plugin.js";
11
10
  import {
@@ -27,15 +26,7 @@ export function renderMarketplaceList(
27
26
  );
28
27
  const section = result.addSection("Sources");
29
28
  for (const marketplace of listing.known) {
30
- // A built-in reads "built in" beside where it comes from, so the user
31
- // knows which names `remove` will refuse.
32
- section.field(
33
- marketplace.name,
34
- isBuiltInMarketplaceName(marketplace.name) &&
35
- marketplace.source.type !== "official"
36
- ? `${describeSource(marketplace.source)} (built in)`
37
- : describeSource(marketplace.source),
38
- );
29
+ section.field(marketplace.name, describeSource(marketplace.source));
39
30
  }
40
31
  for (const marketplace of listing.unreadable) {
41
32
  section.field(marketplace.name, `cannot be read: ${marketplace.reason}`);
@@ -112,7 +112,7 @@ describe("runOAuthFlow", () => {
112
112
  log: () => {},
113
113
  });
114
114
  expect(openBrowser).toHaveBeenCalledWith(
115
- "https://app.stigmer.ai/acme/mcp-servers/github",
115
+ "https://app.stigmer.ai/library/mcp-servers/acme/github",
116
116
  );
117
117
  });
118
118
 
@@ -152,7 +152,7 @@ describe("runOAuthFlow", () => {
152
152
  log: () => {},
153
153
  });
154
154
  expect(openBrowser).toHaveBeenCalledWith(
155
- "http://localhost:7234/acme/mcp-servers/github",
155
+ "http://localhost:7234/library/mcp-servers/acme/github",
156
156
  );
157
157
  });
158
158
  });
@@ -60,7 +60,10 @@ export async function runOAuthFlow(deps: OAuthFlowDeps): Promise<void> {
60
60
 
61
61
  const slug = deps.server.metadata?.slug ?? "";
62
62
  const name = deps.server.metadata?.name ?? slug;
63
- const pageURL = `${consoleURL}/${deps.org}/mcp-servers/${slug}`;
63
+ // The route both consoles serve (`/library/mcp-servers/<org>/<slug>`);
64
+ // the Go-era `/<org>/mcp-servers/<slug>` answered 404 in the TypeScript
65
+ // console and sent the user to a dead page.
66
+ const pageURL = `${consoleURL}/library/mcp-servers/${deps.org}/${slug}`;
64
67
 
65
68
  log(`OAuth authentication required for '${name}'.`);
66
69
  log("Opening web console to complete authentication...");
@@ -2,8 +2,13 @@
2
2
  // sorted sized entries, the ignore matcher applied (defaults, .gitignore,
3
3
  // .stigmerignore), symlinks never followed, and the JSON projection that
4
4
  // reduces overlay documents to their paths; then the prepared push (the
5
- // deterministic archive and its SHA-256, the server's own digest) and the
6
- // install summary renderer.
5
+ // deterministic archive and its SHA-256, the server's own digest), the
6
+ // install summary renderer (only the kinds installed are counted; the Next
7
+ // section says what each server still needs), and `readNextSteps` against an
8
+ // in-memory backend: a sign-in for an OAuth server without a grant, "signed
9
+ // in" with one, the variables an API-key server wants and where they are
10
+ // asked, the agent an MCP-only plugin still needs, and no line for a server
11
+ // the CLI cannot read.
7
12
 
8
13
  import {
9
14
  mkdirSync,
@@ -260,3 +265,115 @@ describe("renderPushOutcome", () => {
260
265
  expect(text).toContain("runs on the session's model");
261
266
  });
262
267
  });
268
+
269
+ describe("readNextSteps", () => {
270
+ it("reads what each server still needs, best-effort, and names the agent an MCP-only plugin lacks", async () => {
271
+ const { readNextSteps, renderPushOutcome } = await import("./plugin.js");
272
+ const { create } = await import("@bufbuild/protobuf");
273
+ const { Code, ConnectError, createRouterTransport } =
274
+ await import("@connectrpc/connect");
275
+ const { Stigmer } = await import("@stigmer/sdk");
276
+ const { McpServerQueryController } =
277
+ await import("@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/query_pb");
278
+ const { McpServerSchema } =
279
+ await import("@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb");
280
+ const { GetOAuthGrantStatusOutputSchema, OAuthConnectionHealth } =
281
+ await import("@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb");
282
+ const { McpServerAuthSchema, McpServerSpecSchema } =
283
+ await import("@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/spec_pb");
284
+ const { EnvVarDeclarationSchema } =
285
+ await import("@stigmer/protos/ai/stigmer/agentic/environment/v1/spec_pb");
286
+ const { PluginSchema } =
287
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb");
288
+ const { PluginMemberSchema } =
289
+ await import("@stigmer/protos/ai/stigmer/agentic/plugin/v1/io_pb");
290
+ const { ApiResourceKind } =
291
+ await import("@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb");
292
+ const { renderResult } = await import("../output/command-result.js");
293
+
294
+ const shapes: Record<string, "oauth" | "oauth-granted" | "api-key" | "open" | "missing"> = {
295
+ linear: "oauth",
296
+ notion: "oauth-granted",
297
+ warmth: "api-key",
298
+ weather: "open",
299
+ ghost: "missing",
300
+ };
301
+ const transport = createRouterTransport(({ service }) => {
302
+ service(McpServerQueryController, {
303
+ getByReference: (ref) => {
304
+ const shape = shapes[ref.slug];
305
+ if (shape === undefined || shape === "missing")
306
+ throw new ConnectError("no server", Code.NotFound);
307
+ const spec = create(McpServerSpecSchema);
308
+ if (shape === "oauth" || shape === "oauth-granted") {
309
+ spec.auth = create(McpServerAuthSchema, { targetEnvVar: "TOKEN", oauthOnly: true });
310
+ spec.env = { TOKEN: create(EnvVarDeclarationSchema, { isSecret: true }) };
311
+ }
312
+ if (shape === "api-key") spec.env = { API_TOKEN: create(EnvVarDeclarationSchema, { isSecret: true }) };
313
+ return create(McpServerSchema, { metadata: { id: `mcp_${ref.slug}`, org: "acme", slug: ref.slug }, spec });
314
+ },
315
+ getOAuthGrantStatus: (input) => {
316
+ const granted = input.resourceId === "mcp_notion";
317
+ return create(GetOAuthGrantStatusOutputSchema, {
318
+ connected: granted,
319
+ connectionHealth: granted
320
+ ? OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_HEALTHY
321
+ : OAuthConnectionHealth.OAUTH_CONNECTION_HEALTH_NO_GRANT,
322
+ });
323
+ },
324
+ });
325
+ });
326
+ const client = new Stigmer({ baseUrl: "/", getAccessToken: () => "t", customTransport: transport });
327
+ const member = (slug: string) =>
328
+ create(PluginMemberSchema, { kind: ApiResourceKind.mcp_server, id: `mcp_${slug}`, slug, name: slug });
329
+
330
+ const next = await readNextSteps(client, "acme", Object.keys(shapes).map(member));
331
+ expect(next).toEqual([
332
+ { kind: "sign-in", server: "linear", command: "stigmer connect mcp-server linear" },
333
+ { kind: "signed-in", server: "notion" },
334
+ {
335
+ kind: "api-key",
336
+ server: "warmth",
337
+ variables: ["API_TOKEN"],
338
+ askedAt: "connect",
339
+ command: "stigmer connect mcp-server warmth --env API_TOKEN=...",
340
+ },
341
+ { kind: "add-to-agent", servers: ["linear", "notion", "warmth", "weather", "ghost"] },
342
+ ]);
343
+
344
+ // With an agent, the variables are the agent's to ask and no agent is missing.
345
+ const withAgent = await readNextSteps(client, "acme", [
346
+ member("warmth"),
347
+ create(PluginMemberSchema, { kind: ApiResourceKind.agent, id: "agt_1", slug: "thermos", name: "thermos" }),
348
+ ]);
349
+ expect(withAgent).toEqual([
350
+ { kind: "api-key", server: "warmth", variables: ["API_TOKEN"], askedAt: "agent" },
351
+ ]);
352
+
353
+ // The renderer names only the kinds installed and prints the Next section.
354
+ const plugin = create(PluginSchema, {
355
+ metadata: { id: "plg_1", slug: "linear" },
356
+ status: { materialized: { skills: 0, mcpServers: 1, agents: 0, workflows: 0 } },
357
+ });
358
+ const result = renderPushOutcome(
359
+ { plugin, members: [member("linear")], archiveBytes: 10 },
360
+ { next: next.slice(0, 1) },
361
+ );
362
+ const lines: string[] = [];
363
+ const original = process.stderr.write.bind(process.stderr);
364
+ process.stderr.write = ((chunk: string | Uint8Array) => {
365
+ lines.push(String(chunk));
366
+ return true;
367
+ }) as typeof process.stderr.write;
368
+ try {
369
+ renderResult(result, "human");
370
+ } finally {
371
+ process.stderr.write = original;
372
+ }
373
+ const text = lines.join("");
374
+ expect(text).toContain("Installed plugin 'linear' (1 MCP server)");
375
+ expect(text).not.toContain("0 skills");
376
+ expect(text).toContain("Next");
377
+ expect(text).toContain("Sign in to linear: stigmer connect mcp-server linear");
378
+ });
379
+ });