
Manage Local Skills
- 13 installs
- 1.1k repo stars
- Updated August 4, 2026
- tencentcloudbase/cloudbase-mcp
Helps with ai & agent building tasks during AI-assisted development.
About
manage-local-skills is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- manage-local-skills
- AI & Agent Building
- AI-coding skill
Manage Local Skills by the numbers
- 13 all-time installs (skills.sh)
- Ranked #11,409 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tencentcloudbase/cloudbase-mcp --skill manage-local-skillsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 1.1k |
| Last updated | August 4, 2026 |
| Repository | tencentcloudbase/cloudbase-mcp ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Manage Local Skills
Manage locally maintained skills as reusable, standard skill assets.
What this skill does
Use this skill to:
- classify local sources as standard skills, non-standard skill-like folders, or mixed repositories
- convert non-standard local materials into a standard
SKILL.md-based structure - validate standard skill structure before installation
- sync one or more local skills into one or more agent skill directories
- maintain explicit agent and IDE path mappings for local skill installation
Do not use this skill for
- publishing remote skills registries or package indexes
- cloning skills from remote repositories
- interactive marketplace search
- generic documentation cleanup unrelated to local skill structure
Workflow
1. Identify whether the user wants analysis only, migration, validation, mapping changes, or installation. 2. If the source is not obviously standard, read references/source-classification.md and run scripts/inspect-source.mjs first. 3. If migration is needed, read references/migration-playbook.md and convert the source into a standard skill folder before installation. 4. Before mounting a skill, read references/cli-alignment.md and references/install-workflow.md to preserve the source-first install model used in this repo. 5. Use scripts/validate-skill.mjs before and after installation when structure or path correctness is in doubt. 6. If the target agent is new or unclear, read references/mapping-extension.md before adding or changing mappings.
Common requests
- "Install this local skill into Claude and Cursor."
- "Sync everything under
./skillsto Codex and CodeBuddy." - "Turn this prompts folder into a reusable skill."
- "Link my local skills into the agent directories for this project."
Routing
| Task | Read | Script |
|---|---|---|
Understand how this differs from or aligns with skills CLI | references/cli-alignment.md | |
| Classify local sources and detect migration candidates | references/source-classification.md | scripts/inspect-source.mjs |
| Convert non-standard local folders into standard skills | references/migration-playbook.md | scripts/inspect-source.mjs |
| Install or mount local skills into agent directories | references/install-workflow.md | scripts/install-skill.mjs |
| Add or update agent mappings | references/mapping-extension.md | scripts/install-skill.mjs |
| Validate structure or installation results | references/install-workflow.md | scripts/validate-skill.mjs |
Operating rules
- Treat
skillsCLI installation semantics as the baseline, but follow this repo's source-first project install model for locally maintained skills. - Prefer analysis first when the source structure is ambiguous.
- Do not execute arbitrary scripts from the source folder while inspecting it.
- In
symlinkmode, keepskills/as the single maintained source of truth and expose.agentsentries as links instead of extra copies. - Prefer symlinks when supported and safe. Fall back to copy when the user requests it or symlinks fail.
- Make scope explicit:
projectmeans the current workspace,globalmeans the user-level agent directory. - Ask the user to confirm before writing files, replacing existing installs, changing mappings, or converting a non-standard source into a standard skill.
- If the user asks to sync multiple skills or multiple agents, summarize the planned batch operation before execution and wait for confirmation.
- Call out differences whenever the requested behavior cannot fully match
skillsCLI.
Quick commands
node skills/manage-local-skills/scripts/inspect-source.mjs --input <path> --json
node skills/manage-local-skills/scripts/validate-skill.mjs --skill-dir <path>
node skills/manage-local-skills/scripts/install-skill.mjs --source-dir skills --skill <name> --agent cursor --scope project --mode symlinkMinimum self-check
- Is the source clearly classified as
standard,nonstandard, ormixed? - Is the target skill structure valid before installation?
- If
symlinkmode was requested, does the installed.agentsentry resolve back to the maintained source? - Is the selected agent mapping explicit and correct for the requested scope?
- If symlink mode is used, is there a defined fallback to copy mode?
- If behavior differs from
skillsCLI, did you state the difference clearly?
CLI Alignment
Use this reference when local skill management must behave like the skills CLI as closely as possible.
Behavioral baseline
The local management flow should preserve these skills CLI ideas:
1. Distinguish project scope from global scope. 2. Keep one stable canonical entry per installed skill. 3. Prefer symlinks when supported. 4. Fall back to copy mode when symlinks fail or when the user explicitly requests copying. 5. Treat universal .agents/skills targets and agent-specific targets with one shared model. 6. Keep path handling and overwrite behavior explicit and auditable.
Canonical directory model
For local management in this skill:
projectcanonical base:<cwd>/.agents/skillsglobalcanonical base:~/.agents/skills
The maintained source might be skills/<skill-name>/.
For this repo's local project workflow:
- in
symlinkmode, the canonical path should point back to the maintained source - in
copymode, the canonical path should be a materialized copy
This keeps skills/ as the single maintained source when symlinks are available, while still allowing a copy fallback when they are not.
This is a deliberate local deviation from a stricter copied-canonical model.
Agent target model
After the canonical directory exists:
- universal agents can use the canonical
.agents/skillspath directly - agent-specific targets should point to the canonical install
- symlink mode should create a canonical entry that resolves back to the maintained source, and agent-specific targets should resolve back to the canonical entry
- copy mode should materialize a standalone copy in the target agent directory
Alignment scope in this skill
This skill intentionally aligns with local installation behavior, not the full CLI surface.
Aligned in the first version:
- canonical install entry under
.agents/skills - project and global scope selection
- universal versus agent-specific target handling
- symlink-first installation with copy fallback
- path safety and conflict checks
- the upstream
sanitizeName()character policy: lowercase, allowa-z,0-9,.,_, replace other runs with-, trim leading and trailing.or-, fallback tounnamed-skill - the four IDE mappings used in this repo: Claude, Cursor, CodeBuddy, and Codex
Not covered in the first version:
- remote repository cloning
- interactive TUI prompts
- telemetry, audit, and leaderboard behavior
- lock file restoration and update workflows
How to reason about differences
If the requested behavior depends on unsupported CLI features, state that clearly and proceed with the local equivalent when possible.
Also state clearly when the local behavior intentionally differs from the stricter copied-canonical CLI mental model. In this repo, the main intentional difference is that project-local maintained skills stay in skills/, and .agents/skills points back to that source in symlink mode.
Examples:
- Remote repository install requested: explain that this skill manages local sources and does not clone repositories.
- Interactive selection requested: explain that this skill expects explicit paths, skill names, and target agents.
- Lockfile restore requested: explain that this skill validates and mounts local skills, but does not rehydrate a lockfile workflow.
Install Workflow
Use this reference when mounting a local skill into one or more agent directories.
Installation model
Treat installation as a two-stage process:
1. Create or refresh the canonical entry in the install directory. 2. Expose that canonical entry to one or more agent directories.
In this repo's symlink mode, the canonical entry should itself be a symlink back to the maintained source so skills/ remains the only maintained copy.
Scope
Use project scope when the skill should live under the current workspace.
- canonical base:
<cwd>/.agents/skills
Use global scope when the skill should live under the user environment.
- canonical base:
~/.agents/skills
Always make scope explicit before writing files.
Before performing a real install, confirm with the user when:
- the operation will write files instead of dry-run only
- multiple skills or multiple agents are involved
- an existing canonical path or target path will be replaced or updated
Mode
Use symlink mode by default when the filesystem supports it and the user does not require physical copies.
In symlink mode:
- create the canonical path as a symlink to the maintained source
- if the target agent is universal and reuses
.agents/skills, that canonical symlink is the final installed entry - if the target agent has its own skills directory, symlink the target entry to the canonical path
Use copy mode when:
- the user asks for independent copies
- symlinks are unsupported or unreliable
- a target environment rejects symlinks
If symlink mode fails, report the fallback and switch to copy mode.
In copy mode:
- materialize the canonical path as a copied directory
- if needed, materialize copied agent targets from the canonical path
Conflict handling
Before mounting a skill, check whether the canonical path or target path already exists.
Report one of these outcomes clearly:
overwrite: replace the existing target with the new installskip: leave the existing target untouchedreplace: remove and recreate the target pathupdate: canonical target already exists and will be refreshed from the source
Do not silently replace an existing skill path. Show the user the conflict summary first, then proceed only after confirmation.
Dry run
Use dry run when the user wants analysis or confirmation before changing files.
Dry run output should include:
- source skill directory
- canonical target path
- agent target path or paths
- selected scope
- selected mode
- detected conflicts
Validation after install
After installation:
- verify the canonical path exists
- verify the target path exists
- if symlink mode was used, verify the canonical path resolves to the maintained source
- if the target path differs from the canonical path, verify the target resolves to the canonical path
- if copy mode was used, verify that required files such as
SKILL.mdare present
Mapping Extension
Use this reference when adding or updating supported agent or IDE targets.
Mapping rule
Keep agent path rules in code, not in prose examples.
Each mapping should define:
- the stable agent key
- the project-scope skill directory
- the global-scope skill directory when supported
- whether the target reuses the canonical
.agents/skillsdirectory
Minimum mapping shape
{
key: {
skillsDir: '.agents/skills',
globalSkillsDir: '~/.example/skills'
}
}Treat a mapping as universal when skillsDir === '.agents/skills'.
Examples:
- Universal:
cursor,codex - Agent-specific:
claude,claude-code,codebuddy
How to add a new mapping
1. Confirm the agent's project-level skill path. 2. Confirm whether it supports a global user-level skill path. 3. Add the mapping entry in scripts/lib/agent-mappings.mjs. 4. Update tests to cover the new path resolution behavior. 5. Update any reference material if the new target introduces a different install expectation.
When to reject a mapping
Do not add a mapping when:
- the agent does not have a stable local skill directory model
- the requested path is only speculative
- the path cannot be expressed safely with the current install model
State the limitation clearly instead of guessing.
Migration Playbook
Use this reference when a local folder is skill-like but not yet a standard skill.
Migration goal
Convert ad-hoc local materials into a reusable skill with this minimum structure:
skill-name/
├── SKILL.md
├── references/
├── scripts/
└── assets/Create only the subdirectories that are actually needed.
Migration steps
1. Classify the source with scripts/inspect-source.mjs. 2. Identify the single reusable capability the skill should represent. 3. Normalize the skill name to a stable kebab-case folder name. 4. Draft SKILL.md with a precise name and description. 5. Move deep detail into references/, deterministic operations into scripts/, and reusable output resources into assets/. 6. Preserve enough source context that another maintainer can understand where the new skill came from. 7. Run scripts/validate-skill.mjs before mounting the result.
Before writing the migrated result, summarize the proposed skill name, target folder, and major file moves, then ask the user to confirm.
How to split content
Put content into SKILL.md when it must change agent behavior directly after trigger.
Put content into references/ when it is:
- long-form explanation
- domain detail
- decision tables
- examples or migration notes that should be read only when needed
Put content into scripts/ when it is:
- repetitive
- deterministic
- path-sensitive
- easy to break if rewritten ad hoc
Put content into assets/ when it is used in output rather than as instructions.
Traceability rule
When migration requires heavy restructuring, keep a short source note in the migrated skill or a migration reference that answers:
- what the original source was
- why the skill boundary was chosen
- which files were promoted into references or scripts
Do not turn this into project-management documentation. Keep it brief and operational.
When to stop and confirm
Pause and confirm with the user when:
- one source folder appears to contain multiple distinct skills
- the skill name is still unclear after inspection
- the source is mostly installation metadata with no reusable capability guidance
- the migration would require inventing large amounts of missing domain logic
Source Classification
Use this reference before migrating or mounting local skills.
Source types
Standard
Classify a source as standard when the target folder already contains a usable SKILL.md and behaves like a normal skill folder.
Typical signals:
SKILL.mdexists at the root or selected subpath- the folder name already looks like a stable skill name
- supporting files are already split into
references/,scripts/, orassets/
Nonstandard
Classify a source as nonstandard when it contains skill-like materials but no valid standard entry point.
Typical signals:
- rules, prompts, scripts, or templates exist without
SKILL.md - files are buried under editor-specific folders or ad-hoc directories
- one capability is present, but it is expressed as loose markdown or config fragments
Mixed
Classify a source as mixed when a repository contains both standard skill folders and nonstandard skill-like materials.
Use mixed classification when:
- one subtree already has
SKILL.md - another subtree still needs migration
File grouping hints
When classifying nonstandard sources, separate materials by purpose:
- Main capability guidance: likely belongs in
SKILL.md - Deep domain detail: likely belongs in
references/ - Repeatable deterministic operations: likely belongs in
scripts/ - Templates or output resources: likely belongs in
assets/ - Editor-specific target paths or install metadata: likely belongs in mapping data or installation references
When to ask the user
Ask for clarification before migration when:
- the source covers multiple unrelated capabilities
- there is no clear skill name
- trigger scope is ambiguous
- files are highly editor-specific and it is unclear whether they should become one skill or multiple skills
- the source is incomplete and cannot support a reusable skill boundary
Minimum classification output
The inspection result should be able to report:
- source type
- candidate skill name
- candidate entry path
- files that look like references
- files that look like scripts
- warnings about ambiguity, collisions, or missing context
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { sanitizeSkillName } from './lib/path-safety.mjs';
const __filename = fileURLToPath(import.meta.url);
function parseArgs(argv) {
const options = {
input: '',
subpath: '',
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--input') {
options.input = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--subpath') {
options.subpath = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--json') {
options.json = true;
}
}
if (!options.input) {
throw new Error('Missing required --input');
}
return options;
}
function walkFiles(rootDir, currentDir = rootDir, result = []) {
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.DS_Store') {
continue;
}
const fullPath = path.join(currentDir, entry.name);
const relativePath = path.relative(rootDir, fullPath) || entry.name;
if (entry.isDirectory()) {
walkFiles(rootDir, fullPath, result);
} else if (entry.isFile()) {
result.push(relativePath.split(path.sep).join('/'));
}
}
return result.sort();
}
function classifyFiles(files) {
const references = [];
const scripts = [];
const assets = [];
const warnings = [];
for (const file of files) {
const lower = file.toLowerCase();
if (lower.endsWith('/skill.md') || lower === 'skill.md') {
continue;
}
if (lower.endsWith('.mjs') || lower.endsWith('.js') || lower.endsWith('.sh') || lower.endsWith('.py')) {
scripts.push(file);
continue;
}
if (lower.endsWith('.md') || lower.endsWith('.mdx') || lower.endsWith('.txt')) {
references.push(file);
continue;
}
assets.push(file);
}
if (references.length === 0 && scripts.length === 0) {
warnings.push('No obvious reference or script files were detected.');
}
return { references, scripts, assets, warnings };
}
export function inspectSource({ input, subpath = '' }) {
const inputPath = path.resolve(input);
const rootPath = subpath ? path.join(inputPath, subpath) : inputPath;
if (!fs.existsSync(rootPath)) {
throw new Error(`Input path not found: ${rootPath}`);
}
const allFiles = walkFiles(rootPath);
const skillFiles = allFiles.filter((file) => path.basename(file).toLowerCase() === 'skill.md');
const hasRootSkill = skillFiles.includes('SKILL.md');
let sourceType = 'nonstandard';
if (hasRootSkill && skillFiles.length === 1) {
sourceType = 'standard';
} else if (skillFiles.length > 0) {
sourceType = hasRootSkill ? 'mixed' : 'mixed';
}
const candidateName = sanitizeSkillName(path.basename(rootPath));
const grouped = classifyFiles(allFiles);
return {
sourceType,
inputPath,
rootPath,
candidateSkillName: candidateName,
skillEntryPath: hasRootSkill ? path.join(rootPath, 'SKILL.md') : null,
detectedFiles: allFiles,
suggestedReferences: grouped.references,
suggestedScripts: grouped.scripts,
suggestedAssets: grouped.assets,
warnings: grouped.warnings,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const result = inspectSource(options);
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log(`Source type: ${result.sourceType}`);
console.log(`Candidate skill: ${result.candidateSkillName}`);
console.log(`Files: ${result.detectedFiles.length}`);
console.log(`References: ${result.suggestedReferences.length}`);
console.log(`Scripts: ${result.suggestedScripts.length}`);
console.log(`Assets: ${result.suggestedAssets.length}`);
if (result.warnings.length > 0) {
console.log('Warnings:');
for (const warning of result.warnings) {
console.log(`- ${warning}`);
}
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { installLocalSkill } from './lib/install-model.mjs';
const __filename = fileURLToPath(import.meta.url);
function parseArgs(argv) {
const options = {
sourceDir: '',
skill: '',
agent: '',
scope: 'project',
mode: 'symlink',
dryRun: false,
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--source-dir') {
options.sourceDir = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--skill') {
options.skill = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--agent') {
options.agent = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--scope') {
options.scope = argv[index + 1] || options.scope;
index += 1;
continue;
}
if (arg === '--mode') {
options.mode = argv[index + 1] || options.mode;
index += 1;
continue;
}
if (arg === '--dry-run') {
options.dryRun = true;
continue;
}
if (arg === '--json') {
options.json = true;
}
}
if (!options.sourceDir || !options.skill || !options.agent) {
throw new Error('Missing required --source-dir, --skill, or --agent');
}
if (!['project', 'global'].includes(options.scope)) {
throw new Error(`Unsupported scope: ${options.scope}`);
}
if (!['symlink', 'copy'].includes(options.mode)) {
throw new Error(`Unsupported mode: ${options.mode}`);
}
return options;
}
export function runInstallSkill(options) {
const skillDir = path.resolve(options.sourceDir, options.skill);
if (!fs.existsSync(path.join(skillDir, 'SKILL.md'))) {
throw new Error(`Missing SKILL.md for skill: ${skillDir}`);
}
return installLocalSkill({
sourceDir: skillDir,
skillName: options.skill,
agentKey: options.agent,
scope: options.scope,
mode: options.mode,
dryRun: options.dryRun,
});
}
function main() {
const options = parseArgs(process.argv.slice(2));
const result = runInstallSkill(options);
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
return;
}
console.log(`Skill: ${result.skillName}`);
console.log(`Agent: ${result.agentKey}`);
console.log(`Scope: ${result.scope}`);
console.log(`Mode: ${result.mode}`);
console.log(`Canonical: ${result.canonicalPath}`);
console.log(`Target: ${result.targetPath}`);
if (result.symlinkFailed) {
console.log('Symlink failed, copied instead.');
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}
import path from 'path';
import { homedir } from 'os';
import { expandHomePath } from './path-safety.mjs';
const HOME = homedir();
const CONFIG_HOME = process.env.XDG_CONFIG_HOME?.trim() || path.join(HOME, '.config');
const CODEX_HOME = process.env.CODEX_HOME?.trim() || path.join(HOME, '.codex');
const CLAUDE_HOME = process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(HOME, '.claude');
const CANONICAL_SKILLS_DIR = '.agents/skills';
export const AGENT_MAPPINGS = {
universal: {
key: 'universal',
displayName: 'Universal',
skillsDir: CANONICAL_SKILLS_DIR,
globalSkillsDir: path.join(CONFIG_HOME, 'agents/skills'),
showInUniversalList: false,
},
claude: {
key: 'claude',
displayName: 'Claude',
skillsDir: '.claude/skills',
globalSkillsDir: path.join(CLAUDE_HOME, 'skills'),
},
'claude-code': {
key: 'claude-code',
displayName: 'Claude Code',
skillsDir: '.claude/skills',
globalSkillsDir: path.join(CLAUDE_HOME, 'skills'),
},
codebuddy: {
key: 'codebuddy',
displayName: 'CodeBuddy',
skillsDir: '.codebuddy/skills',
globalSkillsDir: path.join(HOME, '.codebuddy/skills'),
},
cursor: {
key: 'cursor',
displayName: 'Cursor',
skillsDir: CANONICAL_SKILLS_DIR,
globalSkillsDir: path.join(HOME, '.cursor/skills'),
},
codex: {
key: 'codex',
displayName: 'Codex',
skillsDir: CANONICAL_SKILLS_DIR,
globalSkillsDir: path.join(CODEX_HOME, 'skills'),
},
};
export function getAgentMapping(agentKey) {
const mapping = AGENT_MAPPINGS[agentKey];
if (!mapping) {
throw new Error(`Unsupported agent: ${agentKey}`);
}
return mapping;
}
export function listAgentMappings() {
return Object.values(AGENT_MAPPINGS).map((mapping) => ({ ...mapping }));
}
export function isUniversalAgent(agentKey) {
return getAgentMapping(agentKey).skillsDir === CANONICAL_SKILLS_DIR;
}
export function getUniversalAgents() {
return Object.entries(AGENT_MAPPINGS)
.filter(([, mapping]) => mapping.skillsDir === CANONICAL_SKILLS_DIR && mapping.showInUniversalList !== false)
.map(([agentKey]) => agentKey);
}
export function resolveAgentBaseDir(agentKey, scope, cwd) {
const mapping = getAgentMapping(agentKey);
if (isUniversalAgent(agentKey)) {
return scope === 'global'
? path.join(HOME, '.agents', 'skills')
: path.join(cwd, CANONICAL_SKILLS_DIR);
}
if (scope === 'project') {
return path.join(cwd, mapping.skillsDir);
}
if (!mapping.globalSkillsDir) {
throw new Error(`${mapping.displayName} does not support global scope`);
}
return expandHomePath(mapping.globalSkillsDir, HOME);
}
import fs from 'fs';
import path from 'path';
import { homedir, platform } from 'os';
import { assertPathSafe, sanitizeSkillName } from './path-safety.mjs';
import { getAgentMapping, isUniversalAgent, resolveAgentBaseDir } from './agent-mappings.mjs';
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function removePath(targetPath) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
function shouldSkip(name) {
return name === '.git' || name === 'node_modules' || name === '.DS_Store';
}
export function copyDirectory(sourceDir, targetDir) {
ensureDir(targetDir);
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
if (shouldSkip(entry.name)) {
continue;
}
const sourcePath = path.join(sourceDir, entry.name);
const targetPath = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
copyDirectory(sourcePath, targetPath);
continue;
}
if (entry.isFile()) {
ensureDir(path.dirname(targetPath));
fs.copyFileSync(sourcePath, targetPath);
}
}
}
function symlinkDirectory(sourceDir, targetPath) {
ensureDir(path.dirname(targetPath));
const relativeTarget = path.relative(path.dirname(targetPath), sourceDir);
fs.symlinkSync(relativeTarget, targetPath, platform() === 'win32' ? 'junction' : undefined);
}
export function getCanonicalBaseDir(scope, cwd) {
return scope === 'global' ? path.join(homedir(), '.agents', 'skills') : path.join(cwd, '.agents', 'skills');
}
export function getCanonicalSkillPath(skillName, scope, cwd) {
const sanitized = sanitizeSkillName(skillName);
const baseDir = getCanonicalBaseDir(scope, cwd);
const canonicalPath = path.join(baseDir, sanitized);
assertPathSafe(baseDir, canonicalPath, 'canonical path');
return canonicalPath;
}
export function getAgentSkillPath(agentKey, skillName, scope, cwd) {
const sanitized = sanitizeSkillName(skillName);
const agentBase = resolveAgentBaseDir(agentKey, scope, cwd);
const targetPath = path.join(agentBase, sanitized);
assertPathSafe(agentBase, targetPath, 'agent skill path');
return targetPath;
}
export function detectConflict(targetPath) {
if (!fs.existsSync(targetPath)) {
return 'none';
}
const stats = fs.lstatSync(targetPath);
if (stats.isSymbolicLink()) {
return 'replace';
}
if (stats.isDirectory()) {
return 'overwrite';
}
return 'replace';
}
export function installLocalSkill(options) {
const {
sourceDir,
skillName,
agentKey,
scope = 'project',
mode = 'symlink',
dryRun = false,
cwd = process.cwd(),
} = options;
const mapping = getAgentMapping(agentKey);
const canonicalPath = getCanonicalSkillPath(skillName, scope, cwd);
const targetPath = isUniversalAgent(agentKey)
? canonicalPath
: getAgentSkillPath(agentKey, skillName, scope, cwd);
const canonicalConflict = detectConflict(canonicalPath);
const targetConflict = targetPath === canonicalPath ? canonicalConflict : detectConflict(targetPath);
const canonicalPointsToSource = mode === 'symlink';
let canonicalSymlinkFailed = false;
let canonicalSymlinkError = null;
const plan = {
sourceDir,
skillName: sanitizeSkillName(skillName),
agentKey,
scope,
requestedMode: mode,
canonicalPath,
targetPath,
canonicalConflict,
targetConflict,
targetIsUniversal: isUniversalAgent(agentKey),
canonicalPointsToSource,
};
if (dryRun) {
return {
success: true,
dryRun: true,
mode,
...plan,
};
}
removePath(canonicalPath);
ensureDir(path.dirname(canonicalPath));
if (mode === 'symlink') {
try {
symlinkDirectory(sourceDir, canonicalPath);
} catch (error) {
canonicalSymlinkFailed = true;
canonicalSymlinkError = error instanceof Error ? error.message : String(error);
copyDirectory(sourceDir, canonicalPath);
if (targetPath === canonicalPath) {
return {
success: true,
dryRun: false,
mode: 'copy',
symlinkFailed: true,
error: canonicalSymlinkError,
...plan,
};
}
}
} else {
copyDirectory(sourceDir, canonicalPath);
}
if (targetPath === canonicalPath) {
if (targetPath !== canonicalPath) {
removePath(targetPath);
copyDirectory(canonicalPath, targetPath);
}
return {
success: true,
dryRun: false,
mode,
...plan,
};
}
if (mode === 'copy') {
removePath(targetPath);
copyDirectory(canonicalPath, targetPath);
return {
success: true,
dryRun: false,
mode: 'copy',
...plan,
};
}
removePath(targetPath);
ensureDir(path.dirname(targetPath));
try {
const relativeTarget = path.relative(path.dirname(targetPath), canonicalPath);
fs.symlinkSync(relativeTarget, targetPath, platform() === 'win32' ? 'junction' : undefined);
return {
success: true,
dryRun: false,
mode: canonicalSymlinkFailed ? 'copy' : 'symlink',
...(canonicalSymlinkFailed
? {
symlinkFailed: true,
error: canonicalSymlinkError,
}
: {}),
...plan,
};
} catch (error) {
copyDirectory(canonicalPath, targetPath);
return {
success: true,
dryRun: false,
mode: 'copy',
symlinkFailed: true,
error: error instanceof Error ? error.message : String(error),
...plan,
};
}
}
import path from 'path';
export function sanitizeSkillName(name) {
const sanitized = String(name || '')
.toLowerCase()
.replace(/[^a-z0-9._]+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '')
.substring(0, 255);
return sanitized || 'unnamed-skill';
}
export function isPathSafe(basePath, targetPath) {
const normalizedBase = path.normalize(path.resolve(basePath));
const normalizedTarget = path.normalize(path.resolve(targetPath));
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(`${normalizedBase}${path.sep}`);
}
export function assertPathSafe(basePath, targetPath, label = 'path') {
if (!isPathSafe(basePath, targetPath)) {
throw new Error(`Unsafe ${label}: ${targetPath}`);
}
}
export function expandHomePath(input, homeDir) {
if (!input) {
return input;
}
if (input === '~') {
return homeDir;
}
if (input.startsWith('~/')) {
return path.join(homeDir, input.slice(2));
}
return input;
}
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
function parseArgs(argv) {
const options = {
skillDir: '',
installedPath: '',
json: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--skill-dir') {
options.skillDir = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--installed-path') {
options.installedPath = argv[index + 1] || '';
index += 1;
continue;
}
if (arg === '--json') {
options.json = true;
}
}
if (!options.skillDir) {
throw new Error('Missing required --skill-dir');
}
return options;
}
function parseFrontmatter(content) {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) {
return {};
}
const frontmatter = match[1];
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
const descriptionMatch = frontmatter.match(/^description:\s*(.+)$/m);
return {
name: nameMatch ? nameMatch[1].trim() : '',
description: descriptionMatch ? descriptionMatch[1].trim() : '',
};
}
function collectMissingReferences(skillDir, content) {
const missing = [];
const matches = content.matchAll(/\[[^\]]+\]\(([^)]+)\)/g);
for (const match of matches) {
const target = match[1];
if (/^https?:/.test(target) || target.startsWith('#')) {
continue;
}
const resolved = path.resolve(skillDir, target);
if (!fs.existsSync(resolved)) {
missing.push(target);
}
}
return missing;
}
export function validateSkill({ skillDir, installedPath = '' }) {
const resolvedSkillDir = path.resolve(skillDir);
const skillFile = path.join(resolvedSkillDir, 'SKILL.md');
const errors = [];
if (!fs.existsSync(skillFile)) {
errors.push('Missing SKILL.md');
return { valid: false, errors, warnings: [] };
}
const content = fs.readFileSync(skillFile, 'utf8');
const frontmatter = parseFrontmatter(content);
if (!frontmatter.name) {
errors.push('Missing frontmatter name');
}
if (!frontmatter.description) {
errors.push('Missing frontmatter description');
}
const missingReferences = collectMissingReferences(resolvedSkillDir, content);
for (const reference of missingReferences) {
errors.push(`Missing referenced file: ${reference}`);
}
if (installedPath) {
const resolvedInstalledPath = path.resolve(installedPath);
if (!fs.existsSync(resolvedInstalledPath)) {
errors.push(`Installed path not found: ${resolvedInstalledPath}`);
} else if (!fs.existsSync(path.join(resolvedInstalledPath, 'SKILL.md'))) {
errors.push(`Installed path missing SKILL.md: ${resolvedInstalledPath}`);
}
}
return {
valid: errors.length === 0,
errors,
warnings: [],
frontmatter,
};
}
function main() {
const options = parseArgs(process.argv.slice(2));
const result = validateSkill(options);
if (options.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
if (!result.valid) {
process.exit(1);
}
return;
}
if (!result.valid) {
console.error('Skill validation failed');
for (const error of result.errors) {
console.error(`- ${error}`);
}
process.exit(1);
}
console.log('Skill validation passed');
}
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
}