mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
fix(dashboard): show real shared plugin registry state
This commit is contained in:
1 parent
2a422b3bd3
commit
8b34060294
7 files changed
+587
-129
No files matched your search
@@ -99,7 +99,7 @@ Shared resource editing:
|
|||||||
- reconciles the account instance after metadata is updated
|
- 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 -> 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`.
|
- 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.
|
- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ accounts:
|
|||||||
work:
|
work:
|
||||||
created: "2026-02-24T00:00:00.000Z"
|
created: "2026-02-24T00:00:00.000Z"
|
||||||
last_used: null
|
last_used: null
|
||||||
|
shared_resource_mode: "shared"
|
||||||
context_mode: "shared"
|
context_mode: "shared"
|
||||||
context_group: "team-alpha"
|
context_group: "team-alpha"
|
||||||
continuity_mode: "deeper"
|
continuity_mode: "deeper"
|
||||||
@@ -76,6 +77,7 @@ accounts:
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
|
- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`)
|
||||||
- `context_mode` must be `isolated` or `shared`
|
- `context_mode` must be `isolated` or `shared`
|
||||||
- `context_group` is required when `context_mode=shared`
|
- `context_group` is required when `context_mode=shared`
|
||||||
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
|
- `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_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB
|
||||||
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
||||||
const SHARED_ITEMS_CACHE_TTL_MS = 1000;
|
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 SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins';
|
||||||
type SharedContentType = SharedCollectionType | 'settings';
|
type SharedContentType = SharedCollectionType | 'settings';
|
||||||
@@ -49,6 +51,11 @@ interface SharedItemsCacheEntry {
|
|||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface InstalledPluginRegistry {
|
||||||
|
version?: number;
|
||||||
|
plugins: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
|
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 sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir);
|
||||||
const allowedRoots =
|
const allowedRoots =
|
||||||
typeParam === 'settings'
|
typeParam === 'settings'
|
||||||
? new Set<string>([sharedDirRoot])
|
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||||
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
|
||||||
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
|
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
|
||||||
|
|
||||||
if (!contentResult) {
|
if (!contentResult) {
|
||||||
res.status(404).json({ error: 'Shared content not found' });
|
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 settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||||
const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared'));
|
const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared'));
|
||||||
const resolvedSettingsPath = safeRealPath(settingsPath);
|
const resolvedSettingsPath = safeRealPath(settingsPath);
|
||||||
|
const settingsAllowedRoots = sharedDirRoot
|
||||||
|
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
|
||||||
|
: new Set<string>();
|
||||||
const hasSettings =
|
const hasSettings =
|
||||||
Boolean(sharedDirRoot) &&
|
Boolean(sharedDirRoot) &&
|
||||||
Boolean(resolvedSettingsPath) &&
|
Boolean(resolvedSettingsPath) &&
|
||||||
isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string);
|
isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
commands,
|
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[] {
|
function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
||||||
const ccsDir = getCcsDir();
|
const ccsDir = getCcsDir();
|
||||||
const sharedDir = path.join(ccsDir, 'shared', type);
|
const sharedDir = path.join(ccsDir, 'shared', type);
|
||||||
@@ -209,6 +230,16 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
|||||||
return commandItems;
|
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 {
|
try {
|
||||||
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
const entries = fs.readdirSync(sharedDir, { withFileTypes: true });
|
||||||
|
|
||||||
@@ -251,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] {
|
|||||||
return sortedItems;
|
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[] {
|
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
|
||||||
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
|
||||||
const items: SharedItem[] = [];
|
const items: SharedItem[] = [];
|
||||||
@@ -279,35 +380,13 @@ function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedIt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getSkillOrAgentItem(
|
function getSkillOrAgentItem(
|
||||||
type: 'skills' | 'agents' | 'plugins',
|
type: 'skills' | 'agents',
|
||||||
entry: fs.Dirent,
|
entry: fs.Dirent,
|
||||||
entryPath: string,
|
entryPath: string,
|
||||||
resolvedEntryPath: string,
|
resolvedEntryPath: string,
|
||||||
allowedRoots: Set<string>,
|
allowedRoots: Set<string>,
|
||||||
stats: fs.Stats
|
stats: fs.Stats
|
||||||
): SharedItem | null {
|
): 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 (type === 'skills') {
|
||||||
if (!stats.isDirectory()) {
|
if (!stats.isDirectory()) {
|
||||||
return null;
|
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(
|
function resolveReadableMarkdownPath(
|
||||||
markdownPaths: string[],
|
markdownPaths: string[],
|
||||||
allowedRoots: Set<string>
|
allowedRoots: Set<string>
|
||||||
@@ -582,12 +744,20 @@ function resolveReadableMarkdownPath(
|
|||||||
function getSharedItemContent(
|
function getSharedItemContent(
|
||||||
type: SharedContentType,
|
type: SharedContentType,
|
||||||
itemPath: string,
|
itemPath: string,
|
||||||
allowedRoots: Set<string>
|
allowedRoots: Set<string>,
|
||||||
|
sharedDir: string
|
||||||
): { content: string; contentPath: string } | null {
|
): { content: string; contentPath: string } | null {
|
||||||
if (type === 'settings') {
|
if (type === 'settings') {
|
||||||
return getSharedSettingsContent(itemPath, allowedRoots);
|
return getSharedSettingsContent(itemPath, allowedRoots);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'plugins') {
|
||||||
|
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
|
||||||
|
if (pluginRegistryContent) {
|
||||||
|
return pluginRegistryContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedItemPath = safeRealPath(itemPath);
|
const resolvedItemPath = safeRealPath(itemPath);
|
||||||
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -615,7 +785,7 @@ function getSharedItemContent(
|
|||||||
allowedRoots
|
allowedRoots
|
||||||
);
|
);
|
||||||
} else if (type === 'plugins') {
|
} else if (type === 'plugins') {
|
||||||
if (!itemStats.isDirectory()) {
|
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,10 +799,7 @@ function getSharedItemContent(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!markdownPath) {
|
if (!markdownPath) {
|
||||||
return {
|
return null;
|
||||||
content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots),
|
|
||||||
contentPath: resolvedItemPath,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (itemStats.isDirectory()) {
|
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 {
|
function safeRealPath(targetPath: string): string | null {
|
||||||
try {
|
try {
|
||||||
return fs.realpathSync(targetPath);
|
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 {
|
function isPathWithin(candidatePath: string, basePath: string): boolean {
|
||||||
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
const normalizedCandidate = normalizeForPathComparison(candidatePath);
|
||||||
const normalizedBase = normalizeForPathComparison(basePath);
|
const normalizedBase = normalizeForPathComparison(basePath);
|
||||||
|
|||||||
@@ -285,11 +285,65 @@ describe('web-server shared-routes', () => {
|
|||||||
expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md')));
|
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 pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
||||||
const cacheDir = path.join(pluginsDir, 'cache');
|
const cacheDir = path.join(pluginsDir, 'cache');
|
||||||
fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true });
|
fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}');
|
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<{
|
const listPayload = await getJson<{
|
||||||
items: Array<{ name: string; type: string; description: string; path: string }>;
|
items: Array<{ name: string; type: string; description: string; path: string }>;
|
||||||
@@ -297,23 +351,41 @@ describe('web-server shared-routes', () => {
|
|||||||
|
|
||||||
expect(listPayload.items).toEqual([
|
expect(listPayload.items).toEqual([
|
||||||
{
|
{
|
||||||
name: 'cache',
|
name: 'discord@claude-plugins-official',
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
description: 'Directory with 2 items: payloads/, plugin-index.json',
|
description: 'Installed from claude-plugins-official; 1 record in shared registry',
|
||||||
path: cacheDir,
|
path: 'plugin-registry:discord%40claude-plugins-official',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
const contentPayload = await getJson<{ content: string; contentPath: string }>(
|
||||||
baseUrl,
|
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('# discord@claude-plugins-official');
|
||||||
expect(contentPayload.content).toContain('plugin-index.json');
|
expect(contentPayload.content).toContain('## Marketplace Registry Entry');
|
||||||
expect(contentPayload.content).toContain('payloads/');
|
expect(contentPayload.content).toContain('## Plugin Registry Entry');
|
||||||
expect(contentPayload.content).not.toContain('Shared plugin payload cache');
|
expect(contentPayload.contentPath).toBe(
|
||||||
expect(contentPayload.contentPath).toBe(fs.realpathSync(cacheDir));
|
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 () => {
|
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));
|
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 () => {
|
it('rejects shared settings content when settings.json resolves outside the shared directory', async () => {
|
||||||
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
const settingsPath = path.join(ccsDir, 'shared', 'settings.json');
|
||||||
const outsideSettingsPath = path.join(tempHome, 'outside-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');
|
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 () => {
|
it('returns README content for a shared plugin directory when available', async () => {
|
||||||
const pluginsDir = path.join(ccsDir, 'shared', 'plugins');
|
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.mkdirSync(pluginDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs');
|
fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs');
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -1506,10 +1506,12 @@ const resources = {
|
|||||||
plugins: 'Plugins',
|
plugins: 'Plugins',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
title: 'Shared Resources',
|
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',
|
totalShared: 'Total Resources',
|
||||||
visible: 'Visible',
|
visible: 'Visible',
|
||||||
markdownDetail: 'Markdown detail view',
|
markdownDetail: 'Markdown detail view',
|
||||||
|
registryDetail: 'Registry detail view',
|
||||||
|
maskedSettingsDetail: 'Masked settings view',
|
||||||
filterPrefix: 'Filter:',
|
filterPrefix: 'Filter:',
|
||||||
configurationRequired: 'Configuration Required',
|
configurationRequired: 'Configuration Required',
|
||||||
countsUnavailable: 'Counts unavailable',
|
countsUnavailable: 'Counts unavailable',
|
||||||
@@ -1523,6 +1525,8 @@ const resources = {
|
|||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
noSharedFound:
|
noSharedFound:
|
||||||
'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.',
|
'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}}".',
|
noMatch: 'No {{tab}} match "{{query}}".',
|
||||||
selectOne: 'Select a {{tab}} to view full content.',
|
selectOne: 'Select a {{tab}} to view full content.',
|
||||||
pathLabel: 'Path',
|
pathLabel: 'Path',
|
||||||
@@ -4083,6 +4087,8 @@ const resources = {
|
|||||||
totalShared: '共享总数',
|
totalShared: '共享总数',
|
||||||
visible: '可见',
|
visible: '可见',
|
||||||
markdownDetail: 'Markdown 详情视图',
|
markdownDetail: 'Markdown 详情视图',
|
||||||
|
registryDetail: '注册表详情视图',
|
||||||
|
maskedSettingsDetail: '脱敏设置视图',
|
||||||
filterPrefix: '筛选:',
|
filterPrefix: '筛选:',
|
||||||
configurationRequired: '需要配置',
|
configurationRequired: '需要配置',
|
||||||
countsUnavailable: '统计不可用',
|
countsUnavailable: '统计不可用',
|
||||||
@@ -4095,6 +4101,7 @@ const resources = {
|
|||||||
failedLoadShared: '加载共享{{tab}}失败',
|
failedLoadShared: '加载共享{{tab}}失败',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。',
|
noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。',
|
||||||
|
noPluginsFound: '未安装共享插件。真实插件存在前,插件注册表文件和缓存文件夹会保持隐藏。',
|
||||||
noMatch: '没有匹配“{{query}}”的{{tab}}。',
|
noMatch: '没有匹配“{{query}}”的{{tab}}。',
|
||||||
selectOne: '选择一个{{tab}}查看完整内容。',
|
selectOne: '选择一个{{tab}}查看完整内容。',
|
||||||
pathLabel: '路径',
|
pathLabel: '路径',
|
||||||
@@ -6687,6 +6694,8 @@ const resources = {
|
|||||||
totalShared: 'Tổng số mục dùng chung',
|
totalShared: 'Tổng số mục dùng chung',
|
||||||
visible: 'Hiển thị',
|
visible: 'Hiển thị',
|
||||||
markdownDetail: 'Chế độ xem chi tiết Markdown',
|
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:',
|
filterPrefix: 'Lọc:',
|
||||||
configurationRequired: 'Cần cấu hình',
|
configurationRequired: 'Cần cấu hình',
|
||||||
countsUnavailable: 'Không có số liệu',
|
countsUnavailable: 'Không có số liệu',
|
||||||
@@ -6700,6 +6709,8 @@ const resources = {
|
|||||||
retry: 'Thử lại',
|
retry: 'Thử lại',
|
||||||
noSharedFound:
|
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.',
|
'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}}".',
|
noMatch: 'Không có {{tab}} khớp với "{{query}}".',
|
||||||
selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.',
|
selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.',
|
||||||
pathLabel: 'Đường dẫn',
|
pathLabel: 'Đường dẫn',
|
||||||
@@ -9317,6 +9328,8 @@ const resources = {
|
|||||||
totalShared: '共有数',
|
totalShared: '共有数',
|
||||||
visible: '表示中',
|
visible: '表示中',
|
||||||
markdownDetail: 'Markdown詳細ビュー',
|
markdownDetail: 'Markdown詳細ビュー',
|
||||||
|
registryDetail: 'レジストリ詳細ビュー',
|
||||||
|
maskedSettingsDetail: 'マスク済み設定ビュー',
|
||||||
filterPrefix: 'フィルター:',
|
filterPrefix: 'フィルター:',
|
||||||
configurationRequired: '設定が必要です',
|
configurationRequired: '設定が必要です',
|
||||||
countsUnavailable: '件数を取得できません',
|
countsUnavailable: '件数を取得できません',
|
||||||
@@ -9330,6 +9343,8 @@ const resources = {
|
|||||||
retry: '再試行',
|
retry: '再試行',
|
||||||
noSharedFound:
|
noSharedFound:
|
||||||
'共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。',
|
'共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。',
|
||||||
|
noPluginsFound:
|
||||||
|
'共有プラグインはインストールされていません。実際のプラグインが存在するまで、プラグインレジストリとキャッシュフォルダーは非表示になります。',
|
||||||
noMatch: '{{tab}}で「{{query}}」に一致するものはありません。',
|
noMatch: '{{tab}}で「{{query}}」に一致するものはありません。',
|
||||||
selectOne: '内容を表示する{{tab}}を選択してください。',
|
selectOne: '内容を表示する{{tab}}を選択してください。',
|
||||||
pathLabel: 'パス',
|
pathLabel: 'パス',
|
||||||
|
|||||||
+69
-26
@@ -8,7 +8,12 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { CodeEditor } from '@/components/shared/code-editor';
|
import { CodeEditor } from '@/components/shared/code-editor';
|
||||||
import { useAccounts } from '@/hooks/use-accounts';
|
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 { cn } from '@/lib/utils';
|
||||||
import '@/lib/i18n';
|
import '@/lib/i18n';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -30,6 +35,7 @@ type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings';
|
|||||||
export function SharedPage() {
|
export function SharedPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [tab, setTab] = useState<TabType>('commands');
|
const [tab, setTab] = useState<TabType>('commands');
|
||||||
|
const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false);
|
||||||
const tabLabels: Record<TabType, string> = {
|
const tabLabels: Record<TabType, string> = {
|
||||||
commands: t('sharedPage.commands'),
|
commands: t('sharedPage.commands'),
|
||||||
skills: t('sharedPage.skills'),
|
skills: t('sharedPage.skills'),
|
||||||
@@ -47,8 +53,10 @@ export function SharedPage() {
|
|||||||
error: summaryError,
|
error: summaryError,
|
||||||
refetch: refetchSummary,
|
refetch: refetchSummary,
|
||||||
} = useSharedSummary();
|
} = useSharedSummary();
|
||||||
|
|
||||||
const { data: accountsView } = useAccounts();
|
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 allItems = items?.items ?? [];
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const activeQuery = query.trim();
|
const activeQuery = query.trim();
|
||||||
@@ -79,14 +87,14 @@ export function SharedPage() {
|
|||||||
}, [filteredItems, selectedItemPath]);
|
}, [filteredItems, selectedItemPath]);
|
||||||
|
|
||||||
const contentPath =
|
const contentPath =
|
||||||
tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null);
|
activeTab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null);
|
||||||
const {
|
const {
|
||||||
data: selectedItemContent,
|
data: selectedItemContent,
|
||||||
isLoading: isContentLoading,
|
isLoading: isContentLoading,
|
||||||
isError: isContentError,
|
isError: isContentError,
|
||||||
error: contentError,
|
error: contentError,
|
||||||
refetch: refetchContent,
|
refetch: refetchContent,
|
||||||
} = useSharedItemContent(tab, contentPath);
|
} = useSharedItemContent(activeTab, contentPath);
|
||||||
|
|
||||||
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [
|
const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [
|
||||||
{ id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
|
{ id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 },
|
||||||
@@ -102,9 +110,19 @@ export function SharedPage() {
|
|||||||
];
|
];
|
||||||
const totalSharedItems = summary?.total ?? 0;
|
const totalSharedItems = summary?.total ?? 0;
|
||||||
const settingsCount = summary?.settings ? 1 : 0;
|
const settingsCount = summary?.settings ? 1 : 0;
|
||||||
const currentItemCount = tab === 'settings' ? settingsCount : allItems.length;
|
const currentItemCount = activeTab === 'settings' ? settingsCount : allItems.length;
|
||||||
const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length;
|
const currentVisibleCount = activeTab === 'settings' ? settingsCount : filteredItems.length;
|
||||||
const showListStatus = tab === 'settings' || (!isLoading && !isError);
|
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 hasNoItems = !isLoading && !isError && allItems.length === 0;
|
||||||
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0;
|
const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0;
|
||||||
@@ -123,7 +141,7 @@ export function SharedPage() {
|
|||||||
);
|
);
|
||||||
const itemsErrorMessage = getSharedErrorMessage(
|
const itemsErrorMessage = getSharedErrorMessage(
|
||||||
error,
|
error,
|
||||||
`Unable to fetch shared ${tab}. Please try again.`
|
`Unable to fetch shared ${activeTab}. Please try again.`
|
||||||
);
|
);
|
||||||
const contentErrorMessage = getSharedErrorMessage(
|
const contentErrorMessage = getSharedErrorMessage(
|
||||||
contentError,
|
contentError,
|
||||||
@@ -141,8 +159,9 @@ export function SharedPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={activeTab}
|
||||||
onValueChange={(nextTab) => {
|
onValueChange={(nextTab) => {
|
||||||
|
setHasUserSelectedTab(true);
|
||||||
setTab(nextTab as TabType);
|
setTab(nextTab as TabType);
|
||||||
setQuery('');
|
setQuery('');
|
||||||
setSelectedItemPath(null);
|
setSelectedItemPath(null);
|
||||||
@@ -163,12 +182,12 @@ export function SharedPage() {
|
|||||||
<div className="flex flex-col gap-2 lg:items-end">
|
<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">
|
<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={t('sharedPage.totalShared')} value={totalSharedItems} />
|
||||||
<HeaderMetricCard label={tabLabels[tab]} value={currentItemCount} />
|
<HeaderMetricCard label={tabLabels[activeTab]} value={currentItemCount} />
|
||||||
<HeaderMetricCard label={t('sharedPage.visible')} value={currentVisibleCount} />
|
<HeaderMetricCard label={t('sharedPage.visible')} value={currentVisibleCount} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<Badge variant="secondary">{t('sharedPage.markdownDetail')}</Badge>
|
<Badge variant="secondary">{detailModeLabel}</Badge>
|
||||||
{activeQuery ? (
|
{activeQuery ? (
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{t('sharedPage.filterPrefix')} {activeQuery}
|
{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 justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<FolderOpen className="w-4 h-4 text-primary shrink-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>
|
</div>
|
||||||
{showListStatus && (
|
{showListStatus && (
|
||||||
<Badge variant="outline" className="text-[10px] h-5">
|
<Badge variant="outline" className="text-[10px] h-5">
|
||||||
@@ -242,8 +261,8 @@ export function SharedPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
placeholder={t('sharedPage.filterPlaceholder', { tab })}
|
placeholder={t('sharedPage.filterPlaceholder', { tab: activeTab })}
|
||||||
aria-label={t('sharedPage.filterPlaceholder', { tab })}
|
aria-label={t('sharedPage.filterPlaceholder', { tab: activeTab })}
|
||||||
className="pl-8 h-9"
|
className="pl-8 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +272,7 @@ export function SharedPage() {
|
|||||||
{t('sharedPage.showing', {
|
{t('sharedPage.showing', {
|
||||||
visible: currentVisibleCount,
|
visible: currentVisibleCount,
|
||||||
total: currentItemCount,
|
total: currentItemCount,
|
||||||
tab,
|
tab: activeTab,
|
||||||
})}
|
})}
|
||||||
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
{activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''}
|
||||||
{isFetching ? t('sharedPage.refreshing') : ''}
|
{isFetching ? t('sharedPage.refreshing') : ''}
|
||||||
@@ -262,7 +281,7 @@ export function SharedPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="max-h-[360px] lg:max-h-none lg:flex-1 lg:min-h-0">
|
<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">
|
<div className="p-2 space-y-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -291,7 +310,7 @@ export function SharedPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
{t('sharedPage.loadingShared', { tab })}
|
{t('sharedPage.loadingShared', { tab: activeTab })}
|
||||||
</div>
|
</div>
|
||||||
) : isError ? (
|
) : isError ? (
|
||||||
<div className="p-4 text-center">
|
<div className="p-4 text-center">
|
||||||
@@ -299,7 +318,7 @@ export function SharedPage() {
|
|||||||
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
<AlertCircle className="w-10 h-10 mx-auto text-destructive/50" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
{t('sharedPage.failedLoadShared', { tab })}
|
{t('sharedPage.failedLoadShared', { tab: activeTab })}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
<p className="text-xs text-muted-foreground mt-1">{itemsErrorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,12 +335,12 @@ export function SharedPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : hasNoItems ? (
|
) : hasNoItems ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground leading-relaxed">
|
||||||
{t('sharedPage.noSharedFound', { tab })}
|
{emptyStateMessage}
|
||||||
</div>
|
</div>
|
||||||
) : hasNoMatches ? (
|
) : hasNoMatches ? (
|
||||||
<div className="p-4 text-sm text-muted-foreground">
|
<div className="p-4 text-sm text-muted-foreground">
|
||||||
{t('sharedPage.noMatch', { tab, query: activeQuery })}
|
{t('sharedPage.noMatch', { tab: activeTab, query: activeQuery })}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-2 space-y-1">
|
<div className="p-2 space-y-1">
|
||||||
@@ -342,7 +361,7 @@ export function SharedPage() {
|
|||||||
{item.description}
|
{item.description}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
|
<p className="text-[11px] text-muted-foreground/90 mt-2 font-mono truncate">
|
||||||
{item.path}
|
{formatSharedItemPath(item.path)}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -352,11 +371,11 @@ export function SharedPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 min-h-[360px] flex flex-col bg-muted/20 lg:min-h-0">
|
<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">
|
<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>
|
</div>
|
||||||
) : tab === 'settings' ? (
|
) : activeTab === 'settings' ? (
|
||||||
<>
|
<>
|
||||||
<div className="px-4 py-3 border-b bg-background">
|
<div className="px-4 py-3 border-b bg-background">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
<MetadataField
|
<MetadataField
|
||||||
label={t('sharedPage.pathLabel')}
|
label={t('sharedPage.pathLabel')}
|
||||||
value={selectedItem.path}
|
value={formatSharedItemPath(selectedItem.path)}
|
||||||
mono
|
mono
|
||||||
/>
|
/>
|
||||||
{selectedItemContent?.contentPath &&
|
{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({
|
function ResourcePoliciesPanel({
|
||||||
sharedCount,
|
sharedCount,
|
||||||
profileLocalCount,
|
profileLocalCount,
|
||||||
@@ -604,6 +643,10 @@ function getSharedErrorMessage(error: unknown, fallbackMessage: string): string
|
|||||||
return error.message || fallbackMessage;
|
return error.message || fallbackMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSharedItemPath(itemPath: string): string {
|
||||||
|
return itemPath.startsWith('plugin-registry:') ? 'installed_plugins.json' : itemPath;
|
||||||
|
}
|
||||||
|
|
||||||
interface MarkdownBlockHeading {
|
interface MarkdownBlockHeading {
|
||||||
type: 'heading';
|
type: 'heading';
|
||||||
level: number;
|
level: number;
|
||||||
|
|||||||
@@ -142,7 +142,60 @@ describe('SharedPage', () => {
|
|||||||
expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument();
|
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) => {
|
fetchMock.mockImplementation(async (input) => {
|
||||||
const url = requestUrl(input);
|
const url = requestUrl(input);
|
||||||
if (url.endsWith('/api/shared/summary')) {
|
if (url.endsWith('/api/shared/summary')) {
|
||||||
@@ -166,9 +219,9 @@ describe('SharedPage', () => {
|
|||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'cache',
|
name: 'discord@claude-plugins-official',
|
||||||
description: 'Directory with 2 items: payloads/, plugin-index.json',
|
description: 'Installed from claude-plugins-official; 1 record in shared registry',
|
||||||
path: '/tmp/plugins/cache',
|
path: 'plugin-registry:discord%40claude-plugins-official',
|
||||||
type: 'plugin',
|
type: 'plugin',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -177,8 +230,8 @@ describe('SharedPage', () => {
|
|||||||
if (url.includes('/api/shared/content?') && url.includes('type=plugins')) {
|
if (url.includes('/api/shared/content?') && url.includes('type=plugins')) {
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
content:
|
content:
|
||||||
'# Plugin directory: cache\n\n## Directory contents\n\n- payloads/\n- plugin-index.json',
|
'# discord@claude-plugins-official\n\n## Plugin Registry Entry\n\n```json\n{"enabled":true}\n```',
|
||||||
contentPath: '/tmp/plugins/cache',
|
contentPath: '/tmp/plugins/installed_plugins.json',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (url.includes('/api/shared/content?') && url.includes('type=settings')) {
|
if (url.includes('/api/shared/content?') && url.includes('type=settings')) {
|
||||||
@@ -194,12 +247,11 @@ describe('SharedPage', () => {
|
|||||||
|
|
||||||
render(<SharedPage />);
|
render(<SharedPage />);
|
||||||
|
|
||||||
await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ }));
|
expect(await screen.findByText('Showing 1 of 1 plugins')).toBeInTheDocument();
|
||||||
|
|
||||||
expect(
|
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();
|
).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/ }));
|
await userEvent.click(screen.getByRole('tab', { name: /Settings/ }));
|
||||||
|
|
||||||
@@ -249,6 +301,13 @@ describe('SharedPage', () => {
|
|||||||
expect(screen.getByText('1 shared')).toBeInTheDocument();
|
expect(screen.getByText('1 shared')).toBeInTheDocument();
|
||||||
expect(screen.getByText('1 profile-local')).toBeInTheDocument();
|
expect(screen.getByText('1 profile-local')).toBeInTheDocument();
|
||||||
expect(screen.getByText('sandbox')).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 () => {
|
it('shows offline guidance when network request fails', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user