Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
25 changes: 20 additions & 5 deletions src/auth/account-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 =
Expand All @@ -161,18 +170,19 @@ 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 };
}
}

return {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
skillsMode,
};
}

return { mode: 'isolated' };
return { mode: 'isolated', skillsMode };
}

/**
Expand All @@ -181,28 +191,33 @@ 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,
};
}

/**
* 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}`;
}
4 changes: 4 additions & 0 deletions src/ccs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,10 @@ async function main(): Promise<void> {
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];
Expand Down
11 changes: 11 additions & 0 deletions src/commands/help-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,17 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['--disallowedTools <list>', 'Block specific tools'],
]);

// Per-Profile Skills
printSubSection('Per-Profile Skills', [
['ccs skills <profile> isolate', 'Enable per-profile skills'],
['ccs skills <profile> share', 'Restore shared skills mode'],
['ccs skills <profile> add <pkg>', 'Add skill to specific profile'],
['ccs skills <profile> remove [skill]', 'Remove skill from profile'],
['ccs skills <profile> list', 'List skills for profile'],
['ccs skills <profile> find [query]', 'Search for available skills'],
['ccs skills <profile> sync', 'Sync shared skills to profile'],
]);

// Diagnostics
printSubSection('Diagnostics', [
['ccs setup', 'First-time setup wizard'],
Expand Down
229 changes: 229 additions & 0 deletions src/commands/skills-command.ts
Original file line number Diff line number Diff line change
@@ -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 <profile> <command> [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 <package> 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<number> {
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<void> {
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 <profile> 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 <profile> add <package>'));
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);
}
Loading
Loading