wave-agent-sdk 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/core/plugin.d.ts +2 -2
  2. package/dist/core/plugin.d.ts.map +1 -1
  3. package/dist/core/plugin.js +7 -7
  4. package/dist/managers/backgroundTaskManager.d.ts.map +1 -1
  5. package/dist/managers/backgroundTaskManager.js +0 -12
  6. package/dist/managers/pluginManager.d.ts.map +1 -1
  7. package/dist/managers/pluginManager.js +1 -1
  8. package/dist/services/MarketplaceService.d.ts +53 -12
  9. package/dist/services/MarketplaceService.d.ts.map +1 -1
  10. package/dist/services/MarketplaceService.js +311 -123
  11. package/dist/services/configurationService.d.ts +17 -1
  12. package/dist/services/configurationService.d.ts.map +1 -1
  13. package/dist/services/configurationService.js +104 -0
  14. package/dist/services/pluginLoader.d.ts +6 -0
  15. package/dist/services/pluginLoader.d.ts.map +1 -1
  16. package/dist/services/pluginLoader.js +52 -7
  17. package/dist/types/configuration.d.ts +7 -0
  18. package/dist/types/configuration.d.ts.map +1 -1
  19. package/dist/types/marketplace.d.ts +28 -1
  20. package/dist/types/marketplace.d.ts.map +1 -1
  21. package/dist/types/plugins.d.ts +13 -1
  22. package/dist/types/plugins.d.ts.map +1 -1
  23. package/package.json +1 -1
  24. package/src/core/plugin.ts +13 -7
  25. package/src/managers/backgroundTaskManager.ts +1 -20
  26. package/src/managers/pluginManager.ts +4 -1
  27. package/src/services/MarketplaceService.ts +425 -134
  28. package/src/services/configurationService.ts +131 -0
  29. package/src/services/pluginLoader.ts +66 -7
  30. package/src/types/configuration.ts +8 -0
  31. package/src/types/marketplace.ts +26 -1
  32. package/src/types/plugins.ts +13 -1
@@ -3,14 +3,20 @@ import * as path from "path";
3
3
  import * as crypto from "crypto";
4
4
  import { getPluginsDir } from "../utils/configPaths.js";
5
5
  import { GitService } from "./GitService.js";
6
+ import { ConfigurationService } from "./configurationService.js";
6
7
  /**
7
8
  * Marketplace Service
8
9
  *
9
10
  * Handles local plugin marketplace registration, plugin installation,
10
11
  * and state management for installed plugins.
12
+ *
13
+ * Marketplace declarations are now scoped (user/project/local) via settings files.
14
+ * known_marketplaces.json is kept as a cache for installLocation/lastUpdated metadata.
11
15
  */
