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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ askill run skill-name:command
- Use `-y` to skip confirmation prompts
- Use `-a <agent>` to target a specific agent (claude-code, cursor, opencode, ...)
- Skills are installed to `.agents/skills/` and symlinked into agent directories
- Installed metadata is saved in `~/.agents/.skill-lock.json`
- Installed metadata is saved in `.agents/.skill-lock.json` by default; global installs use `~/.agents/.skill-lock.json`

**For Skill Development:**
- Read [`docs/skill-spec.md`](./docs/skill-spec.md) for SKILL.md format
Expand Down
13 changes: 8 additions & 5 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ extract-errors

## askill update

Update installed skills to their latest versions.
Update installed skills to their latest versions. By default this reads the current project's `.agents/.skill-lock.json`; use `--global` for `~/.agents/.skill-lock.json`.

### Usage

Expand All @@ -276,7 +276,8 @@ askill update [skill] [options]

| Option | Description |
|--------|-------------|
| `-g, --global` | Update global skills |
| `-g, --global` | Update global skills from the global lock file |
| `-y, --yes` | Skip confirmation prompts |

### Examples

Expand All @@ -295,19 +296,19 @@ askill update -g

## askill check

Check installed skills for available updates.
Check installed skills for available updates. By default this reads the current project's `.agents/.skill-lock.json`; use `--global` for `~/.agents/.skill-lock.json`.

### Usage

```bash
askill check [options]
askill check [skill] [options]
```

### Options

| Option | Description |
|--------|-------------|
| `-g, --global` | Check global skills |
| `-g, --global` | Check global skills from the global lock file |

### Examples

Expand Down Expand Up @@ -660,6 +661,8 @@ Skills are installed to agent-specific directories:

By default, skills are written to the canonical location (`.agents/skills/`) and symlinked to each agent's directory for deduplication.

Lock files follow the same scope: project installs write `.agents/.skill-lock.json`, while global installs write `~/.agents/.skill-lock.json`.

---

## Exit Codes
Expand Down
3 changes: 2 additions & 1 deletion skills/discover-a-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ askill installs to canonical directories and links into agent-specific paths.

State and metadata:

- Lock file: `~/.agents/.skill-lock.json`
- Project lock file: `.agents/.skill-lock.json`
- Global lock file: `~/.agents/.skill-lock.json`
- Credentials: `~/.askill/credentials.json`
- Preferences: `~/.config/askill/config.json`

Expand Down
138 changes: 85 additions & 53 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,9 @@ function showCommandHelp(commandInput: string): boolean {

info: `${BOLD}askill info${RESET}\n\nUsage:\n askill info <slug>\n\nDescription:\n Show detailed metadata and installation info for one skill.\n\nExamples:\n askill info @johndoe/awesome-tool\n askill info gh:facebook/react@extract-errors`,

check: `${BOLD}askill check${RESET}\n\nUsage:\n askill check [skill]\n\nDescription:\n Check installed skills for available updates without installing.\n\nExamples:\n askill check\n askill check memory`,
check: `${BOLD}askill check${RESET}\n\nUsage:\n askill check [skill] [options]\n\nDescription:\n Check installed skills for available updates without installing. Defaults to the current project's lock file.\n\nOptions:\n -g, --global Check global lock file\n\nExamples:\n askill check\n askill check memory\n askill check -g`,

update: `${BOLD}askill update${RESET}\n\nUsage:\n askill update [skill]\n\nDescription:\n Update one installed skill or all installed skills.\n\nExamples:\n askill update\n askill update memory`,
update: `${BOLD}askill update${RESET}\n\nUsage:\n askill update [skill] [options]\n\nDescription:\n Update one installed skill or all installed skills. Defaults to the current project's lock file.\n\nOptions:\n -g, --global Update global lock file and global installs\n -y, --yes Skip confirmation prompts\n\nExamples:\n askill update\n askill update memory\n askill update -g -y`,

run: `${BOLD}askill run${RESET}\n\nUsage:\n askill run <skill>:<command> [args...]\n\nDescription:\n Run a command declared in a skill's SKILL.md frontmatter.\n\nExamples:\n askill run @anthropic/memory:save --key name --value \"Alice\"\n askill run my-skill:_setup`,

Expand Down Expand Up @@ -727,6 +727,10 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi
return;
}

const installGlobally = options.global ?? false;
const lockOptions = { global: installGlobally };
const installMode: InstallMode = options.copy ? 'copy' : 'symlink';

