Merge pull request #1200 from kaitranntt/kai/feat/1199-shared-resource-controls

feat(auth): add shared resource controls
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-10 22:01:57 -04:00
commit 248b6b735d
46 files changed
+2721 -144

No files matched your search

+29 -4
View File
@@ -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 <profile>', 'command')} Show profile details`);
console.log(
` ${color('resources <profile>', 'command')} Show or change shared resource mode`
);
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
console.log(` ${color('default <profile>', '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 <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 <profile>', '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 <profile>', '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 <profile> --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<void> {
return handleResources(this.getContext(), args);
}
/**
* Remove profile - delegates to remove-command.ts
*/
@@ -221,8 +242,8 @@ class AuthCommands {
/**
* Reset default profile - delegates to default-command.ts
*/
async handleResetDefault(): Promise<void> {
return handleResetDefault(this.getContext());
async handleResetDefault(args: string[] = []): Promise<void> {
return handleResetDefault(this.getContext(), args);
}
/**
@@ -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;
@@ -272,7 +297,7 @@ class AuthCommands {
break;
case 'reset-default':
await this.handleResetDefault();
await this.handleResetDefault(commandArgs);
break;
case 'current':
+8 -3
View File
@@ -10,7 +10,8 @@ import {
resolveRuntimePlainCcsResumeLane,
} from '../resume-lane-diagnostics';
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
import { CommandContext, parseArgs } from './types';
import { isProfileLocalSharedResourceMode } from '../shared-resource-policy';
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
interface BackupManifest {
target: string;
@@ -36,7 +37,11 @@ function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean
export async function handleBackup(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, json } = parseArgs(args);
const parsed = parseArgs(args);
const { profileName, json } = parsed;
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth backup <profile|default> [--json]',
});
if (!profileName) {
console.log(fail('Profile name is required'));
@@ -69,7 +74,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');
}
+19 -20
View File
@@ -21,9 +21,13 @@ 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';
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
import { stripAmbientProviderCredentials } from './create-command-env';
import { isUnifiedMode } from '../../config/config-loader-facade';
@@ -36,20 +40,13 @@ function sanitizeProfileNameForInstance(name: string): string {
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } =
parseArgs(args);
const parsed = parseArgs(args);
const { profileName, force, shareContext, contextGroup, deeperContinuity, bare } = parsed;
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--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 <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]',
});
if (!profileName) {
console.log(fail('Profile name is required'));
@@ -117,8 +114,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 +199,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 +215,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 +281,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'
)
+11 -3
View File
@@ -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<void> {
await initUI();
const { profileName } = parseArgs(args);
const parsed = parseArgs(args);
const { profileName } = parsed;
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth default <profile>',
});
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<void> {
export async function handleResetDefault(ctx: CommandContext, args: string[] = []): Promise<void> {
await initUI();
const parsed = parseArgs(args);
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth reset-default',
});
try {
// Use unified or legacy based on config mode
+1
View File
@@ -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';
+23 -4
View File
@@ -7,16 +7,27 @@
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';
import {
CommandContext,
ListOutput,
parseArgs,
formatRelativeTime,
rejectUnsupportedAuthOptions,
} from './types';
/**
* Handle the list command
*/
export async function handleList(ctx: CommandContext, args: string[]): Promise<void> {
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)
@@ -33,6 +44,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
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,
};
}
@@ -46,6 +59,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
profiles: profileNames.map((name) => {
const profile = profiles[name];
const contextPolicy = resolveAccountContextPolicy(profile);
const resourcePolicy = resolveSharedResourcePolicy(profile);
const isDefault = name === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(name);
@@ -58,6 +72,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
shared_resource_mode: resourcePolicy.mode,
shared_resource_inferred: resourcePolicy.inferred,
...(resourcePolicy.profileLocal ? { bare: true } : {}),
instance_path: instancePath,
};
}),
@@ -107,6 +124,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
const profile = profiles[name];
const isDefault = name === defaultProfile;
const contextPolicy = resolveAccountContextPolicy(profile);
const resourcePolicy = resolveSharedResourcePolicy(profile);
// Status column
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
@@ -122,6 +140,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
if (verbose) {
row.push(lastUsed);
row.push(formatAccountContextPolicy(contextPolicy));
row.push(resourcePolicy.mode);
}
return row;
@@ -129,14 +148,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
// Headers
const headers = verbose
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
? ['Profile', 'Type', 'Status', 'Last Used', 'Context', 'Resources']
: ['Profile', 'Type', 'Status'];
// Print table
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 34, 16] : [15, 12, 15],
})
);
console.log('');
+6 -2
View File
@@ -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<void> {
await initUI();
const { profileName, yes } = parseArgs(args);
const parsed = parseArgs(args);
const { profileName, yes } = parsed;
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth remove <profile> [--yes]',
});
if (!profileName) {
console.log(fail('Profile name is required'));
+154
View File
@@ -0,0 +1,154 @@
import { initUI, header, color, fail, ok, table } from '../../utils/ui';
import { resolveAccountContextPolicy } from '../account-context';
import {
isSharedResourceMode,
resolveSharedResourcePolicy,
sharedResourceModeToMetadata,
type SharedResourceMode,
} from '../shared-resource-policy';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs, rejectUnsupportedAuthOptions } from './types';
function formatMode(mode: SharedResourceMode): string {
return mode === 'profile-local' ? 'profile-local' : 'shared';
}
function modeDescription(mode: SharedResourceMode): string {
return mode === 'profile-local'
? 'profile-local resources; plugins/settings/commands/skills/agents are not linked from ~/.claude'
: 'shared resources from ~/.claude; plugins/settings/commands/skills/agents are linked into the account';
}
export async function handleResources(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const parsed = parseArgs(args, { allowMode: true });
const { profileName, mode, json } = parsed;
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth resources <profile> [--mode shared|profile-local] [--json]',
allowMode: true,
});
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(
`Usage: ${color('ccs auth resources <profile> [--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 === '') {
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',
ExitCode.PROFILE_ERROR
);
}
if (mode === undefined) {
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 selectedMode: SharedResourceMode = mode;
const metadata = sharedResourceModeToMetadata(selectedMode);
try {
if (existsUnified) {
ctx.registry.updateAccountUnified(profileName, metadata);
}
if (existsLegacy) {
ctx.registry.updateProfile(profileName, metadata);
}
await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: selectedMode === '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: selectedMode,
shared_resource_inferred: false,
bare: selectedMode === 'profile-local' ? true : undefined,
},
null,
2
)
);
return;
}
console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(selectedMode)}`));
console.log('');
console.log(modeDescription(selectedMode));
console.log('');
}
+14 -5
View File
@@ -10,9 +10,10 @@ 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';
import { CommandContext, ProfileOutput, parseArgs, rejectUnsupportedAuthOptions } from './types';
function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory>): string {
const scope = history.projects_shared ? 'shared projects' : 'profile-local projects';
@@ -25,7 +26,11 @@ function formatHistorySummary(history: ReturnType<typeof summarizeAccountHistory
*/
export async function handleShow(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, json } = parseArgs(args);
const parsed = parseArgs(args);
const { profileName, json } = parsed;
rejectUnsupportedAuthOptions(parsed, {
usage: 'ccs auth show <profile> [--json]',
});
if (!profileName) {
console.log(fail('Profile name is required'));
@@ -45,7 +50,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
const isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
const contextPolicy = resolveAccountContextPolicy(profile);
const settingsSync = describeSettingsSync(instancePath, { bare: profile.bare === true });
const resourcePolicy = resolveSharedResourcePolicy(profile);
const settingsSync = describeSettingsSync(instancePath, { bare: resourcePolicy.profileLocal });
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
const plainCcsUsesThisAccount =
@@ -74,6 +80,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
shared_resource_mode: resourcePolicy.mode,
shared_resource_inferred: resourcePolicy.inferred,
instance_path: instancePath,
session_count: sessionCount,
settings_sync: {
@@ -94,7 +102,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
},
}
: {}),
...(profile.bare ? { bare: true } : {}),
...(resourcePolicy.profileLocal ? { bare: true } : {}),
};
console.log(JSON.stringify(output, null, 2));
return;
@@ -112,6 +120,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
['Resources', resourcePolicy.mode],
['Credentials', 'isolated per account'],
['Settings', settingsSync.description],
['History', formatHistorySummary(historySummary)],
@@ -125,7 +134,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
],
]
: []),
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
...(resourcePolicy.profileLocal ? [['Bare', 'yes (no shared symlinks)']] : []),
['Sessions', `${sessionCount}`],
];
+55 -1
View File
@@ -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';
@@ -23,6 +26,7 @@ export interface AuthCommandArgs {
contextGroup?: string;
deeperContinuity?: boolean;
bare?: boolean;
mode?: string;
unknownFlags?: string[];
}
@@ -38,6 +42,8 @@ export interface ProfileOutput {
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
continuity_mode?: 'standard' | 'deeper' | null;
shared_resource_mode?: 'shared' | 'profile-local';
shared_resource_inferred?: boolean;
instance_path?: string;
session_count?: number;
settings_sync?: {
@@ -80,12 +86,39 @@ export interface CommandContext {
version: string;
}
export function rejectUnsupportedAuthOptions(
parsed: Pick<AuthCommandArgs, 'mode' | 'unknownFlags'>,
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;
const unknownFlags = new Set<string>();
const knownBooleanFlags = new Set([
'--force',
@@ -98,6 +131,9 @@ export function parseArgs(args: string[]): AuthCommandArgs {
'--bare',
]);
const knownValueFlags = new Set(['--context-group']);
if (options.allowMode) {
knownValueFlags.add('--mode');
}
for (let i = 0; i < args.length; i++) {
const arg = args[i];
@@ -114,11 +150,28 @@ export function parseArgs(args: string[]): AuthCommandArgs {
continue;
}
if (options.allowMode && 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 (options.allowMode && 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 +202,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],
};
+2 -1
View File
@@ -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 {
+1
View File
@@ -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,
},
};
+7 -2
View File
@@ -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,
};
}
+97
View File
@@ -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<T extends SharedResourceMetadata>(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;
}
+1
View File
@@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [
'backup',
'list',
'show',
'resources',
'remove',
'default',
'reset-default',
+7
View File
@@ -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'));
+1
View File
@@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise<void> {
'Profile Types',
[
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
{ name: 'ccs auth resources <name>', summary: 'Shared resources for an account profile' },
{ name: 'ccs api create', summary: 'API-backed settings profile' },
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
{ name: 'ccs env <profile>', summary: 'Export an existing profile for other tools' },
+3 -2
View File
@@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise<void> {
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)) {
+11
View File
@@ -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<MigrationResult> {
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');
+2
View File
@@ -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;
}
+6 -4
View File
@@ -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<void>
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)
+6 -1
View File
@@ -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 {
+2
View File
@@ -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;
}
@@ -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;
}
+108 -4
View File
@@ -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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
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<void>
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<void>
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<void>
}
});
/**
* PUT /api/accounts/:name/shared-resources - Update account shared resource mode
*/
router.put('/:name/shared-resources', async (req: Request, res: Response): Promise<void> => {
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
*/
+403 -13
View File
@@ -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';
@@ -32,14 +32,17 @@ 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';
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 {
@@ -48,6 +51,11 @@ interface SharedItemsCacheEntry {
expiresAt: number;
}
interface InstalledPluginRegistry {
version?: number;
plugins: Record<string, unknown>;
}
const sharedItemsCache = new Map<SharedCollectionType, SharedItemsCacheEntry>();
/**
@@ -75,13 +83,21 @@ sharedRoutes.get('/agents', (_req: Request, res: Response) => {
});
/**
* GET /api/shared/content?type=commands|skills|agents&path=<item-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=<item-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,15 +107,19 @@ 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 contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots);
const allowedRoots =
typeParam === 'settings'
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
: resolveAllowedRoots(typeParam, ccsDir, sharedDirRoot);
const contentResult = getSharedItemContent(typeParam, itemPathParam, allowedRoots, sharedDirRoot);
if (!contentResult) {
res.status(404).json({ error: 'Shared content not found' });
@@ -116,18 +136,37 @@ 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 settingsAllowedRoots = sharedDirRoot
? resolveSettingsAllowedRoots(ccsDir, sharedDirRoot)
: new Set<string>();
const hasSettings =
Boolean(sharedDirRoot) &&
Boolean(resolvedSettingsPath) &&
isPathWithinAny(resolvedSettingsPath as string, settingsAllowedRoots);
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 +174,7 @@ function resolveAllowedRoots(
ccsDir: string,
sharedDirRoot: string
): Set<string> {
if (type === 'commands') {
if (type === 'commands' || type === 'plugins') {
return new Set<string>([sharedDirRoot]);
}
@@ -151,6 +190,17 @@ function resolveAllowedRoots(
]);
}
function resolveSettingsAllowedRoots(ccsDir: string, sharedDirRoot: string): Set<string> {
const claudeConfigDir = getClaudeConfigDir();
return new Set<string>(
[
sharedDirRoot,
safeRealPath(path.join(claudeConfigDir, 'settings.json')),
safeRealPath(path.join(ccsDir, '.claude', 'settings.json')),
].filter((dirPath): dirPath is string => typeof dirPath === 'string')
);
}
function getSharedItems(type: SharedCollectionType): SharedItem[] {
const ccsDir = getCcsDir();
const sharedDir = path.join(ccsDir, 'shared', type);
@@ -180,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 });
@@ -222,6 +282,76 @@ function getSharedItems(type: SharedCollectionType): SharedItem[] {
return sortedItems;
}
function getPluginItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registryItems = getPluginRegistryItems(sharedDir, allowedRoots);
const legacyDirectoryItems = getLegacyPluginDirectoryItems(sharedDir, allowedRoots);
const itemsByPath = new Map<string, SharedItem>();
for (const item of [...registryItems, ...legacyDirectoryItems]) {
itemsByPath.set(item.path, item);
}
return [...itemsByPath.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function getPluginRegistryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry) {
return [];
}
return Object.entries(registry.plugins).map(([pluginName, metadata]) => ({
name: pluginName,
description: describeInstalledPlugin(pluginName, metadata),
path: encodePluginRegistryPath(pluginName),
type: 'plugin' as const,
}));
}
function getLegacyPluginDirectoryItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(sharedDir, { withFileTypes: true });
} catch {
return [];
}
const items: SharedItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || PLUGIN_INFRASTRUCTURE_DIRECTORIES.has(entry.name)) {
continue;
}
const entryPath = path.join(sharedDir, entry.name);
const resolvedEntryPath = safeRealPath(entryPath);
if (!resolvedEntryPath || !isPathWithinAny(resolvedEntryPath, allowedRoots)) {
continue;
}
const description = readFirstMarkdownDescription(
[
path.join(resolvedEntryPath, 'README.md'),
path.join(resolvedEntryPath, 'readme.md'),
path.join(resolvedEntryPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!description) {
continue;
}
items.push({
name: entry.name,
description,
path: entryPath,
type: 'plugin',
});
}
return items;
}
function getCommandItems(sharedDir: string, allowedRoots: Set<string>): SharedItem[] {
const markdownFiles = collectMarkdownFiles(sharedDir, allowedRoots);
const items: SharedItem[] = [];
@@ -504,6 +634,89 @@ function readMarkdownContent(markdownPath: string, allowedRoots: Set<string>): s
}
}
function encodePluginRegistryPath(pluginName: string): string {
return `${PLUGIN_REGISTRY_PATH_PREFIX}${encodeURIComponent(pluginName)}`;
}
function decodePluginRegistryPath(itemPath: string): string | null {
if (!itemPath.startsWith(PLUGIN_REGISTRY_PATH_PREFIX)) {
return null;
}
try {
const encodedName = itemPath.slice(PLUGIN_REGISTRY_PATH_PREFIX.length);
const pluginName = decodeURIComponent(encodedName);
return pluginName.length > 0 ? pluginName : null;
} catch {
return null;
}
}
function readJsonObject(
filePath: string,
allowedRoots: Set<string>
): Record<string, unknown> | null {
const resolvedPath = safeRealPath(filePath);
if (!resolvedPath || !isPathWithinAny(resolvedPath, allowedRoots)) {
return null;
}
try {
const stats = fs.statSync(resolvedPath);
if (!stats.isFile() || stats.size > MAX_CONTENT_FILE_BYTES) {
return null;
}
const parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
} catch {
return null;
}
}
function readPluginInstalledRegistry(
sharedDir: string,
allowedRoots: Set<string>
): InstalledPluginRegistry | null {
const parsed = readJsonObject(path.join(sharedDir, 'installed_plugins.json'), allowedRoots);
if (
!parsed ||
!parsed.plugins ||
typeof parsed.plugins !== 'object' ||
Array.isArray(parsed.plugins)
) {
return null;
}
return {
version: typeof parsed.version === 'number' ? parsed.version : undefined,
plugins: parsed.plugins as Record<string, unknown>,
};
}
function describeInstalledPlugin(pluginName: string, metadata: unknown): string {
const recordCount = Array.isArray(metadata) ? metadata.length : 1;
const marketplace = extractMarketplaceFromPluginName(pluginName);
const recordLabel = `${recordCount} ${recordCount === 1 ? 'record' : 'records'}`;
return marketplace
? `Installed from ${marketplace}; ${recordLabel} in shared registry`
: `Installed plugin; ${recordLabel} in shared registry`;
}
function extractMarketplaceFromPluginName(pluginName: string): string | null {
const atIndex = pluginName.lastIndexOf('@');
if (atIndex <= 0 || atIndex === pluginName.length - 1) {
return null;
}
return pluginName.slice(atIndex + 1);
}
function resolveReadableMarkdownPath(
markdownPaths: string[],
allowedRoots: Set<string>
@@ -529,10 +742,22 @@ function resolveReadableMarkdownPath(
}
function getSharedItemContent(
type: SharedCollectionType,
type: SharedContentType,
itemPath: string,
allowedRoots: Set<string>
allowedRoots: Set<string>,
sharedDir: string
): { content: string; contentPath: string } | null {
if (type === 'settings') {
return getSharedSettingsContent(itemPath, allowedRoots);
}
if (type === 'plugins') {
const pluginRegistryContent = getPluginRegistryContent(itemPath, sharedDir, allowedRoots);
if (pluginRegistryContent) {
return pluginRegistryContent;
}
}
const resolvedItemPath = safeRealPath(itemPath);
if (!resolvedItemPath || !isPathWithinAny(resolvedItemPath, allowedRoots)) {
return null;
@@ -559,6 +784,23 @@ function getSharedItemContent(
[path.join(resolvedItemPath, 'SKILL.md')],
allowedRoots
);
} else if (type === 'plugins') {
if (!itemStats.isDirectory() || isPluginInfrastructurePath(resolvedItemPath, sharedDir)) {
return null;
}
markdownPath = resolveReadableMarkdownPath(
[
path.join(resolvedItemPath, 'README.md'),
path.join(resolvedItemPath, 'readme.md'),
path.join(resolvedItemPath, 'PLUGIN.md'),
],
allowedRoots
);
if (!markdownPath) {
return null;
}
} else {
if (itemStats.isDirectory()) {
markdownPath = resolveReadableMarkdownPath(
@@ -589,6 +831,106 @@ function getSharedItemContent(
};
}
function isPluginInfrastructurePath(candidatePath: string, sharedDir: string): boolean {
for (const directoryName of PLUGIN_INFRASTRUCTURE_DIRECTORIES) {
const resolvedInfrastructurePath = safeRealPath(path.join(sharedDir, directoryName));
if (resolvedInfrastructurePath && isPathWithin(candidatePath, resolvedInfrastructurePath)) {
return true;
}
}
return false;
}
function getPluginRegistryContent(
itemPath: string,
sharedDir: string,
allowedRoots: Set<string>
): { content: string; contentPath: string } | null {
const pluginName = decodePluginRegistryPath(itemPath);
if (!pluginName) {
return null;
}
const registryPath = path.join(sharedDir, 'installed_plugins.json');
const resolvedRegistryPath = safeRealPath(registryPath);
if (!resolvedRegistryPath || !isPathWithinAny(resolvedRegistryPath, allowedRoots)) {
return null;
}
const registry = readPluginInstalledRegistry(sharedDir, allowedRoots);
if (!registry || !Object.prototype.hasOwnProperty.call(registry.plugins, pluginName)) {
return null;
}
const metadata = registry.plugins[pluginName];
const marketplace = extractMarketplaceFromPluginName(pluginName);
const marketplaceEntry = marketplace
? readJsonObject(path.join(sharedDir, 'known_marketplaces.json'), allowedRoots)?.[marketplace]
: null;
const blocklistEntry = findPluginBlocklistEntry(sharedDir, allowedRoots, pluginName);
const lines = [
`# ${pluginName}`,
'',
`Registry: \`${resolvedRegistryPath}\``,
'',
'## Installed Plugin',
'',
`- Source: shared \`installed_plugins.json\` registry`,
`- Registry version: ${registry.version ?? 'unknown'}`,
`- Marketplace: ${marketplace ?? 'not recorded in plugin name'}`,
`- Install records: ${Array.isArray(metadata) ? metadata.length : 1}`,
];
if (marketplaceEntry) {
lines.push(
'',
'## Marketplace Registry Entry',
'',
'```json',
stringifyJson(marketplaceEntry),
'```'
);
}
if (blocklistEntry) {
lines.push('', '## Blocklist Notice', '', '```json', stringifyJson(blocklistEntry), '```');
}
lines.push('', '## Plugin Registry Entry', '', '```json', stringifyJson(metadata), '```');
return {
content: lines.join('\n'),
contentPath: resolvedRegistryPath,
};
}
function findPluginBlocklistEntry(
sharedDir: string,
allowedRoots: Set<string>,
pluginName: string
): unknown | null {
const blocklist = readJsonObject(path.join(sharedDir, 'blocklist.json'), allowedRoots);
const plugins = blocklist?.plugins;
if (!Array.isArray(plugins)) {
return null;
}
return (
plugins.find(
(entry) =>
entry &&
typeof entry === 'object' &&
'plugin' in entry &&
(entry as { plugin?: unknown }).plugin === pluginName
) ?? null
);
}
function stringifyJson(value: unknown): string {
return JSON.stringify(value, null, 2) ?? 'null';
}
function safeRealPath(targetPath: string): string | null {
try {
return fs.realpathSync(targetPath);
@@ -597,6 +939,54 @@ function safeRealPath(targetPath: string): string | null {
}
}
function getSharedSettingsContent(
itemPath: string,
allowedRoots: Set<string>
): { 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 isPathWithin(candidatePath: string, basePath: string): boolean {
const normalizedCandidate = normalizeForPathComparison(candidatePath);
const normalizedBase = normalizeForPathComparison(basePath);