Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e31309f
fix(collect,install): collect only real skills; let --track take a lo…
runkids Oct 9, 2026
aa9670f
fix(install): keep the drive letter in file:// clone URLs for local -…
runkids Oct 9, 2026
eb72272
fix(collect,install): keep UNC file URLs intact; require SKILL.md to …
runkids Oct 9, 2026
26e4c52
fix(install): escape the local path in the file:// URL used by --track
runkids Oct 9, 2026
b021544
fix(install): accept a local bare repository path for --track
runkids Oct 9, 2026
6c613d7
test(install): build the bare repo path from a Windows file URL corre…
runkids Oct 9, 2026
9585616
fix(install): refuse a --track local source that is the install desti…
runkids Oct 9, 2026
6adeea6
fix(install,collect): keep a tracked source inside its destination; r…
runkids Oct 9, 2026
19fce92
Merge branch 'main' into runkids/collect-install-local
runkids Oct 9, 2026
e9ea9a8
fix(install): treat file://localhost URLs as local clone sources
runkids Oct 9, 2026
9da5f9b
test(install): build the localhost file URL from a slash path
runkids Oct 9, 2026
7375400
fix(install): clone --track --force into a staging dir and swap it in…
runkids Oct 9, 2026
c1e6c21
fix(install): refuse a file:// --track source inside the forced desti…
runkids Oct 9, 2026
98e833a
fix(install): apply the source-inside-destination refusal only to --f…
runkids Oct 9, 2026
c090983
fix(install): report a forced clone's root skill under its tracked name
runkids Oct 9, 2026
890463a
fix(install): stage fresh tracked clones so cleanup never hits anothe…
runkids Oct 9, 2026
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
6 changes: 4 additions & 2 deletions cmd/skillshare/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ func parseInstallArgs(args []string) (*installArgs, bool, error) {

if result.opts.Branch != "" && result.sourceArg != "" {
source, parseErr := install.ParseSource(result.sourceArg)
if parseErr == nil && !source.IsGit() {
trackableLocal := result.opts.Track && source != nil &&
source.Type == install.SourceTypeLocalPath && install.IsLocalGitRepo(source.Path)
if parseErr == nil && !source.IsGit() && !trackableLocal {
return nil, false, fmt.Errorf("--branch can only be used with git repository sources")
}
}
Expand Down Expand Up @@ -597,7 +599,7 @@ func printInstallHelp() {
{"-f, --force", "Overwrite existing skill; also continue if audit would block"},
{"-u, --update", "Update existing (git pull if possible, else reinstall)"},
{"-b, --branch <ref>", "Git branch, tag, or commit SHA to install from (default: remote default)"},
{"-t, --track", "Install as tracked repo (preserves .git for updates)"},
{"-t, --track", "Install as tracked repo (preserves .git for updates; a local\npath works if it is a git repository)"},
{"-a, --agent <names>", "Select specific agents from a multi-agent repo (comma-separated)"},
{"-s, --skill <names>", "Select specific skills from multi-skill repo (comma-separated;\nsupports glob patterns like \"core-*\", \"test-?\")"},
{"--exclude <names>", "Skip specific skills during install (comma-separated;\nsupports glob patterns like \"test-*\")"},
Expand Down
4 changes: 2 additions & 2 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ func (e *TrackKindAmbiguousError) Error() string {
// the kind explicitly to avoid ambiguous install roots; in that case the
// returned error is a *TrackKindAmbiguousError carrying the discovered counts.
func InferTrackedKind(source *Source, explicitKind string) (string, error) {
if !source.IsGit() {
return "", fmt.Errorf("--track requires a git repository source")
if err := normalizeTrackSource(source); err != nil {
return "", err
}

if explicitKind == "skill" || explicitKind == "agent" {
Expand Down
11 changes: 11 additions & 0 deletions internal/install/install_git.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ func IsGitRepo(path string) bool {
return err == nil && (info.IsDir() || info.Mode().IsRegular())
}

// IsLocalGitRepo reports whether path is the root of a git repository, either a
// working tree (.git) or a bare repository (HEAD and objects at the root).
func IsLocalGitRepo(path string) bool {
if IsGitRepo(path) {
return true
}
head, headErr := os.Stat(filepath.Join(path, "HEAD"))
objects, objErr := os.Stat(filepath.Join(path, "objects"))
return headErr == nil && head.Mode().IsRegular() && objErr == nil && objects.IsDir()
}

// IsTrackedCheckout reports whether path is a tracked repo checkout: a
// _-prefixed directory that is a git repo.
func IsTrackedCheckout(path string) bool {
Expand Down
141 changes: 122 additions & 19 deletions internal/install/install_tracked.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,83 @@ package install

import (
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"

"skillshare/internal/sourcefs"
)

// localFileURL builds the file:// URL for a local path. A Windows drive path
// (C:/repo) needs the empty authority (file:///C:/repo), and characters such as
// spaces, '#' and '%' must be escaped because git decodes the URL.
func localFileURL(path string) string {
p := filepath.ToSlash(path)
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return (&url.URL{Scheme: "file", Path: p}).String()
}

// localCloneSource returns the filesystem path a local source clones from: the
// plain path, or the path a file:// URL names, or "" for a remote source.
func localCloneSource(source *Source) string {
if source.Path != "" {
return source.Path
}
u, err := url.Parse(source.CloneURL)
if err != nil || u.Scheme != "file" {
return ""
}
p := u.Path
switch {
case u.Host != "" && !strings.EqualFold(u.Host, "localhost"):
p = "//" + u.Host + p // UNC: file://server/share/repo
case len(p) > 2 && p[0] == '/' && p[2] == ':':
p = p[1:] // file:///C:/repo
}
return filepath.FromSlash(p)
}

// pathWithin reports whether path is dir itself or lies below it, after
// resolving links.
func pathWithin(dir, path string) bool {
resolve := func(p string) string {
if abs, err := filepath.Abs(p); err == nil {
p = abs
}
if r, err := filepath.EvalSymlinks(p); err == nil {
p = r
}
return p
}
rel, err := filepath.Rel(resolve(dir), resolve(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

// normalizeTrackSource turns a local path that is a git repository into the
// file:// form that parseFileURL builds, and rejects other non-git sources.
func normalizeTrackSource(source *Source) error {
if source.Type == SourceTypeLocalPath && IsLocalGitRepo(source.Path) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize native UNC paths before tracked-source normalization

On Windows, install \\server\share\repo --track never reaches this new local-repository branch: ParseSource's isLocalPath recognizes drive-letter and .\/..\ paths, but not the canonical UNC form beginning with \\, so parsing fails as an unrecognized source. This leaves the documented local-git-path support unusable for repositories on network shares even though explicit file://server/share/repo URLs are supported; extend local-path parsing and conversion to handle native UNC paths.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. A native UNC source (\\server\share\repo) is not recognized by ParseSource for any install form today (isLocalPath only knows /, ~, ./, ../ and drive letters), so it fails the same way without --track; adding UNC parsing is a separate change to local-path handling. Network-share repositories work through the explicit file://server/share/repo form, which this PR leaves as parsed (TestParseSource_ExplicitUNCFileURLKeepsAuthority). Noted in the PR body.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow tracked installs from the project repository root

When project mode receives its own Git root as the local source (for example, skillshare install ./ --track -p), both cmdInstallProjectParsed and the dashboard handler call RejectProjectRootLocalInstall before InferTrackedKind, so this normalization is never reached and the request is rejected. The equivalent file:///project/root source works, and cloning rather than recursively copying avoids the hazard that guard addresses; make that guard track-aware or normalize the source before applying it.

AGENTS.md reference: AGENTS.md:L15-L15

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. RejectProjectRootLocalInstall protects the copy-based local install, and installing a project's own repository as a tracked repo inside that project's .skillshare/skills is not a workflow this PR sets out to support; the explicit file:///project/root form still works. Relaxing a safety guard shared by the CLI and the dashboard for that case is a separate decision. Noted in the PR body.

source.Type = SourceTypeGitHTTPS
source.CloneURL = localFileURL(source.Path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse a local source that is also the forced destination

When the local repository already occupies the computed tracked destination—for example, skills/_foo installed from its own path with --track --force—this URL points back to that same directory. The existing force path removes the destination before cloning, so it deletes the only clone source and the subsequent clone fails, potentially losing dirty or unpushed work. Compare the resolved source and destination before removal, or clone to staging before replacing it.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9585616. installTrackedRepoImpl now compares a local source with the destination using os.SameFile (symlinked paths included) and returns an error before anything is removed. Covered by TestInstallTrackedRepo_RefusesLocalSourceThatIsTheDestination, which failed before the change; I also ran it through the CLI with and without --force, via a symlink, and with --dry-run, and the destination stayed intact. The same hazard exists for an explicit file:///dest URL, which this PR does not touch.

return validateCloneURL(source.CloneURL)
}
if source.IsGit() {
return nil
}
if source.Type == SourceTypeLocalPath {
return fmt.Errorf("--track requires a git repository source; %s is not a git repository root (use file:///path for a repository URL)", source.Path)
}
return fmt.Errorf("--track requires a git repository source")
}

func installTrackedRepoImpl(source *Source, sourceDir string, opts InstallOptions) (*TrackedRepoResult, error) {
if !source.IsGit() {
return nil, fmt.Errorf("--track requires a git repository source")
if err := normalizeTrackSource(source); err != nil {
return nil, err
}
if err := resolveWebRef(source); err != nil {
return nil, err
Expand Down Expand Up @@ -63,6 +130,7 @@ func installTrackedRepoImpl(source *Source, sourceDir string, opts InstallOption
}

// Check if already exists
replacing := false
if _, err := os.Stat(destPath); err == nil {
if opts.Update {
return updateTrackedRepo(destPath, result, opts)
Expand All @@ -80,16 +148,17 @@ func installTrackedRepoImpl(source *Source, sourceDir string, opts InstallOption
}
return nil, fmt.Errorf("tracked repo '%s' already exists. To overwrite: %s", trackedName, hint)
}
// A local source that is, or lies inside, the destination would be
// replaced under itself; a clone cannot carry its uncommitted work.
if p := localCloneSource(source); p != "" && pathWithin(destPath, p) {
return nil, fmt.Errorf("source %s is inside the install destination %s; nothing to install", p, destPath)
}
// Force mode - remove existing. A link is refused, even in a dry
// run: removing it would disconnect the repo it points to.
if err := src.CheckNoLink(destRel); err != nil {
return nil, err
}
if !opts.DryRun {
if err := src.RemoveAll(destRel); err != nil {
return nil, fmt.Errorf("failed to remove existing repo: %w", err)
}
}
replacing = true
}

if opts.DryRun {
Expand All @@ -104,44 +173,56 @@ func installTrackedRepoImpl(source *Source, sourceDir string, opts InstallOption
if cloneBranch == "" {
cloneBranch = source.Branch
}
// Clone beside the destination and move it in only after every check
// passed, so a failure never costs the existing repo, and cleanup never
// removes a destination another installer created meanwhile.
stamp := strconv.FormatInt(time.Now().UnixNano(), 36)
cloneRel := filepath.Join(filepath.Dir(destRel), ".skillshare-clone-"+stamp)
clonePath := filepath.Join(sourceDir, cloneRel)
installed := false
defer func() {
if !installed {
_ = src.RemoveAll(cloneRel)
}
Comment on lines +183 to +186

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid removing a destination claimed by another installer

When two CLI processes concurrently install the same previously absent tracked name, both can pass the os.Stat(destPath) check, after which one clone claims destRel and the other clone fails because that directory now exists. The failing process then executes this unconditional deferred RemoveAll(cloneRel), deleting the checkout created by the successful process. Fresh installs should clone to a per-invocation staging path or otherwise clean up only after confirming that this invocation created the destination.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 890463a. Fresh installs now clone into the same per-invocation .skillshare-clone-<stamp> staging directory as --force and are renamed into place last, so the deferred cleanup only ever removes this invocation's staging copy; a destination another installer created meanwhile makes the rename fail and is left alone. Covered by TestInstallTrackedRepo_KeepsDestinationClaimedByAnotherInstaller, which creates the destination from the clone's progress callback and requires the install to fail with that checkout intact.

}()
// A tag or commit SHA has no branch for `git pull` to follow. A SHA fails
// at clone (--branch rejects it); a tag clones but leaves HEAD detached.
if err := cloneTrackedRepoForSource(source, destPath, cloneBranch, opts.OnProgress); err != nil {
if err := cloneTrackedRepoForSource(source, clonePath, cloneBranch, opts.OnProgress); err != nil {
if IsCommitSHA(cloneBranch) {
return nil, errTrackedNeedsBranch(cloneBranch)
}
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
if isDetachedHead(destPath) {
_ = src.RemoveAll(destRel)
if isDetachedHead(clonePath) {
return nil, errTrackedNeedsBranch(cloneBranch)
}
if source.Commit != "" {
if err := resetTrackedToCommit(destPath, source.Commit, source.authEnv()); err != nil {
_ = src.RemoveAll(destRel)
if err := resetTrackedToCommit(clonePath, source.Commit, source.authEnv()); err != nil {
return nil, err
}
}

if source.HasSubdir() {
if err := submoduleError(destPath, source.Subdir, source.authEnv()); err != nil {
_ = src.RemoveAll(destRel)
if err := submoduleError(clonePath, source.Subdir, source.authEnv()); err != nil {
return nil, err
}
}

// Discover skills in the cloned repo. Include root SKILL.md so the count
// matches what `skillshare sync` will see: every SKILL.md inside a tracked
// repo (root and nested alike) becomes an independent skill on sync.
skills := discoverSkills(destPath, true)
skills := discoverSkills(clonePath, true)
Comment thread
runkids marked this conversation as resolved.
result.SkillCount = len(skills)
for _, skill := range skills {
if skill.Path == "." {
skill.Name = trackedName // the root skill is named after its directory, not the staging one
}
result.Skills = append(result.Skills, skill.Name)
}
result.Warnings = append(result.Warnings, SubmoduleWarnings(destPath, source.authEnv())...)
result.Warnings = append(result.Warnings, SubmoduleWarnings(clonePath, source.authEnv())...)

// Also discover agents in the tracked repo
agents := discoverAgents(destPath, len(skills) > 0)
agents := discoverAgents(clonePath, len(skills) > 0)
result.AgentCount = len(agents)
if len(agents) > 0 {
for _, agent := range agents {
Expand All @@ -156,11 +237,33 @@ func installTrackedRepoImpl(source *Source, sourceDir string, opts InstallOption
// Only agents found — not a warning, just informational
}

// Security audit on the entire tracked repo
if err := auditTrackedRepo(destPath, result, opts); err != nil {
// Security audit on the entire tracked repo. Accepted findings are keyed by
// the final path, not the staging one.
opts.AuditAcceptRoot, opts.AuditAcceptPath = opts.auditAcceptTarget(destPath)
if err := auditTrackedRepo(clonePath, result, opts); err != nil {
return nil, err
}

if replacing {
// Keep the old repo until the new one is in place, as swapStagedIntoSource does.
backup := filepath.Join(filepath.Dir(destRel), ".skillshare-"+filepath.Base(destRel)+".old."+stamp)
if err := src.Rename(destRel, backup); err != nil {
return nil, fmt.Errorf("failed to move the existing repo aside: %w", err)
}
if err := src.Rename(cloneRel, destRel); err != nil {
if restoreErr := src.Rename(backup, destRel); restoreErr != nil {
return nil, fmt.Errorf("failed to move the new clone into place: %w; the previous repo is left at %s: %v", err, backup, restoreErr)
}
return nil, fmt.Errorf("failed to move the new clone into place: %w", err)
}
if err := src.RemoveAll(backup); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve nested file-URL sources after swapping

When --force receives a file:///.../_foo/nested or UNC source beneath the destination, the staged clone succeeds, but renaming _foo to backup moves the original source into that backup and this recursive removal then deletes it. Because cloning does not preserve uncommitted or untracked work, this still causes the data loss discussed in the previous threads; the fresh evidence is that deletion now occurs after the clone rather than before it. The new test commits its only marker and checks only the replacement checkout, so it misses the loss of the original nested worktree. Refuse this containment case or preserve the nested source before removing the backup.

AGENTS.md reference: AGENTS.md:L29-L29

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this. --force replaces the whole destination tree on purpose, so anything inside it, including a repository nested under it, is overwritten just like the destination's own uncommitted files. What the staging change guarantees is that nothing is deleted before the clone has finished, so committed content of the source always survives in the new checkout and a failed clone loses nothing. A plain local path inside the destination is still refused up front. Making an explicit file:// URL under the destination a hard error would be one more spelling-by-spelling guard, which the previous threads showed does not converge.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c1e6c21. The containment refusal now maps file:///, file://localhost and UNC file:// sources to their local path (localCloneSource) and refuses a source that is, or lies inside, the destination before anything is cloned or moved, so the backup never holds the source. TestInstallTrackedRepo_ForceNeverDestroysItsLocalSource now leaves an uncommitted wip.txt in the source and requires every case (plain path, dry run, file URL, localhost URL) to be refused with it intact; TestLocalCloneSource_UNCFileURL covers the UNC mapping.

result.Warnings = append(result.Warnings, fmt.Sprintf("failed to remove the previous repo %s: %v", backup, err))
}
} else if err := src.Rename(cloneRel, destRel); err != nil {
return nil, fmt.Errorf("failed to move the new clone into place: %w", err)
Comment on lines +262 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a no-replace move for fresh tracked installs

On Unix, if another process creates destRel as an empty directory after the initial os.Stat check but before this move, rename may replace that directory and this install reports success rather than respecting the concurrent claim; a process that is about to populate the directory can then fail or write into the unexpected checkout. Fresh evidence beyond the prior claimed-destination test is that the test makes the destination non-empty, while the empty-directory interval remains unprotected. Use a no-replace operation or an equivalent atomic destination reservation for this final move.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one. An empty directory carries no data, so replacing it loses nothing. After 890463a every tracked install, fresh or --force, clones into its own staging directory and only renames at the end, so a concurrent skillshare install never creates the destination empty and then fills it; the slower process just fails its own rename and removes its own staging copy. The non-tracked path does the same (swapStagedIntoSource in internal/install/source_write.go uses a plain MoveIn/rename), and the repository has no no-replace rename (renameat2/renamex_np) to build on; adding per-OS syscalls for an external process that creates an empty _<name> in this window is out of scope for this PR.

}
installed = true

// Auto-add to .gitignore to prevent committing tracked repo contents
gitignoreEntry := trackedName
if opts.Into != "" {
Expand Down
Loading
Loading