const { targetAgents: specifiedAgents, invalidAgents } = resolveValidatedAgents(options.agent);
if (invalidAgents.length > 0) {
printJson({
Expand All @@ -748,7 +752,7 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi
targetAgents = specifiedAgents;
} else {
const installedAgents = await detectInstalledAgents();
const preferredAgents = (await getLastSelectedAgents()) || (await getPreferredAgents());
const preferredAgents = (await getLastSelectedAgents(lockOptions)) || (await getPreferredAgents());

if (installedAgents.length === 0) {
targetAgents = validAgents.slice(0, 5) as AgentType[];
Expand Down Expand Up @@ -778,9 +782,6 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi
return;
}

const installGlobally = options.global ?? false;
const installMode: InstallMode = options.copy ? 'copy' : 'symlink';

const allResults: Array<{ skill: string; agent: AgentType; success: boolean; error?: string; isDependency?: boolean }> = [];
const installedNames = new Set<string>();
const invalidDependencies: Array<{ skill: string; dependency: string }> = [];
Expand Down Expand Up @@ -888,7 +889,7 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi
const failed = allResults.filter((result) => !result.success);

if (successful.length > 0) {
await saveLastSelectedAgents(targetAgents);
await saveLastSelectedAgents(targetAgents, lockOptions);

const installedSkillNames = new Set(successful.map((result) => result.skill));
for (const installedSkillName of installedSkillNames) {
Expand Down Expand Up @@ -929,7 +930,7 @@ async function runInstallJson(skillName: string, options: InstallOptions): Promi
sourceUrl,
skillPath: skillPath || undefined,
skillFolderHash,
}).catch(() => {
}, lockOptions).catch(() => {
// Non-critical
});
}
Expand Down Expand Up @@ -1088,6 +1089,28 @@ async function runInstall(args: string[]): Promise<void> {
skillsToInstall = selected as DiscoveredSkill[];
}

// Select scope before agent detection so project/global installs use the matching lock.
let installGlobally = options.global ?? false;

if (options.global === undefined && !options.yes) {
const scope = await p.select({
message: 'Installation scope',
options: [
{ value: false, label: 'Project', hint: 'Install in current directory' },
{ value: true, label: 'Global', hint: 'Install in home directory (all projects)' },
],
});

if (p.isCancel(scope)) {
p.cancel('Installation cancelled');
return;
}

installGlobally = scope as boolean;
}

const lockOptions = { global: installGlobally };

// Detect agents
let targetAgents: AgentType[];
const validAgents = Object.keys(agents) as AgentType[];
Expand All @@ -1110,7 +1133,7 @@ async function runInstall(args: string[]): Promise<void> {
installedAgents = await detectInstalledAgents();
p.log.info(`Found ${installedAgents.length} agent(s)`);
}
const preferredAgents = (await getLastSelectedAgents()) || (await getPreferredAgents());
const preferredAgents = (await getLastSelectedAgents(lockOptions)) || (await getPreferredAgents());

if (installedAgents.length === 0) {
if (options.yes) {
Expand Down Expand Up @@ -1172,26 +1195,6 @@ async function runInstall(args: string[]): Promise<void> {
}
}

// Select scope (global vs project)
let installGlobally = options.global ?? false;

if (options.global === undefined && !options.yes) {
const scope = await p.select({
message: 'Installation scope',
options: [
{ value: false, label: 'Project', hint: 'Install in current directory' },
{ value: true, label: 'Global', hint: 'Install in home directory (all projects)' },
],
});

if (p.isCancel(scope)) {
p.cancel('Installation cancelled');
return;
}

installGlobally = scope as boolean;
}

const installMode: InstallMode = options.copy ? 'copy' : 'symlink';

// Confirm installation
Expand Down Expand Up @@ -1340,7 +1343,7 @@ async function runInstall(args: string[]): Promise<void> {

if (successful.length > 0) {
// Save selected agents as preferred for next time
await saveLastSelectedAgents(targetAgents);
await saveLastSelectedAgents(targetAgents, lockOptions);

// Write lock entries for all successfully installed skills
const installedSkillNames = new Set(successful.map((r) => r.skill));
Expand Down Expand Up @@ -1389,7 +1392,7 @@ async function runInstall(args: string[]): Promise<void> {
sourceUrl,
skillPath: skillPath || undefined,
skillFolderHash,
}).catch(() => {
}, lockOptions).catch(() => {
// Non-critical: lock file write failure shouldn't fail install
});
}
Expand Down Expand Up @@ -2020,7 +2023,7 @@ async function runRemove(args: string[]): Promise<void> {
const orphanRemoval = await removeCanonicalSkill(resolvedSkillName, { global: effectiveGlobalScope });

if (orphanRemoval.success) {
await removeSkillFromLock(resolvedSkillName).catch(() => {
await removeSkillFromLock(resolvedSkillName, { global: effectiveGlobalScope }).catch(() => {
// Non-critical: lock cleanup failure shouldn't fail removal
});

Expand Down Expand Up @@ -2118,7 +2121,7 @@ async function runRemove(args: string[]): Promise<void> {

// Remove from lock file
if (removedAgents.length > 0) {
await removeSkillFromLock(resolvedSkillName).catch(() => {
await removeSkillFromLock(resolvedSkillName, { global: effectiveGlobalScope }).catch(() => {
// Non-critical: lock file cleanup failure shouldn't fail removal
});
}
Expand Down Expand Up @@ -2267,32 +2270,60 @@ export interface SkillUpdateInfo {
remoteHash: string;
}

async function runCheck(_args: string[]): Promise<void> {
interface ScopedSkillOptions {
global: boolean;
skillName?: string;
}

function parseScopedSkillOptions(args: string[]): ScopedSkillOptions {
const options: ScopedSkillOptions = { global: false };

for (const arg of args) {
if (arg === '-g' || arg === '--global') {
options.global = true;
continue;
}

if (!arg.startsWith('-') && !options.skillName) {
options.skillName = arg;
}
}

return options;
}

async function runCheck(args: string[]): Promise<void> {
const options = parseScopedSkillOptions(args);
const lockOptions = { global: options.global };

console.log();
p.intro(pc.bgCyan(pc.black(' askill check ')));

const spinner = p.spinner();
spinner.start('Reading lock file...');

const skills = await getAllLockedSkills();
const skillNames = Object.keys(skills);
const skills = await getAllLockedSkills(lockOptions);
const skillEntries = Object.entries(skills).filter(([name]) => !options.skillName || name === options.skillName);

if (skillNames.length === 0) {
if (skillEntries.length === 0) {
spinner.stop('No skills tracked');
p.log.info('No installed skills found in lock file');
const scope = options.global ? 'global' : 'project';
p.log.info(options.skillName
? `Skill "${options.skillName}" not found in ${scope} lock file`
: `No installed skills found in ${scope} lock file`);
p.log.info(`Install skills with ${pc.cyan('askill add <skill>')}`);
p.outro('');
return;
}

spinner.stop(`Found ${skillNames.length} tracked skill(s)`);
spinner.stop(`Found ${skillEntries.length} tracked skill(s)`);
spinner.start('Checking for updates...');

const updatable: SkillUpdateInfo[] = [];
const upToDate: string[] = [];
const uncheckable: Array<{ name: string; reason: string }> = [];

for (const [name, entry] of Object.entries(skills)) {
for (const [name, entry] of skillEntries) {
// Only GitHub sources can be checked via Tree SHA
if (entry.sourceType !== 'github' || !entry.source) {
const reason = entry.sourceType === 'local'
Expand Down Expand Up @@ -2368,31 +2399,32 @@ async function runCheck(_args: string[]): Promise<void> {

async function runUpdate(args: string[]): Promise<void> {
const isYes = args.includes('-y') || args.includes('--yes');
const specificSkill = args.find((a) => !a.startsWith('-'));
const scopeOptions = parseScopedSkillOptions(args);
const lockOptions = { global: scopeOptions.global };

console.log();
p.intro(pc.bgCyan(pc.black(' askill update ')));

const spinner = p.spinner();
spinner.start('Checking for updates...');

const skills = await getAllLockedSkills();
const skillNames = Object.keys(skills);
const skills = await getAllLockedSkills(lockOptions);
const skillEntries = Object.entries(skills).filter(([name]) => !scopeOptions.skillName || name === scopeOptions.skillName);

if (skillNames.length === 0) {
if (skillEntries.length === 0) {
spinner.stop('No skills tracked');
p.log.info('No installed skills found in lock file');
const scope = scopeOptions.global ? 'global' : 'project';
p.log.info(scopeOptions.skillName
? `Skill "${scopeOptions.skillName}" not found in ${scope} lock file`
: `No installed skills found in ${scope} lock file`);
p.outro('');
return;
}

// Find which skills have updates
const updatable: SkillUpdateInfo[] = [];

for (const [name, entry] of Object.entries(skills)) {
// If specific skill requested, skip others
if (specificSkill && name !== specificSkill) continue;

for (const [name, entry] of skillEntries) {
if (entry.sourceType !== 'github' || !entry.source || !entry.skillFolderHash) {
continue;
}
Expand Down Expand Up @@ -2440,7 +2472,7 @@ async function runUpdate(args: string[]): Promise<void> {
}

// Get last selected agents
const lastAgents = await getLastSelectedAgents();
const lastAgents = await getLastSelectedAgents(lockOptions);
let targetAgents: AgentType[];

if (lastAgents && lastAgents.length > 0) {
Expand Down Expand Up @@ -2494,9 +2526,9 @@ async function runUpdate(args: string[]): Promise<void> {
// Install to all target agents
for (const agent of targetAgents) {
if (skill.path) {
await installSkillFromDir(skill.name, skill.path, agent, { mode: 'symlink' });
await installSkillFromDir(skill.name, skill.path, agent, { mode: 'symlink', global: scopeOptions.global });
} else {
await installSkill(skill.name, skill.rawContent, agent, { mode: 'symlink' });
await installSkill(skill.name, skill.rawContent, agent, { mode: 'symlink', global: scopeOptions.global });
}
}

Expand All @@ -2508,7 +2540,7 @@ async function runUpdate(args: string[]): Promise<void> {
sourceUrl: lockEntry.sourceUrl,
skillPath: lockEntry.skillPath,
skillFolderHash: u.remoteHash,
});
}, lockOptions);

successCount++;
} catch (error) {
Expand Down
Loading
Loading