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
81 changes: 63 additions & 18 deletions src/cli/commands/plugin-skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `@<ref>` 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 `@<ref>` 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<void> {
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,
});
}
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/core/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
try {
Expand Down
89 changes: 80 additions & 9 deletions src/core/sync-state.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -147,22 +150,90 @@ 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:<hex>"`.
*
* 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<string | null> {
if (!existsSync(skillDir)) return null;
const entries: Array<{ rel: string; sha: string }> = [];

async function walk(dir: string): Promise<void> {
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,
key: string,
source: SyncStateSource,
): Promise<void> {
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<void> {
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<string, SyncStateSource>,
): Promise<void> {
await saveSyncState(workspacePath, {
files: (existing?.files ?? {}) as Partial<Record<ClientType, string[]>>,
...(existing?.mcpServers && {
Expand Down
2 changes: 1 addition & 1 deletion src/core/workspace-modify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 23 additions & 3 deletions src/models/sync-state.ts
Original file line number Diff line number Diff line change
@@ -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:<hex>"` where <hex> 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<typeof SyncStateSkillSchema>;

/**
* 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.
Expand All @@ -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<typeof SyncStateSourceSchema>;
Expand All @@ -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(),
});

Expand Down