12
16
  export class MarketplaceService {
13
- constructor() {
17
+ constructor(workdir = process.cwd(), configurationService = new ConfigurationService()) {
18
+ this.workdir = workdir;
19
+ this.configurationService = configurationService;
14
20
  this.pluginsDir = getPluginsDir();
15
21
  this.knownMarketplacesPath = path.join(this.pluginsDir, "known_marketplaces.json");
16
22
  this.installedPluginsPath = path.join(this.pluginsDir, "installed_plugins.json");
@@ -20,6 +26,7 @@ export class MarketplaceService {
20
26
  this.marketplacesDir = path.join(this.pluginsDir, "marketplaces");
21
27
  this.gitService = new GitService();
22
28
  this.ensureDirectoryStructure();
29
+ this.runMigration();
23
30
  }
24
31
  /**
25
32
  * Ensures the required directory structure exists in ~/.wave/plugins
@@ -31,6 +38,32 @@ export class MarketplaceService {
31
38
  }
32
39
  });
33
40
  }
41
+ /**
42
+ * Backwards compatibility migration: migrate entries from known_marketplaces.json
43
+ * to user-level settings if they aren't already declared there.
44
+ */
45
+ async runMigration() {
46
+ try {
47
+ const cacheRegistry = await this.getCacheRegistry();
48
+ const scopedMarketplaces = this.configurationService.getMergedMarketplaces(this.workdir);
49
+ const userMarketplaces = this.configurationService.getScopedMarketplaces(this.workdir, "user");
50
+ const entriesToMigrate = (cacheRegistry?.marketplaces ?? []).filter((m) => !scopedMarketplaces[m.name] && !userMarketplaces[m.name]);
51
+ if (entriesToMigrate.length > 0) {
52
+ for (const m of entriesToMigrate) {
53
+ if (m.name === MarketplaceService.BUILTIN_MARKETPLACE.name)
54
+ continue;
55
+ const config = {
56
+ source: m.source,
57
+ autoUpdate: m.autoUpdate,
58
+ };
59
+ await this.configurationService.addMarketplaceToScope(this.workdir, "user", m.name, config);
60
+ }
61
+ }
62
+ }
63
+ catch {
64
+ // Migration failure should not block startup
65
+ }
66
+ }
34
67
  /**
35
68
  * Check if a lock file is stale by reading its PID and checking if the process is alive.
36
69
  * Returns true if the lock is stale and safe to remove.
@@ -41,17 +74,16 @@ export class MarketplaceService {
41
74
  const pid = parseInt(content.trim(), 10);
42
75
  if (isNaN(pid))
43
76
  return true;
44
- // Check if the process is still running
45
77
  try {
46
78
  process.kill(pid, 0);
47
- return false; // Process exists, lock is valid
79
+ return false;
48
80
  }
49
81
  catch {
50
- return true; // Process doesn't exist, lock is stale
82
+ return true;
51
83
  }
52
84
  }
53
85
  catch {
54
- return true; // Can't read lock file, assume stale
86
+ return true;
55
87
  }
56
88
  }
57
89
  /**
@@ -63,7 +95,7 @@ export class MarketplaceService {
63
95
  return await fn();
64
96
  }
65
97
  let lockFd;
66
- const maxRetries = 600; // 60 seconds total
98
+ const maxRetries = 600;
67
99
  const retryDelay = 100;
68
100
  for (let i = 0; i < maxRetries; i++) {
69
101
  try {
@@ -75,7 +107,6 @@ export class MarketplaceService {
75
107
  typeof error === "object" &&
76
108
  "code" in error &&
77
109
  error.code === "EEXIST") {
78
- // Check for stale lock every 60 retries (every ~6 seconds)
79
110
  if (i > 0 && i % 60 === 0) {
80
111
  const stale = await this.isStaleLock();
81
112
  if (stale) {
@@ -92,7 +123,6 @@ export class MarketplaceService {
92
123
  if (!lockFd) {
93
124
  throw new Error(`Failed to acquire marketplace lock after ${maxRetries} retries. If no other wave-agent process is running, please delete ${this.lockPath}`);
94
125
  }
95
- // Write PID into the lock file for stale lock detection
96
126
  await fs.writeFile(this.lockPath, String(process.pid), "utf-8");
97
127
  MarketplaceService.isLockedInProcess = true;
98
128
  try {
@@ -105,37 +135,86 @@ export class MarketplaceService {
105
135
  }
106
136
  }
107
137
  /**
108
- * Loads the known marketplaces registry
138
+ * Loads the cache registry from known_marketplaces.json
139
+ * Returns null if the file doesn't exist or has no parseable content (for first-run detection).
109
140
  */
110
- async getKnownMarketplaces() {
141
+ async getCacheRegistry() {
111
142
  if (!existsSync(this.knownMarketplacesPath)) {
143
+ return null;
144
+ }
145
+ try {
146
+ const content = await fs.readFile(this.knownMarketplacesPath, "utf-8");
147
+ if (!content.trim()) {
148
+ return null;
149
+ }
150
+ return JSON.parse(content);
151
+ }
152
+ catch {
153
+ return null;
154
+ }
155
+ }
156
+ /**
157
+ * Legacy method: loads known marketplaces with builtin injection.
158
+ * @deprecated Use listMarketplaces() instead, which combines scoped settings.
159
+ */
160
+ async getKnownMarketplaces() {
161
+ const cache = await this.getCacheRegistry();
162
+ // If cache is null (file doesn't exist or has no parseable content), inject builtin
163
+ if (cache === null) {
112
164
  return {
113
165
  marketplaces: [
114
166
  {
115
167
  ...MarketplaceService.BUILTIN_MARKETPLACE,
116
168
  isBuiltin: true,
169
+ declaredScope: "builtin",
117
170
  },
118
171
  ],
119
172
  };
120
173
  }
121
- try {
122
- const content = await fs.readFile(this.knownMarketplacesPath, "utf-8");
123
- if (!content.trim()) {
124
- return {
125
- marketplaces: [
126
- {
127
- ...MarketplaceService.BUILTIN_MARKETPLACE,
128
- isBuiltin: true,
129
- },
130
- ],
131
- };
132
- }
133
- return JSON.parse(content);
174
+ // File has valid JSON - respect user's explicit choice even if empty
175
+ const hasBuiltin = cache.marketplaces.some((m) => m.name === MarketplaceService.BUILTIN_MARKETPLACE.name);
176
+ return {
177
+ marketplaces: cache.marketplaces.map((m) => ({
178
+ ...m,
179
+ isBuiltin: m.isBuiltin || m.name === MarketplaceService.BUILTIN_MARKETPLACE.name,
180
+ declaredScope: m.declaredScope ?? (hasBuiltin ? "builtin" : "user"),
181
+ })),
182
+ };
183
+ }
184
+ /**
185
+ * Updates the cache registry with metadata for a marketplace
186
+ */
187
+ async updateCacheMarketplace(name, metadata) {
188
+ const registry = await this.getCacheRegistry();
189
+ const marketplaces = registry?.marketplaces ?? [];
190
+ const existingIndex = marketplaces.findIndex((m) => m.name === name);
191
+ if (existingIndex >= 0) {
192
+ marketplaces[existingIndex] = {
193
+ ...marketplaces[existingIndex],
194
+ ...metadata,
195
+ };
134
196
  }
135
- catch (error) {
136
- console.error("Failed to load known marketplaces:", error);
137
- return { marketplaces: [] };
197
+ else {
198
+ marketplaces.push({
199
+ name,
200
+ source: metadata.source || { source: "directory", path: "" },
201
+ ...metadata,
202
+ });
138
203
  }
204
+ const tmpPath = `${this.knownMarketplacesPath}.tmp`;
205
+ await fs.writeFile(tmpPath, JSON.stringify({ marketplaces }, null, 2));
206
+ await fs.rename(tmpPath, this.knownMarketplacesPath);
207
+ }
208
+ /**
209
+ * Removes a marketplace from the cache registry
210
+ */
211
+ async removeFromCache(name) {
212
+ const registry = await this.getCacheRegistry();
213
+ const marketplaces = registry?.marketplaces ?? [];
214
+ const filtered = marketplaces.filter((m) => m.name !== name);
215
+ const tmpPath = `${this.knownMarketplacesPath}.tmp`;
216
+ await fs.writeFile(tmpPath, JSON.stringify({ marketplaces: filtered }, null, 2));
217
+ await fs.rename(tmpPath, this.knownMarketplacesPath);
139
218
  }
140
219
  /**
141
220
  * Loads the installed plugins registry
@@ -156,14 +235,6 @@ export class MarketplaceService {
156
235
  return { plugins: [] };
157
236
  }
158
237
  }
159
- /**
160
- * Saves the known marketplaces registry
161
- */
162
- async saveKnownMarketplaces(registry) {
163
- const tmpPath = `${this.knownMarketplacesPath}.tmp`;
164
- await fs.writeFile(tmpPath, JSON.stringify(registry, null, 2));
165
- await fs.rename(tmpPath, this.knownMarketplacesPath);
166
- }
167
238
  /**
168
239
  * Saves the installed plugins registry
169
240
  */
@@ -172,17 +243,32 @@ export class MarketplaceService {
172
243
  await fs.writeFile(tmpPath, JSON.stringify(registry, null, 2));
173
244
  await fs.rename(tmpPath, this.installedPluginsPath);
174
245
  }
246
+ /**
247
+ * Finds the first existing marketplace manifest path.
248
+ * Prefers .wave-plugin/ for backward compatibility, falls back to .claude-plugin/.
249
+ * Returns null if neither exists.
250
+ */
251
+ async findMarketplaceManifestPath(dir) {
252
+ const waveManifestPath = path.join(dir, ".wave-plugin", "marketplace.json");
253
+ const claudeManifestPath = path.join(dir, ".claude-plugin", "marketplace.json");
254
+ if (existsSync(waveManifestPath)) {
255
+ return waveManifestPath;
256
+ }
257
+ if (existsSync(claudeManifestPath)) {
258
+ return claudeManifestPath;
259
+ }
260
+ return null;
261
+ }
175
262
  /**
176
263
  * Loads a marketplace manifest from a local path
177
264
  */
178
265
  async loadMarketplaceManifest(marketplacePath) {
179
- const manifestPath = path.join(marketplacePath, ".wave-plugin", "marketplace.json");
180
- if (!existsSync(manifestPath)) {
181
- throw new Error(`Marketplace manifest not found at ${manifestPath}`);
266
+ const manifestPath = await this.findMarketplaceManifestPath(marketplacePath);
267
+ if (!manifestPath) {
268
+ throw new Error(`Marketplace manifest not found at ${marketplacePath}. Neither .wave-plugin/marketplace.json nor .claude-plugin/marketplace.json exists.`);
182
269
  }
183
270
  const content = await fs.readFile(manifestPath, "utf-8");
184
271
  const manifest = JSON.parse(content);
185
- // Basic validation
186
272
  if (!manifest.name ||
187
273
  !manifest.plugins ||
188
274
  !Array.isArray(manifest.plugins)) {
@@ -193,26 +279,49 @@ export class MarketplaceService {
193
279
  /**
194
280
  * Resolves the local path for a marketplace
195
281
  */
196
- getMarketplacePath(marketplace) {
197
- if (marketplace.source.source === "directory") {
198
- return marketplace.source.path;
282
+ getMarketplacePath(source) {
283
+ if (source.source === "directory") {
284
+ return source.path;
199
285
  }
200
- else if (marketplace.source.source === "github") {
201
- return path.join(this.marketplacesDir, marketplace.source.repo);
286
+ else if (source.source === "github") {
287
+ return path.join(this.marketplacesDir, source.repo);
202
288
  }
203
289
  else {
204
- // For general git, use a hash of the URL to avoid path issues
205
- const hash = crypto
206
- .createHash("md5")
207
- .update(marketplace.source.url)
208
- .digest("hex");
290
+ const hash = crypto.createHash("md5").update(source.url).digest("hex");
209
291
  return path.join(this.marketplacesDir, hash);
210
292
  }
211
293
  }
294
+ /**
295
+ * Builds a KnownMarketplace from a scoped config, enriched with cache metadata
296
+ */
297
+ async buildMarketplaceEntry(name, config, cache, declaredScope) {
298
+ return {
299
+ name,
300
+ source: config.source,
301
+ autoUpdate: config.autoUpdate ?? cache?.autoUpdate,
302
+ lastUpdated: cache?.lastUpdated,
303
+ isBuiltin: name === MarketplaceService.BUILTIN_MARKETPLACE.name,
304
+ declaredScope,
305
+ };
306
+ }
307
+ /**
308
+ * Finds which scope declared a marketplace (user, project, local, or builtin)
309
+ */
310
+ getMarketplaceDeclaringSource(name) {
311
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name)
312
+ return "builtin";
313
+ const scopes = ["local", "project", "user"];
314
+ for (const scope of scopes) {
315
+ const scoped = this.configurationService.getScopedMarketplaces(this.workdir, scope);
316
+ if (scoped[name])
317
+ return scope;
318
+ }
319
+ return null;
320
+ }
212
321
  /**
213
322
  * Adds a new marketplace (local directory, GitHub repo, or Git URL)
214
323
  */
215
- async addMarketplace(input) {
324
+ async addMarketplace(input, scope = "user") {
216
325
  return this.withLock(async () => {
217
326
  let marketplace;
218
327
  const isFullUrl = input.startsWith("http://") ||
@@ -223,16 +332,15 @@ export class MarketplaceService {
223
332
  (input.includes("/") &&
224
333
  !path.isAbsolute(input) &&
225
334
  !input.startsWith("."))) {
226
- // Git or GitHub repo
227
335
  let urlOrRepo = input;
228
336
  let ref;
229
337
  if (input.includes("#")) {
230
338
  [urlOrRepo, ref] = input.split("#");
231
339
  }
232
- const tempMarketplace = isFullUrl
233
- ? { name: "", source: { source: "git", url: urlOrRepo, ref } }
234
- : { name: "", source: { source: "github", repo: urlOrRepo, ref } };
235
- const targetPath = this.getMarketplacePath(tempMarketplace);
340
+ const tempSource = isFullUrl
341
+ ? { source: "git", url: urlOrRepo, ref }
342
+ : { source: "github", repo: urlOrRepo, ref };
343
+ const targetPath = this.getMarketplacePath(tempSource);
236
344
  if (!existsSync(targetPath)) {
237
345
  try {
238
346
  await this.gitService.clone(urlOrRepo, targetPath, ref);
@@ -258,7 +366,6 @@ export class MarketplaceService {
258
366
  };
259
367
  }
260
368
  else {
261
- // Local directory format
262
369
  const absolutePath = path.resolve(input);
263
370
  let manifest;
264
371
  try {
@@ -274,44 +381,68 @@ export class MarketplaceService {
274
381
  lastUpdated: new Date().toISOString(),
275
382
  };
276
383
  }
277
- const registry = await this.getKnownMarketplaces();
278
- // Check if already exists
279
- const existingIndex = registry.marketplaces.findIndex((m) => m.name === marketplace.name);
280
- if (existingIndex >= 0) {
281
- registry.marketplaces[existingIndex] = marketplace;
282
- }
283
- else {
284
- registry.marketplaces.push(marketplace);
285
- }
286
- // Ensure builtin is included if we are creating the file for the first time
287
- // and it hasn't been explicitly removed yet.
288
- // (getKnownMarketplaces already handles the default injection)
289
- await this.saveKnownMarketplaces(registry);
384
+ const config = {
385
+ source: marketplace.source,
386
+ autoUpdate: marketplace.autoUpdate,
387
+ };
388
+ await this.configurationService.addMarketplaceToScope(this.workdir, scope, marketplace.name, config);
389
+ // Update cache with metadata
390
+ await this.updateCacheMarketplace(marketplace.name, {
391
+ source: marketplace.source,
392
+ autoUpdate: marketplace.autoUpdate,
393
+ lastUpdated: marketplace.lastUpdated,
394
+ });
290
395
  return marketplace;
291
396
  });
292
397
  }
293
398
  /**
294
- * Lists all registered marketplaces
399
+ * Lists all registered marketplaces by combining scoped settings + built-in
295
400
  */
296
401
  async listMarketplaces() {
297
- const registry = await this.getKnownMarketplaces();
298
- return registry.marketplaces.map((m) => ({
299
- ...m,
300
- isBuiltin: m.name === MarketplaceService.BUILTIN_MARKETPLACE.name,
301
- }));
402
+ const scopedMarketplaces = this.configurationService.getMergedMarketplaces(this.workdir);
403
+ const cacheRegistry = await this.getCacheRegistry();
404
+ const cacheMap = new Map((cacheRegistry?.marketplaces ?? []).map((m) => [m.name, m]));
405
+ const result = [];
406
+ // Add built-in marketplace
407
+ result.push({
408
+ ...MarketplaceService.BUILTIN_MARKETPLACE,
409
+ isBuiltin: true,
410
+ declaredScope: "builtin",
411
+ });
412
+ // Add all scoped marketplaces (local overrides project overrides user)
413
+ for (const [name, config] of Object.entries(scopedMarketplaces)) {
414
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name)
415
+ continue;
416
+ const cache = cacheMap.get(name);
417
+ const declaredScope = this.getMarketplaceDeclaringSource(name);
418
+ result.push(await this.buildMarketplaceEntry(name, config, cache, declaredScope));
419
+ }
420
+ // Add cache entries not yet in scoped settings (backwards compatibility)
421
+ for (const [name, cache] of cacheMap.entries()) {
422
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name)
423
+ continue;
424
+ if (scopedMarketplaces[name])
425
+ continue;
426
+ result.push({
427
+ ...cache,
428
+ isBuiltin: false,
429
+ declaredScope: cache.declaredScope ?? "user",
430
+ });
431
+ }
432
+ return result;
302
433
  }
303
434
  /**
304
- * Removes a marketplace by name
435
+ * Removes a marketplace by name from the specified scope
305
436
  */
306
- async removeMarketplace(name) {
437
+ async removeMarketplace(name, scope) {
307
438
  return this.withLock(async () => {
308
- const registry = await this.getKnownMarketplaces();
309
- const initialCount = registry.marketplaces.length;
310
- registry.marketplaces = registry.marketplaces.filter((m) => m.name !== name);
311
- if (registry.marketplaces.length === initialCount) {
312
- throw new Error(`Marketplace ${name} not found`);
439
+ const targetScope = scope || this.getMarketplaceDeclaringSource(name) || "user";
440
+ if (targetScope === "builtin") {
441
+ throw new Error("Cannot remove built-in marketplace");
313
442
  }
314
- await this.saveKnownMarketplaces(registry);
443
+ await this.configurationService.removeMarketplaceFromScope(this.workdir, targetScope, name);
444
+ // Also remove from cache
445
+ await this.removeFromCache(name);
315
446
  });
316
447
  }
317
448
  /**
@@ -319,10 +450,10 @@ export class MarketplaceService {
319
450
  */
320
451
  async updateMarketplace(name, options) {
321
452
  return this.withLock(async () => {
322
- const registry = await this.getKnownMarketplaces();
453
+ const marketplaces = await this.listMarketplaces();
323
454
  const toUpdate = name
324
- ? registry.marketplaces.filter((m) => m.name === name)
325
- : registry.marketplaces;
455
+ ? marketplaces.filter((m) => m.name === name)
456
+ : marketplaces;
326
457
  if (name && toUpdate.length === 0) {
327
458
  throw new Error(`Marketplace ${name} not found`);
328
459
  }
@@ -336,7 +467,7 @@ export class MarketplaceService {
336
467
  console.warn(`Skipping update for Git/GitHub marketplace "${marketplace.name}" because Git is not installed.`);
337
468
  continue;
338
469
  }
339
- const targetPath = this.getMarketplacePath(marketplace);
470
+ const targetPath = this.getMarketplacePath(marketplace.source);
340
471
  if (existsSync(targetPath)) {
341
472
  await this.gitService.pull(targetPath);
342
473
  }
@@ -351,9 +482,12 @@ export class MarketplaceService {
351
482
  await this.gitService.clone(url, targetPath, marketplace.source.ref);
352
483
  }
353
484
  }
354
- // For directory source, we just re-validate the manifest
355
- const manifest = await this.loadMarketplaceManifest(this.getMarketplacePath(marketplace));
485
+ const manifest = await this.loadMarketplaceManifest(this.getMarketplacePath(marketplace.source));
356
486
  marketplace.lastUpdated = new Date().toISOString();
487
+ // Update cache metadata
488
+ await this.updateCacheMarketplace(marketplace.name, {
489
+ lastUpdated: marketplace.lastUpdated,
490
+ });
357
491
  if (options?.updatePlugins) {
358
492
  const installedRegistry = await this.getInstalledPlugins();
359
493
  const pluginsToUpdate = installedRegistry.plugins.filter((p) => p.marketplace === marketplace.name);
@@ -387,7 +521,6 @@ export class MarketplaceService {
387
521
  if (errors.length > 0) {
388
522
  throw new Error(`Some marketplaces failed to update:\n${errors.join("\n")}`);
389
523
  }
390
- await this.saveKnownMarketplaces(registry);
391
524
  });
392
525
  }
393
526
  /**
@@ -395,16 +528,18 @@ export class MarketplaceService {
395
528
  */
396
529
  async autoUpdateAll() {
397
530
  return this.withLock(async () => {
398
- const registry = await this.getKnownMarketplaces();
399
- const toAutoUpdate = registry.marketplaces.filter((m) => m.autoUpdate);
400
- for (const marketplace of toAutoUpdate) {
531
+ const scopedMarketplaces = this.configurationService.getMergedMarketplaces(this.workdir);
532
+ const toAutoUpdate = Object.entries(scopedMarketplaces)
533
+ .filter(([, config]) => config.autoUpdate)
534
+ .map(([name]) => name);
535
+ for (const marketplaceName of toAutoUpdate) {
401
536
  try {
402
- await this.updateMarketplace(marketplace.name, {
537
+ await this.updateMarketplace(marketplaceName, {
403
538
  updatePlugins: true,
404
539
  });
405
540
  }
406
541
  catch (error) {
407
- console.error(`Auto-update failed for marketplace "${marketplace.name}":`, error);
542
+ console.error(`Auto-update failed for marketplace "${marketplaceName}":`, error);
408
543
  }
409
544
  }
410
545
  });
@@ -414,15 +549,74 @@ export class MarketplaceService {
414
549
  */
415
550
  async toggleAutoUpdate(name, enabled) {
416
551
  return this.withLock(async () => {
417
- const registry = await this.getKnownMarketplaces();
418
- const marketplace = registry.marketplaces.find((m) => m.name === name);
419
- if (!marketplace) {
552
+ const declaringSource = this.getMarketplaceDeclaringSource(name);
553
+ if (!declaringSource || declaringSource === "builtin") {
554
+ throw new Error(`Marketplace ${name} not found`);
555
+ }
556
+ const scoped = this.configurationService.getScopedMarketplaces(this.workdir, declaringSource);
557
+ const config = scoped[name];
558
+ if (!config) {
420
559
  throw new Error(`Marketplace ${name} not found`);
421
560
  }
422
- marketplace.autoUpdate = enabled;
423
- await this.saveKnownMarketplaces(registry);
561
+ config.autoUpdate = enabled;
562
+ await this.configurationService.addMarketplaceToScope(this.workdir, declaringSource, name, config);
563
+ // Also update cache
564
+ await this.updateCacheMarketplace(name, { autoUpdate: enabled });
424
565
  });
425
566
  }
567
+ /**
568
+ * Resolves a plugin source into a consistent format for installation.
569
+ * Handles both string sources (local paths or git URLs) and object-style MarketplaceSource.
570
+ */
571
+ resolvePluginSource(pluginEntry, marketplacePath) {
572
+ const { source } = pluginEntry;
573
+ if (typeof source === "string") {
574
+ // String source: could be a git URL or a relative local path
575
+ const isGitUrl = source.startsWith("http://") ||
576
+ source.startsWith("https://") ||
577
+ source.startsWith("git@") ||
578
+ source.startsWith("ssh://");
579
+ if (isGitUrl) {
580
+ let url = source;
581
+ let ref;
582
+ if (url.includes("#")) {
583
+ [url, ref] = url.split("#");
584
+ }
585
+ return { isGit: true, url, ref, localPath: "" };
586
+ }
587
+ // Relative local path
588
+ return { isGit: false, localPath: path.resolve(marketplacePath, source) };
589
+ }
590
+ // Object-style source
591
+ if (source.source === "git") {
592
+ return { isGit: true, url: source.url, ref: source.ref, localPath: "" };
593
+ }
594
+ if (source.source === "github") {
595
+ return {
596
+ isGit: true,
597
+ url: `https://github.com/${source.repo}.git`,
598
+ ref: source.ref,
599
+ localPath: "",
600
+ };
601
+ }
602
+ if (source.source === "url") {
603
+ let url = source.url;
604
+ let ref = source.ref;
605
+ if (url.includes("#")) {
606
+ [url, ref] = url.split("#");
607
+ }
608
+ return { isGit: true, url, ref, localPath: "" };
609
+ }
610
+ if (source.source === "directory") {
611
+ return {
612
+ isGit: false,
613
+ localPath: path.resolve(marketplacePath, source.path),
614
+ };
615
+ }
616
+ // Exhaustiveness: this should be unreachable given the union type
617
+ const _exhaustive = source;
618
+ throw new Error(`Unsupported plugin source type: ${_exhaustive.source}`);
619
+ }
426
620
  /**
427
621
  * Installs a plugin from a marketplace
428
622
  */
@@ -437,45 +631,44 @@ export class MarketplaceService {
437
631
  if (!marketplace) {
438
632
  throw new Error(`Marketplace ${marketplaceName} not found`);
439
633
  }
440
- const marketplacePath = this.getMarketplacePath(marketplace);
634
+ const marketplacePath = this.getMarketplacePath(marketplace.source);
441
635
  const manifest = await this.loadMarketplaceManifest(marketplacePath);
442
636
  const pluginEntry = manifest.plugins.find((p) => p.name === pluginName);
443
637
  if (!pluginEntry) {
444
638
  throw new Error(`Plugin ${pluginName} not found in marketplace ${marketplaceName}`);
445
639
  }
446
- const isGitSource = pluginEntry.source.startsWith("http://") ||
447
- pluginEntry.source.startsWith("https://") ||
448
- pluginEntry.source.startsWith("git@") ||
449
- pluginEntry.source.startsWith("ssh://");
640
+ const resolved = this.resolvePluginSource(pluginEntry, marketplacePath);
450
641
  let pluginSrcPath;
451
642
  let tempCloneDir;
452
643
  try {
453
- if (isGitSource) {
644
+ if (resolved.isGit) {
454
645
  tempCloneDir = path.join(this.tmpDir, `clone-${Date.now()}`);
455
- let url = pluginEntry.source;
456
- let ref;
457
- if (url.includes("#")) {
458
- [url, ref] = url.split("#");
459
- }
460
- await this.gitService.clone(url, tempCloneDir, ref);
646
+ await this.gitService.clone(resolved.url, tempCloneDir, resolved.ref);
461
647
  pluginSrcPath = tempCloneDir;
462
648
  }
463
649
  else {
464
- pluginSrcPath = path.resolve(marketplacePath, pluginEntry.source);
650
+ pluginSrcPath = resolved.localPath;
651
+ }
652
+ let pluginManifestPath;
653
+ const wavePluginPath = path.join(pluginSrcPath, ".wave-plugin", "plugin.json");
654
+ const claudePluginPath = path.join(pluginSrcPath, ".claude-plugin", "plugin.json");
655
+ if (existsSync(wavePluginPath)) {
656
+ pluginManifestPath = wavePluginPath;
657
+ }
658
+ else if (existsSync(claudePluginPath)) {
659
+ pluginManifestPath = claudePluginPath;
465
660
  }
466
- const pluginManifestPath = path.join(pluginSrcPath, ".wave-plugin", "plugin.json");
467
- if (!existsSync(pluginManifestPath)) {
468
- throw new Error(`Plugin manifest not found at ${pluginManifestPath}`);
661
+ if (!pluginManifestPath) {
662
+ throw new Error(`Plugin manifest not found at ${pluginSrcPath}. Neither .wave-plugin/plugin.json nor .claude-plugin/plugin.json exists.`);
469
663
  }
470
664
  const pluginManifestContent = await fs.readFile(pluginManifestPath, "utf-8");
471
665
  const pluginManifest = JSON.parse(pluginManifestContent);
472
666
  const version = pluginManifest.version || "1.0.0";
473
- // Atomic installation
474
667
  const tmpPluginDir = path.join(this.tmpDir, `${pluginName}-${Date.now()}`);
475
668
  try {
476
- if (isGitSource) {
669
+ if (resolved.isGit) {
477
670
  await fs.rename(pluginSrcPath, tmpPluginDir);
478
- tempCloneDir = undefined; // Already moved
671
+ tempCloneDir = undefined;
479
672
  }
480
673
  else {
481
674
  await fs.cp(pluginSrcPath, tmpPluginDir, { recursive: true });
@@ -507,7 +700,6 @@ export class MarketplaceService {
507
700
  return installedPlugin;
508
701
  }
509
702
  catch (error) {
510
- // Cleanup tmp dir if it exists
511
703
  if (existsSync(tmpPluginDir)) {
512
704
  await fs.rm(tmpPluginDir, { recursive: true, force: true });
513
705
  }
@@ -515,7 +707,6 @@ export class MarketplaceService {
515
707
  }
516
708
  }
517
709
  catch (error) {
518
- // Cleanup temp clone dir if it exists
519
710
  if (tempCloneDir && existsSync(tempCloneDir)) {
520
711
  await fs.rm(tempCloneDir, { recursive: true, force: true });
521
712
  }
@@ -540,12 +731,9 @@ export class MarketplaceService {
540
731
  throw new Error(`Plugin ${pluginName}@${marketplaceName} is not installed${projectPath ? ` for project ${projectPath}` : ""}`);
541
732
  }
542
733
  const pluginToRemove = installedRegistry.plugins[pluginIndex];
543
- // Remove from registry first
544
734
  installedRegistry.plugins.splice(pluginIndex, 1);
545
735
  await this.saveInstalledPlugins(installedRegistry);
546
- // Check if any other project is still using this same cache path
547
736
  const isStillReferenced = installedRegistry.plugins.some((p) => p.cachePath === pluginToRemove.cachePath);
548
- // Only remove cached files if no other references exist
549
737
  if (!isStillReferenced && existsSync(pluginToRemove.cachePath)) {
550
738
  await fs.rm(pluginToRemove.cachePath, { recursive: true, force: true });
551
739
  }