@stigmer/cli 3.15.3-dev.20260916211208 → 3.16.1-dev.20260917224426

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 (171) hide show
  1. package/commands/agent-exec-flags.d.ts +2 -5
  2. package/commands/agent-exec-flags.d.ts.map +1 -1
  3. package/commands/agent-exec-flags.js +6 -13
  4. package/commands/agent-exec-flags.js.map +1 -1
  5. package/commands/connect.js +1 -1
  6. package/commands/connect.js.map +1 -1
  7. package/commands/install.d.ts +3 -0
  8. package/commands/install.d.ts.map +1 -0
  9. package/commands/install.js +68 -0
  10. package/commands/install.js.map +1 -0
  11. package/commands/marketplace.d.ts +3 -0
  12. package/commands/marketplace.d.ts.map +1 -0
  13. package/commands/marketplace.js +94 -0
  14. package/commands/marketplace.js.map +1 -0
  15. package/commands/push.js +2 -7
  16. package/commands/push.js.map +1 -1
  17. package/commands/run.js +0 -3
  18. package/commands/run.js.map +1 -1
  19. package/config/config.d.ts +19 -0
  20. package/config/config.d.ts.map +1 -1
  21. package/config/config.js +15 -0
  22. package/config/config.js.map +1 -1
  23. package/config/index.d.ts +1 -1
  24. package/config/index.d.ts.map +1 -1
  25. package/config/index.js.map +1 -1
  26. package/local/bootstrap.d.ts +21 -0
  27. package/local/bootstrap.d.ts.map +1 -0
  28. package/local/bootstrap.js +88 -0
  29. package/local/bootstrap.js.map +1 -0
  30. package/local/daemon/launch.d.ts +2 -2
  31. package/local/daemon/launch.d.ts.map +1 -1
  32. package/local/daemon/launch.js +4 -34
  33. package/local/daemon/launch.js.map +1 -1
  34. package/local/daemon/process.d.ts +1 -1
  35. package/local/plugins/defaults.d.ts +35 -0
  36. package/local/plugins/defaults.d.ts.map +1 -0
  37. package/local/plugins/defaults.js +83 -0
  38. package/local/plugins/defaults.js.map +1 -0
  39. package/local/seedpack/content.d.ts +2 -1
  40. package/local/seedpack/content.d.ts.map +1 -1
  41. package/local/seedpack/content.js +5 -34
  42. package/local/seedpack/content.js.map +1 -1
  43. package/marketplace/config.d.ts +59 -0
  44. package/marketplace/config.d.ts.map +1 -0
  45. package/marketplace/config.js +162 -0
  46. package/marketplace/config.js.map +1 -0
  47. package/marketplace/github.d.ts +31 -0
  48. package/marketplace/github.d.ts.map +1 -0
  49. package/marketplace/github.js +203 -0
  50. package/marketplace/github.js.map +1 -0
  51. package/marketplace/index.d.ts +8 -0
  52. package/marketplace/index.d.ts.map +1 -0
  53. package/marketplace/index.js +12 -0
  54. package/marketplace/index.js.map +1 -0
  55. package/marketplace/install.d.ts +28 -0
  56. package/marketplace/install.d.ts.map +1 -0
  57. package/marketplace/install.js +109 -0
  58. package/marketplace/install.js.map +1 -0
  59. package/marketplace/materialize.d.ts +19 -0
  60. package/marketplace/materialize.d.ts.map +1 -0
  61. package/marketplace/materialize.js +53 -0
  62. package/marketplace/materialize.js.map +1 -0
  63. package/marketplace/official.d.ts +28 -0
  64. package/marketplace/official.d.ts.map +1 -0
  65. package/marketplace/official.js +86 -0
  66. package/marketplace/official.js.map +1 -0
  67. package/marketplace/read.d.ts +17 -0
  68. package/marketplace/read.d.ts.map +1 -0
  69. package/marketplace/read.js +44 -0
  70. package/marketplace/read.js.map +1 -0
  71. package/marketplace/ref.d.ts +13 -0
  72. package/marketplace/ref.d.ts.map +1 -0
  73. package/marketplace/ref.js +54 -0
  74. package/marketplace/ref.js.map +1 -0
  75. package/marketplace/render.d.ts +22 -0
  76. package/marketplace/render.d.ts.map +1 -0
  77. package/marketplace/render.js +118 -0
  78. package/marketplace/render.js.map +1 -0
  79. package/output/ndjson.js +1 -1
  80. package/output/ndjson.js.map +1 -1
  81. package/package.json +7 -6
  82. package/program.d.ts.map +1 -1
  83. package/program.js +4 -2
  84. package/program.js.map +1 -1
  85. package/resources/ignore/index.d.ts +2 -4
  86. package/resources/ignore/index.d.ts.map +1 -1
  87. package/resources/ignore/index.js +5 -5
  88. package/resources/ignore/index.js.map +1 -1
  89. package/resources/ignore/matcher.d.ts +2 -35
  90. package/resources/ignore/matcher.d.ts.map +1 -1
  91. package/resources/ignore/matcher.js +25 -109
  92. package/resources/ignore/matcher.js.map +1 -1
  93. package/resources/plugin.d.ts +50 -18
  94. package/resources/plugin.d.ts.map +1 -1
  95. package/resources/plugin.js +73 -74
  96. package/resources/plugin.js.map +1 -1
  97. package/resources/run/agent-exec.js +1 -1
  98. package/resources/run/agent-exec.js.map +1 -1
  99. package/resources/run/prepare.d.ts +8 -19
  100. package/resources/run/prepare.d.ts.map +1 -1
  101. package/resources/run/prepare.js +4 -4
  102. package/resources/run/prepare.js.map +1 -1
  103. package/resources/skill.d.ts +2 -2
  104. package/resources/skill.d.ts.map +1 -1
  105. package/resources/skill.js +6 -10
  106. package/resources/skill.js.map +1 -1
  107. package/src/commands/agent-exec-flags.ts +6 -12
  108. package/src/commands/connect.ts +1 -1
  109. package/src/commands/install.test.ts +289 -0
  110. package/src/commands/install.ts +108 -0
  111. package/src/commands/marketplace.test.ts +200 -0
  112. package/src/commands/marketplace.ts +131 -0
  113. package/src/commands/push.ts +5 -10
  114. package/src/commands/run.ts +0 -3
  115. package/src/config/config.ts +37 -0
  116. package/src/config/index.ts +1 -0
  117. package/src/local/bootstrap.test.ts +71 -0
  118. package/src/local/bootstrap.ts +133 -0
  119. package/src/local/daemon/launch.ts +5 -40
  120. package/src/local/daemon/process.ts +1 -1
  121. package/src/local/plugins/defaults.test.ts +238 -0
  122. package/src/local/plugins/defaults.ts +135 -0
  123. package/src/local/seedpack/content.ts +11 -42
  124. package/src/marketplace/__fixtures__/cursor-marketplace.ts +62 -0
  125. package/src/marketplace/config.test.ts +245 -0
  126. package/src/marketplace/config.ts +236 -0
  127. package/src/marketplace/github.test.ts +318 -0
  128. package/src/marketplace/github.ts +296 -0
  129. package/src/marketplace/index.ts +54 -0
  130. package/src/marketplace/install.test.ts +194 -0
  131. package/src/marketplace/install.ts +159 -0
  132. package/src/marketplace/materialize.ts +80 -0
  133. package/src/marketplace/official.test.ts +107 -0
  134. package/src/marketplace/official.ts +125 -0
  135. package/src/marketplace/read.test.ts +94 -0
  136. package/src/marketplace/read.ts +76 -0
  137. package/src/marketplace/ref.test.ts +179 -0
  138. package/src/marketplace/ref.ts +74 -0
  139. package/src/marketplace/render.ts +154 -0
  140. package/src/output/ndjson.ts +1 -1
  141. package/src/program.test.ts +4 -13
  142. package/src/program.ts +4 -2
  143. package/src/resources/ignore/ignore.test.ts +23 -100
  144. package/src/resources/ignore/index.ts +11 -8
  145. package/src/resources/ignore/matcher.ts +33 -136
  146. package/src/resources/plugin.test.ts +35 -6
  147. package/src/resources/plugin.ts +132 -85
  148. package/src/resources/run/agent-exec.ts +1 -1
  149. package/src/resources/run/prepare.test.ts +4 -18
  150. package/src/resources/run/prepare.ts +12 -24
  151. package/src/resources/skill.ts +6 -10
  152. package/commands/draft.d.ts +0 -3
  153. package/commands/draft.d.ts.map +0 -1
  154. package/commands/draft.js +0 -115
  155. package/commands/draft.js.map +0 -1
  156. package/resources/ignore/defaults.d.ts +0 -2
  157. package/resources/ignore/defaults.d.ts.map +0 -1
  158. package/resources/ignore/defaults.js +0 -140
  159. package/resources/ignore/defaults.js.map +0 -1
  160. package/resources/ignore/match.d.ts +0 -3
  161. package/resources/ignore/match.d.ts.map +0 -1
  162. package/resources/ignore/match.js +0 -166
  163. package/resources/ignore/match.js.map +0 -1
  164. package/resources/ignore/pattern.d.ts +0 -13
  165. package/resources/ignore/pattern.d.ts.map +0 -1
  166. package/resources/ignore/pattern.js +0 -118
  167. package/resources/ignore/pattern.js.map +0 -1
  168. package/src/commands/draft.ts +0 -189
  169. package/src/resources/ignore/defaults.ts +0 -155
  170. package/src/resources/ignore/match.ts +0 -158
  171. package/src/resources/ignore/pattern.ts +0 -131
