From 9bf9d9884dc449bcdf4f9616a44323c0c6c3a839 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 10:48:47 +1000 Subject: [PATCH] feat(config): rename plugin pin to ref --- CHANGELOG.md | 8 ++ ...eat-skill-update-deletion-handling-plan.md | 8 +- .../docs/docs/reference/configuration.mdx | 20 +++++ src/cli/commands/plugin-skills.ts | 74 +++++++++---------- src/cli/metadata/plugin-skills.ts | 8 ++ src/core/marketplace.ts | 9 ++- src/core/plugin.ts | 6 +- src/core/sync.ts | 12 +-- src/models/sync-state.ts | 11 ++- src/models/workspace-config.ts | 27 +++---- src/utils/plugin-path.ts | 6 +- .../cli/skill-update-reconciliation.test.ts | 4 +- tests/unit/core/skill-update.test.ts | 32 ++++---- tests/unit/models/workspace-config.test.ts | 20 +++++ tests/unit/utils/plugin-path.test.ts | 6 +- 15 files changed, 154 insertions(+), 97 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92ea44..163a80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ **Migration**: Re-run `allagents mcp update` or `allagents update` after upgrading so synced client configs are regenerated with `mcp proxy`. +- **Plugin Git ref terminology**: Renamed workspace plugin `pin` to `ref`, CLI + `--pin` to `--ref`, and sync-state `pinnedRef` to `requestedRef`. Inline + `owner/repo@ref` sources are unchanged. + + **Migration**: Replace `pin:` with `ref:` in plugin objects and `--pin` with + `--ref` in scripts. This is a clean cutover; the old names are not accepted. + + ## [1.0.0] - 2026-03-13 ### Breaking Changes diff --git a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md index 87fdb96..22f9185 100644 --- a/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md +++ b/docs/plans/2026-08-10-001-feat-skill-update-deletion-handling-plan.md @@ -40,7 +40,7 @@ Skill installations are source-driven: one plugin or repository can provide seve **Deletion safety** -- R4. Before persistent refresh, AllAgents compares its enabled pre-update skill inventory with full-depth discovery from a temporary checkout of each physical refresh unit. Inventory is derived from raw plugin entries so object-form `pin`, inline refs, marketplace plugin selection, configured selector, scope, and qualified skill subpath are retained. A unit is the connected dependency graph of canonical remote/ref/cache nodes required to resolve its entries: one node for a direct source, a marketplace node for embedded entries, or marketplace plus external-repository nodes for external entries. Shared nodes join aliases, marketplace siblings, and cross-scope installs into one decision boundary. +- R4. Before persistent refresh, AllAgents compares its enabled pre-update skill inventory with full-depth discovery from a temporary checkout of each physical refresh unit. Inventory is derived from raw plugin entries so object-form `ref`, inline refs, marketplace plugin selection, configured selector, scope, and qualified skill subpath are retained. A unit is the connected dependency graph of canonical remote/ref/cache nodes required to resolve its entries: one node for a direct source, a marketplace node for embedded entries, or marketplace plus external-repository nodes for external entries. Shared nodes join aliases, marketplace siblings, and cross-scope installs into one decision boundary. - R5. Fetch, authentication, malformed manifest, missing declared root, and incomplete discovery are reported as source failures and never interpreted as upstream deletions. An existing resolved root with a valid empty inventory may classify installed skills as deleted. A valid marketplace that no longer declares an installed plugin is a distinct authoritative removal: confirmation explicitly removes that entire config entry and every AllAgents-managed artifact from it, not only its skills; declining retains the old marketplace checkout and local artifacts. - R6. Interactive deletion candidates are grouped by physical refresh unit, list every affected selected-scope installation, and require one affirmative confirmation per unit. The prompt states that Yes removes the named skills and updates survivors, while No keeps them and skips every update backed by that unit. Marketplace-entry removal uses separate copy naming the whole plugin entry and managed-artifact consequence. - R7. Confirmed deletions use an atomic per-scope/per-refresh-unit configuration transform, preserving unrelated plugin-entry fields and user-owned client files. All new config bytes are prepared before any rename; on any transform, write, or exact-revision checkout-node update failure, every config and cache node belonging to that unit is restored to its original bytes/revision and the unit is not synced. @@ -93,7 +93,7 @@ Skill installations are source-driven: one plugin or repository can provide seve - KTD1. **Preflight in temporary checkouts.** Resolve each unique remote source into a disposable checkout and discover skill paths there before touching persistent caches. This preserves the meaning of a No answer under symlink installs and follows the safety shape of `vercel-labs/skills` without copying its lock-file model. - KTD2. **Skip an affected source on retention.** AllAgents cannot update surviving skills from a repository-level cache while truthfully retaining a deleted symlinked skill without adding a new archival subsystem. Skipping only that source is the smallest deterministic ownership-safe behavior. -- KTD3. **Treat the connected checkout graph as the transaction boundary.** Build nodes from canonical remote, effective ref/pin, and cache path; connect marketplace manifests to embedded or external plugin checkouts; then attach every project/user/plugin entry consuming any shared node. A retained or out-of-scope impacted entry blocks the entire connected unit. +- KTD3. **Treat the connected checkout graph as the transaction boundary.** Build nodes from canonical remote, effective ref, and cache path; connect marketplace manifests to embedded or external plugin checkouts; then attach every project/user/plugin entry consuming any shared node. A retained or out-of-scope impacted entry blocks the entire connected unit. - KTD4. **Separate orchestration from presentation.** A core/service result describes per-scope, per-refresh-unit, and per-skill outcomes. The cmd-ts handler owns Clack prompts and human/JSON rendering, enabling focused unit tests without terminal coupling. - KTD5. **Batch through configuration ownership.** Prepare every selector mutation for a refresh unit in memory, validate the complete result, then replace affected config files atomically. Do not recursively invoke `skill remove`, which would repeat scans, prompts, syncs, and outros. - KTD6. **Safe automation retains.** Match upstream's important safety semantic: `--yes` means no questions, not permission to delete. JSON and non-TTY execution follow the same retention rule. @@ -165,7 +165,7 @@ flowchart TB - A marketplace plugin may be embedded or point to an external repository. Resolve the temporary discovery root with the same manifest rules as persistent update and test both shapes. - External marketplace entries form a compound unit. Record marketplace and external checkout SHAs, advance external nodes before the manifest node, and roll back changed nodes in reverse order on failure. - Direct GitHub subpaths, marketplace siblings, and project/user entries can share one cache checkout. Canonicalize the physical unit and inventory both scopes before making any decision. -- Object `pin` and inline `@ref` can select a different revision than the raw source. Share one effective-source resolver with normal sync and make the ref part of the unit key. +- Object-form and inline `@ref` selectors can choose a different revision than the raw source. Share one effective-source resolver with normal sync and make the ref part of the unit key. - Snapshot discovery can seed the process-level fetch cache with offline results. Keep preflight cache-independent or reset the fetch cache before persistent refresh. - A successful fresh clone can currently collapse to a skipped plugin-update result. Decide sync eligibility from successful accepted refresh work, not only `action === updated`. - Multiple removed siblings can leave an empty allowlist if each helper sees the original snapshot. Compute the whole config transform in memory, write once, and test last-skill removal. @@ -211,7 +211,7 @@ flowchart TB - **Test scenarios:** - Multiple allowlisted deletions remove selectors sequentially; the last deletion preserves a source with commands/hooks/MCP through an empty allowlist and removes only a standalone skill source. - A qualified nested selector is removed without affecting another skill with the same leaf name. - - Object fields for clients, install mode, artifact exclusions, and pin survive partial pruning. + - Object fields for clients, install mode, artifact exclusions, and ref survive partial pruning. - A blocklist/implicit source does not gain an exclusion for an upstream-absent skill. - A confirmed marketplace-entry removal deletes the complete plugin config entry, preserves unrelated entries, and lets an empty-plan offline sync purge its previously tracked artifacts. - A validation/write failure in the second deletion leaves all config files in that refresh unit unchanged and prevents cache refresh, while unrelated units continue. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index 5507a5d..3e6cf64 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -92,6 +92,26 @@ allagents plugin install superpowers@marketplace --skill brainstorming The top-level `disabledSkills` and `enabledSkills` arrays are deprecated (v1 format). They still work as a fallback for configs that haven't migrated, but new configs should use the inline `skills` field on plugin entries instead. ::: +### Plugin Git Refs + +GitHub plugin entries can select a release tag or branch with `ref`: + +```yaml +plugins: + - source: https://github.com/EveryInc/compound-engineering-plugin + ref: compound-engineering-v3.23.3 +``` + +This is equivalent to the inline `owner/repo@` source form. An inline ref +takes precedence, so do not configure both forms on the same entry. Local paths, +marketplace plugin selectors, and GitHub `/tree/` or `/blob/` URLs already carry +their own location and ignore object-form `ref`. + +`ref` selects a symbolic Git ref; it is not a content-integrity lock. Sync state +records the resolved commit SHA for provenance, but AllAgents does not currently +reject a tag or branch that later resolves to a different commit. + + ### Workspace File Sync The `workspace` section enables syncing files from external sources to your workspace root. diff --git a/src/cli/commands/plugin-skills.ts b/src/cli/commands/plugin-skills.ts index 8d58077..776934b 100644 --- a/src/cli/commands/plugin-skills.ts +++ b/src/cli/commands/plugin-skills.ts @@ -97,24 +97,24 @@ function resolveScope(cwd: string): 'user' | 'project' { } /** - * Record per-source provenance (resolvedRef + resolvedSha + optional pin) + * Record per-source provenance (resolvedRef + resolvedSha + optional requested ref) * into sync-state for the given install. Identity for git-based plugins is * `url + ref`; `resolvedSha` (from `git rev-parse HEAD` after fetch) gives * content identity, so per-skill content hashing is unnecessary. * * The source key is the spec with any `@` suffix stripped so all installs - * of `owner/repo` map to one entry regardless of pin. + * of `owner/repo` map to one entry regardless of requested ref. * * No-op 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; + requestedRef?: string | undefined; workspacePath: string; isUser: boolean; }): Promise { - const { from, pinnedRef, workspacePath, isUser } = opts; + const { from, requestedRef, workspacePath, isUser } = opts; if (!isGitHubUrl(from)) return; const parsed = parseGitHubUrl(from); if (!parsed) return; @@ -131,7 +131,7 @@ async function recordSourceProvenance(opts: { pluginSpec: key, resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', resolvedSha: fetchResult.resolvedSha, - ...(pinnedRef && { pinnedRef }), + ...(requestedRef && { requestedRef }), }); } @@ -1699,11 +1699,11 @@ const addCmd = command({ description: 'Comma-separated skill names to install when the positional argument is a plugin source (e.g., owner/repo --skill foo,bar)', }), - pin: option({ + ref: option({ type: optional(string), - long: 'pin', + long: 'ref', description: - 'Pin the plugin to a specific Git ref (tag, branch, or SHA). Mutually exclusive with inline @ref in --from.', + 'Git ref to use for the plugin (tag or branch). Mutually exclusive with inline @ref in --from.', }), list: flag({ long: 'list', @@ -1721,7 +1721,7 @@ const addCmd = command({ plugin, from: fromArg, skillFlag, - pin, + ref, list, all, }) => { @@ -1766,17 +1766,17 @@ const addCmd = command({ process.exit(1); } - // 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) { + // Resolve --ref together with inline @ref. Three legal states: + // • --ref only → splice into fromArg + // • inline @ref → leave fromArg alone, remember requestedRef + // • neither → use the source's default branch + // Mutex: --ref combined with inline @ref is rejected. + let requestedRef: string | undefined; + if (ref || fromArg) { const inlineRef = fromArg ? extractInlineRef(fromArg) : undefined; - if (pin && inlineRef) { + if (ref && inlineRef) { const error = - 'Cannot combine inline @version in --from with --pin. Use one or the other.'; + 'Cannot combine inline @ref in --from with --ref. Use one or the other.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1784,15 +1784,15 @@ const addCmd = command({ console.error(`Error: ${error}`); process.exit(1); } - if (pin && fromArg) { - // Splice the pin into the source string so downstream parseGitHubUrl + if (ref && fromArg) { + // Splice the ref into the source string so downstream parseGitHubUrl // picks it up as the branch/tag. - fromArg = `${fromArg}@${pin}`; - pinnedRef = pin; + fromArg = `${fromArg}@${ref}`; + requestedRef = ref; } else if (inlineRef) { - pinnedRef = inlineRef; - } else if (pin && !fromArg) { - const error = '--pin requires --from to specify a plugin source.'; + requestedRef = inlineRef; + } else if (ref && !fromArg) { + const error = '--ref requires --from to specify a plugin source.'; if (isJsonMode()) { jsonOutput({ success: false, command: 'skill add', error }); process.exit(1); @@ -1938,10 +1938,10 @@ const addCmd = command({ process.exit(1); } - // Record per-source ref/SHA + optional pin for the --all install path. + // Record per-source ref/SHA provenance for the --all install path. await recordSourceProvenance({ from: fromArg, - pinnedRef, + requestedRef, workspacePath: workspacePathAll, isUser: isUserAll, }); @@ -1957,7 +1957,7 @@ const addCmd = command({ copied: installResult.syncResult.totalCopied, failed: installResult.syncResult.totalFailed, }, - ...(pinnedRef && { pinnedRef }), + ...(requestedRef && { requestedRef }), }, }); return; @@ -2023,7 +2023,7 @@ const addCmd = command({ if (succeeded.length > 0) { await recordSourceProvenance({ from: fromArg, - pinnedRef, + requestedRef, workspacePath: workspacePathSel, isUser: isUserSel, }); @@ -2038,7 +2038,7 @@ const addCmd = command({ source: fromArg, installed: succeeded, failed: failures, - ...(pinnedRef && { pinnedRef }), + ...(requestedRef && { requestedRef }), }, ...(allFailed && { error: failures.map((f) => `${f.skill}: ${f.error}`).join('; '), @@ -2051,8 +2051,8 @@ const addCmd = command({ for (const f of failures) { console.error(`Error installing '${f.skill}': ${f.error}`); } - if (pinnedRef && succeeded.length > 0) { - console.log(`Pinned to ${pinnedRef}.`); + if (requestedRef && succeeded.length > 0) { + console.log(`Using ref ${requestedRef}.`); } if (allFailed) process.exit(1); return; @@ -2133,10 +2133,10 @@ const addCmd = command({ process.exit(1); } - // Record per-source ref/SHA + optional pin in sync-state. + // Record per-source ref/SHA provenance in sync-state. await recordSourceProvenance({ from, - pinnedRef, + requestedRef, workspacePath, isUser, }); @@ -2149,14 +2149,14 @@ const addCmd = command({ skill, plugin: installFromResult.pluginName, syncResult: installFromResult.syncResult, - ...(pinnedRef && { pinnedRef }), + ...(requestedRef && { requestedRef }), }, }); return; } - if (pinnedRef) { - console.log(`Pinned to ${pinnedRef}.`); + if (requestedRef) { + console.log(`Using ref ${requestedRef}.`); } return; } diff --git a/src/cli/metadata/plugin-skills.ts b/src/cli/metadata/plugin-skills.ts index c4d5e65..1e796f3 100644 --- a/src/cli/metadata/plugin-skills.ts +++ b/src/cli/metadata/plugin-skills.ts @@ -200,6 +200,7 @@ export const skillsAddMeta: AgentCommandMeta = { examples: [ 'allagents skill add ReScienceLab/opc-skills', 'allagents skill add reddit --from ReScienceLab/opc-skills', + 'allagents skill add reddit --from ReScienceLab/opc-skills --ref v2', 'allagents skill add NousResearch/hermes-agent --skill llm-wiki', 'allagents skill add NousResearch/hermes-agent --skill llm-wiki,dogfood', 'allagents skill add NousResearch/hermes-agent --list', @@ -249,6 +250,12 @@ export const skillsAddMeta: AgentCommandMeta = { description: 'Comma-separated skill names to install when the positional is a plugin source (e.g., `skill add owner/repo --skill foo,bar`).', }, + { + flag: '--ref', + type: 'string', + description: + 'Git tag or branch to use for the source. Mutually exclusive with an inline @ref.', + }, { flag: '--list', short: '-l', @@ -265,5 +272,6 @@ export const skillsAddMeta: AgentCommandMeta = { skill: 'string', plugin: 'string', syncResult: { copied: 'number', failed: 'number' }, + requestedRef: 'string?', }, }; diff --git a/src/core/marketplace.ts b/src/core/marketplace.ts index 0cf19b0..954bc63 100644 --- a/src/core/marketplace.ts +++ b/src/core/marketplace.ts @@ -537,7 +537,7 @@ export async function addMarketplace( // Check if already registered by source location (idempotent) // Use the full location including branch — each branch is a separate marketplace. - // For branch-pinned registrations, effectiveBranch overrides parsed.location. + // For branch-specific registrations, effectiveBranch overrides parsed.location. const sourceLocation = (() => { if (parsed.type === 'github') { const { owner, repo } = parseLocation(parsed.location); @@ -995,7 +995,7 @@ export async function updateMarketplace( let targetBranch: string; if (storedBranch) { - // Branch-pinned marketplace: use stored branch directly + // Branch-specific marketplace: use stored branch directly targetBranch = storedBranch; } else { // Default branch marketplace: detect default branch @@ -1814,10 +1814,11 @@ async function autoRegisterMarketplace( * * `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) + * `owner/repo@ref[/sub]` → false (GitHub plugin with an inline ref) * * 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 + * `/`, the spec is an `owner/repo` GitHub URL with an inline ref, not a + * * plugin@marketplace pair. Plugin names in marketplaces are bare identifiers * without slashes. */ diff --git a/src/core/plugin.ts b/src/core/plugin.ts index 3ca6eb8..2ad61ad 100644 --- a/src/core/plugin.ts +++ b/src/core/plugin.ts @@ -380,11 +380,11 @@ export async function updateCachedPlugins( /** * 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 + * Cache directories for ref-specific clones use the form + * `-@`. The ref 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. + * plugin name (e.g., setPluginSkillsMode) match against the base form. * * @param pluginPath - Resolved path to the plugin directory * @returns The plugin name (directory basename, without any `@ref` suffix) diff --git a/src/core/sync.ts b/src/core/sync.ts index 991e740..d6015ce 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1898,12 +1898,12 @@ async function buildSourcesProvenance( ): 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(); + // Index user-declared refs by their raw source string so we can attach them + // to the matching validated plugin (whose `.plugin` may have `@ref` spliced in). + const refByRawSource = new Map(); for (const entry of pluginEntries) { if (typeof entry === 'string') continue; - if (entry.pin) pinByRawSource.set(entry.source, entry.pin); + if (entry.ref) refByRawSource.set(entry.source, entry.ref); } for (const validated of validatedPlugins) { @@ -1920,13 +1920,13 @@ async function buildSourcesProvenance( if (!fetchResult.success || !fetchResult.resolvedSha) continue; const rawBase = stripGitRef(`${parsed.owner}/${parsed.repo}`); - const pinned = pinByRawSource.get(rawBase) ?? parsed.branch; + const requestedRef = refByRawSource.get(rawBase) ?? parsed.branch; sources[rawBase] = { pluginSpec: rawBase, resolvedRef: fetchResult.resolvedRef ?? parsed.branch ?? 'HEAD', resolvedSha: fetchResult.resolvedSha, - ...(pinned && { pinnedRef: pinned }), + ...(requestedRef && { requestedRef }), }; } diff --git a/src/models/sync-state.ts b/src/models/sync-state.ts index 69dc33a..ed1adb8 100644 --- a/src/models/sync-state.ts +++ b/src/models/sync-state.ts @@ -6,10 +6,9 @@ import { ClientTypeSchema } from './workspace-config.js'; * * Identity for git-based plugins is `url + ref`. `resolvedSha` is the commit * SHA returned by `git rev-parse HEAD` after the fetch and is what actually - * uniquely identifies the installed content; `resolvedRef` is the symbolic - * name (branch/tag) that was asked for. `pinnedRef` records the user's - * explicit pin (via `@` or `--pin`), distinct from whatever the resolver - * settled on. + * uniquely identifies the installed content; `resolvedRef` is what the Git + * resolver settled on. `requestedRef` records the user's explicit ref selector + * (via object-form `ref`, inline `@`, or `--ref`). * * Per-skill content hashing was removed in #388 — `git rev-parse HEAD` * already gives content identity for free, and recomputing per-skill @@ -19,7 +18,7 @@ export const SyncStateSourceSchema = z.object({ pluginSpec: z.string(), resolvedRef: z.string(), resolvedSha: z.string(), - pinnedRef: z.string().optional(), + requestedRef: z.string().optional(), }); export type SyncStateSource = z.infer; @@ -49,7 +48,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. + // Per-source resolved ref + SHA + optional requested ref. sources: z.record(z.string(), SyncStateSourceSchema).optional(), }); diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index cb00839..ba782ca 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -189,12 +189,13 @@ export const PluginEntrySchema = z.union([ 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. + * Optional Git ref (tag or branch). 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(), - }), + ref: z.string().optional(), + }).strict(), + ]); export type PluginEntry = z.infer; @@ -207,13 +208,13 @@ export function getPluginSource(plugin: PluginEntry): string { } /** - * Resolve the source exactly as sync will fetch it, including object-form pins. + * Resolve the source exactly as sync will fetch it, including object-form refs. * An inline ref remains authoritative when both forms are present. */ export function getEffectivePluginSource(plugin: PluginEntry): string { const source = getPluginSource(plugin); - const pin = getPluginPin(plugin); - if (!pin) return source; + const ref = getPluginRef(plugin); + if (!ref) return source; if ( source.startsWith('.') || source.startsWith('/') || @@ -235,7 +236,7 @@ export function getEffectivePluginSource(plugin: PluginEntry): string { const repoSegment = parts[1]; if (!repoSegment || repoSegment.includes('@')) return source; - parts[1] = `${repoSegment}@${pin}`; + parts[1] = `${repoSegment}@${ref}`; return parts.join('/'); } @@ -266,11 +267,11 @@ export function getPluginExclude(plugin: PluginEntry): string[] | undefined { } /** - * Get the pinned Git ref for a plugin entry (if any). Returns undefined for - * both the string-shorthand form and object entries without `pin:`. + * Get the requested Git ref for a plugin entry (if any). Returns undefined for + * both the string-shorthand form and object entries without `ref:`. */ -export function getPluginPin(plugin: PluginEntry): string | undefined { - return typeof plugin === 'string' ? undefined : plugin.pin; +export function getPluginRef(plugin: PluginEntry): string | undefined { + return typeof plugin === 'string' ? undefined : plugin.ref; } /** diff --git a/src/utils/plugin-path.ts b/src/utils/plugin-path.ts index 8a48df2..b90d762 100644 --- a/src/utils/plugin-path.ts +++ b/src/utils/plugin-path.ts @@ -15,7 +15,7 @@ import { export type PluginSourceType = 'github' | 'local'; /** - * Strip an inline `@` pin suffix from a plugin spec. + * Strip an inline `@` suffix from a plugin spec. * * `owner/repo@v1.2.0` → `owner/repo` * `owner/repo@main/sub` → `owner/repo/sub` (the subpath is preserved) @@ -90,7 +90,7 @@ export function isGitHubUrl(source: string): boolean { ) { // 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. + // 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_.-]+$/; @@ -134,7 +134,7 @@ 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: + // Also accept an optional @ref suffix on the repo segment: // owner/repo@v1.2.0 // owner/repo@main/subpath // Distinguishing from `name@marketplace`: that form has no slash, so isGitHubUrl diff --git a/tests/unit/cli/skill-update-reconciliation.test.ts b/tests/unit/cli/skill-update-reconciliation.test.ts index 3e69e36..0aaeb36 100644 --- a/tests/unit/cli/skill-update-reconciliation.test.ts +++ b/tests/unit/cli/skill-update-reconciliation.test.ts @@ -97,7 +97,7 @@ describe('createSkillUpdateReconciler', () => { clients: ['copilot'], install: 'file', exclude: ['commands/**'], - pin: 'v2', + ref: 'v2', skills: ['nested/one/review', 'nested/two/review', 'keep'], }, ], @@ -135,7 +135,7 @@ describe('createSkillUpdateReconciler', () => { clients: ['copilot'], install: 'file', exclude: ['commands/**'], - pin: 'v2', + ref: 'v2', skills: ['nested/two/review'], }, ]); diff --git a/tests/unit/core/skill-update.test.ts b/tests/unit/core/skill-update.test.ts index bec3e2a..2850048 100644 --- a/tests/unit/core/skill-update.test.ts +++ b/tests/unit/core/skill-update.test.ts @@ -65,33 +65,33 @@ function resolved( } describe('getEffectivePluginSource', () => { - it('applies object pins while preserving inline refs', () => { + it('applies object refs while preserving inline refs', () => { expect( - getEffectivePluginSource({ source: 'acme/skills', pin: 'v2' }), + getEffectivePluginSource({ source: 'acme/skills', ref: 'v2' }), ).toBe('acme/skills@v2'); expect( - getEffectivePluginSource({ source: 'acme/skills@v1', pin: 'v2' }), + getEffectivePluginSource({ source: 'acme/skills@v1', ref: 'v2' }), ).toBe('acme/skills@v1'); expect( getEffectivePluginSource({ source: 'https://github.com/acme/skills', - pin: 'v2', + ref: 'v2', }), ).toBe('acme/skills@v2'); expect( - getEffectivePluginSource({ source: './local/plugin', pin: 'v2' }), + getEffectivePluginSource({ source: './local/plugin', ref: 'v2' }), ).toBe('./local/plugin'); expect( getEffectivePluginSource({ source: 'plugin@acme/marketplace', - pin: 'v2', + ref: 'v2', }), ).toBe('plugin@acme/marketplace'); }); }); describe('createGitHubSkillUpdateInstallation', () => { - it('canonicalizes aliases to one cache node and separates pinned refs', () => { + it('canonicalizes aliases to one cache node and separates requested refs', () => { const shorthand = createGitHubSkillUpdateInstallation({ scope: 'project', configIndex: 0, @@ -108,10 +108,10 @@ describe('createGitHubSkillUpdateInstallation', () => { currentSha: 'old', skills: [{ name: 'keep', subpath: 'keep', enabled: true }], }); - const pinned = createGitHubSkillUpdateInstallation({ + const requestedRef = createGitHubSkillUpdateInstallation({ scope: 'project', configIndex: 1, - plugin: { source: 'acme/skills', pin: 'v2' }, + plugin: { source: 'acme/skills', ref: 'v2' }, pluginName: 'skills', currentSha: 'v2-old', skills: [{ name: 'keep', subpath: 'keep', enabled: true }], @@ -119,8 +119,8 @@ describe('createGitHubSkillUpdateInstallation', () => { expect(shorthand?.nodes[0]?.id).toBe(url?.nodes[0]?.id); expect(shorthand?.rootSubpath).toBe(''); - expect(pinned?.nodes[0]?.ref).toBe('v2'); - expect(pinned?.nodes[0]?.id).not.toBe(shorthand?.nodes[0]?.id); + expect(requestedRef?.nodes[0]?.ref).toBe('v2'); + expect(requestedRef?.nodes[0]?.id).not.toBe(shorthand?.nodes[0]?.id); }); it('groups direct-source siblings by physical checkout while keeping refs separate', () => { @@ -131,25 +131,25 @@ describe('createGitHubSkillUpdateInstallation', () => { effectiveSource: 'acme/skills/plugins/sibling', rootSubpath: 'plugins/sibling', }); - const pinnedNode: CheckoutNode = { + const refNode: CheckoutNode = { ...projectNode, id: '/cache/acme-skills-v2', cachePath: '/cache/acme-skills-v2', ref: 'v2', }; - const pinned = installation({ + const refSpecific = installation({ id: 'project:2', configIndex: 2, rawSource: 'acme/skills@v2', effectiveSource: 'acme/skills@v2', - rootNodeId: pinnedNode.id, - nodes: [pinnedNode], + rootNodeId: refNode.id, + nodes: [refNode], }); const units = buildPhysicalRefreshUnits([ installation(), sibling, - pinned, + refSpecific, ]); expect(units).toHaveLength(2); diff --git a/tests/unit/models/workspace-config.test.ts b/tests/unit/models/workspace-config.test.ts index ff27e6d..ec64158 100644 --- a/tests/unit/models/workspace-config.test.ts +++ b/tests/unit/models/workspace-config.test.ts @@ -49,6 +49,26 @@ describe('WorkspaceConfigSchema', () => { expect(result.success).toBe(true); }); + it('accepts an object-form Git ref', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [{ source: 'owner/repo', ref: 'v2' }], + clients: ['claude'], + }); + + expect(result.success).toBe(true); + }); + + it('rejects the removed pin field instead of silently following the default branch', () => { + const result = WorkspaceConfigSchema.safeParse({ + repositories: [], + plugins: [{ source: 'owner/repo', pin: 'v2' }], + clients: ['claude'], + }); + + expect(result.success).toBe(false); + }); + }); describe('ClientTypeSchema', () => { diff --git a/tests/unit/utils/plugin-path.test.ts b/tests/unit/utils/plugin-path.test.ts index 04ab3ae..16ba4ee 100644 --- a/tests/unit/utils/plugin-path.test.ts +++ b/tests/unit/utils/plugin-path.test.ts @@ -194,7 +194,7 @@ describe('parseGitHubUrl', () => { expect(parseGitHubUrl('')).toBeNull(); }); - it('should parse shorthand owner/repo@ref version pin', () => { + it('should parse shorthand owner/repo@ref selectors', () => { expect(parseGitHubUrl('owner/repo@v1.2.0')).toEqual({ owner: 'owner', repo: 'repo', @@ -202,7 +202,7 @@ describe('parseGitHubUrl', () => { }); }); - it('should parse owner/repo@ref/subpath with both pin and subpath', () => { + it('should parse owner/repo@ref/subpath with both ref and subpath', () => { expect(parseGitHubUrl('owner/repo@main/plugins/foo')).toEqual({ owner: 'owner', repo: 'repo', @@ -212,7 +212,7 @@ describe('parseGitHubUrl', () => { }); }); -describe('isGitHubUrl with @ref pin', () => { +describe('isGitHubUrl with inline @ref', () => { it('accepts owner/repo@ref shorthand', () => { expect(isGitHubUrl('owner/repo@v1.2.0')).toBe(true); });