diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 6d7208f..58dd761 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -32,7 +32,11 @@ import { import { getHomeDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../constants.js'; import { isGitHubUrl, parseGitHubUrl, stripGitRef } from '../../utils/plugin-path.js'; import { fetchPlugin, getPluginName, seedFetchCache } from '../../core/plugin.js'; -import { upsertSyncStateSource } from '../../core/sync-state.js'; +import { + computeSkillFolderHash, + upsertSyncStateSource, + upsertSyncStateSkill, +} from '../../core/sync-state.js'; import { parseSkillMetadata } from '../../validators/skill.js'; import { addMarketplace, @@ -62,39 +66,76 @@ function resolveScope(cwd: string): 'user' | 'project' { } /** - * Record a per-source provenance entry (resolved ref + SHA + pin) into the - * workspace's sync-state. The key is the spec with any `@` suffix stripped - * so all installs of `owner/repo` map to one entry regardless of pin. + * Resolve the on-disk skill folder for a given plugin cache path and skill name. + * Mirrors `resolveSkillMdPath` further down but returns the *folder* containing + * the SKILL.md rather than the file itself. + */ +function resolveSkillFolder(pluginPath: string, skillName: string): string | null { + const candidates = [ + join(pluginPath, 'skills', skillName), + join(pluginPath, skillName), + pluginPath, + ]; + for (const candidate of candidates) { + if (existsSync(join(candidate, 'SKILL.md'))) return candidate; + } + return null; +} + +/** + * Record per-source provenance (resolvedRef + resolvedSha + optional pin) and + * per-skill content hash + timestamps into sync-state for the given install. + * + * The source key is the spec with any `@` suffix stripped so all installs + * of `owner/repo` map to one entry regardless of pin. * - * Silently no-ops for non-GitHub sources (local paths, marketplace shorthand) - * since we can't resolve a SHA from them. + * No-op for non-GitHub sources (local paths, marketplace shorthand) since we + * can't resolve a SHA from them. */ -async function recordSourceProvenance(opts: { +async function recordContentProvenance(opts: { from: string; - pinnedRef: string | undefined; + skills: string[]; + pinnedRef?: string | undefined; workspacePath: string; isUser: boolean; }): Promise { - const { from, pinnedRef, workspacePath, isUser } = opts; + const { from, skills, pinnedRef, workspacePath, isUser } = opts; if (!isGitHubUrl(from)) return; const parsed = parseGitHubUrl(from); if (!parsed) return; - // The fetch ran during install so this hits the cache and returns the - // resolvedSha without another git operation. const fetchResult = await fetchPlugin(from, { ...(parsed.branch && { branch: parsed.branch }), }); if (!fetchResult.success || !fetchResult.resolvedSha) return; - const targetPath = isUser ? getHomeDir() : workspacePath; + const stateRoot = isUser ? getHomeDir() : workspacePath; const key = stripGitRef(`${parsed.owner}/${parsed.repo}`); - await upsertSyncStateSource(targetPath, key, { + + await upsertSyncStateSource(stateRoot, key, { pluginSpec: key, resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', resolvedSha: fetchResult.resolvedSha, ...(pinnedRef && { pinnedRef }), }); + + // Resolve the plugin root inside the cached repo (handle subpath layouts). + const pluginRoot = parsed.subpath + ? join(fetchResult.cachePath, parsed.subpath) + : fetchResult.cachePath; + + const now = new Date().toISOString(); + for (const skillName of skills) { + const folder = resolveSkillFolder(pluginRoot, skillName); + if (!folder) continue; + const hash = await computeSkillFolderHash(folder); + if (!hash) continue; + await upsertSyncStateSkill(stateRoot, key, skillName, { + contentHash: hash, + installedAt: now, + updatedAt: now, + }); + } } /** @@ -1123,9 +1164,12 @@ const addCmd = command({ process.exit(1); } - // Record source provenance for the --all install path too. - await recordSourceProvenance({ + // Record provenance (resolved ref/SHA + optional pin + per-skill + // content hashes) for every skill installed via --all. + const everySkill = installResult.installed.flatMap((i) => i.skills); + await recordContentProvenance({ from: fromArg, + skills: everySkill, pinnedRef, workspacePath: workspacePathAll, isUser: isUserAll, @@ -1225,10 +1269,11 @@ const addCmd = command({ process.exit(1); } - // Record source provenance (resolved ref + SHA + optional pin) for - // drift detection on subsequent syncs. - await recordSourceProvenance({ + // Record per-source ref/SHA + optional pin + per-skill content + // hash for drift detection on subsequent syncs. + await recordContentProvenance({ from, + skills: [skill], pinnedRef, workspacePath, isUser, diff --git a/src/core/plugin.ts b/src/core/plugin.ts index c8c6bec..d95356e 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -57,8 +57,8 @@ export interface FetchDeps { /** * Resolve the HEAD commit SHA of a local repository. Returns undefined if the - * directory isn't a git repo or rev-parse fails (e.g., a cached marketplace - * subdirectory that was copied rather than cloned). + * directory isn't a git repo (e.g., a marketplace subdirectory that was + * copied rather than cloned) or rev-parse fails for any other reason. */ async function resolveHeadSha(repoPath: string): Promise { try { diff --git a/src/core/sync-state.ts b/src/core/sync-state.ts index 038b589..4a0d043 100644 --- a/src/core/sync-state.ts +++ b/src/core/sync-state.ts @@ -1,11 +1,13 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { createHash } from 'node:crypto'; +import { join, dirname, relative } from 'node:path'; import { CONFIG_DIR, SYNC_STATE_FILE } from '../constants.js'; import { SyncStateSchema, type SyncState, type SyncStateSource, + type SyncStateSkill, } from '../models/sync-state.js'; import type { ClientType } from '../models/workspace-config.js'; import { ensureConfigGitignore } from './config-gitignore.js'; @@ -90,7 +92,8 @@ export async function saveSyncState( ...(normalizedData.vscodeWorkspaceHash && { vscodeWorkspaceHash: normalizedData.vscodeWorkspaceHash }), ...(normalizedData.vscodeWorkspaceRepos && { vscodeWorkspaceRepos: normalizedData.vscodeWorkspaceRepos }), ...(normalizedData.skillsIndex && normalizedData.skillsIndex.length > 0 && { skillsIndex: normalizedData.skillsIndex }), - ...(normalizedData.sources && Object.keys(normalizedData.sources).length > 0 && { sources: normalizedData.sources }), + ...(normalizedData.sources && + Object.keys(normalizedData.sources).length > 0 && { sources: normalizedData.sources }), }; await mkdir(dirname(statePath), { recursive: true }); @@ -147,10 +150,51 @@ export function getPreviouslySyncedNativePlugins( } /** - * Upsert a single per-source provenance record into the workspace sync state. - * Preserves every other field of the existing sync-state file. + * Compute a deterministic content hash of a skill folder. * - * If no sync-state exists yet, a fresh one is created with only this entry. + * Algorithm: for every regular file under `skillDir` (recursive), + * compute `sha256(content)`. Sort by relative path (POSIX-style slashes) and + * fold each as `relPath + ":" + sha + "\n"` into a final sha256. The result is + * `"sha256:"`. + * + * Excludes file mtimes / inode metadata so the hash is reproducible across + * machines and re-clones. + * + * Returns null if the directory does not exist or is empty. + */ +export async function computeSkillFolderHash(skillDir: string): Promise { + if (!existsSync(skillDir)) return null; + const entries: Array<{ rel: string; sha: string }> = []; + + async function walk(dir: string): Promise { + const dirEntries = await readdir(dir, { withFileTypes: true }); + for (const entry of dirEntries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile()) { + const content = await readFile(full); + const sha = createHash('sha256').update(content).digest('hex'); + const rel = relative(skillDir, full).split(/[\\/]/).join('/'); + entries.push({ rel, sha }); + } + } + } + + await walk(skillDir); + if (entries.length === 0) return null; + + entries.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0)); + const outer = createHash('sha256'); + for (const { rel, sha } of entries) { + outer.update(`${rel}:${sha}\n`); + } + return `sha256:${outer.digest('hex')}`; +} + +/** + * Upsert a source provenance record into sync-state, preserving all other + * fields. Use when recording the resolved ref/SHA after a fetch. */ export async function upsertSyncStateSource( workspacePath: string, @@ -158,11 +202,38 @@ export async function upsertSyncStateSource( source: SyncStateSource, ): Promise { const existing = await loadSyncState(workspacePath); - const sources = { - ...(existing?.sources ?? {}), - [key]: source, + const sources = { ...(existing?.sources ?? {}), [key]: source }; + await persistMergedSyncState(workspacePath, existing, sources); +} + +/** + * Upsert per-skill provenance (contentHash + timestamps) under the given + * source key. Creates the source entry if it doesn't exist yet (with + * placeholder ref/sha — callers usually call upsertSyncStateSource first). + */ +export async function upsertSyncStateSkill( + workspacePath: string, + sourceKey: string, + skillName: string, + skill: SyncStateSkill, +): Promise { + const existing = await loadSyncState(workspacePath); + const currentSources = { ...(existing?.sources ?? {}) }; + const currentSource: SyncStateSource = currentSources[sourceKey] ?? { + pluginSpec: sourceKey, + resolvedRef: 'HEAD', + resolvedSha: '', }; + const updatedSkills = { ...(currentSource.skills ?? {}), [skillName]: skill }; + currentSources[sourceKey] = { ...currentSource, skills: updatedSkills }; + await persistMergedSyncState(workspacePath, existing, currentSources); +} +async function persistMergedSyncState( + workspacePath: string, + existing: SyncState | null, + sources: Record, +): Promise { await saveSyncState(workspacePath, { files: (existing?.files ?? {}) as Partial>, ...(existing?.mcpServers && { diff --git a/src/core/workspace-modify.ts b/src/core/workspace-modify.ts index a2356bd..09fa871 100644 --- a/src/core/workspace-modify.ts +++ b/src/core/workspace-modify.ts @@ -116,7 +116,7 @@ export async function addPlugin( // Handle plugin@marketplace format if (isPluginSpec(plugin)) { - const resolved = await resolvePluginSpecWithAutoRegister(plugin); + const resolved = await resolvePluginSpecWithAutoRegister(plugin, { workspacePath }); if (!resolved.success) { return { success: false, diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index fc5a51e..ce1b87b 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -1,10 +1,29 @@ import { z } from 'zod'; import { ClientTypeSchema } from './workspace-config.js'; +/** + * Per-skill content provenance: deterministic hash of the skill folder + * contents at install time, plus install/update timestamps. + * + * `contentHash` is `"sha256:"` where is sha256 over a sorted list + * of per-file `sha256(content)` digests. Excludes file mtimes so the value is + * reproducible across machines and clones. + * + * Field name `contentHash` is intentionally a strict subset of gh-skill's + * `skillFolderHash` so a future migration to `.skill-lock.json` is mechanical. + */ +export const SyncStateSkillSchema = z.object({ + contentHash: z.string(), + installedAt: z.string(), + updatedAt: z.string(), +}); + +export type SyncStateSkill = z.infer; + /** * Per-plugin source provenance: which ref was resolved when the plugin was - * installed, and (optionally) the explicit pin the user requested. Keys are - * indexed by plugin spec (e.g., "anthropics/superpowers" or "plugin@market"). + * installed, optional explicit pin, and a map of per-skill content hashes. + * Keys are indexed by canonical plugin spec (e.g., "owner/repo"). * * Field names are a strict subset of the gh-skill lockfile so a future * migration to `.skill-lock.json` is mechanical. @@ -14,6 +33,7 @@ export const SyncStateSourceSchema = z.object({ resolvedRef: z.string(), resolvedSha: z.string(), pinnedRef: z.string().optional(), + skills: z.record(z.string(), SyncStateSkillSchema).optional(), }); export type SyncStateSource = z.infer; @@ -36,7 +56,7 @@ export const SyncStateSchema = z.object({ vscodeWorkspaceRepos: z.array(z.string()).optional(), // Skills-index files tracked for cleanup (relative to .allagents/) skillsIndex: z.array(z.string()).optional(), - // Per-source resolved ref + SHA + optional pin (drift / lockfile data). + // Per-source resolved ref + SHA + optional pin + per-skill content hashes. sources: z.record(z.string(), SyncStateSourceSchema).optional(), });