@@ -0,0 +1,289 @@
1
+ // Command-level contract for `stigmer install`: a ref resolves through the
2
+ // configured marketplaces to one entry, whose folder is pushed exactly as
3
+ // `push plugin` would push it (the archive the backend receives is the one
4
+ // the folder prepares to); --dry-run pushes nothing; a version pin, a path,
5
+ // an unknown name and an ambiguous name refuse with their sentences. A real
6
+ // Connect backend over h2c serves the plugin controllers; the config file
7
+ // (HOME redirected) points a selfhost backend at it.
8
+
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import {
11
+ createServer as createHttp2Server,
12
+ type Http2Server,
13
+ type ServerHttp2Session,
14
+ } from "node:http2";
15
+ import type { AddressInfo } from "node:net";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { create } from "@bufbuild/protobuf";
19
+ import type { ConnectRouter } from "@connectrpc/connect";
20
+ import { connectNodeAdapter } from "@connectrpc/connect-node";
21
+ import { PluginSchema } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/api_pb";
22
+ import { PluginCommandController } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/command_pb";
23
+ import {
24
+ ListPluginMembersResponseSchema,
25
+ type PushPluginRequest,
26
+ } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/io_pb";
27
+ import { PluginQueryController } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/query_pb";
28
+ import { PluginState } from "@stigmer/protos/ai/stigmer/agentic/plugin/v1/status_pb";
29
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
+ import {
31
+ afterAll,
32
+ afterEach,
33
+ beforeAll,
34
+ beforeEach,
35
+ describe,
36
+ expect,
37
+ it,
38
+ vi,
39
+ } from "vitest";
40
+ import { classify, ExitCode } from "../errors/index.js";
41
+ import { writeCursorMarketplace } from "../marketplace/__fixtures__/cursor-marketplace.js";
42
+ import { preparePluginPush } from "../resources/plugin.js";
43
+ import { buildProgram } from "../program.js";
44
+
45
+ let backend: Http2Server;
46
+ let port: number;
47
+ const openSessions = new Set<ServerHttp2Session>();
48
+ let pushes: PushPluginRequest[] = [];
49
+
50
+ let home: string;
51
+ let fixture: string;
52
+ let originalHome: string | undefined;
53
+
54
+ beforeAll(async () => {
55
+ const routes = (router: ConnectRouter) => {
56
+ router.service(PluginCommandController, {
57
+ push: (req) => {
58
+ pushes.push(req);
59
+ return create(PluginSchema, {
60
+ metadata: { id: "plg_1", slug: "thermos", org: "acme" },
61
+ spec: { name: "thermos", version: "1.0.0" },
62
+ status: {
63
+ digest: "a".repeat(64),
64
+ state: PluginState.READY,
65
+ materialized: { skills: 1, agents: 1 },
66
+ },
67
+ });
68
+ },
69
+ });
70
+ router.service(PluginQueryController, {
71
+ listMembers: () =>
72
+ create(ListPluginMembersResponseSchema, {
73
+ members: [
74
+ {
75
+ kind: ApiResourceKind.skill,
76
+ id: "skl_1",
77
+ slug: "warm",
78
+ name: "warm",
79
+ },
80
+ {
81
+ kind: ApiResourceKind.agent,
82
+ id: "agt_1",
83
+ slug: "thermos",
84
+ name: "thermos",
85
+ },
86
+ ],
87
+ }),
88
+ });
89
+ };
90
+ backend = createHttp2Server(connectNodeAdapter({ routes }));
91
+ backend.on("session", (session) => {
92
+ openSessions.add(session);
93
+ session.on("close", () => openSessions.delete(session));
94
+ });
95
+ await new Promise<void>((resolve) => backend.listen(0, "127.0.0.1", resolve));
96
+ port = (backend.address() as AddressInfo).port;
97
+ });
98
+
99
+ afterAll(async () => {
100
+ for (const session of openSessions) session.destroy();
101
+ await new Promise<void>((resolve) => backend.close(() => resolve()));
102
+ });
103
+
104
+ beforeEach(() => {
105
+ pushes = [];
106
+ originalHome = process.env.HOME;
107
+ home = mkdtempSync(join(tmpdir(), "stigmer-install-cmd-"));
108
+ fixture = mkdtempSync(join(tmpdir(), "stigmer-install-cmd-fixture-"));
109
+ writeCursorMarketplace(fixture);
110
+ process.env.HOME = home;
111
+ mkdirSync(join(home, ".stigmer"), { recursive: true });
112
+ writeFileSync(
113
+ join(home, ".stigmer", "config.yaml"),
114
+ [
115
+ "backend:",
116
+ " type: cloud",
117
+ "backends:",
118
+ " test:",
119
+ " type: selfhost",
120
+ ` endpoint: 127.0.0.1:${port}`,
121
+ "current_backend: test",
122
+ "context:",
123
+ " organization: acme",
124
+ "marketplaces:",
125
+ " cursor-plugins:",
126
+ " type: local",
127
+ ` path: ${fixture}`,
128
+ "",
129
+ ].join("\n"),
130
+ );
131
+ });
132
+
133
+ afterEach(() => {
134
+ if (originalHome === undefined) delete process.env.HOME;
135
+ else process.env.HOME = originalHome;
136
+ rmSync(home, { recursive: true, force: true });
137
+ rmSync(fixture, { recursive: true, force: true });
138
+ });
139
+
140
+ interface RunOutcome {
141
+ readonly exitCode: number;
142
+ readonly message: string;
143
+ readonly stdout: string;
144
+ }
145
+
146
+ /** `stigmer [globals] install <args>`: the program's own flags (`--org`) go before the verb. */
147
+ async function run(
148
+ args: string[] | string,
149
+ ...rest: string[]
150
+ ): Promise<RunOutcome> {
151
+ const [globals, local] = Array.isArray(args)
152
+ ? [args, rest]
153
+ : [[], [args, ...rest]];
154
+ const program = buildProgram();
155
+ program.exitOverride();
156
+ let stdout = "";
157
+ const outSpy = vi
158
+ .spyOn(process.stdout, "write")
159
+ .mockImplementation((chunk) => {
160
+ stdout += String(chunk);
161
+ return true;
162
+ });
163
+ const errSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
164
+ try {
165
+ await program.parseAsync([
166
+ "node",
167
+ "stigmer",
168
+ ...globals,
169
+ "install",
170
+ ...local,
171
+ ]);
172
+ return { exitCode: ExitCode.Success, message: "", stdout };
173
+ } catch (err) {
174
+ return {
175
+ exitCode: classify(err)?.exitCode ?? -1,
176
+ message: err instanceof Error ? err.message : String(err),
177
+ stdout,
178
+ };
179
+ } finally {
180
+ outSpy.mockRestore();
181
+ errSpy.mockRestore();
182
+ }
183
+ }
184
+
185
+ describe("install", () => {
186
+ it("pushes the entry's folder as push plugin would, into the context org, and renders the install", async () => {
187
+ const outcome = await run("cursor-plugins/thermos", "--json");
188
+ expect(outcome.exitCode).toBe(ExitCode.Success);
189
+ expect(pushes).toHaveLength(1);
190
+ const expected = await preparePluginPush(join(fixture, "thermos"));
191
+ expect(pushes[0]?.org).toBe("acme");
192
+ expect(
193
+ Buffer.from(pushes[0]?.artifact ?? []).equals(
194
+ Buffer.from(expected.archive),
195
+ ),
196
+ ).toBe(true);
197
+ expect(pushes[0]?.message).toBe(
198
+ "installed from marketplace 'cursor-plugins'",
199
+ );
200
+
201
+ const payload = JSON.parse(outcome.stdout);
202
+ expect(payload.message).toMatch(
203
+ /^Installed plugin 'thermos' \(1 skill, 0 MCP servers, 1 agent\)/,
204
+ );
205
+ const about = payload.sections.find(
206
+ (s: { title: string }) => s.title === "Plugin",
207
+ );
208
+ expect(about.fields).toContainEqual({
209
+ key: "Marketplace",
210
+ value: `cursor-plugins (${fixture})`,
211
+ });
212
+ expect(payload.data.members).toHaveLength(2);
213
+ });
214
+
215
+ it("finds a bare name across the configured marketplaces and honours --org, --visibility and --message", async () => {
216
+ const outcome = await run(
217
+ ["--org", "other"],
218
+ "github",
219
+ "--visibility",
220
+ "org",
221
+ "-m",
222
+ "hello",
223
+ );
224
+ expect(outcome.exitCode).toBe(ExitCode.Success);
225
+ expect(pushes[0]?.org).toBe("other");
226
+ expect(pushes[0]?.message).toBe("hello");
227
+ expect(pushes[0]?.visibility).toBeGreaterThan(0);
228
+ });
229
+
230
+ it("--dry-run reads the marketplace and describes the plugin without pushing", async () => {
231
+ const outcome = await run(
232
+ "cursor-plugins/thermos@1.0.0",
233
+ "--dry-run",
234
+ "--json",
235
+ );
236
+ expect(outcome.exitCode).toBe(ExitCode.Success);
237
+ expect(pushes).toHaveLength(0);
238
+ const payload = JSON.parse(outcome.stdout);
239
+ expect(payload.message).toBe(
240
+ "Dry run: 'cursor-plugins/thermos' would install plugin 'thermos'",
241
+ );
242
+ expect(payload.data.plugin.name).toBe("thermos");
243
+ });
244
+
245
+ it("refuses a version the marketplace does not offer, naming the one it does", async () => {
246
+ const outcome = await run("cursor-plugins/thermos@9.9.9");
247
+ expect(outcome.exitCode).toBe(ExitCode.Usage);
248
+ expect(outcome.message).toMatch(
249
+ /pins version 9\.9\.9, but the marketplace offers 'thermos' at 1\.0\.0/,
250
+ );
251
+ expect(pushes).toHaveLength(0);
252
+ });
253
+
254
+ it("refuses a path toward push plugin, an unknown name, and an unknown marketplace", async () => {
255
+ expect((await run("./thermos")).message).toMatch(
256
+ /is a path.*stigmer push plugin \.\/thermos/s,
257
+ );
258
+ expect((await run("nope")).message).toMatch(
259
+ /no configured marketplace offers a plugin named 'nope'/,
260
+ );
261
+ expect((await run("nowhere/thermos")).message).toMatch(
262
+ /no marketplace named 'nowhere' is configured/,
263
+ );
264
+ expect(pushes).toHaveLength(0);
265
+ });
266
+
267
+ it("refuses a bare name two marketplaces offer, naming each qualified ref", async () => {
268
+ const second = mkdtempSync(join(tmpdir(), "stigmer-install-cmd-second-"));
269
+ try {
270
+ writeCursorMarketplace(second);
271
+ const { load, save } = await import("../config/index.js");
272
+ const config = load();
273
+ save({
274
+ ...config,
275
+ marketplaces: {
276
+ ...config.marketplaces,
277
+ second: { type: "local", path: second },
278
+ },
279
+ });
280
+ const outcome = await run("thermos");
281
+ expect(outcome.exitCode).toBe(ExitCode.Usage);
282
+ expect(outcome.message).toMatch(
283
+ /offered by more than one marketplace: cursor-plugins, second\n\nName the one you mean: cursor-plugins\/thermos or second\/thermos/,
284
+ );
285
+ } finally {
286
+ rmSync(second, { recursive: true, force: true });
287
+ }
288
+ });
289
+ });
@@ -0,0 +1,108 @@
1
+ // `stigmer install [marketplace/]name[@version]` — install a plugin by name
2
+ // from a marketplace. The verb takes a ref, not a kind: a plugin is the one
3
+ // thing a marketplace offers, so there is nothing to disambiguate. A folder
4
+ // on disk is installed with `stigmer push plugin <dir>`; a path given here is
5
+ // refused toward it.
6
+ //
7
+ // Install is: open the marketplace's tree, find the entry, read its directory
8
+ // through the same walker `push plugin` uses, push the archive. The server
9
+ // does the rest (materialising skills, servers, the agent), exactly as for a
10
+ // push, so an install from a marketplace and a push of the same checked-out
11
+ // folder are one digest and one plugin. There is no `uninstall`:
12
+ // `stigmer delete plugin <name>` is the one delete verb across kinds.
13
+ //
14
+ // Heavy modules load lazily inside the action so `--help` stays fast.
15
+
16
+ import type { Command } from "commander";
17
+ import { ensureAuthenticated, resolveOrganization } from "../config/index.js";
18
+ import { type OutputFlags, renderResult } from "../output/index.js";
19
+ import { addResultFlags, globalOrg, resultFormat } from "./shared.js";
20
+
21
+ interface InstallFlags extends OutputFlags {
22
+ visibility?: string;
23
+ message?: string;
24
+ dryRun?: boolean;
25
+ }
26
+
27
+ export function registerInstall(program: Command): void {
28
+ const install = program
29
+ .command("install <ref>")
30
+ .description(
31
+ "install a plugin from a marketplace by name: [marketplace/]name[@version]",
32
+ )
33
+ .option(
34
+ "--visibility <level>",
35
+ "visibility for the plugin and everything it installs (private, org, public, platform)",
36
+ )
37
+ .option(
38
+ "-m, --message <message>",
39
+ "version message describing what changed",
40
+ )
41
+ .option(
42
+ "--dry-run",
43
+ "read the marketplace and show what would install, without pushing",
44
+ )
45
+ .action((ref: string, options: InstallFlags, command: Command) =>
46
+ runInstall(ref, options, command),
47
+ );
48
+ addResultFlags(install);
49
+ }
50
+
51
+ async function runInstall(
52
+ refText: string,
53
+ options: InstallFlags,
54
+ command: Command,
55
+ ): Promise<void> {
56
+ const format = resultFormat(options);
57
+ const m = await import("../marketplace/index.js");
58
+ const plugin = await import("../resources/plugin.js");
59
+ const { parseVisibility } = await import("../resources/skill.js");
60
+ const { CommandResult } = await import("../output/index.js");
61
+
62
+ const ref = m.parseInstallRef(refText);
63
+ const visibility = parseVisibility(options.visibility, "--visibility");
64
+
65
+ const located = await m.locateEntry(ref, m.listMarketplaces());
66
+ try {
67
+ const prepared = await m.prepareEntry(located.tree, ref.name);
68
+ m.assertVersion(prepared, ref.version, refText);
69
+ const qualified = m.formatInstallRef({
70
+ marketplace: located.marketplace.name,
71
+ name: ref.name,
72
+ });
73
+
74
+ if (options.dryRun === true) {
75
+ const result = plugin.describePackageOn(
76
+ CommandResult.success(
77
+ `Dry run: '${qualified}' would install plugin '${prepared.plugin.name}'`,
78
+ ),
79
+ prepared.plugin,
80
+ prepared.warnings,
81
+ prepared.stats,
82
+ );
83
+ result.hint("Run without --dry-run to install it.");
84
+ renderResult(result, format);
85
+ return;
86
+ }
87
+
88
+ const { connectBackend } = await import("../backend.js");
89
+ const client = connectBackend();
90
+ ensureAuthenticated(client.config);
91
+ const org = resolveOrganization(client.config, globalOrg(command));
92
+ const outcome = await plugin.pushPrepared(client.stigmer, prepared, {
93
+ org,
94
+ visibility,
95
+ message:
96
+ options.message ??
97
+ `installed from marketplace '${located.marketplace.name}'`,
98
+ });
99
+ renderResult(
100
+ plugin.renderPushOutcome(outcome, {
101
+ installedFrom: `${located.marketplace.name} (${m.describeSource(located.marketplace.source)})`,
102
+ }),
103
+ format,
104
+ );
105
+ } finally {
106
+ located.open.dispose();
107
+ }
108
+ }
@@ -0,0 +1,200 @@
1
+ // Command-level contract for `stigmer marketplace`: add reads the source once
2
+ // and records it under the file's own name (or --name), list is offline and
3
+ // names an unreadable entry, show lists what a marketplace offers with each
4
+ // entry's version, remove refuses the built-in name. Over the real config
5
+ // file (HOME redirected) and a local marketplace fixture; no network.
6
+
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { load } from "../config/index.js";
12
+ import { classify, ExitCode } from "../errors/index.js";
13
+ import { writeCursorMarketplace } from "../marketplace/__fixtures__/cursor-marketplace.js";
14
+ import { buildProgram } from "../program.js";
15
+
16
+ let home: string;
17
+ let fixture: string;
18
+ let originalHome: string | undefined;
19
+
20
+ beforeEach(() => {
21
+ originalHome = process.env.HOME;
22
+ home = mkdtempSync(join(tmpdir(), "stigmer-marketplace-cmd-"));
23
+ fixture = mkdtempSync(join(tmpdir(), "stigmer-marketplace-cmd-fixture-"));
24
+ writeCursorMarketplace(fixture);
25
+ process.env.HOME = home;
26
+ });
27
+
28
+ afterEach(() => {
29
+ if (originalHome === undefined) delete process.env.HOME;
30
+ else process.env.HOME = originalHome;
31
+ rmSync(home, { recursive: true, force: true });
32
+ rmSync(fixture, { recursive: true, force: true });
33
+ });
34
+
35
+ interface RunOutcome {
36
+ readonly exitCode: number;
37
+ readonly message: string;
38
+ readonly stdout: string;
39
+ readonly stderr: string;
40
+ }
41
+
42
+ async function run(...args: string[]): Promise<RunOutcome> {
43
+ const program = buildProgram();
44
+ program.exitOverride();
45
+ let stdout = "";
46
+ let stderr = "";
47
+ const outSpy = vi
48
+ .spyOn(process.stdout, "write")
49
+ .mockImplementation((chunk) => {
50
+ stdout += String(chunk);
51
+ return true;
52
+ });
53
+ const errSpy = vi
54
+ .spyOn(process.stderr, "write")
55
+ .mockImplementation((chunk) => {
56
+ stderr += String(chunk);
57
+ return true;
58
+ });
59
+ try {
60
+ await program.parseAsync(["node", "stigmer", "marketplace", ...args]);
61
+ return { exitCode: ExitCode.Success, message: "", stdout, stderr };
62
+ } catch (err) {
63
+ return {
64
+ exitCode: classify(err)?.exitCode ?? -1,
65
+ message: err instanceof Error ? err.message : String(err),
66
+ stdout,
67
+ stderr,
68
+ };
69
+ } finally {
70
+ outSpy.mockRestore();
71
+ errSpy.mockRestore();
72
+ }
73
+ }
74
+
75
+ describe("marketplace add", () => {
76
+ it("reads the source, records it under the file's own name, and says what it offers", async () => {
77
+ const outcome = await run("add", fixture, "--json");
78
+ expect(outcome.exitCode).toBe(ExitCode.Success);
79
+ const payload = JSON.parse(outcome.stdout);
80
+ expect(payload.message).toBe(
81
+ `Added marketplace 'cursor-plugins' (${fixture}), offering 2 plugins`,
82
+ );
83
+ expect(payload.data).toEqual({
84
+ name: "cursor-plugins",
85
+ source: { type: "local", path: fixture },
86
+ plugins: 2,
87
+ });
88
+ expect(load().marketplaces?.["cursor-plugins"]).toEqual({
89
+ type: "local",
90
+ path: fixture,
91
+ });
92
+ });
93
+
94
+ it("records it under --name instead, and refuses a name outside the grammar", async () => {
95
+ expect((await run("add", fixture, "--name", "cursor")).exitCode).toBe(
96
+ ExitCode.Success,
97
+ );
98
+ expect(Object.keys(load().marketplaces ?? {})).toEqual(["cursor"]);
99
+ const bad = await run("add", fixture, "--name", "Not Valid");
100
+ expect(bad.exitCode).toBe(ExitCode.Usage);
101
+ expect(bad.message).toMatch(/'Not Valid' is not a marketplace name/);
102
+ });
103
+
104
+ it("refuses a directory that is not a marketplace before recording anything", async () => {
105
+ const outcome = await run("add", home);
106
+ expect(outcome.exitCode).toBe(ExitCode.Usage);
107
+ expect(outcome.message).toMatch(/not a marketplace this CLI can read/);
108
+ expect(load().marketplaces).toBeUndefined();
109
+ });
110
+
111
+ it("refuses the reserved name and a duplicate", async () => {
112
+ expect((await run("add", fixture, "--name", "stigmer")).message).toMatch(
113
+ /built-in marketplace/,
114
+ );
115
+ await run("add", fixture);
116
+ expect((await run("add", fixture)).message).toMatch(/already configured/);
117
+ });
118
+ });
119
+
120
+ describe("marketplace list", () => {
121
+ it("is offline, official first, and names an entry it cannot read", async () => {
122
+ await run("add", fixture);
123
+ const { save } = await import("../config/index.js");
124
+ const config = load();
125
+ save({
126
+ ...config,
127
+ marketplaces: { ...config.marketplaces, broken: { type: "gitlab" } },
128
+ });
129
+
130
+ const outcome = await run("list", "--json");
131
+ expect(outcome.exitCode).toBe(ExitCode.Success);
132
+ const payload = JSON.parse(outcome.stdout);
133
+ expect(payload.status).toBe("warning");
134
+ expect(payload.data.marketplaces).toEqual([
135
+ { name: "stigmer", source: { type: "official" } },
136
+ { name: "cursor-plugins", source: { type: "local", path: fixture } },
137
+ {
138
+ name: "broken",
139
+ unreadable: "unknown type 'gitlab' (expected 'github' or 'local')",
140
+ },
141
+ ]);
142
+ });
143
+ });
144
+
145
+ describe("marketplace show", () => {
146
+ it("lists the offered plugins with their manifest versions and the entries it could not offer", async () => {
147
+ await run("add", fixture);
148
+ const outcome = await run("show", "cursor-plugins", "--json");
149
+ expect(outcome.exitCode).toBe(ExitCode.Success);
150
+ const payload = JSON.parse(outcome.stdout);
151
+ expect(payload.message).toBe(
152
+ "Marketplace 'cursor-plugins' offers 2 plugins, with 1 warning",
153
+ );
154
+ expect(payload.data.plugins).toEqual([
155
+ {
156
+ name: "thermos",
157
+ dir: "thermos",
158
+ version: "1.0.0",
159
+ description: "The thermos plugin.",
160
+ },
161
+ { name: "github", dir: "third_party/github", version: "2.1.0" },
162
+ ]);
163
+ expect(payload.data.warnings).toHaveLength(1);
164
+ expect(payload.data.warnings[0].subject).toBe("ghost");
165
+ });
166
+
167
+ it("shows the built-in marketplace with assistant as its default", async () => {
168
+ const outcome = await run("show", "stigmer", "--json");
169
+ expect(outcome.exitCode).toBe(ExitCode.Success);
170
+ const payload = JSON.parse(outcome.stdout);
171
+ expect(payload.data.marketplace.defaults).toEqual(["assistant"]);
172
+ expect(payload.data.plugins.map((p: { name: string }) => p.name)).toContain(
173
+ "assistant",
174
+ );
175
+ });
176
+
177
+ it("refuses an unknown name toward list", async () => {
178
+ const outcome = await run("show", "nope");
179
+ expect(outcome.exitCode).toBe(ExitCode.Usage);
180
+ expect(outcome.message).toMatch(
181
+ /no marketplace named 'nope'.*stigmer marketplace list/s,
182
+ );
183
+ });
184
+ });
185
+
186
+ describe("marketplace remove", () => {
187
+ it("removes a configured marketplace and refuses the built-in and an unknown one", async () => {
188
+ await run("add", fixture);
189
+ expect((await run("remove", "cursor-plugins")).exitCode).toBe(
190
+ ExitCode.Success,
191
+ );
192
+ expect(load().marketplaces).toBeUndefined();
193
+ expect((await run("remove", "stigmer")).message).toMatch(
194
+ /cannot be removed/,
195
+ );
196
+ expect((await run("remove", "cursor-plugins")).message).toMatch(
197
+ /no marketplace named 'cursor-plugins'/,
198
+ );
199
+ });
200
+ });