From 2a422b3bd32d989e03e8f20efc875b4bb6d35584 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 9 May 2026 02:29:08 -0400 Subject: [PATCH] feat(dashboard): add shared resource controls --- docs/dashboard-auth-cli.md | 4 + docs/project-roadmap.md | 3 +- docs/session-sharing-technical-analysis.md | 9 +- src/web-server/shared-routes.ts | 195 +++++++++++++- tests/unit/web-server/shared-routes.test.ts | 92 +++++++ ui/src/components/account/accounts-table.tsx | 74 +++++- .../edit-account-shared-resources-dialog.tsx | 149 +++++++++++ ui/src/hooks/use-accounts.ts | 25 ++ ui/src/hooks/use-shared.ts | 20 +- ui/src/lib/api-client.ts | 11 + ui/src/lib/i18n.ts | 107 +++++++- ui/src/pages/accounts.tsx | 1 + ui/src/pages/shared.tsx | 240 ++++++++++++++++-- .../unit/ui/pages/shared/shared-page.test.tsx | 142 +++++++++++ 14 files changed, 1012 insertions(+), 60 deletions(-) create mode 100644 ui/src/components/account/edit-account-shared-resources-dialog.tsx diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index f99a6423..dd297535 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -97,6 +97,10 @@ Shared resource editing: - accepts only `shared` or `profile-local` - rejects CLIProxy OAuth account keys for this route - 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. +- Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values. ## Commands diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index a0d928ba..3d7bf32f 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-05-07 +Last Updated: 2026-05-09 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-05-09**: **#1199** Existing Claude auth accounts now have dashboard-visible Shared Resources controls separate from History Sync. Accounts exposes a dedicated Resources action for `shared` vs `profile-local`, `/shared` now inventories commands, skills, agents, plugins, and `settings.json`, plugin directories without docs show factual directory contents, and shared settings content is inspectable read-only through the localhost-gated shared-content API. - **2026-05-07**: **#760** Codex GPT fast mode is now a first-class CLIProxy model tuning suffix. CCS accepts `gpt-5.4-fast`, `gpt-5.4-high-fast`, and equivalent canonicalized forms in raw env configs, CLI variant creation, and the dashboard model picker; runtime requests now send the base upstream model with `reasoning.effort` plus `service_tier: "priority"` instead of leaking the suffixed alias to CLIProxy upstream routing. - **2026-05-07**: **#1103** GitHub Copilot is now treated as a deprecated compatibility bridge. The dashboard moves Copilot out of the active Identity & Access section and into Deprecated, quick setup no longer offers `ghcp` for new onboarding, CLI/help/config copy marks Copilot as deprecated, and existing `ccs copilot` / `ghcp` compatibility paths remain available for current setups. - **2026-05-07**: **#1189** Headless settings-profile delegation now preserves native Claude passthrough args without a Claude flag allowlist. Explicit `--channels` values reach Claude Code, future native flags can carry multiple adjacent values, malformed CCS-owned flags no longer swallow the next native flag, and `--prompt=` routes through headless delegation consistently with `--prompt `. diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index f60734de..69b9ae11 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -171,6 +171,13 @@ ccs auth resources work --mode profile-local ccs auth resources work --mode shared ``` +Dashboard: + +- Open `ccs config` +- Go to `Accounts` +- Use `Resources` to switch an existing account between `shared` and `profile-local` +- Go to `Shared Resources` to inspect the shared commands, skills, agents, plugins, and `settings.json` hub + No account recreation required for this workflow. ### Backup Before Changing Sync @@ -191,7 +198,7 @@ ccs auth backup default - Shared context is local filesystem sharing. It does not bypass remote provider permission models. - Session continuity still depends on what the upstream tool/provider stores and allows. - Context sharing should only be enabled for accounts you intentionally trust to share workspace history. -- Dashboard controls for existing-account Shared Resources are tracked separately; CLI/API support is the source of truth until that UI lands. +- Shared Resources inspection is read-only in the dashboard. Editing individual files still belongs to the owning command, skill, plugin, or settings surface. ## Alternative: CLIProxy Claude Pool diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 05830d71..3ed44425 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -1,7 +1,7 @@ /** * Shared Data Routes (Phase 07) * - * API routes for commands, skills, agents from ~/.ccs/shared/ + * API routes for commands, skills, agents, and plugins from ~/.ccs/shared/ */ import { Router, Request, Response } from 'express'; @@ -33,13 +33,14 @@ 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; -type SharedCollectionType = 'commands' | 'skills' | 'agents'; +type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins'; +type SharedContentType = SharedCollectionType | 'settings'; interface SharedItem { name: string; description: string; path: string; - type: 'command' | 'skill' | 'agent'; + type: 'command' | 'skill' | 'agent' | 'plugin'; } interface SharedItemsCacheEntry { @@ -75,13 +76,21 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => { }); /** - * GET /api/shared/content?type=commands|skills|agents&path= + * GET /api/shared/plugins + */ +sharedRoutes.get('/plugins', (_req: Request, res: Response) => { + const items = getSharedItems('plugins'); + res.json({ items }); +}); + +/** + * GET /api/shared/content?type=commands|skills|agents|plugins|settings&path= */ sharedRoutes.get('/content', (req: Request, res: Response) => { const typeParam = req.query.type; const itemPathParam = req.query.path; - if (!isSharedCollectionType(typeParam)) { + if (!isSharedContentType(typeParam)) { res.status(400).json({ error: 'Invalid or missing type parameter' }); return; } @@ -91,14 +100,18 @@ sharedRoutes.get('/content', (req: Request, res: Response) => { } const ccsDir = getCcsDir(); - const sharedDir = path.join(ccsDir, 'shared', typeParam); + const sharedDir = + typeParam === 'settings' ? path.join(ccsDir, 'shared') : path.join(ccsDir, 'shared', typeParam); if (!fs.existsSync(sharedDir)) { res.status(404).json({ error: 'Shared directory not found' }); return; } const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); - const allowedRoots = resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); + const allowedRoots = + typeParam === 'settings' + ? new Set([sharedDirRoot]) + : resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); if (!contentResult) { @@ -116,18 +129,34 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { const commands = getSharedItems('commands').length; const skills = getSharedItems('skills').length; const agents = getSharedItems('agents').length; + const plugins = getSharedItems('plugins').length; + + const ccsDir = getCcsDir(); + const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); + const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared')); + const resolvedSettingsPath = safeRealPath(settingsPath); + const hasSettings = + Boolean(sharedDirRoot) && + Boolean(resolvedSettingsPath) && + isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string); res.json({ commands, skills, agents, - total: commands + skills + agents, + plugins, + settings: hasSettings, + total: commands + skills + agents + plugins + (hasSettings ? 1 : 0), symlinkStatus: checkSymlinkStatus(), }); }); function isSharedCollectionType(value: unknown): value is SharedCollectionType { - return value === 'commands' || value === 'skills' || value === 'agents'; + return value === 'commands' || value === 'skills' || value === 'agents' || value === 'plugins'; +} + +function isSharedContentType(value: unknown): value is SharedContentType { + return isSharedCollectionType(value) || value === 'settings'; } function resolveAllowedRoots( @@ -135,7 +164,7 @@ function resolveAllowedRoots( ccsDir: string, sharedDirRoot: string ): Set { - if (type === 'commands') { + if (type === 'commands' || type === 'plugins') { return new Set([sharedDirRoot]); } @@ -250,13 +279,35 @@ function getCommandItems(sharedDir: string, allowedRoots: Set): SharedIt } function getSkillOrAgentItem( - type: 'skills' | 'agents', + type: 'skills' | 'agents' | 'plugins', entry: fs.Dirent, entryPath: string, resolvedEntryPath: string, allowedRoots: Set, stats: fs.Stats ): SharedItem | null { + if (type === 'plugins') { + if (!stats.isDirectory()) { + return null; + } + + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'README.md'), + path.join(resolvedEntryPath, 'readme.md'), + path.join(resolvedEntryPath, 'PLUGIN.md'), + ], + allowedRoots + ); + + return { + name: entry.name, + description: description ?? getDirectoryInventoryDescription(resolvedEntryPath, allowedRoots), + path: entryPath, + type: 'plugin', + }; + } + if (type === 'skills') { if (!stats.isDirectory()) { return null; @@ -529,10 +580,14 @@ function resolveReadableMarkdownPath( } function getSharedItemContent( - type: SharedCollectionType, + type: SharedContentType, itemPath: string, allowedRoots: Set ): { content: string; contentPath: string } | null { + if (type === 'settings') { + return getSharedSettingsContent(itemPath, allowedRoots); + } + const resolvedItemPath = safeRealPath(itemPath); if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { return null; @@ -559,6 +614,26 @@ function getSharedItemContent( [path.join(resolvedItemPath, 'SKILL.md')], allowedRoots ); + } else if (type === 'plugins') { + if (!itemStats.isDirectory()) { + return null; + } + + markdownPath = resolveReadableMarkdownPath( + [ + path.join(resolvedItemPath, 'README.md'), + path.join(resolvedItemPath, 'readme.md'), + path.join(resolvedItemPath, 'PLUGIN.md'), + ], + allowedRoots + ); + + if (!markdownPath) { + return { + content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots), + contentPath: resolvedItemPath, + }; + } } else { if (itemStats.isDirectory()) { markdownPath = resolveReadableMarkdownPath( @@ -597,6 +672,102 @@ function safeRealPath(targetPath: string): string | null { } } +function getSharedSettingsContent( + itemPath: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + if (path.basename(itemPath) !== 'settings.json') { + return null; + } + + const sharedRoot = Array.from(allowedRoots)[0]; + if (!sharedRoot) { + return null; + } + + const settingsPath = path.join(sharedRoot, 'settings.json'); + const resolvedSettingsPath = safeRealPath(settingsPath); + if (!resolvedSettingsPath || !isPathWithinAny(resolvedSettingsPath, allowedRoots)) { + return null; + } + + let stats: fs.Stats; + try { + stats = fs.statSync(resolvedSettingsPath); + } catch { + return null; + } + + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + try { + const rawContent = fs.readFileSync(resolvedSettingsPath, 'utf8'); + const parsed = JSON.parse(rawContent) as unknown; + return { + content: JSON.stringify(parsed, null, 2), + contentPath: resolvedSettingsPath, + }; + } catch { + const content = readMarkdownContent(resolvedSettingsPath, allowedRoots); + return content + ? { + content, + contentPath: resolvedSettingsPath, + } + : null; + } +} + +function getDirectoryInventoryDescription( + directoryPath: string, + allowedRoots: Set +): string { + const entries = listDirectoryEntryNames(directoryPath, allowedRoots); + if (entries.length === 0) { + return 'Empty directory'; + } + + return trimDescription( + `Directory with ${entries.length} ${entries.length === 1 ? 'item' : 'items'}: ${entries.join(', ')}` + ); +} + +function renderPluginDirectoryContent( + displayPath: string, + resolvedPath: string, + allowedRoots: Set +): string { + const name = path.basename(displayPath); + const lines = [`# Plugin directory: ${name}`, '', `Path: \`${displayPath}\``]; + + const entries = listDirectoryEntryNames(resolvedPath, allowedRoots); + if (entries.length > 0) { + lines.push('', '## Directory contents', '', ...entries.map((entry) => `- ${entry}`)); + } else { + lines.push('', 'No files or folders found in this plugin directory.'); + } + + return lines.join('\n'); +} + +function listDirectoryEntryNames(directoryPath: string, allowedRoots: Set): string[] { + if (!isPathWithinAny(directoryPath, allowedRoots)) { + return []; + } + + try { + return fs + .readdirSync(directoryPath, { withFileTypes: true }) + .slice(0, 50) + .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } +} + function isPathWithin(candidatePath: string, basePath: string): boolean { const normalizedCandidate = normalizeForPathComparison(candidatePath); const normalizedBase = normalizeForPathComparison(basePath); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index defdd1d3..7f23c7e3 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -285,6 +285,98 @@ 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 () => { + 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'), '{}'); + + const listPayload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/plugins'); + + expect(listPayload.items).toEqual([ + { + name: 'cache', + type: 'plugin', + description: 'Directory with 2 items: payloads/, plugin-index.json', + path: cacheDir, + }, + ]); + + const contentPayload = await getJson<{ content: string; contentPath: string }>( + baseUrl, + `/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + ); + + 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)); + }); + + it('returns real shared settings content when settings.json is present', async () => { + const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { ANTHROPIC_MODEL: 'claude-sonnet-4-5' }, + permissions: { allow: ['Bash(git status)'] }, + }, + null, + 2 + ) + ); + + 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-sonnet-4-5"'); + expect(contentPayload.content).toContain('"allow"'); + expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath)); + }); + + 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'); + fs.writeFileSync(outsideSettingsPath, '{"env":{"SECRET":"do-not-read"}}'); + createFileSymlink(outsideSettingsPath, settingsPath); + + 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'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs'); + + const payload = await getJson<{ content: string; contentPath: string }>( + baseUrl, + `/api/shared/content?type=plugins&path=${encodeURIComponent(pluginDir)}` + ); + + expect(payload.content).toContain('Shared docs'); + expect(payload.contentPath).toBe(fs.realpathSync(path.join(pluginDir, 'README.md'))); + }); + it('does not use markdown frontmatter fences as descriptions when frontmatter is malformed', async () => { const commandsDir = path.join(ccsDir, 'shared', 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 0a409a6f..2c534666 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -25,9 +25,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react'; +import { Box, Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { useTranslation } from 'react-i18next'; import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog'; +import { EditAccountSharedResourcesDialog } from '@/components/account/edit-account-shared-resources-dialog'; import { useSetDefaultAccount, useDeleteAccount, @@ -57,6 +59,7 @@ export function AccountsTable({ const updateContextMutation = useUpdateAccountContext(); const [deleteTarget, setDeleteTarget] = useState(null); const [contextTarget, setContextTarget] = useState(null); + const [resourcesTarget, setResourcesTarget] = useState(null); const columns: ColumnDef[] = [ { @@ -164,6 +167,42 @@ export function AccountsTable({ ); }, }, + { + id: 'resources', + header: t('accountsTable.sharedResources'), + size: 150, + cell: ({ row }) => { + if (row.original.type === 'cliproxy') { + return -; + } + + const mode = row.original.shared_resource_mode || 'shared'; + const isInferred = row.original.shared_resource_inferred; + + return ( +
+ + {mode === 'shared' + ? t('accountsTable.resourcesShared') + : t('accountsTable.resourcesProfileLocal')} + + {isInferred && ( +

+ {t('accountsTable.legacyReview')} +

+ )} +
+ ); + }, + }, { id: 'actions', header: t('accountsTable.actions'), @@ -193,6 +232,19 @@ export function AccountsTable({ {t('accountsTable.sync')} )} + {!isCliproxy && ( + + )} {!isCliproxy && hasLegacyInference && ( + + + + + ); +} diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 890ff1ac..8440e2c1 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -208,3 +208,28 @@ export function useConfirmLegacyAccountPolicies() { }, }); } +export function useUpdateAccountSharedResources() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ + name, + shared_resource_mode, + }: { + name: string; + shared_resource_mode: 'shared' | 'profile-local'; + }) => api.accounts.updateSharedResources(name, { shared_resource_mode }), + onSuccess: (_data, vars) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + const resourceSummary = + vars.shared_resource_mode === 'shared' + ? t('accountsPage.resourcesShared') + : t('accountsPage.resourcesProfileLocal'); + toast.success(t('toasts.resourcesUpdated', { name: vars.name, summary: resourceSummary })); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/hooks/use-shared.ts b/ui/src/hooks/use-shared.ts index 09a726b5..ecba2ea7 100644 --- a/ui/src/hooks/use-shared.ts +++ b/ui/src/hooks/use-shared.ts @@ -4,13 +4,17 @@ export interface SharedItem { name: string; description: string; path: string; - type: 'command' | 'skill' | 'agent'; + type: 'command' | 'skill' | 'agent' | 'plugin'; } -interface SharedSummary { +export type SharedResourceTab = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; + +export interface SharedSummary { commands: number; skills: number; agents: number; + plugins: number; + settings: boolean; total: number; symlinkStatus: { valid: boolean; message: string }; } @@ -99,20 +103,22 @@ export function useSharedSummary() { }); } -export function useSharedItems(type: 'commands' | 'skills' | 'agents') { +export function useSharedItems(type: SharedResourceTab) { return useQuery<{ items: SharedItem[] }>({ queryKey: ['shared', type], + enabled: type !== 'settings', queryFn: async () => { + if (type === 'settings') { + return { items: [] }; + } + const res = await fetch(`/api/shared/${type}`); return readJsonOrThrow<{ items: SharedItem[] }>(res, `Failed to fetch shared ${type}`); }, }); } -export function useSharedItemContent( - type: 'commands' | 'skills' | 'agents', - itemPath: string | null -) { +export function useSharedItemContent(type: SharedResourceTab, itemPath: string | null) { return useQuery({ queryKey: ['shared', type, 'content', itemPath], enabled: typeof itemPath === 'string' && itemPath.length > 0, diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 3dca15a6..0eec23f4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -934,10 +934,16 @@ export interface Account { continuity_mode?: 'standard' | 'deeper'; context_inferred?: boolean; continuity_inferred?: boolean; + shared_resource_mode?: 'shared' | 'profile-local'; + shared_resource_inferred?: boolean; provider?: string; displayName?: string; } +export interface UpdateAccountSharedResources { + shared_resource_mode: 'shared' | 'profile-local'; +} + export interface PlainCcsLane { kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient'; label: string; @@ -1459,6 +1465,11 @@ export const api = { method: 'PUT', body: JSON.stringify(data), }), + updateSharedResources: (name: string, data: UpdateAccountSharedResources) => + request(`/accounts/${encodeURIComponent(name)}/shared-resources`, { + method: 'PUT', + body: JSON.stringify(data), + }), }, // Unified config API config: { diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4a6a2f03..99e4e0f7 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -28,7 +28,7 @@ const resources = { claudeExtension: 'Claude Extension', accounts: 'Accounts', allAccounts: 'All Accounts', - sharedData: 'Shared Data', + sharedData: 'Shared Resources', compatibleClis: 'Compatible', deprecated: 'Deprecated', factoryDroid: 'Factory Droid', @@ -96,6 +96,7 @@ const resources = { settingsSaved: 'Settings saved', defaultTargetUpdated: 'Default target updated', failedUpdateDefaultTarget: 'Failed to update default target to {{target}}{{suffix}}', + resourcesUpdated: 'Shared resources updated', }, profileDialog: { editTitle: 'Edit Profile', @@ -675,8 +676,10 @@ const resources = { created: 'Created', lastUsed: 'Last Used', historySync: 'History Sync', + sharedResources: 'Resources', actions: 'Actions', syncTitle: 'Edit sync mode, group, and continuity depth', + resourcesTitle: 'Edit shared resource inheritance', confirmLegacyTitle: "Confirm this legacy account's current mode as explicit", sync: 'Sync', confirm: 'Confirm', @@ -696,6 +699,8 @@ const resources = { sharedGroupLegacy: 'shared ({{group}}, projects only, legacy)', isolatedLegacy: 'isolated (legacy default)', isolated: 'isolated', + resourcesShared: 'shared', + resourcesProfileLocal: 'profile-local', noSameGroupPeer: 'No same-group peer yet', sameGroupPeerCount_one: '{{count}} same-group peer', sameGroupPeerCount_other: '{{count}} same-group peers', @@ -706,6 +711,7 @@ const resources = { deeper: 'Deeper', legacy: 'Legacy', isolated: 'Isolated', + profileLocal: 'Local', }, }, addAccountDialog: { @@ -1196,6 +1202,9 @@ const resources = { quickCommandsDesc: 'Copy and run in terminal.', workspaceBadge: 'ccs auth Workspace', historySyncBadge: 'History & Resume Controls', + resourcesBadge: 'Shared Resources Controls', + resourcesShared: 'Shared', + resourcesProfileLocal: 'Profile Local', authAccounts: 'Auth Accounts', tableScopePrefix: 'This table is intentionally scoped to', tableScopeMiddle: 'accounts. Use', @@ -1494,9 +1503,11 @@ const resources = { commands: 'Commands', skills: 'Skills', agents: 'Agents', - title: 'Shared Data', - subtitle: 'Commands, skills, and agents shared across Claude instances', - totalShared: 'Total Shared', + plugins: 'Plugins', + settings: 'Settings', + title: 'Shared Resources', + subtitle: 'Commands, skills, agents, and configuration shared across accounts', + totalShared: 'Total Resources', visible: 'Visible', markdownDetail: 'Markdown detail view', filterPrefix: 'Filter:', @@ -1517,9 +1528,41 @@ const resources = { pathLabel: 'Path', resolvedSource: 'Resolved Source', loadingMarkdown: 'Loading markdown content...', + loadingSettings: 'Loading settings content...', failedLoadContent: 'Failed to load content', retryContent: 'Retry content', noMarkdown: 'No markdown content available.', + settingsTitle: 'settings.json', + settingsDescription: + 'This file contains shared settings that are linked across all shared-mode accounts.', + settingsPath: 'Shared Settings Path', + settingsActive: 'Active', + settingsInactive: 'Not Found', + resourcePolicies: 'Resource policies', + resourcePoliciesDescription: + 'Accounts marked shared inherit this hub. Profile-local accounts keep separate commands, skills, agents, plugins, and settings.', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} more', + }, + editAccountSharedResources: { + title: 'Edit Shared Resources', + description: + 'Configure how "{{name}}" inherits commands, skills, and settings from the CCS shared directory.', + resourceMode: 'Resource Mode', + selectResourceMode: 'Select resource mode', + sharedOption: 'Shared', + sharedHint: 'Inherits commands, skills, and agents from the shared directory.', + profileLocalOption: 'Profile Local', + profileLocalHint: "Uses isolated resources in the account's private directory.", + implicationTitle: 'What this means', + sharedImplication: + 'This account will see all tools and configuration in the shared resource hub.', + profileLocalImplication: + 'This account will not see shared tools. It stays fully isolated for security or testing.', + cancel: 'Cancel', + save: 'Save', + saving: 'Saving...', }, // ======================================== @@ -4033,8 +4076,10 @@ const resources = { commands: '命令', skills: '技能', agents: '代理', - title: '共享数据', - subtitle: '在 Claude 实例间共享的命令、技能和代理', + plugins: '插件', + settings: '设置', + title: '共享资源', + subtitle: '在账号之间共享的命令、技能、代理、插件和配置', totalShared: '共享总数', visible: '可见', markdownDetail: 'Markdown 详情视图', @@ -4055,9 +4100,21 @@ const resources = { pathLabel: '路径', resolvedSource: '解析后来源', loadingMarkdown: '正在加载 Markdown 内容...', + loadingSettings: '正在加载设置内容...', failedLoadContent: '加载内容失败', retryContent: '重试内容加载', noMarkdown: '暂无 Markdown 内容。', + settingsTitle: 'settings.json', + settingsDescription: '此文件包含所有 shared 模式账号链接使用的共享设置。', + settingsPath: '共享设置路径', + settingsActive: '已启用', + settingsInactive: '未找到', + resourcePolicies: '资源策略', + resourcePoliciesDescription: + '标记为 shared 的账号继承此中心。profile-local 账号保留单独的命令、技能、代理、插件和设置。', + sharedAccounts: '{{count}} 个 shared', + profileLocalAccounts: '{{count}} 个 profile-local', + moreProfileLocalAccounts: '+{{count}} 个更多', }, heroSection: { title: 'CCS Config', @@ -6623,8 +6680,10 @@ const resources = { commands: 'Lệnh', skills: 'Kỹ năng', agents: 'Agent', - title: 'Dữ liệu dùng chung', - subtitle: 'Các lệnh, kỹ năng và agent được chia sẻ giữa các phiên Claude', + plugins: 'Plugin', + settings: 'Cài đặt', + title: 'Tài nguyên dùng chung', + subtitle: 'Lệnh, kỹ năng, agent, plugin và cấu hình được chia sẻ giữa các tài khoản', totalShared: 'Tổng số mục dùng chung', visible: 'Hiển thị', markdownDetail: 'Chế độ xem chi tiết Markdown', @@ -6646,9 +6705,22 @@ const resources = { pathLabel: 'Đường dẫn', resolvedSource: 'Nguồn đã giải quyết', loadingMarkdown: 'Đang tải nội dung Markdown...', + loadingSettings: 'Đang tải nội dung cài đặt...', failedLoadContent: 'Không tải được nội dung', retryContent: 'Thử lại nội dung', noMarkdown: 'Không có nội dung Markdown khả dụng.', + settingsTitle: 'settings.json', + settingsDescription: + 'Tệp này chứa cài đặt dùng chung được liên kết qua mọi tài khoản ở chế độ shared.', + settingsPath: 'Đường dẫn cài đặt dùng chung', + settingsActive: 'Đang hoạt động', + settingsInactive: 'Không tìm thấy', + resourcePolicies: 'Chính sách tài nguyên', + resourcePoliciesDescription: + 'Tài khoản shared kế thừa hub này. Tài khoản profile-local giữ lệnh, kỹ năng, agent, plugin và cài đặt riêng.', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} khác', }, heroSection: { title: 'CCS Config', @@ -9238,8 +9310,10 @@ const resources = { commands: 'コマンド', skills: 'スキル', agents: 'エージェント', - title: '共有データ', - subtitle: 'Claudeインスタンス間で共有されるコマンド、スキル、エージェント', + plugins: 'プラグイン', + settings: '設定', + title: '共有リソース', + subtitle: 'アカウント間で共有されるコマンド、スキル、エージェント、プラグイン、設定', totalShared: '共有数', visible: '表示中', markdownDetail: 'Markdown詳細ビュー', @@ -9261,9 +9335,22 @@ const resources = { pathLabel: 'パス', resolvedSource: '解決後ソース', loadingMarkdown: 'Markdownコンテンツを読み込み中...', + loadingSettings: '設定内容を読み込み中...', failedLoadContent: 'コンテンツの読み込みに失敗しました', retryContent: '再読み込み', noMarkdown: 'Markdownコンテンツはありません。', + settingsTitle: 'settings.json', + settingsDescription: + 'このファイルには shared モードの全アカウントにリンクされる共有設定が含まれます。', + settingsPath: '共有設定パス', + settingsActive: '有効', + settingsInactive: '未検出', + resourcePolicies: 'リソースポリシー', + resourcePoliciesDescription: + 'shared のアカウントはこのハブを継承します。profile-local のアカウントはコマンド、スキル、エージェント、プラグイン、設定を分離します。', + sharedAccounts: '{{count}} shared', + profileLocalAccounts: '{{count}} profile-local', + moreProfileLocalAccounts: '+{{count}} 件', }, accountCardStats: { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 2e729551..9ded7061 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -176,6 +176,7 @@ export function AccountsPage() {
{t('accountsPage.workspaceBadge')} {t('accountsPage.historySyncBadge')} + {t('accountsPage.resourcesBadge')}

{t('accountsPage.authAccounts')} diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 5b632569..4b3b6328 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -6,6 +6,8 @@ import { Card, CardContent } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; 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 { cn } from '@/lib/utils'; import '@/lib/i18n'; @@ -14,6 +16,8 @@ import { AlertCircle, AlertTriangle, Bot, + Box, + FileJson, FileText, FolderOpen, RefreshCw, @@ -21,7 +25,7 @@ import { Sparkles, } from 'lucide-react'; -type TabType = 'commands' | 'skills' | 'agents'; +type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; export function SharedPage() { const { t } = useTranslation(); @@ -30,6 +34,8 @@ export function SharedPage() { commands: t('sharedPage.commands'), skills: t('sharedPage.skills'), agents: t('sharedPage.agents'), + plugins: t('sharedPage.plugins'), + settings: t('sharedPage.settings'), }; const [query, setQuery] = useState(''); @@ -41,6 +47,7 @@ export function SharedPage() { error: summaryError, refetch: refetchSummary, } = useSharedSummary(); + const { data: accountsView } = useAccounts(); const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab); const allItems = items?.items ?? []; const normalizedQuery = query.trim().toLowerCase(); @@ -71,23 +78,43 @@ export function SharedPage() { return filteredItems.find((item) => item.path === selectedItemPath) ?? filteredItems[0]; }, [filteredItems, selectedItemPath]); + const contentPath = + tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); const { data: selectedItemContent, isLoading: isContentLoading, isError: isContentError, error: contentError, refetch: refetchContent, - } = useSharedItemContent(tab, selectedItem?.path ?? null); + } = useSharedItemContent(tab, contentPath); - const tabs: { id: TabType; label: string; icon: typeof FileText; count: number }[] = [ + const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [ { id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 }, { id: 'skills', label: tabLabels.skills, icon: Sparkles, count: summary?.skills ?? 0 }, { id: 'agents', label: tabLabels.agents, icon: Bot, count: summary?.agents ?? 0 }, + { id: 'plugins', label: tabLabels.plugins, icon: Box, count: summary?.plugins ?? 0 }, + { + id: 'settings', + label: tabLabels.settings, + icon: FileJson, + count: summary?.settings ? 1 : 0, + }, ]; - const totalSharedItems = tabs.reduce((sum, tabOption) => sum + tabOption.count, 0); + 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 hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; + const sharedResourceAccounts = + accountsView?.accounts.filter( + (account) => (account.shared_resource_mode || 'shared') === 'shared' + ) ?? []; + const profileLocalResourceAccounts = + accountsView?.accounts.filter((account) => account.shared_resource_mode === 'profile-local') ?? + []; // TODO i18n: missing key for "Shared item totals could not be loaded. Listing still works." const summaryErrorMessage = getSharedErrorMessage( @@ -104,7 +131,7 @@ export function SharedPage() { ); return ( -
+
@@ -136,8 +163,8 @@ export function SharedPage() {
- - + +
@@ -183,21 +210,29 @@ export function SharedPage() { )} + + {accountsView ? ( + account.name)} + /> + ) : null}
-
-
-
-
+
+
+
+

{tabLabels[tab]}

- {!isLoading && !isError && ( + {showListStatus && ( - {filteredItems.length}/{allItems.length} + {currentVisibleCount}/{currentItemCount} )}
@@ -213,11 +248,11 @@ export function SharedPage() { />
- {!isLoading && !isError && ( + {showListStatus && (

{t('sharedPage.showing', { - visible: filteredItems.length, - total: allItems.length, + visible: currentVisibleCount, + total: currentItemCount, tab, })} {activeQuery ? t('sharedPage.showingQuery', { query: activeQuery }) : ''} @@ -226,8 +261,35 @@ export function SharedPage() { )}

- - {isLoading ? ( + + {tab === 'settings' ? ( +
+ +
+ ) : isLoading ? (
{t('sharedPage.loadingShared', { tab })}
@@ -289,12 +351,94 @@ export function SharedPage() {
-
- {!selectedItem ? ( +
+ {!selectedItem && tab !== 'settings' ? (
{t('sharedPage.selectOne', { tab: tab.slice(0, -1) })}
- ) : ( + ) : tab === 'settings' ? ( + <> +
+
+

settings.json

+ + {t('sharedPage.settings')} + +
+
+ +
+
+
+ + {selectedItemContent?.contentPath ? ( + + ) : null} +
+
+ + + + + {!summary?.settings ? ( +
+ +

+ {t('sharedPage.settingsTitle')} +

+

+ {t('sharedPage.settingsDescription')} +

+
+ {t('sharedPage.settingsInactive')} +
+
+ ) : isContentLoading ? ( +

+ {t('sharedPage.loadingSettings')} +

+ ) : isContentError ? ( + + + {t('sharedPage.failedLoadContent')} + +

{contentErrorMessage}

+
+ +
+
+
+ ) : ( + {}} + language="json" + readonly + minHeight="320px" + /> + )} +
+
+
+
+ + ) : selectedItem ? ( <>
@@ -359,7 +503,7 @@ export function SharedPage() {
- )} + ) : null}
@@ -368,6 +512,58 @@ export function SharedPage() { ); } +function ResourcePoliciesPanel({ + sharedCount, + profileLocalCount, + profileLocalNames, +}: { + sharedCount: number; + profileLocalCount: number; + profileLocalNames: string[]; +}) { + const { t } = useTranslation(); + const displayedProfileLocalNames = profileLocalNames.slice(0, 4); + const hiddenProfileLocalCount = Math.max( + profileLocalNames.length - displayedProfileLocalNames.length, + 0 + ); + + return ( +
+
+
+

{t('sharedPage.resourcePolicies')}

+

+ {t('sharedPage.resourcePoliciesDescription')} +

+
+ +
+ + {t('sharedPage.sharedAccounts', { count: sharedCount })} + + + {t('sharedPage.profileLocalAccounts', { count: profileLocalCount })} + + {displayedProfileLocalNames.map((name) => ( + + {name} + + ))} + {hiddenProfileLocalCount > 0 ? ( + + {t('sharedPage.moreProfileLocalAccounts', { count: hiddenProfileLocalCount })} + + ) : null} +
+
+
+ ); +} + function HeaderMetricCard({ label, value }: { label: string; value: number }) { return (
diff --git a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx index ae70d999..37bc8e6a 100644 --- a/ui/tests/unit/ui/pages/shared/shared-page.test.tsx +++ b/ui/tests/unit/ui/pages/shared/shared-page.test.tsx @@ -21,6 +21,29 @@ function requestUrl(input: RequestInfo | URL): string { return input.url; } +function accountsResponse() { + return jsonResponse({ + default: 'work', + plain_ccs_lane: null, + accounts: [ + { + name: 'work', + type: 'oauth', + created: '2026-05-01T00:00:00.000Z', + context_mode: 'isolated', + shared_resource_mode: 'shared', + }, + { + name: 'sandbox', + type: 'oauth', + created: '2026-05-02T00:00:00.000Z', + context_mode: 'isolated', + shared_resource_mode: 'profile-local', + }, + ], + }); +} + describe('SharedPage', () => { const fetchMock = vi.fn(); @@ -41,10 +64,15 @@ describe('SharedPage', () => { commands: 0, skills: 0, agents: 0, + plugins: 0, + settings: false, total: 0, symlinkStatus: { valid: true, message: 'Symlinks active' }, }); } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } if (url.endsWith('/api/shared/commands')) { return jsonResponse({ error: 'Backend unavailable' }, 500); } @@ -67,10 +95,15 @@ describe('SharedPage', () => { commands: 1, skills: 0, agents: 0, + plugins: 0, + settings: false, total: 1, symlinkStatus: { valid: true, message: 'Symlinks active' }, }); } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } if (url.endsWith('/api/shared/commands')) { return jsonResponse({ items: [ @@ -109,6 +142,115 @@ describe('SharedPage', () => { expect(await screen.findByText('No commands match "no-match".')).toBeInTheDocument(); }); + it('loads plugin directory content and renders real settings content', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 0, + agents: 0, + plugins: 1, + 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/plugins')) { + return jsonResponse({ + items: [ + { + name: 'cache', + description: 'Directory with 2 items: payloads/, plugin-index.json', + path: '/tmp/plugins/cache', + type: 'plugin', + }, + ], + }); + } + 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', + }); + } + if (url.includes('/api/shared/content?') && url.includes('type=settings')) { + return jsonResponse({ + content: + '{\n "env": {\n "ANTHROPIC_MODEL": "claude-sonnet-4-5"\n },\n "permissions": {\n "allow": [\n "Bash(git status)"\n ]\n }\n}', + contentPath: '/tmp/.ccs/shared/settings.json', + }); + } + + return jsonResponse({ error: `Unexpected request: ${url}` }, 404); + }); + + render(); + + await userEvent.click(await screen.findByRole('tab', { name: /Plugins/ })); + + expect( + await screen.findByText('Directory with 2 items: payloads/, plugin-index.json') + ).toBeInTheDocument(); + expect(await screen.findByText('plugin-index.json')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('tab', { name: /Settings/ })); + + expect(await screen.findByText('Showing 1 of 1 settings')).toBeInTheDocument(); + expect( + await screen.findByText((content) => + content.includes('"ANTHROPIC_MODEL": "claude-sonnet-4-5"') + ) + ).toBeInTheDocument(); + expect(screen.getByText('/tmp/.ccs/shared/settings.json')).toBeInTheDocument(); + + const requestedUrls = fetchMock.mock.calls.map(([input]) => requestUrl(input)); + expect( + requestedUrls.some( + (url) => url.includes('/api/shared/content?') && url.includes('type=settings') + ) + ).toBe(true); + }); + + it('shows account resource policy context on the shared hub', async () => { + fetchMock.mockImplementation(async (input) => { + const url = requestUrl(input); + if (url.endsWith('/api/shared/summary')) { + return jsonResponse({ + commands: 0, + skills: 0, + agents: 0, + plugins: 0, + settings: false, + total: 0, + symlinkStatus: { valid: true, message: 'Symlinks active' }, + }); + } + if (url.endsWith('/api/accounts')) { + return accountsResponse(); + } + if (url.endsWith('/api/shared/commands')) { + return jsonResponse({ items: [] }); + } + + return jsonResponse({ items: [] }); + }); + + render(); + + expect(await screen.findByText('Resource policies')).toBeInTheDocument(); + expect(screen.getByText('1 shared')).toBeInTheDocument(); + expect(screen.getByText('1 profile-local')).toBeInTheDocument(); + expect(screen.getByText('sandbox')).toBeInTheDocument(); + }); + it('shows offline guidance when network request fails', async () => { fetchMock.mockImplementation(async (input) => { const url = requestUrl(input);