fix(dashboard): show real shared plugin registry state

This commit is contained in:
Tam Nhu Tran
2026-05-09 03:18:20 -04:00
parent 2a422b3bd3
commit 8b34060294
7 changed files with 587 additions and 129 deletions
+1 -1
View File
@@ -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
@@ -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`)
+299 -80
View File
@@ -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<string, unknown>;
}
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
/**
@@ -110,9 +117,9 @@ sharedRoutes.get('/content', (req: Request, res: Response) => {
const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
const allowedRoots =
typeParam === 'settings'
? new Set<string>([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<string>();
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<string> {
const claudeConfigDir = getClaudeConfigDir();
return new Set<string>(
[
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<string>): SharedItem[] {
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
const itemsByPath = new Map<string, SharedItem>();
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<string>): 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<string>): 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<string>): SharedItem[] {
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
const items: SharedItem[] = [];
@@ -279,35 +380,13 @@ function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedIt
}
function getSkillOrAgentItem(
type: 'skills' | 'agents' | 'plugins',
type: 'skills' | 'agents',
entry: fs.Dirent,
entryPath: string,
resolvedEntryPath: string,
allowedRoots: Set<string>,
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<string>): 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<string>
): Record<string, unknown> | 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<string, unknown>;
} catch {
return null;
}
}
function readPluginInstalledRegistry(
sharedDir: string,
allowedRoots: Set<string>
): 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<string, unknown>,
};
}
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<string>
@@ -582,12 +744,20 @@ function resolveReadableMarkdownPath(
function getSharedItemContent(
type: SharedContentType,
itemPath: string,
allowedRoots: Set<string>
allowedRoots: Set<string>,
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<string>
): { 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<string>,
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>
): 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>
): 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>): 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);
+131 -11
View File
@@ -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');
+16 -1
View File
@@ -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: 'パス',
+69 -26
View File
@@ -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<TabType>('commands');
const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false);
const tabLabels: Record<TabType, string> = {
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() {
</div>
<Tabs
value={tab}
value={activeTab}
onValueChange={(nextTab) => {
setHasUserSelectedTab(true);
setTab(nextTab as TabType);
setQuery('');
setSelectedItemPath(null);
@@ -163,12 +182,12 @@ export function SharedPage() {
<div className="flex flex-col gap-2 lg:items-end">
<div className="grid w-full gap-2 sm:w-auto sm:min-w-[340px] sm:grid-cols-3">
<HeaderMetricCard label={t('sharedPage.totalShared')} value={totalSharedItems} />
<HeaderMetricCard label={tabLabels[tab]} value={currentItemCount} />
<HeaderMetricCard label={tabLabels[activeTab]} value={currentItemCount} />
<HeaderMetricCard label={t('sharedPage.visible')} value={currentVisibleCount} />
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
<Badge variant="secondary">{detailModeLabel}</Badge>
{activeQuery ? (
<Badge variant="outline">
{t('sharedPage.filterPrefix')} {activeQuery}
@@ -228,7 +247,7 @@ export function SharedPage() {
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<FolderOpen className="w-4 h-4 text-primary shrink-0" />
<h2 className="font-semibold truncate">{tabLabels[tab]}</h2>
<h2 className="font-semibold truncate">{tabLabels[activeTab]}</h2>
</div>
{showListStatus && (
<Badge variant="outline" className="text-[10px] h-5">
@@ -242,8 +261,8 @@ export function SharedPage() {
<Input
value={query}
onChange={(event) => 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"
/>
</div>
@@ -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() {
</div>
<ScrollArea className="max-h-[360px] lg:max-h-none lg:flex-1 lg:min-h-0">
{tab === 'settings' ? (
{activeTab === 'settings' ? (
<div className="p-2 space-y-1">
<button
type="button"
@@ -291,7 +310,7 @@ export function SharedPage() {
</div>
) : isLoading ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.loadingShared', { tab })}
{t('sharedPage.loadingShared', { tab: activeTab })}
</div>
) : isError ? (
<div className="p-4 text-center">
@@ -299,7 +318,7 @@ export function SharedPage() {
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
<div>
<p className="text-sm font-medium">
{t('sharedPage.failedLoadShared', { tab })}
{t('sharedPage.failedLoadShared', { tab: activeTab })}
</p>
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
</div>
@@ -316,12 +335,12 @@ export function SharedPage() {
</div>
</div>
) : hasNoItems ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.noSharedFound', { tab })}
<div className="p-4 text-sm text-muted-foreground leading-relaxed">
{emptyStateMessage}
</div>
) : hasNoMatches ? (
<div className="p-4 text-sm text-muted-foreground">
{t('sharedPage.noMatch', { tab, query: activeQuery })}
{t('sharedPage.noMatch', { tab: activeTab, query: activeQuery })}
</div>
) : (
<div className="p-2 space-y-1">
@@ -342,7 +361,7 @@ export function SharedPage() {
{item.description}
</p>
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
{item.path}
{formatSharedItemPath(item.path)}
</p>
</button>
))}
@@ -352,11 +371,11 @@ export function SharedPage() {
</div>
<div className="min-w-0 min-h-[360px] flex flex-col bg-muted/20 lg:min-h-0">
{!selectedItem && tab !== 'settings' ? (
{!selectedItem && activeTab !== 'settings' ? (
<div className="min-h-[320px] flex items-center justify-center p-6 text-center text-muted-foreground">
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
{t('sharedPage.selectOne', { tab: activeTab.slice(0, -1) })}
</div>
) : tab === 'settings' ? (
) : activeTab === 'settings' ? (
<>
<div className="px-4 py-3 border-b bg-background">
<div className="flex items-center gap-2">
@@ -454,7 +473,7 @@ export function SharedPage() {
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<MetadataField
label={t('sharedPage.pathLabel')}
value={selectedItem.path}
value={formatSharedItemPath(selectedItem.path)}
mono
/>
{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;
@@ -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(<SharedPage />);
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(<SharedPage />);
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 () => {