@aexol/opencode-wizard 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,10 @@ Call `opencode_wizard_published_skills_fetch` without `skill` or `skills` to man
52
52
 
53
53
  No-arg discovery returns published skill summaries, assignment counts split into `global`, `project`, `user`, and `other`, policy metadata, and no markdown bodies. Existing `skill` and `skills` calls still fetch one or more full skill body/detail payloads by slug, artifact name, or skill name.
54
54
 
55
+ Workspace delivery still follows the backend contract: the plugin sends `workspaceSlug` when it has one, otherwise falls back to `repositoryUrl`. The plugin now prefers configured or learned workspace slug mappings over a repo-basename fallback, and learns durable slug-to-repo mappings from successful backend catalog responses so worktrees and nontrivial repo roots stay aligned.
56
+
57
+ Published skill fetches still support `refresh: true`, but normal cache entries now self-expire after 30 seconds and fetch/status payloads surface `source`, `workspaceSlug`, and `workspaceSlugSource` so stale-vs-refreshed behavior is visible without relying on manual cache deletion.
58
+
55
59
  Use `opencode_wizard_published_skill_preference_set` for non-TUI preference actions (`install`, `uninstall`, `ignore`, `unignore`) against the same server-backed preference API used by the TUI overlay.
56
60
 
57
61
  `GLOBAL_CONTEXT` skills are active context skills and are not meant to be installed per project. `PROJECT_INSTALLABLE` skills are gallery/installable skills that may be attached globally or to a workspace/path through assignment records; those assignments remain the source of truth for what is active in a catalog response.
package/dist/server.d.ts CHANGED
@@ -5,6 +5,7 @@ type ResolvedConfig = {
5
5
  authSessionUrl: string;
6
6
  presenceUrl: string;
7
7
  actionsUrl: string;
8
+ configuredWorkspaceSlug: string | null;
8
9
  fallbackWorkspaceSlug: string;
9
10
  rootSkillSeedPath: string;
10
11
  authStatePath: string;
@@ -13,6 +14,8 @@ type WorkspaceResolution = {
13
14
  requestedDirectory: string;
14
15
  repositoryRoot: string;
15
16
  repositoryUrl: string | null;
17
+ workspaceSlug?: string | null;
18
+ workspaceSlugSource?: 'configured' | 'learned' | 'backend' | 'fallback' | 'repositoryUrl' | 'placeholder';
16
19
  fallbackWorkspaceSlug: string | null;
17
20
  directoryPath: string;
18
21
  cacheKey: string;
@@ -271,6 +274,8 @@ declare const toWorkspaceResolutionOutput: (resolution: WorkspaceResolution) =>
271
274
  requestedDirectory: string;
272
275
  repositoryRoot: string;
273
276
  repositoryUrl: string | null;
277
+ workspaceSlug: string | null | undefined;
278
+ workspaceSlugSource: "configured" | "learned" | "backend" | "fallback" | "repositoryUrl" | "placeholder" | undefined;
274
279
  fallbackWorkspaceSlug: string | null;
275
280
  directoryPath: string;
276
281
  };
package/dist/server.js CHANGED
@@ -14,6 +14,7 @@ const MODULE_FILE_PATH = fileURLToPath(import.meta.url);
14
14
  const PACKAGE_ROOT_PATH = path.resolve(path.dirname(MODULE_FILE_PATH), '..');
15
15
  export const PLUGIN_ID = 'opencode-wizard';
16
16
  const CACHE_TTL_MS = 30_000;
17
+ const WORKSPACE_MAPPING_LIMIT = 100;
17
18
  const ROOT_SKILL_SEED_PATH = '.opencode/skills';
18
19
  const GLOBAL_CONFIG_PATH = path.join(os.homedir(), '.config', 'opencode', 'opencode-wizard.json');
19
20
  const LEGACY_AUTH_STATE_PATH = 'plugin/opencode-wizard/.generated/auth-state.json';
@@ -330,14 +331,19 @@ const resolveBackendOrigin = async worktree => {
330
331
  localBackendOrigin: envValues.get('OPENCODE_WIZARD_BACKEND_ORIGIN')
331
332
  });
332
333
  };
