diff --git a/CLAUDE.md b/CLAUDE.md index 072e66bff..21f5dca90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,7 @@ bun run validate # Step 3: Final check (must pass) | `ccs env --help` | `src/commands/env-command.ts` → `showHelp()` | | `ccs persist --help` | `src/commands/persist-command.ts` → `showHelp()` | | `ccs setup --help` | `src/commands/setup-command.ts` → `showHelp()` | +| `ccs skills --help` | `src/commands/skills-command.ts` → `showHelp()` | **Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text. diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index a8c28b50f..5f958083b 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -7,17 +7,22 @@ export type AccountContextMode = 'isolated' | 'shared'; export type AccountContinuityMode = 'standard' | 'deeper'; +export type AccountSkillsMode = 'shared' | 'isolated'; + +export const DEFAULT_ACCOUNT_SKILLS_MODE: AccountSkillsMode = 'shared'; export interface AccountContextMetadata { context_mode?: AccountContextMode; context_group?: string; continuity_mode?: AccountContinuityMode; + skills_mode?: AccountSkillsMode; } export interface AccountContextPolicy { mode: AccountContextMode; group?: string; continuityMode?: AccountContinuityMode; + skillsMode?: AccountSkillsMode; } export interface CreateAccountContextInput { @@ -72,13 +77,15 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex const mode = candidate['context_mode']; const group = candidate['context_group']; const continuity = candidate['continuity_mode']; + const skills = candidate['skills_mode']; const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared'; const groupValid = group === undefined || typeof group === 'string'; const continuityValid = continuity === undefined || continuity === 'standard' || continuity === 'deeper'; + const skillsValid = skills === undefined || skills === 'shared' || skills === 'isolated'; - if (!modeValid || !groupValid || !continuityValid) { + if (!modeValid || !groupValid || !continuityValid || !skillsValid) { return false; } @@ -153,6 +160,8 @@ export function resolveAccountContextPolicy( metadata?: AccountContextMetadata | null ): AccountContextPolicy { const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated'; + const skillsMode: AccountSkillsMode = + metadata?.skills_mode === 'isolated' ? 'isolated' : DEFAULT_ACCOUNT_SKILLS_MODE; if (mode === 'shared') { const continuityMode: AccountContinuityMode = @@ -161,7 +170,7 @@ export function resolveAccountContextPolicy( if (rawGroup && rawGroup.trim().length > 0) { const normalized = normalizeContextGroupName(rawGroup); if (isValidContextGroupName(normalized)) { - return { mode: 'shared', group: normalized, continuityMode }; + return { mode: 'shared', group: normalized, continuityMode, skillsMode }; } } @@ -169,10 +178,11 @@ export function resolveAccountContextPolicy( mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP, continuityMode, + skillsMode, }; } - return { mode: 'isolated' }; + return { mode: 'isolated', skillsMode }; } /** @@ -181,17 +191,21 @@ export function resolveAccountContextPolicy( export function policyToAccountContextMetadata( policy: AccountContextPolicy ): AccountContextMetadata { + const skillsMode = policy.skillsMode === 'isolated' ? ('isolated' as const) : undefined; + if (policy.mode === 'shared') { return { context_mode: 'shared', context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP, continuity_mode: policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE, + skills_mode: skillsMode, }; } return { context_mode: 'isolated', + skills_mode: skillsMode, }; } @@ -199,10 +213,11 @@ export function policyToAccountContextMetadata( * User-facing summary for display/help output. */ export function formatAccountContextPolicy(policy: AccountContextPolicy): string { + const skillsSuffix = policy.skillsMode === 'isolated' ? ', skills: isolated' : ''; if (policy.mode === 'shared') { const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard'; - return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`; + return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity}${skillsSuffix})`; } - return 'isolated'; + return `isolated${skillsSuffix}`; } diff --git a/src/ccs.ts b/src/ccs.ts index 0c8ebdedc..6d321cb00 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -564,6 +564,10 @@ async function main(): Promise { const exitCode = await handleCursorCommand(args.slice(1)); process.exit(exitCode); }, + skills: async () => { + const { handleSkillsCommand } = await import('./commands/skills-command'); + await handleSkillsCommand(args.slice(1)); + }, }; const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg]; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5f5f4c56d..90504f5ae 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -294,6 +294,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['--disallowedTools ', 'Block specific tools'], ]); + // Per-Profile Skills + printSubSection('Per-Profile Skills', [ + ['ccs skills isolate', 'Enable per-profile skills'], + ['ccs skills share', 'Restore shared skills mode'], + ['ccs skills add ', 'Add skill to specific profile'], + ['ccs skills remove [skill]', 'Remove skill from profile'], + ['ccs skills list', 'List skills for profile'], + ['ccs skills find [query]', 'Search for available skills'], + ['ccs skills sync', 'Sync shared skills to profile'], + ]); + // Diagnostics printSubSection('Diagnostics', [ ['ccs setup', 'First-time setup wizard'], diff --git a/src/commands/skills-command.ts b/src/commands/skills-command.ts new file mode 100644 index 000000000..7a1ea7fc0 --- /dev/null +++ b/src/commands/skills-command.ts @@ -0,0 +1,229 @@ +/** + * Skills Command Handler + * + * Wraps `npx skills` to provide per-profile skill management. + * Uses CLAUDE_CONFIG_DIR to target the correct instance directory. + */ + +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import { initUI, header, ok, info, warn, fail } from '../utils/ui'; +import { loadOrCreateUnifiedConfig, mutateUnifiedConfig } from '../config/unified-config-loader'; +import { resolveAccountContextPolicy } from '../auth/account-context'; +import InstanceManager from '../management/instance-manager'; +import SharedManager from '../management/shared-manager'; + +function showHelp(): void { + console.log(''); + console.log(header('Per-Profile Skills Management')); + console.log(''); + console.log('Usage:'); + console.log(' ccs skills [args...]'); + console.log(''); + console.log('Commands:'); + console.log(' isolate Enable per-profile skills for this profile'); + console.log(' share Restore shared skills mode'); + console.log(' add Add a skill to this profile'); + console.log(' remove [skill] Remove a skill from this profile'); + console.log(' list List skills for this profile'); + console.log(' find [query] Search for available skills'); + console.log(' sync Sync shared skills into isolated profile'); + console.log(''); + console.log('Examples:'); + console.log(' ccs skills personal isolate'); + console.log(' ccs skills personal add vercel-labs/agent-skills'); + console.log(' ccs skills personal list'); + console.log(' ccs skills personal share'); + console.log(''); +} + +function runNpxSkills(npxArgs: string[], instancePath: string): Promise { + return new Promise((resolve) => { + const env = { + ...process.env, + CLAUDE_CONFIG_DIR: instancePath, + }; + + const child = spawn('npx', ['skills', ...npxArgs], { + env, + stdio: 'inherit', + shell: true, + }); + + child.on('close', (code) => resolve(code ?? 1)); + child.on('error', (err) => { + console.log(fail(`Failed to run npx skills: ${err.message}`)); + resolve(1); + }); + }); +} + +export async function handleSkillsCommand(args: string[]): Promise { + await initUI(); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + showHelp(); + process.exit(0); + } + + const profileName = args[0]; + const subcommand = args[1]; + const subArgs = args.slice(2); + + // Validate profile exists as an account + const config = loadOrCreateUnifiedConfig(); + const account = config.accounts[profileName]; + + if (!account) { + console.log(fail(`Profile "${profileName}" is not an account profile.`)); + console.log(info('Skills isolation is only available for account profiles.')); + console.log(info(`Available accounts: ${Object.keys(config.accounts).join(', ') || '(none)'}`)); + process.exit(1); + } + + const instanceMgr = new InstanceManager(); + const sharedManager = new SharedManager(); + + if (!subcommand || subcommand === '--help' || subcommand === '-h') { + showHelp(); + process.exit(0); + } + + switch (subcommand) { + case 'isolate': { + mutateUnifiedConfig((cfg) => { + if (cfg.accounts[profileName]) { + cfg.accounts[profileName].skills_mode = 'isolated'; + } + }); + + const policy = resolveAccountContextPolicy({ + ...account, + skills_mode: 'isolated', + }); + const instancePath = instanceMgr.getInstancePath(profileName); + + if (!fs.existsSync(instancePath)) { + await instanceMgr.ensureInstance(profileName, policy); + } else { + await sharedManager.syncSkills(instancePath, policy); + } + + console.log(ok(`Skills isolation enabled for "${profileName}".`)); + console.log( + info( + 'Shared skills have been symlinked. Use "ccs skills add" to add profile-specific skills.' + ) + ); + break; + } + + case 'share': { + mutateUnifiedConfig((cfg) => { + if (cfg.accounts[profileName]) { + delete cfg.accounts[profileName].skills_mode; + } + }); + + const policy = resolveAccountContextPolicy({ + ...account, + skills_mode: undefined, + }); + const instancePath = instanceMgr.getInstancePath(profileName); + + if (fs.existsSync(instancePath)) { + await sharedManager.syncSkills(instancePath, policy); + } + + console.log(ok(`Shared skills mode restored for "${profileName}".`)); + break; + } + + case 'add': { + if (subArgs.length === 0) { + console.log(fail('Missing package name. Usage: ccs skills add ')); + process.exit(1); + } + + // Auto-isolate if not already + if (account.skills_mode !== 'isolated') { + console.log(info('Auto-enabling skills isolation for this profile...')); + mutateUnifiedConfig((cfg) => { + if (cfg.accounts[profileName]) { + cfg.accounts[profileName].skills_mode = 'isolated'; + } + }); + + const policy = resolveAccountContextPolicy({ + ...account, + skills_mode: 'isolated', + }); + const instancePath = instanceMgr.getInstancePath(profileName); + + if (!fs.existsSync(instancePath)) { + await instanceMgr.ensureInstance(profileName, policy); + } else { + await sharedManager.syncSkills(instancePath, policy); + } + } + + const instancePath = instanceMgr.getInstancePath(profileName); + const exitCode = await runNpxSkills( + ['add', '-g', ...subArgs, '--agent', 'claude-code'], + instancePath + ); + process.exit(exitCode); + break; + } + + case 'remove': { + const instancePath = instanceMgr.getInstancePath(profileName); + const exitCode = await runNpxSkills( + ['remove', '-g', '--agent', 'claude-code', ...subArgs], + instancePath + ); + process.exit(exitCode); + break; + } + + case 'list': { + const instancePath = instanceMgr.getInstancePath(profileName); + const exitCode = await runNpxSkills(['ls', '-g', '--agent', 'claude-code'], instancePath); + process.exit(exitCode); + break; + } + + case 'find': { + // Search is global — not profile-specific + const exitCode = await runNpxSkills(['find', ...subArgs], ''); + process.exit(exitCode); + break; + } + + case 'sync': { + if (account.skills_mode !== 'isolated') { + console.log(info(`Profile "${profileName}" uses shared skills. Nothing to sync.`)); + process.exit(0); + } + + const policy = resolveAccountContextPolicy(account); + const instancePath = instanceMgr.getInstancePath(profileName); + + if (fs.existsSync(instancePath)) { + await sharedManager.syncSkills(instancePath, policy); + console.log(ok(`Shared skills synced to "${profileName}".`)); + } else { + console.log(warn(`Instance for "${profileName}" does not exist yet.`)); + } + break; + } + + default: { + console.log(fail(`Unknown skills command: ${subcommand}`)); + showHelp(); + process.exit(1); + } + } + + process.exit(0); +} diff --git a/src/commands/sync-command.ts b/src/commands/sync-command.ts index 004c59038..28e9d3104 100644 --- a/src/commands/sync-command.ts +++ b/src/commands/sync-command.ts @@ -70,6 +70,31 @@ export async function handleSyncCommand(): Promise { console.log(info('No instances to sync MCP servers')); } + // Sync skills for isolated-skills profiles + const { resolveAccountContextPolicy } = await import('../auth/account-context'); + const { loadOrCreateUnifiedConfig } = await import('../config/unified-config-loader'); + const config = loadOrCreateUnifiedConfig(); + let skillsSynced = 0; + + for (const [name, account] of Object.entries(config.accounts)) { + if (account.skills_mode !== 'isolated') { + continue; + } + + if (!instanceMgr.hasInstance(name)) { + continue; + } + + const policy = resolveAccountContextPolicy(account); + const instancePath = instanceMgr.getInstancePath(name); + await sharedManager.syncSkills(instancePath, policy); + skillsSynced++; + } + + if (skillsSynced > 0) { + console.log(ok(`Skills synced for ${skillsSynced} isolated profile(s)`)); + } + console.log(''); console.log(ok('Sync complete!')); console.log(''); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 89ed7d9fe..ecf16bc8c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -48,6 +48,8 @@ export interface AccountConfig { continuity_mode?: 'standard' | 'deeper'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; + /** Skills mode: shared (default) uses global symlink, isolated enables per-profile skills */ + skills_mode?: 'shared' | 'isolated'; } /** diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index b6559720b..9e3b4d599 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -56,6 +56,7 @@ class InstanceManager { // Apply context policy (isolated by default, optional shared group). await this.sharedManager.syncProjectContext(instancePath, contextPolicy); await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy); + await this.sharedManager.syncSkills(instancePath, contextPolicy); }); // Sync MCP servers from global ~/.claude.json (unless bare) diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 9a010edd5..dc40c8fe2 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -10,7 +10,11 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ok, info, warn } from '../utils/ui'; -import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context'; +import { + AccountContextPolicy, + DEFAULT_ACCOUNT_CONTEXT_GROUP, + DEFAULT_ACCOUNT_SKILLS_MODE, +} from '../auth/account-context'; import { getCcsDir } from '../utils/config-manager'; interface SharedItem { @@ -539,6 +543,102 @@ class SharedManager { } } + /** + * Sync skills directory based on account policy. + * + * - shared (default): instance/skills is a symlink to shared/skills (→ ~/.claude/skills). + * - isolated: instance/skills is a real directory. Shared skills are individually + * symlinked in, and profile-specific skills (added via npx skills) coexist. + */ + async syncSkills(instancePath: string, policy: AccountContextPolicy): Promise { + const skillsPath = path.join(instancePath, 'skills'); + const skillsMode = policy.skillsMode || DEFAULT_ACCOUNT_SKILLS_MODE; + + if (skillsMode === 'isolated') { + const currentStats = await this.getLstat(skillsPath); + + // Already a real directory — just sync shared skills into it + if (currentStats?.isDirectory() && !currentStats.isSymbolicLink()) { + await this.syncSharedSkillsToIsolated(skillsPath); + return; + } + + // Remove existing symlink (shared → isolated transition) + if (currentStats?.isSymbolicLink()) { + await fs.promises.unlink(skillsPath); + } else if (currentStats) { + await fs.promises.rm(skillsPath, { force: true }); + } + + await this.ensureDirectory(skillsPath); + await this.syncSharedSkillsToIsolated(skillsPath); + } else { + // shared mode: restore shared symlink + const currentStats = await this.getLstat(skillsPath); + + // Already the correct shared symlink — nothing to do + const sharedSkills = path.join(this.sharedDir, 'skills'); + if (currentStats?.isSymbolicLink()) { + if (await this.isSymlinkTarget(skillsPath, sharedSkills)) { + return; + } + await fs.promises.unlink(skillsPath); + } else if (currentStats?.isDirectory()) { + // Real directory → shared: warn about profile-only skills being lost + console.log( + warn( + 'Restoring shared skills mode. Profile-only skills in this directory will be removed.' + ) + ); + await fs.promises.rm(skillsPath, { recursive: true, force: true }); + } else if (currentStats) { + await fs.promises.rm(skillsPath, { force: true }); + } + + await this.linkDirectoryWithFallback(sharedSkills, skillsPath); + } + } + + /** + * Sync shared skills into an isolated instance skills directory. + * Resolves through ~/.ccs/shared/skills (which symlinks to ~/.claude/skills). + * Each shared skill entry is individually symlinked. Existing entries are preserved + * to protect profile-specific skills added via npx skills. + */ + private async syncSharedSkillsToIsolated(instanceSkillsPath: string): Promise { + // Resolve through shared dir symlink (shared/skills -> ~/.claude/skills) + const sharedSkillsSymlink = path.join(this.sharedDir, 'skills'); + + if (!(await this.pathExists(sharedSkillsSymlink))) { + return; + } + + // Resolve to canonical path so we enumerate the actual skills directory + let resolvedSkillsPath: string; + try { + resolvedSkillsPath = await fs.promises.realpath(sharedSkillsSymlink); + } catch (_err) { + return; + } + + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(resolvedSkillsPath, { withFileTypes: true }); + } catch (_err) { + return; + } + + for (const entry of entries) { + const targetPath = path.join(resolvedSkillsPath, entry.name); + const linkPath = path.join(instanceSkillsPath, entry.name); + + // Skip if already exists (preserves profile-specific skills) + if (await this.pathExists(linkPath)) continue; + + await this.linkDirectoryWithFallback(targetPath, linkPath); + } + } + /** * Normalize plugin registry paths to use canonical ~/.claude/ paths * instead of instance-specific ~/.ccs/instances// paths. diff --git a/src/types/config.ts b/src/types/config.ts index f3c2541a5..688f128c1 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -102,6 +102,8 @@ export interface ProfileMetadata { continuity_mode?: 'standard' | 'deeper'; /** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */ bare?: boolean; + /** Skills mode: shared (default) uses global symlink, isolated enables per-profile skills */ + skills_mode?: 'shared' | 'isolated'; } export interface ProfilesRegistry { diff --git a/tests/unit/skills-sync.test.ts b/tests/unit/skills-sync.test.ts new file mode 100644 index 000000000..03fbef43c --- /dev/null +++ b/tests/unit/skills-sync.test.ts @@ -0,0 +1,193 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import SharedManager from '../../src/management/shared-manager'; +import type { AccountContextPolicy } from '../../src/auth/account-context'; + +describe('SharedManager skills sync', () => { + let tempRoot = ''; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-skills-sync-test-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + const isolatedHome = path.join(tempRoot, 'home'); + fs.mkdirSync(isolatedHome, { recursive: true }); + process.env.HOME = isolatedHome; + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + }); + + afterEach(() => { + if (originalHome !== undefined) process.env.HOME = originalHome; + else delete process.env.HOME; + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + function getCcsDir(): string { + return path.join(path.resolve(tempRoot), '.ccs'); + } + + function setupInstance(): { instancePath: string; claudeDir: string; sharedDir: string } { + const ccsDir = getCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'test-profile'); + const claudeDir = path.join(tempRoot, 'home', '.claude'); + const sharedDir = path.join(ccsDir, 'shared'); + + fs.mkdirSync(instancePath, { recursive: true }); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.mkdirSync(sharedDir, { recursive: true }); + + // Create shared skills dir in ~/.claude/ + const claudeSkills = path.join(claudeDir, 'skills'); + fs.mkdirSync(claudeSkills, { recursive: true }); + + // Create shared symlink ~/.ccs/shared/skills -> ~/.claude/skills + const sharedSkills = path.join(sharedDir, 'skills'); + fs.symlinkSync(claudeSkills, sharedSkills, 'dir'); + + // Create initial shared symlink in instance (default state) + const instanceSkills = path.join(instancePath, 'skills'); + fs.symlinkSync(sharedSkills, instanceSkills, 'dir'); + + return { instancePath, claudeDir, sharedDir }; + } + + it('keeps shared symlink in shared mode', async () => { + const { instancePath } = setupInstance(); + const policy: AccountContextPolicy = { mode: 'isolated', skillsMode: 'shared' }; + + const manager = new SharedManager(); + await manager.syncSkills(instancePath, policy); + + const skillsPath = path.join(instancePath, 'skills'); + const stats = fs.lstatSync(skillsPath); + expect(stats.isSymbolicLink()).toBe(true); + }); + + it('converts symlink to real directory in isolated mode', async () => { + const { instancePath } = setupInstance(); + const policy: AccountContextPolicy = { mode: 'isolated', skillsMode: 'isolated' }; + + const manager = new SharedManager(); + await manager.syncSkills(instancePath, policy); + + const skillsPath = path.join(instancePath, 'skills'); + const stats = fs.lstatSync(skillsPath); + expect(stats.isSymbolicLink()).toBe(false); + expect(stats.isDirectory()).toBe(true); + }); + + it('symlinks shared skills into isolated directory', async () => { + const { instancePath, claudeDir } = setupInstance(); + + // Add a shared skill to ~/.claude/skills/ + const sharedSkillDir = path.join(claudeDir, 'skills', 'my-shared-skill'); + fs.mkdirSync(sharedSkillDir, { recursive: true }); + fs.writeFileSync(path.join(sharedSkillDir, 'index.js'), 'module.exports = {}'); + + const policy: AccountContextPolicy = { mode: 'isolated', skillsMode: 'isolated' }; + const manager = new SharedManager(); + await manager.syncSkills(instancePath, policy); + + const skillsPath = path.join(instancePath, 'skills'); + const linkedSkill = path.join(skillsPath, 'my-shared-skill'); + + expect(fs.existsSync(linkedSkill)).toBe(true); + const linkStats = fs.lstatSync(linkedSkill); + expect(linkStats.isSymbolicLink()).toBe(true); + }); + + it('preserves profile-specific skills during sync', async () => { + const { instancePath, claudeDir } = setupInstance(); + const policy: AccountContextPolicy = { mode: 'isolated', skillsMode: 'isolated' }; + const manager = new SharedManager(); + + // First: switch to isolated mode + await manager.syncSkills(instancePath, policy); + + // Simulate a profile-specific skill (added by npx skills) + const profileSkillDir = path.join(instancePath, 'skills', 'profile-only-skill'); + fs.mkdirSync(profileSkillDir, { recursive: true }); + fs.writeFileSync(path.join(profileSkillDir, 'index.js'), 'profile-specific'); + + // Add a shared skill + const sharedSkillDir = path.join(claudeDir, 'skills', 'new-shared-skill'); + fs.mkdirSync(sharedSkillDir, { recursive: true }); + + // Re-sync + await manager.syncSkills(instancePath, policy); + + // Profile-specific skill should still exist + expect(fs.existsSync(profileSkillDir)).toBe(true); + expect(fs.readFileSync(path.join(profileSkillDir, 'index.js'), 'utf8')).toBe( + 'profile-specific' + ); + + // New shared skill should be linked + const newSharedLink = path.join(instancePath, 'skills', 'new-shared-skill'); + expect(fs.existsSync(newSharedLink)).toBe(true); + expect(fs.lstatSync(newSharedLink).isSymbolicLink()).toBe(true); + }); + + it('restores shared symlink when switching from isolated to shared', async () => { + const { instancePath } = setupInstance(); + const manager = new SharedManager(); + + // Switch to isolated + await manager.syncSkills(instancePath, { mode: 'isolated', skillsMode: 'isolated' }); + expect(fs.lstatSync(path.join(instancePath, 'skills')).isDirectory()).toBe(true); + expect(fs.lstatSync(path.join(instancePath, 'skills')).isSymbolicLink()).toBe(false); + + // Switch back to shared + await manager.syncSkills(instancePath, { mode: 'isolated', skillsMode: 'shared' }); + expect(fs.lstatSync(path.join(instancePath, 'skills')).isSymbolicLink()).toBe(true); + }); + + it('handles missing ~/.claude/skills gracefully in isolated mode', async () => { + const ccsDir = getCcsDir(); + const instancePath = path.join(ccsDir, 'instances', 'no-claude-skills'); + const claudeDir = path.join(tempRoot, 'home', '.claude'); + const sharedDir = path.join(ccsDir, 'shared'); + + fs.mkdirSync(instancePath, { recursive: true }); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.mkdirSync(sharedDir, { recursive: true }); + + // No ~/.claude/skills directory exists + const policy: AccountContextPolicy = { mode: 'isolated', skillsMode: 'isolated' }; + const manager = new SharedManager(); + await manager.syncSkills(instancePath, policy); + + const skillsPath = path.join(instancePath, 'skills'); + expect(fs.existsSync(skillsPath)).toBe(true); + expect(fs.lstatSync(skillsPath).isDirectory()).toBe(true); + }); + + it('defaults to shared mode when skillsMode is undefined', async () => { + const { instancePath } = setupInstance(); + const policy: AccountContextPolicy = { mode: 'isolated' }; // no skillsMode + + const manager = new SharedManager(); + await manager.syncSkills(instancePath, policy); + + const skillsPath = path.join(instancePath, 'skills'); + const stats = fs.lstatSync(skillsPath); + expect(stats.isSymbolicLink()).toBe(true); + }); +});