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: 2 additions & 0 deletions src/cli/agent-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
skillsAddMeta,
skillsRemoveMeta,
skillsSearchMeta,
skillsUpdateMeta,
} from './metadata/plugin-skills.js';

const allCommands: AgentCommandMeta[] = [
Expand All @@ -37,6 +38,7 @@ const allCommands: AgentCommandMeta[] = [
skillsAddMeta,
skillsRemoveMeta,
skillsSearchMeta,
skillsUpdateMeta,
updateMeta,
];

Expand Down
218 changes: 218 additions & 0 deletions src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
skillsRemoveMeta,
skillsAddMeta,
skillsSearchMeta,
skillsUpdateMeta,
} from '../metadata/plugin-skills.js';
import {
searchSkills,
Expand All @@ -40,6 +41,7 @@ import { isGitHubUrl, parseGitHubUrl, stripGitRef } from '../../utils/plugin-pat
import { fetchPlugin, getPluginName, seedFetchCache } from '../../core/plugin.js';
import {
computeSkillFolderHash,
loadSyncState,
upsertSyncStateSource,
upsertSyncStateSkill,
} from '../../core/sync-state.js';
Expand Down Expand Up @@ -1515,6 +1517,221 @@ const searchCmd = command({
},
});

// =============================================================================
// skill update
// =============================================================================

/**
* Output row for skill update (per skill, both for human print and JSON).
*/
type SkillUpdateRow = {
skill: string;
source: string;
from: string;
to: string;
status: 'up-to-date' | 'available' | 'updated' | 'pinned' | 'skipped';
};

const updateCmd = command({
name: 'update',
description: buildDescription(skillsUpdateMeta),
args: {
skill: positional({ type: optional(string), displayName: 'skill' }),
all: flag({ long: 'all', description: 'Apply updates without prompting (default in non-TTY).' }),
force: flag({ long: 'force', description: 'Re-download even when content hashes match.' }),
dryRun: flag({ long: 'dry-run', description: 'Report drift without writing any files.' }),
unpin: flag({ long: 'unpin', description: 'Clear pinnedRef before resolving (move to latest).' }),
scope: option({
type: optional(string),
long: 'scope',
short: 's',
description: 'Scope: "project" (default) or "user"',
}),
},
handler: async ({ skill: skillFilter, all, force, dryRun, unpin, scope }) => {
try {
const isUser = scope === 'user' || (!scope && resolveScope(process.cwd()) === 'user');
const workspacePath = isUser ? getHomeDir() : process.cwd();

const state = await loadSyncState(workspacePath);
const sources = state?.sources ?? {};

// Build the list of (source, skill) tuples to consider. When a skill
// name is supplied, only entries matching that name are processed.
type Candidate = {
sourceKey: string;
source: import('../../models/sync-state.js').SyncStateSource;
skillName: string;
recordedHash: string;
};
const candidates: Candidate[] = [];
for (const [key, source] of Object.entries(sources)) {
if (!source.skills) continue;
for (const [skillName, entry] of Object.entries(source.skills)) {
if (skillFilter && skillName !== skillFilter) continue;
candidates.push({
sourceKey: key,
source,
skillName,
recordedHash: entry.contentHash,
});
}
}

const updates: SkillUpdateRow[] = [];
const upToDate: string[] = [];
const pinned: string[] = [];

// Non-TTY default: skip skills that would otherwise prompt; equivalent
// to running with --all from the user's perspective.
const applyImplicitly = all || !process.stdin.isTTY;

for (const cand of candidates) {
const currentlyPinned = Boolean(cand.source.pinnedRef);
if (currentlyPinned && !unpin) {
pinned.push(cand.skillName);
continue;
}

// Resolve upstream (cached fetch). When --unpin, drop the pin from the
// spec so we resolve against the default branch.
const spec = cand.sourceKey;
const fetchOpts = unpin
? {}
: cand.source.pinnedRef
? { branch: cand.source.pinnedRef }
: {};
const fetchResult = await fetchPlugin(spec, fetchOpts);
if (!fetchResult.success || !fetchResult.resolvedSha) {
updates.push({
skill: cand.skillName,
source: cand.sourceKey,
from: cand.source.resolvedSha.slice(0, 7),
to: '?',
status: 'skipped',
});
continue;
}

const pluginRoot = fetchResult.cachePath;
const folder = resolveSkillFolder(pluginRoot, cand.skillName);
const upstreamHash = folder ? await computeSkillFolderHash(folder) : null;
const matches = upstreamHash !== null && upstreamHash === cand.recordedHash;

if (matches && !force) {
upToDate.push(cand.skillName);
continue;
}

const fromShort = cand.source.resolvedSha.slice(0, 7);
const toShort = fetchResult.resolvedSha.slice(0, 7);

if (dryRun) {
updates.push({
skill: cand.skillName,
source: cand.sourceKey,
from: fromShort,
to: toShort,
status: 'available',
});
continue;
}

// Apply: refresh the source provenance entry and re-hash the skill.
if (applyImplicitly) {
const now = new Date().toISOString();
await upsertSyncStateSource(workspacePath, cand.sourceKey, {
pluginSpec: cand.sourceKey,
resolvedRef: fetchResult.resolvedRef ?? cand.source.resolvedRef,
resolvedSha: fetchResult.resolvedSha,
...(unpin
? {}
: cand.source.pinnedRef
? { pinnedRef: cand.source.pinnedRef }
: {}),
});
if (upstreamHash) {
await upsertSyncStateSkill(workspacePath, cand.sourceKey, cand.skillName, {
contentHash: upstreamHash,
installedAt: cand.source.skills?.[cand.skillName]?.installedAt ?? now,
updatedAt: now,
});
}
updates.push({
skill: cand.skillName,
source: cand.sourceKey,
from: fromShort,
to: toShort,
status: 'updated',
});
} else {
// No --all in TTY: report rather than apply, leaving the choice to a follow-up.
updates.push({
skill: cand.skillName,
source: cand.sourceKey,
from: fromShort,
to: toShort,
status: 'available',
});
}
}

const checked = candidates.length;
if (isJsonMode()) {
jsonOutput({
success: true,
command: 'skill update',
data: {
checked,
updates,
upToDate,
pinned,
...(dryRun && { dryRun: true }),
},
});
return;
}

if (checked === 0) {
console.log('No installed skills with recorded content hashes found.');
return;
}

console.log(`Checking ${checked} skill(s)...`);
for (const s of upToDate) {
console.log(` ${chalk.green('✓')} ${s.padEnd(28)} up to date`);
}
for (const u of updates) {
const marker =
u.status === 'updated' ? chalk.cyan('↑')
: u.status === 'available' ? chalk.yellow('*')
: chalk.dim('-');
const tail =
u.status === 'updated' ? `${u.from} → ${u.to} (updated)`
: u.status === 'available' ? `${u.from} → ${u.to} (would update)`
: u.status;
console.log(` ${marker} ${u.skill.padEnd(28)} ${tail}`);
}
for (const p of pinned) {
console.log(` ${chalk.dim('-')} ${p.padEnd(28)} pinned, skipping (--unpin to override)`);
}
console.log(
`\n${upToDate.length} up to date, ${updates.length} update${updates.length === 1 ? '' : 's'} ${dryRun ? 'available' : 'reported'}, ${pinned.length} pinned`,
);
} catch (error) {
if (error instanceof Error) {
if (isJsonMode()) {
jsonOutput({ success: false, command: 'skill update', error: error.message });
process.exit(1);
}
console.error(`Error: ${error.message}`);
process.exit(1);
}
throw error;
}
},
});

