Skip to content

Commit 6647e39

Browse files
authored
Merge pull request #263 from astra-sh/feat/vendor
feat(skill): vendor mode — materialize installed skills as committed files
2 parents d485196 + f945c8d commit 6647e39

10 files changed

Lines changed: 801 additions & 112 deletions

File tree

cmd/add.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ var (
2929
addAs string
3030
addAll bool
3131
addLocal string
32+
addVendor bool
3233
)
3334

3435
var addCmd = &cobra.Command{
@@ -102,7 +103,9 @@ func init() {
102103
addCmd.Flags().BoolVar(&addAll, "all", false,
103104
"install every skill the registry exposes (requires --registry; do not name a skill)")
104105
addCmd.Flags().StringVar(&addLocal, "local", "",
105-
"install an immutable copy of a skill from a local folder (no registry; `qvr edit` to make it mutable)")
106+
"install an immutable copy of a skill from a local folder (no registry; `qvr edit` to make it mutable, or add --vendor to commit it into the repo)")
107+
addCmd.Flags().BoolVar(&addVendor, "vendor", false,
108+
"commit the skill as real files in the repo (no symlink into the store) so it travels with a `git clone` — no store, registry, or qvr needed to read it. Composes with --local.")
106109
rootCmd.AddCommand(addCmd)
107110
}
108111

@@ -333,6 +336,7 @@ func addInstallItem(cmd *cobra.Command, cfg *config.Config, installer *skill.Ins
333336
Registry: item.registry,
334337
SkillPath: item.skillPath,
335338
As: addAs,
339+
Vendor: addVendor,
336340
RequireSigned: cfg.Security.RequireSigned,
337341
TrustedAuthors: trustedAuthorsForRegistry(cfg, item.registry),
338342
TrustedAuthorsByRegistry: trustedAuthorsByRegistry(cfg),
@@ -702,6 +706,12 @@ func validateAddModes() error {
702706
return fmt.Errorf("--frozen cannot be combined with --local")
703707
}
704708
}
709+
if addVendor && addFrozen {
710+
// --frozen pins to the recorded store hash and forbids drift; vendoring
711+
// rewrites how the skill lands (real in-repo files), so the two are
712+
// contradictory.
713+
return fmt.Errorf("--vendor cannot be combined with --frozen")
714+
}
705715
return nil
706716
}
707717

@@ -763,6 +773,7 @@ func runAddLocal(cmd *cobra.Command, cfg *config.Config, installer *skill.Instal
763773
ProjectRoot: projectRoot,
764774
LockPath: lockPath,
765775
Force: addForce,
776+
Vendor: addVendor,
766777
})
767778
if ierr != nil {
768779
printer.Error(fmt.Sprintf("add --local %s: %v", resolved, ierr))
@@ -1272,6 +1283,12 @@ func skillDirForEntry(result *skill.InstallResult, lock *model.LockFile) string
12721283
return result.Worktree
12731284
}
12741285
worktreePath := skill.EntryWorktreePath(entry)
1286+
if worktreePath == "" {
1287+
// No store worktree (vendor/edit/link): the content lives where
1288+
// result.Worktree points — already at the skill root, so don't join
1289+
// entry.Path. result.Worktree is EffectiveTarget for these modes.
1290+
return result.Worktree
1291+
}
12751292
if entry.Path == "" || entry.Path == "." {
12761293
return worktreePath
12771294
}

