From 9ab1facb6f21604cc1353aacabeb4a24c4cdda68 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Tue, 12 May 2026 14:09:07 +0200 Subject: [PATCH] feat(skill): `allagents skills update` per-skill refresh command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `skills update [skill]` subcommand decoupled from workspace sync. Walks the `sources` map in sync-state (introduced for content-hashing in #374), fetches each plugin (cached), and diffs the upstream hash against the recorded `contentHash` to decide: - `up-to-date` — recorded hash matches upstream - `available` — drift detected; reported in --dry-run / TTY default - `updated` — applied in --all (or non-TTY default) mode - `pinned` — `pinnedRef` set; skipped unless --unpin - `skipped` — fetch failed (kept fail-safe) Flags: - `--all` apply without prompting (default behaviour in non-TTY too) - `--force` re-write the source provenance even when hashes match - `--dry-run` report drift, no file writes - `--unpin` clear `pinnedRef` so the resolver picks up the default branch - `--scope` project (default) or user JSON envelope shape: ``` { success: true, command: "skills update", data: { checked, updates: [...], upToDate: [...], pinned: [...] } } ``` Includes: - `skillsUpdateMeta` in metadata, wired into agent-help. - Tests for the new agent-help entry. End-to-end verified against `obra/superpowers@v3.1.0`: dry-run is a read-only no-op, JSON envelope matches the contract, --all applies in a non-TTY shell without blocking, pinned skills are skipped unless --unpin, --force re-stamps updatedAt, and TTY-default with no --all does not hang. Closes #375 --- src/cli/agent-help.ts | 2 + src/cli/commands/plugin-skills.ts | 218 ++++++++++++++++++++++++++++++ src/cli/metadata/plugin-skills.ts | 45 ++++++ tests/unit/cli/agent-help.test.ts | 7 +- 4 files changed, 270 insertions(+), 2 deletions(-) diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index a818d25..20ba9dc 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -18,6 +18,7 @@ import { skillsAddMeta, skillsRemoveMeta, skillsSearchMeta, + skillsUpdateMeta, } from './metadata/plugin-skills.js'; const allCommands: AgentCommandMeta[] = [ @@ -37,6 +38,7 @@ const allCommands: AgentCommandMeta[] = [ skillsAddMeta, skillsRemoveMeta, skillsSearchMeta, + skillsUpdateMeta, updateMeta, ]; diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index d95db7a..2d4caaf 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -29,6 +29,7 @@ import { skillsRemoveMeta, skillsAddMeta, skillsSearchMeta, + skillsUpdateMeta, } from '../metadata/plugin-skills.js'; import { searchSkills, @@ -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'; @@ -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) // ============================================================================= @@ -1527,5 +1744,6 @@ export const skillsCmd = conciseSubcommands({ remove: removeCmd, add: addCmd, search: searchCmd, + update: updateCmd, }, }); diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index fa62736..7065b14 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -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', diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index a2b6c22..cf76f45 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -18,6 +18,7 @@ import { skillsAddMeta, skillsRemoveMeta, skillsSearchMeta, + skillsUpdateMeta, } from '../../../src/cli/metadata/plugin-skills.js'; import type { AgentCommandMeta } from '../../../src/cli/help.js'; @@ -38,6 +39,7 @@ const allCommands: AgentCommandMeta[] = [ skillsAddMeta, skillsRemoveMeta, skillsSearchMeta, + skillsUpdateMeta, updateMeta, ]; @@ -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', () => { @@ -89,6 +91,7 @@ describe('agent command metadata', () => { 'skill list', 'skill remove', 'skill search', + 'skill update', 'update', 'workspace init', 'workspace status',