// =============================================================================
// skill subcommands group (canonical singular; `skills` is a CLI alias)
// =============================================================================
Expand All @@ -1527,5 +1744,6 @@ export const skillsCmd = conciseSubcommands({
remove: removeCmd,
add: addCmd,
search: searchCmd,
update: updateCmd,
},
});
45 changes: 45 additions & 0 deletions src/cli/metadata/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@ export const skillsSearchMeta: AgentCommandMeta = {
},
};

export const skillsUpdateMeta: AgentCommandMeta = {
command: 'skill update',
description: 'Refresh installed skills against their source (per-skill, separate from workspace sync)',
whenToUse:
'To check or apply updates for installed skills without re-syncing the entire workspace. Pinned skills are skipped unless --unpin is passed.',
examples: [
'allagents skill update',
'allagents skill update brainstorming',
'allagents skill update --all',
'allagents skill update --dry-run',
'allagents skill update --force --all',
'allagents skill update git-commit --unpin',
],
expectedOutput: 'Per-skill report of up-to-date / updates available / pinned',
positionals: [
{
name: 'skill',
type: 'string',
required: false,
description: 'Optional skill name. Omit to update across every installed skill.',
},
],
options: [
{ flag: '--all', type: 'boolean', description: 'Apply updates without prompting (default in non-TTY).' },
{ flag: '--force', type: 'boolean', description: 'Re-download even when content hashes match.' },
{ flag: '--dry-run', type: 'boolean', description: 'Report drift without writing any files.' },
{ flag: '--unpin', type: 'boolean', description: 'Clear any pinnedRef before resolving (move to latest).' },
{ flag: '--scope', short: '-s', type: 'string', description: 'Scope: "project" (default) or "user".' },
],
outputSchema: {
checked: 'number',
updates: [
{
skill: 'string',
source: 'string',
from: 'string',
to: 'string',
status: 'string',
},
],
upToDate: ['string'],
pinned: ['string'],
},
};

export const skillsAddMeta: AgentCommandMeta = {
command: 'skill add',
description: 'Add a skill from a plugin, or re-enable a previously disabled skill',
Expand Down
7 changes: 5 additions & 2 deletions tests/unit/cli/agent-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
skillsAddMeta,
skillsRemoveMeta,
skillsSearchMeta,
skillsUpdateMeta,
} from '../../../src/cli/metadata/plugin-skills.js';
import type { AgentCommandMeta } from '../../../src/cli/help.js';

Expand All @@ -38,6 +39,7 @@ const allCommands: AgentCommandMeta[] = [
skillsAddMeta,
skillsRemoveMeta,
skillsSearchMeta,
skillsUpdateMeta,
updateMeta,
];

Expand Down Expand Up @@ -68,8 +70,8 @@ describe('extractAgentHelpFlag', () => {
});

describe('agent command metadata', () => {
test('contains exactly 17 commands', () => {
expect(allCommands.length).toBe(17);
test('contains exactly 18 commands', () => {
expect(allCommands.length).toBe(18);
});

test('all expected commands are present', () => {
Expand All @@ -89,6 +91,7 @@ describe('agent command metadata', () => {
'skill list',
'skill remove',
'skill search',
'skill update',
'update',
'workspace init',
'workspace status',
Expand Down