cmd/edit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ func ejectUnderLock(name, projectRoot, lockPath, projPath string, result **skill
123123
if entry.IsLink() {
124124
return fmt.Errorf("%s is a link install at %s — edit the source path directly", name, entry.Source)
125125
}
126+
// Vendored skills are already real, writable files committed in the repo;
127+
// there's nothing to eject — point the user at the in-repo dir.
128+
if entry.IsVendor() {
129+
return fmt.Errorf("%s is vendored at %s — its files are real and editable in the repo; edit them directly", name, entry.VendorPath)
130+
}
126131
// Already ejected: no-op, return current state.
127132
if entry.IsEdit() {
128133
*idempotent = true

internal/model/lockfile.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,11 @@ var (
8080
// Install mode constants. Empty string means "shared" (default add semantics)
8181
// for backward compatibility — older v5 locks predate this field.
8282
const (
83-
ModeShared = "" // symlink → ~/.quiver/worktrees/.../ (default for `qvr add`)
84-
ModeEdit = "edit" // canonical real dir at EditPath (set by `qvr edit`)
85-
ModeLink = "link" // absolute path in Source (legacy `qvr link`; read-only compat)
86-
ModeLocal = "local" // immutable copy of a local folder (set by `qvr add --local`)
83+
ModeShared = "" // symlink → ~/.quiver/worktrees/.../ (default for `qvr add`)
84+
ModeEdit = "edit" // canonical real dir at EditPath (set by `qvr edit`)
85+
ModeLink = "link" // absolute path in Source (legacy `qvr link`; read-only compat)
86+
ModeLocal = "local" // immutable copy of a local folder (set by `qvr add --local`)
87+
ModeVendor = "vendor" // real files committed into the repo at VendorPath (set by `qvr add --vendor`)
8788
)
8889

8990
// LocalRegistry is the reserved registry name stamped on `qvr add --local`
@@ -180,6 +181,15 @@ type LockEntry struct {
180181
// target carry relative symlinks to it. Empty when Mode != "edit".
181182
EditPath string `json:"editPath,omitempty" toml:"editPath,omitempty"`
182183

184+
// VendorPath is the project-relative path of the canonical vendored
185+
// copy when Mode == "vendor" (e.g. ".claude/skills/auth"). Like
186+
// EditPath it is a real in-repo directory the canonical target IS,
187+
// with sibling targets carrying relative symlinks to it — but the
188+
// bytes are tracked by the outer repo (no nested .git), so a teammate
189+
// who clones the repo gets the skill files with no store, registry, or
190+
// qvr required. Empty when Mode != "vendor".
191+
VendorPath string `json:"vendorPath,omitempty" toml:"vendorPath,omitempty"`
192+
183193
// Targets is the list of agent dirs the skill is symlinked into.
184194
Targets []string `json:"targets" toml:"targets"`
185195

@@ -289,6 +299,20 @@ func (e *LockEntry) IsEdit() bool {
289299
return e.Mode == ModeEdit
290300
}
291301

302+
// IsVendor reports whether this entry is vendored: real skill files committed
303+
// into the repo at VendorPath via `qvr add --vendor`. The canonical agent
304+
// target dir at VendorPath is itself a real directory (sibling targets repoint
305+
// at it), and the bytes are version-controlled by the outer repo rather than
306+
// the qvr store — so the skill travels with a `git clone` without needing a
307+
// store worktree or a reachable registry. Like edit installs, the shared store
308+
// worktree is not load-bearing for a vendored skill.
309+
func (e *LockEntry) IsVendor() bool {
310+
if e == nil {
311+
return false
312+
}
313+
return e.Mode == ModeVendor
314+
}
315+
292316
// LockFile is the on-disk record of installed skills.
293317
//
294318
// The lockfile is qvr's pillar of portability, governance, and reproducibility:

internal/registry/path.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,14 @@ func WorktreePathForEntry(e *model.LockEntry) string {
233233
if e.IsLink() {
234234
return e.Source
235235
}
236+
// Vendored skills live in-repo at VendorPath, not in the store. They claim
237+
// no store worktree, so the transient one materialized at install time
238+
// becomes orphan and is reclaimed by `qvr cache prune` (the content is
239+
// content-addressed and shared, so prune only drops it when no other
240+
// project references it). Returning a path here would wrongly pin it.
241+
if e.IsVendor() {
242+
return ""
243+
}
236244
key := e.InstallCommit
237245
if key == "" {
238246
key = e.Commit

internal/skill/eject.go

Lines changed: 166 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ var (
2828
// ErrNoTargets is returned by Eject when the lock entry records no
2929
// installed targets, leaving no agent dir to eject the copy into.
3030
ErrNoTargets = errors.New("entry has no installed targets to eject into")
31+
// ErrCannotVendorLink is returned by VendorIntoRepo for a link install:
32+
// its bytes live at an external path with no store worktree to copy in.
33+
ErrCannotVendorLink = errors.New("cannot vendor a link install")
34+
// ErrCannotEjectVendor is returned by EjectToTarget for a vendored entry:
35+
// its files are already real and writable in the repo, so there is nothing
36+
// to eject.
37+
ErrCannotEjectVendor = errors.New("cannot eject a vendored install — its files are already editable in the repo")
3138
)
3239

3340
// EjectRequest drives a `qvr edit` eject.
@@ -79,6 +86,12 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
7986
if e.IsLink() {
8087
return nil, ErrCannotEjectLink
8188
}
89+
if e.IsVendor() {
90+
// A vendored skill is already real, writable, in-repo files — there is
91+
// no store worktree to eject and the canonical target is a real dir, so
92+
// the eject rename would refuse to clobber it. Reject explicitly.
93+
return nil, ErrCannotEjectVendor
94+
}
8295
if len(e.Targets) == 0 {
8396
return nil, ErrNoTargets
8497
}
@@ -112,6 +125,12 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
112125

113126
siblingLinks, err := repointSiblingTargets(e, req, canonicalTarget, canonicalAbs)
114127
if err != nil {
128+
// materializeEjectDir already created the canonical real directory; a
129+
// sibling-relink failure must not leave it behind. rollbackLinks (in the
130+
// caller) only removes symlinks, so the real dir would orphan and block
131+
// future installs of this skill. Remove it here to keep eject/vendor
132+
// atomic — the caller still rolls back the target symlinks.
133+
_ = os.RemoveAll(canonicalAbs)
115134
return nil, err
116135
}
117136

@@ -128,47 +147,66 @@ func EjectToTarget(req EjectRequest) (*EjectResult, error) {
128147
// materializeEjectDir resolves the shared-worktree source, copies the skill
129148
// subtree into a staging sibling dir, restores write bits (the immutable→editable
130149
// hinge), inits a fresh git history, then atomically renames the staging dir onto
131-
// the canonical slot — refusing to clobber a real (non-symlink) dir there. The
132-
// staging-then-rename avoids leaving half-copied state if any step fails midway.
150+
// the canonical slot. Thin wrapper over copyTreeToCanonical with the edit-mode
151+
// settings (nested git history seeded).
133152
func materializeEjectDir(e *model.LockEntry, req EjectRequest, canonicalAbs string) error {
134-
// Resolve the shared worktree source: where we'll copy the skill files
135-
// from. Falls back to LoadFromPath when the worktree isn't on disk so a
136-
// user who deleted ~/.quiver/worktrees/ before invoking edit gets a clean
137-
// error instead of an empty copy.
138-
sourceDir := EffectiveTarget(e, req.ProjectRoot)
153+
return copyTreeToCanonical(e, req.ProjectRoot, canonicalAbs, ".ejecting", true, req.Author, req.AuthorEmail)
154+
}
155+
156+
// copyTreeToCanonical resolves the entry's current effective source (the shared
157+
// store worktree or the local copy), copies it into a staging sibling of
158+
// canonicalAbs, restores write bits, optionally seeds a fresh nested git history,
159+
// then atomically renames the staging dir onto the canonical agent-dir slot —
160+
// refusing to clobber a real (non-symlink) dir there. The staging-then-rename
161+
// avoids leaving half-copied state if any step fails midway.
162+
//
163+
// Shared by `qvr edit` (initNestedRepo=true: the eject dir is a standalone fork
164+
// that `qvr publish` later pushes) and `qvr add --vendor` (initNestedRepo=false:
165+
// the bytes are tracked by the OUTER project repo, so a nested .git would be both
166+
// redundant and a committed-repo-inside-a-repo footgun).
167+
func copyTreeToCanonical(e *model.LockEntry, projectRoot, canonicalAbs, stagingSuffix string, initNestedRepo bool, author, authorEmail string) error {
168+
// Resolve the source: where we'll copy the skill files from. Returns ""
169+
// when the worktree isn't on disk so a user who deleted ~/.quiver/worktrees/
170+
// gets a clean error instead of an empty copy.
171+
sourceDir := EffectiveTarget(e, projectRoot)
139172
if sourceDir == "" {
140-
return fmt.Errorf("eject %s: no source worktree to copy from — run `qvr sync` first", e.Name)
173+
return fmt.Errorf("%s: no source worktree to copy from — run `qvr sync` first", e.Name)
141174
}
142175
if _, err := os.Stat(filepath.Join(sourceDir, "SKILL.md")); err != nil {
143-
return fmt.Errorf("eject %s: source %s does not contain SKILL.md: %w", e.Name, sourceDir, err)
176+
return fmt.Errorf("%s: source %s does not contain SKILL.md: %w", e.Name, sourceDir, err)
144177
}
145178

146179
// Stage to a sibling tmp dir, then rename onto canonical. Avoids leaving
147180
// half-copied state if the copy / git init fails midway.
148-
stagingDir := canonicalAbs + ".ejecting"
181+
stagingDir := canonicalAbs + stagingSuffix
149182
_ = os.RemoveAll(stagingDir)
150183
if err := copyDir(sourceDir, stagingDir); err != nil {
151184
_ = os.RemoveAll(stagingDir)
152185
return fmt.Errorf("copy skill tree: %w", err)
153186
}
154-
// The shared worktree is frozen read-only and copyDir preserves source
155-
// modes, so the freshly-copied edit tree would be read-only. Restore write
156-
// bits: this copy is the editable working dir, and initEjectRepo is about
157-
// to `git init` and write into it. This is the immutable→editable hinge.
187+
// The source worktree is frozen read-only and copyDir preserves source
188+
// modes, so the freshly-copied tree would be read-only. Restore write bits:
189+
// this copy is the working dir the user (and any `git init` below) writes to.
158190
setSubtreeWritable(stagingDir)
159-
if err := initEjectRepo(stagingDir, e, req.Author, req.AuthorEmail); err != nil {
160-
_ = os.RemoveAll(stagingDir)
161-
return fmt.Errorf("init edit repo: %w", err)
191+
if initNestedRepo {
192+
if err := initEjectRepo(stagingDir, e, author, authorEmail); err != nil {
193+
_ = os.RemoveAll(stagingDir)
194+
return fmt.Errorf("init edit repo: %w", err)
195+
}
162196
}
197+
return promoteStagingOntoCanonical(e, stagingDir, canonicalAbs)
198+
}
163199

164-
// Remove the existing canonical symlink (or no-op if it isn't there) so
165-
// the rename below lands on a clean slot. CreateSymlink earlier may have
166-
// pointed it at the shared worktree; if a real dir is there already we
167-
// refuse to clobber user content.
200+
// promoteStagingOntoCanonical removes the existing canonical symlink (or no-op
201+
// if absent) so the rename lands on a clean slot, then atomically renames the
202+
// staging dir onto it. CreateSymlink earlier may have pointed the canonical at
203+
// the shared worktree; if a real dir is there already we refuse to clobber user
204+
// content. On any failure the staging dir is removed so no half-state lingers.
205+
func promoteStagingOntoCanonical(e *model.LockEntry, stagingDir, canonicalAbs string) error {
168206
if existing, err := os.Lstat(canonicalAbs); err == nil {
169207
if existing.Mode()&os.ModeSymlink == 0 {
170208
_ = os.RemoveAll(stagingDir)
171-
return fmt.Errorf("eject %s: %s exists and is not a symlink — refuse to overwrite", e.Name, canonicalAbs)
209+
return fmt.Errorf("%s: %s exists and is not a symlink — refuse to overwrite", e.Name, canonicalAbs)
172210
}
173211
if err := os.Remove(canonicalAbs); err != nil {
174212
_ = os.RemoveAll(stagingDir)
@@ -181,11 +219,116 @@ func materializeEjectDir(e *model.LockEntry, req EjectRequest, canonicalAbs stri
181219
}
182220
if err := os.Rename(stagingDir, canonicalAbs); err != nil {
183221
_ = os.RemoveAll(stagingDir)
184-
return fmt.Errorf("finalize edit dir: %w", err)
222+
return fmt.Errorf("finalize canonical dir: %w", err)
185223
}
186224
return nil
187225
}
188226

227+
// VendorRequest drives a `qvr add --vendor` post-install vendoring step.
228+
type VendorRequest struct {
229+
Entry *model.LockEntry // the freshly-installed lock entry (mutated in place on success)
230+
ProjectRoot string // absolute project root
231+
// Global, when true, vendors into the user-global agent dir and writes
232+
// VendorPath as an absolute path; project scope (default) writes a
233+
// project-relative VendorPath so the lockfile stays portable across clones.
234+
Global bool
235+
}
236+
237+
// VendorResult summarises a vendoring for the caller.
238+
type VendorResult struct {
239+
Skill string `json:"skill"`
240+
CanonicalTarget string `json:"canonical_target"`
241+
VendorPath string `json:"vendor_path"` // project-relative (or absolute when Global)
242+
SiblingLinks []string `json:"sibling_links"` // absolute paths of repointed sibling symlinks
243+
}
244+
245+
// VendorIntoRepo promotes a freshly-installed skill's store worktree into a real
246+
// directory committed into the repo at the alphabetical-first installed target,
247+
// and repoints any other installed targets at it via relative symlinks. The lock
248+
// entry is mutated in place: Mode flips to "vendor", VendorPath records the
249+
// project-relative canonical path, and SubtreeHash is re-sealed against the
250+
// in-repo dir.
251+
//
252+
// Unlike EjectToTarget it seeds NO nested git history — the vendored bytes are
253+
// tracked by the OUTER project repo, which is exactly what lets the skill travel
254+
// with a `git clone` (no store, no registry, no qvr needed to read it). It is the
255+
// `--vendor` counterpart to a normal symlink-into-store install.
256+
//
257+
// Idempotent: a second call when Mode is already "vendor" at the same path is a
258+
// no-op. Refuses link installs (no store worktree to copy from).
259+
func VendorIntoRepo(req VendorRequest) (*VendorResult, error) {
260+
e := req.Entry
261+
if e == nil {
262+
return nil, errors.New("vendor: nil entry")
263+
}
264+
if req.ProjectRoot == "" {
265+
return nil, errors.New("vendor: project root is required")
266+
}
267+
if e.IsLink() {
268+
return nil, ErrCannotVendorLink
269+
}
270+
if len(e.Targets) == 0 {
271+
return nil, ErrNoTargets
272+
}
273+
274+
canonicalTarget := pickCanonicalTarget(e.Targets)
275+
t, ok := model.LookupTarget(canonicalTarget)
276+
if !ok {
277+
return nil, fmt.Errorf("%w: %s", ErrUnknownTarget, canonicalTarget)
278+
}
279+
280+
// Reuse the eject path-derivation: --global → absolute canonical/VendorPath,
281+
// project → project-relative. The EjectRequest carries only scope here.
282+
scope := EjectRequest{ProjectRoot: req.ProjectRoot, Global: req.Global}
283+
canonicalAbs, vendorPathForLock, err := ejectCanonicalPaths(t, e, scope)
284+
if err != nil {
285+
return nil, err
286+
}
287+
288+
// Idempotent no-op when the entry already records this exact vendoring.
289+
if e.IsVendor() && e.VendorPath == vendorPathForLock {
290+
return &VendorResult{Skill: e.Name, CanonicalTarget: canonicalTarget, VendorPath: vendorPathForLock}, nil
291+
}
292+
293+
if err := copyTreeToCanonical(e, req.ProjectRoot, canonicalAbs, ".vendoring", false, "", ""); err != nil {
294+
return nil, err
295+
}
296+
297+
siblingLinks, err := repointSiblingTargets(e, scope, canonicalTarget, canonicalAbs)
298+
if err != nil {
299+
// copyTreeToCanonical already materialized the canonical real directory;
300+
// a sibling-relink failure must not leave it behind. The caller's
301+
// rollbackLinks only removes symlinks, so the real dir would orphan and
302+
// block future installs of this skill at that path. Remove it so vendor
303+
// stays atomic (matching EjectToTarget).
304+
_ = os.RemoveAll(canonicalAbs)
305+
return nil, err
306+
}
307+
308+
finalizeVendoredEntry(e, vendorPathForLock, canonicalAbs)
309+
310+
return &VendorResult{
311+
Skill: e.Name,
312+
CanonicalTarget: canonicalTarget,
313+
VendorPath: vendorPathForLock,
314+
SiblingLinks: siblingLinks,
315+
}, nil
316+
}
317+
318+
// finalizeVendoredEntry mutates the entry on success: flips it to mode:vendor,
319+
// records the VendorPath, and re-seals SubtreeHash against the in-repo dir so
320+
// drift detection (`qvr lock verify`) has a current baseline. The copy is
321+
// byte-identical to the store worktree, so the hash normally matches what the
322+
// install already recorded; re-hashing is defensive and matches eject's pattern.
323+
// HashSubtreeFromDisk excludes .git/, so the seal agrees with a later verify.
324+
func finalizeVendoredEntry(e *model.LockEntry, vendorPathForLock, canonicalAbs string) {
325+
e.Mode = model.ModeVendor
326+
e.VendorPath = vendorPathForLock
327+
if h, err := canonical.HashSubtreeFromDisk(canonicalAbs); err == nil {
328+
e.SubtreeHash = h
329+
}
330+
}
331+
189332
// ejectCanonicalPaths computes the canonical eject dir and the EditPath recorded
190333
// in the lock, scoped per issue #82:
191334
//

0 commit comments

Comments
 (0)