From 64e1d1f815836802c1f2ed37758a9d4d104d9e14 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 8 May 2026 11:10:11 -0400 Subject: [PATCH 1/5] feat(auth): add shared resource controls --- docs/dashboard-auth-cli.md | 21 +- docs/session-sharing-technical-analysis.md | 26 ++- src/auth/auth-commands.ts | 27 ++- src/auth/commands/backup-command.ts | 3 +- src/auth/commands/create-command.ts | 35 +++- src/auth/commands/index.ts | 1 + src/auth/commands/list-command.ts | 13 +- src/auth/commands/resources-command.ts | 141 +++++++++++++ src/auth/commands/show-command.ts | 11 +- src/auth/commands/types.ts | 24 ++- src/auth/profile-continuity-inheritance.ts | 3 +- src/auth/profile-detector.ts | 1 + src/auth/profile-registry.ts | 9 +- src/auth/shared-resource-policy.ts | 97 +++++++++ src/commands/command-catalog.ts | 1 + src/commands/completion-backend.ts | 7 + src/commands/help-command.ts | 1 + src/commands/sync-command.ts | 5 +- src/config/migration-manager.ts | 11 + src/config/schemas/auth.ts | 2 + src/dispatcher/flows/account-flow.ts | 10 +- src/shared/claude-extension-setup.ts | 7 +- src/types/config.ts | 2 + .../routes/account-route-helpers.ts | 3 + src/web-server/routes/account-routes.ts | 112 ++++++++++- tests/unit/auth-command-args.test.ts | 14 ++ tests/unit/auth-resources-command.test.ts | 180 +++++++++++++++++ ...ile-registry-context-normalization.test.ts | 45 +++++ .../unit/auth/shared-resource-policy.test.ts | 55 +++++ tests/unit/commands/sync-command.test.ts | 18 +- tests/unit/config/migration-manager.test.ts | 37 ++++ .../web-server/account-routes-context.test.ts | 189 +++++++++++++++++- 32 files changed, 1068 insertions(+), 43 deletions(-) create mode 100644 src/auth/commands/resources-command.ts create mode 100644 src/auth/shared-resource-policy.ts create mode 100644 tests/unit/auth-resources-command.test.ts create mode 100644 tests/unit/auth/shared-resource-policy.test.ts diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index b2c2a33d..f99a6423 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -26,6 +26,7 @@ Dashboard auth and account context metadata are separate: - `dashboard_auth`: protects dashboard access with username/password - `accounts..context_mode/context_group`: controls isolated vs shared account context +- `accounts..shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing Account context is isolation-first. The recommended two-account route is: @@ -48,15 +49,22 @@ Shared continuity depth: - `standard` (default): shares project workspace context only - `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos` -`ccs auth show ` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. +`ccs auth show ` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane. -Non-bare account profiles share the basic Claude `settings.json` with native Claude: +Non-bare account profiles share Claude-local resources with native Claude: ```text ~/.ccs/instances//settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json ``` -This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. +This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`. `context_group` normalization and validation: @@ -83,6 +91,13 @@ Dashboard accounts context editing: - rejects CLIProxy OAuth account keys for this route - applies normalization/validation rules above +Shared resource editing: + +- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts +- accepts only `shared` or `profile-local` +- rejects CLIProxy OAuth account keys for this route +- reconciles the account instance after metadata is updated + ## Commands ### `ccs config auth setup` diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index a160cccb..f60734de 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -26,7 +26,7 @@ ccs personal This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials. -Basic Claude settings are shared for non-bare account profiles: +Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude: ```text ~/.ccs/instances//settings.json @@ -34,7 +34,17 @@ Basic Claude settings are shared for non-bare account profiles: -> ~/.claude/settings.json ``` -This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned. +This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show ` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned. + +For existing accounts, change Shared Resources from the CLI: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + +- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout. +- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting. Only opt in to shared history when both accounts should see the same local continuity: @@ -42,7 +52,7 @@ Only opt in to shared history when both accounts should see the same local conti ccs auth create work2 --share-context --context-group daily --deeper-continuity ``` -For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. +For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that. ## Why This Is Safe Enough @@ -147,11 +157,20 @@ ccs auth create backup2 --share-context --context-group sprint-a --deeper-contin ### Existing account +History Sync: + - Open `ccs config` - Go to `Accounts` - Click the pencil icon (`Edit History Sync`) - Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity +Shared Resources: + +```bash +ccs auth resources work --mode profile-local +ccs auth resources work --mode shared +``` + No account recreation required for this workflow. ### Backup Before Changing Sync @@ -172,6 +191,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. ## Alternative: CLIProxy Claude Pool diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index aef02f4c..dcd248b3 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -24,6 +24,7 @@ import { handleBackup, handleList, handleShow, + handleResources, handleRemove, handleDefault, handleResetDefault, @@ -74,6 +75,9 @@ class AuthCommands { ); console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('show ', 'command')} Show profile details`); + console.log( + ` ${color('resources ', 'command')} Show or change shared resource mode` + ); console.log(` ${color('remove ', 'command')} Remove saved profile`); console.log(` ${color('default ', 'command')} Set default profile`); console.log( @@ -105,6 +109,10 @@ class AuthCommands { console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`); console.log(` ${color('ccs auth create sandbox --bare', 'command')}`); console.log(''); + console.log(` ${dim('# Change shared resources for an existing account')}`); + console.log(` ${color('ccs auth resources work --mode profile-local', 'command')}`); + console.log(` ${color('ccs auth resources work --mode shared', 'command')}`); + console.log(''); console.log(` ${dim('# Set work as default')}`); console.log(` ${color('ccs auth default work', 'command')}`); console.log(''); @@ -136,6 +144,9 @@ class AuthCommands { console.log( ` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)` ); + console.log( + ` ${color('--mode ', 'command')} Shared resource mode for resources: shared|profile-local` + ); console.log( ` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)` ); @@ -160,6 +171,9 @@ class AuthCommands { console.log( ` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show ', 'command')} shows the link state.` ); + console.log( + ` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.` + ); console.log( ` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.` ); @@ -170,7 +184,10 @@ class AuthCommands { ` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default ', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.` ); console.log( - ` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ` Existing history sync: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.` + ); + console.log( + ` Existing shared resources: use ${color('ccs auth resources --mode shared|profile-local', 'command')}.` ); console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); console.log( @@ -204,6 +221,10 @@ class AuthCommands { return handleShow(this.getContext(), args); } + async handleResources(args: string[]): Promise { + return handleResources(this.getContext(), args); + } + /** * Remove profile - delegates to remove-command.ts */ @@ -263,6 +284,10 @@ class AuthCommands { await this.handleShow(commandArgs); break; + case 'resources': + await this.handleResources(commandArgs); + break; + case 'remove': await this.handleRemove(commandArgs); break; diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index 4a682367..e66068be 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -10,6 +10,7 @@ import { resolveRuntimePlainCcsResumeLane, } from '../resume-lane-diagnostics'; import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; +import { isProfileLocalSharedResourceMode } from '../shared-resource-policy'; import { CommandContext, parseArgs } from './types'; interface BackupManifest { @@ -69,7 +70,7 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise isAccountContextMetadata(profile) ? profile : undefined ); sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { - bare: profile.bare === true, + bare: isProfileLocalSharedResourceMode(profile), }); artifactNames = getContinuityArtifactNames('account'); } diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index df45122d..d407ace4 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -21,6 +21,10 @@ import { isValidAccountProfileName, resolveAccountContextPolicy, } from '../account-context'; +import { + isProfileLocalSharedResourceMode, + sharedResourceModeToMetadata, +} from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, parseArgs } from './types'; @@ -36,11 +40,20 @@ function sanitizeProfileNameForInstance(name: string): string { */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } = - parseArgs(args); + const { + profileName, + force, + shareContext, + contextGroup, + deeperContinuity, + bare, + mode, + unknownFlags, + } = parseArgs(args); - if (unknownFlags && unknownFlags.length > 0) { - const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + const unsupportedOptions = [...(unknownFlags ?? []), ...(mode !== undefined ? ['--mode'] : [])]; + if (unsupportedOptions.length > 0) { + const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); console.log(fail(`Unknown option(s): ${unknownList}`)); console.log(''); console.log( @@ -117,8 +130,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ? ctx.registry.getAllAccountsUnified()[profileName] : undefined; const previousBare = - previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true; + isProfileLocalSharedResourceMode(previousLegacyProfile) || + isProfileLocalSharedResourceMode(previousUnifiedProfile); const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare); + const resourceMetadata = effectiveBare ? sharedResourceModeToMetadata('profile-local') : {}; const previousContextPolicy = profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile) ? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile) @@ -200,13 +215,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise ctx.registry.updateAccountUnified(profileName, { context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); ctx.registry.touchAccountUnified(profileName); } else { ctx.registry.createAccountUnified(profileName, { ...contextMetadata, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } else { @@ -216,14 +231,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } else { ctx.registry.createProfile(profileName, { type: 'account', context_mode: contextMetadata.context_mode, context_group: contextMetadata.context_group, - ...(effectiveBare ? { bare: true } : {}), + ...resourceMetadata, }); } } @@ -282,7 +297,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise `Type: account\n` + `Context: ${formatAccountContextPolicy(contextPolicy)}\n` + `Tokens: isolated per account\n` + - `Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` + + `Resources: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude'}` + (effectiveBare ? '\nMode: bare (no shared symlinks)' : ''), 'Profile Created' ) diff --git a/src/auth/commands/index.ts b/src/auth/commands/index.ts index 81d55688..e21869d5 100644 --- a/src/auth/commands/index.ts +++ b/src/auth/commands/index.ts @@ -19,5 +19,6 @@ export { handleCreate } from './create-command'; export { handleBackup } from './backup-command'; export { handleList } from './list-command'; export { handleShow } from './show-command'; +export { handleResources } from './resources-command'; export { handleRemove } from './remove-command'; export { handleDefault, handleResetDefault } from './default-command'; diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index f25ab770..9172e90b 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -7,6 +7,7 @@ import { ProfileMetadata } from '../../types'; import { initUI, header, color, dim, warn, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; +import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; @@ -33,6 +34,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { const profile = profiles[name]; const contextPolicy = resolveAccountContextPolicy(profile); + const resourcePolicy = resolveSharedResourcePolicy(profile); const isDefault = name === defaultProfile; const instancePath = ctx.instanceMgr.getInstancePath(name); @@ -58,6 +62,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise { + await initUI(); + const { profileName, mode, json } = parseArgs(args); + + if (!profileName) { + console.log(fail('Profile name is required')); + console.log(''); + console.log( + `Usage: ${color('ccs auth resources [--mode shared|profile-local] [--json]', 'command')}` + ); + exitWithError('Profile name is required', ExitCode.PROFILE_ERROR); + } + + const profiles = ctx.registry.getAllProfilesMerged(); + const currentProfile = profiles[profileName]; + if (!currentProfile || currentProfile.type !== 'account') { + exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR); + } + + const currentPolicy = resolveSharedResourcePolicy(currentProfile); + + if (mode !== undefined && !isSharedResourceMode(mode)) { + exitWithError( + 'Invalid shared resource mode: expected shared|profile-local', + ExitCode.PROFILE_ERROR + ); + } + + if (!mode) { + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: currentPolicy.mode, + shared_resource_inferred: currentPolicy.inferred, + bare: currentPolicy.profileLocal ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(header(`Shared Resources: ${profileName}`)); + console.log(''); + console.log( + table( + [ + ['Mode', formatMode(currentPolicy.mode)], + ['Effective', modeDescription(currentPolicy.mode)], + ], + { colWidths: [14, 70] } + ) + ); + console.log(''); + return; + } + + const existsUnified = ctx.registry.hasAccountUnified(profileName); + const existsLegacy = ctx.registry.hasProfile(profileName); + const previousUnified = existsUnified + ? ctx.registry.getAllAccountsUnified()[profileName] + : undefined; + const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined; + const contextPolicy = resolveAccountContextPolicy(currentProfile); + const metadata = sharedResourceModeToMetadata(mode); + + try { + if (existsUnified) { + ctx.registry.updateAccountUnified(profileName, metadata); + } + if (existsLegacy) { + ctx.registry.updateProfile(profileName, metadata); + } + + await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { + bare: mode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + ctx.registry.updateAccountUnified(profileName, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + ctx.registry.updateProfile(profileName, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + if (json) { + console.log( + JSON.stringify( + { + name: profileName, + shared_resource_mode: mode, + shared_resource_inferred: false, + bare: mode === 'profile-local' ? true : undefined, + }, + null, + 2 + ) + ); + return; + } + + console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(mode)}`)); + console.log(''); + console.log(modeDescription(mode)); + console.log(''); +} diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 54ca56de..212ef7a1 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -10,6 +10,7 @@ import { initUI, header, color, fail, table } from '../../utils/ui'; import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context'; import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics'; import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics'; +import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; import { CommandContext, ProfileOutput, parseArgs } from './types'; @@ -45,7 +46,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise null); const plainCcsUsesThisAccount = @@ -74,6 +76,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise(); const knownBooleanFlags = new Set([ '--force', @@ -97,7 +101,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { '--deeper-continuity', '--bare', ]); - const knownValueFlags = new Set(['--context-group']); + const knownValueFlags = new Set(['--context-group', '--mode']); for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -114,11 +118,28 @@ export function parseArgs(args: string[]): AuthCommandArgs { continue; } + if (arg === '--mode') { + const next = args[i + 1]; + if (!next || next.startsWith('-')) { + mode = ''; + continue; + } + + mode = next; + i++; + continue; + } + if (arg.startsWith('--context-group=')) { contextGroup = arg.slice('--context-group='.length); continue; } + if (arg.startsWith('--mode=')) { + mode = arg.slice('--mode='.length); + continue; + } + if (arg.startsWith('-')) { const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg; const isKnownFlag = @@ -149,6 +170,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { shareContext: args.includes('--share-context'), deeperContinuity: args.includes('--deeper-continuity'), bare: args.includes('--bare'), + mode, contextGroup, unknownFlags: [...unknownFlags], }; diff --git a/src/auth/profile-continuity-inheritance.ts b/src/auth/profile-continuity-inheritance.ts index 1af11922..5d6575e3 100644 --- a/src/auth/profile-continuity-inheritance.ts +++ b/src/auth/profile-continuity-inheritance.ts @@ -4,6 +4,7 @@ import { warn } from '../utils/ui'; import InstanceManager from '../management/instance-manager'; import ProfileRegistry from './profile-registry'; import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context'; +import { isProfileLocalSharedResourceMode } from './shared-resource-policy'; import type { ProfileType } from '../types/profile'; import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat'; import { @@ -155,7 +156,7 @@ export async function resolveProfileContinuityInheritance( ); const instanceMgr = new InstanceManager(); const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, { - bare: mappedProfile.bare === true, + bare: isProfileLocalSharedResourceMode(mappedProfile), }); return { diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index f577c367..8fa35701 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -213,6 +213,7 @@ class ProfileDetector { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }, }; diff --git a/src/auth/profile-registry.ts b/src/auth/profile-registry.ts index 31c5482d..50f43107 100644 --- a/src/auth/profile-registry.ts +++ b/src/auth/profile-registry.ts @@ -12,6 +12,7 @@ import { loadOrCreateUnifiedConfig, mutateConfig, } from '../config/config-loader-facade'; +import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy'; const logger = createLogger('auth:profile-registry'); @@ -50,6 +51,7 @@ interface CreateMetadata { context_mode?: 'isolated' | 'shared'; context_group?: string; continuity_mode?: 'standard' | 'deeper'; + shared_resource_mode?: SharedResourceMode; bare?: boolean; } @@ -90,7 +92,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig { @@ -110,7 +112,7 @@ export class ProfileRegistry { normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard'; } - return normalized; + return normalizeSharedResourceMetadata(normalized); } /** @@ -179,6 +181,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); @@ -335,6 +338,7 @@ export class ProfileRegistry { context_mode: metadata.context_mode, context_group: metadata.context_group, continuity_mode: metadata.continuity_mode, + shared_resource_mode: metadata.shared_resource_mode, bare: metadata.bare, }); }); @@ -462,6 +466,7 @@ export class ProfileRegistry { context_mode: account.context_mode, context_group: account.context_group, continuity_mode: account.continuity_mode, + shared_resource_mode: account.shared_resource_mode, bare: account.bare, }; } diff --git a/src/auth/shared-resource-policy.ts b/src/auth/shared-resource-policy.ts new file mode 100644 index 00000000..a569a714 --- /dev/null +++ b/src/auth/shared-resource-policy.ts @@ -0,0 +1,97 @@ +export type SharedResourceMode = 'shared' | 'profile-local'; + +export interface SharedResourceMetadata { + shared_resource_mode?: unknown; + bare?: unknown; +} + +export interface SharedResourcePolicy { + mode: SharedResourceMode; + inferred: boolean; + profileLocal: boolean; +} + +export interface SharedResourceMetadataUpdate { + shared_resource_mode: SharedResourceMode; + bare?: boolean | undefined; +} + +export function isSharedResourceMode(value: unknown): value is SharedResourceMode { + return value === 'shared' || value === 'profile-local'; +} + +export function resolveSharedResourcePolicy( + metadata?: SharedResourceMetadata | null +): SharedResourcePolicy { + const explicitMode = metadata?.shared_resource_mode; + if (isSharedResourceMode(explicitMode)) { + return { + mode: explicitMode, + inferred: false, + profileLocal: explicitMode === 'profile-local', + }; + } + + if (metadata?.bare === true) { + return { + mode: 'profile-local', + inferred: true, + profileLocal: true, + }; + } + + return { + mode: 'shared', + inferred: true, + profileLocal: false, + }; +} + +export function isProfileLocalSharedResourceMode( + metadata?: SharedResourceMetadata | null +): boolean { + return resolveSharedResourcePolicy(metadata).profileLocal; +} + +export function sharedResourceModeToMetadata( + mode: SharedResourceMode +): SharedResourceMetadataUpdate { + if (mode === 'profile-local') { + return { + shared_resource_mode: 'profile-local', + bare: true, + }; + } + + return { + shared_resource_mode: 'shared', + bare: undefined, + }; +} + +export function normalizeSharedResourceMetadata(metadata: T): T { + const normalized = { ...metadata } as T & { + shared_resource_mode?: SharedResourceMode; + bare?: boolean; + }; + + if (normalized.shared_resource_mode === 'profile-local') { + normalized.bare = true; + return normalized; + } + + if (normalized.shared_resource_mode === 'shared') { + delete normalized.bare; + return normalized; + } + + delete normalized.shared_resource_mode; + if (normalized.bare === true) { + normalized.shared_resource_mode = 'profile-local'; + normalized.bare = true; + } else { + delete normalized.bare; + } + + return normalized; +} diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index a45b21e2..3dbe0371 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [ 'backup', 'list', 'show', + 'resources', 'remove', 'default', 'reset-default', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 660503af..78dfca09 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -108,6 +108,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']); if (subcommand === 'show') return completeSubcommands(getProfileNames('accounts'), ['--json']); + if (subcommand === 'resources') + return completeSubcommands(getProfileNames('accounts'), [ + '--mode', + '--mode=shared', + '--mode=profile-local', + '--json', + ]); if (subcommand === 'remove') return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']); if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts')); diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 014df452..4dd736fb 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise { 'Profile Types', [ { name: 'ccs auth create ', summary: 'Concurrent Claude account profile' }, + { name: 'ccs auth resources ', summary: 'Shared resources for an account profile' }, { name: 'ccs api create', summary: 'API-backed settings profile' }, { name: 'ccs cliproxy create ', summary: 'Named CLIProxy variant profile' }, { name: 'ccs env ', summary: 'Export an existing profile for other tools' }, diff --git a/src/commands/sync-command.ts b/src/commands/sync-command.ts index 004c5903..d24b9051 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise { const { InstanceManager } = await import('../management/instance-manager'); const instanceMgr = new InstanceManager(); const ProfileRegistry = (await import('../auth/profile-registry')).default; + const { isProfileLocalSharedResourceMode } = await import('../auth/shared-resource-policy'); const registry = new ProfileRegistry(); const allProfiles = registry.getAllProfilesMerged(); let mcpSynced = 0; for (const [name, profile] of Object.entries(allProfiles)) { - if (profile.bare) { - continue; // Skip bare profiles + if (isProfileLocalSharedResourceMode(profile)) { + continue; // Skip profile-local shared-resource profiles } if (!instanceMgr.hasInstance(name)) { diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index 650cd07d..f7490f7b 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -22,6 +22,7 @@ import { createEmptyUnifiedConfig } from './unified-config-types'; import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader'; import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context'; +import { isSharedResourceMode, resolveSharedResourcePolicy } from '../auth/shared-resource-policy'; import { infoBox, warn } from '../utils/ui'; const BACKUP_DIR_PREFIX = 'backup-v1-'; @@ -222,6 +223,16 @@ export async function migrate(dryRun = false): Promise { context_group: contextMode === 'shared' ? contextGroup : undefined, continuity_mode: contextMode === 'shared' ? continuityMode : undefined, }; + const resourcePolicy = resolveSharedResourcePolicy({ + shared_resource_mode: metadata.shared_resource_mode, + bare: metadata.bare, + }); + if (resourcePolicy.mode === 'profile-local') { + account.shared_resource_mode = 'profile-local'; + account.bare = true; + } else if (isSharedResourceMode(metadata.shared_resource_mode)) { + account.shared_resource_mode = 'shared'; + } unifiedConfig.accounts[name] = account; } migratedFiles.push('profiles.json → config.yaml.accounts'); diff --git a/src/config/schemas/auth.ts b/src/config/schemas/auth.ts index 4056ecf8..2b4eb944 100644 --- a/src/config/schemas/auth.ts +++ b/src/config/schemas/auth.ts @@ -27,6 +27,8 @@ export interface AccountConfig { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; + /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + shared_resource_mode?: 'shared' | 'profile-local'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } diff --git a/src/dispatcher/flows/account-flow.ts b/src/dispatcher/flows/account-flow.ts index 6fec0073..bf67bc80 100644 --- a/src/dispatcher/flows/account-flow.ts +++ b/src/dispatcher/flows/account-flow.ts @@ -7,6 +7,7 @@ import { execClaude } from '../../utils/shell-executor'; import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning'; +import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy'; import { resolveNativeClaudeLaunchArgs } from '../environment-builder'; import type { ProfileDispatchContext } from '../dispatcher-context'; @@ -26,10 +27,11 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise const accountMetadata = isAccountContextMetadata(profileInfo.profile) ? profileInfo.profile : undefined; - const isBareProfile = - typeof profileInfo.profile === 'object' && - profileInfo.profile !== null && - (profileInfo.profile as { bare?: unknown }).bare === true; + const isBareProfile = isProfileLocalSharedResourceMode( + typeof profileInfo.profile === 'object' && profileInfo.profile !== null + ? (profileInfo.profile as { shared_resource_mode?: unknown; bare?: unknown }) + : undefined + ); const contextPolicy = resolveAccountContextPolicy(accountMetadata); // Ensure instance exists (lazy init if needed) diff --git a/src/shared/claude-extension-setup.ts b/src/shared/claude-extension-setup.ts index 9f1a860e..f29e5e21 100644 --- a/src/shared/claude-extension-setup.ts +++ b/src/shared/claude-extension-setup.ts @@ -2,6 +2,7 @@ import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector import ProfileDetector from '../auth/profile-detector'; import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance'; import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context'; +import { isProfileLocalSharedResourceMode } from '../auth/shared-resource-policy'; import type { ProfileDetectionResult } from '../auth/profile-detector'; import { getEffectiveEnvVars, @@ -158,8 +159,12 @@ async function resolveExtensionEnv( const policy = resolveAccountContextPolicy( isAccountContextMetadata(result.profile) ? result.profile : undefined ); + const sharedResourceMetadata = + typeof result.profile === 'object' && result.profile !== null + ? (result.profile as { shared_resource_mode?: unknown; bare?: unknown }) + : undefined; const instancePath = await instanceManager.ensureInstance(result.name, policy, { - bare: result.profile?.bare === true, + bare: isProfileLocalSharedResourceMode(sharedResourceMetadata), }); notes.push('Account profiles authenticate through the isolated Claude config directory.'); return { diff --git a/src/types/config.ts b/src/types/config.ts index 47de4f0d..32c7f286 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -121,6 +121,8 @@ export interface ProfileMetadata { context_group?: string; /** Shared continuity depth when context_mode='shared' */ continuity_mode?: 'standard' | 'deeper'; + /** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */ + shared_resource_mode?: 'shared' | 'profile-local'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; } diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts index bbf05469..21f0e2e2 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -10,6 +10,9 @@ export interface MergedAccountEntry { continuity_mode?: 'standard' | 'deeper'; context_inferred?: boolean; continuity_inferred?: boolean; + shared_resource_mode?: 'shared' | 'profile-local'; + shared_resource_inferred?: boolean; + bare?: boolean; provider?: string; displayName?: string; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index df520087..50294ae8 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -26,6 +26,11 @@ import { normalizeContextGroupName, resolveAccountContextPolicy, } from '../../auth/account-context'; +import { + isSharedResourceMode, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, +} from '../../auth/shared-resource-policy'; import { buildCliproxyAccountKey, parseCliproxyKey, @@ -79,6 +84,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { const contextPolicy = resolveAccountContextPolicy(meta); + const resourcePolicy = resolveSharedResourcePolicy(meta); const hasExplicitContextMode = meta.context_mode === 'isolated' || meta.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -93,6 +99,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -100,6 +109,7 @@ router.get('/', async (_req: Request, res: Response): Promise => { for (const [name, account] of Object.entries(unifiedAccounts)) { const rawAccount = rawUnifiedAccounts[name]; const contextPolicy = resolveAccountContextPolicy(account); + const resourcePolicy = resolveSharedResourcePolicy(account); const hasExplicitContextMode = rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared'; const hasExplicitContinuityMode = @@ -114,6 +124,9 @@ router.get('/', async (_req: Request, res: Response): Promise => { context_inferred: !hasExplicitContextMode, continuity_inferred: contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined, + shared_resource_mode: resourcePolicy.mode, + shared_resource_inferred: resourcePolicy.inferred, + ...(resourcePolicy.profileLocal ? { bare: true } : {}), }; } @@ -305,7 +318,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; - const isBare = previousUnified?.bare === true || previousLegacy?.bare === true; + const resourcePolicy = resolveSharedResourcePolicy(previousUnified ?? previousLegacy); try { if (existsUnified) { @@ -315,13 +328,21 @@ router.put('/:name/context', async (req: Request, res: Response): Promise registry.updateProfile(name, metadata); } - await instanceMgr.ensureInstance(name, policy, { bare: isBare }); + await instanceMgr.ensureInstance(name, policy, { bare: resourcePolicy.profileLocal }); } catch (error) { if (existsUnified && previousUnified) { - registry.updateAccountUnified(name, previousUnified); + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); } if (existsLegacy && previousLegacy) { - registry.updateProfile(name, previousLegacy); + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); } throw error; } @@ -342,6 +363,89 @@ router.put('/:name/context', async (req: Request, res: Response): Promise } }); +/** + * PUT /api/accounts/:name/shared-resources - Update account shared resource mode + */ +router.put('/:name/shared-resources', async (req: Request, res: Response): Promise => { + try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; + if (cliproxyKey) { + res.status(400).json({ + error: `Shared resource mode is not supported for CLIProxy account: ${name}`, + }); + return; + } + + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); + if (!existsUnified && !existsLegacy) { + res.status(404).json({ error: `Account not found: ${name}` }); + return; + } + + const mode = req.body?.shared_resource_mode; + if (!isSharedResourceMode(mode)) { + res.status(400).json({ + error: 'Missing or invalid shared_resource_mode: expected shared|profile-local', + }); + return; + } + + const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; + const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + const previousMetadata = previousUnified ?? previousLegacy; + const contextPolicy = resolveAccountContextPolicy(previousMetadata); + const metadata = sharedResourceModeToMetadata(mode); + + try { + if (existsUnified) { + registry.updateAccountUnified(name, metadata); + } + if (existsLegacy) { + registry.updateProfile(name, metadata); + } + + await instanceMgr.ensureInstance(name, contextPolicy, { + bare: mode === 'profile-local', + }); + } catch (error) { + if (existsUnified && previousUnified) { + registry.updateAccountUnified(name, { + ...previousUnified, + shared_resource_mode: previousUnified.shared_resource_mode, + bare: previousUnified.bare, + }); + } + if (existsLegacy && previousLegacy) { + registry.updateProfile(name, { + ...previousLegacy, + shared_resource_mode: previousLegacy.shared_resource_mode, + bare: previousLegacy.bare, + }); + } + throw error; + } + + res.json({ + name, + shared_resource_mode: mode, + shared_resource_inferred: false, + bare: mode === 'profile-local' ? true : undefined, + }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index 5b5838cb..7673dee4 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -63,6 +63,20 @@ describe('auth command args parsing', () => { expect(parsed.contextGroup).toBe('sprint-a'); }); + it('parses shared resource mode value for resources command', () => { + const parsed = parseArgs(['work', '--mode', 'profile-local']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBe('profile-local'); + }); + + it('parses inline shared resource mode value', () => { + const parsed = parseArgs(['work', '--mode=shared']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBe('shared'); + }); + it('tracks unknown flags and keeps positional profile intact', () => { const parsed = parseArgs(['--foo', 'bar', 'work']); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts new file mode 100644 index 00000000..d76bc36f --- /dev/null +++ b/tests/unit/auth-resources-command.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import ProfileRegistry from '../../src/auth/profile-registry'; +import InstanceManager from '../../src/management/instance-manager'; +import { handleResources } from '../../src/auth/commands/resources-command'; +import { handleCreate } from '../../src/auth/commands/create-command'; + +describe('auth resources command', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalUnifiedMode: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-')); + originalCcsHome = process.env.CCS_HOME; + originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG; + + process.env.CCS_HOME = tempRoot; + process.env.CCS_UNIFIED_CONFIG = '1'; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode; + else delete process.env.CCS_UNIFIED_CONFIG; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('updates an existing account shared resource mode without changing context metadata', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'work') + ); + const lines: string[] = []; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await handleResources( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local', '--json'] + ); + } finally { + console.log = originalLog; + ensureSpy.mockRestore(); + } + + const output = JSON.parse(lines.join('\n')) as { + shared_resource_mode: string; + bare?: boolean; + }; + expect(output.shared_resource_mode).toBe('profile-local'); + expect(output.bare).toBe(true); + + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBe('profile-local'); + expect(account.bare).toBe(true); + expect(account.context_mode).toBe('shared'); + expect(account.context_group).toBe('sprint-a'); + }); + + it('rolls back inferred shared metadata when instance reconciliation fails', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('reconcile failed') + ); + + try { + await expect( + handleResources( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local'] + ) + ).rejects.toThrow('reconcile failed'); + } finally { + ensureSpy.mockRestore(); + } + + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBeUndefined(); + expect(account.bare).toBeUndefined(); + }); + + it('rejects --mode on auth create instead of silently ignoring it', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const originalExit = process.exit; + const originalLog = console.log; + const originalError = console.error; + const lines: string[] = []; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await expect( + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local'] + ) + ).rejects.toThrow('process.exit(7)'); + } finally { + process.exit = originalExit; + console.log = originalLog; + console.error = originalError; + } + + expect(lines.join('\n')).toContain('Unknown option(s): "--mode"'); + }); +}); diff --git a/tests/unit/auth/profile-registry-context-normalization.test.ts b/tests/unit/auth/profile-registry-context-normalization.test.ts index 8ad59688..6616591d 100644 --- a/tests/unit/auth/profile-registry-context-normalization.test.ts +++ b/tests/unit/auth/profile-registry-context-normalization.test.ts @@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => { const profile = registry.getProfile('work'); expect(profile.bare).toBe(true); + expect(profile.shared_resource_mode).toBe('profile-local'); }); it('persists bare flag for unified accounts and merged projection', () => { @@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => { const accounts = registry.getAllAccountsUnified(); expect(accounts.work.bare).toBe(true); + expect(accounts.work.shared_resource_mode).toBe('profile-local'); const merged = registry.getAllProfilesMerged(); expect(merged.work.bare).toBe(true); + expect(merged.work.shared_resource_mode).toBe('profile-local'); + }); + + it('lets explicit shared resource mode override stale unified bare metadata', () => { + process.env.CCS_UNIFIED_CONFIG = '1'; + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-03-05T00:00:00.000Z"', + ' last_used: null', + ' shared_resource_mode: shared', + ' bare: true', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const accounts = registry.getAllAccountsUnified(); + + expect(accounts.work.shared_resource_mode).toBe('shared'); + expect(accounts.work.bare).toBeUndefined(); + }); + + it('persists explicit profile-local mode for legacy profiles', () => { + const registry = new ProfileRegistry(); + registry.createProfile('work', { + type: 'account', + shared_resource_mode: 'profile-local', + }); + + const profile = registry.getProfile('work'); + expect(profile.shared_resource_mode).toBe('profile-local'); + expect(profile.bare).toBe(true); }); }); diff --git a/tests/unit/auth/shared-resource-policy.test.ts b/tests/unit/auth/shared-resource-policy.test.ts new file mode 100644 index 00000000..e0306a54 --- /dev/null +++ b/tests/unit/auth/shared-resource-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test'; +import { + normalizeSharedResourceMetadata, + resolveSharedResourcePolicy, + sharedResourceModeToMetadata, +} from '../../../src/auth/shared-resource-policy'; + +describe('shared resource policy', () => { + it('defaults missing metadata to shared resources', () => { + const policy = resolveSharedResourcePolicy(undefined); + + expect(policy.mode).toBe('shared'); + expect(policy.inferred).toBe(true); + expect(policy.profileLocal).toBe(false); + }); + + it('resolves legacy bare metadata as profile-local', () => { + const policy = resolveSharedResourcePolicy({ bare: true }); + + expect(policy.mode).toBe('profile-local'); + expect(policy.inferred).toBe(true); + expect(policy.profileLocal).toBe(true); + }); + + it('lets explicit shared mode override stale bare metadata', () => { + const normalized = normalizeSharedResourceMetadata({ + shared_resource_mode: 'shared', + bare: true, + }); + + expect(normalized.shared_resource_mode).toBe('shared'); + expect(normalized.bare).toBeUndefined(); + expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false); + }); + + it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => { + const normalized = normalizeSharedResourceMetadata({ + shared_resource_mode: 'profile-local', + }); + + expect(normalized.shared_resource_mode).toBe('profile-local'); + expect(normalized.bare).toBe(true); + }); + + it('builds write metadata for both supported modes', () => { + expect(sharedResourceModeToMetadata('profile-local')).toEqual({ + shared_resource_mode: 'profile-local', + bare: true, + }); + expect(sharedResourceModeToMetadata('shared')).toEqual({ + shared_resource_mode: 'shared', + bare: undefined, + }); + }); +}); diff --git a/tests/unit/commands/sync-command.test.ts b/tests/unit/commands/sync-command.test.ts index a0d94b98..c4e62382 100644 --- a/tests/unit/commands/sync-command.test.ts +++ b/tests/unit/commands/sync-command.test.ts @@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => { mock.restore(); }); - it('syncs MCP servers only to non-bare profiles', async () => { + it('syncs MCP servers only to shared-resource profiles', async () => { spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true); spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({ success: true, @@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => { spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ work: profile(), sandbox: profile({ bare: true }), + local: profile({ shared_resource_mode: 'profile-local' }), + restored: profile({ bare: true, shared_resource_mode: 'shared' }), personal: profile(), }); spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); @@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => { await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)'); - expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']); - expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']); + expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([ + 'work', + 'restored', + 'personal', + ]); + expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([ + '/tmp/work', + '/tmp/restored', + '/tmp/personal', + ]); }); it('skips MCP sync when all profiles are bare', async () => { @@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => { spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {}); spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({ sandbox: profile({ bare: true }), - experiment: profile({ bare: true }), + experiment: profile({ shared_resource_mode: 'profile-local' }), }); spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 0beb80f1..8e754151 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => { expect(unified?.accounts.personal.continuity_mode).toBeUndefined(); }); + it('migrates legacy bare accounts to profile-local shared resource mode', async () => { + fs.writeFileSync( + path.join(ccsDir, 'profiles.json'), + JSON.stringify( + { + default: 'sandbox', + profiles: { + sandbox: { + type: 'account', + created: '2026-02-01T00:00:00.000Z', + last_used: null, + bare: true, + }, + restored: { + type: 'account', + created: '2026-02-02T00:00:00.000Z', + last_used: null, + shared_resource_mode: 'shared', + bare: true, + }, + }, + }, + null, + 2 + ) + ); + + const result = await migrate(false); + expect(result.success).toBe(true); + + const unified = loadUnifiedConfig(); + expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local'); + expect(unified?.accounts.sandbox.bare).toBe(true); + expect(unified?.accounts.restored.shared_resource_mode).toBe('shared'); + expect(unified?.accounts.restored.bare).toBeUndefined(); + }); + it('applies safe browser defaults when migrating legacy config files', async () => { fs.writeFileSync( path.join(ccsDir, 'config.json'), diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 093a6b14..9675a7b0 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import express from 'express'; import * as fs from 'fs'; import * as os from 'os'; @@ -262,6 +262,51 @@ describe('web-server account-routes context normalization', () => { expect(fs.existsSync(instancePath)).toBe(false); }); + it('reports effective shared resource mode without initializing an account instance', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 12', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' shared_resource_mode: profile-local', + ' restored:', + ' created: "2026-02-02T00:00:00.000Z"', + ' shared_resource_mode: shared', + ' bare: true', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const payload = await getJson<{ + accounts: Array<{ + name: string; + shared_resource_mode?: string; + shared_resource_inferred?: boolean; + bare?: boolean; + }>; + }>(baseUrl, '/api/accounts'); + + const work = payload.accounts.find((account) => account.name === 'work'); + const restored = payload.accounts.find((account) => account.name === 'restored'); + expect(work?.shared_resource_mode).toBe('profile-local'); + expect(work?.shared_resource_inferred).toBe(false); + expect(work?.bare).toBe(true); + expect(restored?.shared_resource_mode).toBe('shared'); + expect(restored?.shared_resource_inferred).toBe(false); + expect(restored?.bare).toBeUndefined(); + expect(fs.existsSync(path.join(ccsDir, 'instances', 'work'))).toBe(false); + }); + it('does not delete metadata when instance deletion fails', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -354,6 +399,148 @@ describe('web-server account-routes context normalization', () => { expect(work?.continuity_mode).toBe('deeper'); }); + it('updates existing account shared resource mode without changing history sync metadata', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + ' context_mode: shared', + ' context_group: sprint-a', + ' continuity_mode: deeper', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue( + path.join(ccsDir, 'instances', 'work') + ); + + try { + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(200); + const payload = (await response.json()) as { + shared_resource_mode: string; + shared_resource_inferred?: boolean; + }; + expect(payload.shared_resource_mode).toBe('profile-local'); + expect(payload.shared_resource_inferred).toBe(false); + expect(ensureSpy).toHaveBeenCalledWith( + 'work', + { + mode: 'shared', + group: 'sprint-a', + continuityMode: 'deeper', + }, + { bare: true } + ); + } finally { + ensureSpy.mockRestore(); + } + + const registry = new ProfileRegistry(); + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBe('profile-local'); + expect(account.bare).toBe(true); + expect(account.context_mode).toBe('shared'); + expect(account.context_group).toBe('sprint-a'); + expect(account.continuity_mode).toBe('deeper'); + }); + + it('rolls back inferred shared resource metadata when instance reconciliation fails', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue( + new Error('reconcile failed') + ); + + try { + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(500); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('reconcile failed'); + } finally { + ensureSpy.mockRestore(); + } + + const registry = new ProfileRegistry(); + const account = registry.getAllAccountsUnified().work; + expect(account.shared_resource_mode).toBeUndefined(); + expect(account.bare).toBeUndefined(); + }); + + it('rejects invalid shared resource mode updates', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', { + shared_resource_mode: 'plugins-only', + }); + expect(response.status).toBe(400); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('shared_resource_mode'); + }); + + it('rejects shared resource updates for CLIProxy account identifiers', async () => { + const response = await putJson(baseUrl, '/api/accounts/gemini:test/shared-resources', { + shared_resource_mode: 'profile-local', + }); + expect(response.status).toBe(400); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('CLIProxy'); + }); + it('rejects shared mode updates without context_group', async () => { const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); From bfe32257b6e354b5007e6607b786f9b56ce49743 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 8 May 2026 23:29:03 -0400 Subject: [PATCH 2/5] fix(auth): reject resource mode outside resources command --- src/auth/auth-commands.ts | 6 +- src/auth/commands/backup-command.ts | 8 +- src/auth/commands/create-command.ts | 30 ++------ src/auth/commands/default-command.ts | 14 +++- src/auth/commands/list-command.ts | 14 +++- src/auth/commands/remove-command.ts | 8 +- src/auth/commands/resources-command.ts | 9 ++- src/auth/commands/show-command.ts | 8 +- src/auth/commands/types.ts | 40 +++++++++- tests/unit/auth-command-args.test.ts | 12 ++- tests/unit/auth-resources-command.test.ts | 91 +++++++++++++++-------- 11 files changed, 165 insertions(+), 75 deletions(-) diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index dcd248b3..7556094e 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -242,8 +242,8 @@ class AuthCommands { /** * Reset default profile - delegates to default-command.ts */ - async handleResetDefault(): Promise { - return handleResetDefault(this.getContext()); + async handleResetDefault(args: string[] = []): Promise { + return handleResetDefault(this.getContext(), args); } /** @@ -297,7 +297,7 @@ class AuthCommands { break; case 'reset-default': - await this.handleResetDefault(); + await this.handleResetDefault(commandArgs); break; case 'current': diff --git a/src/auth/commands/backup-command.ts b/src/auth/commands/backup-command.ts index e66068be..f6fe167d 100644 --- a/src/auth/commands/backup-command.ts +++ b/src/auth/commands/backup-command.ts @@ -11,7 +11,7 @@ import { } from '../resume-lane-diagnostics'; import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context'; import { isProfileLocalSharedResourceMode } from '../shared-resource-policy'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; interface BackupManifest { target: string; @@ -37,7 +37,11 @@ function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean export async function handleBackup(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, json } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth backup [--json]', + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index d407ace4..b0cd4aad 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -27,7 +27,7 @@ import { } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { stripAmbientProviderCredentials } from './create-command-env'; import { isUnifiedMode } from '../../config/config-loader-facade'; @@ -40,29 +40,13 @@ function sanitizeProfileNameForInstance(name: string): string { */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { - profileName, - force, - shareContext, - contextGroup, - deeperContinuity, - bare, - mode, - unknownFlags, - } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, force, shareContext, contextGroup, deeperContinuity, bare } = parsed; - const unsupportedOptions = [...(unknownFlags ?? []), ...(mode !== undefined ? ['--mode'] : [])]; - if (unsupportedOptions.length > 0) { - const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); - console.log(fail(`Unknown option(s): ${unknownList}`)); - console.log(''); - console.log( - `Usage: ${color('ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', 'command')}` - ); - console.log(`Help: ${color('ccs auth --help', 'command')}`); - console.log(''); - exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); - } + rejectUnsupportedAuthOptions(parsed, { + usage: + 'ccs auth create [--force] [--bare] [--share-context] [--context-group ] [--deeper-continuity]', + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/default-command.ts b/src/auth/commands/default-command.ts index 2800f931..8836aee1 100644 --- a/src/auth/commands/default-command.ts +++ b/src/auth/commands/default-command.ts @@ -8,7 +8,7 @@ import { initUI, color, dim, ok, fail } from '../../utils/ui'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { isUnifiedMode } from '../../config/config-loader-facade'; /** @@ -16,7 +16,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade'; */ export async function handleDefault(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth default ', + }); if (!profileName) { console.log(fail('Profile name is required')); @@ -48,8 +52,12 @@ export async function handleDefault(ctx: CommandContext, args: string[]): Promis /** * Handle the reset-default command (clear the custom default) */ -export async function handleResetDefault(ctx: CommandContext): Promise { +export async function handleResetDefault(ctx: CommandContext, args: string[] = []): Promise { await initUI(); + const parsed = parseArgs(args); + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth reset-default', + }); try { // Use unified or legacy based on config mode diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index 9172e90b..88646d0d 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -10,14 +10,24 @@ import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../acco import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types'; +import { + CommandContext, + ListOutput, + parseArgs, + formatRelativeTime, + rejectUnsupportedAuthOptions, +} from './types'; /** * Handle the list command */ export async function handleList(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { verbose, json } = parseArgs(args); + const parsed = parseArgs(args); + const { verbose, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth list [--verbose] [--json]', + }); try { // Get profiles from both legacy (profiles.json) and unified config (config.yaml) diff --git a/src/auth/commands/remove-command.ts b/src/auth/commands/remove-command.ts index 9302d1b0..fed45098 100644 --- a/src/auth/commands/remove-command.ts +++ b/src/auth/commands/remove-command.ts @@ -11,7 +11,7 @@ import { InteractivePrompt } from '../../utils/prompt'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; import { isUnifiedMode } from '../../config/config-loader-facade'; /** @@ -19,7 +19,11 @@ import { isUnifiedMode } from '../../config/config-loader-facade'; */ export async function handleRemove(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, yes } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, yes } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth remove [--yes]', + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/resources-command.ts b/src/auth/commands/resources-command.ts index a20998b8..f3269457 100644 --- a/src/auth/commands/resources-command.ts +++ b/src/auth/commands/resources-command.ts @@ -8,7 +8,7 @@ import { } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, parseArgs } from './types'; +import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types'; function formatMode(mode: SharedResourceMode): string { return mode === 'profile-local' ? 'profile-local' : 'shared'; @@ -22,7 +22,12 @@ function modeDescription(mode: SharedResourceMode): string { export async function handleResources(ctx: CommandContext, args: string[]): Promise { await initUI(); - const { profileName, mode, json } = parseArgs(args); + const parsed = parseArgs(args, { allowMode: true }); + const { profileName, mode, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth resources [--mode shared|profile-local] [--json]', + allowMode: true, + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/show-command.ts b/src/auth/commands/show-command.ts index 212ef7a1..37602688 100644 --- a/src/auth/commands/show-command.ts +++ b/src/auth/commands/show-command.ts @@ -13,7 +13,7 @@ import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics' import { resolveSharedResourcePolicy } from '../shared-resource-policy'; import { exitWithError } from '../../errors'; import { ExitCode } from '../../errors/exit-codes'; -import { CommandContext, ProfileOutput, parseArgs } from './types'; +import { CommandContext, ProfileOutput, parseArgs, rejectUnsupportedAuthOptions } from './types'; function formatHistorySummary(history: ReturnType): string { const scope = history.projects_shared ? 'shared projects' : 'profile-local projects'; @@ -26,7 +26,11 @@ function formatHistorySummary(history: ReturnType { await initUI(); - const { profileName, json } = parseArgs(args); + const parsed = parseArgs(args); + const { profileName, json } = parsed; + rejectUnsupportedAuthOptions(parsed, { + usage: 'ccs auth show [--json]', + }); if (!profileName) { console.log(fail('Profile name is required')); diff --git a/src/auth/commands/types.ts b/src/auth/commands/types.ts index 906f7135..f091b410 100644 --- a/src/auth/commands/types.ts +++ b/src/auth/commands/types.ts @@ -6,6 +6,9 @@ import ProfileRegistry from '../profile-registry'; import { InstanceManager } from '../../management/instance-manager'; +import { exitWithError } from '../../errors'; +import { ExitCode } from '../../errors/exit-codes'; +import { color, fail } from '../../utils/ui'; // Re-export for backward compatibility export { formatRelativeTime } from '../../utils/time'; @@ -83,10 +86,36 @@ export interface CommandContext { version: string; } +export function rejectUnsupportedAuthOptions( + parsed: Pick, + options: { usage: string; allowMode?: boolean } +): void { + const unsupportedOptions = [ + ...(parsed.unknownFlags ?? []), + ...(!options.allowMode && parsed.mode !== undefined ? ['--mode'] : []), + ]; + + if (unsupportedOptions.length === 0) { + return; + } + + const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', '); + console.log(fail(`Unknown option(s): ${unknownList}`)); + console.log(''); + console.log(`Usage: ${color(options.usage, 'command')}`); + console.log(`Help: ${color('ccs auth --help', 'command')}`); + console.log(''); + exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR); +} + +interface ParseArgsOptions { + allowMode?: boolean; +} + /** * Parse command arguments from raw args array */ -export function parseArgs(args: string[]): AuthCommandArgs { +export function parseArgs(args: string[], options: ParseArgsOptions = {}): AuthCommandArgs { let profileName: string | undefined; let contextGroup: string | undefined; let mode: string | undefined; @@ -101,7 +130,10 @@ export function parseArgs(args: string[]): AuthCommandArgs { '--deeper-continuity', '--bare', ]); - const knownValueFlags = new Set(['--context-group', '--mode']); + const knownValueFlags = new Set(['--context-group']); + if (options.allowMode) { + knownValueFlags.add('--mode'); + } for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -118,7 +150,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { continue; } - if (arg === '--mode') { + if (options.allowMode && arg === '--mode') { const next = args[i + 1]; if (!next || next.startsWith('-')) { mode = ''; @@ -135,7 +167,7 @@ export function parseArgs(args: string[]): AuthCommandArgs { continue; } - if (arg.startsWith('--mode=')) { + if (options.allowMode && arg.startsWith('--mode=')) { mode = arg.slice('--mode='.length); continue; } diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index 7673dee4..9badc1f2 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -64,19 +64,27 @@ describe('auth command args parsing', () => { }); it('parses shared resource mode value for resources command', () => { - const parsed = parseArgs(['work', '--mode', 'profile-local']); + const parsed = parseArgs(['work', '--mode', 'profile-local'], { allowMode: true }); expect(parsed.profileName).toBe('work'); expect(parsed.mode).toBe('profile-local'); }); it('parses inline shared resource mode value', () => { - const parsed = parseArgs(['work', '--mode=shared']); + const parsed = parseArgs(['work', '--mode=shared'], { allowMode: true }); expect(parsed.profileName).toBe('work'); expect(parsed.mode).toBe('shared'); }); + it('treats shared resource mode as unknown unless the command opts in', () => { + const parsed = parseArgs(['work', '--mode', 'shared']); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBeUndefined(); + expect(parsed.unknownFlags).toEqual(['--mode']); + }); + it('tracks unknown flags and keeps positional profile intact', () => { const parsed = parseArgs(['--foo', 'bar', 'work']); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts index d76bc36f..88379cfe 100644 --- a/tests/unit/auth-resources-command.test.ts +++ b/tests/unit/auth-resources-command.test.ts @@ -6,6 +6,38 @@ import ProfileRegistry from '../../src/auth/profile-registry'; import InstanceManager from '../../src/management/instance-manager'; import { handleResources } from '../../src/auth/commands/resources-command'; import { handleCreate } from '../../src/auth/commands/create-command'; +import { handleList } from '../../src/auth/commands/list-command'; +import { handleShow } from '../../src/auth/commands/show-command'; +import { handleBackup } from '../../src/auth/commands/backup-command'; +import { handleRemove } from '../../src/auth/commands/remove-command'; +import { handleDefault, handleResetDefault } from '../../src/auth/commands/default-command'; + +async function expectProfileExit(action: () => Promise): Promise { + const originalExit = process.exit; + const originalLog = console.log; + const originalError = console.error; + const lines: string[] = []; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + console.error = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + + try { + await expect(action()).rejects.toThrow('process.exit(7)'); + } finally { + process.exit = originalExit; + console.log = originalLog; + console.error = originalError; + } + + return lines.join('\n'); +} describe('auth resources command', () => { let tempRoot = ''; @@ -143,38 +175,37 @@ describe('auth resources command', () => { it('rejects --mode on auth create instead of silently ignoring it', async () => { const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); - const originalExit = process.exit; - const originalLog = console.log; - const originalError = console.error; - const lines: string[] = []; - process.exit = ((code?: number) => { - throw new Error(`process.exit(${code ?? 0})`); - }) as typeof process.exit; - console.log = (...args: unknown[]) => { - lines.push(args.map(String).join(' ')); - }; - console.error = (...args: unknown[]) => { - lines.push(args.map(String).join(' ')); - }; + const output = await expectProfileExit(() => + handleCreate( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode', 'profile-local'] + ) + ); - try { - await expect( - handleCreate( - { - registry, - instanceMgr, - version: 'test', - }, - ['work', '--mode', 'profile-local'] - ) - ).rejects.toThrow('process.exit(7)'); - } finally { - process.exit = originalExit; - console.log = originalLog; - console.error = originalError; + expect(output).toContain('Unknown option(s): "--mode"'); + }); + + it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => { + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + const ctx = { registry, instanceMgr, version: 'test' }; + const cases: Array<[string, () => Promise]> = [ + ['list', () => handleList(ctx, ['--mode', 'shared'])], + ['show', () => handleShow(ctx, ['work', '--mode=shared'])], + ['backup', () => handleBackup(ctx, ['work', '--mode', 'profile-local'])], + ['remove', () => handleRemove(ctx, ['work', '--mode', 'shared'])], + ['default', () => handleDefault(ctx, ['work', '--mode', 'shared'])], + ['reset-default', () => handleResetDefault(ctx, ['--mode', 'shared'])], + ]; + + for (const [commandName, action] of cases) { + const output = await expectProfileExit(action); + expect(`${commandName}\n${output}`).toContain('Unknown option(s): "--mode"'); } - - expect(lines.join('\n')).toContain('Unknown option(s): "--mode"'); }); }); From 3b550205ddcc98d31a221427dd71f1c9bf3073c4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 8 May 2026 23:36:12 -0400 Subject: [PATCH 3/5] fix(auth): require resource mode value --- src/auth/commands/resources-command.ts | 22 +++++++++----- tests/unit/auth-command-args.test.ts | 7 +++++ tests/unit/auth-resources-command.test.ts | 37 +++++++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/auth/commands/resources-command.ts b/src/auth/commands/resources-command.ts index f3269457..cf6666c3 100644 --- a/src/auth/commands/resources-command.ts +++ b/src/auth/commands/resources-command.ts @@ -46,6 +46,13 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom const currentPolicy = resolveSharedResourcePolicy(currentProfile); + if (mode === '') { + exitWithError( + 'Missing value for --mode: expected shared|profile-local', + ExitCode.PROFILE_ERROR + ); + } + if (mode !== undefined && !isSharedResourceMode(mode)) { exitWithError( 'Invalid shared resource mode: expected shared|profile-local', @@ -53,7 +60,7 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom ); } - if (!mode) { + if (mode === undefined) { if (json) { console.log( JSON.stringify( @@ -92,7 +99,8 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom : undefined; const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined; const contextPolicy = resolveAccountContextPolicy(currentProfile); - const metadata = sharedResourceModeToMetadata(mode); + const selectedMode: SharedResourceMode = mode; + const metadata = sharedResourceModeToMetadata(selectedMode); try { if (existsUnified) { @@ -103,7 +111,7 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom } await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, { - bare: mode === 'profile-local', + bare: selectedMode === 'profile-local', }); } catch (error) { if (existsUnified && previousUnified) { @@ -128,9 +136,9 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom JSON.stringify( { name: profileName, - shared_resource_mode: mode, + shared_resource_mode: selectedMode, shared_resource_inferred: false, - bare: mode === 'profile-local' ? true : undefined, + bare: selectedMode === 'profile-local' ? true : undefined, }, null, 2 @@ -139,8 +147,8 @@ export async function handleResources(ctx: CommandContext, args: string[]): Prom return; } - console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(mode)}`)); + console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(selectedMode)}`)); console.log(''); - console.log(modeDescription(mode)); + console.log(modeDescription(selectedMode)); console.log(''); } diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index 9badc1f2..f7404ab0 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -77,6 +77,13 @@ describe('auth command args parsing', () => { expect(parsed.mode).toBe('shared'); }); + it('flags missing shared resource mode value as empty string', () => { + const parsed = parseArgs(['work', '--mode'], { allowMode: true }); + + expect(parsed.profileName).toBe('work'); + expect(parsed.mode).toBe(''); + }); + it('treats shared resource mode as unknown unless the command opts in', () => { const parsed = parseArgs(['work', '--mode', 'shared']); diff --git a/tests/unit/auth-resources-command.test.ts b/tests/unit/auth-resources-command.test.ts index 88379cfe..2264ed3d 100644 --- a/tests/unit/auth-resources-command.test.ts +++ b/tests/unit/auth-resources-command.test.ts @@ -172,6 +172,43 @@ describe('auth resources command', () => { expect(account.bare).toBeUndefined(); }); + it('rejects resources --mode without a value instead of showing current mode', async () => { + const ccsDir = path.join(tempRoot, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + [ + 'version: 8', + 'accounts:', + ' work:', + ' created: "2026-02-01T00:00:00.000Z"', + ' last_used: null', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + const registry = new ProfileRegistry(); + const instanceMgr = new InstanceManager(); + + const output = await expectProfileExit(() => + handleResources( + { + registry, + instanceMgr, + version: 'test', + }, + ['work', '--mode'] + ) + ); + + expect(output).toContain('Missing value for --mode: expected shared|profile-local'); + }); + it('rejects --mode on auth create instead of silently ignoring it', async () => { const registry = new ProfileRegistry(); const instanceMgr = new InstanceManager(); From 2a422b3bd32d989e03e8f20efc875b4bb6d35584 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 9 May 2026 02:29:08 -0400 Subject: [PATCH 4/5] 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); From 8b340602947312335c368109455488e5ed51cca2 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 9 May 2026 03:18:20 -0400 Subject: [PATCH 5/5] fix(dashboard): show real shared plugin registry state --- docs/dashboard-auth-cli.md | 2 +- docs/session-sharing-technical-analysis.md | 2 + src/web-server/shared-routes.ts | 379 ++++++++++++++---- tests/unit/web-server/shared-routes.test.ts | 142 ++++++- ui/src/lib/i18n.ts | 17 +- ui/src/pages/shared.tsx | 95 +++-- .../unit/ui/pages/shared/shared-page.test.tsx | 79 +++- 7 files changed, 587 insertions(+), 129 deletions(-) diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index dd297535..af5f8b52 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -99,7 +99,7 @@ Shared resource editing: - reconciles the account instance after metadata is updated - Dashboard -> Accounts exposes this as a separate Resources action so it is not confused with History Sync. - Dashboard -> Shared Resources shows the shared hub inventory for commands, skills, agents, plugins, and `settings.json`. -- Plugin directories without README/PLUGIN docs render their actual directory inventory. +- The Plugins tab is registry-oriented: installed plugin entries come from `installed_plugins.json`, while internal cache/data/marketplace folders stay hidden unless a real plugin entry exists. - Shared `settings.json` is read-only in the Shared Resources page and still edited through the settings surfaces that own those values. ## Commands diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 69b9ae11..f3aa0966 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -69,6 +69,7 @@ accounts: work: created: "2026-02-24T00:00:00.000Z" last_used: null + shared_resource_mode: "shared" context_mode: "shared" context_group: "team-alpha" continuity_mode: "deeper" @@ -76,6 +77,7 @@ accounts: Rules: +- `shared_resource_mode` controls commands, skills, agents, plugins, and `settings.json` (`shared` or `profile-local`) - `context_mode` must be `isolated` or `shared` - `context_group` is required when `context_mode=shared` - `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`) diff --git a/src/web-server/shared-routes.ts b/src/web-server/shared-routes.ts index 3ed44425..5cd449de 100644 --- a/src/web-server/shared-routes.ts +++ b/src/web-server/shared-routes.ts @@ -32,6 +32,8 @@ const MAX_DESCRIPTION_LENGTH = 140; const MAX_MARKDOWN_FILE_BYTES = 1024 * 1024; // 1 MiB const MAX_CONTENT_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB const SHARED_ITEMS_CACHE_TTL_MS = 1000; +const PLUGIN_REGISTRY_PATH_PREFIX = 'plugin-registry:'; +const PLUGIN_INFRASTRUCTURE_DIRECTORIES = new Set(['cache', 'data', 'marketplaces']); type SharedCollectionType = 'commands' | 'skills' | 'agents' | 'plugins'; type SharedContentType = SharedCollectionType | 'settings'; @@ -49,6 +51,11 @@ interface SharedItemsCacheEntry { expiresAt: number; } +interface InstalledPluginRegistry { + version?: number; + plugins: Record; +} + const sharedItemsCache = new Map(); /** @@ -110,9 +117,9 @@ sharedRoutes.get('/content', (req: Request, res: Response) => { const sharedDirRoot = safeRealPath(sharedDir) ?? path.resolve(sharedDir); const allowedRoots = typeParam === 'settings' - ? new Set([sharedDirRoot]) + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) : resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot); - const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots); + const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot); if (!contentResult) { res.status(404).json({ error: 'Shared content not found' }); @@ -135,10 +142,13 @@ sharedRoutes.get('/summary', (_req: Request, res: Response) => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const sharedDirRoot = safeRealPath(path.join(ccsDir, 'shared')); const resolvedSettingsPath = safeRealPath(settingsPath); + const settingsAllowedRoots = sharedDirRoot + ? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot) + : new Set(); const hasSettings = Boolean(sharedDirRoot) && Boolean(resolvedSettingsPath) && - isPathWithin(resolvedSettingsPath as string, sharedDirRoot as string); + isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots); res.json({ commands, @@ -180,6 +190,17 @@ function resolveAllowedRoots( ]); } +function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set { + const claudeConfigDir = getClaudeConfigDir(); + return new Set( + [ + sharedDirRoot, + safeRealPath(path.join(claudeConfigDir, 'settings.json')), + safeRealPath(path.join(ccsDir, '.claude', 'settings.json')), + ].filter((dirPath): dirPath is string => typeof dirPath === 'string') + ); +} + function getSharedItems(type: SharedCollectionType): SharedItem[] { const ccsDir = getCcsDir(); const sharedDir = path.join(ccsDir, 'shared', type); @@ -209,6 +230,16 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return commandItems; } + if (type === 'plugins') { + const pluginItems = getPluginItems(sharedDir, allowedRoots); + sharedItemsCache.set(type, { + items: pluginItems, + sharedDir, + expiresAt: now + SHARED_ITEMS_CACHE_TTL_MS, + }); + return pluginItems; + } + try { const entries = fs.readdirSync(sharedDir, { withFileTypes: true }); @@ -251,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] { return sortedItems; } +function getPluginItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registryItems = getPluginRegistryItems(sharedDir, allowedRoots); + const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots); + const itemsByPath = new Map(); + + for (const item of [...registryItems, ...legacyDirectoryItems]) { + itemsByPath.set(item.path, item); + } + + return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +function getPluginRegistryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry) { + return []; + } + + return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({ + name: pluginName, + description: describeInstalledPlugin(pluginName, metadata), + path: encodePluginRegistryPath(pluginName), + type: 'plugin' as const, + })); +} + +function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set): SharedItem[] { + let entries: fs.Dirent[] = []; + try { + entries = fs.readdirSync(sharedDir, { withFileTypes: true }); + } catch { + return []; + } + + const items: SharedItem[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) { + continue; + } + + const entryPath = path.join(sharedDir, entry.name); + const resolvedEntryPath = safeRealPath(entryPath); + if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) { + continue; + } + + const description = readFirstMarkdownDescription( + [ + path.join(resolvedEntryPath, 'README.md'), + path.join(resolvedEntryPath, 'readme.md'), + path.join(resolvedEntryPath, 'PLUGIN.md'), + ], + allowedRoots + ); + + if (!description) { + continue; + } + + items.push({ + name: entry.name, + description, + path: entryPath, + type: 'plugin', + }); + } + + return items; +} + function getCommandItems(sharedDir: string, allowedRoots: Set): SharedItem[] { const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots); const items: SharedItem[] = []; @@ -279,35 +380,13 @@ function getCommandItems(sharedDir: string, allowedRoots: Set): SharedIt } function getSkillOrAgentItem( - type: 'skills' | 'agents' | 'plugins', + type: 'skills' | 'agents', entry: fs.Dirent, entryPath: string, resolvedEntryPath: string, allowedRoots: Set, stats: fs.Stats ): SharedItem | null { - if (type === 'plugins') { - if (!stats.isDirectory()) { - return null; - } - - const description = readFirstMarkdownDescription( - [ - path.join(resolvedEntryPath, 'README.md'), - path.join(resolvedEntryPath, 'readme.md'), - path.join(resolvedEntryPath, 'PLUGIN.md'), - ], - allowedRoots - ); - - return { - name: entry.name, - description: description ?? getDirectoryInventoryDescription(resolvedEntryPath, allowedRoots), - path: entryPath, - type: 'plugin', - }; - } - if (type === 'skills') { if (!stats.isDirectory()) { return null; @@ -555,6 +634,89 @@ function readMarkdownContent(markdownPath: string, allowedRoots: Set): s } } +function encodePluginRegistryPath(pluginName: string): string { + return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`; +} + +function decodePluginRegistryPath(itemPath: string): string | null { + if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) { + return null; + } + + try { + const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length); + const pluginName = decodeURIComponent(encodedName); + return pluginName.length > 0 ? pluginName : null; + } catch { + return null; + } +} + +function readJsonObject( + filePath: string, + allowedRoots: Set +): Record | null { + const resolvedPath = safeRealPath(filePath); + if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) { + return null; + } + + try { + const stats = fs.statSync(resolvedPath); + if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) { + return null; + } + + const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null; + } + + return parsed as Record; + } catch { + return null; + } +} + +function readPluginInstalledRegistry( + sharedDir: string, + allowedRoots: Set +): InstalledPluginRegistry | null { + const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots); + if ( + !parsed || + !parsed.plugins || + typeof parsed.plugins !== 'object' || + Array.isArray(parsed.plugins) + ) { + return null; + } + + return { + version: typeof parsed.version === 'number' ? parsed.version : undefined, + plugins: parsed.plugins as Record, + }; +} + +function describeInstalledPlugin(pluginName: string, metadata: unknown): string { + const recordCount = Array.isArray(metadata) ? metadata.length : 1; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`; + + return marketplace + ? `Installed from ${marketplace}; ${recordLabel} in shared registry` + : `Installed plugin; ${recordLabel} in shared registry`; +} + +function extractMarketplaceFromPluginName(pluginName: string): string | null { + const atIndex = pluginName.lastIndexOf('@'); + if (atIndex <= 0 || atIndex === pluginName.length - 1) { + return null; + } + + return pluginName.slice(atIndex + 1); +} + function resolveReadableMarkdownPath( markdownPaths: string[], allowedRoots: Set @@ -582,12 +744,20 @@ function resolveReadableMarkdownPath( function getSharedItemContent( type: SharedContentType, itemPath: string, - allowedRoots: Set + allowedRoots: Set, + sharedDir: string ): { content: string; contentPath: string } | null { if (type === 'settings') { return getSharedSettingsContent(itemPath, allowedRoots); } + if (type === 'plugins') { + const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots); + if (pluginRegistryContent) { + return pluginRegistryContent; + } + } + const resolvedItemPath = safeRealPath(itemPath); if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) { return null; @@ -615,7 +785,7 @@ function getSharedItemContent( allowedRoots ); } else if (type === 'plugins') { - if (!itemStats.isDirectory()) { + if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) { return null; } @@ -629,10 +799,7 @@ function getSharedItemContent( ); if (!markdownPath) { - return { - content: renderPluginDirectoryContent(itemPath, resolvedItemPath, allowedRoots), - contentPath: resolvedItemPath, - }; + return null; } } else { if (itemStats.isDirectory()) { @@ -664,6 +831,106 @@ function getSharedItemContent( }; } +function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean { + for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) { + const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName)); + if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) { + return true; + } + } + + return false; +} + +function getPluginRegistryContent( + itemPath: string, + sharedDir: string, + allowedRoots: Set +): { content: string; contentPath: string } | null { + const pluginName = decodePluginRegistryPath(itemPath); + if (!pluginName) { + return null; + } + + const registryPath = path.join(sharedDir, 'installed_plugins.json'); + const resolvedRegistryPath = safeRealPath(registryPath); + if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) { + return null; + } + + const registry = readPluginInstalledRegistry(sharedDir, allowedRoots); + if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) { + return null; + } + + const metadata = registry.plugins[pluginName]; + const marketplace = extractMarketplaceFromPluginName(pluginName); + const marketplaceEntry = marketplace + ? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace] + : null; + const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName); + const lines = [ + `# ${pluginName}`, + '', + `Registry: \`${resolvedRegistryPath}\``, + '', + '## Installed Plugin', + '', + `- Source: shared \`installed_plugins.json\` registry`, + `- Registry version: ${registry.version ?? 'unknown'}`, + `- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`, + `- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`, + ]; + + if (marketplaceEntry) { + lines.push( + '', + '## Marketplace Registry Entry', + '', + '```json', + stringifyJson(marketplaceEntry), + '```' + ); + } + + if (blocklistEntry) { + lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```'); + } + + lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```'); + + return { + content: lines.join('\n'), + contentPath: resolvedRegistryPath, + }; +} + +function findPluginBlocklistEntry( + sharedDir: string, + allowedRoots: Set, + pluginName: string +): unknown | null { + const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots); + const plugins = blocklist?.plugins; + if (!Array.isArray(plugins)) { + return null; + } + + return ( + plugins.find( + (entry) => + entry && + typeof entry === 'object' && + 'plugin' in entry && + (entry as { plugin?: unknown }).plugin === pluginName + ) ?? null + ); +} + +function stringifyJson(value: unknown): string { + return JSON.stringify(value, null, 2) ?? 'null'; +} + function safeRealPath(targetPath: string): string | null { try { return fs.realpathSync(targetPath); @@ -720,54 +987,6 @@ function getSharedSettingsContent( } } -function getDirectoryInventoryDescription( - directoryPath: string, - allowedRoots: Set -): string { - const entries = listDirectoryEntryNames(directoryPath, allowedRoots); - if (entries.length === 0) { - return 'Empty directory'; - } - - return trimDescription( - `Directory with ${entries.length} ${entries.length === 1 ? 'item' : 'items'}: ${entries.join(', ')}` - ); -} - -function renderPluginDirectoryContent( - displayPath: string, - resolvedPath: string, - allowedRoots: Set -): string { - const name = path.basename(displayPath); - const lines = [`# Plugin directory: ${name}`, '', `Path: \`${displayPath}\``]; - - const entries = listDirectoryEntryNames(resolvedPath, allowedRoots); - if (entries.length > 0) { - lines.push('', '## Directory contents', '', ...entries.map((entry) => `- ${entry}`)); - } else { - lines.push('', 'No files or folders found in this plugin directory.'); - } - - return lines.join('\n'); -} - -function listDirectoryEntryNames(directoryPath: string, allowedRoots: Set): string[] { - if (!isPathWithinAny(directoryPath, allowedRoots)) { - return []; - } - - try { - return fs - .readdirSync(directoryPath, { withFileTypes: true }) - .slice(0, 50) - .map((entry) => `${entry.name}${entry.isDirectory() ? '/' : ''}`) - .sort((a, b) => a.localeCompare(b)); - } catch { - return []; - } -} - function isPathWithin(candidatePath: string, basePath: string): boolean { const normalizedCandidate = normalizeForPathComparison(candidatePath); const normalizedBase = normalizeForPathComparison(basePath); diff --git a/tests/unit/web-server/shared-routes.test.ts b/tests/unit/web-server/shared-routes.test.ts index 7f23c7e3..182b2063 100644 --- a/tests/unit/web-server/shared-routes.test.ts +++ b/tests/unit/web-server/shared-routes.test.ts @@ -285,11 +285,65 @@ describe('web-server shared-routes', () => { expect(payload.contentPath).toBe(fs.realpathSync(path.join(skillTargetDir, 'SKILL.md'))); }); - it('returns factual directory content for a shared plugin directory without markdown', async () => { + it('hides plugin infrastructure directories when no plugins are installed', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); const cacheDir = path.join(pluginsDir, 'cache'); fs.mkdirSync(path.join(cacheDir, 'payloads'), { recursive: true }); fs.writeFileSync(path.join(cacheDir, 'plugin-index.json'), '{}'); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const listPayload = await getJson<{ + items: Array<{ name: string; type: string; description: string; path: string }>; + }>(baseUrl, '/api/shared/plugins'); + + expect(listPayload.items).toEqual([]); + + const contentResponse = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + ); + + expect(contentResponse.status).toBe(404); + const contentPayload = (await contentResponse.json()) as { error: string }; + expect(contentPayload.error).toBe('Shared content not found'); + }); + + it('lists installed plugins from the shared plugin registry', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify( + { + version: 2, + plugins: { + 'discord@claude-plugins-official': [ + { + installPath: path.join(pluginsDir, 'cache', 'discord'), + enabled: true, + }, + ], + }, + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(pluginsDir, 'known_marketplaces.json'), + JSON.stringify( + { + 'claude-plugins-official': { + source: { type: 'github', repo: 'anthropics/claude-plugins' }, + installLocation: path.join(pluginsDir, 'marketplaces', 'claude-plugins-official'), + }, + }, + null, + 2 + ) + ); const listPayload = await getJson<{ items: Array<{ name: string; type: string; description: string; path: string }>; @@ -297,23 +351,41 @@ describe('web-server shared-routes', () => { expect(listPayload.items).toEqual([ { - name: 'cache', + name: 'discord@claude-plugins-official', type: 'plugin', - description: 'Directory with 2 items: payloads/, plugin-index.json', - path: cacheDir, + description: 'Installed from claude-plugins-official; 1 record in shared registry', + path: 'plugin-registry:discord%40claude-plugins-official', }, ]); const contentPayload = await getJson<{ content: string; contentPath: string }>( baseUrl, - `/api/shared/content?type=plugins&path=${encodeURIComponent(cacheDir)}` + `/api/shared/content?type=plugins&path=${encodeURIComponent(listPayload.items[0].path)}` ); - expect(contentPayload.content).toContain('# Plugin directory: cache'); - expect(contentPayload.content).toContain('plugin-index.json'); - expect(contentPayload.content).toContain('payloads/'); - expect(contentPayload.content).not.toContain('Shared plugin payload cache'); - expect(contentPayload.contentPath).toBe(fs.realpathSync(cacheDir)); + expect(contentPayload.content).toContain('# discord@claude-plugins-official'); + expect(contentPayload.content).toContain('## Marketplace Registry Entry'); + expect(contentPayload.content).toContain('## Plugin Registry Entry'); + expect(contentPayload.contentPath).toBe( + fs.realpathSync(path.join(pluginsDir, 'installed_plugins.json')) + ); + }); + + it('rejects plugin registry prototype keys as plugin names', async () => { + const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); + fs.mkdirSync(pluginsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginsDir, 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: {} }, null, 2) + ); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=plugins&path=${encodeURIComponent('plugin-registry:toString')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); }); it('returns real shared settings content when settings.json is present', async () => { @@ -347,6 +419,36 @@ describe('web-server shared-routes', () => { expect(contentPayload.contentPath).toBe(fs.realpathSync(settingsPath)); }); + it('returns shared settings content when settings.json links to the Claude config file', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const claudeSettingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync( + claudeSettingsPath, + JSON.stringify({ hooks: { PreToolUse: [] }, env: { ANTHROPIC_MODEL: 'claude-opus-4-1' } }) + ); + + const sharedSettingsPath = path.join(ccsDir, 'shared', 'settings.json'); + createFileSymlink(claudeSettingsPath, sharedSettingsPath); + + const summaryPayload = await getJson<{ settings: boolean; total: number }>( + baseUrl, + '/api/shared/summary' + ); + expect(summaryPayload.settings).toBe(true); + expect(summaryPayload.total).toBe(1); + + const contentPayload = await getJson<{ content: string; contentPath: string }>( + baseUrl, + `/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(contentPayload.content).toContain('"ANTHROPIC_MODEL": "claude-opus-4-1"'); + expect(contentPayload.contentPath).toBe(fs.realpathSync(claudeSettingsPath)); + }); + it('rejects shared settings content when settings.json resolves outside the shared directory', async () => { const settingsPath = path.join(ccsDir, 'shared', 'settings.json'); const outsideSettingsPath = path.join(tempHome, 'outside-settings.json'); @@ -362,9 +464,27 @@ describe('web-server shared-routes', () => { expect(payload.error).toBe('Shared content not found'); }); + it('rejects shared settings symlinks to non-settings files inside the Claude config directory', async () => { + const claudeDir = path.join(tempHome, '.claude'); + process.env.CLAUDE_CONFIG_DIR = claudeDir; + fs.mkdirSync(claudeDir, { recursive: true }); + + const otherClaudeFile = path.join(claudeDir, 'credentials.json'); + fs.writeFileSync(otherClaudeFile, '{"SECRET":"do-not-read"}'); + createFileSymlink(otherClaudeFile, path.join(ccsDir, 'shared', 'settings.json')); + + const response = await fetch( + `${baseUrl}/api/shared/content?type=settings&path=${encodeURIComponent('settings.json')}` + ); + + expect(response.status).toBe(404); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe('Shared content not found'); + }); + it('returns README content for a shared plugin directory when available', async () => { const pluginsDir = path.join(ccsDir, 'shared', 'plugins'); - const pluginDir = path.join(pluginsDir, 'marketplaces'); + const pluginDir = path.join(pluginsDir, 'legacy-plugin'); fs.mkdirSync(pluginDir, { recursive: true }); fs.writeFileSync(path.join(pluginDir, 'README.md'), '# Plugin Marketplace\n\nShared docs'); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 99e4e0f7..ec63f655 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -1506,10 +1506,12 @@ const resources = { plugins: 'Plugins', settings: 'Settings', title: 'Shared Resources', - subtitle: 'Commands, skills, agents, and configuration shared across accounts', + subtitle: 'Commands, skills, agents, plugins, and configuration shared across accounts', totalShared: 'Total Resources', visible: 'Visible', markdownDetail: 'Markdown detail view', + registryDetail: 'Registry detail view', + maskedSettingsDetail: 'Masked settings view', filterPrefix: 'Filter:', configurationRequired: 'Configuration Required', countsUnavailable: 'Counts unavailable', @@ -1523,6 +1525,8 @@ const resources = { retry: 'Retry', noSharedFound: 'No shared {{tab}} found. Run `ccs sync` or add items in your shared directory.', + noPluginsFound: + 'No shared plugins are installed. Plugin registry files and cache folders are hidden until an actual plugin is present.', noMatch: 'No {{tab}} match "{{query}}".', selectOne: 'Select a {{tab}} to view full content.', pathLabel: 'Path', @@ -4083,6 +4087,8 @@ const resources = { totalShared: '共享总数', visible: '可见', markdownDetail: 'Markdown 详情视图', + registryDetail: '注册表详情视图', + maskedSettingsDetail: '脱敏设置视图', filterPrefix: '筛选:', configurationRequired: '需要配置', countsUnavailable: '统计不可用', @@ -4095,6 +4101,7 @@ const resources = { failedLoadShared: '加载共享{{tab}}失败', retry: '重试', noSharedFound: '未找到共享{{tab}}。请运行 `ccs sync` 或在共享目录添加内容。', + noPluginsFound: '未安装共享插件。真实插件存在前,插件注册表文件和缓存文件夹会保持隐藏。', noMatch: '没有匹配“{{query}}”的{{tab}}。', selectOne: '选择一个{{tab}}查看完整内容。', pathLabel: '路径', @@ -6687,6 +6694,8 @@ const resources = { totalShared: 'Tổng số mục dùng chung', visible: 'Hiển thị', markdownDetail: 'Chế độ xem chi tiết Markdown', + registryDetail: 'Chế độ xem chi tiết registry', + maskedSettingsDetail: 'Chế độ xem cài đặt đã ẩn nhạy cảm', filterPrefix: 'Lọc:', configurationRequired: 'Cần cấu hình', countsUnavailable: 'Không có số liệu', @@ -6700,6 +6709,8 @@ const resources = { retry: 'Thử lại', noSharedFound: 'Không tìm thấy {{tab}} dùng chung. Chạy `ccs sync` hoặc thêm mục trong thư mục dùng chung của bạn.', + noPluginsFound: + 'Chưa cài plugin dùng chung. Tệp registry plugin và thư mục cache sẽ bị ẩn cho đến khi có plugin thật.', noMatch: 'Không có {{tab}} khớp với "{{query}}".', selectOne: 'Chọn {{tab}} để xem toàn bộ nội dung.', pathLabel: 'Đường dẫn', @@ -9317,6 +9328,8 @@ const resources = { totalShared: '共有数', visible: '表示中', markdownDetail: 'Markdown詳細ビュー', + registryDetail: 'レジストリ詳細ビュー', + maskedSettingsDetail: 'マスク済み設定ビュー', filterPrefix: 'フィルター:', configurationRequired: '設定が必要です', countsUnavailable: '件数を取得できません', @@ -9330,6 +9343,8 @@ const resources = { retry: '再試行', noSharedFound: '共有{{tab}}が見つかりません。`ccs sync` を実行するか、共有ディレクトリに項目を追加してください。', + noPluginsFound: + '共有プラグインはインストールされていません。実際のプラグインが存在するまで、プラグインレジストリとキャッシュフォルダーは非表示になります。', noMatch: '{{tab}}で「{{query}}」に一致するものはありません。', selectOne: '内容を表示する{{tab}}を選択してください。', pathLabel: 'パス', diff --git a/ui/src/pages/shared.tsx b/ui/src/pages/shared.tsx index 4b3b6328..2694802e 100644 --- a/ui/src/pages/shared.tsx +++ b/ui/src/pages/shared.tsx @@ -8,7 +8,12 @@ import { ScrollArea } from '@/components/ui/scroll-area'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { CodeEditor } from '@/components/shared/code-editor'; import { useAccounts } from '@/hooks/use-accounts'; -import { useSharedItemContent, useSharedItems, useSharedSummary } from '@/hooks/use-shared'; +import { + type SharedSummary, + useSharedItemContent, + useSharedItems, + useSharedSummary, +} from '@/hooks/use-shared'; import { cn } from '@/lib/utils'; import '@/lib/i18n'; import { useTranslation } from 'react-i18next'; @@ -30,6 +35,7 @@ type TabType = 'commands' | 'skills' | 'agents' | 'plugins' | 'settings'; export function SharedPage() { const { t } = useTranslation(); const [tab, setTab] = useState('commands'); + const [hasUserSelectedTab, setHasUserSelectedTab] = useState(false); const tabLabels: Record = { commands: t('sharedPage.commands'), skills: t('sharedPage.skills'), @@ -47,8 +53,10 @@ export function SharedPage() { error: summaryError, refetch: refetchSummary, } = useSharedSummary(); + const { data: accountsView } = useAccounts(); - const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(tab); + const activeTab = summary && !hasUserSelectedTab ? getPreferredInitialTab(summary) : tab; + const { data: items, isLoading, isFetching, isError, error, refetch } = useSharedItems(activeTab); const allItems = items?.items ?? []; const normalizedQuery = query.trim().toLowerCase(); const activeQuery = query.trim(); @@ -79,14 +87,14 @@ export function SharedPage() { }, [filteredItems, selectedItemPath]); const contentPath = - tab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); + activeTab === 'settings' && summary?.settings ? 'settings.json' : (selectedItem?.path ?? null); const { data: selectedItemContent, isLoading: isContentLoading, isError: isContentError, error: contentError, refetch: refetchContent, - } = useSharedItemContent(tab, contentPath); + } = useSharedItemContent(activeTab, contentPath); const tabs: { id: TabType; label: string; icon: typeof FileText; count: number | string }[] = [ { id: 'commands', label: tabLabels.commands, icon: FileText, count: summary?.commands ?? 0 }, @@ -102,9 +110,19 @@ export function SharedPage() { ]; const totalSharedItems = summary?.total ?? 0; const settingsCount = summary?.settings ? 1 : 0; - const currentItemCount = tab === 'settings' ? settingsCount : allItems.length; - const currentVisibleCount = tab === 'settings' ? settingsCount : filteredItems.length; - const showListStatus = tab === 'settings' || (!isLoading && !isError); + const currentItemCount = activeTab === 'settings' ? settingsCount : allItems.length; + const currentVisibleCount = activeTab === 'settings' ? settingsCount : filteredItems.length; + const showListStatus = activeTab === 'settings' || (!isLoading && !isError); + const detailModeLabel = + activeTab === 'plugins' + ? t('sharedPage.registryDetail') + : activeTab === 'settings' + ? t('sharedPage.maskedSettingsDetail') + : t('sharedPage.markdownDetail'); + const emptyStateMessage = + activeTab === 'plugins' + ? t('sharedPage.noPluginsFound') + : t('sharedPage.noSharedFound', { tab: activeTab }); const hasNoItems = !isLoading && !isError && allItems.length === 0; const hasNoMatches = !isLoading && !isError && allItems.length > 0 && filteredItems.length === 0; @@ -123,7 +141,7 @@ export function SharedPage() { ); const itemsErrorMessage = getSharedErrorMessage( error, - `Unable to fetch shared ${tab}. Please try again.` + `Unable to fetch shared ${activeTab}. Please try again.` ); const contentErrorMessage = getSharedErrorMessage( contentError, @@ -141,8 +159,9 @@ export function SharedPage() {
{ + setHasUserSelectedTab(true); setTab(nextTab as TabType); setQuery(''); setSelectedItemPath(null); @@ -163,12 +182,12 @@ export function SharedPage() {
- +
- {t('sharedPage.markdownDetail')} + {detailModeLabel} {activeQuery ? ( {t('sharedPage.filterPrefix')} {activeQuery} @@ -228,7 +247,7 @@ export function SharedPage() {
-

{tabLabels[tab]}

+

{tabLabels[activeTab]}

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

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

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