diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index dd297535..af5f8b52 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -99,7 +99,7 @@ Shared resource editing: - reconciles the account instance after metadata is updated - Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync. - Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`. -- Plugin directories without README/PLUGIN docs render their actual directory inventory. +- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists. - Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values. ## Commands diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 69b9ae11..f3aa0966 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -69,6 +69,7 @@ accounts: work: created: "2026-02-24T00:00:00.000Z" last_used: null + shared_resource_mode: "shared" context_mode: "shared" context_group: "team-alpha" continuity_mode: "deeper" @@ -76,6 +77,7 @@ accounts: Rules: +- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`) - `context_mode` must be `isolated` or `shared` - `context_group` is required when `context_mode=shared` - `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`) diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 3ed44425..5cd449de 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -32,6 +32,8 @@ const MAX_DESCRIPTION_LENGTH = 140; const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB const SHARED_ITEMS_CACHE_TTL_MS = 1000; +const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:'; +const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']); type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins'; type SharedContentType = SharedCollectionType | 'settings'; @@ -49,6 +51,11 @@ interface SharedItemsCacheEntry { expiresAt: number; } +interface InstalledPluginRegistry { + version?: number; + plugins: Record; +} + const sharedItemsCache = new Map(); /** @@ -110,9 +117,9 @@ sharedRoutes.get('/content', (req: Request, res: Response) => { const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); const allowedRoots = typeParam === 'settings' - ? new Set([sharedDirRoot]) + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) : resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); - const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); + const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot); if (!contentResult) { res.status(404).json({ error: 'Shared content not found' }); @@ -135,10 +142,13 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared')); const resolvedSettingsPath = safeRealPath(settingsPath); + const settingsAllowedRoots = sharedDirRoot + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) + : new Set(); const hasSettings = Boolean(sharedDirRoot) && Boolean(resolvedSettingsPath) && - isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string); + isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots); res.json({ commands, @@ -180,6 +190,17 @@ function resolveAllowedRoots( ]); } +function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set { + const claudeConfigDir = getClaudeConfigDir(); + return new Set( + [ + sharedDirRoot, + safeRealPath(path.join(claudeConfigDir, 'settings.json')), + safeRealPath(path.join(ccsDir, '.claude', 'settings.json')), + ].filter((dirPath): dirPath is string => typeof dirPath === 'string') + ); +} + function getSharedItems(type: SharedCollectionType): SharedItem[] { const ccsDir = getCcsDir(); const sharedDir = path.join(ccsDir, 'shared', type); @@ -209,6 +230,16 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return commandItems; } + if (type === 'plugins') { + const pluginItems = getPluginItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: pluginItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return pluginItems; + } + try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); @@ -251,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return sortedItems; } +function getPluginItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registryItems = getPluginRegistryItems(sharedDir, allowedRoots); + const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots); + const itemsByPath = new Map(); + + for (const item of [...registryItems, ...legacyDirectoryItems]) { + itemsByPath.set(item.path, item); + } + + return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function getPluginRegistryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry) { + return []; + } + + return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({ + name: pluginName, + description: describeInstalledPlugin(pluginName, metadata), + path: encodePluginRegistryPath(pluginName), + type: 'plugin' as const, + })); +} + +function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(sharedDir, { withFileTypes: true }); + } catch { + return []; + } + + const items: SharedItem[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'README.md'), + path.join(resolvedEntryPath, 'readme.md'), + path.join(resolvedEntryPath, 'PLUGIN.md'), + ], + allowedRoots + ); + + if (!description) { + continue; + } + + items.push({ + name: entry.name, + description, + path: entryPath, + type: 'plugin', + }); + } + + return items; +} + function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); const items: SharedItem[] = []; @@ -279,35 +380,13 @@ function getCommandItems(sharedDir: string, allowedRoots: Set): SharedIt } function getSkillOrAgentItem( - type: 'skills' | 'agents' | 'plugins', + type: 'skills' | 'agents', entry: fs.Dirent, entryPath: string, resolvedEntryPath: string, allowedRoots: Set, stats: fs.Stats ): SharedItem | null { - if (type === 'plugins') { - if (!stats.isDirectory()) { - return null; - } - - const description = readFirstMarkdownDescription( - [ - path.join(resolvedEntryPath, 'README.md'), - path.join(resolvedEntryPath, 'readme.md'), - path.join(resolvedEntryPath, 'PLUGIN.md'), - ], - allowedRoots - ); - - return { - name: entry.name, - description: description ?? getDirectoryInventoryDescription(resolvedEntryPath, allowedRoots), - path: entryPath, - type: 'plugin', - }; - } - if (type === 'skills') { if (!stats.isDirectory()) { return null; @@ -555,6 +634,89 @@ function readMarkdownContent(markdownPath: string, allowedRoots: Set): s } } +function encodePluginRegistryPath(pluginName: string): string { + return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`; +} + +function decodePluginRegistryPath(itemPath: string): string | null { + if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) { + return null; + } + + try { + const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length); + const pluginName = decodeURIComponent(encodedName); + return pluginName.length > 0 ? pluginName : null; + } catch { + return null; + } +} + +function readJsonObject( + filePath: string, + allowedRoots: Set +): Record | null { + const resolvedPath = safeRealPath(filePath); + if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) { + return null; + } + + try { + const stats = fs.statSync(resolvedPath); + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return parsed as Record; + } catch { + return null; + } +} + +function readPluginInstalledRegistry( + sharedDir: string, + allowedRoots: Set +): InstalledPluginRegistry | null { + const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots); + if ( + !parsed || + !parsed.plugins || + typeof parsed.plugins !== 'object' || + Array.isArray(parsed.plugins) + ) { + return null; + } + + return { + version: typeof parsed.version === 'number' ? parsed.version : undefined, + plugins: parsed.plugins as Record, + }; +} + +function describeInstalledPlugin(pluginName: string, metadata: unknown): string { + const recordCount = Array.isArray(metadata) ? metadata.length : 1; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`; + + return marketplace + ? `Installed from ${marketplace}; ${recordLabel} in shared registry` + : `Installed plugin; ${recordLabel} in shared registry`; +} + +function extractMarketplaceFromPluginName(pluginName: string): string | null { + const atIndex = pluginName.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === pluginName.length - 1) { + return null; + } + + return pluginName.slice(atIndex + 1); +} + function resolveReadableMarkdownPath( markdownPaths: string[], allowedRoots: Set @@ -582,12 +744,20 @@ function resolveReadableMarkdownPath( function getSharedItemContent( type: SharedContentType, itemPath: string, - allowedRoots: Set + allowedRoots: Set, + sharedDir: string ): { content: string; contentPath: string } | null { if (type === 'settings') { return getSharedSettingsContent(itemPath, allowedRoots); } + if (type === 'plugins') { + const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots); + if (pluginRegistryContent) { + return pluginRegistryContent; + } + } + const resolvedItemPath = safeRealPath(itemPath); if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { return null; @@ -615,7 +785,7 @@ function getSharedItemContent( allowedRoots ); } else if (type === 'plugins') { - if (!itemStats.isDirectory()) { + if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) { return null; } @@ -629,10 +799,7 @@ function getSharedItemContent( ); if (!markdownPath) { - return { - content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots), - contentPath: resolvedItemPath, - }; + return null; } } else { if (itemStats.isDirectory()) { @@ -664,6 +831,106 @@ function getSharedItemContent( }; } +function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean { + for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) { + const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName)); + if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) { + return true; + } + } + + return false; +} + +function getPluginRegistryContent( + itemPath: string, + sharedDir: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + const pluginName = decodePluginRegistryPath(itemPath); + if (!pluginName) { + return null; + } + + const registryPath = path.join(sharedDir, 'installed_plugins.json'); + const resolvedRegistryPath = safeRealPath(registryPath); + if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) { + return null; + } + + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) { + return null; + } + + const metadata = registry.plugins[pluginName]; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const marketplaceEntry = marketplace + ? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace] + : null; + const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName); + const lines = [ + `# ${pluginName}`, + '', + `Registry: \`${resolvedRegistryPath}\``, + '', + '## Installed Plugin', + '', + `- Source: shared \`installed_plugins.json\` registry`, + `- Registry version: ${registry.version ?? 'unknown'}`, + `- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`, + `- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`, + ]; + + if (marketplaceEntry) { + lines.push( + '', + '## Marketplace Registry Entry', + '', + '```json', + stringifyJson(marketplaceEntry), + '```' + ); + } + + if (blocklistEntry) { + lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```'); + } + + lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```'); + + return { + content: lines.join('\n'), + contentPath: resolvedRegistryPath, + }; +} + +function findPluginBlocklistEntry( + sharedDir: string, + allowedRoots: Set, + pluginName: string +): unknown | null { + const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots); + const plugins = blocklist?.plugins; + if (!Array.isArray(plugins)) { + return null; + } + + return ( + plugins.find( + (entry) => + entry && + typeof entry === 'object' && + 'plugin' in entry && + (entry as { plugin?: unknown }).plugin === pluginName + ) ?? null + ); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2) ?? 'null'; +} + function safeRealPath(targetPath: string): string | null { try { return fs.realpathSync(targetPath); @@ -720,54 +987,6 @@ function getSharedSettingsContent( } } -function getDirectoryInventoryDescription( - directoryPath: string, - allowedRoots: Set -): string { - const entries = listDirectoryEntryNames(directoryPath, allowedRoots); - if (entries.length === 0) { - return 'Empty directory'; - } - - return trimDescription( - `Directory with ${entries.length} ${entries.length === 1 ? 'item' : 'items'}: ${entries.join(', ')}` - ); -} - -function renderPluginDirectoryContent( - displayPath: string, - resolvedPath: string, - allowedRoots: Set -): string { - const name = path.basename(displayPath); - const lines = [`# Plugin directory: ${name}`, '', `Path: \`${displayPath}\``]; - - const entries = listDirectoryEntryNames(resolvedPath, allowedRoots); - if (entries.length > 0) { - lines.push('', '## Directory contents', '', ...entries.map((entry) => `- ${entry}`)); - } else { - lines.push('', 'No files or folders found in this plugin directory.'); - } - - return lines.join('\n'); -} - -function listDirectoryEntryNames(directoryPath: string, allowedRoots: Set): string[] { - if (!isPathWithinAny(directoryPath, allowedRoots)) { - return []; - } - - try { - return fs - .readdirSync(directoryPath, { withFileTypes: true }) - .slice(0, 50) - .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`) - .sort((a, b) => a.localeCompare(b)); - } catch { - return []; - } -} - function isPathWithin(candidatePath: string, basePath: string): boolean { const normalizedCandidate = normalizeForPathComparison(candidatePath); const normalizedBase = normalizeForPathComparison(basePath); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 7f23c7e3..182b2063 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -285,11 +285,65 @@ describe('web-server shared-routes', () => { expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md'))); }); - it('returns factual directory content for a shared plugin directory without markdown', async () => { + it('hides plugin infrastructure directories when no plugins are installed', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); const cacheDir = path.join(pluginsDir, 'cache'); fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true }); fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}'); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const listPayload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/plugins'); + + expect(listPayload.items).toEqual([]); + + const contentResponse = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + ); + + expect(contentResponse.status).toBe(404); + const contentPayload = (await contentResponse.json()) as { error: string }; + expect(contentPayload.error).toBe('Shared content not found'); + }); + + it('lists installed plugins from the shared plugin registry', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify( + { + version: 2, + plugins: { + 'discord@claude-plugins-official': [ + { + installPath: path.join(pluginsDir, 'cache', 'discord'), + enabled: true, + }, + ], + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(pluginsDir, 'known_marketplaces.json'), + JSON.stringify( + { + 'claude-plugins-official': { + source: { type: 'github', repo: 'anthropics/claude-plugins' }, + installLocation: path.join(pluginsDir, 'marketplaces', 'claude-plugins-official'), + }, + }, + null, + 2 + ) + ); const listPayload = await getJson<{ items: Array<{ name: string; type: string; description: string; path: string }>; @@ -297,23 +351,41 @@ describe('web-server shared-routes', () => { expect(listPayload.items).toEqual([ { - name: 'cache', + name: 'discord@claude-plugins-official', type: 'plugin', - description: 'Directory with 2 items: payloads/, plugin-index.json', - path: cacheDir, + description: 'Installed from claude-plugins-official; 1 record in shared registry', + path: 'plugin-registry:discord%40claude-plugins-official', }, ]); const contentPayload = await getJson<{ content: string; contentPath: string }>( baseUrl, - `/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + `/api/shared/content?type=plugins&path=${encodeURIComponent(listPayload.items[0].path)}` ); - expect(contentPayload.content).toContain('# Plugin directory: cache'); - expect(contentPayload.content).toContain('plugin-index.json'); - expect(contentPayload.content).toContain('payloads/'); - expect(contentPayload.content).not.toContain('Shared plugin payload cache'); - expect(contentPayload.contentPath).toBe(fs.realpathSync(cacheDir)); + expect(contentPayload.content).toContain('# discord@claude-plugins-official'); + expect(contentPayload.content).toContain('## Marketplace Registry Entry'); + expect(contentPayload.content).toContain('## Plugin Registry Entry'); + expect(contentPayload.contentPath).toBe( + fs.realpathSync(path.join(pluginsDir, 'installed_plugins.json')) + ); + }); + + it('rejects plugin registry prototype keys as plugin names', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent('plugin-registry:toString')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); }); it('returns real shared settings content when settings.json is present', async () => { @@ -347,6 +419,36 @@ describe('web-server shared-routes', () => { expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath)); }); + it('returns shared settings content when settings.json links to the Claude config file', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeSettingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync( + claudeSettingsPath, + JSON.stringify({ hooks: { PreToolUse: [] }, env: { ANTHROPIC_MODEL: 'claude-opus-4-1' } }) + ); + + const sharedSettingsPath = path.join(ccsDir, 'shared', 'settings.json'); + createFileSymlink(claudeSettingsPath, sharedSettingsPath); + + const summaryPayload = await getJson<{ settings: boolean; total: number }>( + baseUrl, + '/api/shared/summary' + ); + expect(summaryPayload.settings).toBe(true); + expect(summaryPayload.total).toBe(1); + + const contentPayload = await getJson<{ content: string; contentPath: string }>( + baseUrl, + `/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-opus-4-1"'); + expect(contentPayload.contentPath).toBe(fs.realpathSync(claudeSettingsPath)); + }); + it('rejects shared settings content when settings.json resolves outside the shared directory', async () => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const outsideSettingsPath = path.join(tempHome, 'outside-settings.json'); @@ -362,9 +464,27 @@ describe('web-server shared-routes', () => { expect(payload.error).toBe('Shared content not found'); }); + it('rejects shared settings symlinks to non-settings files inside the Claude config directory', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const otherClaudeFile = path.join(claudeDir, 'credentials.json'); + fs.writeFileSync(otherClaudeFile, '{"SECRET":"do-not-read"}'); + createFileSymlink(otherClaudeFile, path.join(ccsDir, 'shared', 'settings.json')); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); + }); + it('returns README content for a shared plugin directory when available', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); - const pluginDir = path.join(pluginsDir, 'marketplaces'); + const pluginDir = path.join(pluginsDir, 'legacy-plugin'); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs'); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 99e4e0f7..ec63f655 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1506,10 +1506,12 @@ const resources = { plugins: 'Plugins', settings: 'Settings', title: 'Shared Resources', - subtitle: 'Commands, skills, agents, and configuration shared across accounts', + subtitle: 'Commands, skills, agents, plugins, and configuration shared across accounts', totalShared: 'Total Resources', visible: 'Visible', markdownDetail: 'Markdown detail view', + registryDetail: 'Registry detail view', + maskedSettingsDetail: 'Masked settings view', filterPrefix: 'Filter:', configurationRequired: 'Configuration Required', countsUnavailable: 'Counts unavailable', @@ -1523,6 +1525,8 @@ const resources = { retry: 'Retry', noSharedFound: 'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.', + noPluginsFound: + 'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.', noMatch: 'No {{tab}} match "{{query}}".', selectOne: 'Select a {{tab}} to view full content.', pathLabel: 'Path', @@ -4083,6 +4087,8 @@ const resources = { totalShared: '共享总数', visible: '可见', markdownDetail: 'Markdown 详情视图', + registryDetail: '注册表详情视图', + maskedSettingsDetail: '脱敏设置视图', filterPrefix: '筛选:', configurationRequired: '需要配置', countsUnavailable: '统计不可用', @@ -4095,6 +4101,7 @@ const resources = { failedLoadShared: '加载共享{{tab}}失败', retry: '重试', noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。', + noPluginsFound: '未安装共享插件。真实插件存在前,插件注册表文件和缓存文件夹会保持隐藏。', noMatch: '没有匹配“{{query}}”的{{tab}}。', selectOne: '选择一个{{tab}}查看完整内容。', pathLabel: '路径', @@ -6687,6 +6694,8 @@ const resources = { totalShared: 'Tổng số mục dùng chung', visible: 'Hiển thị', markdownDetail: 'Chế độ xem chi tiết Markdown', + registryDetail: 'Chế độ xem chi tiết registry', + maskedSettingsDetail: 'Chế độ xem cài đặt đã ẩn nhạy cảm', filterPrefix: 'Lọc:', configurationRequired: 'Cần cấu hình', countsUnavailable: 'Không có số liệu', @@ -6700,6 +6709,8 @@ const resources = { retry: 'Thử lại', noSharedFound: 'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm mục trong thư mục dùng chung của bạn.', + noPluginsFound: + 'Chưa cài plugin dùng chung. Tệp registry plugin và thư mục cache sẽ bị ẩn cho đến khi có plugin thật.', noMatch: 'Không có {{tab}} khớp với "{{query}}".', selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.', pathLabel: 'Đường dẫn', @@ -9317,6 +9328,8 @@ const resources = { totalShared: '共有数', visible: '表示中', markdownDetail: 'Markdown詳細ビュー', + registryDetail: 'レジストリ詳細ビュー', + maskedSettingsDetail: 'マスク済み設定ビュー', filterPrefix: 'フィルター:', configurationRequired: '設定が必要です', countsUnavailable: '件数を取得できません', @@ -9330,6 +9343,8 @@ const resources = { retry: '再試行', noSharedFound: '共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。', + noPluginsFound: + '共有プラグインはインストールされていません。実際のプラグインが存在するまで、プラグインレジストリとキャッシュフォルダーは非表示になります。', noMatch: '{{tab}}で「{{query}}」に一致するものはありません。', selectOne: '内容を表示する{{tab}}を選択してください。', pathLabel: 'パス', diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 4b3b6328..2694802e 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -8,7 +8,12 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CodeEditor } from '@/components/shared/code-editor'; import { useAccounts } from '@/hooks/use-accounts'; -import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; +import { + type SharedSummary, + useSharedItemContent, + useSharedItems, + useSharedSummary, +} from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import '@/lib/i18n'; import { useTranslation } from 'react-i18next'; @@ -30,6 +35,7 @@ type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; export function SharedPage() { const { t } = useTranslation(); const [tab, setTab] = useState('commands'); + const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false); const tabLabels: Record = { commands: t('sharedPage.commands'), skills: t('sharedPage.skills'), @@ -47,8 +53,10 @@ export function SharedPage() { error: summaryError, refetch: refetchSummary, } = useSharedSummary(); + const { data: accountsView } = useAccounts(); - const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab); + const activeTab = summary && !hasUserSelectedTab ? getPreferredInitialTab(summary) : tab; + const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(activeTab); const allItems = items?.items ?? []; const normalizedQuery = query.trim().toLowerCase(); const activeQuery = query.trim(); @@ -79,14 +87,14 @@ export function SharedPage() { }, [filteredItems, selectedItemPath]); const contentPath = - tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); + activeTab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); const { data: selectedItemContent, isLoading: isContentLoading, isError: isContentError, error: contentError, refetch: refetchContent, - } = useSharedItemContent(tab, contentPath); + } = useSharedItemContent(activeTab, contentPath); const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [ { id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 }, @@ -102,9 +110,19 @@ export function SharedPage() { ]; const totalSharedItems = summary?.total ?? 0; const settingsCount = summary?.settings ? 1 : 0; - const currentItemCount = tab === 'settings' ? settingsCount : allItems.length; - const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length; - const showListStatus = tab === 'settings' || (!isLoading && !isError); + const currentItemCount = activeTab === 'settings' ? settingsCount : allItems.length; + const currentVisibleCount = activeTab === 'settings' ? settingsCount : filteredItems.length; + const showListStatus = activeTab === 'settings' || (!isLoading && !isError); + const detailModeLabel = + activeTab === 'plugins' + ? t('sharedPage.registryDetail') + : activeTab === 'settings' + ? t('sharedPage.maskedSettingsDetail') + : t('sharedPage.markdownDetail'); + const emptyStateMessage = + activeTab === 'plugins' + ? t('sharedPage.noPluginsFound') + : t('sharedPage.noSharedFound', { tab: activeTab }); const hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; @@ -123,7 +141,7 @@ export function SharedPage() { ); const itemsErrorMessage = getSharedErrorMessage( error, - `Unable to fetch shared ${tab}. Please try again.` + `Unable to fetch shared ${activeTab}. Please try again.` ); const contentErrorMessage = getSharedErrorMessage( contentError, @@ -141,8 +159,9 @@ export function SharedPage() { { + setHasUserSelectedTab(true); setTab(nextTab as TabType); setQuery(''); setSelectedItemPath(null); @@ -163,12 +182,12 @@ export function SharedPage() {
- +
- {t('sharedPage.markdownDetail')} + {detailModeLabel} {activeQuery ? ( {t('sharedPage.filterPrefix')} {activeQuery} @@ -228,7 +247,7 @@ export function SharedPage() {
-

{tabLabels[tab]}

+

{tabLabels[activeTab]}

{showListStatus && ( @@ -242,8 +261,8 @@ export function SharedPage() { setQuery(event.target.value)} - placeholder={t('sharedPage.filterPlaceholder', { tab })} - aria-label={t('sharedPage.filterPlaceholder', { tab })} + placeholder={t('sharedPage.filterPlaceholder', { tab: activeTab })} + aria-label={t('sharedPage.filterPlaceholder', { tab: activeTab })} className="pl-8 h-9" />
@@ -253,7 +272,7 @@ export function SharedPage() { {t('sharedPage.showing', { visible: currentVisibleCount, total: currentItemCount, - tab, + tab: activeTab, })} {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} {isFetching ? t('sharedPage.refreshing') : ''} @@ -262,7 +281,7 @@ export function SharedPage() {
- {tab === 'settings' ? ( + {activeTab === 'settings' ? (
) : hasNoItems ? ( -
- {t('sharedPage.noSharedFound', { tab })} +
+ {emptyStateMessage}
) : hasNoMatches ? (
- {t('sharedPage.noMatch', { tab, query: activeQuery })} + {t('sharedPage.noMatch', { tab: activeTab, query: activeQuery })}
) : (
@@ -342,7 +361,7 @@ export function SharedPage() { {item.description}

- {item.path} + {formatSharedItemPath(item.path)}

))} @@ -352,11 +371,11 @@ export function SharedPage() {
- {!selectedItem && tab !== 'settings' ? ( + {!selectedItem && activeTab !== 'settings' ? (
- {t('sharedPage.selectOne', { tab: tab.slice(0, -1) })} + {t('sharedPage.selectOne', { tab: activeTab.slice(0, -1) })}
- ) : tab === 'settings' ? ( + ) : activeTab === 'settings' ? ( <>
@@ -454,7 +473,7 @@ export function SharedPage() {
{selectedItemContent?.contentPath && @@ -512,6 +531,26 @@ export function SharedPage() { ); } +function getPreferredInitialTab(summary: SharedSummary): TabType { + if (summary.commands > 0) { + return 'commands'; + } + if (summary.skills > 0) { + return 'skills'; + } + if (summary.agents > 0) { + return 'agents'; + } + if (summary.plugins > 0) { + return 'plugins'; + } + if (summary.settings) { + return 'settings'; + } + + return 'commands'; +} + function ResourcePoliciesPanel({ sharedCount, profileLocalCount, @@ -604,6 +643,10 @@ function getSharedErrorMessage(error: unknown, fallbackMessage: string): string return error.message || fallbackMessage; } +function formatSharedItemPath(itemPath: string): string { + return itemPath.startsWith('plugin-registry:') ? 'installed_plugins.json' : itemPath; +} + interface MarkdownBlockHeading { type: 'heading'; level: number; diff --git a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx index 37bc8e6a..139b1124 100644 --- a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx +++ b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx @@ -142,7 +142,60 @@ describe('SharedPage', () => { expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument(); }); - it('loads plugin directory content and renders real settings content', async () => { + it('opens the first populated resource tab instead of an empty commands tab', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 1, + agents: 0, + plugins: 0, + settings: true, + total: 2, + symlinkStatus: { valid: true, message: 'Symlinks active' }, + }); + } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } + if (url.endsWith('/api/shared/commands')) { + return jsonResponse({ items: [] }); + } + if (url.endsWith('/api/shared/skills')) { + return jsonResponse({ + items: [ + { + name: 'review-helper', + description: 'Review the latest PR changes.', + path: '/tmp/skills/review-helper/SKILL.md', + type: 'skill', + }, + ], + }); + } + if (url.includes('/api/shared/content?') && url.includes('type=skills')) { + return jsonResponse({ + content: '# Skill Body\n\nReal shared skill instructions', + contentPath: '/tmp/skills/review-helper/SKILL.md', + }); + } + + return jsonResponse({ items: [] }); + }); + + render(); + + expect(await screen.findByText('Showing 1 of 1 skills')).toBeInTheDocument(); + expect(screen.getByText('Review the latest PR changes.')).toBeInTheDocument(); + expect(await screen.findByText('Skill Body')).toBeInTheDocument(); + expect(screen.queryByText('Select a command to view full content.')).not.toBeInTheDocument(); + + const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input)); + expect(requestedUrls.some((url) => url.endsWith('/api/shared/skills'))).toBe(true); + }); + + it('loads plugin registry content and renders real settings content', async () => { fetchMock.mockImplementation(async (input) => { const url = requestUrl(input); if (url.endsWith('/api/shared/summary')) { @@ -166,9 +219,9 @@ describe('SharedPage', () => { return jsonResponse({ items: [ { - name: 'cache', - description: 'Directory with 2 items: payloads/, plugin-index.json', - path: '/tmp/plugins/cache', + name: 'discord@claude-plugins-official', + description: 'Installed from claude-plugins-official; 1 record in shared registry', + path: 'plugin-registry:discord%40claude-plugins-official', type: 'plugin', }, ], @@ -177,8 +230,8 @@ describe('SharedPage', () => { if (url.includes('/api/shared/content?') && url.includes('type=plugins')) { return jsonResponse({ content: - '# Plugin directory: cache\n\n## Directory contents\n\n- payloads/\n- plugin-index.json', - contentPath: '/tmp/plugins/cache', + '# discord@claude-plugins-official\n\n## Plugin Registry Entry\n\n```json\n{"enabled":true}\n```', + contentPath: '/tmp/plugins/installed_plugins.json', }); } if (url.includes('/api/shared/content?') && url.includes('type=settings')) { @@ -194,12 +247,11 @@ describe('SharedPage', () => { render(); - await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ })); - + expect(await screen.findByText('Showing 1 of 1 plugins')).toBeInTheDocument(); expect( - await screen.findByText('Directory with 2 items: payloads/, plugin-index.json') + await screen.findByText('Installed from claude-plugins-official; 1 record in shared registry') ).toBeInTheDocument(); - expect(await screen.findByText('plugin-index.json')).toBeInTheDocument(); + expect(await screen.findByText('Plugin Registry Entry')).toBeInTheDocument(); await userEvent.click(screen.getByRole('tab', { name: /Settings/ })); @@ -249,6 +301,13 @@ describe('SharedPage', () => { expect(screen.getByText('1 shared')).toBeInTheDocument(); expect(screen.getByText('1 profile-local')).toBeInTheDocument(); expect(screen.getByText('sandbox')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: /Plugins/ })); + expect( + await screen.findByText( + 'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.' + ) + ).toBeInTheDocument(); }); it('shows offline guidance when network request fails', async () => {