@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.
- package/commands/apply.js +3 -1
- package/commands/apply.js.map +1 -1
- package/commands/install.js +2 -1
- package/commands/install.js.map +1 -1
- package/commands/marketplace.js +3 -3
- package/commands/marketplace.js.map +1 -1
- package/commands/push.js +3 -1
- package/commands/push.js.map +1 -1
- package/marketplace/config.d.ts.map +1 -1
- package/marketplace/config.js +13 -9
- package/marketplace/config.js.map +1 -1
- package/marketplace/render.d.ts.map +1 -1
- package/marketplace/render.js +1 -7
- package/marketplace/render.js.map +1 -1
- package/package.json +7 -7
- package/resources/connect/oauth.d.ts.map +1 -1
- package/resources/connect/oauth.js +4 -1
- package/resources/connect/oauth.js.map +1 -1
- package/resources/plugin.d.ts +27 -0
- package/resources/plugin.d.ts.map +1 -1
- package/resources/plugin.js +113 -8
- package/resources/plugin.js.map +1 -1
- package/src/commands/apply.ts +6 -1
- package/src/commands/install.test.ts +20 -21
- package/src/commands/install.ts +2 -1
- package/src/commands/marketplace.test.ts +10 -19
- package/src/commands/marketplace.ts +3 -3
- package/src/commands/push.ts +6 -1
- package/src/local/plugins/defaults.test.ts +28 -28
- package/src/marketplace/__fixtures__/cursor-marketplace.ts +16 -12
- package/src/marketplace/config.test.ts +12 -28
- package/src/marketplace/config.ts +13 -9
- package/src/marketplace/install.test.ts +19 -19
- package/src/marketplace/read.test.ts +6 -6
- package/src/marketplace/render.ts +1 -10
- package/src/resources/connect/oauth.test.ts +2 -2
- package/src/resources/connect/oauth.ts +4 -1
- package/src/resources/plugin.test.ts +119 -2
- 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: "
|
|
28
|
-
source: "
|
|
31
|
+
name: "warmer",
|
|
32
|
+
source: "warmer",
|
|
29
33
|
description: "Keeps things warm.",
|
|
30
34
|
},
|
|
31
|
-
{ name: "
|
|
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
|
-
"
|
|
41
|
+
"warmer/.cursor-plugin/plugin.json",
|
|
38
42
|
JSON.stringify({
|
|
39
|
-
name: "
|
|
43
|
+
name: "warmer",
|
|
40
44
|
version: "1.0.0",
|
|
41
|
-
description: "The
|
|
45
|
+
description: "The warmer plugin.",
|
|
42
46
|
skills: "./skills/",
|
|
43
47
|
}),
|
|
44
48
|
);
|
|
45
49
|
write(
|
|
46
|
-
"
|
|
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/
|
|
54
|
+
"third_party/codeforge/.cursor-plugin/plugin.json",
|
|
51
55
|
JSON.stringify({
|
|
52
|
-
name: "
|
|
56
|
+
name: "codeforge",
|
|
53
57
|
version: "2.1.0",
|
|
54
58
|
mcpServers: "./mcp.json",
|
|
55
59
|
}),
|
|
56
60
|
);
|
|
57
61
|
write(
|
|
58
|
-
"third_party/
|
|
62
|
+
"third_party/codeforge/mcp.json",
|
|
59
63
|
JSON.stringify({
|
|
60
64
|
mcpServers: {
|
|
61
|
-
|
|
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
|
|
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
|
|
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-
|
|
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
|
-
//
|
|
80
|
-
"
|
|
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[
|
|
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[
|
|
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[
|
|
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
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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-
|
|
9
|
-
// code, not config:
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
86
|
-
// built-in wins and the entry is left
|
|
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, "
|
|
37
|
-
const viaFolder = await preparePluginPush(join(root, "third_party", "
|
|
38
|
-
expect(viaMarketplace.plugin.name).toBe("
|
|
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), "
|
|
61
|
-
expect(() => assertVersion(prepared, undefined, "
|
|
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", "
|
|
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/
|
|
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 '
|
|
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: "
|
|
107
|
+
plugins: [{ name: "warmer", source: "./warmer" }],
|
|
108
108
|
}),
|
|
109
|
-
"
|
|
110
|
-
name: "
|
|
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/
|
|
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/
|
|
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("
|
|
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("
|
|
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\/
|
|
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
|
-
// `
|
|
201
|
-
const located = await locateEntry(parseInstallRef("
|
|
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/
|
|
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
|
-
"
|
|
31
|
-
"
|
|
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
|
|
75
|
-
expect(
|
|
76
|
-
expect(entryDirectory(tree,
|
|
77
|
-
join(root, "third_party", "
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
-
|
|
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)
|
|
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
|
+
});
|