diff --git a/AGENTS.md b/AGENTS.md index 02a93044..b13878d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,25 +66,16 @@ tests/ ### How `skills check` and `skills update` Work -1. Read `~/.agents/.skill-lock.json` for installed skills -2. For each skill, get `skillFolderHash` from lock file -3. POST to `https://add-skill.vercel.sh/check-updates` with: - ```json - { - "skills": [{ "name": "...", "source": "...", "skillFolderHash": "..." }], - "forceRefresh": true - } - ``` -4. API fetches fresh content from GitHub, computes hash, compares -5. Returns list of skills with different hashes (updates available) - -### Why `forceRefresh: true`? - -Both `check` and `update` always send `forceRefresh: true`. This ensures the API fetches fresh content from GitHub rather than using its Redis cache. - -**Without forceRefresh:** Users saw phantom "updates available" due to stale cached hashes. The fix was to always fetch fresh. - -**Tradeoff:** Slightly slower (GitHub API call per skill), but always accurate. +1. Read lock files from project and/or global scope: + - Project: `/.agents/.skill-lock.json` + - Global: `~/.agents/.skill-lock.json` +2. For each GitHub skill, compare stored `skillFolderHash` to current GitHub tree SHA via `fetchSkillFolderHash`. +3. `skills check` reports available updates. +4. `skills update` reinstalls changed skills via `npx skills add ... --rename ` to preserve local rename aliases. +5. Scope flags: + - `--project` / `-p`: project only + - `--global` / `-g`: global only + - default: both scopes ### Lock File Compatibility diff --git a/README.md b/README.md index a8b62f8e..3b7a3111 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ npx skills add ./my-local-skills | `-g, --global` | Install to user directory instead of project | | `-a, --agent ` | Target specific agents (e.g., `claude-code`, `codex`). See [Available Agents](#available-agents) | | `-s, --skill ` | Install specific skills by name (use `'*'` for all skills) | +| `--rename ` | Install the selected skill under a different local name (updates folder + `SKILL.md` frontmatter `name`) | | `-l, --list` | List available skills without installing | | `--copy` | Copy files instead of symlinking to agent directories | | `-y, --yes` | Skip all confirmation prompts | @@ -72,6 +73,9 @@ npx skills add vercel-labs/agent-skills --skill '*' -a claude-code # Install specific skills to all agents npx skills add vercel-labs/agent-skills --agent '*' --skill frontend-design + +# Install a skill under a different local name +npx skills add vercel-labs/agent-skills --skill review --rename team-review ``` ### Installation Scope @@ -131,13 +135,21 @@ npx skills find typescript ### `skills check` / `skills update` ```bash -# Check if any installed skills have updates +# Check for updates in both project + global lock files (default) npx skills check -# Update all skills to latest versions +# Check only project-scoped installs +npx skills check --project + +# Update all tracked skills (project + global) npx skills update + +# Update only global installs +npx skills update --global ``` +`skills update` preserves local install names (including `--rename`) during upgrades. + ### `skills init` ```bash diff --git a/src/add.test.ts b/src/add.test.ts index 71b5525b..de6c2b82 100644 --- a/src/add.test.ts +++ b/src/add.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'; +import { existsSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { runCli } from './test-utils.ts'; @@ -158,6 +158,68 @@ description: Test expect(result.stdout).toContain('No project skills found in skills-lock.json'); }); + it('should install a skill with --rename and rewrite SKILL.md name', () => { + const sourceSkillDir = join(testDir, 'source', 'my-skill'); + mkdirSync(sourceSkillDir, { recursive: true }); + writeFileSync( + join(sourceSkillDir, 'SKILL.md'), + `--- +name: my-skill +description: Original name skill +--- +# My Skill +` + ); + + const projectDir = join(testDir, 'project'); + mkdirSync(projectDir, { recursive: true }); + + const result = runCli( + ['add', join(testDir, 'source'), '-y', '--agent', 'amp', '--rename', 'renamed-skill'], + projectDir + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('renamed-skill'); + + const installedPath = join(projectDir, '.agents', 'skills', 'renamed-skill', 'SKILL.md'); + expect(existsSync(installedPath)).toBe(true); + const installed = readFileSync(installedPath, 'utf-8'); + expect(installed).toContain('name: renamed-skill'); + }); + + it('should reject --rename when multiple skills are selected', () => { + const skillOneDir = join(testDir, 'multi', 'skill-one'); + const skillTwoDir = join(testDir, 'multi', 'skill-two'); + mkdirSync(skillOneDir, { recursive: true }); + mkdirSync(skillTwoDir, { recursive: true }); + + writeFileSync( + join(skillOneDir, 'SKILL.md'), + `--- +name: skill-one +description: First skill +--- +# Skill One +` + ); + writeFileSync( + join(skillTwoDir, 'SKILL.md'), + `--- +name: skill-two +description: Second skill +--- +# Skill Two +` + ); + + const result = runCli( + ['add', join(testDir, 'multi'), '-y', '--agent', 'amp', '--rename', 'renamed'], + testDir + ); + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain('--rename requires exactly one selected skill'); + }); + describe('internal skills', () => { it('should skip internal skills by default', () => { // Create an internal skill @@ -389,6 +451,18 @@ describe('parseAddOptions', () => { expect(result.options.list).toBe(true); expect(result.options.global).toBe(true); }); + + it('should parse --rename with a value', () => { + const result = parseAddOptions(['source', '--rename', 'renamed-skill']); + expect(result.source).toEqual(['source']); + expect(result.options.rename).toBe('renamed-skill'); + }); + + it('should mark --rename as invalid when value is missing', () => { + const result = parseAddOptions(['source', '--rename']); + expect(result.source).toEqual(['source']); + expect(result.options.rename).toBe(''); + }); }); describe('find-skills prompt with -y flag', () => { diff --git a/src/add.ts b/src/add.ts index db898842..3017c1ef 100644 --- a/src/add.ts +++ b/src/add.ts @@ -419,6 +419,22 @@ export interface AddOptions { all?: boolean; fullDepth?: boolean; copy?: boolean; + rename?: string; +} + +function getNormalizedRename(rename: string | undefined): string | undefined { + if (rename === undefined) return undefined; + const trimmed = rename.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function validateSingleRename(rename: string | undefined, selectedCount: number): void { + if (!rename) return; + if (selectedCount !== 1) { + p.log.error('--rename requires exactly one selected skill'); + p.log.info('Use --skill to select a single skill, then pass --rename '); + process.exit(1); + } } /** @@ -462,6 +478,8 @@ async function handleRemoteSkill( sourceIdentifier: provider.getSourceIdentifier(url), metadata: providerSkill.metadata, }; + const renameTarget = getNormalizedRename(options.rename); + const installName = renameTarget || remoteSkill.installName; spinner.stop(`Found skill: ${pc.cyan(remoteSkill.installName)}`); @@ -473,7 +491,7 @@ async function handleRemoteSkill( console.log(); p.log.step(pc.bold('Skill Details')); p.log.message(` ${pc.cyan('Name:')} ${remoteSkill.name}`); - p.log.message(` ${pc.cyan('Install as:')} ${remoteSkill.installName}`); + p.log.message(` ${pc.cyan('Install as:')} ${installName}`); p.log.message(` ${pc.cyan('Provider:')} ${provider.displayName}`); p.log.message(` ${pc.cyan('Description:')} ${remoteSkill.description}`); console.log(); @@ -605,7 +623,7 @@ async function handleRemoteSkill( const overwriteChecks = await Promise.all( targetAgents.map(async (agent) => ({ agent, - installed: await isSkillInstalled(remoteSkill.installName, agent, { + installed: await isSkillInstalled(installName, agent, { global: installGlobally, }), })) @@ -617,7 +635,7 @@ async function handleRemoteSkill( // Build installation summary const summaryLines: string[] = []; - const canonicalPath = getCanonicalPath(remoteSkill.installName, { global: installGlobally }); + const canonicalPath = getCanonicalPath(installName, { global: installGlobally }); const shortCanonical = shortenPath(canonicalPath, cwd); summaryLines.push(`${pc.cyan(shortCanonical)}`); summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); @@ -661,9 +679,10 @@ async function handleRemoteSkill( const result = await installRemoteSkillForAgent(remoteSkill, agent, { global: installGlobally, mode: installMode, + renameTo: renameTarget, }); results.push({ - skill: remoteSkill.installName, + skill: installName, agent: agents[agent].displayName, ...result, }); @@ -683,16 +702,16 @@ async function handleRemoteSkill( track({ event: 'install', source: remoteSkill.sourceIdentifier, - skills: remoteSkill.installName, + skills: installName, agents: targetAgents.join(','), ...(installGlobally && { global: '1' }), - skillFiles: JSON.stringify({ [remoteSkill.installName]: url }), + skillFiles: JSON.stringify({ [installName]: url }), sourceType: remoteSkill.providerId, }); } - // Add to skill lock file for update tracking (only for global installs) - if (successful.length > 0 && installGlobally) { + // Add to skill lock file for update tracking + if (successful.length > 0) { try { // Try to fetch the folder hash from GitHub Trees API let skillFolderHash = ''; @@ -701,12 +720,16 @@ async function handleRemoteSkill( if (hash) skillFolderHash = hash; } - await addSkillToLock(remoteSkill.installName, { - source: remoteSkill.sourceIdentifier, - sourceType: remoteSkill.providerId, - sourceUrl: url, - skillFolderHash, - }); + await addSkillToLock( + installName, + { + source: remoteSkill.sourceIdentifier, + sourceType: remoteSkill.providerId, + sourceUrl: url, + skillFolderHash, + }, + { global: installGlobally, cwd } + ); } catch { // Don't fail installation if lock file update fails } @@ -737,7 +760,7 @@ async function handleRemoteSkill( const firstResult = successful[0]!; if (firstResult.mode === 'copy') { - resultLines.push(`${pc.green('✓')} ${remoteSkill.installName} ${pc.dim('(copied)')}`); + resultLines.push(`${pc.green('✓')} ${installName} ${pc.dim('(copied)')}`); for (const r of successful) { const shortPath = shortenPath(r.path, cwd); resultLines.push(` ${pc.dim('→')} ${shortPath}`); @@ -748,7 +771,7 @@ async function handleRemoteSkill( const shortPath = shortenPath(firstResult.canonicalPath, cwd); resultLines.push(`${pc.green('✓')} ${shortPath}`); } else { - resultLines.push(`${pc.green('✓')} ${remoteSkill.installName}`); + resultLines.push(`${pc.green('✓')} ${installName}`); } resultLines.push(...buildResultLines(successful, targetAgents)); } @@ -893,6 +916,12 @@ async function handleWellKnownSkills( selectedSkills = selected as WellKnownSkill[]; } + const renameTarget = getNormalizedRename(options.rename); + validateSingleRename(renameTarget, selectedSkills.length); + const getInstallName = (skill: WellKnownSkill): string => + renameTarget && selectedSkills.length === 1 ? renameTarget : skill.installName; + const getRenameForSkill = (skill: WellKnownSkill): string | undefined => + renameTarget && selectedSkills.length === 1 ? renameTarget : undefined; // Detect agents let targetAgents: AgentType[]; @@ -1030,9 +1059,11 @@ async function handleWellKnownSkills( const overwriteChecks = await Promise.all( selectedSkills.flatMap((skill) => targetAgents.map(async (agent) => ({ - skillName: skill.installName, + skillName: getInstallName(skill), agent, - installed: await isSkillInstalled(skill.installName, agent, { global: installGlobally }), + installed: await isSkillInstalled(getInstallName(skill), agent, { + global: installGlobally, + }), })) ) ); @@ -1047,7 +1078,8 @@ async function handleWellKnownSkills( for (const skill of selectedSkills) { if (summaryLines.length > 0) summaryLines.push(''); - const canonicalPath = getCanonicalPath(skill.installName, { global: installGlobally }); + const installName = getInstallName(skill); + const canonicalPath = getCanonicalPath(installName, { global: installGlobally }); const shortCanonical = shortenPath(canonicalPath, cwd); summaryLines.push(`${pc.cyan(shortCanonical)}`); summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); @@ -1055,7 +1087,7 @@ async function handleWellKnownSkills( summaryLines.push(` ${pc.dim('files:')} ${skill.files.size}`); } - const skillOverwrites = overwriteStatus.get(skill.installName); + const skillOverwrites = overwriteStatus.get(installName); const overwriteAgents = targetAgents .filter((a) => skillOverwrites?.get(a)) .map((a) => agents[a].displayName); @@ -1091,13 +1123,16 @@ async function handleWellKnownSkills( }[] = []; for (const skill of selectedSkills) { + const installName = getInstallName(skill); + const renameTo = getRenameForSkill(skill); for (const agent of targetAgents) { const result = await installWellKnownSkillForAgent(skill, agent, { global: installGlobally, mode: installMode, + renameTo, }); results.push({ - skill: skill.installName, + skill: installName, agent: agents[agent].displayName, ...result, }); @@ -1116,7 +1151,7 @@ async function handleWellKnownSkills( // Build skillFiles map: { skillName: sourceUrl } const skillFiles: Record = {}; for (const skill of selectedSkills) { - skillFiles[skill.installName] = skill.sourceUrl; + skillFiles[getInstallName(skill)] = skill.sourceUrl; } // Skip telemetry for private GitHub repos @@ -1126,7 +1161,7 @@ async function handleWellKnownSkills( track({ event: 'install', source: sourceIdentifier, - skills: selectedSkills.map((s) => s.installName).join(','), + skills: selectedSkills.map((s) => getInstallName(s)).join(','), agents: targetAgents.join(','), ...(installGlobally && { global: '1' }), skillFiles: JSON.stringify(skillFiles), @@ -1134,18 +1169,23 @@ async function handleWellKnownSkills( }); } - // Add to skill lock file for update tracking (only for global installs) - if (successful.length > 0 && installGlobally) { + // Add to skill lock file for update tracking + if (successful.length > 0) { const successfulSkillNames = new Set(successful.map((r) => r.skill)); for (const skill of selectedSkills) { - if (successfulSkillNames.has(skill.installName)) { + const installName = getInstallName(skill); + if (successfulSkillNames.has(installName)) { try { - await addSkillToLock(skill.installName, { - source: sourceIdentifier, - sourceType: 'well-known', - sourceUrl: skill.sourceUrl, - skillFolderHash: '', // Well-known skills don't have a folder hash - }); + await addSkillToLock( + installName, + { + source: sourceIdentifier, + sourceType: 'well-known', + sourceUrl: skill.sourceUrl, + skillFolderHash: '', // Well-known skills don't have a folder hash + }, + { global: installGlobally, cwd } + ); } catch { // Don't fail installation if lock file update fails } @@ -1279,6 +1319,8 @@ async function handleDirectUrlSkillLegacy( providerId: 'mintlify', sourceIdentifier: 'mintlify/com', }; + const renameTarget = getNormalizedRename(options.rename); + const installName = renameTarget || remoteSkill.installName; spinner.stop(`Found skill: ${pc.cyan(remoteSkill.installName)}`); @@ -1289,7 +1331,7 @@ async function handleDirectUrlSkillLegacy( console.log(); p.log.step(pc.bold('Skill Details')); p.log.message(` ${pc.cyan('Name:')} ${remoteSkill.name}`); - p.log.message(` ${pc.cyan('Site:')} ${remoteSkill.installName}`); + p.log.message(` ${pc.cyan('Site:')} ${installName}`); p.log.message(` ${pc.cyan('Description:')} ${remoteSkill.description}`); console.log(); p.outro('Run without --list to install'); @@ -1406,7 +1448,7 @@ async function handleDirectUrlSkillLegacy( const overwriteChecks = await Promise.all( targetAgents.map(async (agent) => ({ agent, - installed: await isSkillInstalled(remoteSkill.installName, agent, { + installed: await isSkillInstalled(installName, agent, { global: installGlobally, }), })) @@ -1418,7 +1460,7 @@ async function handleDirectUrlSkillLegacy( // Build installation summary const summaryLines: string[] = []; const agentNames = targetAgents.map((a) => agents[a].displayName); - const canonicalPath = getCanonicalPath(remoteSkill.installName, { global: installGlobally }); + const canonicalPath = getCanonicalPath(installName, { global: installGlobally }); const shortCanonical = shortenPath(canonicalPath, cwd); summaryLines.push(`${pc.cyan(shortCanonical)}`); summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); @@ -1462,9 +1504,10 @@ async function handleDirectUrlSkillLegacy( const result = await installRemoteSkillForAgent(remoteSkill, agent, { global: installGlobally, mode: installMode, + renameTo: renameTarget, }); results.push({ - skill: remoteSkill.installName, + skill: installName, agent: agents[agent].displayName, ...result, }); @@ -1481,24 +1524,28 @@ async function handleDirectUrlSkillLegacy( track({ event: 'install', source: 'mintlify/com', - skills: remoteSkill.installName, + skills: installName, agents: targetAgents.join(','), ...(installGlobally && { global: '1' }), - skillFiles: JSON.stringify({ [remoteSkill.installName]: url }), + skillFiles: JSON.stringify({ [installName]: url }), sourceType: 'mintlify', }); - // Add to skill lock file for update tracking (only for global installs) - if (successful.length > 0 && installGlobally) { + // Add to skill lock file for update tracking + if (successful.length > 0) { try { // skillFolderHash will be populated by telemetry server // Mintlify skills are single-file, so folder hash = content hash on server - await addSkillToLock(remoteSkill.installName, { - source: `mintlify/${remoteSkill.installName}`, - sourceType: 'mintlify', - sourceUrl: url, - skillFolderHash: '', // Populated by server - }); + await addSkillToLock( + installName, + { + source: `mintlify/${remoteSkill.installName}`, + sourceType: 'mintlify', + sourceUrl: url, + skillFolderHash: '', // Populated by server + }, + { global: installGlobally, cwd } + ); } catch { // Don't fail installation if lock file update fails } @@ -1512,7 +1559,7 @@ async function handleDirectUrlSkillLegacy( const shortPath = shortenPath(firstResult.canonicalPath, cwd); resultLines.push(`${pc.green('✓')} ${shortPath}`); } else { - resultLines.push(`${pc.green('✓')} ${remoteSkill.installName}`); + resultLines.push(`${pc.green('✓')} ${installName}`); } resultLines.push(...buildResultLines(successful, targetAgents)); @@ -1552,6 +1599,14 @@ async function handleDirectUrlSkillLegacy( export async function runAdd(args: string[], options: AddOptions = {}): Promise { const source = args[0]; let installTipShown = false; + const normalizedRename = getNormalizedRename(options.rename); + + if (options.rename !== undefined && !normalizedRename) { + p.log.error('Missing value for --rename'); + p.log.info('Usage: npx skills add --rename '); + process.exit(1); + } + options.rename = normalizedRename; const showInstallTip = (): void => { if (installTipShown) return; @@ -1727,6 +1782,12 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< selectedSkills = selected as Skill[]; } + const renameTarget = getNormalizedRename(options.rename); + validateSingleRename(renameTarget, selectedSkills.length); + const getInstallName = (skill: Skill): string => + renameTarget && selectedSkills.length === 1 ? renameTarget : skill.name; + const getRenameForSkill = (skill: Skill): string | undefined => + renameTarget && selectedSkills.length === 1 ? renameTarget : undefined; // Kick off security audit fetch early (non-blocking) so it runs // in parallel with agent selection, scope, and mode prompts. @@ -1878,9 +1939,11 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< const overwriteChecks = await Promise.all( selectedSkills.flatMap((skill) => targetAgents.map(async (agent) => ({ - skillName: skill.name, + skillName: getInstallName(skill), agent, - installed: await isSkillInstalled(skill.name, agent, { global: installGlobally }), + installed: await isSkillInstalled(getInstallName(skill), agent, { + global: installGlobally, + }), })) ) ); @@ -1895,12 +1958,13 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< for (const skill of selectedSkills) { if (summaryLines.length > 0) summaryLines.push(''); - const canonicalPath = getCanonicalPath(skill.name, { global: installGlobally }); + const installName = getInstallName(skill); + const canonicalPath = getCanonicalPath(installName, { global: installGlobally }); const shortCanonical = shortenPath(canonicalPath, cwd); summaryLines.push(`${pc.cyan(shortCanonical)}`); summaryLines.push(...buildAgentSummaryLines(targetAgents, installMode)); - const skillOverwrites = overwriteStatus.get(skill.name); + const skillOverwrites = overwriteStatus.get(installName); const overwriteAgents = targetAgents .filter((a) => skillOverwrites?.get(a)) .map((a) => agents[a].displayName); @@ -1958,13 +2022,16 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< }[] = []; for (const skill of selectedSkills) { + const installName = getInstallName(skill); + const renameTo = getRenameForSkill(skill); for (const agent of targetAgents) { const result = await installSkillForAgent(skill, agent, { global: installGlobally, mode: installMode, + renameTo, }); results.push({ - skill: getSkillDisplayName(skill), + skill: installName, agent: agents[agent].displayName, ...result, }); @@ -1998,7 +2065,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< // Local path - skip telemetry for local installs continue; } - skillFiles[skill.name] = relativePath; + skillFiles[getInstallName(skill)] = relativePath; } // Normalize source to owner/repo format for telemetry @@ -2016,7 +2083,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< track({ event: 'install', source: normalizedSource, - skills: selectedSkills.map((s) => s.name).join(','), + skills: selectedSkills.map((s) => getInstallName(s)).join(','), agents: targetAgents.join(','), ...(installGlobally && { global: '1' }), skillFiles: JSON.stringify(skillFiles), @@ -2027,7 +2094,7 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< track({ event: 'install', source: normalizedSource, - skills: selectedSkills.map((s) => s.name).join(','), + skills: selectedSkills.map((s) => getInstallName(s)).join(','), agents: targetAgents.join(','), ...(installGlobally && { global: '1' }), skillFiles: JSON.stringify(skillFiles), @@ -2035,28 +2102,32 @@ export async function runAdd(args: string[], options: AddOptions = {}): Promise< } } - // Add to skill lock file for update tracking (only for global installs) - if (successful.length > 0 && installGlobally && normalizedSource) { + // Add to skill lock file for update tracking + if (successful.length > 0 && normalizedSource) { const successfulSkillNames = new Set(successful.map((r) => r.skill)); for (const skill of selectedSkills) { - const skillDisplayName = getSkillDisplayName(skill); - if (successfulSkillNames.has(skillDisplayName)) { + const installName = getInstallName(skill); + if (successfulSkillNames.has(installName)) { try { // Fetch the folder hash from GitHub Trees API let skillFolderHash = ''; - const skillPathValue = skillFiles[skill.name]; + const skillPathValue = skillFiles[installName]; if (parsed.type === 'github' && skillPathValue) { const hash = await fetchSkillFolderHash(normalizedSource, skillPathValue); if (hash) skillFolderHash = hash; } - await addSkillToLock(skill.name, { - source: normalizedSource, - sourceType: parsed.type, - sourceUrl: parsed.url, - skillPath: skillPathValue, - skillFolderHash, - }); + await addSkillToLock( + installName, + { + source: normalizedSource, + sourceType: parsed.type, + sourceUrl: parsed.url, + skillPath: skillPathValue, + skillFolderHash, + }, + { global: installGlobally, cwd } + ); } catch { // Don't fail installation if lock file update fails } @@ -2303,6 +2374,15 @@ export function parseAddOptions(args: string[]): { source: string[]; options: Ad options.fullDepth = true; } else if (arg === '--copy') { options.copy = true; + } else if (arg === '--rename') { + const nextArg = args[i + 1]; + if (nextArg && !nextArg.startsWith('-')) { + options.rename = nextArg; + i++; + } else { + // Mark as present but invalid; runAdd will print a clear error + options.rename = ''; + } } else if (arg && !arg.startsWith('-')) { source.push(arg); } diff --git a/src/cli.test.ts b/src/cli.test.ts index 2cbc6620..1b9e268a 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -17,9 +17,12 @@ describe('skills CLI', () => { expect(output).toContain('-g, --global'); expect(output).toContain('-a, --agent'); expect(output).toContain('-s, --skill'); + expect(output).toContain('--rename '); expect(output).toContain('-l, --list'); expect(output).toContain('-y, --yes'); expect(output).toContain('--all'); + expect(output).toContain('--project'); + expect(output).toContain('--global'); }); it('should show same output for -h alias', () => { diff --git a/src/cli.ts b/src/cli.ts index e4f794d6..979a4cd9 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,10 +1,8 @@ #!/usr/bin/env node -import { spawn, spawnSync } from 'child_process'; -import { writeFileSync, readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs'; +import { spawnSync } from 'child_process'; +import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'; import { basename, join, dirname } from 'path'; -import { homedir } from 'os'; -import { createHash } from 'crypto'; import { fileURLToPath } from 'url'; import { runAdd, parseAddOptions, initTelemetry } from './add.ts'; import { runFind } from './find.ts'; @@ -13,7 +11,12 @@ import { runList } from './list.ts'; import { removeCommand, parseRemoveOptions } from './remove.ts'; import { runSync, parseSyncOptions } from './sync.ts'; import { track } from './telemetry.ts'; -import { fetchSkillFolderHash, getGitHubToken } from './skill-lock.ts'; +import { + fetchSkillFolderHash, + getGitHubToken, + readSkillLock, + type SkillLockEntry, +} from './skill-lock.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -128,12 +131,17 @@ ${BOLD}Add Options:${RESET} -g, --global Install skill globally (user-level) instead of project-level -a, --agent Specify agents to install to (use '*' for all agents) -s, --skill Specify skill names to install (use '*' for all skills) + --rename Install selected skill under a new local name -l, --list List available skills in the repository without installing -y, --yes Skip confirmation prompts --copy Copy files instead of symlinking to agent directories --all Shorthand for --skill '*' --agent '*' -y --full-depth Search all subdirectories even when a root SKILL.md exists +${BOLD}Check/Update Options:${RESET} + -g, --global Check/update only global lock file + -p, --project Check/update only project lock file + ${BOLD}Remove Options:${RESET} -g, --global Remove from global scope -a, --agent Remove from specific agents (use '*' for all agents) @@ -156,6 +164,7 @@ ${BOLD}Options:${RESET} ${BOLD}Examples:${RESET} ${DIM}$${RESET} skills add vercel-labs/agent-skills ${DIM}$${RESET} skills add vercel-labs/agent-skills -g + ${DIM}$${RESET} skills add vercel-labs/agent-skills --skill review --rename my-review-skill ${DIM}$${RESET} skills add vercel-labs/agent-skills --agent claude-code cursor ${DIM}$${RESET} skills add vercel-labs/agent-skills --skill pr-review commit ${DIM}$${RESET} skills remove ${DIM}# interactive remove${RESET} @@ -167,7 +176,8 @@ ${BOLD}Examples:${RESET} ${DIM}$${RESET} skills find ${DIM}# interactive search${RESET} ${DIM}$${RESET} skills find typescript ${DIM}# search by keyword${RESET} ${DIM}$${RESET} skills check - ${DIM}$${RESET} skills update + ${DIM}$${RESET} skills check --project ${DIM}# project only${RESET} + ${DIM}$${RESET} skills update --global ${DIM}# global only${RESET} ${DIM}$${RESET} skills experimental_install ${DIM}# restore from skills-lock.json${RESET} ${DIM}$${RESET} skills init my-skill ${DIM}$${RESET} skills experimental_sync ${DIM}# sync from node_modules${RESET} @@ -275,90 +285,96 @@ Describe when this skill should be used. // Check and Update Commands // ============================================ -const AGENTS_DIR = '.agents'; -const LOCK_FILE = '.skill-lock.json'; -const CHECK_UPDATES_API_URL = 'https://add-skill.vercel.sh/check-updates'; -const CURRENT_LOCK_VERSION = 3; // Bumped from 2 to 3 for folder hash support - -interface SkillLockEntry { - source: string; - sourceType: string; - sourceUrl: string; - skillPath?: string; - /** GitHub tree SHA for the entire skill folder (v3) */ - skillFolderHash: string; - installedAt: string; - updatedAt: string; +interface ScopeOptions { + project: boolean; + global: boolean; } -interface SkillLockFile { - version: number; - skills: Record; +interface ScopedSkillLockEntry { + name: string; + entry: SkillLockEntry; + scope: 'project' | 'global'; } -interface CheckUpdatesRequest { - skills: Array<{ - name: string; - source: string; - path?: string; - skillFolderHash: string; - }>; -} +export function parseScopeOptions(args: string[]): ScopeOptions { + const wantsGlobal = args.includes('-g') || args.includes('--global'); + const wantsProject = args.includes('-p') || args.includes('--project'); -interface CheckUpdatesResponse { - updates: Array<{ - name: string; - source: string; - currentHash: string; - latestHash: string; - }>; - errors?: Array<{ - name: string; - source: string; - error: string; - }>; -} + // Default: check both scopes + if (!wantsGlobal && !wantsProject) { + return { project: true, global: true }; + } -function getSkillLockPath(): string { - return join(homedir(), AGENTS_DIR, LOCK_FILE); + return { + project: wantsProject, + global: wantsGlobal, + }; } -function readSkillLock(): SkillLockFile { - const lockPath = getSkillLockPath(); - try { - const content = readFileSync(lockPath, 'utf-8'); - const parsed = JSON.parse(content) as SkillLockFile; - if (typeof parsed.version !== 'number' || !parsed.skills) { - return { version: CURRENT_LOCK_VERSION, skills: {} }; +async function loadScopedLockEntries(options: ScopeOptions): Promise { + const entries: ScopedSkillLockEntry[] = []; + + if (options.project) { + const projectLock = await readSkillLock({ global: false, cwd: process.cwd() }); + for (const [name, entry] of Object.entries(projectLock.skills)) { + entries.push({ name, entry, scope: 'project' }); } - // If old version, wipe and start fresh (backwards incompatible change) - // v3 adds skillFolderHash - we want fresh installs to populate it - if (parsed.version < CURRENT_LOCK_VERSION) { - return { version: CURRENT_LOCK_VERSION, skills: {} }; + } + + if (options.global) { + const globalLock = await readSkillLock({ global: true }); + for (const [name, entry] of Object.entries(globalLock.skills)) { + entries.push({ name, entry, scope: 'global' }); } - return parsed; - } catch { - return { version: CURRENT_LOCK_VERSION, skills: {} }; } + + return entries; +} + +export function buildInstallUrl(entry: SkillLockEntry): string { + let installUrl = entry.sourceUrl; + + if (!entry.skillPath) { + return installUrl; + } + + // Extract the skill folder path (remove /SKILL.md suffix) + let skillFolder = entry.skillPath; + if (skillFolder.endsWith('/SKILL.md')) { + skillFolder = skillFolder.slice(0, -9); + } else if (skillFolder.endsWith('SKILL.md')) { + skillFolder = skillFolder.slice(0, -8); + } + if (skillFolder.endsWith('/')) { + skillFolder = skillFolder.slice(0, -1); + } + + // Convert git URL to tree URL with path + // https://github.com/owner/repo.git -> https://github.com/owner/repo/tree/main/path + installUrl = entry.sourceUrl.replace(/\.git$/, '').replace(/\/$/, ''); + return `${installUrl}/tree/main/${skillFolder}`; } -function writeSkillLock(lock: SkillLockFile): void { - const lockPath = getSkillLockPath(); - const dir = join(homedir(), AGENTS_DIR); - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); +export function buildUpdateAddArgs( + installUrl: string, + skillName: string, + scope: 'project' | 'global' +): string[] { + const addArgs = ['-y', 'skills', 'add', installUrl, '-y', '--rename', skillName]; + if (scope === 'global') { + addArgs.push('-g'); } - writeFileSync(lockPath, JSON.stringify(lock, null, 2), 'utf-8'); + return addArgs; } async function runCheck(args: string[] = []): Promise { console.log(`${TEXT}Checking for skill updates...${RESET}`); console.log(); - const lock = readSkillLock(); - const skillNames = Object.keys(lock.skills); + const scopeOptions = parseScopeOptions(args); + const lockEntries = await loadScopedLockEntries(scopeOptions); - if (skillNames.length === 0) { + if (lockEntries.length === 0) { console.log(`${DIM}No skills tracked in lock file.${RESET}`); console.log(`${DIM}Install skills with${RESET} ${TEXT}npx skills add ${RESET}`); return; @@ -368,13 +384,11 @@ async function runCheck(args: string[] = []): Promise { const token = getGitHubToken(); // Group skills by source (owner/repo) to batch GitHub API calls - const skillsBySource = new Map>(); + const skillsBySource = new Map(); let skippedCount = 0; - for (const skillName of skillNames) { - const entry = lock.skills[skillName]; - if (!entry) continue; - + for (const scoped of lockEntries) { + const { entry } = scoped; // Only check GitHub-sourced skills with folder hash if (entry.sourceType !== 'github' || !entry.skillFolderHash || !entry.skillPath) { skippedCount++; @@ -382,11 +396,11 @@ async function runCheck(args: string[] = []): Promise { } const existing = skillsBySource.get(entry.source) || []; - existing.push({ name: skillName, entry }); + existing.push(scoped); skillsBySource.set(entry.source, existing); } - const totalSkills = skillNames.length - skippedCount; + const totalSkills = lockEntries.length - skippedCount; if (totalSkills === 0) { console.log(`${DIM}No GitHub skills to check.${RESET}`); return; @@ -394,27 +408,33 @@ async function runCheck(args: string[] = []): Promise { console.log(`${DIM}Checking ${totalSkills} skill(s) for updates...${RESET}`); - const updates: Array<{ name: string; source: string }> = []; - const errors: Array<{ name: string; source: string; error: string }> = []; + const updates: Array<{ name: string; source: string; scope: 'project' | 'global' }> = []; + const errors: Array<{ + name: string; + source: string; + scope: 'project' | 'global'; + error: string; + }> = []; // Check each source (one API call per repo) for (const [source, skills] of skillsBySource) { - for (const { name, entry } of skills) { + for (const { name, entry, scope } of skills) { try { const latestHash = await fetchSkillFolderHash(source, entry.skillPath!, token); if (!latestHash) { - errors.push({ name, source, error: 'Could not fetch from GitHub' }); + errors.push({ name, source, scope, error: 'Could not fetch from GitHub' }); continue; } if (latestHash !== entry.skillFolderHash) { - updates.push({ name, source }); + updates.push({ name, source, scope }); } } catch (err) { errors.push({ name, source, + scope, error: err instanceof Error ? err.message : 'Unknown error', }); } @@ -431,6 +451,7 @@ async function runCheck(args: string[] = []): Promise { for (const update of updates) { console.log(` ${TEXT}↑${RESET} ${update.name}`); console.log(` ${DIM}source: ${update.source}${RESET}`); + console.log(` ${DIM}scope: ${update.scope}${RESET}`); } console.log(); console.log( @@ -453,14 +474,14 @@ async function runCheck(args: string[] = []): Promise { console.log(); } -async function runUpdate(): Promise { +async function runUpdate(args: string[] = []): Promise { console.log(`${TEXT}Checking for skill updates...${RESET}`); console.log(); - const lock = readSkillLock(); - const skillNames = Object.keys(lock.skills); + const scopeOptions = parseScopeOptions(args); + const lockEntries = await loadScopedLockEntries(scopeOptions); - if (skillNames.length === 0) { + if (lockEntries.length === 0) { console.log(`${DIM}No skills tracked in lock file.${RESET}`); console.log(`${DIM}Install skills with${RESET} ${TEXT}npx skills add ${RESET}`); return; @@ -470,13 +491,15 @@ async function runUpdate(): Promise { const token = getGitHubToken(); // Find skills that need updates by checking GitHub directly - const updates: Array<{ name: string; source: string; entry: SkillLockEntry }> = []; + const updates: Array<{ + name: string; + source: string; + entry: SkillLockEntry; + scope: 'project' | 'global'; + }> = []; let checkedCount = 0; - for (const skillName of skillNames) { - const entry = lock.skills[skillName]; - if (!entry) continue; - + for (const { name: skillName, entry, scope } of lockEntries) { // Only check GitHub-sourced skills with folder hash if (entry.sourceType !== 'github' || !entry.skillFolderHash || !entry.skillPath) { continue; @@ -488,7 +511,7 @@ async function runUpdate(): Promise { const latestHash = await fetchSkillFolderHash(entry.source, entry.skillPath, token); if (latestHash && latestHash !== entry.skillFolderHash) { - updates.push({ name: skillName, source: entry.source, entry }); + updates.push({ name: skillName, source: entry.source, entry, scope }); } } catch { // Skip skills that fail to check @@ -514,31 +537,13 @@ async function runUpdate(): Promise { let failCount = 0; for (const update of updates) { - console.log(`${TEXT}Updating ${update.name}...${RESET}`); - - // Build the URL with subpath to target the specific skill directory - // e.g., https://github.com/owner/repo/tree/main/skills/my-skill - let installUrl = update.entry.sourceUrl; - if (update.entry.skillPath) { - // Extract the skill folder path (remove /SKILL.md suffix) - let skillFolder = update.entry.skillPath; - if (skillFolder.endsWith('/SKILL.md')) { - skillFolder = skillFolder.slice(0, -9); - } else if (skillFolder.endsWith('SKILL.md')) { - skillFolder = skillFolder.slice(0, -8); - } - if (skillFolder.endsWith('/')) { - skillFolder = skillFolder.slice(0, -1); - } + console.log(`${TEXT}Updating ${update.name} (${update.scope})...${RESET}`); - // Convert git URL to tree URL with path - // https://github.com/owner/repo.git -> https://github.com/owner/repo/tree/main/path - installUrl = update.entry.sourceUrl.replace(/\.git$/, '').replace(/\/$/, ''); - installUrl = `${installUrl}/tree/main/${skillFolder}`; - } + const installUrl = buildInstallUrl(update.entry); + const addArgs = buildUpdateAddArgs(installUrl, update.name, update.scope); - // Use skills CLI to reinstall with -g -y flags - const result = spawnSync('npx', ['-y', 'skills', 'add', installUrl, '-g', '-y'], { + // Reinstall while preserving install name via --rename + const result = spawnSync('npx', addArgs, { stdio: ['inherit', 'pipe', 'pipe'], }); @@ -635,11 +640,11 @@ async function main(): Promise { await runList(restArgs); break; case 'check': - runCheck(restArgs); + await runCheck(restArgs); break; case 'update': case 'upgrade': - runUpdate(); + await runUpdate(restArgs); break; case '--help': case '-h': @@ -656,4 +661,6 @@ async function main(): Promise { } } -main(); +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main(); +} diff --git a/src/installer.ts b/src/installer.ts index bb24b738..6eb93aa8 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -8,11 +8,13 @@ import { rm, readlink, writeFile, + readFile, stat, realpath, } from 'fs/promises'; import { join, basename, normalize, resolve, sep, relative, dirname } from 'path'; import { homedir, platform } from 'os'; +import matter from 'gray-matter'; import type { Skill, AgentType, MintlifySkill, RemoteSkill } from './types.ts'; import type { WellKnownSkill } from './providers/wellknown.ts'; import { agents, detectInstalledAgents, isUniversalAgent } from './agents.ts'; @@ -208,10 +210,30 @@ async function createSymlink(target: string, linkPath: string): Promise } } +function rewriteSkillNameInContent(content: string, name: string): string { + try { + const parsed = matter(content); + parsed.data = { + ...parsed.data, + name, + }; + return matter.stringify(parsed.content, parsed.data); + } catch { + return content; + } +} + +async function rewriteInstalledSkillName(targetDir: string, name: string): Promise { + const skillMdPath = join(targetDir, 'SKILL.md'); + const current = await readFile(skillMdPath, 'utf-8'); + const updated = rewriteSkillNameInContent(current, name); + await writeFile(skillMdPath, updated, 'utf-8'); +} + export async function installSkillForAgent( skill: Skill, agentType: AgentType, - options: { global?: boolean; cwd?: string; mode?: InstallMode } = {} + options: { global?: boolean; cwd?: string; mode?: InstallMode; renameTo?: string } = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; @@ -228,7 +250,7 @@ export async function installSkillForAgent( } // Sanitize skill name to prevent directory traversal - const rawSkillName = skill.name || basename(skill.path); + const rawSkillName = options.renameTo || skill.name || basename(skill.path); const skillName = sanitizeName(rawSkillName); // Canonical location: .agents/skills/ @@ -265,6 +287,9 @@ export async function installSkillForAgent( if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); await copyDirectory(skill.path, agentDir); + if (options.renameTo) { + await rewriteInstalledSkillName(agentDir, options.renameTo); + } return { success: true, @@ -276,6 +301,9 @@ export async function installSkillForAgent( // Symlink mode: copy to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); await copyDirectory(skill.path, canonicalDir); + if (options.renameTo) { + await rewriteInstalledSkillName(canonicalDir, options.renameTo); + } // For universal agents with global install, the skill is already in the canonical // ~/.agents/skills directory. Skip creating a symlink to the agent-specific global dir @@ -556,7 +584,7 @@ export async function installMintlifySkillForAgent( export async function installRemoteSkillForAgent( skill: RemoteSkill, agentType: AgentType, - options: { global?: boolean; cwd?: string; mode?: InstallMode } = {} + options: { global?: boolean; cwd?: string; mode?: InstallMode; renameTo?: string } = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; @@ -574,7 +602,10 @@ export async function installRemoteSkillForAgent( } // Use installName as the skill directory name - const skillName = sanitizeName(skill.installName); + const skillName = sanitizeName(options.renameTo || skill.installName); + const skillContent = options.renameTo + ? rewriteSkillNameInContent(skill.content, options.renameTo) + : skill.content; // Canonical location: .agents/skills/ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd); @@ -608,7 +639,7 @@ export async function installRemoteSkillForAgent( if (installMode === 'copy') { await cleanAndCreateDirectory(agentDir); const skillMdPath = join(agentDir, 'SKILL.md'); - await writeFile(skillMdPath, skill.content, 'utf-8'); + await writeFile(skillMdPath, skillContent, 'utf-8'); return { success: true, @@ -620,7 +651,7 @@ export async function installRemoteSkillForAgent( // Symlink mode: write to canonical location and symlink to agent location await cleanAndCreateDirectory(canonicalDir); const skillMdPath = join(canonicalDir, 'SKILL.md'); - await writeFile(skillMdPath, skill.content, 'utf-8'); + await writeFile(skillMdPath, skillContent, 'utf-8'); // For universal agents with global install, skip creating agent-specific symlink if (isGlobal && isUniversalAgent(agentType)) { @@ -638,7 +669,7 @@ export async function installRemoteSkillForAgent( // Symlink failed, fall back to copy await cleanAndCreateDirectory(agentDir); const agentSkillMdPath = join(agentDir, 'SKILL.md'); - await writeFile(agentSkillMdPath, skill.content, 'utf-8'); + await writeFile(agentSkillMdPath, skillContent, 'utf-8'); return { success: true, @@ -675,7 +706,7 @@ export async function installRemoteSkillForAgent( export async function installWellKnownSkillForAgent( skill: WellKnownSkill, agentType: AgentType, - options: { global?: boolean; cwd?: string; mode?: InstallMode } = {} + options: { global?: boolean; cwd?: string; mode?: InstallMode; renameTo?: string } = {} ): Promise { const agent = agents[agentType]; const isGlobal = options.global ?? false; @@ -693,7 +724,7 @@ export async function installWellKnownSkillForAgent( } // Use installName as the skill directory name - const skillName = sanitizeName(skill.installName); + const skillName = sanitizeName(options.renameTo || skill.installName); // Canonical location: .agents/skills/ const canonicalBase = getCanonicalSkillsDir(isGlobal, cwd); @@ -738,8 +769,11 @@ export async function installWellKnownSkillForAgent( if (parentDir !== targetDir) { await mkdir(parentDir, { recursive: true }); } - - await writeFile(fullPath, content, 'utf-8'); + const output = + options.renameTo && filePath.toLowerCase() === 'skill.md' + ? rewriteSkillNameInContent(content, options.renameTo) + : content; + await writeFile(fullPath, output, 'utf-8'); } } diff --git a/src/remove.ts b/src/remove.ts index 67653bef..c4474d9e 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -208,13 +208,11 @@ export async function removeCommand(skillNames: string[], options: RemoveOptions await rm(canonicalPath, { recursive: true, force: true }); } - const lockEntry = isGlobal ? await getSkillFromLock(skillName) : null; + const lockEntry = await getSkillFromLock(skillName, { global: isGlobal, cwd }); const effectiveSource = lockEntry?.source || 'local'; const effectiveSourceType = lockEntry?.sourceType || 'local'; - if (isGlobal) { - await removeSkillFromLock(skillName); - } + await removeSkillFromLock(skillName, { global: isGlobal, cwd }); results.push({ skill: skillName, diff --git a/src/skill-lock.test.ts b/src/skill-lock.test.ts new file mode 100644 index 00000000..6dbf3b8d --- /dev/null +++ b/src/skill-lock.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + addSkillToLock, + getSkillLockPath, + readSkillLock, + removeSkillFromLock, + getSkillFromLock, +} from './skill-lock.ts'; + +describe('skill lock scope handling', () => { + let projectDir: string; + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'skills-lock-scope-')); + }); + + afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }); + }); + + it('stores project lock file under /.agents/.skill-lock.json', () => { + const lockPath = getSkillLockPath({ global: false, cwd: projectDir }); + expect(lockPath).toBe(join(projectDir, '.agents', '.skill-lock.json')); + }); + + it('can add/read/remove entries in the project-scoped lock', async () => { + await addSkillToLock( + 'renamed-skill', + { + source: 'owner/repo', + sourceType: 'github', + sourceUrl: 'https://github.com/owner/repo.git', + skillPath: 'skills/original/SKILL.md', + skillFolderHash: 'abc123', + }, + { global: false, cwd: projectDir } + ); + + const lock = await readSkillLock({ global: false, cwd: projectDir }); + expect(lock.skills['renamed-skill']).toBeDefined(); + + const entry = await getSkillFromLock('renamed-skill', { global: false, cwd: projectDir }); + expect(entry?.source).toBe('owner/repo'); + + const removed = await removeSkillFromLock('renamed-skill', { + global: false, + cwd: projectDir, + }); + expect(removed).toBe(true); + + const after = await getSkillFromLock('renamed-skill', { global: false, cwd: projectDir }); + expect(after).toBeNull(); + }); +}); diff --git a/src/skill-lock.ts b/src/skill-lock.ts index fe882e19..839a75fe 100644 --- a/src/skill-lock.ts +++ b/src/skill-lock.ts @@ -54,11 +54,22 @@ export interface SkillLockFile { lastSelectedAgents?: string[]; } +export interface SkillLockScopeOptions { + /** Global lock at ~/.agents/.skill-lock.json (default) */ + global?: boolean; + /** Project root for project-scoped lock file */ + cwd?: string; +} + /** - * Get the path to the global skill lock file. - * Located at ~/.agents/.skill-lock.json + * Get the path to the lock file for a given scope. + * Global scope: ~/.agents/.skill-lock.json + * Project scope: /.agents/.skill-lock.json */ -export function getSkillLockPath(): string { +export function getSkillLockPath(options: SkillLockScopeOptions = {}): string { + if (options.global === false) { + return join(options.cwd || process.cwd(), AGENTS_DIR, LOCK_FILE); + } return join(homedir(), AGENTS_DIR, LOCK_FILE); } @@ -67,8 +78,8 @@ export function getSkillLockPath(): string { * Returns an empty lock file structure if the file doesn't exist. * Wipes the lock file if it's an old format (version < CURRENT_VERSION). */ -export async function readSkillLock(): Promise { - const lockPath = getSkillLockPath(); +export async function readSkillLock(options: SkillLockScopeOptions = {}): Promise { + const lockPath = getSkillLockPath(options); try { const content = await readFile(lockPath, 'utf-8'); @@ -96,8 +107,11 @@ export async function readSkillLock(): Promise { * Write the skill lock file. * Creates the directory if it doesn't exist. */ -export async function writeSkillLock(lock: SkillLockFile): Promise { - const lockPath = getSkillLockPath(); +export async function writeSkillLock( + lock: SkillLockFile, + options: SkillLockScopeOptions = {} +): Promise { + const lockPath = getSkillLockPath(options); // Ensure directory exists await mkdir(dirname(lockPath), { recursive: true }); @@ -226,9 +240,10 @@ export async function fetchSkillFolderHash( */ export async function addSkillToLock( skillName: string, - entry: Omit + entry: Omit, + options: SkillLockScopeOptions = {} ): Promise { - const lock = await readSkillLock(); + const lock = await readSkillLock(options); const now = new Date().toISOString(); const existingEntry = lock.skills[skillName]; @@ -239,37 +254,45 @@ export async function addSkillToLock( updatedAt: now, }; - await writeSkillLock(lock); + await writeSkillLock(lock, options); } /** * Remove a skill from the lock file. */ -export async function removeSkillFromLock(skillName: string): Promise { - const lock = await readSkillLock(); +export async function removeSkillFromLock( + skillName: string, + options: SkillLockScopeOptions = {} +): Promise { + const lock = await readSkillLock(options); if (!(skillName in lock.skills)) { return false; } delete lock.skills[skillName]; - await writeSkillLock(lock); + await writeSkillLock(lock, options); return true; } /** * Get a skill entry from the lock file. */ -export async function getSkillFromLock(skillName: string): Promise { - const lock = await readSkillLock(); +export async function getSkillFromLock( + skillName: string, + options: SkillLockScopeOptions = {} +): Promise { + const lock = await readSkillLock(options); return lock.skills[skillName] ?? null; } /** * Get all skills from the lock file. */ -export async function getAllLockedSkills(): Promise> { - const lock = await readSkillLock(); +export async function getAllLockedSkills( + options: SkillLockScopeOptions = {} +): Promise> { + const lock = await readSkillLock(options); return lock.skills; } @@ -309,7 +332,7 @@ function createEmptyLockFile(): SkillLockFile { * Check if a prompt has been dismissed. */ export async function isPromptDismissed(promptKey: keyof DismissedPrompts): Promise { - const lock = await readSkillLock(); + const lock = await readSkillLock({ global: true }); return lock.dismissed?.[promptKey] === true; } @@ -317,19 +340,19 @@ export async function isPromptDismissed(promptKey: keyof DismissedPrompts): Prom * Mark a prompt as dismissed. */ export async function dismissPrompt(promptKey: keyof DismissedPrompts): Promise { - const lock = await readSkillLock(); + const lock = await readSkillLock({ global: true }); if (!lock.dismissed) { lock.dismissed = {}; } lock.dismissed[promptKey] = true; - await writeSkillLock(lock); + await writeSkillLock(lock, { global: true }); } /** * Get the last selected agents. */ export async function getLastSelectedAgents(): Promise { - const lock = await readSkillLock(); + const lock = await readSkillLock({ global: true }); return lock.lastSelectedAgents; } @@ -337,7 +360,7 @@ export async function getLastSelectedAgents(): Promise { * Save the selected agents to the lock file. */ export async function saveSelectedAgents(agents: string[]): Promise { - const lock = await readSkillLock(); + const lock = await readSkillLock({ global: true }); lock.lastSelectedAgents = agents; - await writeSkillLock(lock); + await writeSkillLock(lock, { global: true }); } diff --git a/src/update-helpers.test.ts b/src/update-helpers.test.ts new file mode 100644 index 00000000..8ca863c6 --- /dev/null +++ b/src/update-helpers.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { parseScopeOptions, buildInstallUrl, buildUpdateAddArgs } from './cli.ts'; +import type { SkillLockEntry } from './skill-lock.ts'; + +describe('check/update scope options', () => { + it('defaults to both project and global', () => { + expect(parseScopeOptions([])).toEqual({ project: true, global: true }); + }); + + it('supports --global only', () => { + expect(parseScopeOptions(['--global'])).toEqual({ project: false, global: true }); + }); + + it('supports --project only', () => { + expect(parseScopeOptions(['--project'])).toEqual({ project: true, global: false }); + }); + + it('supports both flags together', () => { + expect(parseScopeOptions(['--project', '--global'])).toEqual({ project: true, global: true }); + }); +}); + +describe('update install argument builders', () => { + const baseEntry: SkillLockEntry = { + source: 'owner/repo', + sourceType: 'github', + sourceUrl: 'https://github.com/owner/repo.git', + skillFolderHash: 'hash', + installedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + it('buildInstallUrl returns sourceUrl when no skillPath is set', () => { + expect(buildInstallUrl(baseEntry)).toBe('https://github.com/owner/repo.git'); + }); + + it('buildInstallUrl appends tree/main/ when skillPath is present', () => { + const entry: SkillLockEntry = { + ...baseEntry, + skillPath: 'skills/my-skill/SKILL.md', + }; + + expect(buildInstallUrl(entry)).toBe('https://github.com/owner/repo/tree/main/skills/my-skill'); + }); + + it('buildUpdateAddArgs preserves local name via --rename', () => { + const args = buildUpdateAddArgs( + 'https://github.com/owner/repo/tree/main/skills/my-skill', + 'my-renamed-skill', + 'project' + ); + expect(args).toEqual([ + '-y', + 'skills', + 'add', + 'https://github.com/owner/repo/tree/main/skills/my-skill', + '-y', + '--rename', + 'my-renamed-skill', + ]); + }); + + it('buildUpdateAddArgs adds -g for global scope', () => { + const args = buildUpdateAddArgs( + 'https://github.com/owner/repo/tree/main/skills/my-skill', + 'my-renamed-skill', + 'global' + ); + expect(args).toContain('-g'); + }); +}); diff --git a/tests/rename-install.test.ts b/tests/rename-install.test.ts new file mode 100644 index 00000000..670e001f --- /dev/null +++ b/tests/rename-install.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtemp, mkdir, rm, writeFile, readFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + installSkillForAgent, + installRemoteSkillForAgent, + installWellKnownSkillForAgent, +} from '../src/installer.ts'; + +describe('rename install behavior', () => { + it('renames local skill directory and SKILL.md frontmatter', async () => { + const root = await mkdtemp(join(tmpdir(), 'skills-rename-local-')); + const sourceDir = join(root, 'source-skill'); + const projectDir = join(root, 'project'); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(projectDir, { recursive: true }); + await writeFile( + join(sourceDir, 'SKILL.md'), + `---\nname: original-local\ndescription: Local skill\n---\n\n# Local\n`, + 'utf-8' + ); + + try { + const result = await installSkillForAgent( + { name: 'original-local', description: 'Local skill', path: sourceDir }, + 'amp', + { + cwd: projectDir, + global: false, + mode: 'symlink', + renameTo: 'renamed-local', + } + ); + + expect(result.success).toBe(true); + const installedPath = join(projectDir, '.agents', 'skills', 'renamed-local', 'SKILL.md'); + const content = await readFile(installedPath, 'utf-8'); + expect(content).toContain('name: renamed-local'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('renames remote skill directory and SKILL.md frontmatter', async () => { + const root = await mkdtemp(join(tmpdir(), 'skills-rename-remote-')); + + try { + const result = await installRemoteSkillForAgent( + { + name: 'Remote Original', + description: 'Remote skill', + content: `---\nname: remote-original\ndescription: Remote skill\n---\n\n# Remote\n`, + installName: 'remote-original', + sourceUrl: 'https://example.com/skill.md', + providerId: 'mintlify', + sourceIdentifier: 'mintlify/com', + }, + 'amp', + { + cwd: root, + global: false, + mode: 'symlink', + renameTo: 'renamed-remote', + } + ); + + expect(result.success).toBe(true); + const installedPath = join(root, '.agents', 'skills', 'renamed-remote', 'SKILL.md'); + const content = await readFile(installedPath, 'utf-8'); + expect(content).toContain('name: renamed-remote'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it('renames well-known skill directory and SKILL.md frontmatter', async () => { + const root = await mkdtemp(join(tmpdir(), 'skills-rename-wellknown-')); + + const skillMd = `---\nname: original-well-known\ndescription: Well-known skill\n---\n\n# Well Known\n`; + + try { + const result = await installWellKnownSkillForAgent( + { + name: 'Well Known Original', + description: 'Well-known skill', + content: skillMd, + installName: 'original-well-known', + sourceUrl: 'https://example.com/.well-known/skills/original/SKILL.md', + files: new Map([ + ['SKILL.md', skillMd], + ['README.md', 'Additional docs'], + ]), + indexEntry: { + name: 'original-well-known', + description: 'Well-known skill', + files: ['SKILL.md', 'README.md'], + }, + }, + 'amp', + { + cwd: root, + global: false, + mode: 'symlink', + renameTo: 'renamed-well-known', + } + ); + + expect(result.success).toBe(true); + const skillPath = join(root, '.agents', 'skills', 'renamed-well-known', 'SKILL.md'); + const readmePath = join(root, '.agents', 'skills', 'renamed-well-known', 'README.md'); + + const content = await readFile(skillPath, 'utf-8'); + expect(content).toContain('name: renamed-well-known'); + await expect(stat(readmePath)).resolves.toBeDefined(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});