334
+ const readConfiguredWorkspaceSlug = () => {
335
+ const configuredWorkspaceSlug = process.env.OPENCODE_WIZARD_SKILLS_WORKSPACE_SLUG?.trim();
336
+ if (!configuredWorkspaceSlug) return null;
337
+ return toWorkspaceSlug(configuredWorkspaceSlug);
338
+ };
333
339
  const toWorkspaceSlug = value => {
334
340
  const normalized = value.trim().toLowerCase().replace(/[^a-z0-9-]+/gu, '-').replace(/^-+|-+$/gu, '');
335
341
  if (normalized) return normalized;
336
342
  return 'workspace';
337
343
  };
338
344
  const resolveFallbackWorkspaceSlug = worktree => {
339
- const configuredWorkspaceSlug = process.env.OPENCODE_WIZARD_SKILLS_WORKSPACE_SLUG?.trim();
340
- if (configuredWorkspaceSlug) return toWorkspaceSlug(configuredWorkspaceSlug);
345
+ const configuredWorkspaceSlug = readConfiguredWorkspaceSlug();
346
+ if (configuredWorkspaceSlug) return configuredWorkspaceSlug;
341
347
  return toWorkspaceSlug(path.basename(path.resolve(worktree)));
342
348
  };
343
349
  export const resolveConfig = async worktree => {
@@ -348,6 +354,7 @@ export const resolveConfig = async worktree => {
348
354
  authSessionUrl: `${backendOrigin}/api/opencode-plugin/oauth/session`,
349
355
  presenceUrl: `${backendOrigin}/api/opencode-plugin/presence`,
350
356
  actionsUrl: `${backendOrigin}/api/opencode-plugin/actions`,
357
+ configuredWorkspaceSlug: readConfiguredWorkspaceSlug(),
351
358
  fallbackWorkspaceSlug: resolveFallbackWorkspaceSlug(worktree),
352
359
  rootSkillSeedPath: ROOT_SKILL_SEED_PATH,
353
360
  authStatePath: GLOBAL_CONFIG_PATH
@@ -408,22 +415,31 @@ const resolveWorkspace = async ({
408
415
  const gitRoot = await resolveGitRoot(requestedDirectory);
409
416
  const repositoryRoot = gitRoot ?? requestedDirectory;
410
417
  const repositoryUrl = gitRoot ? await resolveGitRemoteOriginUrl(gitRoot) : null;
418
+ const learnedWorkspaceMapping = await findWorkspaceSlugMapping({
419
+ configFile: config.authStatePath,
420
+ repositoryUrl,
421
+ repositoryRoot
422
+ });
411
423
  const fallbackWorkspaceSlug = config.fallbackWorkspaceSlug;
412
424
  const directoryPath = normalizeRepositoryPath(repositoryRoot, requestedDirectory);
413
- const workspaceIdentity = `workspaceSlug:${fallbackWorkspaceSlug}`;
425
+ const workspaceSlug = config.configuredWorkspaceSlug ?? learnedWorkspaceMapping?.workspaceSlug ?? fallbackWorkspaceSlug ?? null;
426
+ const workspaceSlugSource = config.configuredWorkspaceSlug ? 'configured' : learnedWorkspaceMapping?.workspaceSlug ? 'learned' : fallbackWorkspaceSlug ? 'fallback' : repositoryUrl ? 'repositoryUrl' : 'placeholder';
427
+ const workspaceIdentity = workspaceSlug ? `workspaceSlug:${workspaceSlug}` : repositoryUrl ? `repository:${repositoryUrl}` : 'workspace:placeholder';
414
428
  return {
415
429
  requestedDirectory,
416
430
  repositoryRoot,
417
431
  repositoryUrl,
432
+ workspaceSlug,
433
+ workspaceSlugSource,
418
434
  fallbackWorkspaceSlug,
419
435
  directoryPath,
420
436
  cacheKey: JSON.stringify([workspaceIdentity, directoryPath])
421
437
  };
422
438
  };
423
439
  const toDeliveryInput = resolution => {
424
- if (resolution.fallbackWorkspaceSlug) {
440
+ if (resolution.workspaceSlug) {
425
441
  return {
426
- workspaceSlug: resolution.fallbackWorkspaceSlug,
442
+ workspaceSlug: resolution.workspaceSlug,
427
443
  directoryPath: resolution.directoryPath
428
444
  };
429
445
  }
@@ -475,6 +491,76 @@ const withoutLegacyPublishedSkillPreferences = config => {
475
491
  const hasLegacyPublishedSkillPreferences = config => {
476
492
  return Object.prototype.hasOwnProperty.call(config, 'publishedSkillPreferences') || Object.prototype.hasOwnProperty.call(config, 'ignoredPublishedSkills');
477
493
  };
494
+ const isStoredWorkspaceSlugMapping = value => {
495
+ if (!isRecord(value)) return false;
496
+ const {
497
+ repositoryUrl,
498
+ repositoryRoot,
499
+ workspaceSlug,
500
+ updatedAt
501
+ } = value;
502
+ const hasValidRepositoryUrl = repositoryUrl === null || typeof repositoryUrl === 'string';
503
+ const hasValidRepositoryRoot = repositoryRoot === null || typeof repositoryRoot === 'string';
504
+ return hasValidRepositoryUrl && hasValidRepositoryRoot && typeof workspaceSlug === 'string' && workspaceSlug.trim().length > 0 && isValidIsoDateString(updatedAt);
505
+ };
506
+ const readWorkspaceSlugMappings = async configFile => {
507
+ const storedConfig = await readGlobalConfig(configFile);
508
+ const mappings = storedConfig.workspaceSlugMappings;
509
+ if (!Array.isArray(mappings)) return [];
510
+ return mappings.filter(isStoredWorkspaceSlugMapping).slice(0, WORKSPACE_MAPPING_LIMIT);
511
+ };
512
+ const writeWorkspaceSlugMappings = async (configFile, nextMappings) => {
513
+ const storedConfig = await readGlobalConfig(configFile);
514
+ await writeGlobalConfig(configFile, {
515
+ ...withoutLegacyPublishedSkillPreferences(storedConfig),
516
+ workspaceSlugMappings: nextMappings.slice(0, WORKSPACE_MAPPING_LIMIT)
517
+ });
518
+ };
519
+ const normalizeStoredRepositoryRoot = value => {
520
+ if (!value) return null;
521
+ return normalizeAbsolutePath(value);
522
+ };
523
+ const upsertWorkspaceSlugMapping = async ({
524
+ configFile,
525
+ repositoryUrl,
526
+ repositoryRoot,
527
+ workspaceSlug
528
+ }) => {
529
+ const normalizedWorkspaceSlug = toWorkspaceSlug(workspaceSlug);
530
+ if (!normalizedWorkspaceSlug) return;
531
+ const normalizedRepositoryRoot = normalizeStoredRepositoryRoot(repositoryRoot);
532
+ const existingMappings = await readWorkspaceSlugMappings(configFile);
533
+ const filteredMappings = existingMappings.filter(mapping => {
534
+ if (repositoryUrl && mapping.repositoryUrl === repositoryUrl) return false;
535
+ if (normalizedRepositoryRoot && normalizeStoredRepositoryRoot(mapping.repositoryRoot) === normalizedRepositoryRoot) {
536
+ return false;
537
+ }
538
+ return true;
539
+ });
540
+ await writeWorkspaceSlugMappings(configFile, [{
541
+ repositoryUrl,
542
+ repositoryRoot: normalizedRepositoryRoot,
543
+ workspaceSlug: normalizedWorkspaceSlug,
544
+ updatedAt: new Date().toISOString()
545
+ }, ...filteredMappings]);
546
+ };
547
+ const findWorkspaceSlugMapping = async ({
548
+ configFile,
549
+ repositoryUrl,
550
+ repositoryRoot
551
+ }) => {
552
+ const normalizedRepositoryRoot = normalizeStoredRepositoryRoot(repositoryRoot);
553
+ const mappings = await readWorkspaceSlugMappings(configFile);
554
+ if (repositoryUrl) {
555
+ const repositoryMatch = mappings.find(mapping => mapping.repositoryUrl === repositoryUrl);
556
+ if (repositoryMatch) return repositoryMatch;
557
+ }
558
+ if (normalizedRepositoryRoot) {
559
+ const rootMatch = mappings.find(mapping => normalizeStoredRepositoryRoot(mapping.repositoryRoot) === normalizedRepositoryRoot);
560
+ if (rootMatch) return rootMatch;
561
+ }
562
+ return null;
563
+ };
478
564
  const readGlobalAuthState = async configFile => {
479
565
  const storedConfig = await readGlobalConfig(configFile);
480
566
  const storedAuthState = storedConfig.auth;
@@ -531,7 +617,7 @@ const toPublishedSkillPreferenceScope = (value, defaultScope) => {
531
617
  throw new Error('Published skill preferenceScope must be either global or project.');
532
618
  };
533
619
  const getPublishedSkillIgnoreScopeKey = (resolution, payload) => {
534
- const workspaceSlug = payload?.workspace?.slug ?? resolution.fallbackWorkspaceSlug;
620
+ const workspaceSlug = payload?.workspace?.slug ?? resolution.workspaceSlug ?? resolution.fallbackWorkspaceSlug;
535
621
  if (workspaceSlug) return `workspace:${toWorkspaceSlug(workspaceSlug)}`;
536
622
  if (resolution.repositoryUrl) return `repository:${resolution.repositoryUrl}`;
537
623
  return `path:${toWorkspaceSlug(path.basename(resolution.repositoryRoot))}`;
@@ -879,6 +965,8 @@ const toWorkspaceResolutionOutput = resolution => ({
879
965
  requestedDirectory: resolution.requestedDirectory,
880
966
  repositoryRoot: resolution.repositoryRoot,
881
967
  repositoryUrl: resolution.repositoryUrl,
968
+ workspaceSlug: resolution.workspaceSlug,
969
+ workspaceSlugSource: resolution.workspaceSlugSource,
882
970
  fallbackWorkspaceSlug: resolution.fallbackWorkspaceSlug,
883
971
  directoryPath: resolution.directoryPath
884
972
  });
@@ -886,6 +974,8 @@ const toWorkspaceResolutionMetadata = resolution => ({
886
974
  directoryPath: resolution.directoryPath,
887
975
  repositoryRoot: resolution.repositoryRoot,
888
976
  repositoryUrl: resolution.repositoryUrl ?? '',
977
+ workspaceSlug: resolution.workspaceSlug ?? '',
978
+ workspaceSlugSource: resolution.workspaceSlugSource ?? 'placeholder',
889
979
  fallbackWorkspaceSlug: resolution.fallbackWorkspaceSlug ?? ''
890
980
  });
891
981
  const formatStatusOutput = async (worktree, config, publishedSkillsResult, loginBootstrapSnapshot) => {
@@ -1183,7 +1273,12 @@ const toPluginStatusMetadata = snapshot => ({
1183
1273
  pluginStatus: snapshot.status,
1184
1274
  authStatus: snapshot.authState.status,
1185
1275
  authEmail: snapshot.authState.email ?? '',
1186
- authUserId: snapshot.authState.userId ?? ''
1276
+ authUserId: snapshot.authState.userId ?? '',
1277
+ directoryPath: snapshot.workspaceResolution.directoryPath,
1278
+ repositoryUrl: snapshot.workspaceResolution.repositoryUrl ?? '',
1279
+ source: snapshot.source,
1280
+ workspaceSlug: snapshot.workspaceResolution.workspaceSlug ?? '',
1281
+ workspaceSlugSource: snapshot.workspaceResolution.workspaceSlugSource ?? 'placeholder'
1187
1282
  });
1188
1283
  const isUnauthorizedGraphQlMessage = message => {
1189
1284
  const normalizedMessage = message.toLowerCase();
@@ -1504,6 +1599,24 @@ const fetchPublishedSkillsCatalog = async (worktree, config, resolution, signal,
1504
1599
  source: 'network'
1505
1600
  };
1506
1601
  };
1602
+ const maybePersistWorkspaceSlugFromCatalog = async ({
1603
+ config,
1604
+ resolution,
1605
+ fetchResult
1606
+ }) => {
1607
+ if (!fetchResult.ok) return;
1608
+ const backendWorkspaceSlug = fetchResult.payload.workspace?.slug?.trim();
1609
+ if (!backendWorkspaceSlug) return;
1610
+ const normalizedWorkspaceSlug = toWorkspaceSlug(backendWorkspaceSlug);
1611
+ if (!normalizedWorkspaceSlug) return;
1612
+ if (resolution.workspaceSlug === normalizedWorkspaceSlug && resolution.workspaceSlugSource !== 'fallback') return;
1613
+ await upsertWorkspaceSlugMapping({
1614
+ configFile: config.authStatePath,
1615
+ repositoryUrl: resolution.repositoryUrl,
1616
+ repositoryRoot: resolution.repositoryRoot,
1617
+ workspaceSlug: normalizedWorkspaceSlug
1618
+ });
1619
+ };
1507
1620
  const fetchPublishedSkillDetail = async ({
1508
1621
  worktree,
1509
1622
  config,
@@ -1941,6 +2054,11 @@ const OpencodeWizardSkillsPlugin = async input => {
1941
2054
  }
1942
2055
  const requestPromise = (async () => {
1943
2056
  const fetchResult = await fetchPublishedSkillsCatalog(input.worktree, config, workspaceResolution, signal, clearPublishedSkillState);
2057
+ await maybePersistWorkspaceSlugFromCatalog({
2058
+ config,
2059
+ resolution: workspaceResolution,
2060
+ fetchResult
2061
+ });
1944
2062
  cache.set(cacheKey, {
1945
2063
  result: fetchResult,
1946
2064
  expiresAt: Date.now() + CACHE_TTL_MS
@@ -1971,7 +2089,7 @@ const OpencodeWizardSkillsPlugin = async input => {
1971
2089
  const cacheKey = getDetailCacheKey(catalogCacheKey, item.skillVersion.id);
1972
2090
  const inflightKey = getDetailInflightKey(catalogCacheKey, item.skillVersion.id, purpose);
1973
2091
  const cached = detailCache.get(cacheKey);
1974
- if (purpose === 'SYSTEM_CONTEXT' && useCache && cached && cached.expiresAt > Date.now()) {
2092
+ if (useCache && cached && cached.expiresAt > Date.now()) {
1975
2093
  return {
1976
2094
  ok: true,
1977
2095
  detail: toPublishedSkillDetail({
@@ -2148,11 +2266,13 @@ const OpencodeWizardSkillsPlugin = async input => {
2148
2266
  },
2149
2267
  fetchedAt: filteredPublishedSkillsResult.fetchResult.fetchedAt,
2150
2268
  source: filteredPublishedSkillsResult.fetchResult.source,
2151
- message: 'Catalog discovery only. Provide `skill` for one identifier or prefer `skills` for comma/newline-separated multiple identifiers to fetch markdown bodies/details.'
2269
+ cacheTtlMs: CACHE_TTL_MS,
2270
+ message: args.refresh ? 'Catalog discovery refreshed from the backend. Provide `skill` for one identifier or prefer `skills` for comma/newline-separated multiple identifiers to fetch markdown bodies/details.' : 'Catalog discovery only. Cached results expire automatically after 30 seconds, or pass `refresh: true` to force a backend refresh immediately. Provide `skill` for one identifier or prefer `skills` for comma/newline-separated multiple identifiers to fetch markdown bodies/details.'
2152
2271
  }, null, 2),
2153
2272
  metadata: {
2154
2273
  status: 'ready',
2155
2274
  ...toWorkspaceResolutionMetadata(filteredPublishedSkillsResult.workspaceResolution),
2275
+ source: filteredPublishedSkillsResult.fetchResult.source,
2156
2276
  publishedSkillCount: catalog.publishedSkillCount.toString(),
2157
2277
  globalAssignmentCount: catalog.assignmentCounts.global.toString(),
2158
2278
  projectAssignmentCount: catalog.assignmentCounts.project.toString(),
@@ -2242,6 +2362,7 @@ const OpencodeWizardSkillsPlugin = async input => {
2242
2362
  metadata: {
2243
2363
  status: 'ready',
2244
2364
  ...toWorkspaceResolutionMetadata(filteredPublishedSkillsResult.workspaceResolution),
2365
+ source: filteredPublishedSkillsResult.fetchResult.source,
2245
2366
  skillSlug: detail.skillSlug
2246
2367
  }
2247
2368
  };
@@ -2271,6 +2392,7 @@ const OpencodeWizardSkillsPlugin = async input => {
2271
2392
  metadata: {
2272
2393
  status: selection.missingIdentifiers.length > 0 ? 'partial' : 'ready',
2273
2394
  ...toWorkspaceResolutionMetadata(filteredPublishedSkillsResult.workspaceResolution),
2395
+ source: filteredPublishedSkillsResult.fetchResult.source,
2274
2396
  matchedCount: skillDetails.length.toString()
2275
2397
  }
2276
2398
  };