mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 08:17:22 +00:00
fix(dashboard): show real shared plugin registry state
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user