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
@@ -8,14 +8,21 @@ import {
8
8
  InstalledPlugin,
9
9
  InstalledPluginsRegistry,
10
10
  MarketplaceManifest,
11
+ MarketplacePluginEntry,
12
+ MarketplaceSource,
11
13
  } from "../types/marketplace.js";
12
14
  import { GitService } from "./GitService.js";
15
+ import { ConfigurationService } from "./configurationService.js";
16
+ import type { MarketplaceConfig, Scope } from "../types/configuration.js";
13
17
 
14
18
  /**
15
19
  * Marketplace Service
16
20
  *
17
21
  * Handles local plugin marketplace registration, plugin installation,
18
22
  * and state management for installed plugins.
23
+ *
24
+ * Marketplace declarations are now scoped (user/project/local) via settings files.
25
+ * known_marketplaces.json is kept as a cache for installLocation/lastUpdated metadata.
19
26
  */
20
27
  export class MarketplaceService {
21
28
  private static isLockedInProcess = false;
@@ -27,6 +34,8 @@ export class MarketplaceService {
27
34
  private cacheDir: string;
28
35
  private marketplacesDir: string;
29
36
  private gitService: GitService;
37
+ private configurationService: ConfigurationService;
38
+ private workdir: string;
30
39
  private static readonly BUILTIN_MARKETPLACE: KnownMarketplace = {
31
40
  name: "wave-plugins-official",
32
41
  source: {
@@ -36,7 +45,12 @@ export class MarketplaceService {
36
45
  autoUpdate: true,
37
46
  };
38
47
 
39
- constructor() {
48
+ constructor(
49
+ workdir: string = process.cwd(),
50
+ configurationService: ConfigurationService = new ConfigurationService(),
51
+ ) {
52
+ this.workdir = workdir;
53
+ this.configurationService = configurationService;
40
54
  this.pluginsDir = getPluginsDir();
41
55
  this.knownMarketplacesPath = path.join(
42
56
  this.pluginsDir,
@@ -53,6 +67,7 @@ export class MarketplaceService {
53
67
  this.gitService = new GitService();
54
68
 
55
69
  this.ensureDirectoryStructure();
70
+ this.runMigration();
56
71
  }
57
72
 
58
73
  /**
@@ -68,6 +83,44 @@ export class MarketplaceService {
68
83
  );
69
84
  }
70
85
 
86
+ /**
87
+ * Backwards compatibility migration: migrate entries from known_marketplaces.json
88
+ * to user-level settings if they aren't already declared there.
89
+ */
90
+ private async runMigration(): Promise<void> {
91
+ try {
92
+ const cacheRegistry = await this.getCacheRegistry();
93
+ const scopedMarketplaces =
94
+ this.configurationService.getMergedMarketplaces(this.workdir);
95
+ const userMarketplaces = this.configurationService.getScopedMarketplaces(
96
+ this.workdir,
97
+ "user",
98
+ );
99
+
100
+ const entriesToMigrate = (cacheRegistry?.marketplaces ?? []).filter(
101
+ (m) => !scopedMarketplaces[m.name] && !userMarketplaces[m.name],
102
+ );
103
+
104
+ if (entriesToMigrate.length > 0) {
105
+ for (const m of entriesToMigrate) {
106
+ if (m.name === MarketplaceService.BUILTIN_MARKETPLACE.name) continue;
107
+ const config: MarketplaceConfig = {
108
+ source: m.source,
109
+ autoUpdate: m.autoUpdate,
110
+ };
111
+ await this.configurationService.addMarketplaceToScope(
112
+ this.workdir,
113
+ "user",
114
+ m.name,
115
+ config,
116
+ );
117
+ }
118
+ }
119
+ } catch {
120
+ // Migration failure should not block startup
121
+ }
122
+ }
123
+
71
124
  /**
72
125
  * Check if a lock file is stale by reading its PID and checking if the process is alive.
73
126
  * Returns true if the lock is stale and safe to remove.
@@ -77,15 +130,14 @@ export class MarketplaceService {
77
130
  const content = await fs.readFile(this.lockPath, "utf-8");
78
131
  const pid = parseInt(content.trim(), 10);
79
132
  if (isNaN(pid)) return true;
80
- // Check if the process is still running
81
133
  try {
82
134
  process.kill(pid, 0);
83
- return false; // Process exists, lock is valid
135
+ return false;
84
136
  } catch {
85
- return true; // Process doesn't exist, lock is stale
137
+ return true;
86
138
  }
87
139
  } catch {
88
- return true; // Can't read lock file, assume stale
140
+ return true;
89
141
  }
90
142
  }
91
143
 
@@ -99,7 +151,7 @@ export class MarketplaceService {
99
151
  }
100
152
 
101
153
  let lockFd: Awaited<ReturnType<typeof fs.open>> | undefined;
102
- const maxRetries = 600; // 60 seconds total
154
+ const maxRetries = 600;
103
155
  const retryDelay = 100;
104
156
 
105
157
  for (let i = 0; i < maxRetries; i++) {
@@ -113,7 +165,6 @@ export class MarketplaceService {
113
165
  "code" in error &&
114
166
  error.code === "EEXIST"
115
167
  ) {
116
- // Check for stale lock every 60 retries (every ~6 seconds)
117
168
  if (i > 0 && i % 60 === 0) {
118
169
  const stale = await this.isStaleLock();
119
170
  if (stale) {
@@ -134,7 +185,6 @@ export class MarketplaceService {
134
185
  );
135
186
  }
136
187
 
137
- // Write PID into the lock file for stale lock detection
138
188
  await fs.writeFile(this.lockPath, String(process.pid), "utf-8");
139
189
 
140
190
  MarketplaceService.isLockedInProcess = true;
@@ -148,36 +198,96 @@ export class MarketplaceService {
148
198
  }
149
199
 
150
200
  /**
151
- * Loads the known marketplaces registry
201
+ * Loads the cache registry from known_marketplaces.json
202
+ * Returns null if the file doesn't exist or has no parseable content (for first-run detection).
152
203
  */
153
- async getKnownMarketplaces(): Promise<KnownMarketplacesRegistry> {
204
+ async getCacheRegistry(): Promise<KnownMarketplacesRegistry | null> {
154
205
  if (!existsSync(this.knownMarketplacesPath)) {
206
+ return null;
207
+ }
208
+ try {
209
+ const content = await fs.readFile(this.knownMarketplacesPath, "utf-8");
210
+ if (!content.trim()) {
211
+ return null;
212
+ }
213
+ return JSON.parse(content);
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Legacy method: loads known marketplaces with builtin injection.
221
+ * @deprecated Use listMarketplaces() instead, which combines scoped settings.
222
+ */
223
+ async getKnownMarketplaces(): Promise<KnownMarketplacesRegistry> {
224
+ const cache = await this.getCacheRegistry();
225
+ // If cache is null (file doesn't exist or has no parseable content), inject builtin
226
+ if (cache === null) {
155
227
  return {
156
228
  marketplaces: [
157
229
  {
158
230
  ...MarketplaceService.BUILTIN_MARKETPLACE,
159
231
  isBuiltin: true,
232
+ declaredScope: "builtin",
160
233
  },
161
234
  ],
162
235
  };
163
236
  }
164
- try {
165
- const content = await fs.readFile(this.knownMarketplacesPath, "utf-8");
166
- if (!content.trim()) {
167
- return {
168
- marketplaces: [
169
- {
170
- ...MarketplaceService.BUILTIN_MARKETPLACE,
171
- isBuiltin: true,
172
- },
173
- ],
174
- };
175
- }
176
- return JSON.parse(content);
177
- } catch (error) {
178
- console.error("Failed to load known marketplaces:", error);
179
- return { marketplaces: [] };
237
+ // File has valid JSON - respect user's explicit choice even if empty
238
+ const hasBuiltin = cache.marketplaces.some(
239
+ (m) => m.name === MarketplaceService.BUILTIN_MARKETPLACE.name,
240
+ );
241
+ return {
242
+ marketplaces: cache.marketplaces.map((m) => ({
243
+ ...m,
244
+ isBuiltin:
245
+ m.isBuiltin || m.name === MarketplaceService.BUILTIN_MARKETPLACE.name,
246
+ declaredScope: m.declaredScope ?? (hasBuiltin ? "builtin" : "user"),
247
+ })),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Updates the cache registry with metadata for a marketplace
253
+ */
254
+ private async updateCacheMarketplace(
255
+ name: string,
256
+ metadata: Partial<KnownMarketplace>,
257
+ ): Promise<void> {
258
+ const registry = await this.getCacheRegistry();
259
+ const marketplaces = registry?.marketplaces ?? [];
260
+ const existingIndex = marketplaces.findIndex((m) => m.name === name);
261
+ if (existingIndex >= 0) {
262
+ marketplaces[existingIndex] = {
263
+ ...marketplaces[existingIndex],
264
+ ...metadata,
265
+ };
266
+ } else {
267
+ marketplaces.push({
268
+ name,
269
+ source: metadata.source || { source: "directory", path: "" },
270
+ ...metadata,
271
+ });
180
272
  }
273
+ const tmpPath = `${this.knownMarketplacesPath}.tmp`;
274
+ await fs.writeFile(tmpPath, JSON.stringify({ marketplaces }, null, 2));
275
+ await fs.rename(tmpPath, this.knownMarketplacesPath);
276
+ }
277
+
278
+ /**
279
+ * Removes a marketplace from the cache registry
280
+ */
281
+ private async removeFromCache(name: string): Promise<void> {
282
+ const registry = await this.getCacheRegistry();
283
+ const marketplaces = registry?.marketplaces ?? [];
284
+ const filtered = marketplaces.filter((m) => m.name !== name);
285
+ const tmpPath = `${this.knownMarketplacesPath}.tmp`;
286
+ await fs.writeFile(
287
+ tmpPath,
288
+ JSON.stringify({ marketplaces: filtered }, null, 2),
289
+ );
290
+ await fs.rename(tmpPath, this.knownMarketplacesPath);
181
291
  }
182
292
 
183
293
  /**
@@ -199,17 +309,6 @@ export class MarketplaceService {
199
309
  }
200
310
  }
201
311
 
202
- /**
203
- * Saves the known marketplaces registry
204
- */
205
- async saveKnownMarketplaces(
206
- registry: KnownMarketplacesRegistry,
207
- ): Promise<void> {
208
- const tmpPath = `${this.knownMarketplacesPath}.tmp`;
209
- await fs.writeFile(tmpPath, JSON.stringify(registry, null, 2));
210
- await fs.rename(tmpPath, this.knownMarketplacesPath);
211
- }
212
-
213
312
  /**
214
313
  * Saves the installed plugins registry
215
314
  */
@@ -221,24 +320,46 @@ export class MarketplaceService {
221
320
  await fs.rename(tmpPath, this.installedPluginsPath);
222
321
  }
223
322
 
323
+ /**
324
+ * Finds the first existing marketplace manifest path.
325
+ * Prefers .wave-plugin/ for backward compatibility, falls back to .claude-plugin/.
326
+ * Returns null if neither exists.
327
+ */
328
+ private async findMarketplaceManifestPath(
329
+ dir: string,
330
+ ): Promise<string | null> {
331
+ const waveManifestPath = path.join(dir, ".wave-plugin", "marketplace.json");
332
+ const claudeManifestPath = path.join(
333
+ dir,
334
+ ".claude-plugin",
335
+ "marketplace.json",
336
+ );
337
+
338
+ if (existsSync(waveManifestPath)) {
339
+ return waveManifestPath;
340
+ }
341
+ if (existsSync(claudeManifestPath)) {
342
+ return claudeManifestPath;
343
+ }
344
+ return null;
345
+ }
346
+
224
347
  /**
225
348
  * Loads a marketplace manifest from a local path
226
349
  */
227
350
  async loadMarketplaceManifest(
228
351
  marketplacePath: string,
229
352
  ): Promise<MarketplaceManifest> {
230
- const manifestPath = path.join(
231
- marketplacePath,
232
- ".wave-plugin",
233
- "marketplace.json",
234
- );
235
- if (!existsSync(manifestPath)) {
236
- throw new Error(`Marketplace manifest not found at ${manifestPath}`);
353
+ const manifestPath =
354
+ await this.findMarketplaceManifestPath(marketplacePath);
355
+ if (!manifestPath) {
356
+ throw new Error(
357
+ `Marketplace manifest not found at ${marketplacePath}. Neither .wave-plugin/marketplace.json nor .claude-plugin/marketplace.json exists.`,
358
+ );
237
359
  }
238
360
  const content = await fs.readFile(manifestPath, "utf-8");
239
361
  const manifest = JSON.parse(content);
240
362
 
241
- // Basic validation
242
363
  if (
243
364
  !manifest.name ||
244
365
  !manifest.plugins ||
@@ -253,25 +374,60 @@ export class MarketplaceService {
253
374
  /**
254
375
  * Resolves the local path for a marketplace
255
376
  */
256
- public getMarketplacePath(marketplace: KnownMarketplace): string {
257
- if (marketplace.source.source === "directory") {
258
- return marketplace.source.path;
259
- } else if (marketplace.source.source === "github") {
260
- return path.join(this.marketplacesDir, marketplace.source.repo);
377
+ public getMarketplacePath(source: MarketplaceSource): string {
378
+ if (source.source === "directory") {
379
+ return source.path;
380
+ } else if (source.source === "github") {
381
+ return path.join(this.marketplacesDir, source.repo);
261
382
  } else {
262
- // For general git, use a hash of the URL to avoid path issues
263
- const hash = crypto
264
- .createHash("md5")
265
- .update(marketplace.source.url)
266
- .digest("hex");
383
+ const hash = crypto.createHash("md5").update(source.url).digest("hex");
267
384
  return path.join(this.marketplacesDir, hash);
268
385
  }
269
386
  }
270
387
 
388
+ /**
389
+ * Builds a KnownMarketplace from a scoped config, enriched with cache metadata
390
+ */
391
+ private async buildMarketplaceEntry(
392
+ name: string,
393
+ config: MarketplaceConfig,
394
+ cache: KnownMarketplace | undefined,
395
+ declaredScope: "user" | "project" | "local" | "builtin",
396
+ ): Promise<KnownMarketplace> {
397
+ return {
398
+ name,
399
+ source: config.source,
400
+ autoUpdate: config.autoUpdate ?? cache?.autoUpdate,
401
+ lastUpdated: cache?.lastUpdated,
402
+ isBuiltin: name === MarketplaceService.BUILTIN_MARKETPLACE.name,
403
+ declaredScope,
404
+ };
405
+ }
406
+
407
+ /**
408
+ * Finds which scope declared a marketplace (user, project, local, or builtin)
409
+ */
410
+ getMarketplaceDeclaringSource(name: string): Scope | "builtin" | null {
411
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name) return "builtin";
412
+
413
+ const scopes: Scope[] = ["local", "project", "user"];
414
+ for (const scope of scopes) {
415
+ const scoped = this.configurationService.getScopedMarketplaces(
416
+ this.workdir,
417
+ scope,
418
+ );
419
+ if (scoped[name]) return scope;
420
+ }
421
+ return null;
422
+ }
423
+
271
424
  /**
272
425
  * Adds a new marketplace (local directory, GitHub repo, or Git URL)
273
426
  */
274
- async addMarketplace(input: string): Promise<KnownMarketplace> {
427
+ async addMarketplace(
428
+ input: string,
429
+ scope: Scope = "user",
430
+ ): Promise<KnownMarketplace> {
275
431
  return this.withLock(async () => {
276
432
  let marketplace: KnownMarketplace;
277
433
 
@@ -287,7 +443,6 @@ export class MarketplaceService {
287
443
  !path.isAbsolute(input) &&
288
444
  !input.startsWith("."))
289
445
  ) {
290
- // Git or GitHub repo
291
446
  let urlOrRepo = input;
292
447
  let ref: string | undefined;
293
448
 
@@ -295,11 +450,11 @@ export class MarketplaceService {
295
450
  [urlOrRepo, ref] = input.split("#");
296
451
  }
297
452
 
298
- const tempMarketplace: KnownMarketplace = isFullUrl
299
- ? { name: "", source: { source: "git", url: urlOrRepo, ref } }
300
- : { name: "", source: { source: "github", repo: urlOrRepo, ref } };
453
+ const tempSource: MarketplaceSource = isFullUrl
454
+ ? { source: "git", url: urlOrRepo, ref }
455
+ : { source: "github", repo: urlOrRepo, ref };
301
456
 
302
- const targetPath = this.getMarketplacePath(tempMarketplace);
457
+ const targetPath = this.getMarketplacePath(tempSource);
303
458
 
304
459
  if (!existsSync(targetPath)) {
305
460
  try {
@@ -329,7 +484,6 @@ export class MarketplaceService {
329
484
  lastUpdated: new Date().toISOString(),
330
485
  };
331
486
  } else {
332
- // Local directory format
333
487
  const absolutePath = path.resolve(input);
334
488
  let manifest: MarketplaceManifest;
335
489
  try {
@@ -348,54 +502,97 @@ export class MarketplaceService {
348
502
  };
349
503
  }
350
504
 
351
- const registry = await this.getKnownMarketplaces();
505
+ const config: MarketplaceConfig = {
506
+ source: marketplace.source,
507
+ autoUpdate: marketplace.autoUpdate,
508
+ };
352
509
 
353
- // Check if already exists
354
- const existingIndex = registry.marketplaces.findIndex(
355
- (m) => m.name === marketplace.name,
510
+ await this.configurationService.addMarketplaceToScope(
511
+ this.workdir,
512
+ scope,
513
+ marketplace.name,
514
+ config,
356
515
  );
357
- if (existingIndex >= 0) {
358
- registry.marketplaces[existingIndex] = marketplace;
359
- } else {
360
- registry.marketplaces.push(marketplace);
361
- }
362
516
 
363
- // Ensure builtin is included if we are creating the file for the first time
364
- // and it hasn't been explicitly removed yet.
365
- // (getKnownMarketplaces already handles the default injection)
517
+ // Update cache with metadata
518
+ await this.updateCacheMarketplace(marketplace.name, {
519
+ source: marketplace.source,
520
+ autoUpdate: marketplace.autoUpdate,
521
+ lastUpdated: marketplace.lastUpdated,
522
+ });
366
523
 
367
- await this.saveKnownMarketplaces(registry);
368
524
  return marketplace;
369
525
  });
370
526
  }
371
527
 
372
528
  /**
373
- * Lists all registered marketplaces
529
+ * Lists all registered marketplaces by combining scoped settings + built-in
374
530
  */
375
531
  async listMarketplaces(): Promise<KnownMarketplace[]> {
376
- const registry = await this.getKnownMarketplaces();
377
- return registry.marketplaces.map((m) => ({
378
- ...m,
379
- isBuiltin: m.name === MarketplaceService.BUILTIN_MARKETPLACE.name,
380
- }));
532
+ const scopedMarketplaces = this.configurationService.getMergedMarketplaces(
533
+ this.workdir,
534
+ );
535
+ const cacheRegistry = await this.getCacheRegistry();
536
+ const cacheMap = new Map(
537
+ (cacheRegistry?.marketplaces ?? []).map((m) => [m.name, m]),
538
+ );
539
+
540
+ const result: KnownMarketplace[] = [];
541
+
542
+ // Add built-in marketplace
543
+ result.push({
544
+ ...MarketplaceService.BUILTIN_MARKETPLACE,
545
+ isBuiltin: true,
546
+ declaredScope: "builtin",
547
+ });
548
+
549
+ // Add all scoped marketplaces (local overrides project overrides user)
550
+ for (const [name, config] of Object.entries(scopedMarketplaces)) {
551
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name) continue;
552
+ const cache = cacheMap.get(name);
553
+ const declaredScope = this.getMarketplaceDeclaringSource(name) as
554
+ | "user"
555
+ | "project"
556
+ | "local";
557
+ result.push(
558
+ await this.buildMarketplaceEntry(name, config, cache, declaredScope),
559
+ );
560
+ }
561
+
562
+ // Add cache entries not yet in scoped settings (backwards compatibility)
563
+ for (const [name, cache] of cacheMap.entries()) {
564
+ if (name === MarketplaceService.BUILTIN_MARKETPLACE.name) continue;
565
+ if (scopedMarketplaces[name]) continue;
566
+ result.push({
567
+ ...cache,
568
+ isBuiltin: false,
569
+ declaredScope: cache.declaredScope ?? "user",
570
+ });
571
+ }
572
+
573
+ return result;
381
574
  }
382
575
 
383
576
  /**
384
- * Removes a marketplace by name
577
+ * Removes a marketplace by name from the specified scope
385
578
  */
386
- async removeMarketplace(name: string): Promise<void> {
579
+ async removeMarketplace(name: string, scope?: Scope): Promise<void> {
387
580
  return this.withLock(async () => {
388
- const registry = await this.getKnownMarketplaces();
389
- const initialCount = registry.marketplaces.length;
390
- registry.marketplaces = registry.marketplaces.filter(
391
- (m) => m.name !== name,
392
- );
581
+ const targetScope =
582
+ scope || this.getMarketplaceDeclaringSource(name) || "user";
393
583
 
394
- if (registry.marketplaces.length === initialCount) {
395
- throw new Error(`Marketplace ${name} not found`);
584
+ if (targetScope === "builtin") {
585
+ throw new Error("Cannot remove built-in marketplace");
396
586
  }
397
587
 
398
- await this.saveKnownMarketplaces(registry);
588
+ await this.configurationService.removeMarketplaceFromScope(
589
+ this.workdir,
590
+ targetScope,
591
+ name,
592
+ );
593
+
594
+ // Also remove from cache
595
+ await this.removeFromCache(name);
399
596
  });
400
597
  }
401
598
 
@@ -407,10 +604,10 @@ export class MarketplaceService {
407
604
  options?: { updatePlugins?: boolean },
408
605
  ): Promise<void> {
409
606
  return this.withLock(async () => {
410
- const registry = await this.getKnownMarketplaces();
607
+ const marketplaces = await this.listMarketplaces();
411
608
  const toUpdate = name
412
- ? registry.marketplaces.filter((m) => m.name === name)
413
- : registry.marketplaces;
609
+ ? marketplaces.filter((m) => m.name === name)
610
+ : marketplaces;
414
611
 
415
612
  if (name && toUpdate.length === 0) {
416
613
  throw new Error(`Marketplace ${name} not found`);
@@ -430,7 +627,7 @@ export class MarketplaceService {
430
627
  );
431
628
  continue;
432
629
  }
433
- const targetPath = this.getMarketplacePath(marketplace);
630
+ const targetPath = this.getMarketplacePath(marketplace.source);
434
631
  if (existsSync(targetPath)) {
435
632
  await this.gitService.pull(targetPath);
436
633
  } else {
@@ -447,13 +644,17 @@ export class MarketplaceService {
447
644
  );
448
645
  }
449
646
  }
450
- // For directory source, we just re-validate the manifest
451
647
  const manifest = await this.loadMarketplaceManifest(
452
- this.getMarketplacePath(marketplace),
648
+ this.getMarketplacePath(marketplace.source),
453
649
  );
454
650
 
455
651
  marketplace.lastUpdated = new Date().toISOString();
456
652
 
653
+ // Update cache metadata
654
+ await this.updateCacheMarketplace(marketplace.name, {
655
+ lastUpdated: marketplace.lastUpdated,
656
+ });
657
+
457
658
  if (options?.updatePlugins) {
458
659
  const installedRegistry = await this.getInstalledPlugins();
459
660
  const pluginsToUpdate = installedRegistry.plugins.filter(
@@ -505,8 +706,6 @@ export class MarketplaceService {
505
706
  `Some marketplaces failed to update:\n${errors.join("\n")}`,
506
707
  );
507
708
  }
508
-
509
- await this.saveKnownMarketplaces(registry);
510
709
  });
511
710
  }
512
711
 
@@ -515,17 +714,20 @@ export class MarketplaceService {
515
714
  */
516
715
  async autoUpdateAll(): Promise<void> {
517
716
  return this.withLock(async () => {
518
- const registry = await this.getKnownMarketplaces();
519
- const toAutoUpdate = registry.marketplaces.filter((m) => m.autoUpdate);
717
+ const scopedMarketplaces =
718
+ this.configurationService.getMergedMarketplaces(this.workdir);
719
+ const toAutoUpdate = Object.entries(scopedMarketplaces)
720
+ .filter(([, config]) => config.autoUpdate)
721
+ .map(([name]) => name);
520
722
 
521
- for (const marketplace of toAutoUpdate) {
723
+ for (const marketplaceName of toAutoUpdate) {
522
724
  try {
523
- await this.updateMarketplace(marketplace.name, {
725
+ await this.updateMarketplace(marketplaceName, {
524
726
  updatePlugins: true,
525
727
  });
526
728
  } catch (error) {
527
729
  console.error(
528
- `Auto-update failed for marketplace "${marketplace.name}":`,
730
+ `Auto-update failed for marketplace "${marketplaceName}":`,
529
731
  error,
530
732
  );
531
733
  }
@@ -538,16 +740,101 @@ export class MarketplaceService {
538
740
  */
539
741
  async toggleAutoUpdate(name: string, enabled: boolean): Promise<void> {
540
742
  return this.withLock(async () => {
541
- const registry = await this.getKnownMarketplaces();
542
- const marketplace = registry.marketplaces.find((m) => m.name === name);
543
- if (!marketplace) {
743
+ const declaringSource = this.getMarketplaceDeclaringSource(name);
744
+ if (!declaringSource || declaringSource === "builtin") {
544
745
  throw new Error(`Marketplace ${name} not found`);
545
746
  }
546
- marketplace.autoUpdate = enabled;
547
- await this.saveKnownMarketplaces(registry);
747
+
748
+ const scoped = this.configurationService.getScopedMarketplaces(
749
+ this.workdir,
750
+ declaringSource,
751
+ );
752
+ const config = scoped[name];
753
+ if (!config) {
754
+ throw new Error(`Marketplace ${name} not found`);
755
+ }
756
+
757
+ config.autoUpdate = enabled;
758
+ await this.configurationService.addMarketplaceToScope(
759
+ this.workdir,
760
+ declaringSource,
761
+ name,
762
+ config,
763
+ );
764
+
765
+ // Also update cache
766
+ await this.updateCacheMarketplace(name, { autoUpdate: enabled });
548
767
  });
549
768
  }
550
769
 
770
+ /**
771
+ * Resolves a plugin source into a consistent format for installation.
772
+ * Handles both string sources (local paths or git URLs) and object-style MarketplaceSource.
773
+ */
774
+ private resolvePluginSource(
775
+ pluginEntry: MarketplacePluginEntry,
776
+ marketplacePath: string,
777
+ ): { isGit: boolean; url?: string; ref?: string; localPath: string } {
778
+ const { source } = pluginEntry;
779
+
780
+ if (typeof source === "string") {
781
+ // String source: could be a git URL or a relative local path
782
+ const isGitUrl =
783
+ source.startsWith("http://") ||
784
+ source.startsWith("https://") ||
785
+ source.startsWith("git@") ||
786
+ source.startsWith("ssh://");
787
+
788
+ if (isGitUrl) {
789
+ let url = source;
790
+ let ref: string | undefined;
791
+ if (url.includes("#")) {
792
+ [url, ref] = url.split("#");
793
+ }
794
+ return { isGit: true, url, ref, localPath: "" };
795
+ }
796
+
797
+ // Relative local path
798
+ return { isGit: false, localPath: path.resolve(marketplacePath, source) };
799
+ }
800
+
801
+ // Object-style source
802
+ if (source.source === "git") {
803
+ return { isGit: true, url: source.url, ref: source.ref, localPath: "" };
804
+ }
805
+
806
+ if (source.source === "github") {
807
+ return {
808
+ isGit: true,
809
+ url: `https://github.com/${source.repo}.git`,
810
+ ref: source.ref,
811
+ localPath: "",
812
+ };
813
+ }
814
+
815
+ if (source.source === "url") {
816
+ let url = source.url;
817
+ let ref = source.ref;
818
+ if (url.includes("#")) {
819
+ [url, ref] = url.split("#");
820
+ }
821
+ return { isGit: true, url, ref, localPath: "" };
822
+ }
823
+
824
+ if (source.source === "directory") {
825
+ return {
826
+ isGit: false,
827
+ localPath: path.resolve(marketplacePath, source.path),
828
+ };
829
+ }
830
+
831
+ // Exhaustiveness: this should be unreachable given the union type
832
+ const _exhaustive: never = source;
833
+ throw new Error(
834
+ `Unsupported plugin source type: ${(_exhaustive as { source: string }).source}`,
835
+ );
836
+ }
837
+
551
838
  /**
552
839
  * Installs a plugin from a marketplace
553
840
  */
@@ -567,7 +854,7 @@ export class MarketplaceService {
567
854
  throw new Error(`Marketplace ${marketplaceName} not found`);
568
855
  }
569
856
 
570
- const marketplacePath = this.getMarketplacePath(marketplace);
857
+ const marketplacePath = this.getMarketplacePath(marketplace.source);
571
858
  const manifest = await this.loadMarketplaceManifest(marketplacePath);
572
859
  const pluginEntry = manifest.plugins.find((p) => p.name === pluginName);
573
860
  if (!pluginEntry) {
@@ -576,36 +863,46 @@ export class MarketplaceService {
576
863
  );
577
864
  }
578
865
 
579
- const isGitSource =
580
- pluginEntry.source.startsWith("http://") ||
581
- pluginEntry.source.startsWith("https://") ||
582
- pluginEntry.source.startsWith("git@") ||
583
- pluginEntry.source.startsWith("ssh://");
866
+ const resolved = this.resolvePluginSource(pluginEntry, marketplacePath);
584
867
 
585
868
  let pluginSrcPath: string;
586
869
  let tempCloneDir: string | undefined;
587
870
 
588
871
  try {
589
- if (isGitSource) {
872
+ if (resolved.isGit) {
590
873
  tempCloneDir = path.join(this.tmpDir, `clone-${Date.now()}`);
591
- let url = pluginEntry.source;
592
- let ref: string | undefined;
593
- if (url.includes("#")) {
594
- [url, ref] = url.split("#");
595
- }
596
- await this.gitService.clone(url, tempCloneDir, ref);
874
+ await this.gitService.clone(
875
+ resolved.url!,
876
+ tempCloneDir,
877
+ resolved.ref,
878
+ );
597
879
  pluginSrcPath = tempCloneDir;
598
880
  } else {
599
- pluginSrcPath = path.resolve(marketplacePath, pluginEntry.source);
881
+ pluginSrcPath = resolved.localPath;
600
882
  }
601
883
 
602
- const pluginManifestPath = path.join(
884
+ let pluginManifestPath: string | undefined;
885
+ const wavePluginPath = path.join(
603
886
  pluginSrcPath,
604
887
  ".wave-plugin",
605
888
  "plugin.json",
606
889
  );
607
- if (!existsSync(pluginManifestPath)) {
608
- throw new Error(`Plugin manifest not found at ${pluginManifestPath}`);
890
+ const claudePluginPath = path.join(
891
+ pluginSrcPath,
892
+ ".claude-plugin",
893
+ "plugin.json",
894
+ );
895
+
896
+ if (existsSync(wavePluginPath)) {
897
+ pluginManifestPath = wavePluginPath;
898
+ } else if (existsSync(claudePluginPath)) {
899
+ pluginManifestPath = claudePluginPath;
900
+ }
901
+
902
+ if (!pluginManifestPath) {
903
+ throw new Error(
904
+ `Plugin manifest not found at ${pluginSrcPath}. Neither .wave-plugin/plugin.json nor .claude-plugin/plugin.json exists.`,
905
+ );
609
906
  }
610
907
 
611
908
  const pluginManifestContent = await fs.readFile(
@@ -615,15 +912,14 @@ export class MarketplaceService {
615
912
  const pluginManifest = JSON.parse(pluginManifestContent);
616
913
  const version = pluginManifest.version || "1.0.0";
617
914
 
618
- // Atomic installation
619
915
  const tmpPluginDir = path.join(
620
916
  this.tmpDir,
621
917
  `${pluginName}-${Date.now()}`,
622
918
  );
623
919
  try {
624
- if (isGitSource) {
920
+ if (resolved.isGit) {
625
921
  await fs.rename(pluginSrcPath, tmpPluginDir);
626
- tempCloneDir = undefined; // Already moved
922
+ tempCloneDir = undefined;
627
923
  } else {
628
924
  await fs.cp(pluginSrcPath, tmpPluginDir, { recursive: true });
629
925
  }
@@ -665,14 +961,12 @@ export class MarketplaceService {
665
961
  await this.saveInstalledPlugins(installedRegistry);
666
962
  return installedPlugin;
667
963
  } catch (error) {
668
- // Cleanup tmp dir if it exists
669
964
  if (existsSync(tmpPluginDir)) {
670
965
  await fs.rm(tmpPluginDir, { recursive: true, force: true });
671
966
  }
672
967
  throw error;
673
968
  }
674
969
  } catch (error) {
675
- // Cleanup temp clone dir if it exists
676
970
  if (tempCloneDir && existsSync(tempCloneDir)) {
677
971
  await fs.rm(tempCloneDir, { recursive: true, force: true });
678
972
  }
@@ -712,16 +1006,13 @@ export class MarketplaceService {
712
1006
 
713
1007
  const pluginToRemove = installedRegistry.plugins[pluginIndex];
714
1008
 
715
- // Remove from registry first
716
1009
  installedRegistry.plugins.splice(pluginIndex, 1);
717
1010
  await this.saveInstalledPlugins(installedRegistry);
718
1011
 
719
- // Check if any other project is still using this same cache path
720
1012
  const isStillReferenced = installedRegistry.plugins.some(
721
1013
  (p) => p.cachePath === pluginToRemove.cachePath,
722
1014
  );
723
1015
 
724
- // Only remove cached files if no other references exist
725
1016
  if (!isStillReferenced && existsSync(pluginToRemove.cachePath)) {
726
1017
  await fs.rm(pluginToRemove.cachePath, { recursive: true, force: true });
727
1018
  }