diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 51fcaa8b..6d7208ff 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -30,8 +30,9 @@ import { skillsAddMeta, } from '../metadata/plugin-skills.js'; import { getHomeDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../constants.js'; -import { isGitHubUrl, parseGitHubUrl } from '../../utils/plugin-path.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 { parseSkillMetadata } from '../../validators/skill.js'; import { addMarketplace, @@ -60,6 +61,60 @@ function resolveScope(cwd: string): 'user' | 'project' { return 'user'; } +/** + * 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. + * + * Silently no-ops for non-GitHub sources (local paths, marketplace shorthand) + * since we can't resolve a SHA from them. + */ +async function recordSourceProvenance(opts: { + from: string; + pinnedRef: string | undefined; + workspacePath: string; + isUser: boolean; +}): Promise { + const { from, 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 key = stripGitRef(`${parsed.owner}/${parsed.repo}`); + await upsertSyncStateSource(targetPath, key, { + pluginSpec: key, + resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', + resolvedSha: fetchResult.resolvedSha, + ...(pinnedRef && { pinnedRef }), + }); +} + +/** + * Extract the inline `@` suffix from a plugin source spec, if present. + * Only matches owner/repo-style sources (must have a slash before the `@`), + * so `plugin@marketplace` returns undefined. + */ +function extractInlineRef(spec: string): string | undefined { + const slashIdx = spec.indexOf('/'); + if (slashIdx === -1) return undefined; + const atIdx = spec.indexOf('@', slashIdx); + if (atIdx === -1) return undefined; + const ref = spec.slice(atIdx + 1); + // If the @ref contains another slash, it's the subpath portion; the ref is + // the chunk between @ and the next slash. + const nextSlash = ref.indexOf('/'); + const cleanRef = nextSlash === -1 ? ref : ref.slice(0, nextSlash); + return cleanRef.length > 0 ? cleanRef : undefined; +} + /** * If the skill argument is a GitHub URL, extract the skill name and return * it along with the URL as the plugin source. Returns null if not a URL. @@ -905,6 +960,11 @@ const addCmd = command({ short: 'f', description: 'Plugin source to install if the skill is not already available', }), + pin: option({ + type: optional(string), + long: 'pin', + description: 'Pin the plugin to a specific Git ref (tag, branch, or SHA). Mutually exclusive with inline @ref in --from.', + }), list: flag({ long: 'list', short: 'l', @@ -915,8 +975,43 @@ const addCmd = command({ description: 'Install every skill from --from', }), }, - handler: async ({ skill: skillArg, scope, plugin, from: fromArg, list, all }) => { + handler: async ({ skill: skillArg, scope, plugin, from: fromArg, pin, list, all }) => { try { + // Resolve --pin together with inline @ref. Three legal states: + // • --pin only → splice into fromArg + // • inline @ref → leave fromArg alone, remember pinnedRef + // • neither → no pin + // Mutex: --pin combined with inline @ref is rejected. + let pinnedRef: string | undefined; + if (pin || fromArg) { + const inlineRef = fromArg ? extractInlineRef(fromArg) : undefined; + if (pin && inlineRef) { + const error = 'Cannot combine inline @version in --from with --pin. Use one or the other.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + if (pin && fromArg) { + // Splice the pin into the source string so downstream parseGitHubUrl + // picks it up as the branch/tag. + fromArg = `${fromArg}@${pin}`; + pinnedRef = pin; + } else if (inlineRef) { + pinnedRef = inlineRef; + } else if (pin && !fromArg) { + const error = '--pin requires --from to specify a plugin source.'; + if (isJsonMode()) { + jsonOutput({ success: false, command: 'skill add', error }); + process.exit(1); + } + console.error(`Error: ${error}`); + process.exit(1); + } + } + // --list: dry-run discovery, no workspace changes if (list) { if (skillArg) { @@ -1028,6 +1123,14 @@ const addCmd = command({ process.exit(1); } + // Record source provenance for the --all install path too. + await recordSourceProvenance({ + from: fromArg, + pinnedRef, + workspacePath: workspacePathAll, + isUser: isUserAll, + }); + if (isJsonMode()) { jsonOutput({ success: true, @@ -1039,6 +1142,7 @@ const addCmd = command({ copied: installResult.syncResult.totalCopied, failed: installResult.syncResult.totalFailed, }, + ...(pinnedRef && { pinnedRef }), }, }); return; @@ -1121,6 +1225,15 @@ const addCmd = command({ process.exit(1); } + // Record source provenance (resolved ref + SHA + optional pin) for + // drift detection on subsequent syncs. + await recordSourceProvenance({ + from, + pinnedRef, + workspacePath, + isUser, + }); + if (isJsonMode()) { jsonOutput({ success: true, @@ -1129,11 +1242,15 @@ const addCmd = command({ skill, plugin: installFromResult.pluginName, syncResult: installFromResult.syncResult, + ...(pinnedRef && { pinnedRef }), }, }); return; } + if (pinnedRef) { + console.log(`Pinned to ${pinnedRef}.`); + } console.log('Sync complete.'); return; } diff --git a/src/core/marketplace.ts b/src/core/marketplace.ts index 250e616e..2f1872c3 100644 --- a/src/core/marketplace.ts +++ b/src/core/marketplace.ts @@ -1243,12 +1243,25 @@ async function autoRegisterMarketplace( /** * Check if a spec is in plugin@marketplace format + * + * `plugin@marketplace` → true (plain marketplace shorthand) + * `plugin@owner/repo[/sub]` → true (marketplace by GitHub repo) + * `owner/repo@ref[/sub]` → false (GitHub plugin with `@ref` version pin) + * + * The disambiguation: when the segment before the last `@` itself contains a + * `/`, the spec is an `owner/repo` GitHub URL with a `@ref` pin, not a + * plugin@marketplace pair. Plugin names in marketplaces are bare identifiers + * without slashes. */ export function isPluginSpec(spec: string): boolean { const atIndex = spec.lastIndexOf('@'); if (atIndex === -1 || atIndex === 0 || atIndex === spec.length - 1) { return false; } + const beforeAt = spec.slice(0, atIndex); + if (beforeAt.includes('/')) { + return false; + } return true; } diff --git a/src/core/plugin.ts b/src/core/plugin.ts index a61c5a12..c8c6bec8 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -1,6 +1,7 @@ import { mkdir, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; +import simpleGit from 'simple-git'; import { parseGitHubUrl, getPluginCachePath, @@ -28,6 +29,10 @@ export interface FetchResult { error?: string; /** Duration of the git operation in milliseconds */ durationMs?: number; + /** The ref (branch/tag) the fetch resolved against, if known. */ + resolvedRef?: string; + /** Resolved commit SHA of the cached working tree, if known. */ + resolvedSha?: string; } /** @@ -50,6 +55,21 @@ export interface FetchDeps { pull?: typeof pull; } +/** + * 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). + */ +async function resolveHeadSha(repoPath: string): Promise { + try { + const sha = await simpleGit(repoPath).revparse(['HEAD']); + const trimmed = sha.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } catch { + return undefined; + } +} + // Deduplicates git operations for the same cache directory within a sync session. // Both concurrent and sequential callers targeting the same repo reuse the // result of the first git operation. Call `resetFetchCache()` between sync @@ -197,9 +217,24 @@ async function doFetchPlugin( const pullStart = performance.now(); await pullFn(cachePath); const pullMs = Math.round(performance.now() - pullStart); - return { success: true, action: 'updated', cachePath, durationMs: pullMs }; + const sha = await resolveHeadSha(cachePath); + return { + success: true, + action: 'updated', + cachePath, + durationMs: pullMs, + ...(branch && { resolvedRef: branch }), + ...(sha && { resolvedSha: sha }), + }; } catch { - return { success: true, action: 'skipped', cachePath }; + const sha = await resolveHeadSha(cachePath); + return { + success: true, + action: 'skipped', + cachePath, + ...(branch && { resolvedRef: branch }), + ...(sha && { resolvedSha: sha }), + }; } } @@ -212,12 +247,15 @@ async function doFetchPlugin( const cloneStart = performance.now(); await cloneToFn(repoUrl, cachePath, branch); const cloneMs = Math.round(performance.now() - cloneStart); + const sha = await resolveHeadSha(cachePath); return { success: true, action: 'fetched', cachePath, durationMs: cloneMs, + ...(branch && { resolvedRef: branch }), + ...(sha && { resolvedSha: sha }), }; } catch (error) { if (error instanceof GitCloneError) { @@ -339,12 +377,21 @@ export async function updateCachedPlugins( } /** - * Get the plugin name from the directory name + * Get the plugin name from the directory name. + * + * Cache directories for branch/tag-pinned clones use the form + * `-@`. The pin suffix is part of the on-disk + * layout for collision avoidance, but the logical plugin name is the + * base — strip the suffix so callers that key workspace.yaml entries by + * plugin name (e.g., setPluginSkillsMode) match against the unpinned form. + * * @param pluginPath - Resolved path to the plugin directory - * @returns The plugin name (directory basename) + * @returns The plugin name (directory basename, without any `@ref` suffix) */ export function getPluginName(pluginPath: string): string { - return basename(pluginPath); + const base = basename(pluginPath); + const atIdx = base.indexOf('@'); + return atIdx === -1 ? base : base.slice(0, atIdx); } /** diff --git a/src/core/sync-state.ts b/src/core/sync-state.ts index 5db63a2d..038b5891 100644 --- a/src/core/sync-state.ts +++ b/src/core/sync-state.ts @@ -2,7 +2,11 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { CONFIG_DIR, SYNC_STATE_FILE } from '../constants.js'; -import { SyncStateSchema, type SyncState } from '../models/sync-state.js'; +import { + SyncStateSchema, + type SyncState, + type SyncStateSource, +} from '../models/sync-state.js'; import type { ClientType } from '../models/workspace-config.js'; import { ensureConfigGitignore } from './config-gitignore.js'; @@ -19,6 +23,7 @@ export interface SyncStateData { vscodeWorkspaceHash?: string; vscodeWorkspaceRepos?: string[]; skillsIndex?: string[]; + sources?: Record; } /** @@ -85,6 +90,7 @@ 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 }), }; await mkdir(dirname(statePath), { recursive: true }); @@ -139,3 +145,39 @@ export function getPreviouslySyncedNativePlugins( if (!state?.nativePlugins) return []; return state.nativePlugins[client] ?? []; } + +/** + * Upsert a single per-source provenance record into the workspace sync state. + * Preserves every other field of the existing sync-state file. + * + * If no sync-state exists yet, a fresh one is created with only this entry. + */ +export async function upsertSyncStateSource( + workspacePath: string, + key: string, + source: SyncStateSource, +): Promise { + const existing = await loadSyncState(workspacePath); + const sources = { + ...(existing?.sources ?? {}), + [key]: source, + }; + + await saveSyncState(workspacePath, { + files: (existing?.files ?? {}) as Partial>, + ...(existing?.mcpServers && { + mcpServers: existing.mcpServers as Partial>, + }), + ...(existing?.nativePlugins && { + nativePlugins: existing.nativePlugins as Partial>, + }), + ...(existing?.vscodeWorkspaceHash && { + vscodeWorkspaceHash: existing.vscodeWorkspaceHash, + }), + ...(existing?.vscodeWorkspaceRepos && { + vscodeWorkspaceRepos: existing.vscodeWorkspaceRepos, + }), + ...(existing?.skillsIndex && { skillsIndex: existing.skillsIndex }), + sources, + }); +} diff --git a/src/core/sync.ts b/src/core/sync.ts index 11e03a59..f7ec7d52 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -19,6 +19,7 @@ import type { } from '../models/workspace-config.js'; import { getPluginClients, + getPluginPin, getPluginSource, getPluginExclude, getClientTypes, @@ -30,6 +31,7 @@ import { isGitHubUrl, parseGitHubUrl, parseFileSource, + stripGitRef, } from '../utils/plugin-path.js'; import { fetchPlugin, getPluginName, seedFetchCache } from './plugin.js'; import { @@ -75,7 +77,7 @@ import { getPreviouslySyncedMcpServers, getPreviouslySyncedNativePlugins, } from './sync-state.js'; -import type { SyncState } from '../models/sync-state.js'; +import type { SyncState, SyncStateSource } from '../models/sync-state.js'; import { getUserWorkspaceConfig, migrateUserWorkspaceSkillsV1toV2, @@ -1213,7 +1215,14 @@ export function buildPluginSyncPlans( const workspaceClientTypes = getClientTypes(clientEntries); const plans = plugins.map((plugin) => { - const source = getPluginSource(plugin); + const rawSource = getPluginSource(plugin); + const pin = getPluginPin(plugin); + // Apply the optional `pin:` workspace.yaml field by splicing `@` into + // GitHub-shaped sources. Marketplace and local sources ignore the pin + // since they don't go through fetchPlugin. + const source = pin && isGitHubUrl(rawSource) && !rawSource.includes('@') + ? `${rawSource}@${pin}` + : rawSource; const pluginClientTypes = getPluginClients(plugin) ?? workspaceClientTypes; if (pluginClientTypes.length === 0) { @@ -1836,6 +1845,53 @@ async function syncVscodeWorkspaceFile( return { config: updatedConfig, hash, repos }; } +/** + * Build the `sources` block for sync-state from validated plugins. Each + * GitHub-shaped source contributes one entry keyed by `owner/repo` (without + * the `@` suffix). Local plugins and marketplace specs are skipped since + * they have no remote ref to record. + */ +async function buildSourcesProvenance( + validatedPlugins: ValidatedPlugin[], + pluginEntries: PluginEntry[], +): Promise> { + const sources: Record = {}; + + // Index user-declared pins by their raw source string so we can attach them + // to the matching validated plugin (whose `.plugin` may have `@pin` spliced in). + const pinByRawSource = new Map(); + for (const entry of pluginEntries) { + if (typeof entry === 'string') continue; + if (entry.pin) pinByRawSource.set(entry.source, entry.pin); + } + + for (const validated of validatedPlugins) { + if (!validated.success) continue; + const spec = validated.plugin; + if (!isGitHubUrl(spec)) continue; + const parsed = parseGitHubUrl(spec); + if (!parsed) continue; + + // Re-call fetchPlugin to pick up the cached resolvedSha / resolvedRef. + const fetchResult = await fetchPlugin(spec, { + ...(parsed.branch && { branch: parsed.branch }), + }); + if (!fetchResult.success || !fetchResult.resolvedSha) continue; + + const rawBase = stripGitRef(`${parsed.owner}/${parsed.repo}`); + const pinned = pinByRawSource.get(rawBase) ?? parsed.branch; + + sources[rawBase] = { + pluginSpec: rawBase, + resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', + resolvedSha: fetchResult.resolvedSha, + ...(pinned && { pinnedRef: pinned }), + }; + } + + return sources; +} + async function persistSyncState( workspacePath: string, pluginResults: PluginSyncResult[], @@ -1848,6 +1904,7 @@ async function persistSyncState( mcpTrackedServers?: Partial>; clientMappings?: Record; skillsIndex?: string[]; + sources?: Record; }, ): Promise { const allCopyResults: CopyResult[] = [ @@ -1894,6 +1951,8 @@ async function persistSyncState( ...(extra?.mcpTrackedServers && { mcpServers: extra.mcpTrackedServers }), ...(extra?.skillsIndex && extra.skillsIndex.length > 0 && { skillsIndex: extra.skillsIndex }), + ...(extra?.sources && + Object.keys(extra.sources).length > 0 && { sources: extra.sources }), }); } @@ -2322,6 +2381,7 @@ export async function syncWorkspace( const { pluginsByClient: nativePluginsByClient } = collectNativePluginSources(validPlugins); if (!dryRun) { + const sources = await buildSourcesProvenance(validPlugins, config.plugins); await sw.measure('persist-state', () => persistSyncState( workspacePath, @@ -2343,6 +2403,7 @@ export async function syncWorkspace( ...(writtenSkillsIndexFiles.length > 0 && { skillsIndex: writtenSkillsIndexFiles, }), + ...(Object.keys(sources).length > 0 && { sources }), }, ), ); diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index 3b2bb25b..fc5a51e2 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -1,6 +1,23 @@ import { z } from 'zod'; import { ClientTypeSchema } from './workspace-config.js'; +/** + * 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"). + * + * Field names are a strict subset of the gh-skill lockfile so a future + * migration to `.skill-lock.json` is mechanical. + */ +export const SyncStateSourceSchema = z.object({ + pluginSpec: z.string(), + resolvedRef: z.string(), + resolvedSha: z.string(), + pinnedRef: z.string().optional(), +}); + +export type SyncStateSource = z.infer; + /** * Sync state schema - tracks which files were synced per client * Used for non-destructive sync (only purge files we previously created) @@ -19,6 +36,8 @@ 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). + sources: z.record(z.string(), SyncStateSourceSchema).optional(), }); export type SyncState = z.infer; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 490d6eb6..34134697 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -188,6 +188,12 @@ export const PluginEntrySchema = z.union([ install: InstallModeSchema.optional(), exclude: z.array(z.string()).optional(), skills: PluginSkillsConfigSchema.optional(), + /** + * Optional Git ref (tag/branch/SHA) to pin the plugin to. Equivalent to + * passing the `owner/repo@` shorthand on install. When set, every + * sync resolves the plugin at this ref instead of the default branch. + */ + pin: z.string().optional(), }), ]); @@ -226,6 +232,14 @@ export function getPluginExclude(plugin: PluginEntry): string[] | undefined { return typeof plugin === 'string' ? undefined : plugin.exclude; } +/** + * Get the pinned Git ref for a plugin entry (if any). Returns undefined for + * both the string-shorthand form and object entries without `pin:`. + */ +export function getPluginPin(plugin: PluginEntry): string | undefined { + return typeof plugin === 'string' ? undefined : plugin.pin; +} + /** * Normalize a client entry to { name, install } form. */ diff --git a/src/utils/plugin-path.ts b/src/utils/plugin-path.ts index 4140b332..bbaaba7d 100644 --- a/src/utils/plugin-path.ts +++ b/src/utils/plugin-path.ts @@ -14,6 +14,31 @@ import { getHomeDir } from '../constants.js'; */ export type PluginSourceType = 'github' | 'local'; +/** + * Strip an inline `@` pin suffix from a plugin spec. + * + * `owner/repo@v1.2.0` → `owner/repo` + * `owner/repo@main/sub` → `owner/repo/sub` (the subpath is preserved) + * `plugin@marketplace` → unchanged (no slash before `@`, so the `@` is a marketplace marker) + * `owner/repo` → unchanged + * + * Used as the canonical key for sync-state's `sources` block so the same + * plugin keyed across different installs maps to one provenance entry. + */ +export function stripGitRef(spec: string): string { + const slash = spec.indexOf('/'); + if (slash === -1) return spec; // `name@marketplace` + const parts = spec.split('/'); + const ownerSeg = parts[0]; + const repoSeg = parts[1]; + if (!ownerSeg || !repoSeg) return spec; + const atIdx = repoSeg.indexOf('@'); + if (atIdx === -1) return spec; + const cleanRepo = repoSeg.slice(0, atIdx); + const rest = parts.slice(2); + return rest.length === 0 ? `${ownerSeg}/${cleanRepo}` : `${ownerSeg}/${cleanRepo}/${rest.join('/')}`; +} + /** * Parsed plugin source information */ @@ -49,7 +74,8 @@ export function isGitHubUrl(source: string): boolean { return true; } - // Shorthand: owner/repo or owner/repo/subpath + // Shorthand: owner/repo or owner/repo/subpath, optionally suffixed with @ref + // (e.g. owner/repo@v1.2.0 or owner/repo@main/subpath). // Must not start with . or / (local paths) and must contain at least one / // Also must not look like a Windows path (C:/) or contain backslashes if ( @@ -59,12 +85,15 @@ export function isGitHubUrl(source: string): boolean { !/^[a-zA-Z]:/.test(source) && source.includes('/') ) { - // Check if it looks like owner/repo format (alphanumeric, hyphens, underscores, dots) - // GitHub allows dots in repo names (e.g., WTG.AI.Prompts) + // Check if it looks like owner/repo format (alphanumeric, hyphens, underscores, dots). + // GitHub allows dots in repo names (e.g., WTG.AI.Prompts). + // For pinning, the repo segment may carry an @ref suffix — strip it before validating. const parts = source.split('/'); if (parts.length >= 2 && parts[0] && parts[1]) { const validOwnerRepo = /^[a-zA-Z0-9_.-]+$/; - if (validOwnerRepo.test(parts[0]) && validOwnerRepo.test(parts[1])) { + const atIdx = parts[1].indexOf('@'); + const repoPart = atIdx === -1 ? parts[1] : parts[1].slice(0, atIdx); + if (validOwnerRepo.test(parts[0]) && validOwnerRepo.test(repoPart)) { return true; } } @@ -102,26 +131,32 @@ export function parseGitHubUrl( } // Handle shorthand: owner/repo or owner/repo/subpath (no protocol, no github.com) + // Also accept an optional @ref suffix on the repo segment for version pinning: + // owner/repo@v1.2.0 + // owner/repo@main/subpath + // Distinguishing from `name@marketplace`: that form has no slash, so isGitHubUrl + // rejects it before this function is reached. if (!normalized.includes('://') && !normalized.startsWith('github.com')) { const parts = normalized.split('/'); if (parts.length >= 2) { const owner = parts[0]; - const repo = parts[1]; - // Allow dots in repo names (e.g., WTG.AI.Prompts) + const rawRepo = parts[1]; const validOwnerRepo = /^[a-zA-Z0-9_.-]+$/; - if ( - owner && - repo && - validOwnerRepo.test(owner) && - validOwnerRepo.test(repo) - ) { - if (parts.length > 2) { - // Has subpath: owner/repo/path/to/plugin - const subpath = parts.slice(2).join('/'); - return { owner, repo, subpath }; - } - return { owner, repo }; + if (!owner || !rawRepo || !validOwnerRepo.test(owner)) return null; + + // Split @ref off the repo segment, if present. + const atIdx = rawRepo.indexOf('@'); + const repo = atIdx === -1 ? rawRepo : rawRepo.slice(0, atIdx); + const branch = atIdx === -1 ? undefined : rawRepo.slice(atIdx + 1) || undefined; + if (!validOwnerRepo.test(repo)) return null; + + if (parts.length > 2) { + const subpath = parts.slice(2).join('/'); + return branch + ? { owner, repo, branch, subpath } + : { owner, repo, subpath }; } + return branch ? { owner, repo, branch } : { owner, repo }; } return null; } diff --git a/tests/unit/utils/plugin-path.test.ts b/tests/unit/utils/plugin-path.test.ts index 42602828..1adbb70b 100644 --- a/tests/unit/utils/plugin-path.test.ts +++ b/tests/unit/utils/plugin-path.test.ts @@ -178,6 +178,33 @@ describe('parseGitHubUrl', () => { expect(parseGitHubUrl('not-a-url')).toBeNull(); expect(parseGitHubUrl('')).toBeNull(); }); + + it('should parse shorthand owner/repo@ref version pin', () => { + expect(parseGitHubUrl('owner/repo@v1.2.0')).toEqual({ + owner: 'owner', + repo: 'repo', + branch: 'v1.2.0', + }); + }); + + it('should parse owner/repo@ref/subpath with both pin and subpath', () => { + expect(parseGitHubUrl('owner/repo@main/plugins/foo')).toEqual({ + owner: 'owner', + repo: 'repo', + branch: 'main', + subpath: 'plugins/foo', + }); + }); +}); + +describe('isGitHubUrl with @ref pin', () => { + it('accepts owner/repo@ref shorthand', () => { + expect(isGitHubUrl('owner/repo@v1.2.0')).toBe(true); + }); + + it('still rejects plugin@marketplace (no slash before @)', () => { + expect(isGitHubUrl('plugin@marketplace')).toBe(false); + }); }); describe('normalizePluginPath', () => {