@stigmer/cli 3.16.0 → 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,245 @@
1
+ // Pins the marketplace list over the real config file (HOME redirected):
2
+ // the official one is always first and never stored; add and remove round-
3
+ // trip through the file's own dialect (`type`); a hand-edited entry the CLI
4
+ // cannot read is listed with its reason, kept on save, and named when a
5
+ // command asks for it; the reserved name and --standalone refuse.
6
+
7
+ import {
8
+ mkdirSync,
9
+ mkdtempSync,
10
+ readFileSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
17
+ import { configPath, load } from "../config/index.js";
18
+ import { setStandalone } from "../runtime.js";
19
+ import {
20
+ OFFICIAL_MARKETPLACE_NAME,
21
+ addMarketplace,
22
+ describeSource,
23
+ findMarketplace,
24
+ isOwnerRepo,
25
+ listMarketplaces,
26
+ narrowEntry,
27
+ removeMarketplace,
28
+ } from "./config.js";
29
+
30
+ let home: string;
31
+ let originalHome: string | undefined;
32
+
33
+ beforeEach(() => {
34
+ originalHome = process.env.HOME;
35
+ home = mkdtempSync(join(tmpdir(), "stigmer-marketplace-config-"));
36
+ process.env.HOME = home;
37
+ });
38
+
39
+ afterEach(() => {
40
+ setStandalone(false);
41
+ if (originalHome === undefined) delete process.env.HOME;
42
+ else process.env.HOME = originalHome;
43
+ rmSync(home, { recursive: true, force: true });
44
+ });
45
+
46
+ function writeConfig(yaml: string): void {
47
+ mkdirSync(join(home, ".stigmer"), { recursive: true });
48
+ writeFileSync(configPath(), yaml);
49
+ }
50
+
51
+ describe("listMarketplaces", () => {
52
+ it("is the official marketplace alone on a fresh machine", () => {
53
+ const listing = listMarketplaces();
54
+ expect(listing.known.map((m) => m.name)).toEqual([
55
+ OFFICIAL_MARKETPLACE_NAME,
56
+ ]);
57
+ expect(listing.known[0]?.source).toEqual({ type: "official" });
58
+ expect(listing.unreadable).toEqual([]);
59
+ });
60
+
61
+ it("lists configured entries after the official one, in file order, and names the unreadable ones", () => {
62
+ writeConfig(
63
+ [
64
+ "backend:",
65
+ " type: local",
66
+ "marketplaces:",
67
+ " cursor-plugins:",
68
+ " type: github",
69
+ " repo: cursor/plugins",
70
+ " pinned:",
71
+ " type: github",
72
+ " repo: anthropics/claude-code",
73
+ " ref: v1.2.3",
74
+ " team:",
75
+ " type: local",
76
+ " path: /srv/plugins",
77
+ " broken:",
78
+ " type: gitlab",
79
+ " repo: a/b",
80
+ " typeless:",
81
+ " repo: a/b",
82
+ "",
83
+ ].join("\n"),
84
+ );
85
+ const listing = listMarketplaces();
86
+ expect(listing.known.map((m) => m.name)).toEqual([
87
+ "stigmer",
88
+ "cursor-plugins",
89
+ "pinned",
90
+ "team",
91
+ ]);
92
+ expect(listing.known[1]?.source).toEqual({
93
+ type: "github",
94
+ repo: "cursor/plugins",
95
+ });
96
+ expect(listing.known[2]?.source).toEqual({
97
+ type: "github",
98
+ repo: "anthropics/claude-code",
99
+ ref: "v1.2.3",
100
+ });
101
+ expect(listing.known[3]?.source).toEqual({
102
+ type: "local",
103
+ path: "/srv/plugins",
104
+ });
105
+ expect(listing.unreadable).toEqual([
106
+ {
107
+ name: "broken",
108
+ reason: "unknown type 'gitlab' (expected 'github' or 'local')",
109
+ },
110
+ { name: "typeless", reason: "no 'type' (expected 'github' or 'local')" },
111
+ ]);
112
+ });
113
+ });
114
+
115
+ describe("addMarketplace and removeMarketplace", () => {
116
+ it("round-trips a github source and a local source through the file", () => {
117
+ addMarketplace("cursor-plugins", {
118
+ type: "github",
119
+ repo: "cursor/plugins",
120
+ });
121
+ addMarketplace("team", { type: "local", path: "/srv/plugins" });
122
+ const text = readFileSync(configPath(), "utf8");
123
+ expect(text).toContain("marketplaces:");
124
+ expect(text).toContain("type: github");
125
+ expect(text).toContain("repo: cursor/plugins");
126
+ expect(text).not.toContain("ref:");
127
+ expect(text).toContain("path: /srv/plugins");
128
+ expect(listMarketplaces().known.map((m) => m.name)).toEqual([
129
+ "stigmer",
130
+ "cursor-plugins",
131
+ "team",
132
+ ]);
133
+
134
+ removeMarketplace("cursor-plugins");
135
+ expect(listMarketplaces().known.map((m) => m.name)).toEqual([
136
+ "stigmer",
137
+ "team",
138
+ ]);
139
+ removeMarketplace("team");
140
+ expect(load().marketplaces).toBeUndefined();
141
+ expect(readFileSync(configPath(), "utf8")).not.toContain("marketplaces");
142
+ });
143
+
144
+ it("keeps the rest of the config and an unreadable entry across a save", () => {
145
+ writeConfig(
146
+ "backend:\n type: cloud\nbackends:\n cloud:\n type: cloud\n token: t\ncurrent_backend: cloud\nmarketplaces:\n broken:\n type: gitlab\n",
147
+ );
148
+ addMarketplace("team", { type: "local", path: "/srv/plugins" });
149
+ const after = load();
150
+ expect(after.current_backend).toBe("cloud");
151
+ expect(after.backends?.["cloud"]?.token).toBe("t");
152
+ expect(after.marketplaces?.["broken"]).toEqual({ type: "gitlab" });
153
+ expect(after.marketplaces?.["team"]).toEqual({
154
+ type: "local",
155
+ path: "/srv/plugins",
156
+ });
157
+ });
158
+
159
+ it("refuses the reserved name, a duplicate, an unknown removal, and the reserved removal", () => {
160
+ expect(() =>
161
+ addMarketplace("stigmer", { type: "local", path: "/x" }),
162
+ ).toThrow(/built-in marketplace/);
163
+ addMarketplace("team", { type: "local", path: "/srv/plugins" });
164
+ expect(() =>
165
+ addMarketplace("team", { type: "local", path: "/other" }),
166
+ ).toThrow(/already configured/);
167
+ expect(() => removeMarketplace("nope")).toThrow(
168
+ /no marketplace named 'nope'/,
169
+ );
170
+ expect(() => removeMarketplace("stigmer")).toThrow(/cannot be removed/);
171
+ });
172
+
173
+ it("refuses under --standalone instead of writing a default file over the user's", () => {
174
+ writeConfig(
175
+ "backend:\n type: cloud\nbackends:\n cloud:\n type: cloud\n token: t\ncurrent_backend: cloud\n",
176
+ );
177
+ setStandalone(true);
178
+ expect(() => addMarketplace("team", { type: "local", path: "/x" })).toThrow(
179
+ /--standalone/,
180
+ );
181
+ expect(() => removeMarketplace("team")).toThrow(/--standalone/);
182
+ setStandalone(false);
183
+ expect(load().backends?.["cloud"]?.token).toBe("t");
184
+ });
185
+ });
186
+
187
+ describe("findMarketplace", () => {
188
+ it("finds the official and configured ones, returns undefined for an unknown name, and names an unreadable entry", () => {
189
+ writeConfig(
190
+ "backend:\n type: local\nmarketplaces:\n team:\n type: local\n path: /srv\n broken:\n type: gitlab\n",
191
+ );
192
+ expect(findMarketplace("stigmer")?.source).toEqual({ type: "official" });
193
+ expect(findMarketplace("team")?.source).toEqual({
194
+ type: "local",
195
+ path: "/srv",
196
+ });
197
+ expect(findMarketplace("nope")).toBeUndefined();
198
+ expect(() => findMarketplace("broken")).toThrow(
199
+ /configured but cannot be read: unknown type 'gitlab'/,
200
+ );
201
+ });
202
+ });
203
+
204
+ describe("narrowEntry, describeSource, isOwnerRepo", () => {
205
+ it("refuses a github entry without a repo and a local entry without a path", () => {
206
+ expect(narrowEntry({ type: "github" })).toEqual({
207
+ ok: false,
208
+ reason: "type 'github' needs 'repo: owner/repo'",
209
+ });
210
+ expect(narrowEntry({ type: "github", repo: "not-a-repo" })).toEqual({
211
+ ok: false,
212
+ reason: "type 'github' needs 'repo: owner/repo'",
213
+ });
214
+ expect(narrowEntry({ type: "github", repo: "a/b", ref: "" })).toEqual({
215
+ ok: false,
216
+ reason: "'ref' must be a non-empty branch, tag or commit",
217
+ });
218
+ expect(narrowEntry({ type: "local" })).toEqual({
219
+ ok: false,
220
+ reason: "type 'local' needs 'path: <directory>'",
221
+ });
222
+ });
223
+
224
+ it("describes each source in one phrase", () => {
225
+ expect(describeSource({ type: "official" })).toBe("built in");
226
+ expect(describeSource({ type: "github", repo: "cursor/plugins" })).toBe(
227
+ "github.com/cursor/plugins",
228
+ );
229
+ expect(
230
+ describeSource({ type: "github", repo: "cursor/plugins", ref: "main" }),
231
+ ).toBe("github.com/cursor/plugins@main");
232
+ expect(describeSource({ type: "local", path: "/srv/plugins" })).toBe(
233
+ "/srv/plugins",
234
+ );
235
+ });
236
+
237
+ it("accepts owner/repo as GitHub spells it and nothing else", () => {
238
+ expect(isOwnerRepo("cursor/plugins")).toBe(true);
239
+ expect(isOwnerRepo("my-org/my.repo_1")).toBe(true);
240
+ expect(isOwnerRepo("cursor")).toBe(false);
241
+ expect(isOwnerRepo("a/b/c")).toBe(false);
242
+ expect(isOwnerRepo("../x")).toBe(false);
243
+ expect(isOwnerRepo("a/")).toBe(false);
244
+ });
245
+ });
@@ -0,0 +1,236 @@
1
+ // The marketplaces a CLI knows: the official one, built in, and the ones a
2
+ // user added under the `marketplaces` key of ~/.stigmer/config.yaml.
3
+ //
4
+ // A marketplace is client-side configuration, never a server resource: the
5
+ // server only ever sees the plugin archive a client pushes. That is why the
6
+ // list lives beside the named backends and the context, and why `stigmer
7
+ // marketplace` is a noun group like `config` and `auth`.
8
+ //
9
+ // The config module keeps the on-disk entry loose (`MarketplaceEntryConfig`,
10
+ // every field optional) so a hand-edited entry this CLI cannot read survives
11
+ // the next save; this module is where the entry is narrowed into a
12
+ // `MarketplaceSource` the fetchers act on, and where an unreadable entry
13
+ // becomes a reason `stigmer marketplace list` prints instead of a silent gap.
14
+
15
+ import {
16
+ type Config,
17
+ type MarketplaceEntryConfig,
18
+ load as loadConfig,
19
+ save as saveConfig,
20
+ } from "../config/index.js";
21
+ import {
22
+ type GitHubMarketplaceSource,
23
+ OFFICIAL_MARKETPLACE_NAME,
24
+ describeGitHubSource,
25
+ isOwnerRepo,
26
+ } from "@stigmer/plugin-package/client";
27
+ import { UsageError } from "../errors/index.js";
28
+ import { isStandalone } from "../runtime.js";
29
+
30
+ export { OFFICIAL_MARKETPLACE_NAME, isOwnerRepo };
31
+
32
+ /** Where a marketplace tree comes from. Every source yields a directory. */
33
+ export type MarketplaceSource =
34
+ /** The catalogue this CLI ships with: the repo tree in dev, `@stigmer/plugins` otherwise. */
35
+ | { readonly type: "official" }
36
+ /** A public GitHub repository, fetched as a zipball of `ref` (the default branch when absent); the console reads the same record. */
37
+ | GitHubMarketplaceSource
38
+ /** A directory on this machine, stored absolute. */
39
+ | { readonly type: "local"; readonly path: string };
40
+
41
+ /** A marketplace the CLI can act on. */
42
+ export interface KnownMarketplace {
43
+ readonly name: string;
44
+ readonly source: MarketplaceSource;
45
+ }
46
+
47
+ /** A configured entry this CLI cannot read; listed with its reason, never dropped. */
48
+ export interface UnreadableMarketplace {
49
+ readonly name: string;
50
+ readonly reason: string;
51
+ }
52
+
53
+ export interface MarketplaceListing {
54
+ /** The official marketplace first, then the config's entries in file order. */
55
+ readonly known: readonly KnownMarketplace[];
56
+ readonly unreadable: readonly UnreadableMarketplace[];
57
+ }
58
+
59
+ export const OFFICIAL_MARKETPLACE: KnownMarketplace = {
60
+ name: OFFICIAL_MARKETPLACE_NAME,
61
+ source: { type: "official" },
62
+ };
63
+
64
+ /** Every marketplace the config names, the official one first. */
65
+ export function listMarketplaces(
66
+ config: Config = loadConfig(),
67
+ ): MarketplaceListing {
68
+ const known: KnownMarketplace[] = [OFFICIAL_MARKETPLACE];
69
+ const unreadable: UnreadableMarketplace[] = [];
70
+ for (const [name, entry] of Object.entries(config.marketplaces ?? {})) {
71
+ const narrowed = narrowEntry(entry);
72
+ if (narrowed.ok) known.push({ name, source: narrowed.source });
73
+ else unreadable.push({ name, reason: narrowed.reason });
74
+ }
75
+ return { known, unreadable };
76
+ }
77
+
78
+ /** The marketplace called `name`, or `undefined`; an unreadable entry refuses with its reason. */
79
+ export function findMarketplace(
80
+ name: string,
81
+ config: Config = loadConfig(),
82
+ ): KnownMarketplace | undefined {
83
+ const listing = listMarketplaces(config);
84
+ const known = listing.known.find((marketplace) => marketplace.name === name);
85
+ if (known !== undefined) return known;
86
+ const broken = listing.unreadable.find(
87
+ (marketplace) => marketplace.name === name,
88
+ );
89
+ if (broken !== undefined) {
90
+ throw new UsageError(
91
+ `marketplace '${name}' is configured but cannot be read: ${broken.reason}\n\n` +
92
+ `Fix the entry under 'marketplaces.${name}' in the config file, or remove it with 'stigmer marketplace remove ${name}'.`,
93
+ );
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ /**
99
+ * Record a marketplace under `name` and save. Refuses the reserved name, a
100
+ * name already taken, and a run under `--standalone` (the config file is
101
+ * ignored there, so there is nothing to add to; saving would write a fresh
102
+ * default file over the user's own).
103
+ */
104
+ export function addMarketplace(
105
+ name: string,
106
+ source: Exclude<MarketplaceSource, { type: "official" }>,
107
+ ): void {
108
+ refuseStandalone("add");
109
+ if (name === OFFICIAL_MARKETPLACE_NAME) {
110
+ throw new UsageError(
111
+ `'${OFFICIAL_MARKETPLACE_NAME}' is the built-in marketplace and cannot be added or replaced\n\n` +
112
+ "Pass --name <other-name> to add this source under a different name.",
113
+ );
114
+ }
115
+ const config = loadConfig();
116
+ if (config.marketplaces?.[name] !== undefined) {
117
+ throw new UsageError(
118
+ `a marketplace named '${name}' is already configured\n\n` +
119
+ `Remove it first with 'stigmer marketplace remove ${name}', or pass --name <other-name>.`,
120
+ );
121
+ }
122
+ saveConfig({
123
+ ...config,
124
+ marketplaces: { ...config.marketplaces, [name]: toEntry(source) },
125
+ });
126
+ }
127
+
128
+ /** Remove the marketplace called `name` and save. Refuses the reserved name and an unknown one. */
129
+ export function removeMarketplace(name: string): void {
130
+ refuseStandalone("remove");
131
+ if (name === OFFICIAL_MARKETPLACE_NAME) {
132
+ throw new UsageError(
133
+ `'${OFFICIAL_MARKETPLACE_NAME}' is the built-in marketplace and cannot be removed`,
134
+ );
135
+ }
136
+ const config = loadConfig();
137
+ if (config.marketplaces?.[name] === undefined) {
138
+ throw new UsageError(
139
+ `no marketplace named '${name}' is configured\n\nRun 'stigmer marketplace list' to see the configured names.`,
140
+ );
141
+ }
142
+ const { [name]: _removed, ...rest } = config.marketplaces;
143
+ saveConfig({
144
+ ...config,
145
+ marketplaces: Object.keys(rest).length === 0 ? undefined : rest,
146
+ });
147
+ }
148
+
149
+ function refuseStandalone(verb: string): void {
150
+ if (isStandalone()) {
151
+ throw new UsageError(
152
+ `--standalone ignores the config file, so there is no marketplace list to ${verb} to\n\n` +
153
+ "Run without --standalone to change the configured marketplaces.",
154
+ );
155
+ }
156
+ }
157
+
158
+ type NarrowedEntry =
159
+ | { readonly ok: true; readonly source: MarketplaceSource }
160
+ | { readonly ok: false; readonly reason: string };
161
+
162
+ /** The loose on-disk entry as a source, or the reason it is not one. */
163
+ export function narrowEntry(entry: MarketplaceEntryConfig): NarrowedEntry {
164
+ switch (entry.type) {
165
+ case "github": {
166
+ if (typeof entry.repo !== "string" || !isOwnerRepo(entry.repo)) {
167
+ return { ok: false, reason: "type 'github' needs 'repo: owner/repo'" };
168
+ }
169
+ if (
170
+ entry.ref !== undefined &&
171
+ (typeof entry.ref !== "string" || entry.ref === "")
172
+ ) {
173
+ return {
174
+ ok: false,
175
+ reason: "'ref' must be a non-empty branch, tag or commit",
176
+ };
177
+ }
178
+ return {
179
+ ok: true,
180
+ source: {
181
+ type: "github",
182
+ repo: entry.repo,
183
+ ...(entry.ref !== undefined && { ref: entry.ref }),
184
+ },
185
+ };
186
+ }
187
+ case "local": {
188
+ if (typeof entry.path !== "string" || entry.path === "") {
189
+ return { ok: false, reason: "type 'local' needs 'path: <directory>'" };
190
+ }
191
+ return { ok: true, source: { type: "local", path: entry.path } };
192
+ }
193
+ case undefined:
194
+ return { ok: false, reason: "no 'type' (expected 'github' or 'local')" };
195
+ default:
196
+ return {
197
+ ok: false,
198
+ reason: `unknown type '${String(entry.type)}' (expected 'github' or 'local')`,
199
+ };
200
+ }
201
+ }
202
+
203
+ function toEntry(
204
+ source: Exclude<MarketplaceSource, { type: "official" }>,
205
+ ): MarketplaceEntryConfig {
206
+ switch (source.type) {
207
+ case "github":
208
+ return {
209
+ type: "github",
210
+ repo: source.repo,
211
+ ...(source.ref !== undefined && { ref: source.ref }),
212
+ };
213
+ case "local":
214
+ return { type: "local", path: source.path };
215
+ default: {
216
+ const exhaustive: never = source;
217
+ return exhaustive;
218
+ }
219
+ }
220
+ }
221
+
222
+ /** One sentence naming where a source comes from, for lists and sentences. */
223
+ export function describeSource(source: MarketplaceSource): string {
224
+ switch (source.type) {
225
+ case "official":
226
+ return "built in";
227
+ case "github":
228
+ return describeGitHubSource(source);
229
+ case "local":
230
+ return source.path;
231
+ default: {
232
+ const exhaustive: never = source;
233
+ return exhaustive;
234
+ }
235
+ }
236
+ }